/** * Graph editor — visual entity graph with autolayout, pan/zoom, search. */ import { GraphCanvas } from '../core/graph-canvas.js'; import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } 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, csptCache, } from '../core/state.js'; import { fetchWithAuth } from '../core/api.js'; import { showToast, showConfirm } from '../core/ui.js'; import { t } from '../core/i18n.js'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js'; import { showTypePicker } from '../core/icon-select.js'; import * as P from '../core/icon-paths.js'; let _canvas = null; let _nodeMap = null; let _edges = null; let _bounds = null; let _selectedIds = new Set(); let _initialized = false; let _legendVisible = (() => { try { return localStorage.getItem('graph_legend_visible') === '1'; } catch { return false; } })(); let _minimapVisible = true; let _loading = false; let _filterVisible = false; let _filterQuery = ''; // current active filter text let _filterKinds = new Set(); // empty = all kinds shown let _filterRunning = null; // null = all, true = running only, false = stopped only // 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; // Port-drag connection state let _connectState = null; // { sourceNodeId, sourceKind, portType, startPos, dragPath } let _connectListenersAdded = false; // Edge context menu let _edgeContextMenu = null; // Selected edge for Delete key detach let _selectedEdge = null; // { from, to, field, targetKind } // Minimap position/size persisted in localStorage (with anchor corner) 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)); } /** * Anchor-based positioning: detect closest corner, store offset from that corner, * and reposition from that corner on resize. Works for minimap, toolbar, etc. */ function _anchorCorner(el, container) { const cr = container.getBoundingClientRect(); const cx = el.offsetLeft + el.offsetWidth / 2; const cy = el.offsetTop + el.offsetHeight / 2; return (cy > cr.height / 2 ? 'b' : 't') + (cx > cr.width / 2 ? 'r' : 'l'); } function _saveAnchored(el, container, saveFn) { const cr = container.getBoundingClientRect(); const anchor = _anchorCorner(el, container); const data = { width: el.offsetWidth, height: el.offsetHeight, anchor, offsetX: anchor.includes('r') ? cr.width - el.offsetLeft - el.offsetWidth : el.offsetLeft, offsetY: anchor.includes('b') ? cr.height - el.offsetTop - el.offsetHeight : el.offsetTop, }; saveFn(data); return data; } function _applyAnchor(el, container, saved) { if (!saved?.anchor) return; const cr = container.getBoundingClientRect(); const w = saved.width || el.offsetWidth; const h = saved.height || el.offsetHeight; const ox = Math.max(0, saved.offsetX || 0); const oy = Math.max(0, saved.offsetY || 0); let l = saved.anchor.includes('r') ? cr.width - w - ox : ox; let t = saved.anchor.includes('b') ? cr.height - h - oy : oy; l = Math.max(0, Math.min(cr.width - el.offsetWidth, l)); t = Math.max(0, Math.min(cr.height - el.offsetHeight, t)); el.style.left = l + 'px'; el.style.top = t + 'px'; } /** True when the graph container is in fullscreen — suppress anchor persistence. */ function _isFullscreen() { return !!document.fullscreenElement; } // Toolbar position persisted in localStorage const _TB_KEY = 'graph_toolbar'; const _TB_MARGIN = 12; // 8 dock positions: tl, tc, tr, cl, cr, bl, bc, br function _computeDockPositions(container, el) { const cr = container.getBoundingClientRect(); const w = el.offsetWidth, h = el.offsetHeight; const m = _TB_MARGIN; return { tl: { x: m, y: m }, tc: { x: (cr.width - w) / 2, y: m }, tr: { x: cr.width - w - m, y: m }, cl: { x: m, y: (cr.height - h) / 2 }, cr: { x: cr.width - w - m, y: (cr.height - h) / 2 }, bl: { x: m, y: cr.height - h - m }, bc: { x: (cr.width - w) / 2, y: cr.height - h - m }, br: { x: cr.width - w - m, y: cr.height - h - m }, }; } function _nearestDock(container, el) { const docks = _computeDockPositions(container, el); const cx = el.offsetLeft + el.offsetWidth / 2; const cy = el.offsetTop + el.offsetHeight / 2; let best = 'tl', bestDist = Infinity; for (const [key, pos] of Object.entries(docks)) { const dx = (pos.x + el.offsetWidth / 2) - cx; const dy = (pos.y + el.offsetHeight / 2) - cy; const dist = dx * dx + dy * dy; if (dist < bestDist) { bestDist = dist; best = key; } } return best; } function _isVerticalDock(dock) { return dock === 'cl' || dock === 'cr'; } function _applyToolbarDock(el, container, dock, animate = false) { const isVert = _isVerticalDock(dock); el.classList.toggle('vertical', isVert); // Recompute positions after layout change requestAnimationFrame(() => { const docks = _computeDockPositions(container, el); const pos = docks[dock]; if (!pos) return; if (animate) { el.style.transition = 'left 0.25s ease, top 0.25s ease'; el.style.left = pos.x + 'px'; el.style.top = pos.y + 'px'; setTimeout(() => { el.style.transition = ''; }, 260); } else { el.style.left = pos.x + 'px'; el.style.top = pos.y + 'px'; } }); } function _loadToolbarPos() { try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; } } function _saveToolbarPos(r) { localStorage.setItem(_TB_KEY, JSON.stringify(r)); } // Legend position persisted in localStorage const _LG_KEY = 'graph_legend'; function _loadLegendPos() { try { return JSON.parse(localStorage.getItem(_LG_KEY)); } catch { return null; } } function _saveLegendPos(r) { localStorage.setItem(_LG_KEY, JSON.stringify(r)); } /** * Generic draggable panel setup. * @param {HTMLElement} el - The panel element * @param {HTMLElement} handle - The drag handle element * @param {object} opts - { loadFn, saveFn } */ function _makeDraggable(el, handle, { loadFn, saveFn }) { if (!el || !handle) return; const container = el.closest('.graph-container'); if (!container) return; // Apply saved anchor position or clamp const saved = loadFn(); if (saved?.anchor) { _applyAnchor(el, container, saved); } else { _clampElementInContainer(el, container); } let dragStart = null, dragStartPos = null; handle.addEventListener('pointerdown', (e) => { e.preventDefault(); dragStart = { x: e.clientX, y: e.clientY }; dragStartPos = { left: el.offsetLeft, top: el.offsetTop }; handle.classList.add('dragging'); handle.setPointerCapture(e.pointerId); }); handle.addEventListener('pointermove', (e) => { if (!dragStart) return; const cr = container.getBoundingClientRect(); const ew = el.offsetWidth, eh = el.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 - ew, l)); t = Math.max(0, Math.min(cr.height - eh, t)); el.style.left = l + 'px'; el.style.top = t + 'px'; }); handle.addEventListener('pointerup', () => { if (dragStart) { dragStart = null; handle.classList.remove('dragging'); if (!_isFullscreen()) _saveAnchored(el, container, saveFn); } }); } /* ── 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 = '
'; } else { const overlay = document.createElement('div'); overlay.className = 'graph-loading-overlay'; overlay.innerHTML = '
'; 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); computePorts(nodes, edges); _nodeMap = nodes; _edges = edges; _bounds = _calcBounds(nodes); _renderGraph(container); } finally { _loading = false; } // Ensure keyboard focus whenever the graph is (re-)loaded container.focus(); } export function toggleGraphLegend() { _legendVisible = !_legendVisible; try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } catch {} const legend = document.querySelector('.graph-legend'); if (!legend) return; legend.classList.toggle('visible', _legendVisible); const legendBtn = document.getElementById('graph-legend-toggle'); if (legendBtn) legendBtn.classList.toggle('active', _legendVisible); if (_legendVisible) { const container = legend.closest('.graph-container'); if (container) { const saved = _loadLegendPos(); if (saved?.anchor) { _applyAnchor(legend, container, saved); } else if (!legend.style.left) { // Default to top-right const cr = container.getBoundingClientRect(); legend.style.left = (cr.width - legend.offsetWidth - 12) + 'px'; legend.style.top = '12px'; } } } } export function toggleGraphMinimap() { _minimapVisible = !_minimapVisible; const mm = document.querySelector('.graph-minimap'); if (mm) mm.classList.toggle('visible', _minimapVisible); const mmBtn = document.getElementById('graph-minimap-toggle'); if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible); } /* ── Filter type groups ── */ const _FILTER_GROUPS = [ { key: 'capture', kinds: ['picture_source', 'capture_template', 'pp_template'] }, { key: 'strip', kinds: ['color_strip_source', 'cspt'] }, { key: 'audio', kinds: ['audio_source', 'audio_template'] }, { key: 'targets', kinds: ['device', 'output_target', 'pattern_template'] }, { key: 'other', kinds: ['value_source', 'sync_clock', 'automation', 'scene_preset'] }, ]; function _buildFilterGroupsHTML() { const groupLabels = { capture: t('graph.filter_group.capture') || 'Capture', strip: t('graph.filter_group.strip') || 'Color Strip', audio: t('graph.filter_group.audio') || 'Audio', targets: t('graph.filter_group.targets') || 'Targets', other: t('graph.filter_group.other') || 'Other', }; return _FILTER_GROUPS.map(g => { const items = g.kinds.map(kind => { const label = ENTITY_LABELS[kind] || kind; const color = ENTITY_COLORS[kind] || '#666'; return ``; }).join(''); return `
${groupLabels[g.key]}
${items}
`; }).join(''); } function _updateFilterBadge() { const badge = document.querySelector('.graph-filter-types-badge'); if (!badge) return; const count = _filterKinds.size; badge.textContent = count > 0 ? String(count) : ''; badge.classList.toggle('visible', count > 0); // Also update toolbar button const btn = document.querySelector('.graph-filter-btn'); if (btn) btn.classList.toggle('active', count > 0 || _filterRunning !== null || !!_filterQuery); } function _syncPopoverCheckboxes() { const popover = document.querySelector('.graph-filter-types-popover'); if (!popover) return; popover.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.checked = _filterKinds.has(cb.value); }); } export function toggleGraphFilterTypes(btn) { const popover = document.querySelector('.graph-filter-types-popover'); if (!popover) return; const isOpen = popover.classList.contains('visible'); if (isOpen) { popover.classList.remove('visible'); } else { _syncPopoverCheckboxes(); popover.classList.add('visible'); } } export function toggleGraphFilter() { _filterVisible = !_filterVisible; const bar = document.querySelector('.graph-filter'); if (!bar) return; bar.classList.toggle('visible', _filterVisible); if (_filterVisible) { const input = bar.querySelector('.graph-filter-input'); if (input) { input.value = _filterQuery; input.focus(); } // Restore running pill states bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); _syncPopoverCheckboxes(); _updateFilterBadge(); } else { _filterKinds.clear(); _filterRunning = null; // Close types popover const popover = bar.querySelector('.graph-filter-types-popover'); if (popover) popover.classList.remove('visible'); _applyFilter(''); _updateFilterBadge(); } } function _applyFilter(query) { if (query !== undefined) _filterQuery = query; const q = _filterQuery.toLowerCase().trim(); const nodeGroup = document.querySelector('.graph-nodes'); const edgeGroup = document.querySelector('.graph-edges'); const mm = document.querySelector('.graph-minimap'); if (!_nodeMap) return; // Parse structured filters: type:device, tag:foo, running:true let textPart = q; const parsedKinds = new Set(); const parsedTags = []; const tokens = q.split(/\s+/); const plainTokens = []; for (const tok of tokens) { if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); } else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); } else { plainTokens.push(tok); } } textPart = plainTokens.join(' '); const hasTextFilter = !!textPart; const hasParsedKinds = parsedKinds.size > 0; const hasParsedTags = parsedTags.length > 0; const hasKindFilter = _filterKinds.size > 0; const hasRunningFilter = _filterRunning !== null; const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter || hasParsedKinds || hasParsedTags; // Build set of matching node IDs const matchIds = new Set(); for (const node of _nodeMap.values()) { const textMatch = !hasTextFilter || node.name.toLowerCase().includes(textPart) || node.kind.includes(textPart) || (node.subtype || '').toLowerCase().includes(textPart); const kindMatch = !hasKindFilter || _filterKinds.has(node.kind); const parsedKindMatch = !hasParsedKinds || parsedKinds.has(node.kind) || parsedKinds.has((node.subtype || '')); const tagMatch = !hasParsedTags || parsedTags.every(t => (node.tags || []).some(nt => nt.toLowerCase().includes(t))); const runMatch = !hasRunningFilter || (node.running === _filterRunning); if (textMatch && kindMatch && parsedKindMatch && tagMatch && runMatch) matchIds.add(node.id); } // Apply filtered-out class to nodes if (nodeGroup) { nodeGroup.querySelectorAll('.graph-node').forEach(el => { el.classList.toggle('graph-filtered-out', hasAny && !matchIds.has(el.getAttribute('data-id'))); }); } // Dim edges where either endpoint is filtered out if (edgeGroup) { edgeGroup.querySelectorAll('.graph-edge').forEach(el => { const from = el.getAttribute('data-from'); const to = el.getAttribute('data-to'); el.classList.toggle('graph-filtered-out', hasAny && (!matchIds.has(from) || !matchIds.has(to))); }); } // Dim minimap nodes if (mm) { mm.querySelectorAll('.graph-minimap-node').forEach(el => { const id = el.getAttribute('data-id'); el.setAttribute('opacity', (!hasAny || matchIds.has(id)) ? '0.7' : '0.07'); }); } // Update filter button active state const btn = document.querySelector('.graph-filter-btn'); if (btn) btn.classList.toggle('active', hasAny); } export function graphFitAll() { if (_canvas && _bounds) _canvas.fitAll(_bounds); } export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); } export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); } export function graphToggleFullscreen() { const container = document.querySelector('#graph-editor-content .graph-container'); if (!container) return; if (document.fullscreenElement) { document.exitFullscreen(); } else { // Move bg-anim canvas into container so it's visible in fullscreen const bgCanvas = document.getElementById('bg-anim-canvas'); if (bgCanvas && !container.contains(bgCanvas)) { container.insertBefore(bgCanvas, container.firstChild); } container.requestFullscreen().catch(() => {}); } } // Restore bg-anim canvas to body when exiting fullscreen document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement) { const bgCanvas = document.getElementById('bg-anim-canvas'); if (bgCanvas && bgCanvas.parentElement !== document.body) { document.body.insertBefore(bgCanvas, document.body.firstChild); } } }); export async function graphRelayout() { if (_manualPositions.size > 0) { const ok = await showConfirm(t('graph.relayout_confirm')); if (!ok) return; } _manualPositions.clear(); await loadGraphEditor(); } // Entity kind → window function to open add/create modal + icon path const _ico = (d) => `${d}`; const ADD_ENTITY_MAP = [ { kind: 'device', fn: () => window.showAddDevice?.(), icon: _ico(P.monitor) }, { kind: 'capture_template', fn: () => window.showAddTemplateModal?.(), icon: _ico(P.camera) }, { kind: 'pp_template', fn: () => window.showAddPPTemplateModal?.(), icon: _ico(P.wrench) }, { kind: 'cspt', fn: () => window.showAddCSPTModal?.(), icon: _ico(P.wrench) }, { kind: 'audio_template', fn: () => window.showAddAudioTemplateModal?.(),icon: _ico(P.music) }, { kind: 'picture_source', fn: () => window.showAddStreamModal?.(), icon: _ico(P.tv) }, { kind: 'audio_source', fn: () => window.showAudioSourceModal?.(), icon: _ico(P.music) }, { kind: 'value_source', fn: () => window.showValueSourceModal?.(), icon: _ico(P.hash) }, { kind: 'color_strip_source', fn: () => window.showCSSEditor?.(), icon: _ico(P.film) }, { kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) }, { kind: 'automation', fn: () => window.openAutomationEditor?.(), icon: _ico(P.clipboardList) }, { kind: 'sync_clock', fn: () => window.showSyncClockModal?.(), icon: _ico(P.clock) }, { kind: 'scene_preset', fn: () => window.editScenePreset?.(), icon: _ico(P.sparkles) }, { kind: 'pattern_template', fn: () => window.showPatternTemplateEditor?.(),icon: _ico(P.fileText) }, ]; // All caches to watch for new entity creation const ALL_CACHES = [ devicesCache, captureTemplatesCache, ppTemplatesCache, streamsCache, audioSourcesCache, audioTemplatesCache, valueSourcesCache, colorStripSourcesCache, syncClocksCache, outputTargetsCache, patternTemplatesCache, scenePresetsCache, automationsCacheObj, csptCache, ]; export function graphAddEntity() { const items = ADD_ENTITY_MAP.map(item => ({ value: item.kind, icon: item.icon, label: ENTITY_LABELS[item.kind] || item.kind.replace(/_/g, ' '), })); showTypePicker({ title: t('graph.add_entity') || 'Add Entity', items, onPick: (kind) => { const entry = ADD_ENTITY_MAP.find(e => e.kind === kind); if (entry) { _watchForNewEntity(); entry.fn(); } }, }); } // Watch for new entity creation after add-entity menu action let _entityWatchCleanup = null; function _watchForNewEntity() { // Cleanup any previous watcher if (_entityWatchCleanup) _entityWatchCleanup(); // Snapshot all current IDs const knownIds = new Set(); for (const cache of ALL_CACHES) { for (const item of (cache.data || [])) { if (item.id) knownIds.add(item.id); } } const handler = (data) => { if (!Array.isArray(data)) return; for (const item of data) { if (item.id && !knownIds.has(item.id)) { // Found a new entity — reload graph and zoom to it const newId = item.id; cleanup(); loadGraphEditor().then(() => { const node = _nodeMap?.get(newId); if (node && _canvas) { // Animate zoom + pan together in one transition _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2); } // Highlight the node and its chain (without re-panning) const nodeGroup = document.querySelector('.graph-nodes'); if (nodeGroup) { highlightNode(nodeGroup, newId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } const edgeGroup = document.querySelector('.graph-edges'); if (edgeGroup && _edges) { highlightChain(edgeGroup, newId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); } }); return; } } }; for (const cache of ALL_CACHES) cache.subscribe(handler); // Auto-cleanup after 2 minutes (user might cancel the modal) const timeout = setTimeout(cleanup, 120_000); function cleanup() { clearTimeout(timeout); for (const cache of ALL_CACHES) cache.unsubscribe(handler); _entityWatchCleanup = null; } _entityWatchCleanup = cleanup; } /* ── Data fetching ── */ async function _fetchAllEntities() { const [ devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, syncClocks, outputTargets, patternTemplates, scenePresets, automations, csptTemplates, batchStatesResp, ] = 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(), csptCache.fetch(), fetchWithAuth('/output-targets/batch/states').catch(() => null), ]); // Enrich output targets with running state from batch states let batchStates = {}; if (batchStatesResp && batchStatesResp.ok) { const data = await batchStatesResp.json().catch(() => ({})); batchStates = data.states || {}; } const enrichedTargets = (outputTargets || []).map(t => ({ ...t, running: batchStates[t.id]?.processing || false, })); return { devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations, csptTemplates, }; } /* ── 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, onStartStopNode: _onStartStopNode, onTestNode: _onTestNode, onNotificationTest: _onNotificationTest, onCloneNode: _onCloneNode, onActivatePreset: _onActivatePreset, }); 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); }; const legendEl = container.querySelector('.graph-legend'); _renderLegend(legendEl); _initLegendDrag(legendEl); _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); _initResizeClamp(container); _initNodeDrag(nodeGroup, edgeGroup); _initPortDrag(svgEl, 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); }); // Edge right-click: detach connection edgeGroup.addEventListener('contextmenu', (e) => { const edgePath = e.target.closest('.graph-edge'); if (!edgePath) return; e.preventDefault(); e.stopPropagation(); _onEdgeContextMenu(edgePath, e, container); }); const filterInput = container.querySelector('.graph-filter-input'); if (filterInput) { filterInput.addEventListener('input', (e) => _applyFilter(e.target.value)); filterInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') toggleGraphFilter(); }); } const filterClear = container.querySelector('.graph-filter-clear'); if (filterClear) { filterClear.addEventListener('click', () => { if (filterInput) filterInput.value = ''; _filterKinds.clear(); _filterRunning = null; container.querySelectorAll('.graph-filter-pill').forEach(p => p.classList.remove('active')); _applyFilter(''); }); } // Entity type checkboxes in popover container.querySelectorAll('.graph-filter-type-item input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', () => { if (cb.checked) _filterKinds.add(cb.value); else _filterKinds.delete(cb.value); _updateFilterBadge(); _applyFilter(); }); }); // Group header toggles (click group label → toggle all in group) container.querySelectorAll('[data-group-toggle]').forEach(header => { header.addEventListener('click', () => { const groupKey = header.dataset.groupToggle; const group = _FILTER_GROUPS.find(g => g.key === groupKey); if (!group) return; const allActive = group.kinds.every(k => _filterKinds.has(k)); group.kinds.forEach(k => { if (allActive) _filterKinds.delete(k); else _filterKinds.add(k); }); _syncPopoverCheckboxes(); _updateFilterBadge(); _applyFilter(); }); }); // Close popover when clicking outside container.addEventListener('click', (e) => { const popover = container.querySelector('.graph-filter-types-popover'); if (!popover || !popover.classList.contains('visible')) return; if (!e.target.closest('.graph-filter-types-popover') && !e.target.closest('.graph-filter-types-btn')) { popover.classList.remove('visible'); } }); // Running/stopped pills container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => { pill.addEventListener('click', () => { const val = pill.dataset.running === 'true'; if (_filterRunning === val) { _filterRunning = null; pill.classList.remove('active'); } else { _filterRunning = val; container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active')); pill.classList.add('active'); } _updateFilterBadge(); _applyFilter(); }); }); // Restore active filter if re-rendering if ((_filterQuery || _filterKinds.size || _filterRunning !== null) && _filterVisible) { const bar = container.querySelector('.graph-filter'); if (bar) { bar.classList.add('visible'); _syncPopoverCheckboxes(); bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); } _updateFilterBadge(); _applyFilter(_filterQuery); } // Deselect on click on empty space (not after a pan gesture) svgEl.addEventListener('click', (e) => { _dismissEdgeContextMenu(); 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'); container.style.outline = 'none'; // Focus the container so keyboard shortcuts work immediately container.focus(); // Re-focus when clicking inside the graph svgEl.addEventListener('pointerdown', () => container.focus()); _initialized = true; } function _deselect(nodeGroup, edgeGroup) { _selectedIds.clear(); _selectedEdge = null; if (nodeGroup) { updateSelection(nodeGroup, _selectedIds); nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); } if (edgeGroup) clearEdgeHighlights(edgeGroup); } function _graphHTML() { const mmRect = _loadMinimapRect(); // Only set size from saved state; position is applied in _initMinimap via anchor logic const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : ''; return `
100%
${t('graph.legend')}
${t('graph.minimap')}
${_buildFilterGroupsHTML()}
`; } /* ── Legend ── */ function _renderLegend(legendEl) { if (!legendEl) return; const body = legendEl.querySelector('.graph-legend-body'); if (!body) return; let html = ''; for (const [kind, color] of Object.entries(ENTITY_COLORS)) { const label = ENTITY_LABELS[kind] || kind; html += `
${label}
`; } body.innerHTML = html; } function _initLegendDrag(legendEl) { if (!legendEl) return; const handle = legendEl.querySelector('.graph-legend-header'); _makeDraggable(legendEl, handle, { loadFn: _loadLegendPos, saveFn: _saveLegendPos }); } /* ── 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 = getNodeDisplayColor(node.id, node.kind); html += ``; } // Add viewport rect (updated live via _updateMinimapViewport) html += ``; svg.innerHTML = html; // Apply saved anchored position or default to bottom-right const saved = _loadMinimapRect(); if (saved?.anchor) { if (saved.width) mmEl.style.width = saved.width + 'px'; if (saved.height) mmEl.style.height = saved.height + 'px'; _applyAnchor(mmEl, container, saved); } else if (!mmEl.style.left || mmEl.style.left === '0px') { const cr = container.getBoundingClientRect(); mmEl.style.width = '200px'; mmEl.style.height = '130px'; mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px'; mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px'; } // Initial viewport update if (_canvas) { _updateMinimapViewport(mmEl, _canvas.getViewport()); } // Helper to clamp minimap within container function _clampMinimap() { _clampElementInContainer(mmEl, container); } // ── 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 (uses shared _makeDraggable) ── const header = mmEl.querySelector('.graph-minimap-header'); _makeDraggable(mmEl, header, { loadFn: () => null, saveFn: _saveMinimapRect }); // ── 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; if (!_isFullscreen()) _saveAnchored(mmEl, container, _saveMinimapRect); } }); } } 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 }; } /* ── Shared element clamping ── */ /** Clamp an absolutely-positioned element within its container. */ function _clampElementInContainer(el, container) { if (!el || !container) return; const cr = container.getBoundingClientRect(); const ew = el.offsetWidth, eh = el.offsetHeight; if (!ew || !eh) return; let l = el.offsetLeft, t = el.offsetTop; const cl = Math.max(0, Math.min(cr.width - ew, l)); const ct = Math.max(0, Math.min(cr.height - eh, t)); if (cl !== l || ct !== t) { el.style.left = cl + 'px'; el.style.top = ct + 'px'; } return { left: cl, top: ct }; } let _resizeObserver = null; function _reanchorPanel(el, container, loadFn) { if (!el) return; if (_isFullscreen()) { _clampElementInContainer(el, container); } else { const saved = loadFn(); if (saved?.anchor) { _applyAnchor(el, container, saved); } else { _clampElementInContainer(el, container); } } } function _initResizeClamp(container) { if (_resizeObserver) _resizeObserver.disconnect(); _resizeObserver = new ResizeObserver(() => { _reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect); _reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos); _reanchorPanel(container.querySelector('.graph-help-panel.visible'), container, _loadHelpPos); // Toolbar uses dock system, not anchor system const tb = container.querySelector('.graph-toolbar'); if (tb) { const saved = _loadToolbarPos(); const dock = saved?.dock || 'tl'; _applyToolbarDock(tb, container, dock, false); } }); _resizeObserver.observe(container); } /* ── Toolbar drag ── */ let _dockIndicators = null; function _showDockIndicators(container) { _hideDockIndicators(); const cr = container.getBoundingClientRect(); const m = _TB_MARGIN + 16; // offset from edges // 8 dock positions as percentage-based fixed points const positions = { tl: { x: m, y: m }, tc: { x: cr.width / 2, y: m }, tr: { x: cr.width - m, y: m }, cl: { x: m, y: cr.height / 2 }, cr: { x: cr.width - m, y: cr.height / 2 }, bl: { x: m, y: cr.height - m }, bc: { x: cr.width / 2, y: cr.height - m }, br: { x: cr.width - m, y: cr.height - m }, }; const wrap = document.createElement('div'); wrap.className = 'graph-dock-indicators'; for (const [key, pos] of Object.entries(positions)) { const dot = document.createElement('div'); dot.className = 'graph-dock-dot'; dot.dataset.dock = key; dot.style.left = pos.x + 'px'; dot.style.top = pos.y + 'px'; wrap.appendChild(dot); } container.appendChild(wrap); _dockIndicators = wrap; } function _updateDockHighlight(container, tbEl) { if (!_dockIndicators) return; const nearest = _nearestDock(container, tbEl); _dockIndicators.querySelectorAll('.graph-dock-dot').forEach(d => { d.classList.toggle('nearest', d.dataset.dock === nearest); }); } function _hideDockIndicators() { if (_dockIndicators) { _dockIndicators.remove(); _dockIndicators = null; } } function _initToolbarDrag(tbEl) { if (!tbEl) return; const container = tbEl.closest('.graph-container'); if (!container) return; const handle = tbEl.querySelector('.graph-toolbar-drag'); if (!handle) return; // Restore saved dock position const saved = _loadToolbarPos(); const dock = saved?.dock || 'tl'; _applyToolbarDock(tbEl, container, dock, false); let dragStart = null, dragStartPos = null; handle.addEventListener('pointerdown', (e) => { e.preventDefault(); // If vertical, temporarily switch to horizontal for free dragging tbEl.classList.remove('vertical'); requestAnimationFrame(() => { dragStart = { x: e.clientX, y: e.clientY }; dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop }; handle.classList.add('dragging'); handle.setPointerCapture(e.pointerId); _showDockIndicators(container); }); }); handle.addEventListener('pointermove', (e) => { if (!dragStart) return; const cr = container.getBoundingClientRect(); const ew = tbEl.offsetWidth, eh = 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 - ew, l)); t = Math.max(0, Math.min(cr.height - eh, t)); tbEl.style.left = l + 'px'; tbEl.style.top = t + 'px'; _updateDockHighlight(container, tbEl); }); handle.addEventListener('pointerup', () => { if (!dragStart) return; dragStart = null; handle.classList.remove('dragging'); _hideDockIndicators(); // Snap to nearest dock position const newDock = _nearestDock(container, tbEl); _applyToolbarDock(tbEl, container, newDock, true); _saveToolbarPos({ dock: newDock }); }); } /* ── 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); } } /** Navigate graph to a node by entity ID — zoom + highlight. */ export function graphNavigateToNode(entityId) { const node = _nodeMap?.get(entityId); if (!node || !_canvas) return false; _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2); const nodeGroup = document.querySelector('.graph-nodes'); if (nodeGroup) { highlightNode(nodeGroup, entityId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } const edgeGroup = document.querySelector('.graph-edges'); if (edgeGroup && _edges) { highlightChain(edgeGroup, entityId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); } return true; } 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: () => window.editSyncClock?.(node.id), output_target: () => window.showTargetEditor?.(node.id), cspt: () => window.editCSPT?.(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), cspt: () => window.deleteCSPT?.(node.id), sync_clock: () => window.deleteSyncClock?.(node.id), }; fnMap[node.kind]?.(); } async function _bulkDeleteSelected() { const count = _selectedIds.size; if (count < 2) return; const ok = await showConfirm( (t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count)) ); if (!ok) return; for (const id of _selectedIds) { const node = _nodeMap.get(id); if (node) _onDeleteNode(node); } _selectedIds.clear(); } function _onCloneNode(node) { const fnMap = { device: () => window.cloneDevice?.(node.id), capture_template: () => window.cloneCaptureTemplate?.(node.id), pp_template: () => window.clonePPTemplate?.(node.id), audio_template: () => window.cloneAudioTemplate?.(node.id), pattern_template: () => window.clonePatternTemplate?.(node.id), picture_source: () => window.cloneStream?.(node.id), audio_source: () => window.cloneAudioSource?.(node.id), value_source: () => window.cloneValueSource?.(node.id), color_strip_source: () => window.cloneColorStrip?.(node.id), output_target: () => window.cloneTarget?.(node.id), scene_preset: () => window.cloneScenePreset?.(node.id), automation: () => window.cloneAutomation?.(node.id), cspt: () => window.cloneCSPT?.(node.id), sync_clock: () => window.cloneSyncClock?.(node.id), }; _watchForNewEntity(); fnMap[node.kind]?.(); } async function _onActivatePreset(node) { if (node.kind !== 'scene_preset') return; try { const resp = await fetchWithAuth(`/scene-presets/${node.id}/activate`, { method: 'POST' }); if (resp.ok) { showToast(t('scene_preset.activated') || 'Preset activated', 'success'); setTimeout(() => loadGraphEditor(), 500); } else { const err = await resp.json().catch(() => ({})); showToast(err.detail || 'Activation failed', 'error'); } } catch (e) { showToast(e.message, 'error'); } } function _onStartStopNode(node) { const newRunning = !node.running; // Optimistic update — toggle UI immediately _updateNodeRunning(node.id, newRunning); if (node.kind === 'output_target') { const action = newRunning ? 'start' : 'stop'; fetchWithAuth(`/output-targets/${node.id}/${action}`, { method: 'POST' }).then(resp => { if (resp.ok) { showToast(t(action === 'start' ? 'device.started' : 'device.stopped'), 'success'); } else { resp.json().catch(() => ({})).then(err => { showToast(err.detail || t(`target.error.${action}_failed`), 'error'); }); _updateNodeRunning(node.id, !newRunning); // revert } }).catch(() => { _updateNodeRunning(node.id, !newRunning); }); } else if (node.kind === 'sync_clock') { const action = newRunning ? 'resume' : 'pause'; fetchWithAuth(`/sync-clocks/${node.id}/${action}`, { method: 'POST' }).then(resp => { if (resp.ok) { showToast(t(action === 'pause' ? 'sync_clock.paused' : 'sync_clock.resumed'), 'success'); } else { _updateNodeRunning(node.id, !newRunning); // revert } }).catch(() => { _updateNodeRunning(node.id, !newRunning); }); } else if (node.kind === 'automation') { fetchWithAuth(`/automations/${node.id}`, { method: 'PUT', body: JSON.stringify({ enabled: newRunning }), }).then(resp => { if (resp.ok) { showToast(t(newRunning ? 'automation.enabled' : 'automation.disabled') || (newRunning ? 'Enabled' : 'Disabled'), 'success'); } else { _updateNodeRunning(node.id, !newRunning); } }).catch(() => { _updateNodeRunning(node.id, !newRunning); }); } } /** Update a node's running state in the model and patch it in-place (no re-render). */ function _updateNodeRunning(nodeId, running) { const node = _nodeMap?.get(nodeId); if (!node) return; node.running = running; const nodeGroup = document.querySelector('.graph-nodes'); const edgeGroup = document.querySelector('.graph-edges'); if (nodeGroup) { patchNodeRunning(nodeGroup, node); } // Update flow dots since running set changed if (edgeGroup) { const runningIds = new Set(); for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); } renderFlowDots(edgeGroup, _edges, runningIds); } } function _onTestNode(node) { const fnMap = { capture_template: () => window.showTestTemplateModal?.(node.id), pp_template: () => window.showTestPPTemplateModal?.(node.id), audio_template: () => window.showTestAudioTemplateModal?.(node.id), picture_source: () => window.showTestStreamModal?.(node.id), audio_source: () => window.testAudioSource?.(node.id), value_source: () => window.testValueSource?.(node.id), color_strip_source: () => window.testColorStrip?.(node.id), cspt: () => window.testCSPT?.(node.id), output_target: () => window.testKCTarget?.(node.id), }; fnMap[node.kind]?.(); } function _onNotificationTest(node) { if (node.kind === 'color_strip_source' && node.subtype === 'notification') { window.testNotification?.(node.id); } } /* ── Keyboard ── */ function _onKeydown(e) { // Trap Tab inside the graph to prevent focus escaping to footer if (e.key === 'Tab') { e.preventDefault(); return; } // Skip when typing in search input (except Escape/F11) const inInput = e.target.matches('input, textarea, select'); if (e.key === '/' && !inInput) { e.preventDefault(); window.openCommandPalette?.(); } if ((e.key === 'f' || e.key === 'F') && !inInput && !e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleGraphFilter(); } if (e.key === 'Escape') { if (_filterVisible) { toggleGraphFilter(); } else { const ng = document.querySelector('.graph-nodes'); const eg = document.querySelector('.graph-edges'); _deselect(ng, eg); } } // Delete key → detach selected edge or delete selected node(s) if (e.key === 'Delete' && !inInput) { if (_selectedEdge) { _detachSelectedEdge(); } else if (_selectedIds.size === 1) { const nodeId = [..._selectedIds][0]; const node = _nodeMap.get(nodeId); if (node) _onDeleteNode(node); } else if (_selectedIds.size > 1) { _bulkDeleteSelected(); } } // Ctrl+A → select all if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !inInput) { e.preventDefault(); _selectAll(); } // F11 → fullscreen if (e.key === 'F11') { e.preventDefault(); graphToggleFullscreen(); } // + → add entity if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) { graphAddEntity(); } // ? → keyboard shortcuts help if (e.key === '?' && !inInput) { e.preventDefault(); toggleGraphHelp(); } // Ctrl+Z / Ctrl+Shift+Z → undo/redo if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !inInput) { e.preventDefault(); if (e.shiftKey) _redo(); else _undo(); } // Arrow keys / WASD → spatial navigation between nodes if (_selectedIds.size <= 1 && !inInput) { const dir = _arrowDir(e); if (dir) { e.preventDefault(); _navigateDirection(dir); } } } function _arrowDir(e) { if (e.ctrlKey || e.metaKey) return null; switch (e.key) { case 'ArrowLeft': case 'a': case 'A': return 'left'; case 'ArrowRight': case 'd': case 'D': return 'right'; case 'ArrowUp': case 'w': case 'W': return 'up'; case 'ArrowDown': case 's': case 'S': return 'down'; default: return null; } } function _navigateDirection(dir) { if (!_nodeMap || _nodeMap.size === 0) return; // Get current anchor node let anchor = null; if (_selectedIds.size === 1) { anchor = _nodeMap.get([..._selectedIds][0]); } if (!anchor) { // Select first visible node (topmost-leftmost) let best = null; for (const n of _nodeMap.values()) { if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n; } if (best) { _selectedIds.clear(); _selectedIds.add(best.id); const ng = document.querySelector('.graph-nodes'); const eg = document.querySelector('.graph-edges'); if (ng) updateSelection(ng, _selectedIds); if (eg) clearEdgeHighlights(eg); if (_canvas) _canvas.panTo(best.x + best.width / 2, best.y + best.height / 2, true); } return; } const cx = anchor.x + anchor.width / 2; const cy = anchor.y + anchor.height / 2; let bestNode = null; let bestDist = Infinity; for (const n of _nodeMap.values()) { if (n.id === anchor.id) continue; const nx = n.x + n.width / 2; const ny = n.y + n.height / 2; const dx = nx - cx; const dy = ny - cy; // Check direction constraint let valid = false; if (dir === 'right' && dx > 10) valid = true; if (dir === 'left' && dx < -10) valid = true; if (dir === 'down' && dy > 10) valid = true; if (dir === 'up' && dy < -10) valid = true; if (!valid) continue; // Distance with directional bias (favor the primary axis) const primaryDist = dir === 'left' || dir === 'right' ? Math.abs(dx) : Math.abs(dy); const crossDist = dir === 'left' || dir === 'right' ? Math.abs(dy) : Math.abs(dx); const dist = primaryDist + crossDist * 2; if (dist < bestDist) { bestDist = dist; bestNode = n; } } if (bestNode) { _selectedIds.clear(); _selectedIds.add(bestNode.id); const ng = document.querySelector('.graph-nodes'); const eg = document.querySelector('.graph-edges'); if (ng) updateSelection(ng, _selectedIds); if (eg && _edges) { const chain = highlightChain(eg, bestNode.id, _edges); // Dim non-chain nodes like _onNodeClick does if (ng) { ng.querySelectorAll('.graph-node').forEach(n => { n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25'; }); } } if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true); } } 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'); const field = edgePath.getAttribute('data-field') || ''; // Track selected edge for Delete key detach const toNode = _nodeMap?.get(toId); if (toNode && isEditableEdge(field)) { _selectedEdge = { from: fromId, to: toId, field, targetKind: toNode.kind }; } else { _selectedEdge = null; } _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; if (e.target.closest('.graph-port-out')) return; // handled by port drag 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 }; } /* ── Port drag (connect/reconnect) ── */ const SVG_NS = 'http://www.w3.org/2000/svg'; function _initPortDrag(svgEl, nodeGroup, edgeGroup) { // Capture-phase on output ports to prevent node drag nodeGroup.addEventListener('pointerdown', (e) => { const port = e.target.closest('.graph-port-out'); if (!port || e.button !== 0) return; e.stopPropagation(); e.preventDefault(); const sourceNodeId = port.getAttribute('data-node-id'); const sourceKind = port.getAttribute('data-node-kind'); const portType = port.getAttribute('data-port-type'); const sourceNode = _nodeMap?.get(sourceNodeId); if (!sourceNode) return; // Compute start position in graph coords (output port = right side of node) const portY = sourceNode.outputPorts?.ports?.[portType] ?? sourceNode.height / 2; const startX = sourceNode.x + sourceNode.width; const startY = sourceNode.y + portY; // Create temporary drag edge in SVG const dragPath = document.createElementNS(SVG_NS, 'path'); dragPath.setAttribute('class', 'graph-drag-edge'); dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`); const root = svgEl.querySelector('.graph-root'); root.appendChild(dragPath); _connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath }; if (_canvas) _canvas.blockPan = true; svgEl.classList.add('connecting'); // Highlight compatible input ports const compatible = getCompatibleInputs(sourceKind); const compatibleSet = new Set(compatible.map(c => `${c.targetKind}:${c.edgeType}`)); nodeGroup.querySelectorAll('.graph-port-in').forEach(p => { const nKind = p.getAttribute('data-node-kind'); const pType = p.getAttribute('data-port-type'); const nId = p.getAttribute('data-node-id'); // Don't connect to self if (nId === sourceNodeId) { p.classList.add('graph-port-incompatible'); return; } if (compatibleSet.has(`${nKind}:${pType}`)) { p.classList.add('graph-port-compatible'); } else { p.classList.add('graph-port-incompatible'); } }); }, true); // capture phase to beat node drag if (!_connectListenersAdded) { window.addEventListener('pointermove', _onConnectPointerMove); window.addEventListener('pointerup', _onConnectPointerUp); _connectListenersAdded = true; } } function _onConnectPointerMove(e) { if (!_connectState || !_canvas) return; const gp = _canvas.screenToGraph(e.clientX, e.clientY); const { startX, startY, dragPath } = _connectState; const dx = Math.abs(gp.x - startX) * 0.4; dragPath.setAttribute('d', `M ${startX} ${startY} C ${startX + dx} ${startY} ${gp.x - dx} ${gp.y} ${gp.x} ${gp.y}` ); // Highlight drop target port const svgEl = document.querySelector('.graph-svg'); if (!svgEl) return; const elem = document.elementFromPoint(e.clientX, e.clientY); const port = elem?.closest?.('.graph-port-compatible'); svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target')); if (port) port.classList.add('graph-port-drop-target'); } function _onConnectPointerUp(e) { if (!_connectState) return; const { sourceNodeId, sourceKind, portType, dragPath } = _connectState; // Clean up drag edge dragPath.remove(); const svgEl = document.querySelector('.graph-svg'); if (svgEl) svgEl.classList.remove('connecting'); if (_canvas) _canvas.blockPan = false; // Clean up port highlights const nodeGroup = document.querySelector('.graph-nodes'); if (nodeGroup) { nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => { p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target'); }); } // Check if dropped on a compatible input port const elem = document.elementFromPoint(e.clientX, e.clientY); const targetPort = elem?.closest?.('.graph-port-in'); if (targetPort) { const targetNodeId = targetPort.getAttribute('data-node-id'); const targetKind = targetPort.getAttribute('data-node-kind'); const targetPortType = targetPort.getAttribute('data-port-type'); if (targetNodeId !== sourceNodeId) { // Find the matching connection const matches = findConnection(targetKind, sourceKind, targetPortType); if (matches.length === 1) { _doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId); } else if (matches.length > 1) { // Multiple possible fields (e.g., template → picture_source could be capture or pp template) // Resolve by source kind const exact = matches.find(m => m.sourceKind === sourceKind); if (exact) { _doConnect(targetNodeId, targetKind, exact.field, sourceNodeId); } } } } _connectState = null; } async function _doConnect(targetId, targetKind, field, sourceId) { const ok = await updateConnection(targetId, targetKind, field, sourceId); if (ok) { showToast(t('graph.connection_updated') || 'Connection updated', 'success'); await loadGraphEditor(); } else { showToast(t('graph.connection_failed') || 'Failed to update connection', 'error'); } } /* ── Undo / Redo ── */ const _undoStack = []; const _redoStack = []; const _MAX_UNDO = 30; /** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */ export function pushUndoAction(action) { _undoStack.push(action); if (_undoStack.length > _MAX_UNDO) _undoStack.shift(); _redoStack.length = 0; _updateUndoRedoButtons(); } function _updateUndoRedoButtons() { const undoBtn = document.getElementById('graph-undo-btn'); const redoBtn = document.getElementById('graph-redo-btn'); if (undoBtn) undoBtn.disabled = _undoStack.length === 0; if (redoBtn) redoBtn.disabled = _redoStack.length === 0; } export async function graphUndo() { await _undo(); } export async function graphRedo() { await _redo(); } async function _undo() { if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; } const action = _undoStack.pop(); try { await action.undo(); _redoStack.push(action); showToast(t('graph.undone') || `Undone: ${action.label}`, 'info'); _updateUndoRedoButtons(); await loadGraphEditor(); } catch (e) { showToast(e.message, 'error'); _updateUndoRedoButtons(); } } async function _redo() { if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; } const action = _redoStack.pop(); try { await action.redo(); _undoStack.push(action); showToast(t('graph.redone') || `Redone: ${action.label}`, 'info'); _updateUndoRedoButtons(); await loadGraphEditor(); } catch (e) { showToast(e.message, 'error'); _updateUndoRedoButtons(); } } /* ── Keyboard shortcuts help ── */ let _helpVisible = false; function _loadHelpPos() { try { const saved = JSON.parse(localStorage.getItem('graph_help_pos')); return saved || { anchor: 'br', offsetX: 12, offsetY: 12 }; } catch { return { anchor: 'br', offsetX: 12, offsetY: 12 }; } } function _saveHelpPos(pos) { localStorage.setItem('graph_help_pos', JSON.stringify(pos)); } export function toggleGraphHelp() { _helpVisible = !_helpVisible; const helpBtn = document.getElementById('graph-help-toggle'); if (helpBtn) helpBtn.classList.toggle('active', _helpVisible); let panel = document.querySelector('.graph-help-panel'); if (_helpVisible) { if (!panel) { const container = document.querySelector('#graph-editor-content .graph-container'); if (!container) return; panel = document.createElement('div'); panel.className = 'graph-help-panel visible'; panel.innerHTML = `
${t('graph.help_title')}
/ ${t('graph.help.search')}
F ${t('graph.help.filter')}
+ ${t('graph.help.add')}
? ${t('graph.help.shortcuts')}
Del ${t('graph.help.delete')}
Ctrl+A ${t('graph.help.select_all')}
Ctrl+Z ${t('graph.help.undo')}
Ctrl+Shift+Z ${t('graph.help.redo')}
F11 ${t('graph.help.fullscreen')}
Esc ${t('graph.help.deselect')}
\u2190\u2191\u2192\u2193 ${t('graph.help.navigate')}
${t('graph.help.click')} ${t('graph.help.click_desc')}
${t('graph.help.dblclick')} ${t('graph.help.dblclick_desc')}
${t('graph.help.shift_click')} ${t('graph.help.shift_click_desc')}
${t('graph.help.shift_drag')} ${t('graph.help.shift_drag_desc')}
${t('graph.help.drag_node')} ${t('graph.help.drag_node_desc')}
${t('graph.help.drag_port')} ${t('graph.help.drag_port_desc')}
${t('graph.help.right_click')} ${t('graph.help.right_click_desc')}
`; container.appendChild(panel); // Make draggable with anchor persistence const header = panel.querySelector('.graph-help-header'); _makeDraggable(panel, header, { loadFn: _loadHelpPos, saveFn: _saveHelpPos }); } else { panel.classList.add('visible'); } } else if (panel) { panel.classList.remove('visible'); } } /* ── Edge context menu (right-click to detach) ── */ function _onEdgeContextMenu(edgePath, e, container) { _dismissEdgeContextMenu(); const field = edgePath.getAttribute('data-field') || ''; if (!isEditableEdge(field)) return; // nested fields can't be detached from graph const toId = edgePath.getAttribute('data-to'); const toNode = _nodeMap?.get(toId); if (!toNode) return; const menu = document.createElement('div'); menu.className = 'graph-edge-menu'; menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`; menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`; const btn = document.createElement('button'); btn.className = 'graph-edge-menu-item danger'; btn.textContent = t('graph.disconnect') || 'Disconnect'; btn.addEventListener('click', async () => { _dismissEdgeContextMenu(); const ok = await detachConnection(toId, toNode.kind, field); if (ok) { showToast(t('graph.connection_removed') || 'Connection removed', 'success'); await loadGraphEditor(); } else { showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error'); } }); menu.appendChild(btn); container.querySelector('.graph-container').appendChild(menu); _edgeContextMenu = menu; } function _dismissEdgeContextMenu() { if (_edgeContextMenu) { _edgeContextMenu.remove(); _edgeContextMenu = null; } } async function _detachSelectedEdge() { if (!_selectedEdge) return; const { to, field, targetKind } = _selectedEdge; _selectedEdge = null; const ok = await detachConnection(to, targetKind, field); if (ok) { showToast(t('graph.connection_removed') || 'Connection removed', 'success'); await loadGraphEditor(); } else { showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error'); } } // 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); } });