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:
@@ -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')}">×</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) {
|
||||
|
||||
Reference in New Issue
Block a user