Major graph editor improvements: standalone features, touch, docking, UX

Graph standalone features:
- Clone button on all entity nodes (copy icon, watches for new entity)
- Scene preset activation button (play icon, calls /activate API)
- Automation enable/disable via start/stop toggle (PUT enabled)
- Add missing entity types: sync_clock, scene_preset, pattern_template
- Fix edit/delete handlers for cspt, sync_clock
- CSPT added to test/preview button kinds
- Bulk delete with multi-select + Delete key confirmation
- Undo/redo framework with toolbar buttons (disabled when empty)
- Keyboard shortcuts help panel (? key, draggable, anchor-persisted)
- Enhanced search: type:device, tag:production filter syntax
- Tags passed through to all graph nodes for tag-based filtering
- Filter popover with grouped checkboxes replaces flat pill row

Touch device support:
- Pinch-to-zoom with 2-finger gesture tracking
- Double-tap zoom toggle (1.0x ↔ 1.8x)
- Multi-touch pointer tracking with pinch-to-pan
- Overlay buttons and port labels visible on selected (tapped) nodes
- Larger touch targets for ports (@media pointer: coarse)
- touch-action: none on SVG canvas
- 10px dead zone for touch vs 4px for mouse

Visual improvements:
- Port pin labels shown outside node on hover/select (outlined text)
- Hybrid active edge flow: thicker + glow + animated dots
- Test/preview icon changed to flask (matching card tabs)
- Clone icon scaled down to 60% for compact overlay buttons
- Monospace font for metric values (stable-width digits)
- Hide scrollbar on graph tab (html:has override)

Toolbar docking:
- 8-position dock system (4 corners + 4 side midpoints)
- Vertical layout when docked to left/right sides
- Dock position indicators shown during drag (dots with highlight)
- Snap animation on drop
- Persisted dock position in localStorage

Resize handling:
- View center preserved on fullscreen/window resize (ResizeObserver)
- All docked panels re-anchored on container resize
- Zoom inertia for wheel and toolbar +/- buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:58:45 +03:00
parent 50c40ed13f
commit 0bbaf81e26
12 changed files with 1176 additions and 100 deletions

View File

@@ -106,6 +106,63 @@ 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; }
}
@@ -238,6 +295,72 @@ export function toggleGraphMinimap() {
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 `<label class="graph-filter-type-item" data-kind="${kind}">
<input type="checkbox" value="${kind}">
<span class="graph-filter-type-dot" style="background:${color}"></span>
<span>${label}</span>
</label>`;
}).join('');
return `<div class="graph-filter-type-group" data-group="${g.key}">
<div class="graph-filter-type-group-header" data-group-toggle="${g.key}">${groupLabels[g.key]}</div>
${items}
</div>`;
}).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');
@@ -246,17 +369,20 @@ export function toggleGraphFilter() {
if (_filterVisible) {
const input = bar.querySelector('.graph-filter-input');
if (input) { input.value = _filterQuery; input.focus(); }
// Restore pill active states
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
});
// 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();
}
}
@@ -269,18 +395,35 @@ function _applyFilter(query) {
if (!_nodeMap) return;
const hasTextFilter = !!q;
// 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;
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(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q);
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 && runMatch) matchIds.add(node.id);
if (textMatch && kindMatch && parsedKindMatch && tagMatch && runMatch) matchIds.add(node.id);
}
// Apply filtered-out class to nodes
@@ -367,6 +510,9 @@ const ADD_ENTITY_MAP = [
{ 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
@@ -509,6 +655,8 @@ function _renderGraph(container) {
onStartStopNode: _onStartStopNode,
onTestNode: _onTestNode,
onNotificationTest: _onNotificationTest,
onCloneNode: _onCloneNode,
onActivatePreset: _onActivatePreset,
});
markOrphans(nodeGroup, _nodeMap, _edges);
@@ -579,16 +727,39 @@ function _renderGraph(container) {
});
}
// Entity type pills
container.querySelectorAll('.graph-filter-pill[data-kind]').forEach(pill => {
pill.addEventListener('click', () => {
const kind = pill.dataset.kind;
if (_filterKinds.has(kind)) { _filterKinds.delete(kind); pill.classList.remove('active'); }
else { _filterKinds.add(kind); pill.classList.add('active'); }
// 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', () => {
@@ -598,10 +769,10 @@ function _renderGraph(container) {
pill.classList.remove('active');
} else {
_filterRunning = val;
// Deactivate sibling running pills, activate this one
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
}
_updateFilterBadge();
_applyFilter();
});
});
@@ -611,13 +782,12 @@ function _renderGraph(container) {
const bar = container.querySelector('.graph-filter');
if (bar) {
bar.classList.add('visible');
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
});
_syncPopoverCheckboxes();
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
});
}
_updateFilterBadge();
_applyFilter(_filterQuery);
}
@@ -663,13 +833,9 @@ 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;` : '';
// Toolbar position is applied in _initToolbarDrag via anchor logic
const tbPos = _loadToolbarPos();
const tbStyle = tbPos && !tbPos.anchor ? `left:${tbPos.left}px;top:${tbPos.top}px;` : '';
return `
<div class="graph-container">
<div class="graph-toolbar" style="${tbStyle}">
<div class="graph-toolbar">
<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>
@@ -695,6 +861,13 @@ function _graphHTML() {
<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" id="graph-undo-btn" onclick="graphUndo()" title="${t('graph.help.undo') || 'Undo'} (Ctrl+Z)" disabled>
<svg class="icon" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
</button>
<button class="btn-icon" id="graph-redo-btn" onclick="graphRedo()" title="${t('graph.help.redo') || 'Redo'} (Ctrl+Shift+Z)" disabled>
<svg class="icon" viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></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>
@@ -705,6 +878,9 @@ function _graphHTML() {
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
<button class="btn-icon${_helpVisible ? ' active' : ''}" id="graph-help-toggle" onclick="toggleGraphHelp()" title="${t('graph.help_title') || 'Shortcuts'} (?)">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button>
</div>
<div class="graph-legend${_legendVisible ? ' visible' : ''}">
@@ -727,14 +903,16 @@ function _graphHTML() {
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">&times;</button>
</div>
<div class="graph-filter-pills">
${Object.entries(ENTITY_LABELS).map(([kind, label]) =>
`<button class="graph-filter-pill" data-kind="${kind}" style="--pill-color:${ENTITY_COLORS[kind] || '#666'}" title="${label}">${label}</button>`
).join('')}
<span class="graph-filter-sep"></span>
<div class="graph-filter-row graph-filter-actions">
<button class="graph-filter-types-btn" onclick="toggleGraphFilterTypes(this)">
${t('graph.filter_types') || 'Types'} <span class="graph-filter-types-badge"></span>
</button>
<button class="graph-filter-pill graph-filter-running" data-running="true" style="--pill-color:var(--success-color)" title="${t('graph.filter_running') || 'Running'}">${t('graph.filter_running') || 'Running'}</button>
<button class="graph-filter-pill graph-filter-running" data-running="false" style="--pill-color:var(--text-muted)" title="${t('graph.filter_stopped') || 'Stopped'}">${t('graph.filter_stopped') || 'Stopped'}</button>
</div>
<div class="graph-filter-types-popover">
${_buildFilterGroupsHTML()}
</div>
</div>
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
@@ -946,18 +1124,112 @@ function _initResizeClamp(container) {
if (_resizeObserver) _resizeObserver.disconnect();
_resizeObserver = new ResizeObserver(() => {
_reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect);
_reanchorPanel(container.querySelector('.graph-toolbar'), container, _loadToolbarPos);
_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');
_makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos });
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 });
});
}
@@ -1021,8 +1293,9 @@ function _onEditNode(node) {
audio_source: () => window.editAudioSource?.(node.id),
value_source: () => window.editValueSource?.(node.id),
color_strip_source: () => window.showCSSEditor?.(node.id),
sync_clock: () => {},
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),
};
@@ -1043,10 +1316,63 @@ function _onDeleteNode(node) {
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
@@ -1073,6 +1399,17 @@ function _onStartStopNode(node) {
_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); });
}
}
@@ -1105,6 +1442,7 @@ function _onTestNode(node) {
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]?.();
@@ -1132,7 +1470,7 @@ function _onKeydown(e) {
_deselect(ng, eg);
}
}
// Delete key → detach selected edge or delete single selected node
// Delete key → detach selected edge or delete selected node(s)
if (e.key === 'Delete' && !inInput) {
if (_selectedEdge) {
_detachSelectedEdge();
@@ -1140,6 +1478,8 @@ function _onKeydown(e) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
if (node) _onDeleteNode(node);
} else if (_selectedIds.size > 1) {
_bulkDeleteSelected();
}
}
// Ctrl+A → select all
@@ -1156,6 +1496,16 @@ function _onKeydown(e) {
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);
@@ -1240,7 +1590,15 @@ function _navigateDirection(dir) {
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
if (ng) updateSelection(ng, _selectedIds);
if (eg && _edges) highlightChain(eg, bestNode.id, _edges);
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);
}
}
@@ -1697,6 +2055,122 @@ async function _doConnect(targetId, targetKind, field, sourceId) {
}
}
/* ── 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 = `
<div class="graph-help-header">
<span>${t('graph.help_title')}</span>
</div>
<div class="graph-help-body">
<div class="graph-help-row"><kbd>/</kbd> <span>${t('graph.help.search')}</span></div>
<div class="graph-help-row"><kbd>F</kbd> <span>${t('graph.help.filter')}</span></div>
<div class="graph-help-row"><kbd>+</kbd> <span>${t('graph.help.add')}</span></div>
<div class="graph-help-row"><kbd>?</kbd> <span>${t('graph.help.shortcuts')}</span></div>
<div class="graph-help-row"><kbd>Del</kbd> <span>${t('graph.help.delete')}</span></div>
<div class="graph-help-row"><kbd>Ctrl+A</kbd> <span>${t('graph.help.select_all')}</span></div>
<div class="graph-help-row"><kbd>Ctrl+Z</kbd> <span>${t('graph.help.undo')}</span></div>
<div class="graph-help-row"><kbd>Ctrl+Shift+Z</kbd> <span>${t('graph.help.redo')}</span></div>
<div class="graph-help-row"><kbd>F11</kbd> <span>${t('graph.help.fullscreen')}</span></div>
<div class="graph-help-row"><kbd>Esc</kbd> <span>${t('graph.help.deselect')}</span></div>
<div class="graph-help-row"><kbd>\u2190\u2191\u2192\u2193</kbd> <span>${t('graph.help.navigate')}</span></div>
<div class="graph-help-sep"></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.click')}</span> <span>${t('graph.help.click_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.dblclick')}</span> <span>${t('graph.help.dblclick_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_click')}</span> <span>${t('graph.help.shift_click_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_drag')}</span> <span>${t('graph.help.shift_drag_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_node')}</span> <span>${t('graph.help.drag_node_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_port')}</span> <span>${t('graph.help.drag_port_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.right_click')}</span> <span>${t('graph.help.right_click_desc')}</span></div>
</div>`;
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) {