Add minimap and node search to node graph
This commit is contained in:
parent
a379266f99
commit
2cdde33e37
268
src/main.js
268
src/main.js
|
|
@ -6140,6 +6140,34 @@ function nodeEditor() {
|
||||||
palette.className = "node-palette";
|
palette.className = "node-palette";
|
||||||
container.appendChild(palette);
|
container.appendChild(palette);
|
||||||
|
|
||||||
|
// Create persistent search input
|
||||||
|
const paletteSearch = document.createElement("div");
|
||||||
|
paletteSearch.className = "palette-search";
|
||||||
|
paletteSearch.innerHTML = `
|
||||||
|
<input type="text" placeholder="Search nodes..." class="palette-search-input" value="">
|
||||||
|
<button class="palette-search-clear" style="display: none;">×</button>
|
||||||
|
`;
|
||||||
|
palette.appendChild(paletteSearch);
|
||||||
|
|
||||||
|
// Create content container that will be updated
|
||||||
|
const paletteContent = document.createElement("div");
|
||||||
|
paletteContent.className = "palette-content";
|
||||||
|
palette.appendChild(paletteContent);
|
||||||
|
|
||||||
|
// Get references to search elements
|
||||||
|
const searchInput = paletteSearch.querySelector(".palette-search-input");
|
||||||
|
const searchClearBtn = paletteSearch.querySelector(".palette-search-clear");
|
||||||
|
|
||||||
|
// Create minimap
|
||||||
|
const minimap = document.createElement("div");
|
||||||
|
minimap.className = "node-minimap";
|
||||||
|
minimap.style.display = 'none'; // Hidden by default
|
||||||
|
minimap.innerHTML = `
|
||||||
|
<canvas id="minimap-canvas"></canvas>
|
||||||
|
<div class="minimap-viewport"></div>
|
||||||
|
`;
|
||||||
|
container.appendChild(minimap);
|
||||||
|
|
||||||
// Category display names
|
// Category display names
|
||||||
const categoryNames = {
|
const categoryNames = {
|
||||||
[NodeCategory.INPUT]: 'Inputs',
|
[NodeCategory.INPUT]: 'Inputs',
|
||||||
|
|
@ -6149,12 +6177,31 @@ function nodeEditor() {
|
||||||
[NodeCategory.OUTPUT]: 'Outputs'
|
[NodeCategory.OUTPUT]: 'Outputs'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
let searchQuery = '';
|
||||||
|
|
||||||
|
// Handle search input changes
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
searchQuery = e.target.value;
|
||||||
|
searchClearBtn.style.display = searchQuery ? 'flex' : 'none';
|
||||||
|
updatePalette();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle search clear
|
||||||
|
searchClearBtn.addEventListener('click', () => {
|
||||||
|
searchQuery = '';
|
||||||
|
searchInput.value = '';
|
||||||
|
searchClearBtn.style.display = 'none';
|
||||||
|
searchInput.focus();
|
||||||
|
updatePalette();
|
||||||
|
});
|
||||||
|
|
||||||
// Function to update palette based on context and selected category
|
// Function to update palette based on context and selected category
|
||||||
function updatePalette() {
|
function updatePalette() {
|
||||||
const isTemplate = editingContext !== null;
|
const isTemplate = editingContext !== null;
|
||||||
|
|
||||||
if (selectedCategory === null) {
|
if (selectedCategory === null && !searchQuery) {
|
||||||
// Show categories
|
// Show categories when no search query
|
||||||
const categories = getCategories().filter(category => {
|
const categories = getCategories().filter(category => {
|
||||||
// Filter categories based on context
|
// Filter categories based on context
|
||||||
if (isTemplate) {
|
if (isTemplate) {
|
||||||
|
|
@ -6166,7 +6213,7 @@ function nodeEditor() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
palette.innerHTML = `
|
paletteContent.innerHTML = `
|
||||||
<h3>Node Categories</h3>
|
<h3>Node Categories</h3>
|
||||||
${categories.map(category => `
|
${categories.map(category => `
|
||||||
<div class="node-category-item" data-category="${category}">
|
<div class="node-category-item" data-category="${category}">
|
||||||
|
|
@ -6174,12 +6221,18 @@ function nodeEditor() {
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else if (selectedCategory === null && searchQuery) {
|
||||||
// Show nodes in selected category
|
// Show all matching nodes across all categories when searching from main panel
|
||||||
const nodesInCategory = getNodesByCategory(selectedCategory);
|
const allCategories = getCategories();
|
||||||
|
let allNodes = [];
|
||||||
|
|
||||||
|
allCategories.forEach(category => {
|
||||||
|
const nodesInCategory = getNodesByCategory(category);
|
||||||
|
allNodes = allNodes.concat(nodesInCategory);
|
||||||
|
});
|
||||||
|
|
||||||
// Filter based on context
|
// Filter based on context
|
||||||
const filteredNodes = nodesInCategory.filter(node => {
|
let filteredNodes = allNodes.filter(node => {
|
||||||
if (isTemplate) {
|
if (isTemplate) {
|
||||||
// In template: hide VoiceAllocator, AudioOutput, MidiInput
|
// In template: hide VoiceAllocator, AudioOutput, MidiInput
|
||||||
return node.type !== 'VoiceAllocator' && node.type !== 'AudioOutput' && node.type !== 'MidiInput';
|
return node.type !== 'VoiceAllocator' && node.type !== 'AudioOutput' && node.type !== 'MidiInput';
|
||||||
|
|
@ -6189,16 +6242,68 @@ function nodeEditor() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
palette.innerHTML = `
|
// Apply search filter
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filteredNodes = filteredNodes.filter(node => {
|
||||||
|
return node.name.toLowerCase().includes(query) ||
|
||||||
|
node.description.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to highlight search matches in text
|
||||||
|
const highlightMatch = (text) => {
|
||||||
|
const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||||
|
return text.replace(regex, '<mark>$1</mark>');
|
||||||
|
};
|
||||||
|
|
||||||
|
paletteContent.innerHTML = `
|
||||||
|
<h3>Search Results</h3>
|
||||||
|
${filteredNodes.length > 0 ? filteredNodes.map(node => `
|
||||||
|
<div class="node-palette-item" data-node-type="${node.type}" draggable="true" title="${node.description}">
|
||||||
|
${highlightMatch(node.name)}
|
||||||
|
</div>
|
||||||
|
`).join('') : '<div class="no-results">No matching nodes found</div>'}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Show nodes in selected category
|
||||||
|
const nodesInCategory = getNodesByCategory(selectedCategory);
|
||||||
|
|
||||||
|
// Filter based on context
|
||||||
|
let filteredNodes = nodesInCategory.filter(node => {
|
||||||
|
if (isTemplate) {
|
||||||
|
// In template: hide VoiceAllocator, AudioOutput, MidiInput
|
||||||
|
return node.type !== 'VoiceAllocator' && node.type !== 'AudioOutput' && node.type !== 'MidiInput';
|
||||||
|
} else {
|
||||||
|
// In main graph: hide TemplateInput/TemplateOutput
|
||||||
|
return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filteredNodes = filteredNodes.filter(node => {
|
||||||
|
return node.name.toLowerCase().includes(query) ||
|
||||||
|
node.description.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to highlight search matches in text
|
||||||
|
const highlightMatch = (text) => {
|
||||||
|
if (!searchQuery) return text;
|
||||||
|
const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||||
|
return text.replace(regex, '<mark>$1</mark>');
|
||||||
|
};
|
||||||
|
|
||||||
|
paletteContent.innerHTML = `
|
||||||
<div class="palette-header">
|
<div class="palette-header">
|
||||||
<button class="palette-back-btn">← Back</button>
|
<button class="palette-back-btn">← Back</button>
|
||||||
<h3>${categoryNames[selectedCategory] || selectedCategory}</h3>
|
<h3>${categoryNames[selectedCategory] || selectedCategory}</h3>
|
||||||
</div>
|
</div>
|
||||||
${filteredNodes.map(node => `
|
${filteredNodes.length > 0 ? filteredNodes.map(node => `
|
||||||
<div class="node-palette-item" data-node-type="${node.type}" draggable="true" title="${node.description}">
|
<div class="node-palette-item" data-node-type="${node.type}" draggable="true" title="${node.description}">
|
||||||
${node.name}
|
${highlightMatch(node.name)}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('') : '<div class="no-results">No matching nodes found</div>'}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6241,6 +6346,147 @@ function nodeEditor() {
|
||||||
set suppressActionRecording(value) { suppressActionRecording = value; }
|
set suppressActionRecording(value) { suppressActionRecording = value; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize minimap
|
||||||
|
const minimapCanvas = container.querySelector("#minimap-canvas");
|
||||||
|
const minimapViewport = container.querySelector(".minimap-viewport");
|
||||||
|
const minimapCtx = minimapCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Set canvas size to match container
|
||||||
|
minimapCanvas.width = 200;
|
||||||
|
minimapCanvas.height = 150;
|
||||||
|
|
||||||
|
function updateMinimap() {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const ctx = minimapCtx;
|
||||||
|
const canvas = minimapCanvas;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Get all nodes
|
||||||
|
const module = editor.module;
|
||||||
|
const nodes = editor.drawflow.drawflow[module]?.data || {};
|
||||||
|
const nodeList = Object.values(nodes);
|
||||||
|
|
||||||
|
if (nodeList.length === 0) {
|
||||||
|
minimap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounding box of all nodes
|
||||||
|
let minX = Infinity, minY = Infinity;
|
||||||
|
let maxX = -Infinity, maxY = -Infinity;
|
||||||
|
|
||||||
|
nodeList.forEach(node => {
|
||||||
|
minX = Math.min(minX, node.pos_x);
|
||||||
|
minY = Math.min(minY, node.pos_y);
|
||||||
|
maxX = Math.max(maxX, node.pos_x + 160); // Approximate node width
|
||||||
|
maxY = Math.max(maxY, node.pos_y + 100); // Approximate node height
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add padding
|
||||||
|
const padding = 20;
|
||||||
|
minX -= padding;
|
||||||
|
minY -= padding;
|
||||||
|
maxX += padding;
|
||||||
|
maxY += padding;
|
||||||
|
|
||||||
|
// Calculate graph dimensions
|
||||||
|
const graphWidth = maxX - minX;
|
||||||
|
const graphHeight = maxY - minY;
|
||||||
|
|
||||||
|
// Check if graph fits in viewport
|
||||||
|
const zoom = editor.zoom || 1;
|
||||||
|
const drawflowRect = drawflowDiv.getBoundingClientRect();
|
||||||
|
const viewportWidth = drawflowRect.width / zoom;
|
||||||
|
const viewportHeight = drawflowRect.height / zoom;
|
||||||
|
|
||||||
|
// Only show minimap if graph is larger than viewport
|
||||||
|
if (graphWidth <= viewportWidth && graphHeight <= viewportHeight) {
|
||||||
|
minimap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
minimap.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scale to fit in minimap
|
||||||
|
const scale = Math.min(canvas.width / graphWidth, canvas.height / graphHeight);
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
nodeList.forEach(node => {
|
||||||
|
const x = (node.pos_x - minX) * scale;
|
||||||
|
const y = (node.pos_y - minY) * scale;
|
||||||
|
const width = 160 * scale;
|
||||||
|
const height = 100 * scale;
|
||||||
|
|
||||||
|
ctx.fillRect(x, y, width, height);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update viewport indicator
|
||||||
|
const canvasX = editor.canvas_x || 0;
|
||||||
|
const canvasY = editor.canvas_y || 0;
|
||||||
|
|
||||||
|
const viewportX = (-canvasX / zoom - minX) * scale;
|
||||||
|
const viewportY = (-canvasY / zoom - minY) * scale;
|
||||||
|
const viewportIndicatorWidth = (drawflowRect.width / zoom) * scale;
|
||||||
|
const viewportIndicatorHeight = (drawflowRect.height / zoom) * scale;
|
||||||
|
|
||||||
|
minimapViewport.style.left = Math.max(0, viewportX) + 'px';
|
||||||
|
minimapViewport.style.top = Math.max(0, viewportY) + 'px';
|
||||||
|
minimapViewport.style.width = viewportIndicatorWidth + 'px';
|
||||||
|
minimapViewport.style.height = viewportIndicatorHeight + 'px';
|
||||||
|
|
||||||
|
// Store scale info for click navigation
|
||||||
|
minimapCanvas.dataset.scale = scale;
|
||||||
|
minimapCanvas.dataset.minX = minX;
|
||||||
|
minimapCanvas.dataset.minY = minY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update minimap on various events
|
||||||
|
editor.on('nodeCreated', () => setTimeout(updateMinimap, 100));
|
||||||
|
editor.on('nodeRemoved', () => setTimeout(updateMinimap, 100));
|
||||||
|
editor.on('nodeMoved', () => updateMinimap());
|
||||||
|
|
||||||
|
// Update minimap on pan/zoom
|
||||||
|
drawflowDiv.addEventListener('wheel', () => setTimeout(updateMinimap, 10));
|
||||||
|
|
||||||
|
// Initial minimap render
|
||||||
|
setTimeout(updateMinimap, 200);
|
||||||
|
|
||||||
|
// Click-to-navigate on minimap
|
||||||
|
minimapCanvas.addEventListener('mousedown', (e) => {
|
||||||
|
const rect = minimapCanvas.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const clickY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const scale = parseFloat(minimapCanvas.dataset.scale || 1);
|
||||||
|
const minX = parseFloat(minimapCanvas.dataset.minX || 0);
|
||||||
|
const minY = parseFloat(minimapCanvas.dataset.minY || 0);
|
||||||
|
|
||||||
|
// Convert click position to graph coordinates
|
||||||
|
const graphX = (clickX / scale) + minX;
|
||||||
|
const graphY = (clickY / scale) + minY;
|
||||||
|
|
||||||
|
// Center the viewport on the clicked position
|
||||||
|
const zoom = editor.zoom || 1;
|
||||||
|
const drawflowRect = drawflowDiv.getBoundingClientRect();
|
||||||
|
const viewportCenterX = drawflowRect.width / (2 * zoom);
|
||||||
|
const viewportCenterY = drawflowRect.height / (2 * zoom);
|
||||||
|
|
||||||
|
editor.canvas_x = -(graphX - viewportCenterX) * zoom;
|
||||||
|
editor.canvas_y = -(graphY - viewportCenterY) * zoom;
|
||||||
|
|
||||||
|
// Update the canvas transform
|
||||||
|
const precanvas = drawflowDiv.querySelector('.drawflow');
|
||||||
|
if (precanvas) {
|
||||||
|
precanvas.style.transform = `translate(${editor.canvas_x}px, ${editor.canvas_y}px) scale(${zoom})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMinimap();
|
||||||
|
});
|
||||||
|
|
||||||
// Add reconnection support: dragging from a connected input disconnects and starts new connection
|
// Add reconnection support: dragging from a connected input disconnects and starts new connection
|
||||||
drawflowDiv.addEventListener('mousedown', (e) => {
|
drawflowDiv.addEventListener('mousedown', (e) => {
|
||||||
// Check if clicking on an input port
|
// Check if clicking on an input port
|
||||||
|
|
|
||||||
|
|
@ -1348,6 +1348,101 @@ button {
|
||||||
background: #5d5d5d;
|
background: #5d5d5d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Palette search */
|
||||||
|
.palette-search {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-content {
|
||||||
|
/* Content container for dynamic palette updates */
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 28px 6px 8px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--node-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--node-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #3d3d3d;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-search-clear:hover {
|
||||||
|
background: var(--node-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 12px 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-palette-item mark {
|
||||||
|
background: var(--success-dark);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
padding: 1px 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimap */
|
||||||
|
.node-minimap {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 200px;
|
||||||
|
height: 150px;
|
||||||
|
background: rgba(40, 40, 40, 0.9);
|
||||||
|
border: 2px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: all;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
#minimap-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap-viewport {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid #4CAF50;
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* Node content styling */
|
/* Node content styling */
|
||||||
.node-content {
|
.node-content {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue