Add minimap and node search to node graph

This commit is contained in:
Skyler Lehmkuhl 2025-10-28 10:27:54 -04:00
parent a379266f99
commit 2cdde33e37
2 changed files with 352 additions and 11 deletions

View File

@ -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

View File

@ -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;