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 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
|
||||
@@ -159,10 +159,11 @@
|
||||
}
|
||||
|
||||
.dashboard-metric-value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color);
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.dashboard-metric-label {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/* ── Graph Editor ─────────────────────────────────────── */
|
||||
|
||||
/* 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 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@@ -42,12 +51,66 @@
|
||||
margin-right: 2px;
|
||||
padding-right: 2px;
|
||||
letter-spacing: -1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.graph-toolbar-drag:active {
|
||||
.graph-toolbar-drag:active,
|
||||
.graph-toolbar-drag.dragging {
|
||||
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 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -72,6 +135,12 @@
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
|
||||
.graph-toolbar .btn-icon:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-toolbar-sep {
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
@@ -361,11 +430,38 @@
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.graph-node:hover .graph-port {
|
||||
.graph-node:hover .graph-port,
|
||||
.graph-node.selected .graph-port {
|
||||
r: 5;
|
||||
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 */
|
||||
.graph-port-out {
|
||||
cursor: crosshair;
|
||||
@@ -441,18 +537,22 @@
|
||||
.graph-edge-scene { stroke: #CE93D8; color: #CE93D8; }
|
||||
.graph-edge-default { stroke: var(--text-muted); color: var(--text-muted); }
|
||||
|
||||
/* Animated flow dots on active edges */
|
||||
.graph-edge-flow {
|
||||
fill: none;
|
||||
stroke-width: 0;
|
||||
pointer-events: none;
|
||||
/* ── Active edge flow (hybrid: thicker + saturated + animated dash) ── */
|
||||
|
||||
.graph-edge.graph-edge-active {
|
||||
opacity: 1;
|
||||
stroke-width: 2.5;
|
||||
filter: drop-shadow(0 0 3px currentColor);
|
||||
}
|
||||
|
||||
.graph-edge-flow circle {
|
||||
r: 3;
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 2px currentColor);
|
||||
}
|
||||
/* Clear dash patterns on active edges so glow looks clean */
|
||||
.graph-edge-clock.graph-edge-active,
|
||||
.graph-edge-template.graph-edge-active { stroke-dasharray: none; }
|
||||
.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 ── */
|
||||
|
||||
@@ -513,7 +613,8 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-node:hover .graph-node-overlay {
|
||||
.graph-node:hover .graph-node-overlay,
|
||||
.graph-node.selected .graph-node-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -727,11 +828,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.graph-filter-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.graph-filter-actions {
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.graph-filter-pill {
|
||||
@@ -757,11 +855,116 @@
|
||||
border-color: var(--pill-color, var(--primary-color));
|
||||
}
|
||||
|
||||
.graph-filter-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border-color);
|
||||
margin: 0 2px;
|
||||
/* ── Types button + popover ── */
|
||||
|
||||
.graph-filter-types-btn {
|
||||
background: transparent;
|
||||
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 {
|
||||
@@ -895,3 +1098,77 @@
|
||||
z-index: 50;
|
||||
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
|
||||
import {
|
||||
loadGraphEditor,
|
||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes,
|
||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
|
||||
graphToggleFullscreen, graphAddEntity,
|
||||
} from './features/graph-editor.js';
|
||||
@@ -487,6 +487,9 @@ Object.assign(window, {
|
||||
toggleGraphMinimap,
|
||||
toggleGraphFilter,
|
||||
toggleGraphFilterTypes,
|
||||
toggleGraphHelp,
|
||||
graphUndo,
|
||||
graphRedo,
|
||||
graphFitAll,
|
||||
graphZoomIn,
|
||||
graphZoomOut,
|
||||
|
||||
@@ -30,6 +30,22 @@ export class GraphCanvas {
|
||||
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). */
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -150,14 +166,26 @@ export class GraphCanvas {
|
||||
this._zoomAnim = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
zoomIn() { this.zoomTo(this._zoom * 1.25); }
|
||||
zoomOut() { this.zoomTo(this._zoom / 1.25); }
|
||||
zoomIn() { this._buttonZoomKick(0.06); }
|
||||
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() {
|
||||
for (const [el, ev, fn, opts] of this._listeners) {
|
||||
el.removeEventListener(ev, fn, opts);
|
||||
}
|
||||
this._listeners = [];
|
||||
if (this._resizeObs) { this._resizeObs.disconnect(); this._resizeObs = null; }
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
@@ -172,17 +200,104 @@ export class GraphCanvas {
|
||||
this._on(this.svg, 'pointerdown', this._onPointerDown.bind(this));
|
||||
this._on(window, 'pointermove', this._onPointerMove.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) {
|
||||
e.preventDefault();
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
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
|
||||
if (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
@@ -190,11 +305,12 @@ export class GraphCanvas {
|
||||
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) {
|
||||
const onNode = e.target.closest('.graph-node');
|
||||
if (!onNode) {
|
||||
this._panPending = true;
|
||||
this._panDeadZone = deadZone;
|
||||
this._panStart = { x: e.clientX, y: e.clientY };
|
||||
this._panViewStart = { x: this._vx, y: this._vy };
|
||||
}
|
||||
@@ -202,11 +318,37 @@ export class GraphCanvas {
|
||||
}
|
||||
|
||||
_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) {
|
||||
const dx = e.clientX - this._panStart.x;
|
||||
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.svg.classList.add('panning');
|
||||
this.svg.setPointerCapture(e.pointerId);
|
||||
@@ -221,15 +363,50 @@ export class GraphCanvas {
|
||||
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;
|
||||
if (this._panning) {
|
||||
this._panning = false;
|
||||
this._justPanned = true;
|
||||
this.svg.classList.remove('panning');
|
||||
// Clear justPanned after the click event fires (next microtask + rAF)
|
||||
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) {
|
||||
|
||||
@@ -241,7 +241,10 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
|
||||
* @param {Set<string>} runningIds - IDs of currently running nodes
|
||||
*/
|
||||
export function renderFlowDots(group, edges, runningIds) {
|
||||
// Clear previous flow state
|
||||
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;
|
||||
|
||||
// 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 pathEl = pathIndex.get(`${edge.from}|${edge.to}|${edge.field || ''}`);
|
||||
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');
|
||||
if (!d) continue;
|
||||
|
||||
const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default;
|
||||
const flowG = svgEl('g', { class: 'graph-edge-flow' });
|
||||
|
||||
// Two dots staggered for smoother visual flow
|
||||
for (const beginFrac of ['0s', '1s']) {
|
||||
const circle = svgEl('circle', { fill: color, opacity: '0.85' });
|
||||
circle.setAttribute('r', '3');
|
||||
const circle = svgEl('circle', { fill: color, opacity: '0.9', r: '2.5' });
|
||||
const anim = document.createElementNS(SVG_NS, 'animateMotion');
|
||||
anim.setAttribute('dur', '2s');
|
||||
anim.setAttribute('repeatCount', 'indefinite');
|
||||
|
||||
@@ -162,7 +162,7 @@ function buildGraph(e) {
|
||||
function addNode(id, kind, name, subtype, extra = {}) {
|
||||
if (!id || nodeIds.has(id)) return;
|
||||
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 = '') {
|
||||
@@ -179,72 +179,72 @@ function buildGraph(e) {
|
||||
|
||||
// 1. 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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)
|
||||
for (const t of e.csptTemplates || []) {
|
||||
addNode(t.id, 'cspt', t.name, '');
|
||||
addNode(t.id, 'cspt', t.name, '', { tags: t.tags });
|
||||
}
|
||||
|
||||
// ── Edges ──
|
||||
|
||||
@@ -9,6 +9,19 @@ import * as P from './icon-paths.js';
|
||||
|
||||
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 ──
|
||||
const KIND_ICONS = {
|
||||
device: P.monitor,
|
||||
@@ -210,9 +223,17 @@ function renderNode(node, callbacks) {
|
||||
'data-port-dir': 'in',
|
||||
});
|
||||
const tip = svgEl('title');
|
||||
tip.textContent = t;
|
||||
tip.textContent = PORT_LABELS[t] || t;
|
||||
dot.appendChild(tip);
|
||||
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',
|
||||
});
|
||||
const tip = svgEl('title');
|
||||
tip.textContent = t;
|
||||
tip.textContent = PORT_LABELS[t] || t;
|
||||
dot.appendChild(tip);
|
||||
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;
|
||||
}
|
||||
|
||||
// Entity kinds that support start/stop
|
||||
const START_STOP_KINDS = new Set(['output_target', 'sync_clock']);
|
||||
// Entity kinds that support start/stop (including automation enable/disable)
|
||||
const START_STOP_KINDS = new Set(['output_target', 'sync_clock', 'automation']);
|
||||
|
||||
// Entity kinds that support test/preview
|
||||
const TEST_KINDS = new Set([
|
||||
'capture_template', 'pp_template', 'audio_template',
|
||||
'picture_source', 'audio_source', 'value_source',
|
||||
'color_strip_source',
|
||||
'color_strip_source', 'cspt',
|
||||
]);
|
||||
|
||||
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
|
||||
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
|
||||
@@ -335,6 +368,9 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
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
|
||||
btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎
|
||||
btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖
|
||||
@@ -360,8 +396,10 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
|
||||
const ACTION_LABELS = {
|
||||
startstop: node.running ? 'Stop' : 'Start',
|
||||
activate: 'Activate preset',
|
||||
test: 'Test / Preview',
|
||||
notify: 'Test notification',
|
||||
clone: 'Clone',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
};
|
||||
@@ -372,8 +410,11 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
const bg = svgEl('g', { class: `graph-node-overlay-btn ${btn.cls}` });
|
||||
bg.appendChild(svgEl('rect', { x: bx, y: by, width: btnSize, height: btnSize }));
|
||||
if (btn.svgPath) {
|
||||
const s = btn.scale || ((btnSize - 4) / 24);
|
||||
const iconSize = 24 * s;
|
||||
const pad = (btnSize - iconSize) / 2;
|
||||
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.setAttribute('fill', 'none');
|
||||
@@ -397,6 +438,8 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
if (btn.action === 'startstop' && callbacks.onStartStopNode) callbacks.onStartStopNode(node);
|
||||
if (btn.action === 'test' && callbacks.onTestNode) callbacks.onTestNode(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);
|
||||
});
|
||||
|
||||
@@ -106,6 +106,63 @@ function _isFullscreen() { return !!document.fullscreenElement; }
|
||||
|
||||
// Toolbar position persisted in localStorage
|
||||
const _TB_KEY = 'graph_toolbar';
|
||||
const _TB_MARGIN = 12;
|
||||
|
||||
// 8 dock positions: tl, tc, tr, cl, cr, bl, bc, br
|
||||
function _computeDockPositions(container, el) {
|
||||
const cr = container.getBoundingClientRect();
|
||||
const w = el.offsetWidth, h = el.offsetHeight;
|
||||
const m = _TB_MARGIN;
|
||||
return {
|
||||
tl: { x: m, y: m },
|
||||
tc: { x: (cr.width - w) / 2, y: m },
|
||||
tr: { x: cr.width - w - m, y: m },
|
||||
cl: { x: m, y: (cr.height - h) / 2 },
|
||||
cr: { x: cr.width - w - m, y: (cr.height - h) / 2 },
|
||||
bl: { x: m, y: cr.height - h - m },
|
||||
bc: { x: (cr.width - w) / 2, y: cr.height - h - m },
|
||||
br: { x: cr.width - w - m, y: cr.height - h - m },
|
||||
};
|
||||
}
|
||||
|
||||
function _nearestDock(container, el) {
|
||||
const docks = _computeDockPositions(container, el);
|
||||
const cx = el.offsetLeft + el.offsetWidth / 2;
|
||||
const cy = el.offsetTop + el.offsetHeight / 2;
|
||||
let best = 'tl', bestDist = Infinity;
|
||||
for (const [key, pos] of Object.entries(docks)) {
|
||||
const dx = (pos.x + el.offsetWidth / 2) - cx;
|
||||
const dy = (pos.y + el.offsetHeight / 2) - cy;
|
||||
const dist = dx * dx + dy * dy;
|
||||
if (dist < bestDist) { bestDist = dist; best = key; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function _isVerticalDock(dock) {
|
||||
return dock === 'cl' || dock === 'cr';
|
||||
}
|
||||
|
||||
function _applyToolbarDock(el, container, dock, animate = false) {
|
||||
const isVert = _isVerticalDock(dock);
|
||||
el.classList.toggle('vertical', isVert);
|
||||
// Recompute positions after layout change
|
||||
requestAnimationFrame(() => {
|
||||
const docks = _computeDockPositions(container, el);
|
||||
const pos = docks[dock];
|
||||
if (!pos) return;
|
||||
if (animate) {
|
||||
el.style.transition = 'left 0.25s ease, top 0.25s ease';
|
||||
el.style.left = pos.x + 'px';
|
||||
el.style.top = pos.y + 'px';
|
||||
setTimeout(() => { el.style.transition = ''; }, 260);
|
||||
} else {
|
||||
el.style.left = pos.x + 'px';
|
||||
el.style.top = pos.y + 'px';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _loadToolbarPos() {
|
||||
try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; }
|
||||
}
|
||||
@@ -238,6 +295,72 @@ export function toggleGraphMinimap() {
|
||||
if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible);
|
||||
}
|
||||
|
||||
/* ── Filter type groups ── */
|
||||
|
||||
const _FILTER_GROUPS = [
|
||||
{ key: 'capture', kinds: ['picture_source', 'capture_template', 'pp_template'] },
|
||||
{ key: 'strip', kinds: ['color_strip_source', 'cspt'] },
|
||||
{ key: 'audio', kinds: ['audio_source', 'audio_template'] },
|
||||
{ key: 'targets', kinds: ['device', 'output_target', 'pattern_template'] },
|
||||
{ key: 'other', kinds: ['value_source', 'sync_clock', 'automation', 'scene_preset'] },
|
||||
];
|
||||
|
||||
function _buildFilterGroupsHTML() {
|
||||
const groupLabels = {
|
||||
capture: t('graph.filter_group.capture') || 'Capture',
|
||||
strip: t('graph.filter_group.strip') || 'Color Strip',
|
||||
audio: t('graph.filter_group.audio') || 'Audio',
|
||||
targets: t('graph.filter_group.targets') || 'Targets',
|
||||
other: t('graph.filter_group.other') || 'Other',
|
||||
};
|
||||
return _FILTER_GROUPS.map(g => {
|
||||
const items = g.kinds.map(kind => {
|
||||
const label = ENTITY_LABELS[kind] || kind;
|
||||
const color = ENTITY_COLORS[kind] || '#666';
|
||||
return `<label class="graph-filter-type-item" data-kind="${kind}">
|
||||
<input type="checkbox" value="${kind}">
|
||||
<span class="graph-filter-type-dot" style="background:${color}"></span>
|
||||
<span>${label}</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
return `<div class="graph-filter-type-group" data-group="${g.key}">
|
||||
<div class="graph-filter-type-group-header" data-group-toggle="${g.key}">${groupLabels[g.key]}</div>
|
||||
${items}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _updateFilterBadge() {
|
||||
const badge = document.querySelector('.graph-filter-types-badge');
|
||||
if (!badge) return;
|
||||
const count = _filterKinds.size;
|
||||
badge.textContent = count > 0 ? String(count) : '';
|
||||
badge.classList.toggle('visible', count > 0);
|
||||
// Also update toolbar button
|
||||
const btn = document.querySelector('.graph-filter-btn');
|
||||
if (btn) btn.classList.toggle('active', count > 0 || _filterRunning !== null || !!_filterQuery);
|
||||
}
|
||||
|
||||
function _syncPopoverCheckboxes() {
|
||||
const popover = document.querySelector('.graph-filter-types-popover');
|
||||
if (!popover) return;
|
||||
popover.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = _filterKinds.has(cb.value);
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleGraphFilterTypes(btn) {
|
||||
const popover = document.querySelector('.graph-filter-types-popover');
|
||||
if (!popover) return;
|
||||
const isOpen = popover.classList.contains('visible');
|
||||
if (isOpen) {
|
||||
popover.classList.remove('visible');
|
||||
} else {
|
||||
_syncPopoverCheckboxes();
|
||||
popover.classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleGraphFilter() {
|
||||
_filterVisible = !_filterVisible;
|
||||
const bar = document.querySelector('.graph-filter');
|
||||
@@ -246,17 +369,20 @@ export function toggleGraphFilter() {
|
||||
if (_filterVisible) {
|
||||
const input = bar.querySelector('.graph-filter-input');
|
||||
if (input) { input.value = _filterQuery; input.focus(); }
|
||||
// Restore pill active states
|
||||
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
|
||||
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
|
||||
});
|
||||
// Restore running pill states
|
||||
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
|
||||
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
|
||||
});
|
||||
_syncPopoverCheckboxes();
|
||||
_updateFilterBadge();
|
||||
} else {
|
||||
_filterKinds.clear();
|
||||
_filterRunning = null;
|
||||
// Close types popover
|
||||
const popover = bar.querySelector('.graph-filter-types-popover');
|
||||
if (popover) popover.classList.remove('visible');
|
||||
_applyFilter('');
|
||||
_updateFilterBadge();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,18 +395,35 @@ function _applyFilter(query) {
|
||||
|
||||
if (!_nodeMap) return;
|
||||
|
||||
const hasTextFilter = !!q;
|
||||
// Parse structured filters: type:device, tag:foo, running:true
|
||||
let textPart = q;
|
||||
const parsedKinds = new Set();
|
||||
const parsedTags = [];
|
||||
const tokens = q.split(/\s+/);
|
||||
const plainTokens = [];
|
||||
for (const tok of tokens) {
|
||||
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
|
||||
else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
|
||||
else { plainTokens.push(tok); }
|
||||
}
|
||||
textPart = plainTokens.join(' ');
|
||||
|
||||
const hasTextFilter = !!textPart;
|
||||
const hasParsedKinds = parsedKinds.size > 0;
|
||||
const hasParsedTags = parsedTags.length > 0;
|
||||
const hasKindFilter = _filterKinds.size > 0;
|
||||
const hasRunningFilter = _filterRunning !== null;
|
||||
const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter;
|
||||
const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter || hasParsedKinds || hasParsedTags;
|
||||
|
||||
// Build set of matching node IDs
|
||||
const matchIds = new Set();
|
||||
for (const node of _nodeMap.values()) {
|
||||
const textMatch = !hasTextFilter || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q);
|
||||
const textMatch = !hasTextFilter || node.name.toLowerCase().includes(textPart) || node.kind.includes(textPart) || (node.subtype || '').toLowerCase().includes(textPart);
|
||||
const kindMatch = !hasKindFilter || _filterKinds.has(node.kind);
|
||||
const parsedKindMatch = !hasParsedKinds || parsedKinds.has(node.kind) || parsedKinds.has((node.subtype || ''));
|
||||
const tagMatch = !hasParsedTags || parsedTags.every(t => (node.tags || []).some(nt => nt.toLowerCase().includes(t)));
|
||||
const runMatch = !hasRunningFilter || (node.running === _filterRunning);
|
||||
if (textMatch && kindMatch && runMatch) matchIds.add(node.id);
|
||||
if (textMatch && kindMatch && parsedKindMatch && tagMatch && runMatch) matchIds.add(node.id);
|
||||
}
|
||||
|
||||
// Apply filtered-out class to nodes
|
||||
@@ -367,6 +510,9 @@ const ADD_ENTITY_MAP = [
|
||||
{ kind: 'color_strip_source', fn: () => window.showCSSEditor?.(), icon: _ico(P.film) },
|
||||
{ kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) },
|
||||
{ kind: 'automation', fn: () => window.openAutomationEditor?.(), icon: _ico(P.clipboardList) },
|
||||
{ kind: 'sync_clock', fn: () => window.showSyncClockModal?.(), icon: _ico(P.clock) },
|
||||
{ kind: 'scene_preset', fn: () => window.editScenePreset?.(), icon: _ico(P.sparkles) },
|
||||
{ kind: 'pattern_template', fn: () => window.showPatternTemplateEditor?.(),icon: _ico(P.fileText) },
|
||||
];
|
||||
|
||||
// All caches to watch for new entity creation
|
||||
@@ -509,6 +655,8 @@ function _renderGraph(container) {
|
||||
onStartStopNode: _onStartStopNode,
|
||||
onTestNode: _onTestNode,
|
||||
onNotificationTest: _onNotificationTest,
|
||||
onCloneNode: _onCloneNode,
|
||||
onActivatePreset: _onActivatePreset,
|
||||
});
|
||||
markOrphans(nodeGroup, _nodeMap, _edges);
|
||||
|
||||
@@ -579,16 +727,39 @@ function _renderGraph(container) {
|
||||
});
|
||||
}
|
||||
|
||||
// Entity type pills
|
||||
container.querySelectorAll('.graph-filter-pill[data-kind]').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
const kind = pill.dataset.kind;
|
||||
if (_filterKinds.has(kind)) { _filterKinds.delete(kind); pill.classList.remove('active'); }
|
||||
else { _filterKinds.add(kind); pill.classList.add('active'); }
|
||||
// Entity type checkboxes in popover
|
||||
container.querySelectorAll('.graph-filter-type-item input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) _filterKinds.add(cb.value);
|
||||
else _filterKinds.delete(cb.value);
|
||||
_updateFilterBadge();
|
||||
_applyFilter();
|
||||
});
|
||||
});
|
||||
|
||||
// Group header toggles (click group label → toggle all in group)
|
||||
container.querySelectorAll('[data-group-toggle]').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const groupKey = header.dataset.groupToggle;
|
||||
const group = _FILTER_GROUPS.find(g => g.key === groupKey);
|
||||
if (!group) return;
|
||||
const allActive = group.kinds.every(k => _filterKinds.has(k));
|
||||
group.kinds.forEach(k => { if (allActive) _filterKinds.delete(k); else _filterKinds.add(k); });
|
||||
_syncPopoverCheckboxes();
|
||||
_updateFilterBadge();
|
||||
_applyFilter();
|
||||
});
|
||||
});
|
||||
|
||||
// Close popover when clicking outside
|
||||
container.addEventListener('click', (e) => {
|
||||
const popover = container.querySelector('.graph-filter-types-popover');
|
||||
if (!popover || !popover.classList.contains('visible')) return;
|
||||
if (!e.target.closest('.graph-filter-types-popover') && !e.target.closest('.graph-filter-types-btn')) {
|
||||
popover.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Running/stopped pills
|
||||
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
@@ -598,10 +769,10 @@ function _renderGraph(container) {
|
||||
pill.classList.remove('active');
|
||||
} else {
|
||||
_filterRunning = val;
|
||||
// Deactivate sibling running pills, activate this one
|
||||
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active'));
|
||||
pill.classList.add('active');
|
||||
}
|
||||
_updateFilterBadge();
|
||||
_applyFilter();
|
||||
});
|
||||
});
|
||||
@@ -611,13 +782,12 @@ function _renderGraph(container) {
|
||||
const bar = container.querySelector('.graph-filter');
|
||||
if (bar) {
|
||||
bar.classList.add('visible');
|
||||
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
|
||||
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
|
||||
});
|
||||
_syncPopoverCheckboxes();
|
||||
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
|
||||
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
|
||||
});
|
||||
}
|
||||
_updateFilterBadge();
|
||||
_applyFilter(_filterQuery);
|
||||
}
|
||||
|
||||
@@ -663,13 +833,9 @@ function _graphHTML() {
|
||||
const mmRect = _loadMinimapRect();
|
||||
// Only set size from saved state; position is applied in _initMinimap via anchor logic
|
||||
const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
||||
// Toolbar position is applied in _initToolbarDrag via anchor logic
|
||||
const tbPos = _loadToolbarPos();
|
||||
const tbStyle = tbPos && !tbPos.anchor ? `left:${tbPos.left}px;top:${tbPos.top}px;` : '';
|
||||
|
||||
return `
|
||||
<div class="graph-container">
|
||||
<div class="graph-toolbar" style="${tbStyle}">
|
||||
<div class="graph-toolbar">
|
||||
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
||||
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/></svg>
|
||||
@@ -695,6 +861,13 @@ function _graphHTML() {
|
||||
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
|
||||
</button>
|
||||
<span class="graph-toolbar-sep"></span>
|
||||
<button class="btn-icon" id="graph-undo-btn" onclick="graphUndo()" title="${t('graph.help.undo') || 'Undo'} (Ctrl+Z)" disabled>
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="graph-redo-btn" onclick="graphRedo()" title="${t('graph.help.redo') || 'Redo'} (Ctrl+Shift+Z)" disabled>
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>
|
||||
</button>
|
||||
<span class="graph-toolbar-sep"></span>
|
||||
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||
</button>
|
||||
@@ -705,6 +878,9 @@ function _graphHTML() {
|
||||
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon${_helpVisible ? ' active' : ''}" id="graph-help-toggle" onclick="toggleGraphHelp()" title="${t('graph.help_title') || 'Shortcuts'} (?)">
|
||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="graph-legend${_legendVisible ? ' visible' : ''}">
|
||||
@@ -727,14 +903,16 @@ function _graphHTML() {
|
||||
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
|
||||
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">×</button>
|
||||
</div>
|
||||
<div class="graph-filter-pills">
|
||||
${Object.entries(ENTITY_LABELS).map(([kind, label]) =>
|
||||
`<button class="graph-filter-pill" data-kind="${kind}" style="--pill-color:${ENTITY_COLORS[kind] || '#666'}" title="${label}">${label}</button>`
|
||||
).join('')}
|
||||
<span class="graph-filter-sep"></span>
|
||||
<div class="graph-filter-row graph-filter-actions">
|
||||
<button class="graph-filter-types-btn" onclick="toggleGraphFilterTypes(this)">
|
||||
${t('graph.filter_types') || 'Types'} <span class="graph-filter-types-badge"></span>
|
||||
</button>
|
||||
<button class="graph-filter-pill graph-filter-running" data-running="true" style="--pill-color:var(--success-color)" title="${t('graph.filter_running') || 'Running'}">${t('graph.filter_running') || 'Running'}</button>
|
||||
<button class="graph-filter-pill graph-filter-running" data-running="false" style="--pill-color:var(--text-muted)" title="${t('graph.filter_stopped') || 'Stopped'}">${t('graph.filter_stopped') || 'Stopped'}</button>
|
||||
</div>
|
||||
<div class="graph-filter-types-popover">
|
||||
${_buildFilterGroupsHTML()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -946,18 +1124,112 @@ function _initResizeClamp(container) {
|
||||
if (_resizeObserver) _resizeObserver.disconnect();
|
||||
_resizeObserver = new ResizeObserver(() => {
|
||||
_reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect);
|
||||
_reanchorPanel(container.querySelector('.graph-toolbar'), container, _loadToolbarPos);
|
||||
_reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos);
|
||||
_reanchorPanel(container.querySelector('.graph-help-panel.visible'), container, _loadHelpPos);
|
||||
// Toolbar uses dock system, not anchor system
|
||||
const tb = container.querySelector('.graph-toolbar');
|
||||
if (tb) {
|
||||
const saved = _loadToolbarPos();
|
||||
const dock = saved?.dock || 'tl';
|
||||
_applyToolbarDock(tb, container, dock, false);
|
||||
}
|
||||
});
|
||||
_resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
/* ── Toolbar drag ── */
|
||||
|
||||
let _dockIndicators = null;
|
||||
|
||||
function _showDockIndicators(container) {
|
||||
_hideDockIndicators();
|
||||
const cr = container.getBoundingClientRect();
|
||||
const m = _TB_MARGIN + 16; // offset from edges
|
||||
// 8 dock positions as percentage-based fixed points
|
||||
const positions = {
|
||||
tl: { x: m, y: m },
|
||||
tc: { x: cr.width / 2, y: m },
|
||||
tr: { x: cr.width - m, y: m },
|
||||
cl: { x: m, y: cr.height / 2 },
|
||||
cr: { x: cr.width - m, y: cr.height / 2 },
|
||||
bl: { x: m, y: cr.height - m },
|
||||
bc: { x: cr.width / 2, y: cr.height - m },
|
||||
br: { x: cr.width - m, y: cr.height - m },
|
||||
};
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'graph-dock-indicators';
|
||||
for (const [key, pos] of Object.entries(positions)) {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'graph-dock-dot';
|
||||
dot.dataset.dock = key;
|
||||
dot.style.left = pos.x + 'px';
|
||||
dot.style.top = pos.y + 'px';
|
||||
wrap.appendChild(dot);
|
||||
}
|
||||
container.appendChild(wrap);
|
||||
_dockIndicators = wrap;
|
||||
}
|
||||
|
||||
function _updateDockHighlight(container, tbEl) {
|
||||
if (!_dockIndicators) return;
|
||||
const nearest = _nearestDock(container, tbEl);
|
||||
_dockIndicators.querySelectorAll('.graph-dock-dot').forEach(d => {
|
||||
d.classList.toggle('nearest', d.dataset.dock === nearest);
|
||||
});
|
||||
}
|
||||
|
||||
function _hideDockIndicators() {
|
||||
if (_dockIndicators) { _dockIndicators.remove(); _dockIndicators = null; }
|
||||
}
|
||||
|
||||
function _initToolbarDrag(tbEl) {
|
||||
if (!tbEl) return;
|
||||
const container = tbEl.closest('.graph-container');
|
||||
if (!container) return;
|
||||
const handle = tbEl.querySelector('.graph-toolbar-drag');
|
||||
_makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos });
|
||||
if (!handle) return;
|
||||
|
||||
// Restore saved dock position
|
||||
const saved = _loadToolbarPos();
|
||||
const dock = saved?.dock || 'tl';
|
||||
_applyToolbarDock(tbEl, container, dock, false);
|
||||
|
||||
let dragStart = null, dragStartPos = null;
|
||||
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
// If vertical, temporarily switch to horizontal for free dragging
|
||||
tbEl.classList.remove('vertical');
|
||||
requestAnimationFrame(() => {
|
||||
dragStart = { x: e.clientX, y: e.clientY };
|
||||
dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop };
|
||||
handle.classList.add('dragging');
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
_showDockIndicators(container);
|
||||
});
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!dragStart) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
|
||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
||||
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
||||
l = Math.max(0, Math.min(cr.width - ew, l));
|
||||
t = Math.max(0, Math.min(cr.height - eh, t));
|
||||
tbEl.style.left = l + 'px';
|
||||
tbEl.style.top = t + 'px';
|
||||
_updateDockHighlight(container, tbEl);
|
||||
});
|
||||
handle.addEventListener('pointerup', () => {
|
||||
if (!dragStart) return;
|
||||
dragStart = null;
|
||||
handle.classList.remove('dragging');
|
||||
_hideDockIndicators();
|
||||
// Snap to nearest dock position
|
||||
const newDock = _nearestDock(container, tbEl);
|
||||
_applyToolbarDock(tbEl, container, newDock, true);
|
||||
_saveToolbarPos({ dock: newDock });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1021,8 +1293,9 @@ function _onEditNode(node) {
|
||||
audio_source: () => window.editAudioSource?.(node.id),
|
||||
value_source: () => window.editValueSource?.(node.id),
|
||||
color_strip_source: () => window.showCSSEditor?.(node.id),
|
||||
sync_clock: () => {},
|
||||
sync_clock: () => window.editSyncClock?.(node.id),
|
||||
output_target: () => window.showTargetEditor?.(node.id),
|
||||
cspt: () => window.editCSPT?.(node.id),
|
||||
scene_preset: () => window.editScenePreset?.(node.id),
|
||||
automation: () => window.openAutomationEditor?.(node.id),
|
||||
};
|
||||
@@ -1043,10 +1316,63 @@ function _onDeleteNode(node) {
|
||||
output_target: () => window.deleteTarget?.(node.id),
|
||||
scene_preset: () => window.deleteScenePreset?.(node.id),
|
||||
automation: () => window.deleteAutomation?.(node.id),
|
||||
cspt: () => window.deleteCSPT?.(node.id),
|
||||
sync_clock: () => window.deleteSyncClock?.(node.id),
|
||||
};
|
||||
fnMap[node.kind]?.();
|
||||
}
|
||||
|
||||
async function _bulkDeleteSelected() {
|
||||
const count = _selectedIds.size;
|
||||
if (count < 2) return;
|
||||
const ok = await showConfirm(
|
||||
(t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count))
|
||||
);
|
||||
if (!ok) return;
|
||||
for (const id of _selectedIds) {
|
||||
const node = _nodeMap.get(id);
|
||||
if (node) _onDeleteNode(node);
|
||||
}
|
||||
_selectedIds.clear();
|
||||
}
|
||||
|
||||
function _onCloneNode(node) {
|
||||
const fnMap = {
|
||||
device: () => window.cloneDevice?.(node.id),
|
||||
capture_template: () => window.cloneCaptureTemplate?.(node.id),
|
||||
pp_template: () => window.clonePPTemplate?.(node.id),
|
||||
audio_template: () => window.cloneAudioTemplate?.(node.id),
|
||||
pattern_template: () => window.clonePatternTemplate?.(node.id),
|
||||
picture_source: () => window.cloneStream?.(node.id),
|
||||
audio_source: () => window.cloneAudioSource?.(node.id),
|
||||
value_source: () => window.cloneValueSource?.(node.id),
|
||||
color_strip_source: () => window.cloneColorStrip?.(node.id),
|
||||
output_target: () => window.cloneTarget?.(node.id),
|
||||
scene_preset: () => window.cloneScenePreset?.(node.id),
|
||||
automation: () => window.cloneAutomation?.(node.id),
|
||||
cspt: () => window.cloneCSPT?.(node.id),
|
||||
sync_clock: () => window.cloneSyncClock?.(node.id),
|
||||
};
|
||||
_watchForNewEntity();
|
||||
fnMap[node.kind]?.();
|
||||
}
|
||||
|
||||
async function _onActivatePreset(node) {
|
||||
if (node.kind !== 'scene_preset') return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${node.id}/activate`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
showToast(t('scene_preset.activated') || 'Preset activated', 'success');
|
||||
setTimeout(() => loadGraphEditor(), 500);
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || 'Activation failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _onStartStopNode(node) {
|
||||
const newRunning = !node.running;
|
||||
// Optimistic update — toggle UI immediately
|
||||
@@ -1073,6 +1399,17 @@ function _onStartStopNode(node) {
|
||||
_updateNodeRunning(node.id, !newRunning); // revert
|
||||
}
|
||||
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
|
||||
} else if (node.kind === 'automation') {
|
||||
fetchWithAuth(`/automations/${node.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: newRunning }),
|
||||
}).then(resp => {
|
||||
if (resp.ok) {
|
||||
showToast(t(newRunning ? 'automation.enabled' : 'automation.disabled') || (newRunning ? 'Enabled' : 'Disabled'), 'success');
|
||||
} else {
|
||||
_updateNodeRunning(node.id, !newRunning);
|
||||
}
|
||||
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,6 +1442,7 @@ function _onTestNode(node) {
|
||||
audio_source: () => window.testAudioSource?.(node.id),
|
||||
value_source: () => window.testValueSource?.(node.id),
|
||||
color_strip_source: () => window.testColorStrip?.(node.id),
|
||||
cspt: () => window.testCSPT?.(node.id),
|
||||
output_target: () => window.testKCTarget?.(node.id),
|
||||
};
|
||||
fnMap[node.kind]?.();
|
||||
@@ -1132,7 +1470,7 @@ function _onKeydown(e) {
|
||||
_deselect(ng, eg);
|
||||
}
|
||||
}
|
||||
// Delete key → detach selected edge or delete single selected node
|
||||
// Delete key → detach selected edge or delete selected node(s)
|
||||
if (e.key === 'Delete' && !inInput) {
|
||||
if (_selectedEdge) {
|
||||
_detachSelectedEdge();
|
||||
@@ -1140,6 +1478,8 @@ function _onKeydown(e) {
|
||||
const nodeId = [..._selectedIds][0];
|
||||
const node = _nodeMap.get(nodeId);
|
||||
if (node) _onDeleteNode(node);
|
||||
} else if (_selectedIds.size > 1) {
|
||||
_bulkDeleteSelected();
|
||||
}
|
||||
}
|
||||
// Ctrl+A → select all
|
||||
@@ -1156,6 +1496,16 @@ function _onKeydown(e) {
|
||||
if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) {
|
||||
graphAddEntity();
|
||||
}
|
||||
// ? → keyboard shortcuts help
|
||||
if (e.key === '?' && !inInput) {
|
||||
e.preventDefault();
|
||||
toggleGraphHelp();
|
||||
}
|
||||
// Ctrl+Z / Ctrl+Shift+Z → undo/redo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !inInput) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) _redo(); else _undo();
|
||||
}
|
||||
// Arrow keys / WASD → spatial navigation between nodes
|
||||
if (_selectedIds.size <= 1 && !inInput) {
|
||||
const dir = _arrowDir(e);
|
||||
@@ -1240,7 +1590,15 @@ function _navigateDirection(dir) {
|
||||
const ng = document.querySelector('.graph-nodes');
|
||||
const eg = document.querySelector('.graph-edges');
|
||||
if (ng) updateSelection(ng, _selectedIds);
|
||||
if (eg && _edges) highlightChain(eg, bestNode.id, _edges);
|
||||
if (eg && _edges) {
|
||||
const chain = highlightChain(eg, bestNode.id, _edges);
|
||||
// Dim non-chain nodes like _onNodeClick does
|
||||
if (ng) {
|
||||
ng.querySelectorAll('.graph-node').forEach(n => {
|
||||
n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25';
|
||||
});
|
||||
}
|
||||
}
|
||||
if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true);
|
||||
}
|
||||
}
|
||||
@@ -1697,6 +2055,122 @@ async function _doConnect(targetId, targetKind, field, sourceId) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Undo / Redo ── */
|
||||
|
||||
const _undoStack = [];
|
||||
const _redoStack = [];
|
||||
const _MAX_UNDO = 30;
|
||||
|
||||
/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
|
||||
export function pushUndoAction(action) {
|
||||
_undoStack.push(action);
|
||||
if (_undoStack.length > _MAX_UNDO) _undoStack.shift();
|
||||
_redoStack.length = 0;
|
||||
_updateUndoRedoButtons();
|
||||
}
|
||||
|
||||
function _updateUndoRedoButtons() {
|
||||
const undoBtn = document.getElementById('graph-undo-btn');
|
||||
const redoBtn = document.getElementById('graph-redo-btn');
|
||||
if (undoBtn) undoBtn.disabled = _undoStack.length === 0;
|
||||
if (redoBtn) redoBtn.disabled = _redoStack.length === 0;
|
||||
}
|
||||
|
||||
export async function graphUndo() { await _undo(); }
|
||||
export async function graphRedo() { await _redo(); }
|
||||
|
||||
async function _undo() {
|
||||
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
|
||||
const action = _undoStack.pop();
|
||||
try {
|
||||
await action.undo();
|
||||
_redoStack.push(action);
|
||||
showToast(t('graph.undone') || `Undone: ${action.label}`, 'info');
|
||||
_updateUndoRedoButtons();
|
||||
await loadGraphEditor();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
_updateUndoRedoButtons();
|
||||
}
|
||||
}
|
||||
|
||||
async function _redo() {
|
||||
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
|
||||
const action = _redoStack.pop();
|
||||
try {
|
||||
await action.redo();
|
||||
_undoStack.push(action);
|
||||
showToast(t('graph.redone') || `Redone: ${action.label}`, 'info');
|
||||
_updateUndoRedoButtons();
|
||||
await loadGraphEditor();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
_updateUndoRedoButtons();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Keyboard shortcuts help ── */
|
||||
|
||||
let _helpVisible = false;
|
||||
|
||||
function _loadHelpPos() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('graph_help_pos'));
|
||||
return saved || { anchor: 'br', offsetX: 12, offsetY: 12 };
|
||||
} catch { return { anchor: 'br', offsetX: 12, offsetY: 12 }; }
|
||||
}
|
||||
function _saveHelpPos(pos) {
|
||||
localStorage.setItem('graph_help_pos', JSON.stringify(pos));
|
||||
}
|
||||
|
||||
export function toggleGraphHelp() {
|
||||
_helpVisible = !_helpVisible;
|
||||
const helpBtn = document.getElementById('graph-help-toggle');
|
||||
if (helpBtn) helpBtn.classList.toggle('active', _helpVisible);
|
||||
let panel = document.querySelector('.graph-help-panel');
|
||||
if (_helpVisible) {
|
||||
if (!panel) {
|
||||
const container = document.querySelector('#graph-editor-content .graph-container');
|
||||
if (!container) return;
|
||||
panel = document.createElement('div');
|
||||
panel.className = 'graph-help-panel visible';
|
||||
panel.innerHTML = `
|
||||
<div class="graph-help-header">
|
||||
<span>${t('graph.help_title')}</span>
|
||||
</div>
|
||||
<div class="graph-help-body">
|
||||
<div class="graph-help-row"><kbd>/</kbd> <span>${t('graph.help.search')}</span></div>
|
||||
<div class="graph-help-row"><kbd>F</kbd> <span>${t('graph.help.filter')}</span></div>
|
||||
<div class="graph-help-row"><kbd>+</kbd> <span>${t('graph.help.add')}</span></div>
|
||||
<div class="graph-help-row"><kbd>?</kbd> <span>${t('graph.help.shortcuts')}</span></div>
|
||||
<div class="graph-help-row"><kbd>Del</kbd> <span>${t('graph.help.delete')}</span></div>
|
||||
<div class="graph-help-row"><kbd>Ctrl+A</kbd> <span>${t('graph.help.select_all')}</span></div>
|
||||
<div class="graph-help-row"><kbd>Ctrl+Z</kbd> <span>${t('graph.help.undo')}</span></div>
|
||||
<div class="graph-help-row"><kbd>Ctrl+Shift+Z</kbd> <span>${t('graph.help.redo')}</span></div>
|
||||
<div class="graph-help-row"><kbd>F11</kbd> <span>${t('graph.help.fullscreen')}</span></div>
|
||||
<div class="graph-help-row"><kbd>Esc</kbd> <span>${t('graph.help.deselect')}</span></div>
|
||||
<div class="graph-help-row"><kbd>\u2190\u2191\u2192\u2193</kbd> <span>${t('graph.help.navigate')}</span></div>
|
||||
<div class="graph-help-sep"></div>
|
||||
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.click')}</span> <span>${t('graph.help.click_desc')}</span></div>
|
||||
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.dblclick')}</span> <span>${t('graph.help.dblclick_desc')}</span></div>
|
||||
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_click')}</span> <span>${t('graph.help.shift_click_desc')}</span></div>
|
||||
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_drag')}</span> <span>${t('graph.help.shift_drag_desc')}</span></div>
|
||||
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_node')}</span> <span>${t('graph.help.drag_node_desc')}</span></div>
|
||||
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_port')}</span> <span>${t('graph.help.drag_port_desc')}</span></div>
|
||||
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.right_click')}</span> <span>${t('graph.help.right_click_desc')}</span></div>
|
||||
</div>`;
|
||||
container.appendChild(panel);
|
||||
// Make draggable with anchor persistence
|
||||
const header = panel.querySelector('.graph-help-header');
|
||||
_makeDraggable(panel, header, { loadFn: _loadHelpPos, saveFn: _saveHelpPos });
|
||||
} else {
|
||||
panel.classList.add('visible');
|
||||
}
|
||||
} else if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Edge context menu (right-click to detach) ── */
|
||||
|
||||
function _onEdgeContextMenu(edgePath, e, container) {
|
||||
|
||||
@@ -1512,7 +1512,7 @@
|
||||
"graph.add_entity": "Add entity",
|
||||
"graph.color_picker": "Node color",
|
||||
"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_running": "Running",
|
||||
"graph.filter_stopped": "Stopped",
|
||||
@@ -1521,5 +1521,37 @@
|
||||
"graph.filter_group.strip": "Color Strip",
|
||||
"graph.filter_group.audio": "Audio",
|
||||
"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.audio": "Аудио",
|
||||
"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.audio": "音频",
|
||||
"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