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

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

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

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

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

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

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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 ──

View File

@@ -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);
});

View File

@@ -106,6 +106,63 @@ function _isFullscreen() { return !!document.fullscreenElement; }
// Toolbar position persisted in localStorage
const _TB_KEY = 'graph_toolbar';
const _TB_MARGIN = 12;
// 8 dock positions: tl, tc, tr, cl, cr, bl, bc, br
function _computeDockPositions(container, el) {
const cr = container.getBoundingClientRect();
const w = el.offsetWidth, h = el.offsetHeight;
const m = _TB_MARGIN;
return {
tl: { x: m, y: m },
tc: { x: (cr.width - w) / 2, y: m },
tr: { x: cr.width - w - m, y: m },
cl: { x: m, y: (cr.height - h) / 2 },
cr: { x: cr.width - w - m, y: (cr.height - h) / 2 },
bl: { x: m, y: cr.height - h - m },
bc: { x: (cr.width - w) / 2, y: cr.height - h - m },
br: { x: cr.width - w - m, y: cr.height - h - m },
};
}
function _nearestDock(container, el) {
const docks = _computeDockPositions(container, el);
const cx = el.offsetLeft + el.offsetWidth / 2;
const cy = el.offsetTop + el.offsetHeight / 2;
let best = 'tl', bestDist = Infinity;
for (const [key, pos] of Object.entries(docks)) {
const dx = (pos.x + el.offsetWidth / 2) - cx;
const dy = (pos.y + el.offsetHeight / 2) - cy;
const dist = dx * dx + dy * dy;
if (dist < bestDist) { bestDist = dist; best = key; }
}
return best;
}
function _isVerticalDock(dock) {
return dock === 'cl' || dock === 'cr';
}
function _applyToolbarDock(el, container, dock, animate = false) {
const isVert = _isVerticalDock(dock);
el.classList.toggle('vertical', isVert);
// Recompute positions after layout change
requestAnimationFrame(() => {
const docks = _computeDockPositions(container, el);
const pos = docks[dock];
if (!pos) return;
if (animate) {
el.style.transition = 'left 0.25s ease, top 0.25s ease';
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
setTimeout(() => { el.style.transition = ''; }, 260);
} else {
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
}
});
}
function _loadToolbarPos() {
try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; }
}
@@ -238,6 +295,72 @@ export function toggleGraphMinimap() {
if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible);
}
/* ── Filter type groups ── */
const _FILTER_GROUPS = [
{ key: 'capture', kinds: ['picture_source', 'capture_template', 'pp_template'] },
{ key: 'strip', kinds: ['color_strip_source', 'cspt'] },
{ key: 'audio', kinds: ['audio_source', 'audio_template'] },
{ key: 'targets', kinds: ['device', 'output_target', 'pattern_template'] },
{ key: 'other', kinds: ['value_source', 'sync_clock', 'automation', 'scene_preset'] },
];
function _buildFilterGroupsHTML() {
const groupLabels = {
capture: t('graph.filter_group.capture') || 'Capture',
strip: t('graph.filter_group.strip') || 'Color Strip',
audio: t('graph.filter_group.audio') || 'Audio',
targets: t('graph.filter_group.targets') || 'Targets',
other: t('graph.filter_group.other') || 'Other',
};
return _FILTER_GROUPS.map(g => {
const items = g.kinds.map(kind => {
const label = ENTITY_LABELS[kind] || kind;
const color = ENTITY_COLORS[kind] || '#666';
return `<label class="graph-filter-type-item" data-kind="${kind}">
<input type="checkbox" value="${kind}">
<span class="graph-filter-type-dot" style="background:${color}"></span>
<span>${label}</span>
</label>`;
}).join('');
return `<div class="graph-filter-type-group" data-group="${g.key}">
<div class="graph-filter-type-group-header" data-group-toggle="${g.key}">${groupLabels[g.key]}</div>
${items}
</div>`;
}).join('');
}
function _updateFilterBadge() {
const badge = document.querySelector('.graph-filter-types-badge');
if (!badge) return;
const count = _filterKinds.size;
badge.textContent = count > 0 ? String(count) : '';
badge.classList.toggle('visible', count > 0);
// Also update toolbar button
const btn = document.querySelector('.graph-filter-btn');
if (btn) btn.classList.toggle('active', count > 0 || _filterRunning !== null || !!_filterQuery);
}
function _syncPopoverCheckboxes() {
const popover = document.querySelector('.graph-filter-types-popover');
if (!popover) return;
popover.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = _filterKinds.has(cb.value);
});
}
export function toggleGraphFilterTypes(btn) {
const popover = document.querySelector('.graph-filter-types-popover');
if (!popover) return;
const isOpen = popover.classList.contains('visible');
if (isOpen) {
popover.classList.remove('visible');
} else {
_syncPopoverCheckboxes();
popover.classList.add('visible');
}
}
export function toggleGraphFilter() {
_filterVisible = !_filterVisible;
const bar = document.querySelector('.graph-filter');
@@ -246,17 +369,20 @@ export function toggleGraphFilter() {
if (_filterVisible) {
const input = bar.querySelector('.graph-filter-input');
if (input) { input.value = _filterQuery; input.focus(); }
// Restore pill active states
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
});
// Restore running pill states
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
});
_syncPopoverCheckboxes();
_updateFilterBadge();
} else {
_filterKinds.clear();
_filterRunning = null;
// Close types popover
const popover = bar.querySelector('.graph-filter-types-popover');
if (popover) popover.classList.remove('visible');
_applyFilter('');
_updateFilterBadge();
}
}
@@ -269,18 +395,35 @@ function _applyFilter(query) {
if (!_nodeMap) return;
const hasTextFilter = !!q;
// Parse structured filters: type:device, tag:foo, running:true
let textPart = q;
const parsedKinds = new Set();
const parsedTags = [];
const tokens = q.split(/\s+/);
const plainTokens = [];
for (const tok of tokens) {
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
else { plainTokens.push(tok); }
}
textPart = plainTokens.join(' ');
const hasTextFilter = !!textPart;
const hasParsedKinds = parsedKinds.size > 0;
const hasParsedTags = parsedTags.length > 0;
const hasKindFilter = _filterKinds.size > 0;
const hasRunningFilter = _filterRunning !== null;
const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter;
const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter || hasParsedKinds || hasParsedTags;
// Build set of matching node IDs
const matchIds = new Set();
for (const node of _nodeMap.values()) {
const textMatch = !hasTextFilter || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q);
const textMatch = !hasTextFilter || node.name.toLowerCase().includes(textPart) || node.kind.includes(textPart) || (node.subtype || '').toLowerCase().includes(textPart);
const kindMatch = !hasKindFilter || _filterKinds.has(node.kind);
const parsedKindMatch = !hasParsedKinds || parsedKinds.has(node.kind) || parsedKinds.has((node.subtype || ''));
const tagMatch = !hasParsedTags || parsedTags.every(t => (node.tags || []).some(nt => nt.toLowerCase().includes(t)));
const runMatch = !hasRunningFilter || (node.running === _filterRunning);
if (textMatch && kindMatch && runMatch) matchIds.add(node.id);
if (textMatch && kindMatch && parsedKindMatch && tagMatch && runMatch) matchIds.add(node.id);
}
// Apply filtered-out class to nodes
@@ -367,6 +510,9 @@ const ADD_ENTITY_MAP = [
{ kind: 'color_strip_source', fn: () => window.showCSSEditor?.(), icon: _ico(P.film) },
{ kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) },
{ kind: 'automation', fn: () => window.openAutomationEditor?.(), icon: _ico(P.clipboardList) },
{ kind: 'sync_clock', fn: () => window.showSyncClockModal?.(), icon: _ico(P.clock) },
{ kind: 'scene_preset', fn: () => window.editScenePreset?.(), icon: _ico(P.sparkles) },
{ kind: 'pattern_template', fn: () => window.showPatternTemplateEditor?.(),icon: _ico(P.fileText) },
];
// All caches to watch for new entity creation
@@ -509,6 +655,8 @@ function _renderGraph(container) {
onStartStopNode: _onStartStopNode,
onTestNode: _onTestNode,
onNotificationTest: _onNotificationTest,
onCloneNode: _onCloneNode,
onActivatePreset: _onActivatePreset,
});
markOrphans(nodeGroup, _nodeMap, _edges);
@@ -579,16 +727,39 @@ function _renderGraph(container) {
});
}
// Entity type pills
container.querySelectorAll('.graph-filter-pill[data-kind]').forEach(pill => {
pill.addEventListener('click', () => {
const kind = pill.dataset.kind;
if (_filterKinds.has(kind)) { _filterKinds.delete(kind); pill.classList.remove('active'); }
else { _filterKinds.add(kind); pill.classList.add('active'); }
// Entity type checkboxes in popover
container.querySelectorAll('.graph-filter-type-item input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
if (cb.checked) _filterKinds.add(cb.value);
else _filterKinds.delete(cb.value);
_updateFilterBadge();
_applyFilter();
});
});
// Group header toggles (click group label → toggle all in group)
container.querySelectorAll('[data-group-toggle]').forEach(header => {
header.addEventListener('click', () => {
const groupKey = header.dataset.groupToggle;
const group = _FILTER_GROUPS.find(g => g.key === groupKey);
if (!group) return;
const allActive = group.kinds.every(k => _filterKinds.has(k));
group.kinds.forEach(k => { if (allActive) _filterKinds.delete(k); else _filterKinds.add(k); });
_syncPopoverCheckboxes();
_updateFilterBadge();
_applyFilter();
});
});
// Close popover when clicking outside
container.addEventListener('click', (e) => {
const popover = container.querySelector('.graph-filter-types-popover');
if (!popover || !popover.classList.contains('visible')) return;
if (!e.target.closest('.graph-filter-types-popover') && !e.target.closest('.graph-filter-types-btn')) {
popover.classList.remove('visible');
}
});
// Running/stopped pills
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => {
pill.addEventListener('click', () => {
@@ -598,10 +769,10 @@ function _renderGraph(container) {
pill.classList.remove('active');
} else {
_filterRunning = val;
// Deactivate sibling running pills, activate this one
container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
}
_updateFilterBadge();
_applyFilter();
});
});
@@ -611,13 +782,12 @@ function _renderGraph(container) {
const bar = container.querySelector('.graph-filter');
if (bar) {
bar.classList.add('visible');
bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => {
p.classList.toggle('active', _filterKinds.has(p.dataset.kind));
});
_syncPopoverCheckboxes();
bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
});
}
_updateFilterBadge();
_applyFilter(_filterQuery);
}
@@ -663,13 +833,9 @@ function _graphHTML() {
const mmRect = _loadMinimapRect();
// Only set size from saved state; position is applied in _initMinimap via anchor logic
const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
// Toolbar position is applied in _initToolbarDrag via anchor logic
const tbPos = _loadToolbarPos();
const tbStyle = tbPos && !tbPos.anchor ? `left:${tbPos.left}px;top:${tbPos.top}px;` : '';
return `
<div class="graph-container">
<div class="graph-toolbar" style="${tbStyle}">
<div class="graph-toolbar">
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
<svg class="icon" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/></svg>
@@ -695,6 +861,13 @@ function _graphHTML() {
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" id="graph-undo-btn" onclick="graphUndo()" title="${t('graph.help.undo') || 'Undo'} (Ctrl+Z)" disabled>
<svg class="icon" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
</button>
<button class="btn-icon" id="graph-redo-btn" onclick="graphRedo()" title="${t('graph.help.redo') || 'Redo'} (Ctrl+Shift+Z)" disabled>
<svg class="icon" viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
@@ -705,6 +878,9 @@ function _graphHTML() {
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
<button class="btn-icon${_helpVisible ? ' active' : ''}" id="graph-help-toggle" onclick="toggleGraphHelp()" title="${t('graph.help_title') || 'Shortcuts'} (?)">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button>
</div>
<div class="graph-legend${_legendVisible ? ' visible' : ''}">
@@ -727,14 +903,16 @@ function _graphHTML() {
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">&times;</button>
</div>
<div class="graph-filter-pills">
${Object.entries(ENTITY_LABELS).map(([kind, label]) =>
`<button class="graph-filter-pill" data-kind="${kind}" style="--pill-color:${ENTITY_COLORS[kind] || '#666'}" title="${label}">${label}</button>`
).join('')}
<span class="graph-filter-sep"></span>
<div class="graph-filter-row graph-filter-actions">
<button class="graph-filter-types-btn" onclick="toggleGraphFilterTypes(this)">
${t('graph.filter_types') || 'Types'} <span class="graph-filter-types-badge"></span>
</button>
<button class="graph-filter-pill graph-filter-running" data-running="true" style="--pill-color:var(--success-color)" title="${t('graph.filter_running') || 'Running'}">${t('graph.filter_running') || 'Running'}</button>
<button class="graph-filter-pill graph-filter-running" data-running="false" style="--pill-color:var(--text-muted)" title="${t('graph.filter_stopped') || 'Stopped'}">${t('graph.filter_stopped') || 'Stopped'}</button>
</div>
<div class="graph-filter-types-popover">
${_buildFilterGroupsHTML()}
</div>
</div>
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
@@ -946,18 +1124,112 @@ function _initResizeClamp(container) {
if (_resizeObserver) _resizeObserver.disconnect();
_resizeObserver = new ResizeObserver(() => {
_reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect);
_reanchorPanel(container.querySelector('.graph-toolbar'), container, _loadToolbarPos);
_reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos);
_reanchorPanel(container.querySelector('.graph-help-panel.visible'), container, _loadHelpPos);
// Toolbar uses dock system, not anchor system
const tb = container.querySelector('.graph-toolbar');
if (tb) {
const saved = _loadToolbarPos();
const dock = saved?.dock || 'tl';
_applyToolbarDock(tb, container, dock, false);
}
});
_resizeObserver.observe(container);
}
/* ── Toolbar drag ── */
let _dockIndicators = null;
function _showDockIndicators(container) {
_hideDockIndicators();
const cr = container.getBoundingClientRect();
const m = _TB_MARGIN + 16; // offset from edges
// 8 dock positions as percentage-based fixed points
const positions = {
tl: { x: m, y: m },
tc: { x: cr.width / 2, y: m },
tr: { x: cr.width - m, y: m },
cl: { x: m, y: cr.height / 2 },
cr: { x: cr.width - m, y: cr.height / 2 },
bl: { x: m, y: cr.height - m },
bc: { x: cr.width / 2, y: cr.height - m },
br: { x: cr.width - m, y: cr.height - m },
};
const wrap = document.createElement('div');
wrap.className = 'graph-dock-indicators';
for (const [key, pos] of Object.entries(positions)) {
const dot = document.createElement('div');
dot.className = 'graph-dock-dot';
dot.dataset.dock = key;
dot.style.left = pos.x + 'px';
dot.style.top = pos.y + 'px';
wrap.appendChild(dot);
}
container.appendChild(wrap);
_dockIndicators = wrap;
}
function _updateDockHighlight(container, tbEl) {
if (!_dockIndicators) return;
const nearest = _nearestDock(container, tbEl);
_dockIndicators.querySelectorAll('.graph-dock-dot').forEach(d => {
d.classList.toggle('nearest', d.dataset.dock === nearest);
});
}
function _hideDockIndicators() {
if (_dockIndicators) { _dockIndicators.remove(); _dockIndicators = null; }
}
function _initToolbarDrag(tbEl) {
if (!tbEl) return;
const container = tbEl.closest('.graph-container');
if (!container) return;
const handle = tbEl.querySelector('.graph-toolbar-drag');
_makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos });
if (!handle) return;
// Restore saved dock position
const saved = _loadToolbarPos();
const dock = saved?.dock || 'tl';
_applyToolbarDock(tbEl, container, dock, false);
let dragStart = null, dragStartPos = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
// If vertical, temporarily switch to horizontal for free dragging
tbEl.classList.remove('vertical');
requestAnimationFrame(() => {
dragStart = { x: e.clientX, y: e.clientY };
dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop };
handle.classList.add('dragging');
handle.setPointerCapture(e.pointerId);
_showDockIndicators(container);
});
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
const cr = container.getBoundingClientRect();
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
let t = dragStartPos.top + (e.clientY - dragStart.y);
l = Math.max(0, Math.min(cr.width - ew, l));
t = Math.max(0, Math.min(cr.height - eh, t));
tbEl.style.left = l + 'px';
tbEl.style.top = t + 'px';
_updateDockHighlight(container, tbEl);
});
handle.addEventListener('pointerup', () => {
if (!dragStart) return;
dragStart = null;
handle.classList.remove('dragging');
_hideDockIndicators();
// Snap to nearest dock position
const newDock = _nearestDock(container, tbEl);
_applyToolbarDock(tbEl, container, newDock, true);
_saveToolbarPos({ dock: newDock });
});
}
@@ -1021,8 +1293,9 @@ function _onEditNode(node) {
audio_source: () => window.editAudioSource?.(node.id),
value_source: () => window.editValueSource?.(node.id),
color_strip_source: () => window.showCSSEditor?.(node.id),
sync_clock: () => {},
sync_clock: () => window.editSyncClock?.(node.id),
output_target: () => window.showTargetEditor?.(node.id),
cspt: () => window.editCSPT?.(node.id),
scene_preset: () => window.editScenePreset?.(node.id),
automation: () => window.openAutomationEditor?.(node.id),
};
@@ -1043,10 +1316,63 @@ function _onDeleteNode(node) {
output_target: () => window.deleteTarget?.(node.id),
scene_preset: () => window.deleteScenePreset?.(node.id),
automation: () => window.deleteAutomation?.(node.id),
cspt: () => window.deleteCSPT?.(node.id),
sync_clock: () => window.deleteSyncClock?.(node.id),
};
fnMap[node.kind]?.();
}
async function _bulkDeleteSelected() {
const count = _selectedIds.size;
if (count < 2) return;
const ok = await showConfirm(
(t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count))
);
if (!ok) return;
for (const id of _selectedIds) {
const node = _nodeMap.get(id);
if (node) _onDeleteNode(node);
}
_selectedIds.clear();
}
function _onCloneNode(node) {
const fnMap = {
device: () => window.cloneDevice?.(node.id),
capture_template: () => window.cloneCaptureTemplate?.(node.id),
pp_template: () => window.clonePPTemplate?.(node.id),
audio_template: () => window.cloneAudioTemplate?.(node.id),
pattern_template: () => window.clonePatternTemplate?.(node.id),
picture_source: () => window.cloneStream?.(node.id),
audio_source: () => window.cloneAudioSource?.(node.id),
value_source: () => window.cloneValueSource?.(node.id),
color_strip_source: () => window.cloneColorStrip?.(node.id),
output_target: () => window.cloneTarget?.(node.id),
scene_preset: () => window.cloneScenePreset?.(node.id),
automation: () => window.cloneAutomation?.(node.id),
cspt: () => window.cloneCSPT?.(node.id),
sync_clock: () => window.cloneSyncClock?.(node.id),
};
_watchForNewEntity();
fnMap[node.kind]?.();
}
async function _onActivatePreset(node) {
if (node.kind !== 'scene_preset') return;
try {
const resp = await fetchWithAuth(`/scene-presets/${node.id}/activate`, { method: 'POST' });
if (resp.ok) {
showToast(t('scene_preset.activated') || 'Preset activated', 'success');
setTimeout(() => loadGraphEditor(), 500);
} else {
const err = await resp.json().catch(() => ({}));
showToast(err.detail || 'Activation failed', 'error');
}
} catch (e) {
showToast(e.message, 'error');
}
}
function _onStartStopNode(node) {
const newRunning = !node.running;
// Optimistic update — toggle UI immediately
@@ -1073,6 +1399,17 @@ function _onStartStopNode(node) {
_updateNodeRunning(node.id, !newRunning); // revert
}
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
} else if (node.kind === 'automation') {
fetchWithAuth(`/automations/${node.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: newRunning }),
}).then(resp => {
if (resp.ok) {
showToast(t(newRunning ? 'automation.enabled' : 'automation.disabled') || (newRunning ? 'Enabled' : 'Disabled'), 'success');
} else {
_updateNodeRunning(node.id, !newRunning);
}
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
}
}
@@ -1105,6 +1442,7 @@ function _onTestNode(node) {
audio_source: () => window.testAudioSource?.(node.id),
value_source: () => window.testValueSource?.(node.id),
color_strip_source: () => window.testColorStrip?.(node.id),
cspt: () => window.testCSPT?.(node.id),
output_target: () => window.testKCTarget?.(node.id),
};
fnMap[node.kind]?.();
@@ -1132,7 +1470,7 @@ function _onKeydown(e) {
_deselect(ng, eg);
}
}
// Delete key → detach selected edge or delete single selected node
// Delete key → detach selected edge or delete selected node(s)
if (e.key === 'Delete' && !inInput) {
if (_selectedEdge) {
_detachSelectedEdge();
@@ -1140,6 +1478,8 @@ function _onKeydown(e) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
if (node) _onDeleteNode(node);
} else if (_selectedIds.size > 1) {
_bulkDeleteSelected();
}
}
// Ctrl+A → select all
@@ -1156,6 +1496,16 @@ function _onKeydown(e) {
if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) {
graphAddEntity();
}
// ? → keyboard shortcuts help
if (e.key === '?' && !inInput) {
e.preventDefault();
toggleGraphHelp();
}
// Ctrl+Z / Ctrl+Shift+Z → undo/redo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !inInput) {
e.preventDefault();
if (e.shiftKey) _redo(); else _undo();
}
// Arrow keys / WASD → spatial navigation between nodes
if (_selectedIds.size <= 1 && !inInput) {
const dir = _arrowDir(e);
@@ -1240,7 +1590,15 @@ function _navigateDirection(dir) {
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
if (ng) updateSelection(ng, _selectedIds);
if (eg && _edges) highlightChain(eg, bestNode.id, _edges);
if (eg && _edges) {
const chain = highlightChain(eg, bestNode.id, _edges);
// Dim non-chain nodes like _onNodeClick does
if (ng) {
ng.querySelectorAll('.graph-node').forEach(n => {
n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25';
});
}
}
if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true);
}
}
@@ -1697,6 +2055,122 @@ async function _doConnect(targetId, targetKind, field, sourceId) {
}
}
/* ── Undo / Redo ── */
const _undoStack = [];
const _redoStack = [];
const _MAX_UNDO = 30;
/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
export function pushUndoAction(action) {
_undoStack.push(action);
if (_undoStack.length > _MAX_UNDO) _undoStack.shift();
_redoStack.length = 0;
_updateUndoRedoButtons();
}
function _updateUndoRedoButtons() {
const undoBtn = document.getElementById('graph-undo-btn');
const redoBtn = document.getElementById('graph-redo-btn');
if (undoBtn) undoBtn.disabled = _undoStack.length === 0;
if (redoBtn) redoBtn.disabled = _redoStack.length === 0;
}
export async function graphUndo() { await _undo(); }
export async function graphRedo() { await _redo(); }
async function _undo() {
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
const action = _undoStack.pop();
try {
await action.undo();
_redoStack.push(action);
showToast(t('graph.undone') || `Undone: ${action.label}`, 'info');
_updateUndoRedoButtons();
await loadGraphEditor();
} catch (e) {
showToast(e.message, 'error');
_updateUndoRedoButtons();
}
}
async function _redo() {
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
const action = _redoStack.pop();
try {
await action.redo();
_undoStack.push(action);
showToast(t('graph.redone') || `Redone: ${action.label}`, 'info');
_updateUndoRedoButtons();
await loadGraphEditor();
} catch (e) {
showToast(e.message, 'error');
_updateUndoRedoButtons();
}
}
/* ── Keyboard shortcuts help ── */
let _helpVisible = false;
function _loadHelpPos() {
try {
const saved = JSON.parse(localStorage.getItem('graph_help_pos'));
return saved || { anchor: 'br', offsetX: 12, offsetY: 12 };
} catch { return { anchor: 'br', offsetX: 12, offsetY: 12 }; }
}
function _saveHelpPos(pos) {
localStorage.setItem('graph_help_pos', JSON.stringify(pos));
}
export function toggleGraphHelp() {
_helpVisible = !_helpVisible;
const helpBtn = document.getElementById('graph-help-toggle');
if (helpBtn) helpBtn.classList.toggle('active', _helpVisible);
let panel = document.querySelector('.graph-help-panel');
if (_helpVisible) {
if (!panel) {
const container = document.querySelector('#graph-editor-content .graph-container');
if (!container) return;
panel = document.createElement('div');
panel.className = 'graph-help-panel visible';
panel.innerHTML = `
<div class="graph-help-header">
<span>${t('graph.help_title')}</span>
</div>
<div class="graph-help-body">
<div class="graph-help-row"><kbd>/</kbd> <span>${t('graph.help.search')}</span></div>
<div class="graph-help-row"><kbd>F</kbd> <span>${t('graph.help.filter')}</span></div>
<div class="graph-help-row"><kbd>+</kbd> <span>${t('graph.help.add')}</span></div>
<div class="graph-help-row"><kbd>?</kbd> <span>${t('graph.help.shortcuts')}</span></div>
<div class="graph-help-row"><kbd>Del</kbd> <span>${t('graph.help.delete')}</span></div>
<div class="graph-help-row"><kbd>Ctrl+A</kbd> <span>${t('graph.help.select_all')}</span></div>
<div class="graph-help-row"><kbd>Ctrl+Z</kbd> <span>${t('graph.help.undo')}</span></div>
<div class="graph-help-row"><kbd>Ctrl+Shift+Z</kbd> <span>${t('graph.help.redo')}</span></div>
<div class="graph-help-row"><kbd>F11</kbd> <span>${t('graph.help.fullscreen')}</span></div>
<div class="graph-help-row"><kbd>Esc</kbd> <span>${t('graph.help.deselect')}</span></div>
<div class="graph-help-row"><kbd>\u2190\u2191\u2192\u2193</kbd> <span>${t('graph.help.navigate')}</span></div>
<div class="graph-help-sep"></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.click')}</span> <span>${t('graph.help.click_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.dblclick')}</span> <span>${t('graph.help.dblclick_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_click')}</span> <span>${t('graph.help.shift_click_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_drag')}</span> <span>${t('graph.help.shift_drag_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_node')}</span> <span>${t('graph.help.drag_node_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_port')}</span> <span>${t('graph.help.drag_port_desc')}</span></div>
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.right_click')}</span> <span>${t('graph.help.right_click_desc')}</span></div>
</div>`;
container.appendChild(panel);
// Make draggable with anchor persistence
const header = panel.querySelector('.graph-help-header');
_makeDraggable(panel, header, { loadFn: _loadHelpPos, saveFn: _saveHelpPos });
} else {
panel.classList.add('visible');
}
} else if (panel) {
panel.classList.remove('visible');
}
}
/* ── Edge context menu (right-click to detach) ── */
function _onEdgeContextMenu(edgePath, e, container) {

View File

@@ -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"
}

View File

@@ -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": "Пресет активирован"
}

View File

@@ -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": "预设已激活"
}