- Shift+drag on empty space draws selection rectangle to select multiple nodes - Multi-node drag: dragging a selected node moves all selected nodes together - Click edge to highlight it and its connected nodes - Delete key removes single selected node, Ctrl+A selects all - Edges now have pointer cursor for click affordance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1011 lines
38 KiB
JavaScript
1011 lines
38 KiB
JavaScript
/**
|
|
* Graph editor — visual entity graph with autolayout, pan/zoom, search.
|
|
*/
|
|
|
|
import { GraphCanvas } from '../core/graph-canvas.js';
|
|
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
|
import { renderNodes, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js';
|
|
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
|
|
import {
|
|
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
|
streamsCache, audioSourcesCache, audioTemplatesCache,
|
|
valueSourcesCache, colorStripSourcesCache, syncClocksCache,
|
|
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
|
|
automationsCacheObj,
|
|
} from '../core/state.js';
|
|
import { t } from '../core/i18n.js';
|
|
|
|
let _canvas = null;
|
|
let _nodeMap = null;
|
|
let _edges = null;
|
|
let _bounds = null;
|
|
let _selectedIds = new Set();
|
|
let _initialized = false;
|
|
let _legendVisible = false;
|
|
let _minimapVisible = true;
|
|
let _searchVisible = false;
|
|
let _searchIndex = -1;
|
|
let _searchItems = [];
|
|
let _loading = false;
|
|
|
|
// Node drag state
|
|
let _dragState = null; // { nodeId, el, startClient, startNode, dragging }
|
|
let _justDragged = false;
|
|
let _dragListenersAdded = false;
|
|
|
|
// Manual position overrides (persisted in memory; cleared on relayout)
|
|
let _manualPositions = new Map();
|
|
|
|
// Rubber-band selection state
|
|
let _rubberBand = null;
|
|
let _rubberBandListenersAdded = false;
|
|
|
|
// Minimap position/size persisted in localStorage
|
|
const _MM_KEY = 'graph_minimap';
|
|
function _loadMinimapRect() {
|
|
try { return JSON.parse(localStorage.getItem(_MM_KEY)); } catch { return null; }
|
|
}
|
|
function _saveMinimapRect(r) {
|
|
localStorage.setItem(_MM_KEY, JSON.stringify(r));
|
|
}
|
|
|
|
// Toolbar position persisted in localStorage
|
|
const _TB_KEY = 'graph_toolbar';
|
|
function _loadToolbarPos() {
|
|
try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; }
|
|
}
|
|
function _saveToolbarPos(r) {
|
|
localStorage.setItem(_TB_KEY, JSON.stringify(r));
|
|
}
|
|
|
|
/* ── Public API ── */
|
|
|
|
export async function loadGraphEditor() {
|
|
const container = document.getElementById('graph-editor-content');
|
|
if (!container) return;
|
|
if (_loading) return;
|
|
_loading = true;
|
|
|
|
// First load: replace with spinner. Re-layout: overlay spinner on top.
|
|
if (!_initialized) {
|
|
container.innerHTML = '<div class="loading-spinner"></div>';
|
|
} else {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'graph-loading-overlay';
|
|
overlay.innerHTML = '<div class="loading-spinner"></div>';
|
|
const gc = container.querySelector('.graph-container');
|
|
if (gc) gc.appendChild(overlay);
|
|
}
|
|
|
|
try {
|
|
const entities = await _fetchAllEntities();
|
|
const { nodes, edges, bounds } = await computeLayout(entities);
|
|
|
|
// Apply manual position overrides from previous drag operations
|
|
_applyManualPositions(nodes, edges);
|
|
|
|
_nodeMap = nodes;
|
|
_edges = edges;
|
|
_bounds = _calcBounds(nodes);
|
|
_renderGraph(container);
|
|
} finally {
|
|
_loading = false;
|
|
}
|
|
}
|
|
|
|
export function openGraphSearch() {
|
|
if (!_nodeMap) return;
|
|
const panel = document.querySelector('.graph-search');
|
|
if (!panel) return;
|
|
|
|
_searchItems = [];
|
|
for (const node of _nodeMap.values()) _searchItems.push(node);
|
|
|
|
_searchIndex = -1;
|
|
_searchVisible = true;
|
|
panel.classList.add('visible');
|
|
const input = panel.querySelector('.graph-search-input');
|
|
input.value = '';
|
|
input.focus();
|
|
_renderSearchResults('');
|
|
}
|
|
|
|
export function closeGraphSearch() {
|
|
_searchVisible = false;
|
|
const panel = document.querySelector('.graph-search');
|
|
if (panel) panel.classList.remove('visible');
|
|
}
|
|
|
|
export function toggleGraphLegend() {
|
|
_legendVisible = !_legendVisible;
|
|
const legend = document.querySelector('.graph-legend');
|
|
if (legend) legend.classList.toggle('visible', _legendVisible);
|
|
}
|
|
|
|
export function toggleGraphMinimap() {
|
|
_minimapVisible = !_minimapVisible;
|
|
const mm = document.querySelector('.graph-minimap');
|
|
if (mm) mm.classList.toggle('visible', _minimapVisible);
|
|
}
|
|
|
|
export function graphFitAll() {
|
|
if (_canvas && _bounds) _canvas.fitAll(_bounds);
|
|
}
|
|
|
|
export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); }
|
|
export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); }
|
|
|
|
export async function graphRelayout() {
|
|
_manualPositions.clear();
|
|
await loadGraphEditor();
|
|
}
|
|
|
|
/* ── Data fetching ── */
|
|
|
|
async function _fetchAllEntities() {
|
|
const [
|
|
devices, captureTemplates, ppTemplates, pictureSources,
|
|
audioSources, audioTemplates, valueSources, colorStripSources,
|
|
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
|
] = await Promise.all([
|
|
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
|
|
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
|
|
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
|
|
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
|
|
automationsCacheObj.fetch(),
|
|
]);
|
|
return {
|
|
devices, captureTemplates, ppTemplates, pictureSources,
|
|
audioSources, audioTemplates, valueSources, colorStripSources,
|
|
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
|
};
|
|
}
|
|
|
|
/* ── Rendering ── */
|
|
|
|
function _renderGraph(container) {
|
|
// Destroy previous canvas to clean up window event listeners
|
|
if (_canvas) { _canvas.destroy(); _canvas = null; }
|
|
|
|
container.innerHTML = _graphHTML();
|
|
|
|
const svgEl = container.querySelector('.graph-svg');
|
|
_canvas = new GraphCanvas(svgEl);
|
|
|
|
const nodeGroup = svgEl.querySelector('.graph-nodes');
|
|
const edgeGroup = svgEl.querySelector('.graph-edges');
|
|
|
|
renderEdges(edgeGroup, _edges);
|
|
renderNodes(nodeGroup, _nodeMap, {
|
|
onNodeClick: _onNodeClick,
|
|
onNodeDblClick: _onNodeDblClick,
|
|
onEditNode: _onEditNode,
|
|
onDeleteNode: _onDeleteNode,
|
|
});
|
|
markOrphans(nodeGroup, _nodeMap, _edges);
|
|
|
|
// Animated flow dots for running nodes
|
|
const runningIds = new Set();
|
|
for (const node of _nodeMap.values()) {
|
|
if (node.running) runningIds.add(node.id);
|
|
}
|
|
renderFlowDots(edgeGroup, _edges, runningIds);
|
|
|
|
// Set bounds for view clamping, then fit
|
|
if (_bounds) _canvas.setBounds(_bounds);
|
|
requestAnimationFrame(() => {
|
|
if (_canvas && _bounds) _canvas.fitAll(_bounds, false);
|
|
});
|
|
|
|
_canvas.onZoomChange = (z) => {
|
|
const label = container.querySelector('.graph-zoom-label');
|
|
if (label) label.textContent = `${Math.round(z * 100)}%`;
|
|
};
|
|
|
|
_canvas.onViewChange = (vp) => {
|
|
_updateMinimapViewport(container.querySelector('.graph-minimap'), vp);
|
|
};
|
|
|
|
_renderLegend(container.querySelector('.graph-legend'));
|
|
_initMinimap(container.querySelector('.graph-minimap'));
|
|
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
|
_initNodeDrag(nodeGroup, edgeGroup);
|
|
_initRubberBand(svgEl);
|
|
|
|
// Edge click: select edge and its endpoints
|
|
edgeGroup.addEventListener('click', (e) => {
|
|
const edgePath = e.target.closest('.graph-edge');
|
|
if (!edgePath) return;
|
|
e.stopPropagation();
|
|
_onEdgeClick(edgePath, nodeGroup, edgeGroup);
|
|
});
|
|
|
|
const searchInput = container.querySelector('.graph-search-input');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value));
|
|
searchInput.addEventListener('keydown', _onSearchKeydown);
|
|
}
|
|
|
|
// Deselect on click on empty space (not after a pan gesture)
|
|
svgEl.addEventListener('click', (e) => {
|
|
if (_canvas.wasPanning) return;
|
|
if (e.shiftKey) return; // Shift+click reserved for rubber-band
|
|
if (!e.target.closest('.graph-node')) {
|
|
_deselect(nodeGroup, edgeGroup);
|
|
}
|
|
});
|
|
|
|
// Double-click empty → fit all
|
|
svgEl.addEventListener('dblclick', (e) => {
|
|
if (!e.target.closest('.graph-node')) graphFitAll();
|
|
});
|
|
|
|
// Prevent text selection on SVG drag
|
|
svgEl.addEventListener('mousedown', (e) => {
|
|
// Prevent default only on the SVG background / edges, not on inputs
|
|
if (!e.target.closest('input, textarea, select')) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
container.addEventListener('keydown', _onKeydown);
|
|
container.setAttribute('tabindex', '0');
|
|
_initialized = true;
|
|
}
|
|
|
|
function _deselect(nodeGroup, edgeGroup) {
|
|
_selectedIds.clear();
|
|
if (nodeGroup) {
|
|
updateSelection(nodeGroup, _selectedIds);
|
|
nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
|
}
|
|
if (edgeGroup) clearEdgeHighlights(edgeGroup);
|
|
}
|
|
|
|
function _graphHTML() {
|
|
const mmRect = _loadMinimapRect();
|
|
// Default: bottom-right corner with 12px margin (computed after render via _initMinimap)
|
|
const mmStyle = mmRect ? `left:${mmRect.left}px;top:${mmRect.top}px;width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
|
const tbPos = _loadToolbarPos();
|
|
const tbStyle = tbPos ? `left:${tbPos.left}px;top:${tbPos.top}px;` : '';
|
|
|
|
return `
|
|
<div class="graph-container">
|
|
<div class="graph-toolbar" style="${tbStyle}">
|
|
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
|
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/></svg>
|
|
</button>
|
|
<button class="btn-icon" onclick="graphZoomIn()" title="${t('graph.zoom_in')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>
|
|
</button>
|
|
<span class="graph-zoom-label">100%</span>
|
|
<button class="btn-icon" onclick="graphZoomOut()" title="${t('graph.zoom_out')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/></svg>
|
|
</button>
|
|
<span class="graph-toolbar-sep"></span>
|
|
<button class="btn-icon" onclick="openGraphSearch()" title="${t('graph.search')} (/)">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
|
</button>
|
|
<button class="btn-icon" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
|
|
</button>
|
|
<button class="btn-icon" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
|
|
</button>
|
|
<span class="graph-toolbar-sep"></span>
|
|
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="graph-legend">
|
|
<div class="graph-legend-title">${t('graph.legend')}</div>
|
|
</div>
|
|
|
|
<div class="graph-minimap${_minimapVisible ? ' visible' : ''}" style="${mmStyle}">
|
|
<div class="graph-minimap-header"><span>${t('graph.minimap')}</span></div>
|
|
<div class="graph-minimap-resize graph-minimap-resize-br"></div>
|
|
<div class="graph-minimap-resize graph-minimap-resize-bl"></div>
|
|
<svg></svg>
|
|
</div>
|
|
|
|
<div class="graph-search">
|
|
<input class="graph-search-input" placeholder="${t('graph.search_placeholder')}" autocomplete="off">
|
|
<div class="graph-search-results"></div>
|
|
</div>
|
|
|
|
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
|
|
<stop offset="0%" stop-color="var(--primary-color)"/>
|
|
<stop offset="50%" stop-color="var(--success-color)"/>
|
|
<stop offset="100%" stop-color="var(--primary-color)"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect class="graph-bg" width="100%" height="100%" fill="transparent"/>
|
|
<g class="graph-root">
|
|
<g class="graph-edges"></g>
|
|
<g class="graph-nodes"></g>
|
|
<rect class="graph-selection-rect" x="0" y="0" width="0" height="0" style="display:none"/>
|
|
</g>
|
|
</svg>
|
|
|
|
</div>`;
|
|
}
|
|
|
|
/* ── Legend ── */
|
|
|
|
function _renderLegend(legendEl) {
|
|
if (!legendEl) return;
|
|
let html = `<div class="graph-legend-title">${t('graph.legend')}</div>`;
|
|
for (const [kind, color] of Object.entries(ENTITY_COLORS)) {
|
|
const label = ENTITY_LABELS[kind] || kind;
|
|
html += `<div class="graph-legend-item">
|
|
<span class="graph-legend-dot" style="background:${color}"></span>
|
|
<span>${label}</span>
|
|
</div>`;
|
|
}
|
|
legendEl.innerHTML = html;
|
|
}
|
|
|
|
/* ── Minimap (draggable header & resize handle) ── */
|
|
|
|
function _initMinimap(mmEl) {
|
|
if (!mmEl || !_nodeMap || !_bounds) return;
|
|
const svg = mmEl.querySelector('svg');
|
|
if (!svg) return;
|
|
const container = mmEl.closest('.graph-container');
|
|
|
|
const pad = 10;
|
|
const vb = `${_bounds.x - pad} ${_bounds.y - pad} ${_bounds.width + pad * 2} ${_bounds.height + pad * 2}`;
|
|
svg.setAttribute('viewBox', vb);
|
|
|
|
let html = '';
|
|
for (const node of _nodeMap.values()) {
|
|
const color = ENTITY_COLORS[node.kind] || '#666';
|
|
html += `<rect class="graph-minimap-node" data-id="${node.id}" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
|
|
}
|
|
// Add viewport rect (updated live via _updateMinimapViewport)
|
|
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
|
|
svg.innerHTML = html;
|
|
|
|
// Set default position (bottom-right corner) if no saved position
|
|
if (!mmEl.style.left) {
|
|
const cr = container.getBoundingClientRect();
|
|
mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px';
|
|
mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px';
|
|
mmEl.style.width = '200px';
|
|
mmEl.style.height = '130px';
|
|
}
|
|
|
|
// Initial viewport update
|
|
if (_canvas) {
|
|
_updateMinimapViewport(mmEl, _canvas.getViewport());
|
|
}
|
|
|
|
// Helper to clamp minimap within container
|
|
function _clampMinimap() {
|
|
const cr = container.getBoundingClientRect();
|
|
const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight;
|
|
let l = parseFloat(mmEl.style.left) || 0;
|
|
let t = parseFloat(mmEl.style.top) || 0;
|
|
l = Math.max(0, Math.min(cr.width - mw, l));
|
|
t = Math.max(0, Math.min(cr.height - mh, t));
|
|
mmEl.style.left = l + 'px';
|
|
mmEl.style.top = t + 'px';
|
|
}
|
|
|
|
// ── Click on minimap SVG → pan main canvas to that point ──
|
|
let mmDraggingViewport = false;
|
|
svg.addEventListener('pointerdown', (e) => {
|
|
if (!_canvas || !_bounds) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
mmDraggingViewport = true;
|
|
svg.setPointerCapture(e.pointerId);
|
|
_panToMinimapPoint(svg, e);
|
|
});
|
|
svg.addEventListener('pointermove', (e) => {
|
|
if (!mmDraggingViewport) return;
|
|
_panToMinimapPoint(svg, e);
|
|
});
|
|
svg.addEventListener('pointerup', () => { mmDraggingViewport = false; });
|
|
|
|
// ── Drag via header ──
|
|
const header = mmEl.querySelector('.graph-minimap-header');
|
|
let dragStart = null, dragStartPos = null;
|
|
|
|
header.addEventListener('pointerdown', (e) => {
|
|
e.preventDefault();
|
|
dragStart = { x: e.clientX, y: e.clientY };
|
|
dragStartPos = { left: mmEl.offsetLeft, top: mmEl.offsetTop };
|
|
header.classList.add('dragging');
|
|
header.setPointerCapture(e.pointerId);
|
|
});
|
|
header.addEventListener('pointermove', (e) => {
|
|
if (!dragStart) return;
|
|
const cr = container.getBoundingClientRect();
|
|
const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight;
|
|
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
|
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
|
l = Math.max(0, Math.min(cr.width - mw, l));
|
|
t = Math.max(0, Math.min(cr.height - mh, t));
|
|
mmEl.style.left = l + 'px';
|
|
mmEl.style.top = t + 'px';
|
|
});
|
|
header.addEventListener('pointerup', () => {
|
|
if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapRect(_mmRect(mmEl)); }
|
|
});
|
|
|
|
// ── Resize handles ──
|
|
_initResizeHandle(mmEl.querySelector('.graph-minimap-resize-br'), 'br');
|
|
_initResizeHandle(mmEl.querySelector('.graph-minimap-resize-bl'), 'bl');
|
|
|
|
function _initResizeHandle(rh, corner) {
|
|
if (!rh) return;
|
|
let rs = null, rss = null;
|
|
rh.addEventListener('pointerdown', (e) => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
rs = { x: e.clientX, y: e.clientY };
|
|
rss = { w: mmEl.offsetWidth, h: mmEl.offsetHeight, left: mmEl.offsetLeft };
|
|
rh.setPointerCapture(e.pointerId);
|
|
});
|
|
rh.addEventListener('pointermove', (e) => {
|
|
if (!rs) return;
|
|
const cr = container.getBoundingClientRect();
|
|
const dy = e.clientY - rs.y;
|
|
const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
|
|
mmEl.style.height = newH + 'px';
|
|
|
|
if (corner === 'br') {
|
|
// Bottom-right: grow width rightward, left stays fixed
|
|
const dx = e.clientX - rs.x;
|
|
const maxW = cr.width - mmEl.offsetLeft - 4;
|
|
mmEl.style.width = Math.max(120, Math.min(maxW, rss.w + dx)) + 'px';
|
|
} else {
|
|
// Bottom-left: grow width leftward, right edge stays fixed
|
|
const dx = rs.x - e.clientX;
|
|
const newW = Math.max(120, Math.min(cr.width - 20, rss.w + dx));
|
|
const newLeft = rss.left - (newW - rss.w);
|
|
mmEl.style.width = newW + 'px';
|
|
mmEl.style.left = Math.max(0, newLeft) + 'px';
|
|
}
|
|
_clampMinimap();
|
|
});
|
|
rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapRect(_mmRect(mmEl)); } });
|
|
}
|
|
}
|
|
|
|
function _panToMinimapPoint(svg, e) {
|
|
if (!_canvas || !_bounds) return;
|
|
const svgRect = svg.getBoundingClientRect();
|
|
const pad = 10;
|
|
const bx = _bounds.x - pad, by = _bounds.y - pad;
|
|
const bw = _bounds.width + pad * 2, bh = _bounds.height + pad * 2;
|
|
const gx = bx + ((e.clientX - svgRect.left) / svgRect.width) * bw;
|
|
const gy = by + ((e.clientY - svgRect.top) / svgRect.height) * bh;
|
|
_canvas.panTo(gx, gy, false);
|
|
}
|
|
|
|
function _updateMinimapViewport(mmEl, vp) {
|
|
if (!mmEl) return;
|
|
const rect = mmEl.querySelector('.graph-minimap-viewport');
|
|
if (!rect) return;
|
|
rect.setAttribute('x', vp.x);
|
|
rect.setAttribute('y', vp.y);
|
|
rect.setAttribute('width', vp.width);
|
|
rect.setAttribute('height', vp.height);
|
|
}
|
|
|
|
function _mmRect(mmEl) {
|
|
return { left: mmEl.offsetLeft, top: mmEl.offsetTop, width: mmEl.offsetWidth, height: mmEl.offsetHeight };
|
|
}
|
|
|
|
/* ── Toolbar drag ── */
|
|
|
|
function _initToolbarDrag(tbEl) {
|
|
if (!tbEl) return;
|
|
const container = tbEl.closest('.graph-container');
|
|
const handle = tbEl.querySelector('.graph-toolbar-drag');
|
|
if (!handle) return;
|
|
|
|
let dragStart = null, dragStartPos = null;
|
|
|
|
handle.addEventListener('pointerdown', (e) => {
|
|
e.preventDefault();
|
|
dragStart = { x: e.clientX, y: e.clientY };
|
|
dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop };
|
|
handle.setPointerCapture(e.pointerId);
|
|
});
|
|
handle.addEventListener('pointermove', (e) => {
|
|
if (!dragStart) return;
|
|
const cr = container.getBoundingClientRect();
|
|
const tw = tbEl.offsetWidth, th = tbEl.offsetHeight;
|
|
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
|
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
|
l = Math.max(0, Math.min(cr.width - tw, l));
|
|
t = Math.max(0, Math.min(cr.height - th, t));
|
|
tbEl.style.left = l + 'px';
|
|
tbEl.style.top = t + 'px';
|
|
});
|
|
handle.addEventListener('pointerup', () => {
|
|
if (dragStart) {
|
|
dragStart = null;
|
|
_saveToolbarPos({ left: tbEl.offsetLeft, top: tbEl.offsetTop });
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ── Search ── */
|
|
|
|
function _renderSearchResults(query) {
|
|
const results = document.querySelector('.graph-search-results');
|
|
if (!results) return;
|
|
|
|
const q = query.toLowerCase().trim();
|
|
const filtered = q
|
|
? _searchItems.filter(n => n.name.toLowerCase().includes(q) || n.kind.includes(q) || (n.subtype || '').includes(q))
|
|
: _searchItems.slice(0, 20);
|
|
|
|
_searchIndex = filtered.length > 0 ? 0 : -1;
|
|
|
|
results.innerHTML = filtered.map((n, i) => {
|
|
const color = ENTITY_COLORS[n.kind] || '#666';
|
|
return `<div class="graph-search-item${i === _searchIndex ? ' active' : ''}" data-id="${n.id}">
|
|
<span class="graph-search-item-dot" style="background:${color}"></span>
|
|
<span class="graph-search-item-name">${_escHtml(n.name)}</span>
|
|
<span class="graph-search-item-type">${n.kind.replace(/_/g, ' ')}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
results.querySelectorAll('.graph-search-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
_navigateToNode(item.getAttribute('data-id'));
|
|
closeGraphSearch();
|
|
});
|
|
});
|
|
}
|
|
|
|
function _onSearchKeydown(e) {
|
|
const results = document.querySelectorAll('.graph-search-item');
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); _searchIndex = Math.min(_searchIndex + 1, results.length - 1); _updateSearchActive(results); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); _searchIndex = Math.max(_searchIndex - 1, 0); _updateSearchActive(results); }
|
|
else if (e.key === 'Enter') { e.preventDefault(); if (results[_searchIndex]) { _navigateToNode(results[_searchIndex].getAttribute('data-id')); closeGraphSearch(); } }
|
|
else if (e.key === 'Escape') { closeGraphSearch(); }
|
|
}
|
|
|
|
function _updateSearchActive(items) {
|
|
items.forEach((el, i) => el.classList.toggle('active', i === _searchIndex));
|
|
if (items[_searchIndex]) items[_searchIndex].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
function _navigateToNode(nodeId) {
|
|
const node = _nodeMap?.get(nodeId);
|
|
if (!node || !_canvas) return;
|
|
_canvas.panTo(node.x + node.width / 2, node.y + node.height / 2, true);
|
|
|
|
const nodeGroup = document.querySelector('.graph-nodes');
|
|
if (nodeGroup) { highlightNode(nodeGroup, nodeId); setTimeout(() => highlightNode(nodeGroup, null), 3000); }
|
|
|
|
const edgeGroup = document.querySelector('.graph-edges');
|
|
if (edgeGroup && _edges) { highlightChain(edgeGroup, nodeId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); }
|
|
}
|
|
|
|
/* ── Node callbacks ── */
|
|
|
|
function _onNodeClick(node, e) {
|
|
if (_justDragged) return; // suppress click after node drag
|
|
|
|
const nodeGroup = document.querySelector('.graph-nodes');
|
|
const edgeGroup = document.querySelector('.graph-edges');
|
|
|
|
if (e.shiftKey) {
|
|
if (_selectedIds.has(node.id)) _selectedIds.delete(node.id);
|
|
else _selectedIds.add(node.id);
|
|
} else {
|
|
_selectedIds.clear();
|
|
_selectedIds.add(node.id);
|
|
}
|
|
if (nodeGroup) updateSelection(nodeGroup, _selectedIds);
|
|
|
|
if (_selectedIds.size === 1 && edgeGroup && _edges) {
|
|
const chain = highlightChain(edgeGroup, node.id, _edges);
|
|
if (nodeGroup) {
|
|
nodeGroup.querySelectorAll('.graph-node').forEach(n => {
|
|
n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25';
|
|
});
|
|
}
|
|
} else if (edgeGroup) {
|
|
clearEdgeHighlights(edgeGroup);
|
|
if (nodeGroup) nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
|
}
|
|
}
|
|
|
|
function _onNodeDblClick(node) {
|
|
// Zoom to node and center it in one step
|
|
if (_canvas) {
|
|
_canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2);
|
|
}
|
|
}
|
|
|
|
function _onEditNode(node) {
|
|
const fnMap = {
|
|
device: () => window.showSettings?.(node.id),
|
|
capture_template: () => window.editTemplate?.(node.id),
|
|
pp_template: () => window.editPPTemplate?.(node.id),
|
|
audio_template: () => window.editAudioTemplate?.(node.id),
|
|
pattern_template: () => window.showPatternTemplateEditor?.(node.id),
|
|
picture_source: () => window.editStream?.(node.id),
|
|
audio_source: () => window.editAudioSource?.(node.id),
|
|
value_source: () => window.editValueSource?.(node.id),
|
|
color_strip_source: () => window.showCSSEditor?.(node.id),
|
|
sync_clock: () => {},
|
|
output_target: () => window.showTargetEditor?.(node.id),
|
|
scene_preset: () => window.editScenePreset?.(node.id),
|
|
automation: () => window.openAutomationEditor?.(node.id),
|
|
};
|
|
fnMap[node.kind]?.();
|
|
}
|
|
|
|
function _onDeleteNode(node) {
|
|
const fnMap = {
|
|
device: () => window.removeDevice?.(node.id),
|
|
capture_template: () => window.deleteTemplate?.(node.id),
|
|
pp_template: () => window.deletePPTemplate?.(node.id),
|
|
audio_template: () => window.deleteAudioTemplate?.(node.id),
|
|
pattern_template: () => window.deletePatternTemplate?.(node.id),
|
|
picture_source: () => window.deleteStream?.(node.id),
|
|
audio_source: () => window.deleteAudioSource?.(node.id),
|
|
value_source: () => window.deleteValueSource?.(node.id),
|
|
color_strip_source: () => window.deleteColorStrip?.(node.id),
|
|
output_target: () => window.deleteTarget?.(node.id),
|
|
scene_preset: () => window.deleteScenePreset?.(node.id),
|
|
automation: () => window.deleteAutomation?.(node.id),
|
|
};
|
|
fnMap[node.kind]?.();
|
|
}
|
|
|
|
/* ── Keyboard ── */
|
|
|
|
function _onKeydown(e) {
|
|
if (e.key === '/' && !_searchVisible) { e.preventDefault(); openGraphSearch(); }
|
|
if (e.key === 'Escape') {
|
|
if (_searchVisible) { closeGraphSearch(); }
|
|
else {
|
|
const ng = document.querySelector('.graph-nodes');
|
|
const eg = document.querySelector('.graph-edges');
|
|
_deselect(ng, eg);
|
|
}
|
|
}
|
|
// Delete key → delete single selected node
|
|
if (e.key === 'Delete' && _selectedIds.size === 1) {
|
|
const nodeId = [..._selectedIds][0];
|
|
const node = _nodeMap.get(nodeId);
|
|
if (node) _onDeleteNode(node);
|
|
}
|
|
// Ctrl+A → select all
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
e.preventDefault();
|
|
_selectAll();
|
|
}
|
|
}
|
|
|
|
function _selectAll() {
|
|
if (!_nodeMap) return;
|
|
_selectedIds.clear();
|
|
for (const id of _nodeMap.keys()) _selectedIds.add(id);
|
|
const ng = document.querySelector('.graph-nodes');
|
|
if (ng) {
|
|
updateSelection(ng, _selectedIds);
|
|
ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
|
}
|
|
const eg = document.querySelector('.graph-edges');
|
|
if (eg) clearEdgeHighlights(eg);
|
|
}
|
|
|
|
/* ── Edge click ── */
|
|
|
|
function _onEdgeClick(edgePath, nodeGroup, edgeGroup) {
|
|
const fromId = edgePath.getAttribute('data-from');
|
|
const toId = edgePath.getAttribute('data-to');
|
|
|
|
_selectedIds.clear();
|
|
_selectedIds.add(fromId);
|
|
_selectedIds.add(toId);
|
|
|
|
if (nodeGroup) {
|
|
updateSelection(nodeGroup, _selectedIds);
|
|
nodeGroup.querySelectorAll('.graph-node').forEach(n => {
|
|
n.style.opacity = _selectedIds.has(n.getAttribute('data-id')) ? '1' : '0.25';
|
|
});
|
|
}
|
|
if (edgeGroup) {
|
|
edgeGroup.querySelectorAll('.graph-edge').forEach(p => {
|
|
const isThis = p === edgePath;
|
|
p.classList.toggle('highlighted', isThis);
|
|
p.classList.toggle('dimmed', !isThis);
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── Node dragging (supports multi-node) ── */
|
|
|
|
const DRAG_DEAD_ZONE = 4;
|
|
|
|
function _initNodeDrag(nodeGroup, edgeGroup) {
|
|
nodeGroup.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0) return;
|
|
const nodeEl = e.target.closest('.graph-node');
|
|
if (!nodeEl) return;
|
|
if (e.target.closest('.graph-node-overlay-btn')) return;
|
|
|
|
const nodeId = nodeEl.getAttribute('data-id');
|
|
const node = _nodeMap.get(nodeId);
|
|
if (!node) return;
|
|
|
|
// Multi-node drag: if dragged node is part of a multi-selection
|
|
if (_selectedIds.size > 1 && _selectedIds.has(nodeId)) {
|
|
_dragState = {
|
|
multi: true,
|
|
nodes: [..._selectedIds].map(id => ({
|
|
id,
|
|
el: nodeGroup.querySelector(`.graph-node[data-id="${id}"]`),
|
|
startX: _nodeMap.get(id)?.x || 0,
|
|
startY: _nodeMap.get(id)?.y || 0,
|
|
})).filter(n => n.el),
|
|
startClient: { x: e.clientX, y: e.clientY },
|
|
dragging: false,
|
|
};
|
|
} else {
|
|
_dragState = {
|
|
multi: false,
|
|
nodeId,
|
|
el: nodeEl,
|
|
startClient: { x: e.clientX, y: e.clientY },
|
|
startNode: { x: node.x, y: node.y },
|
|
dragging: false,
|
|
};
|
|
}
|
|
|
|
e.stopPropagation();
|
|
});
|
|
|
|
if (!_dragListenersAdded) {
|
|
window.addEventListener('pointermove', _onDragPointerMove);
|
|
window.addEventListener('pointerup', _onDragPointerUp);
|
|
_dragListenersAdded = true;
|
|
}
|
|
}
|
|
|
|
function _onDragPointerMove(e) {
|
|
if (!_dragState) return;
|
|
|
|
const dx = e.clientX - _dragState.startClient.x;
|
|
const dy = e.clientY - _dragState.startClient.y;
|
|
|
|
if (!_dragState.dragging) {
|
|
if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return;
|
|
_dragState.dragging = true;
|
|
if (_canvas) _canvas.blockPan = true;
|
|
if (_dragState.multi) {
|
|
_dragState.nodes.forEach(n => n.el?.classList.add('dragging'));
|
|
} else {
|
|
_dragState.el.classList.add('dragging');
|
|
// Clear chain highlights during single-node drag
|
|
const eg = document.querySelector('.graph-edges');
|
|
if (eg) clearEdgeHighlights(eg);
|
|
const ng = document.querySelector('.graph-nodes');
|
|
if (ng) ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
|
}
|
|
}
|
|
|
|
if (!_canvas) return;
|
|
const gdx = dx / _canvas.zoom;
|
|
const gdy = dy / _canvas.zoom;
|
|
|
|
const edgeGroup = document.querySelector('.graph-edges');
|
|
|
|
if (_dragState.multi) {
|
|
for (const item of _dragState.nodes) {
|
|
const node = _nodeMap.get(item.id);
|
|
if (!node) continue;
|
|
node.x = item.startX + gdx;
|
|
node.y = item.startY + gdy;
|
|
if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
|
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges);
|
|
_updateMinimapNode(item.id, node);
|
|
}
|
|
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges);
|
|
} else {
|
|
const node = _nodeMap.get(_dragState.nodeId);
|
|
if (!node) return;
|
|
node.x = _dragState.startNode.x + gdx;
|
|
node.y = _dragState.startNode.y + gdy;
|
|
_dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
|
if (edgeGroup) {
|
|
updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
|
updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
|
}
|
|
_updateMinimapNode(_dragState.nodeId, node);
|
|
}
|
|
}
|
|
|
|
function _onDragPointerUp() {
|
|
if (!_dragState) return;
|
|
|
|
if (_dragState.dragging) {
|
|
if (_canvas) _canvas.blockPan = false;
|
|
_justDragged = true;
|
|
requestAnimationFrame(() => { _justDragged = false; });
|
|
|
|
if (_dragState.multi) {
|
|
_dragState.nodes.forEach(n => {
|
|
if (n.el) n.el.classList.remove('dragging');
|
|
const node = _nodeMap.get(n.id);
|
|
if (node) _manualPositions.set(n.id, { x: node.x, y: node.y });
|
|
});
|
|
} else {
|
|
_dragState.el.classList.remove('dragging');
|
|
const node = _nodeMap.get(_dragState.nodeId);
|
|
if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y });
|
|
}
|
|
|
|
_bounds = _calcBounds(_nodeMap);
|
|
if (_canvas && _bounds) _canvas.setBounds(_bounds);
|
|
|
|
// Re-render flow dots (paths changed)
|
|
const edgeGroup = document.querySelector('.graph-edges');
|
|
if (edgeGroup && _edges && _nodeMap) {
|
|
const runningIds = new Set();
|
|
for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
|
|
renderFlowDots(edgeGroup, _edges, runningIds);
|
|
}
|
|
}
|
|
|
|
_dragState = null;
|
|
}
|
|
|
|
/* ── Rubber-band selection (Shift+drag on empty space) ── */
|
|
|
|
function _initRubberBand(svgEl) {
|
|
// Capture-phase: intercept Shift+click on empty space before canvas panning
|
|
svgEl.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0 || !e.shiftKey) return;
|
|
if (e.target.closest('.graph-node')) return;
|
|
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
_rubberBand = {
|
|
startGraph: _canvas.screenToGraph(e.clientX, e.clientY),
|
|
startClient: { x: e.clientX, y: e.clientY },
|
|
active: false,
|
|
};
|
|
}, true); // capture phase
|
|
|
|
if (!_rubberBandListenersAdded) {
|
|
window.addEventListener('pointermove', _onRubberBandMove);
|
|
window.addEventListener('pointerup', _onRubberBandUp);
|
|
_rubberBandListenersAdded = true;
|
|
}
|
|
}
|
|
|
|
function _onRubberBandMove(e) {
|
|
if (!_rubberBand || !_canvas) return;
|
|
|
|
if (!_rubberBand.active) {
|
|
const dx = e.clientX - _rubberBand.startClient.x;
|
|
const dy = e.clientY - _rubberBand.startClient.y;
|
|
if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return;
|
|
_rubberBand.active = true;
|
|
}
|
|
|
|
const gp = _canvas.screenToGraph(e.clientX, e.clientY);
|
|
const s = _rubberBand.startGraph;
|
|
const x = Math.min(s.x, gp.x), y = Math.min(s.y, gp.y);
|
|
const w = Math.abs(gp.x - s.x), h = Math.abs(gp.y - s.y);
|
|
|
|
const rect = document.querySelector('.graph-selection-rect');
|
|
if (rect) {
|
|
rect.setAttribute('x', x);
|
|
rect.setAttribute('y', y);
|
|
rect.setAttribute('width', w);
|
|
rect.setAttribute('height', h);
|
|
rect.style.display = '';
|
|
}
|
|
}
|
|
|
|
function _onRubberBandUp() {
|
|
if (!_rubberBand) return;
|
|
|
|
const rect = document.querySelector('.graph-selection-rect');
|
|
|
|
if (_rubberBand.active && rect && _nodeMap) {
|
|
const rx = parseFloat(rect.getAttribute('x'));
|
|
const ry = parseFloat(rect.getAttribute('y'));
|
|
const rw = parseFloat(rect.getAttribute('width'));
|
|
const rh = parseFloat(rect.getAttribute('height'));
|
|
|
|
_selectedIds.clear();
|
|
for (const node of _nodeMap.values()) {
|
|
if (node.x + node.width > rx && node.x < rx + rw &&
|
|
node.y + node.height > ry && node.y < ry + rh) {
|
|
_selectedIds.add(node.id);
|
|
}
|
|
}
|
|
|
|
const ng = document.querySelector('.graph-nodes');
|
|
const eg = document.querySelector('.graph-edges');
|
|
if (ng) {
|
|
updateSelection(ng, _selectedIds);
|
|
ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
|
}
|
|
if (eg) clearEdgeHighlights(eg);
|
|
}
|
|
|
|
if (rect) {
|
|
rect.style.display = 'none';
|
|
rect.setAttribute('width', '0');
|
|
rect.setAttribute('height', '0');
|
|
}
|
|
_rubberBand = null;
|
|
}
|
|
|
|
function _updateMinimapNode(nodeId, node) {
|
|
const mm = document.querySelector('.graph-minimap');
|
|
if (!mm) return;
|
|
const mmNode = mm.querySelector(`rect.graph-minimap-node[data-id="${nodeId}"]`);
|
|
if (mmNode) {
|
|
mmNode.setAttribute('x', node.x);
|
|
mmNode.setAttribute('y', node.y);
|
|
}
|
|
}
|
|
|
|
/* ── Manual position helpers ── */
|
|
|
|
function _applyManualPositions(nodeMap, edges) {
|
|
if (_manualPositions.size === 0) return;
|
|
for (const [id, pos] of _manualPositions) {
|
|
const node = nodeMap.get(id);
|
|
if (node) {
|
|
node.x = pos.x;
|
|
node.y = pos.y;
|
|
}
|
|
}
|
|
// Invalidate ELK edge routing for edges connected to moved nodes
|
|
for (const edge of edges) {
|
|
if (_manualPositions.has(edge.from) || _manualPositions.has(edge.to)) {
|
|
edge.points = null; // forces default bezier
|
|
}
|
|
}
|
|
}
|
|
|
|
function _calcBounds(nodeMap) {
|
|
if (!nodeMap || nodeMap.size === 0) return { x: 0, y: 0, width: 400, height: 300 };
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const n of nodeMap.values()) {
|
|
minX = Math.min(minX, n.x);
|
|
minY = Math.min(minY, n.y);
|
|
maxX = Math.max(maxX, n.x + n.width);
|
|
maxY = Math.max(maxY, n.y + n.height);
|
|
}
|
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
}
|
|
|
|
/* ── Helpers ── */
|
|
|
|
function _escHtml(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
|
|
document.addEventListener('languageChanged', () => {
|
|
if (_initialized && _nodeMap) {
|
|
const container = document.getElementById('graph-editor-content');
|
|
if (container) _renderGraph(container);
|
|
}
|
|
});
|