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:
@@ -804,9 +804,10 @@ ul.section-tip li {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metric-value {
|
.metric-value {
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
|
|||||||
@@ -159,10 +159,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric-value {
|
.dashboard-metric-value {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric-label {
|
.dashboard-metric-label {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
/* ── Graph Editor ─────────────────────────────────────── */
|
/* ── Graph Editor ─────────────────────────────────────── */
|
||||||
|
|
||||||
/* Full viewport: hide footer & scroll-to-top when graph is active */
|
/* Full viewport: hide footer & scroll-to-top when graph is active */
|
||||||
|
#tab-graph {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the global scrollbar when graph tab is active */
|
||||||
|
html:has(#tab-graph.active) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
#tab-graph #graph-editor-content {
|
#tab-graph #graph-editor-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -42,12 +51,66 @@
|
|||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
letter-spacing: -1px;
|
letter-spacing: -1px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-toolbar-drag:active {
|
.graph-toolbar-drag:active,
|
||||||
|
.graph-toolbar-drag.dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Vertical toolbar (docked to left/right side) */
|
||||||
|
.graph-toolbar.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-toolbar.vertical .graph-toolbar-drag {
|
||||||
|
width: auto;
|
||||||
|
height: 16px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-toolbar.vertical .graph-toolbar-sep {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-toolbar.vertical .graph-zoom-label {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dock position indicators during toolbar drag */
|
||||||
|
.graph-dock-indicators {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-dock-dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-left: -5px;
|
||||||
|
margin-top: -5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
opacity: 0.25;
|
||||||
|
transition: opacity 0.15s, transform 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-dock-dot.nearest {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--primary-color);
|
||||||
|
transform: scale(1.6);
|
||||||
|
box-shadow: 0 0 8px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.graph-toolbar .btn-icon {
|
.graph-toolbar .btn-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -72,6 +135,12 @@
|
|||||||
color: var(--primary-contrast);
|
color: var(--primary-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graph-toolbar .btn-icon:disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.graph-toolbar-sep {
|
.graph-toolbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
@@ -361,11 +430,38 @@
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-node:hover .graph-port {
|
.graph-node:hover .graph-port,
|
||||||
|
.graph-node.selected .graph-port {
|
||||||
r: 5;
|
r: 5;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Larger touch targets for ports on touch devices */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.graph-port { r: 6; }
|
||||||
|
.graph-node:hover .graph-port,
|
||||||
|
.graph-node.selected .graph-port { r: 7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Port labels — hidden by default, shown on node hover, positioned outside node */
|
||||||
|
.graph-port-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
fill: var(--text-color);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
stroke: var(--bg-color);
|
||||||
|
stroke-width: 3px;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node:hover .graph-port-label,
|
||||||
|
.graph-node.selected .graph-port-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Port output cursor: draggable */
|
/* Port output cursor: draggable */
|
||||||
.graph-port-out {
|
.graph-port-out {
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
@@ -441,18 +537,22 @@
|
|||||||
.graph-edge-scene { stroke: #CE93D8; color: #CE93D8; }
|
.graph-edge-scene { stroke: #CE93D8; color: #CE93D8; }
|
||||||
.graph-edge-default { stroke: var(--text-muted); color: var(--text-muted); }
|
.graph-edge-default { stroke: var(--text-muted); color: var(--text-muted); }
|
||||||
|
|
||||||
/* Animated flow dots on active edges */
|
/* ── Active edge flow (hybrid: thicker + saturated + animated dash) ── */
|
||||||
.graph-edge-flow {
|
|
||||||
fill: none;
|
.graph-edge.graph-edge-active {
|
||||||
stroke-width: 0;
|
opacity: 1;
|
||||||
pointer-events: none;
|
stroke-width: 2.5;
|
||||||
|
filter: drop-shadow(0 0 3px currentColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-edge-flow circle {
|
/* Clear dash patterns on active edges so glow looks clean */
|
||||||
r: 3;
|
.graph-edge-clock.graph-edge-active,
|
||||||
opacity: 0.85;
|
.graph-edge-template.graph-edge-active { stroke-dasharray: none; }
|
||||||
filter: drop-shadow(0 0 2px currentColor);
|
.graph-edge-nested.graph-edge-active { stroke-dasharray: none; }
|
||||||
}
|
|
||||||
|
/* Flow dots on active edges */
|
||||||
|
.graph-edge-flow { pointer-events: none; }
|
||||||
|
.graph-edge-flow circle { filter: drop-shadow(0 0 2px currentColor); }
|
||||||
|
|
||||||
/* ── Drag connection preview ── */
|
/* ── Drag connection preview ── */
|
||||||
|
|
||||||
@@ -513,7 +613,8 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-node:hover .graph-node-overlay {
|
.graph-node:hover .graph-node-overlay,
|
||||||
|
.graph-node.selected .graph-node-overlay {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,11 +828,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-filter-pills {
|
.graph-filter-actions {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-filter-pill {
|
.graph-filter-pill {
|
||||||
@@ -757,11 +855,116 @@
|
|||||||
border-color: var(--pill-color, var(--primary-color));
|
border-color: var(--pill-color, var(--primary-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-filter-sep {
|
/* ── Types button + popover ── */
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
.graph-filter-types-btn {
|
||||||
background: var(--border-color);
|
background: transparent;
|
||||||
margin: 0 2px;
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-types-btn:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-types-badge {
|
||||||
|
display: none;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
line-height: 14px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-types-badge.visible {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-types-popover {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-types-popover.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-type-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-type-group-header {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-type-group-header:hover {
|
||||||
|
background: var(--hover-bg, rgba(255,255,255,0.05));
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-type-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 4px 3px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-type-item:hover {
|
||||||
|
background: var(--hover-bg, rgba(255,255,255,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-type-item input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-filter-type-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-filter-icon {
|
.graph-filter-icon {
|
||||||
@@ -895,3 +1098,77 @@
|
|||||||
z-index: 50;
|
z-index: 50;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Keyboard shortcuts help panel ── */
|
||||||
|
|
||||||
|
.graph-help-panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||||
|
width: 300px;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-panel.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-body {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-row kbd,
|
||||||
|
.graph-help-row .graph-help-mouse {
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-row kbd {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-row .graph-help-mouse {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-row span:last-child {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-help-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ import {
|
|||||||
// Layer 5.5: graph editor
|
// Layer 5.5: graph editor
|
||||||
import {
|
import {
|
||||||
loadGraphEditor,
|
loadGraphEditor,
|
||||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes,
|
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
||||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
|
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
|
||||||
graphToggleFullscreen, graphAddEntity,
|
graphToggleFullscreen, graphAddEntity,
|
||||||
} from './features/graph-editor.js';
|
} from './features/graph-editor.js';
|
||||||
@@ -487,6 +487,9 @@ Object.assign(window, {
|
|||||||
toggleGraphMinimap,
|
toggleGraphMinimap,
|
||||||
toggleGraphFilter,
|
toggleGraphFilter,
|
||||||
toggleGraphFilterTypes,
|
toggleGraphFilterTypes,
|
||||||
|
toggleGraphHelp,
|
||||||
|
graphUndo,
|
||||||
|
graphRedo,
|
||||||
graphFitAll,
|
graphFitAll,
|
||||||
graphZoomIn,
|
graphZoomIn,
|
||||||
graphZoomOut,
|
graphZoomOut,
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ export class GraphCanvas {
|
|||||||
this._bounds = null; // data bounds for view clamping {x, y, width, height}
|
this._bounds = null; // data bounds for view clamping {x, y, width, height}
|
||||||
/** Set to true externally to suppress pan on left-click (e.g. during node drag). */
|
/** Set to true externally to suppress pan on left-click (e.g. during node drag). */
|
||||||
this.blockPan = false;
|
this.blockPan = false;
|
||||||
|
// Zoom inertia state
|
||||||
|
this._zoomVelocity = 0;
|
||||||
|
this._zoomInertiaAnim = null;
|
||||||
|
this._lastWheelX = 0;
|
||||||
|
this._lastWheelY = 0;
|
||||||
|
// Touch / multi-pointer state
|
||||||
|
this._pointers = new Map(); // pointerId → {x, y}
|
||||||
|
this._pinchStartDist = 0;
|
||||||
|
this._pinchStartZoom = 1;
|
||||||
|
this._pinchMidX = 0;
|
||||||
|
this._pinchMidY = 0;
|
||||||
|
// Double-tap detection
|
||||||
|
this._lastTapTime = 0;
|
||||||
|
this._lastTapX = 0;
|
||||||
|
this._lastTapY = 0;
|
||||||
|
this._isTouch = false;
|
||||||
this._bind();
|
this._bind();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,14 +166,26 @@ export class GraphCanvas {
|
|||||||
this._zoomAnim = requestAnimationFrame(step);
|
this._zoomAnim = requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
zoomIn() { this.zoomTo(this._zoom * 1.25); }
|
zoomIn() { this._buttonZoomKick(0.06); }
|
||||||
zoomOut() { this.zoomTo(this._zoom / 1.25); }
|
zoomOut() { this._buttonZoomKick(-0.06); }
|
||||||
|
|
||||||
|
_buttonZoomKick(impulse) {
|
||||||
|
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
|
||||||
|
const r = this.svg.getBoundingClientRect();
|
||||||
|
this._lastWheelX = r.left + r.width / 2;
|
||||||
|
this._lastWheelY = r.top + r.height / 2;
|
||||||
|
this._zoomVelocity = this._zoomVelocity * 0.5 + impulse;
|
||||||
|
const newZoom = this._zoom * (1 + impulse);
|
||||||
|
this.zoomTo(newZoom, this._lastWheelX, this._lastWheelY);
|
||||||
|
if (!this._zoomInertiaAnim) this._tickZoomInertia();
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
for (const [el, ev, fn, opts] of this._listeners) {
|
for (const [el, ev, fn, opts] of this._listeners) {
|
||||||
el.removeEventListener(ev, fn, opts);
|
el.removeEventListener(ev, fn, opts);
|
||||||
}
|
}
|
||||||
this._listeners = [];
|
this._listeners = [];
|
||||||
|
if (this._resizeObs) { this._resizeObs.disconnect(); this._resizeObs = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ──
|
// ── Private ──
|
||||||
@@ -172,17 +200,104 @@ export class GraphCanvas {
|
|||||||
this._on(this.svg, 'pointerdown', this._onPointerDown.bind(this));
|
this._on(this.svg, 'pointerdown', this._onPointerDown.bind(this));
|
||||||
this._on(window, 'pointermove', this._onPointerMove.bind(this));
|
this._on(window, 'pointermove', this._onPointerMove.bind(this));
|
||||||
this._on(window, 'pointerup', this._onPointerUp.bind(this));
|
this._on(window, 'pointerup', this._onPointerUp.bind(this));
|
||||||
|
this._on(window, 'pointercancel', this._onPointerUp.bind(this));
|
||||||
|
// Preserve view center on container resize (fullscreen, window resize)
|
||||||
|
this._resizeObs = new ResizeObserver(() => this._onResize());
|
||||||
|
this._resizeObs.observe(this.svg);
|
||||||
|
this._lastSvgRect = this.svg.getBoundingClientRect();
|
||||||
|
// Prevent default touch actions on the SVG (browser pan/zoom)
|
||||||
|
this.svg.style.touchAction = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
_onResize() {
|
||||||
|
const prev = this._lastSvgRect;
|
||||||
|
const next = this.svg.getBoundingClientRect();
|
||||||
|
this._lastSvgRect = next;
|
||||||
|
if (!prev || prev.width === 0 || prev.height === 0) return;
|
||||||
|
// Compute center in graph-space using old dimensions
|
||||||
|
const cx = this._vx + (prev.width / 2) / this._zoom;
|
||||||
|
const cy = this._vy + (prev.height / 2) / this._zoom;
|
||||||
|
// Recompute vx/vy to keep same center with new dimensions
|
||||||
|
this._vx = cx - (next.width / 2) / this._zoom;
|
||||||
|
this._vy = cy - (next.height / 2) / this._zoom;
|
||||||
|
this._applyTransform(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onWheel(e) {
|
_onWheel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
|
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
|
||||||
const delta = -e.deltaY * ZOOM_SENSITIVITY;
|
|
||||||
const newZoom = this._zoom * (1 + delta);
|
// Accumulate velocity for inertia (capped to prevent runaway)
|
||||||
|
const impulse = -e.deltaY * ZOOM_SENSITIVITY;
|
||||||
|
this._zoomVelocity = this._zoomVelocity * 0.5 + impulse * 0.3;
|
||||||
|
this._lastWheelX = e.clientX;
|
||||||
|
this._lastWheelY = e.clientY;
|
||||||
|
|
||||||
|
// Apply immediate step
|
||||||
|
const newZoom = this._zoom * (1 + impulse);
|
||||||
this.zoomTo(newZoom, e.clientX, e.clientY);
|
this.zoomTo(newZoom, e.clientX, e.clientY);
|
||||||
|
|
||||||
|
// Start inertia decay if not already running
|
||||||
|
if (!this._zoomInertiaAnim) this._tickZoomInertia();
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickZoomInertia() {
|
||||||
|
const FRICTION = 0.85;
|
||||||
|
const MIN_VEL = 0.0003;
|
||||||
|
|
||||||
|
this._zoomVelocity *= FRICTION;
|
||||||
|
|
||||||
|
if (Math.abs(this._zoomVelocity) < MIN_VEL) {
|
||||||
|
this._zoomVelocity = 0;
|
||||||
|
this._zoomInertiaAnim = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newZoom = this._zoom * (1 + this._zoomVelocity);
|
||||||
|
this.zoomTo(newZoom, this._lastWheelX, this._lastWheelY);
|
||||||
|
|
||||||
|
this._zoomInertiaAnim = requestAnimationFrame(() => this._tickZoomInertia());
|
||||||
|
}
|
||||||
|
|
||||||
|
_pointerDist() {
|
||||||
|
if (this._pointers.size < 2) return 0;
|
||||||
|
const pts = [...this._pointers.values()];
|
||||||
|
const dx = pts[1].x - pts[0].x;
|
||||||
|
const dy = pts[1].y - pts[0].y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pointerMid() {
|
||||||
|
if (this._pointers.size < 2) return null;
|
||||||
|
const pts = [...this._pointers.values()];
|
||||||
|
return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
_onPointerDown(e) {
|
_onPointerDown(e) {
|
||||||
|
this._isTouch = e.pointerType === 'touch';
|
||||||
|
const deadZone = this._isTouch ? 10 : PAN_DEAD_ZONE;
|
||||||
|
|
||||||
|
// Track pointer for multi-touch
|
||||||
|
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
// Second finger → start pinch-to-zoom
|
||||||
|
if (this._pointers.size === 2) {
|
||||||
|
this._panning = false;
|
||||||
|
this._panPending = false;
|
||||||
|
this.svg.classList.remove('panning');
|
||||||
|
this._pinchStartDist = this._pointerDist();
|
||||||
|
this._pinchStartZoom = this._zoom;
|
||||||
|
const mid = this._pointerMid();
|
||||||
|
this._pinchMidX = mid.x;
|
||||||
|
this._pinchMidY = mid.y;
|
||||||
|
this._panStart = { x: mid.x, y: mid.y };
|
||||||
|
this._panViewStart = { x: this._vx, y: this._vy };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore 3+ fingers
|
||||||
|
if (this._pointers.size > 2) return;
|
||||||
|
|
||||||
// Middle button or Ctrl/Meta+left → immediate pan
|
// Middle button or Ctrl/Meta+left → immediate pan
|
||||||
if (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey))) {
|
if (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -190,11 +305,12 @@ export class GraphCanvas {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left-click on SVG background or edge (not on a node) → pending pan
|
// Left-click / single touch on SVG background → pending pan
|
||||||
if (e.button === 0 && !this.blockPan) {
|
if (e.button === 0 && !this.blockPan) {
|
||||||
const onNode = e.target.closest('.graph-node');
|
const onNode = e.target.closest('.graph-node');
|
||||||
if (!onNode) {
|
if (!onNode) {
|
||||||
this._panPending = true;
|
this._panPending = true;
|
||||||
|
this._panDeadZone = deadZone;
|
||||||
this._panStart = { x: e.clientX, y: e.clientY };
|
this._panStart = { x: e.clientX, y: e.clientY };
|
||||||
this._panViewStart = { x: this._vx, y: this._vy };
|
this._panViewStart = { x: this._vx, y: this._vy };
|
||||||
}
|
}
|
||||||
@@ -202,11 +318,37 @@ export class GraphCanvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_onPointerMove(e) {
|
_onPointerMove(e) {
|
||||||
// Check dead-zone for pending left-click pan
|
// Update tracked pointer position
|
||||||
|
if (this._pointers.has(e.pointerId)) {
|
||||||
|
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinch-to-zoom (2 fingers)
|
||||||
|
if (this._pointers.size === 2 && this._pinchStartDist > 0) {
|
||||||
|
const dist = this._pointerDist();
|
||||||
|
const scale = dist / this._pinchStartDist;
|
||||||
|
const newZoom = this._pinchStartZoom * scale;
|
||||||
|
const mid = this._pointerMid();
|
||||||
|
|
||||||
|
// Zoom around pinch midpoint
|
||||||
|
this.zoomTo(newZoom, mid.x, mid.y);
|
||||||
|
|
||||||
|
// Pan with pinch movement
|
||||||
|
const dx = (mid.x - this._panStart.x) / this._zoom;
|
||||||
|
const dy = (mid.y - this._panStart.y) / this._zoom;
|
||||||
|
this._vx = this._panViewStart.x - dx;
|
||||||
|
this._vy = this._panViewStart.y - dy;
|
||||||
|
this._applyTransform(false);
|
||||||
|
if (this._onZoomChange) this._onZoomChange(this._zoom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dead-zone for pending single-finger pan
|
||||||
|
const dz = this._panDeadZone || PAN_DEAD_ZONE;
|
||||||
if (this._panPending && !this._panning) {
|
if (this._panPending && !this._panning) {
|
||||||
const dx = e.clientX - this._panStart.x;
|
const dx = e.clientX - this._panStart.x;
|
||||||
const dy = e.clientY - this._panStart.y;
|
const dy = e.clientY - this._panStart.y;
|
||||||
if (Math.abs(dx) > PAN_DEAD_ZONE || Math.abs(dy) > PAN_DEAD_ZONE) {
|
if (Math.abs(dx) > dz || Math.abs(dy) > dz) {
|
||||||
this._panning = true;
|
this._panning = true;
|
||||||
this.svg.classList.add('panning');
|
this.svg.classList.add('panning');
|
||||||
this.svg.setPointerCapture(e.pointerId);
|
this.svg.setPointerCapture(e.pointerId);
|
||||||
@@ -221,15 +363,50 @@ export class GraphCanvas {
|
|||||||
this._applyTransform(false);
|
this._applyTransform(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onPointerUp() {
|
_onPointerUp(e) {
|
||||||
|
this._pointers.delete(e.pointerId);
|
||||||
|
|
||||||
|
// If we were pinching and one finger lifts, reset pinch state
|
||||||
|
if (this._pinchStartDist > 0 && this._pointers.size < 2) {
|
||||||
|
this._pinchStartDist = 0;
|
||||||
|
// If one finger remains, restart pan from it
|
||||||
|
if (this._pointers.size === 1) {
|
||||||
|
const pt = [...this._pointers.values()][0];
|
||||||
|
this._panPending = true;
|
||||||
|
this._panStart = { x: pt.x, y: pt.y };
|
||||||
|
this._panViewStart = { x: this._vx, y: this._vy };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._panPending = false;
|
this._panPending = false;
|
||||||
if (this._panning) {
|
if (this._panning) {
|
||||||
this._panning = false;
|
this._panning = false;
|
||||||
this._justPanned = true;
|
this._justPanned = true;
|
||||||
this.svg.classList.remove('panning');
|
this.svg.classList.remove('panning');
|
||||||
// Clear justPanned after the click event fires (next microtask + rAF)
|
|
||||||
requestAnimationFrame(() => { this._justPanned = false; });
|
requestAnimationFrame(() => { this._justPanned = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Double-tap detection (touch only)
|
||||||
|
if (this._isTouch && this._pointers.size === 0) {
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = now - this._lastTapTime;
|
||||||
|
const tapDx = e.clientX - this._lastTapX;
|
||||||
|
const tapDy = e.clientY - this._lastTapY;
|
||||||
|
if (dt < 350 && Math.abs(tapDx) < 30 && Math.abs(tapDy) < 30 && !this._justPanned) {
|
||||||
|
// Double-tap → zoom in/out toggle
|
||||||
|
const r = this.svg.getBoundingClientRect();
|
||||||
|
const gx = (e.clientX - r.left) / this._zoom + this._vx;
|
||||||
|
const gy = (e.clientY - r.top) / this._zoom + this._vy;
|
||||||
|
const targetZoom = this._zoom < 1.2 ? 1.8 : 1.0;
|
||||||
|
this.zoomToPoint(targetZoom, gx, gy, 300);
|
||||||
|
this._lastTapTime = 0; // reset so triple-tap doesn't re-trigger
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastTapTime = now;
|
||||||
|
this._lastTapX = e.clientX;
|
||||||
|
this._lastTapY = e.clientY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_startPan(e) {
|
_startPan(e) {
|
||||||
|
|||||||
@@ -241,7 +241,10 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
|
|||||||
* @param {Set<string>} runningIds - IDs of currently running nodes
|
* @param {Set<string>} runningIds - IDs of currently running nodes
|
||||||
*/
|
*/
|
||||||
export function renderFlowDots(group, edges, runningIds) {
|
export function renderFlowDots(group, edges, runningIds) {
|
||||||
|
// Clear previous flow state
|
||||||
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
|
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
|
||||||
|
group.querySelectorAll('.graph-edge.graph-edge-active').forEach(el => el.classList.remove('graph-edge-active'));
|
||||||
|
|
||||||
if (!runningIds || runningIds.size === 0) return;
|
if (!runningIds || runningIds.size === 0) return;
|
||||||
|
|
||||||
// Build adjacency index for O(E) BFS instead of O(N*E)
|
// Build adjacency index for O(E) BFS instead of O(N*E)
|
||||||
@@ -277,16 +280,17 @@ export function renderFlowDots(group, edges, runningIds) {
|
|||||||
const edge = edges[idx];
|
const edge = edges[idx];
|
||||||
const pathEl = pathIndex.get(`${edge.from}|${edge.to}|${edge.field || ''}`);
|
const pathEl = pathIndex.get(`${edge.from}|${edge.to}|${edge.field || ''}`);
|
||||||
if (!pathEl) continue;
|
if (!pathEl) continue;
|
||||||
|
|
||||||
|
// Hybrid: static styling (thicker + glow)
|
||||||
|
pathEl.classList.add('graph-edge-active');
|
||||||
|
|
||||||
|
// Animated dots flowing along the path
|
||||||
const d = pathEl.getAttribute('d');
|
const d = pathEl.getAttribute('d');
|
||||||
if (!d) continue;
|
if (!d) continue;
|
||||||
|
|
||||||
const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default;
|
const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default;
|
||||||
const flowG = svgEl('g', { class: 'graph-edge-flow' });
|
const flowG = svgEl('g', { class: 'graph-edge-flow' });
|
||||||
|
|
||||||
// Two dots staggered for smoother visual flow
|
|
||||||
for (const beginFrac of ['0s', '1s']) {
|
for (const beginFrac of ['0s', '1s']) {
|
||||||
const circle = svgEl('circle', { fill: color, opacity: '0.85' });
|
const circle = svgEl('circle', { fill: color, opacity: '0.9', r: '2.5' });
|
||||||
circle.setAttribute('r', '3');
|
|
||||||
const anim = document.createElementNS(SVG_NS, 'animateMotion');
|
const anim = document.createElementNS(SVG_NS, 'animateMotion');
|
||||||
anim.setAttribute('dur', '2s');
|
anim.setAttribute('dur', '2s');
|
||||||
anim.setAttribute('repeatCount', 'indefinite');
|
anim.setAttribute('repeatCount', 'indefinite');
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ function buildGraph(e) {
|
|||||||
function addNode(id, kind, name, subtype, extra = {}) {
|
function addNode(id, kind, name, subtype, extra = {}) {
|
||||||
if (!id || nodeIds.has(id)) return;
|
if (!id || nodeIds.has(id)) return;
|
||||||
nodeIds.add(id);
|
nodeIds.add(id);
|
||||||
nodes.push({ id, kind, name: name || id, subtype: subtype || '', ...extra });
|
nodes.push({ id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra });
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEdge(from, to, field, label = '') {
|
function addEdge(from, to, field, label = '') {
|
||||||
@@ -179,72 +179,72 @@ function buildGraph(e) {
|
|||||||
|
|
||||||
// 1. Devices
|
// 1. Devices
|
||||||
for (const d of e.devices || []) {
|
for (const d of e.devices || []) {
|
||||||
addNode(d.id, 'device', d.name, d.device_type);
|
addNode(d.id, 'device', d.name, d.device_type, { tags: d.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Capture templates
|
// 2. Capture templates
|
||||||
for (const t of e.captureTemplates || []) {
|
for (const t of e.captureTemplates || []) {
|
||||||
addNode(t.id, 'capture_template', t.name, t.engine_type);
|
addNode(t.id, 'capture_template', t.name, t.engine_type, { tags: t.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. PP templates
|
// 3. PP templates
|
||||||
for (const t of e.ppTemplates || []) {
|
for (const t of e.ppTemplates || []) {
|
||||||
addNode(t.id, 'pp_template', t.name, '');
|
addNode(t.id, 'pp_template', t.name, '', { tags: t.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Audio templates
|
// 4. Audio templates
|
||||||
for (const t of e.audioTemplates || []) {
|
for (const t of e.audioTemplates || []) {
|
||||||
addNode(t.id, 'audio_template', t.name, t.engine_type);
|
addNode(t.id, 'audio_template', t.name, t.engine_type, { tags: t.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Pattern templates
|
// 5. Pattern templates
|
||||||
for (const t of e.patternTemplates || []) {
|
for (const t of e.patternTemplates || []) {
|
||||||
addNode(t.id, 'pattern_template', t.name, '');
|
addNode(t.id, 'pattern_template', t.name, '', { tags: t.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Sync clocks
|
// 6. Sync clocks
|
||||||
for (const c of e.syncClocks || []) {
|
for (const c of e.syncClocks || []) {
|
||||||
addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false });
|
addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false, tags: c.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Picture sources
|
// 7. Picture sources
|
||||||
for (const s of e.pictureSources || []) {
|
for (const s of e.pictureSources || []) {
|
||||||
addNode(s.id, 'picture_source', s.name, s.stream_type);
|
addNode(s.id, 'picture_source', s.name, s.stream_type, { tags: s.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Audio sources
|
// 8. Audio sources
|
||||||
for (const s of e.audioSources || []) {
|
for (const s of e.audioSources || []) {
|
||||||
addNode(s.id, 'audio_source', s.name, s.source_type);
|
addNode(s.id, 'audio_source', s.name, s.source_type, { tags: s.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Value sources
|
// 9. Value sources
|
||||||
for (const s of e.valueSources || []) {
|
for (const s of e.valueSources || []) {
|
||||||
addNode(s.id, 'value_source', s.name, s.source_type);
|
addNode(s.id, 'value_source', s.name, s.source_type, { tags: s.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Color strip sources
|
// 10. Color strip sources
|
||||||
for (const s of e.colorStripSources || []) {
|
for (const s of e.colorStripSources || []) {
|
||||||
addNode(s.id, 'color_strip_source', s.name, s.source_type);
|
addNode(s.id, 'color_strip_source', s.name, s.source_type, { tags: s.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Output targets
|
// 11. Output targets
|
||||||
for (const t of e.outputTargets || []) {
|
for (const t of e.outputTargets || []) {
|
||||||
addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false });
|
addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false, tags: t.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12. Scene presets
|
// 12. Scene presets
|
||||||
for (const s of e.scenePresets || []) {
|
for (const s of e.scenePresets || []) {
|
||||||
addNode(s.id, 'scene_preset', s.name, '');
|
addNode(s.id, 'scene_preset', s.name, '', { tags: s.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 13. Automations
|
// 13. Automations
|
||||||
for (const a of e.automations || []) {
|
for (const a of e.automations || []) {
|
||||||
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false });
|
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false, tags: a.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 14. Color strip processing templates (CSPT)
|
// 14. Color strip processing templates (CSPT)
|
||||||
for (const t of e.csptTemplates || []) {
|
for (const t of e.csptTemplates || []) {
|
||||||
addNode(t.id, 'cspt', t.name, '');
|
addNode(t.id, 'cspt', t.name, '', { tags: t.tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edges ──
|
// ── Edges ──
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ import * as P from './icon-paths.js';
|
|||||||
|
|
||||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
// ── Port type → human-readable label ──
|
||||||
|
const PORT_LABELS = {
|
||||||
|
template: 'Template',
|
||||||
|
picture: 'Picture',
|
||||||
|
colorstrip: 'Strip',
|
||||||
|
value: 'Value',
|
||||||
|
audio: 'Audio',
|
||||||
|
clock: 'Clock',
|
||||||
|
scene: 'Scene',
|
||||||
|
device: 'Device',
|
||||||
|
default: 'Ref',
|
||||||
|
};
|
||||||
|
|
||||||
// ── Entity kind → default icon path data ──
|
// ── Entity kind → default icon path data ──
|
||||||
const KIND_ICONS = {
|
const KIND_ICONS = {
|
||||||
device: P.monitor,
|
device: P.monitor,
|
||||||
@@ -210,9 +223,17 @@ function renderNode(node, callbacks) {
|
|||||||
'data-port-dir': 'in',
|
'data-port-dir': 'in',
|
||||||
});
|
});
|
||||||
const tip = svgEl('title');
|
const tip = svgEl('title');
|
||||||
tip.textContent = t;
|
tip.textContent = PORT_LABELS[t] || t;
|
||||||
dot.appendChild(tip);
|
dot.appendChild(tip);
|
||||||
g.appendChild(dot);
|
g.appendChild(dot);
|
||||||
|
// Port label (shown on hover) — outside node, left of port
|
||||||
|
const lbl = svgEl('text', {
|
||||||
|
class: 'graph-port-label graph-port-label-in',
|
||||||
|
x: -8, y: py + 3,
|
||||||
|
'text-anchor': 'end',
|
||||||
|
});
|
||||||
|
lbl.textContent = PORT_LABELS[t] || t;
|
||||||
|
g.appendChild(lbl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,9 +251,16 @@ function renderNode(node, callbacks) {
|
|||||||
'data-port-dir': 'out',
|
'data-port-dir': 'out',
|
||||||
});
|
});
|
||||||
const tip = svgEl('title');
|
const tip = svgEl('title');
|
||||||
tip.textContent = t;
|
tip.textContent = PORT_LABELS[t] || t;
|
||||||
dot.appendChild(tip);
|
dot.appendChild(tip);
|
||||||
g.appendChild(dot);
|
g.appendChild(dot);
|
||||||
|
// Port label (shown on hover) — outside node, right of port
|
||||||
|
const lbl = svgEl('text', {
|
||||||
|
class: 'graph-port-label graph-port-label-out',
|
||||||
|
x: width + 8, y: py + 3,
|
||||||
|
});
|
||||||
|
lbl.textContent = PORT_LABELS[t] || t;
|
||||||
|
g.appendChild(lbl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,14 +326,14 @@ function renderNode(node, callbacks) {
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity kinds that support start/stop
|
// Entity kinds that support start/stop (including automation enable/disable)
|
||||||
const START_STOP_KINDS = new Set(['output_target', 'sync_clock']);
|
const START_STOP_KINDS = new Set(['output_target', 'sync_clock', 'automation']);
|
||||||
|
|
||||||
// Entity kinds that support test/preview
|
// Entity kinds that support test/preview
|
||||||
const TEST_KINDS = new Set([
|
const TEST_KINDS = new Set([
|
||||||
'capture_template', 'pp_template', 'audio_template',
|
'capture_template', 'pp_template', 'audio_template',
|
||||||
'picture_source', 'audio_source', 'value_source',
|
'picture_source', 'audio_source', 'value_source',
|
||||||
'color_strip_source',
|
'color_strip_source', 'cspt',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function _createOverlay(node, nodeWidth, callbacks) {
|
function _createOverlay(node, nodeWidth, callbacks) {
|
||||||
@@ -325,9 +353,14 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scene preset activation
|
||||||
|
if (node.kind === 'scene_preset') {
|
||||||
|
btns.push({ svgPath: P.play, action: 'activate', cls: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
// Test button for applicable kinds
|
// Test button for applicable kinds
|
||||||
if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) {
|
if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) {
|
||||||
btns.push({ svgPath: P.eye, action: 'test', cls: '' });
|
btns.push({ svgPath: P.flaskConical, action: 'test', cls: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification test for notification color strip sources
|
// Notification test for notification color strip sources
|
||||||
@@ -335,6 +368,9 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
|||||||
btns.push({ svgPath: P.bellRing, action: 'notify', cls: '' });
|
btns.push({ svgPath: P.bellRing, action: 'notify', cls: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone (smaller scale to fit the compact button)
|
||||||
|
btns.push({ svgPath: P.copy, action: 'clone', cls: '', scale: 0.6 });
|
||||||
|
|
||||||
// Always: edit and delete
|
// Always: edit and delete
|
||||||
btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎
|
btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎
|
||||||
btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖
|
btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖
|
||||||
@@ -360,8 +396,10 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
|||||||
|
|
||||||
const ACTION_LABELS = {
|
const ACTION_LABELS = {
|
||||||
startstop: node.running ? 'Stop' : 'Start',
|
startstop: node.running ? 'Stop' : 'Start',
|
||||||
|
activate: 'Activate preset',
|
||||||
test: 'Test / Preview',
|
test: 'Test / Preview',
|
||||||
notify: 'Test notification',
|
notify: 'Test notification',
|
||||||
|
clone: 'Clone',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
};
|
};
|
||||||
@@ -372,8 +410,11 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
|||||||
const bg = svgEl('g', { class: `graph-node-overlay-btn ${btn.cls}` });
|
const bg = svgEl('g', { class: `graph-node-overlay-btn ${btn.cls}` });
|
||||||
bg.appendChild(svgEl('rect', { x: bx, y: by, width: btnSize, height: btnSize }));
|
bg.appendChild(svgEl('rect', { x: bx, y: by, width: btnSize, height: btnSize }));
|
||||||
if (btn.svgPath) {
|
if (btn.svgPath) {
|
||||||
|
const s = btn.scale || ((btnSize - 4) / 24);
|
||||||
|
const iconSize = 24 * s;
|
||||||
|
const pad = (btnSize - iconSize) / 2;
|
||||||
const iconG = svgEl('g', {
|
const iconG = svgEl('g', {
|
||||||
transform: `translate(${bx + 2}, ${by + 2}) scale(${(btnSize - 4) / 24})`,
|
transform: `translate(${bx + pad}, ${by + pad}) scale(${s})`,
|
||||||
});
|
});
|
||||||
iconG.innerHTML = btn.svgPath;
|
iconG.innerHTML = btn.svgPath;
|
||||||
iconG.setAttribute('fill', 'none');
|
iconG.setAttribute('fill', 'none');
|
||||||
@@ -397,6 +438,8 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
|||||||
if (btn.action === 'startstop' && callbacks.onStartStopNode) callbacks.onStartStopNode(node);
|
if (btn.action === 'startstop' && callbacks.onStartStopNode) callbacks.onStartStopNode(node);
|
||||||
if (btn.action === 'test' && callbacks.onTestNode) callbacks.onTestNode(node);
|
if (btn.action === 'test' && callbacks.onTestNode) callbacks.onTestNode(node);
|
||||||
if (btn.action === 'notify' && callbacks.onNotificationTest) callbacks.onNotificationTest(node);
|
if (btn.action === 'notify' && callbacks.onNotificationTest) callbacks.onNotificationTest(node);
|
||||||
|
if (btn.action === 'clone' && callbacks.onCloneNode) callbacks.onCloneNode(node);
|
||||||
|
if (btn.action === 'activate' && callbacks.onActivatePreset) callbacks.onActivatePreset(node);
|
||||||
});
|
});
|
||||||
overlay.appendChild(bg);
|
overlay.appendChild(bg);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,6 +106,63 @@ function _isFullscreen() { return !!document.fullscreenElement; }
|
|||||||
|
|
||||||
// Toolbar position persisted in localStorage
|
// Toolbar position persisted in localStorage
|
||||||
const _TB_KEY = 'graph_toolbar';
|
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() {
|
function _loadToolbarPos() {
|
||||||
try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; }
|
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);
|
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() {
|
export function toggleGraphFilter() {
|
||||||
_filterVisible = !_filterVisible;
|
_filterVisible = !_filterVisible;
|
||||||
const bar = document.querySelector('.graph-filter');
|
const bar = document.querySelector('.graph-filter');
|
||||||
@@ -246,17 +369,20 @@ export function toggleGraphFilter() {
|
|||||||
if (_filterVisible) {
|
if (_filterVisible) {
|
||||||
const input = bar.querySelector('.graph-filter-input');
|
const input = bar.querySelector('.graph-filter-input');
|
||||||
if (input) { input.value = _filterQuery; input.focus(); }
|
if (input) { input.value = _filterQuery; input.focus(); }
|
||||||
// Restore pill active states
|
// Restore running pill states
|
||||||
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
|
|
||||||
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
|
|
||||||
});
|
|
||||||
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
|
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
|
||||||
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
|
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
|
||||||
});
|
});
|
||||||
|
_syncPopoverCheckboxes();
|
||||||
|
_updateFilterBadge();
|
||||||
} else {
|
} else {
|
||||||
_filterKinds.clear();
|
_filterKinds.clear();
|
||||||
_filterRunning = null;
|
_filterRunning = null;
|
||||||
|
// Close types popover
|
||||||
|
const popover = bar.querySelector('.graph-filter-types-popover');
|
||||||
|
if (popover) popover.classList.remove('visible');
|
||||||
_applyFilter('');
|
_applyFilter('');
|
||||||
|
_updateFilterBadge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,18 +395,35 @@ function _applyFilter(query) {
|
|||||||
|
|
||||||
if (!_nodeMap) return;
|
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 hasKindFilter = _filterKinds.size > 0;
|
||||||
const hasRunningFilter = _filterRunning !== null;
|
const hasRunningFilter = _filterRunning !== null;
|
||||||
const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter;
|
const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter || hasParsedKinds || hasParsedTags;
|
||||||
|
|
||||||
// Build set of matching node IDs
|
// Build set of matching node IDs
|
||||||
const matchIds = new Set();
|
const matchIds = new Set();
|
||||||
for (const node of _nodeMap.values()) {
|
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 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);
|
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
|
// 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: 'color_strip_source', fn: () => window.showCSSEditor?.(), icon: _ico(P.film) },
|
||||||
{ kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) },
|
{ kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) },
|
||||||
{ kind: 'automation', fn: () => window.openAutomationEditor?.(), icon: _ico(P.clipboardList) },
|
{ 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
|
// All caches to watch for new entity creation
|
||||||
@@ -509,6 +655,8 @@ function _renderGraph(container) {
|
|||||||
onStartStopNode: _onStartStopNode,
|
onStartStopNode: _onStartStopNode,
|
||||||
onTestNode: _onTestNode,
|
onTestNode: _onTestNode,
|
||||||
onNotificationTest: _onNotificationTest,
|
onNotificationTest: _onNotificationTest,
|
||||||
|
onCloneNode: _onCloneNode,
|
||||||
|
onActivatePreset: _onActivatePreset,
|
||||||
});
|
});
|
||||||
markOrphans(nodeGroup, _nodeMap, _edges);
|
markOrphans(nodeGroup, _nodeMap, _edges);
|
||||||
|
|
||||||
@@ -579,16 +727,39 @@ function _renderGraph(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity type pills
|
// Entity type checkboxes in popover
|
||||||
container.querySelectorAll('.graph-filter-pill[data-kind]').forEach(pill => {
|
container.querySelectorAll('.graph-filter-type-item input[type="checkbox"]').forEach(cb => {
|
||||||
pill.addEventListener('click', () => {
|
cb.addEventListener('change', () => {
|
||||||
const kind = pill.dataset.kind;
|
if (cb.checked) _filterKinds.add(cb.value);
|
||||||
if (_filterKinds.has(kind)) { _filterKinds.delete(kind); pill.classList.remove('active'); }
|
else _filterKinds.delete(cb.value);
|
||||||
else { _filterKinds.add(kind); pill.classList.add('active'); }
|
_updateFilterBadge();
|
||||||
_applyFilter();
|
_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
|
// Running/stopped pills
|
||||||
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => {
|
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => {
|
||||||
pill.addEventListener('click', () => {
|
pill.addEventListener('click', () => {
|
||||||
@@ -598,10 +769,10 @@ function _renderGraph(container) {
|
|||||||
pill.classList.remove('active');
|
pill.classList.remove('active');
|
||||||
} else {
|
} else {
|
||||||
_filterRunning = val;
|
_filterRunning = val;
|
||||||
// Deactivate sibling running pills, activate this one
|
|
||||||
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active'));
|
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active'));
|
||||||
pill.classList.add('active');
|
pill.classList.add('active');
|
||||||
}
|
}
|
||||||
|
_updateFilterBadge();
|
||||||
_applyFilter();
|
_applyFilter();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -611,13 +782,12 @@ function _renderGraph(container) {
|
|||||||
const bar = container.querySelector('.graph-filter');
|
const bar = container.querySelector('.graph-filter');
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.classList.add('visible');
|
bar.classList.add('visible');
|
||||||
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
|
_syncPopoverCheckboxes();
|
||||||
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
|
|
||||||
});
|
|
||||||
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
|
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
|
||||||
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
|
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
_updateFilterBadge();
|
||||||
_applyFilter(_filterQuery);
|
_applyFilter(_filterQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,13 +833,9 @@ function _graphHTML() {
|
|||||||
const mmRect = _loadMinimapRect();
|
const mmRect = _loadMinimapRect();
|
||||||
// Only set size from saved state; position is applied in _initMinimap via anchor logic
|
// 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;` : '';
|
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 `
|
return `
|
||||||
<div class="graph-container">
|
<div class="graph-container">
|
||||||
<div class="graph-toolbar" style="${tbStyle}">
|
<div class="graph-toolbar">
|
||||||
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
||||||
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
<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>
|
<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>
|
<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>
|
</button>
|
||||||
<span class="graph-toolbar-sep"></span>
|
<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')}">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -705,6 +878,9 @@ function _graphHTML() {
|
|||||||
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
|
<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>
|
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="graph-legend${_legendVisible ? ' visible' : ''}">
|
<div class="graph-legend${_legendVisible ? ' visible' : ''}">
|
||||||
@@ -727,14 +903,16 @@ function _graphHTML() {
|
|||||||
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
|
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
|
||||||
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">×</button>
|
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="graph-filter-pills">
|
<div class="graph-filter-row graph-filter-actions">
|
||||||
${Object.entries(ENTITY_LABELS).map(([kind, label]) =>
|
<button class="graph-filter-types-btn" onclick="toggleGraphFilterTypes(this)">
|
||||||
`<button class="graph-filter-pill" data-kind="${kind}" style="--pill-color:${ENTITY_COLORS[kind] || '#666'}" title="${label}">${label}</button>`
|
${t('graph.filter_types') || 'Types'} <span class="graph-filter-types-badge"></span>
|
||||||
).join('')}
|
</button>
|
||||||
<span class="graph-filter-sep"></span>
|
|
||||||
<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="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>
|
<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>
|
||||||
|
<div class="graph-filter-types-popover">
|
||||||
|
${_buildFilterGroupsHTML()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -946,18 +1124,112 @@ function _initResizeClamp(container) {
|
|||||||
if (_resizeObserver) _resizeObserver.disconnect();
|
if (_resizeObserver) _resizeObserver.disconnect();
|
||||||
_resizeObserver = new ResizeObserver(() => {
|
_resizeObserver = new ResizeObserver(() => {
|
||||||
_reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect);
|
_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-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);
|
_resizeObserver.observe(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Toolbar drag ── */
|
/* ── 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) {
|
function _initToolbarDrag(tbEl) {
|
||||||
if (!tbEl) return;
|
if (!tbEl) return;
|
||||||
|
const container = tbEl.closest('.graph-container');
|
||||||
|
if (!container) return;
|
||||||
const handle = tbEl.querySelector('.graph-toolbar-drag');
|
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),
|
audio_source: () => window.editAudioSource?.(node.id),
|
||||||
value_source: () => window.editValueSource?.(node.id),
|
value_source: () => window.editValueSource?.(node.id),
|
||||||
color_strip_source: () => window.showCSSEditor?.(node.id),
|
color_strip_source: () => window.showCSSEditor?.(node.id),
|
||||||
sync_clock: () => {},
|
sync_clock: () => window.editSyncClock?.(node.id),
|
||||||
output_target: () => window.showTargetEditor?.(node.id),
|
output_target: () => window.showTargetEditor?.(node.id),
|
||||||
|
cspt: () => window.editCSPT?.(node.id),
|
||||||
scene_preset: () => window.editScenePreset?.(node.id),
|
scene_preset: () => window.editScenePreset?.(node.id),
|
||||||
automation: () => window.openAutomationEditor?.(node.id),
|
automation: () => window.openAutomationEditor?.(node.id),
|
||||||
};
|
};
|
||||||
@@ -1043,10 +1316,63 @@ function _onDeleteNode(node) {
|
|||||||
output_target: () => window.deleteTarget?.(node.id),
|
output_target: () => window.deleteTarget?.(node.id),
|
||||||
scene_preset: () => window.deleteScenePreset?.(node.id),
|
scene_preset: () => window.deleteScenePreset?.(node.id),
|
||||||
automation: () => window.deleteAutomation?.(node.id),
|
automation: () => window.deleteAutomation?.(node.id),
|
||||||
|
cspt: () => window.deleteCSPT?.(node.id),
|
||||||
|
sync_clock: () => window.deleteSyncClock?.(node.id),
|
||||||
};
|
};
|
||||||
fnMap[node.kind]?.();
|
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) {
|
function _onStartStopNode(node) {
|
||||||
const newRunning = !node.running;
|
const newRunning = !node.running;
|
||||||
// Optimistic update — toggle UI immediately
|
// Optimistic update — toggle UI immediately
|
||||||
@@ -1073,6 +1399,17 @@ function _onStartStopNode(node) {
|
|||||||
_updateNodeRunning(node.id, !newRunning); // revert
|
_updateNodeRunning(node.id, !newRunning); // revert
|
||||||
}
|
}
|
||||||
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
|
}).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),
|
audio_source: () => window.testAudioSource?.(node.id),
|
||||||
value_source: () => window.testValueSource?.(node.id),
|
value_source: () => window.testValueSource?.(node.id),
|
||||||
color_strip_source: () => window.testColorStrip?.(node.id),
|
color_strip_source: () => window.testColorStrip?.(node.id),
|
||||||
|
cspt: () => window.testCSPT?.(node.id),
|
||||||
output_target: () => window.testKCTarget?.(node.id),
|
output_target: () => window.testKCTarget?.(node.id),
|
||||||
};
|
};
|
||||||
fnMap[node.kind]?.();
|
fnMap[node.kind]?.();
|
||||||
@@ -1132,7 +1470,7 @@ function _onKeydown(e) {
|
|||||||
_deselect(ng, eg);
|
_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 (e.key === 'Delete' && !inInput) {
|
||||||
if (_selectedEdge) {
|
if (_selectedEdge) {
|
||||||
_detachSelectedEdge();
|
_detachSelectedEdge();
|
||||||
@@ -1140,6 +1478,8 @@ function _onKeydown(e) {
|
|||||||
const nodeId = [..._selectedIds][0];
|
const nodeId = [..._selectedIds][0];
|
||||||
const node = _nodeMap.get(nodeId);
|
const node = _nodeMap.get(nodeId);
|
||||||
if (node) _onDeleteNode(node);
|
if (node) _onDeleteNode(node);
|
||||||
|
} else if (_selectedIds.size > 1) {
|
||||||
|
_bulkDeleteSelected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ctrl+A → select all
|
// Ctrl+A → select all
|
||||||
@@ -1156,6 +1496,16 @@ function _onKeydown(e) {
|
|||||||
if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) {
|
if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) {
|
||||||
graphAddEntity();
|
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
|
// Arrow keys / WASD → spatial navigation between nodes
|
||||||
if (_selectedIds.size <= 1 && !inInput) {
|
if (_selectedIds.size <= 1 && !inInput) {
|
||||||
const dir = _arrowDir(e);
|
const dir = _arrowDir(e);
|
||||||
@@ -1240,7 +1590,15 @@ function _navigateDirection(dir) {
|
|||||||
const ng = document.querySelector('.graph-nodes');
|
const ng = document.querySelector('.graph-nodes');
|
||||||
const eg = document.querySelector('.graph-edges');
|
const eg = document.querySelector('.graph-edges');
|
||||||
if (ng) updateSelection(ng, _selectedIds);
|
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);
|
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) ── */
|
/* ── Edge context menu (right-click to detach) ── */
|
||||||
|
|
||||||
function _onEdgeContextMenu(edgePath, e, container) {
|
function _onEdgeContextMenu(edgePath, e, container) {
|
||||||
|
|||||||
@@ -1512,7 +1512,7 @@
|
|||||||
"graph.add_entity": "Add entity",
|
"graph.add_entity": "Add entity",
|
||||||
"graph.color_picker": "Node color",
|
"graph.color_picker": "Node color",
|
||||||
"graph.filter": "Filter nodes",
|
"graph.filter": "Filter nodes",
|
||||||
"graph.filter_placeholder": "Filter by name...",
|
"graph.filter_placeholder": "Filter: name, type:x, tag:x",
|
||||||
"graph.filter_clear": "Clear filter",
|
"graph.filter_clear": "Clear filter",
|
||||||
"graph.filter_running": "Running",
|
"graph.filter_running": "Running",
|
||||||
"graph.filter_stopped": "Stopped",
|
"graph.filter_stopped": "Stopped",
|
||||||
@@ -1521,5 +1521,37 @@
|
|||||||
"graph.filter_group.strip": "Color Strip",
|
"graph.filter_group.strip": "Color Strip",
|
||||||
"graph.filter_group.audio": "Audio",
|
"graph.filter_group.audio": "Audio",
|
||||||
"graph.filter_group.targets": "Targets",
|
"graph.filter_group.targets": "Targets",
|
||||||
"graph.filter_group.other": "Other"
|
"graph.filter_group.other": "Other",
|
||||||
|
"graph.bulk_delete_confirm": "Delete {count} selected entities?",
|
||||||
|
"graph.nothing_to_undo": "Nothing to undo",
|
||||||
|
"graph.nothing_to_redo": "Nothing to redo",
|
||||||
|
"graph.help_title": "Keyboard Shortcuts",
|
||||||
|
"graph.help.search": "Search",
|
||||||
|
"graph.help.filter": "Filter",
|
||||||
|
"graph.help.add": "Add entity",
|
||||||
|
"graph.help.shortcuts": "Shortcuts",
|
||||||
|
"graph.help.delete": "Delete / Detach",
|
||||||
|
"graph.help.select_all": "Select all",
|
||||||
|
"graph.help.undo": "Undo",
|
||||||
|
"graph.help.redo": "Redo",
|
||||||
|
"graph.help.fullscreen": "Fullscreen",
|
||||||
|
"graph.help.deselect": "Deselect",
|
||||||
|
"graph.help.navigate": "Navigate nodes",
|
||||||
|
"graph.help.click": "Click",
|
||||||
|
"graph.help.click_desc": "Select node",
|
||||||
|
"graph.help.dblclick": "Double-click",
|
||||||
|
"graph.help.dblclick_desc": "Zoom to node",
|
||||||
|
"graph.help.shift_click": "Shift+Click",
|
||||||
|
"graph.help.shift_click_desc": "Multi-select",
|
||||||
|
"graph.help.shift_drag": "Shift+Drag",
|
||||||
|
"graph.help.shift_drag_desc": "Rubber-band select",
|
||||||
|
"graph.help.drag_node": "Drag node",
|
||||||
|
"graph.help.drag_node_desc": "Reposition",
|
||||||
|
"graph.help.drag_port": "Drag port",
|
||||||
|
"graph.help.drag_port_desc": "Connect entities",
|
||||||
|
"graph.help.right_click": "Right-click edge",
|
||||||
|
"graph.help.right_click_desc": "Detach connection",
|
||||||
|
"automation.enabled": "Automation enabled",
|
||||||
|
"automation.disabled": "Automation disabled",
|
||||||
|
"scene_preset.activated": "Preset activated"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1521,5 +1521,37 @@
|
|||||||
"graph.filter_group.strip": "Цвет. полосы",
|
"graph.filter_group.strip": "Цвет. полосы",
|
||||||
"graph.filter_group.audio": "Аудио",
|
"graph.filter_group.audio": "Аудио",
|
||||||
"graph.filter_group.targets": "Цели",
|
"graph.filter_group.targets": "Цели",
|
||||||
"graph.filter_group.other": "Другое"
|
"graph.filter_group.other": "Другое",
|
||||||
|
"graph.bulk_delete_confirm": "Удалить {count} выбранных сущностей?",
|
||||||
|
"graph.nothing_to_undo": "Нечего отменять",
|
||||||
|
"graph.nothing_to_redo": "Нечего повторять",
|
||||||
|
"graph.help_title": "Горячие клавиши",
|
||||||
|
"graph.help.search": "Поиск",
|
||||||
|
"graph.help.filter": "Фильтр",
|
||||||
|
"graph.help.add": "Добавить сущность",
|
||||||
|
"graph.help.shortcuts": "Горячие клавиши",
|
||||||
|
"graph.help.delete": "Удалить / Отсоединить",
|
||||||
|
"graph.help.select_all": "Выбрать все",
|
||||||
|
"graph.help.undo": "Отменить",
|
||||||
|
"graph.help.redo": "Повторить",
|
||||||
|
"graph.help.fullscreen": "Полный экран",
|
||||||
|
"graph.help.deselect": "Снять выбор",
|
||||||
|
"graph.help.navigate": "Навигация по узлам",
|
||||||
|
"graph.help.click": "Клик",
|
||||||
|
"graph.help.click_desc": "Выбрать узел",
|
||||||
|
"graph.help.dblclick": "Двойной клик",
|
||||||
|
"graph.help.dblclick_desc": "Приблизить к узлу",
|
||||||
|
"graph.help.shift_click": "Shift+Клик",
|
||||||
|
"graph.help.shift_click_desc": "Множественный выбор",
|
||||||
|
"graph.help.shift_drag": "Shift+Перетащить",
|
||||||
|
"graph.help.shift_drag_desc": "Выбор рамкой",
|
||||||
|
"graph.help.drag_node": "Перетащить узел",
|
||||||
|
"graph.help.drag_node_desc": "Переместить",
|
||||||
|
"graph.help.drag_port": "Перетащить порт",
|
||||||
|
"graph.help.drag_port_desc": "Соединить сущности",
|
||||||
|
"graph.help.right_click": "ПКМ по связи",
|
||||||
|
"graph.help.right_click_desc": "Отсоединить связь",
|
||||||
|
"automation.enabled": "Автоматизация включена",
|
||||||
|
"automation.disabled": "Автоматизация выключена",
|
||||||
|
"scene_preset.activated": "Пресет активирован"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1521,5 +1521,37 @@
|
|||||||
"graph.filter_group.strip": "色带",
|
"graph.filter_group.strip": "色带",
|
||||||
"graph.filter_group.audio": "音频",
|
"graph.filter_group.audio": "音频",
|
||||||
"graph.filter_group.targets": "目标",
|
"graph.filter_group.targets": "目标",
|
||||||
"graph.filter_group.other": "其他"
|
"graph.filter_group.other": "其他",
|
||||||
|
"graph.bulk_delete_confirm": "删除 {count} 个选中的实体?",
|
||||||
|
"graph.nothing_to_undo": "没有可撤销的操作",
|
||||||
|
"graph.nothing_to_redo": "没有可重做的操作",
|
||||||
|
"graph.help_title": "键盘快捷键",
|
||||||
|
"graph.help.search": "搜索",
|
||||||
|
"graph.help.filter": "筛选",
|
||||||
|
"graph.help.add": "添加实体",
|
||||||
|
"graph.help.shortcuts": "快捷键",
|
||||||
|
"graph.help.delete": "删除 / 断开",
|
||||||
|
"graph.help.select_all": "全选",
|
||||||
|
"graph.help.undo": "撤销",
|
||||||
|
"graph.help.redo": "重做",
|
||||||
|
"graph.help.fullscreen": "全屏",
|
||||||
|
"graph.help.deselect": "取消选择",
|
||||||
|
"graph.help.navigate": "节点导航",
|
||||||
|
"graph.help.click": "单击",
|
||||||
|
"graph.help.click_desc": "选择节点",
|
||||||
|
"graph.help.dblclick": "双击",
|
||||||
|
"graph.help.dblclick_desc": "缩放到节点",
|
||||||
|
"graph.help.shift_click": "Shift+单击",
|
||||||
|
"graph.help.shift_click_desc": "多选",
|
||||||
|
"graph.help.shift_drag": "Shift+拖拽",
|
||||||
|
"graph.help.shift_drag_desc": "框选",
|
||||||
|
"graph.help.drag_node": "拖拽节点",
|
||||||
|
"graph.help.drag_node_desc": "重新定位",
|
||||||
|
"graph.help.drag_port": "拖拽端口",
|
||||||
|
"graph.help.drag_port_desc": "连接实体",
|
||||||
|
"graph.help.right_click": "右键边线",
|
||||||
|
"graph.help.right_click_desc": "断开连接",
|
||||||
|
"automation.enabled": "自动化已启用",
|
||||||
|
"automation.disabled": "自动化已禁用",
|
||||||
|
"scene_preset.activated": "预设已激活"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user