From b370bb7d75e38b34c14d42c3de1eb105458551eb Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 13 Mar 2026 17:15:33 +0300 Subject: [PATCH] Add interactive graph editor connections: port-based edges, drag-connect, and detach - Add visible typed ports on graph nodes (colored dots for each edge type) - Route edges to specific port positions instead of node center - Drag from output port to compatible input port to create/change connections - Right-click edge context menu with Disconnect option - Delete key detaches selected edge - Mark nested edges (composite layers, zones) as non-editable with dotted style - Add resolve_ref helper for empty-string sentinel to clear reference fields - Apply resolve_ref across all storage stores for consistent detach support - Add connection mapping module (graph-connections.js) with API field resolution - Add i18n keys for connection operations (en/ru/zh) Co-Authored-By: Claude Opus 4.6 --- .../static/css/graph-editor.css | 93 +++++-- .../static/js/core/graph-connections.js | 133 ++++++++++ .../static/js/core/graph-edges.js | 41 ++- .../static/js/core/graph-layout.js | 66 ++++- .../static/js/core/graph-nodes.js | 43 ++- .../static/js/features/graph-editor.js | 247 +++++++++++++++++- .../wled_controller/static/locales/en.json | 7 +- .../wled_controller/static/locales/ru.json | 7 +- .../wled_controller/static/locales/zh.json | 7 +- .../storage/audio_source_store.py | 18 +- .../storage/automation_store.py | 4 +- .../storage/color_strip_store.py | 7 +- .../storage/key_colors_output_target.py | 3 +- .../storage/picture_source_store.py | 10 +- server/src/wled_controller/storage/utils.py | 23 ++ .../storage/value_source_store.py | 5 +- .../storage/wled_output_target.py | 7 +- 17 files changed, 661 insertions(+), 60 deletions(-) create mode 100644 server/src/wled_controller/static/js/core/graph-connections.js create mode 100644 server/src/wled_controller/storage/utils.py diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index e914e42..3dd8f9c 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -336,32 +336,40 @@ /* ── Ports ── */ .graph-port { + stroke: var(--bg-color); + stroke-width: 2; + opacity: 0.85; + transition: r 0.15s, opacity 0.15s; + pointer-events: all; +} + +.graph-node:hover .graph-port { + r: 5; + opacity: 1; +} + +/* Port output cursor: draggable */ +.graph-port-out { cursor: crosshair; } -.graph-port circle { - fill: var(--bg-secondary); - stroke: var(--text-muted); - stroke-width: 1.5; - r: 5; - transition: fill 0.15s, stroke 0.15s, r 0.15s; +/* Port interaction states during connection drag */ +.graph-port-compatible { + r: 6 !important; + opacity: 1 !important; + cursor: pointer; + filter: drop-shadow(0 0 3px currentColor); } -.graph-port:hover circle { - fill: var(--primary-color); - stroke: var(--primary-color); - r: 6; +.graph-port-incompatible { + opacity: 0.15 !important; } -.graph-port.connected circle { - fill: var(--text-secondary); - stroke: var(--text-secondary); -} - -.graph-port-label { - fill: var(--text-muted); - font-size: 9px; - font-family: 'DM Sans', sans-serif; +.graph-port-drop-target { + r: 7 !important; + stroke: var(--primary-color) !important; + stroke-width: 3 !important; + filter: drop-shadow(0 0 6px var(--primary-color)); } /* ── Edges ── */ @@ -398,6 +406,12 @@ opacity: 0.12; } +/* Nested edges (composite layers, zones) — not drag-editable */ +.graph-edge-nested { + stroke-dasharray: 2 2; + opacity: 0.4; +} + /* Edge type colors */ .graph-edge-picture { stroke: #42A5F5; color: #42A5F5; } .graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; } @@ -433,6 +447,47 @@ pointer-events: none; } +/* ── Edge context menu ── */ + +.graph-edge-menu { + position: absolute; + z-index: 40; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 16px var(--shadow-color); + padding: 4px; + min-width: 120px; +} + +.graph-edge-menu-item { + display: block; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text-color); + font-size: 0.85rem; + font-family: inherit; + text-align: left; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s; +} + +.graph-edge-menu-item:hover { + background: var(--bg-secondary); +} + +.graph-edge-menu-item.danger { + color: var(--danger-color); +} + +.graph-edge-menu-item.danger:hover { + background: var(--danger-color); + color: var(--primary-contrast); +} + /* ── Hover overlay (action buttons) ── */ .graph-node-overlay { diff --git a/server/src/wled_controller/static/js/core/graph-connections.js b/server/src/wled_controller/static/js/core/graph-connections.js new file mode 100644 index 0000000..19720ba --- /dev/null +++ b/server/src/wled_controller/static/js/core/graph-connections.js @@ -0,0 +1,133 @@ +/** + * Graph connection editing — maps edge types to API fields and endpoints. + * Supports creating, changing, and detaching connections via the graph editor. + */ + +import { fetchWithAuth } from './api.js'; +import { + streamsCache, colorStripSourcesCache, valueSourcesCache, + audioSourcesCache, outputTargetsCache, automationsCacheObj, +} from './state.js'; + +/** + * Connection map: for each (targetKind, field) pair, defines: + * - sourceKind: which entity kind(s) can be the source + * - edgeType: the edge type for this connection + * - endpoint: the API endpoint pattern (use {id} for target ID) + * - cache: the DataCache to invalidate after update + * - nested: true if this field is inside a nested structure (not editable via drag) + */ +const CONNECTION_MAP = [ + // Picture sources + { targetKind: 'picture_source', field: 'capture_template_id', sourceKind: 'capture_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache }, + { targetKind: 'picture_source', field: 'source_stream_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/picture-sources/{id}', cache: streamsCache }, + { targetKind: 'picture_source', field: 'postprocessing_template_id', sourceKind: 'pp_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache }, + + // Audio sources + { targetKind: 'audio_source', field: 'audio_template_id', sourceKind: 'audio_template', edgeType: 'audio', endpoint: '/audio-sources/{id}', cache: audioSourcesCache }, + { targetKind: 'audio_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/audio-sources/{id}', cache: audioSourcesCache }, + + // Value sources + { targetKind: 'value_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, + { targetKind: 'value_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, + + // Color strip sources + { targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache }, + { targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache }, + { targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache }, + + // Output targets + { targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, + { targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, + { targetKind: 'output_target', field: 'brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, + { targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, + + // Automations + { targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj }, + { targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj }, + + // ── Nested fields (not drag-editable in V1) ── + { targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true }, + { targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, + { targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true }, + { targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true }, + { targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true }, + { targetKind: 'output_target', field: 'settings.brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, + { targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true }, +]; + +/** + * Check if an edge (by field name) is editable via drag-connect. + */ +export function isEditableEdge(field) { + const entry = CONNECTION_MAP.find(c => c.field === field); + return entry ? !entry.nested : false; +} + +/** + * Find the connection mapping for a given target kind and source kind. + * Returns the matching entry (or entries) from CONNECTION_MAP. + */ +export function findConnection(targetKind, sourceKind, edgeType) { + return CONNECTION_MAP.filter(c => + !c.nested && + c.targetKind === targetKind && + c.sourceKind === sourceKind && + (!edgeType || c.edgeType === edgeType) + ); +} + +/** + * Find compatible input port fields for a given source kind. + * Returns array of { targetKind, field, edgeType }. + */ +export function getCompatibleInputs(sourceKind) { + return CONNECTION_MAP + .filter(c => !c.nested && c.sourceKind === sourceKind) + .map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType })); +} + +/** + * Find the connection entry for a specific edge (by target kind and field). + */ +export function getConnectionByField(targetKind, field) { + return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested); +} + +/** + * Update a connection: set the reference field on the target entity. + * @param {string} targetId - The target entity's ID + * @param {string} targetKind - The target entity's kind + * @param {string} field - The field name to update + * @param {string|null} newSourceId - New source ID, or '' to detach + * @returns {Promise} success + */ +export async function updateConnection(targetId, targetKind, field, newSourceId) { + const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested); + if (!entry) return false; + + const url = entry.endpoint.replace('{id}', targetId); + const body = { [field]: newSourceId }; + + try { + const resp = await fetchWithAuth(url, { + method: 'PUT', + body: JSON.stringify(body), + }); + if (!resp.ok) return false; + // Invalidate the relevant cache so data refreshes + if (entry.cache) entry.cache.invalidate(); + return true; + } catch { + return false; + } +} + +/** + * Detach a connection (set field to null via empty-string sentinel). + */ +export async function detachConnection(targetId, targetKind, field) { + return updateConnection(targetId, targetKind, field, ''); +} + +export { CONNECTION_MAP }; diff --git a/server/src/wled_controller/static/js/core/graph-edges.js b/server/src/wled_controller/static/js/core/graph-edges.js index b95f3c2..4664d85 100644 --- a/server/src/wled_controller/static/js/core/graph-edges.js +++ b/server/src/wled_controller/static/js/core/graph-edges.js @@ -51,9 +51,16 @@ function _createArrowMarker(type) { } function _renderEdge(edge) { - const { from, to, type, points, fromNode, toNode, field } = edge; - const cssClass = `graph-edge graph-edge-${type}`; - const d = points ? _pointsToPath(points) : _defaultBezier(fromNode, toNode); + const { from, to, type, points, fromNode, toNode, field, editable } = edge; + const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`; + let d; + if (points) { + // Adjust ELK start/end points to match port positions + const adjusted = _adjustEndpoints(points, fromNode, toNode, edge.fromPortY, edge.toPortY); + d = _pointsToPath(adjusted); + } else { + d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY); + } const path = svgEl('path', { class: cssClass, @@ -104,13 +111,31 @@ function _pointsToPath(points) { } /** - * Fallback bezier when no ELK routing is available. + * Adjust ELK-routed start/end points to match port Y positions. */ -function _defaultBezier(fromNode, toNode) { +function _adjustEndpoints(points, fromNode, toNode, fromPortY, toPortY) { + if (points.length < 2) return points; + const result = points.map(p => ({ ...p })); + if (fromPortY != null) { + result[0].y = fromNode.y + fromPortY; + result[0].x = fromNode.x + fromNode.width; + } + if (toPortY != null) { + result[result.length - 1].y = toNode.y + toPortY; + result[result.length - 1].x = toNode.x; + } + return result; +} + +/** + * Fallback bezier when no ELK routing is available. + * Uses port Y offsets when provided, otherwise centers vertically. + */ +function _defaultBezier(fromNode, toNode, fromPortY, toPortY) { const x1 = fromNode.x + fromNode.width; - const y1 = fromNode.y + fromNode.height / 2; + const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2); const x2 = toNode.x; - const y2 = toNode.y + toNode.height / 2; + const y2 = toNode.y + (toPortY ?? toNode.height / 2); const dx = Math.abs(x2 - x1) * 0.4; return `M ${x1} ${y1} C ${x1 + dx} ${y1} ${x2 - dx} ${y2} ${x2} ${y2}`; } @@ -195,7 +220,7 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) { const toNode = nodeMap.get(edge.to); if (!fromNode || !toNode) continue; - const d = _defaultBezier(fromNode, toNode); + const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY); group.querySelectorAll(`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(pathEl => { if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) { pathEl.setAttribute('d', d); diff --git a/server/src/wled_controller/static/js/core/graph-layout.js b/server/src/wled_controller/static/js/core/graph-layout.js index ffb0512..f2a4f12 100644 --- a/server/src/wled_controller/static/js/core/graph-layout.js +++ b/server/src/wled_controller/static/js/core/graph-layout.js @@ -168,7 +168,9 @@ function buildGraph(e) { nodes.find(n => n.id === to)?.kind, field ); - edges.push({ from, to, field, label, type }); + // Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable + const editable = !field.includes('.'); + edges.push({ from, to, field, label, type, editable }); } // 1. Devices @@ -317,4 +319,66 @@ function buildGraph(e) { return { nodes, edges }; } +/* ── Port computation ── */ + +/** Canonical ordering of port types (top → bottom on the node). */ +const PORT_TYPE_ORDER = ['template', 'picture', 'colorstrip', 'value', 'audio', 'clock', 'scene', 'device', 'default']; + +/** + * Compute input/output port positions on every node from the edge list. + * Mutates edges (adds fromPortY, toPortY) and nodes (adds inputPorts, outputPorts). + */ +export function computePorts(nodeMap, edges) { + // Collect which port types each node needs (keyed by edge type) + const inputTypes = new Map(); // nodeId → Set + const outputTypes = new Map(); // nodeId → Set + + for (const edge of edges) { + if (!inputTypes.has(edge.to)) inputTypes.set(edge.to, new Set()); + inputTypes.get(edge.to).add(edge.type); + + if (!outputTypes.has(edge.from)) outputTypes.set(edge.from, new Set()); + outputTypes.get(edge.from).add(edge.type); + } + + // Sort port types and assign vertical positions + function assignPorts(typeSet, height) { + const types = [...typeSet].sort((a, b) => { + const ai = PORT_TYPE_ORDER.indexOf(a); + const bi = PORT_TYPE_ORDER.indexOf(b); + return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi); + }); + const ports = {}; + const n = types.length; + types.forEach((t, i) => { + ports[t] = height * (i + 1) / (n + 1); + }); + return { types, ports }; + } + + // Build port maps on nodes + for (const [id, node] of nodeMap) { + const inSet = inputTypes.get(id) || new Set(); + const outSet = outputTypes.get(id) || new Set(); + node.inputPorts = assignPorts(inSet, node.height); + node.outputPorts = assignPorts(outSet, node.height); + } + + // Annotate edges with port Y offsets + for (const edge of edges) { + const fromNode = nodeMap.get(edge.from); + const toNode = nodeMap.get(edge.to); + if (fromNode?.outputPorts?.ports) { + edge.fromPortY = fromNode.outputPorts.ports[edge.type] ?? fromNode.height / 2; + } else { + edge.fromPortY = fromNode ? fromNode.height / 2 : 0; + } + if (toNode?.inputPorts?.ports) { + edge.toPortY = toNode.inputPorts.ports[edge.type] ?? toNode.height / 2; + } else { + edge.toPortY = toNode ? toNode.height / 2 : 0; + } + } +} + export { NODE_WIDTH, NODE_HEIGHT }; diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index 5ac5730..0b14954 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -2,7 +2,8 @@ * SVG node rendering for the graph editor. */ -import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT } from './graph-layout.js'; +import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js'; +import { EDGE_COLORS } from './graph-edges.js'; import * as P from './icon-paths.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; @@ -110,6 +111,46 @@ function renderNode(node, callbacks) { }); g.appendChild(barCover); + // Input ports (left side) + if (node.inputPorts?.types) { + for (const t of node.inputPorts.types) { + const py = node.inputPorts.ports[t]; + const dot = svgEl('circle', { + class: `graph-port graph-port-in graph-port-${t}`, + cx: 0, cy: py, r: 4, + fill: EDGE_COLORS[t] || EDGE_COLORS.default, + 'data-node-id': id, + 'data-node-kind': kind, + 'data-port-type': t, + 'data-port-dir': 'in', + }); + const tip = svgEl('title'); + tip.textContent = t; + dot.appendChild(tip); + g.appendChild(dot); + } + } + + // Output ports (right side) + if (node.outputPorts?.types) { + for (const t of node.outputPorts.types) { + const py = node.outputPorts.ports[t]; + const dot = svgEl('circle', { + class: `graph-port graph-port-out graph-port-${t}`, + cx: width, cy: py, r: 4, + fill: EDGE_COLORS[t] || EDGE_COLORS.default, + 'data-node-id': id, + 'data-node-kind': kind, + 'data-port-type': t, + 'data-port-dir': 'out', + }); + const tip = svgEl('title'); + tip.textContent = t; + dot.appendChild(tip); + g.appendChild(dot); + } + } + // Entity icon (right side) const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind]; if (iconPaths) { diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index 9b6ac00..afaebac 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -3,7 +3,7 @@ */ import { GraphCanvas } from '../core/graph-canvas.js'; -import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; +import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js'; import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js'; import { @@ -16,6 +16,7 @@ import { import { fetchWithAuth } from '../core/api.js'; import { showToast } from '../core/ui.js'; import { t } from '../core/i18n.js'; +import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js'; let _canvas = null; let _nodeMap = null; @@ -42,6 +43,16 @@ let _manualPositions = new Map(); let _rubberBand = null; let _rubberBandListenersAdded = false; +// Port-drag connection state +let _connectState = null; // { sourceNodeId, sourceKind, portType, startPos, dragPath } +let _connectListenersAdded = false; + +// Edge context menu +let _edgeContextMenu = null; + +// Selected edge for Delete key detach +let _selectedEdge = null; // { from, to, field, targetKind } + // Minimap position/size persisted in localStorage const _MM_KEY = 'graph_minimap'; function _loadMinimapRect() { @@ -86,6 +97,7 @@ export async function loadGraphEditor() { // Apply manual position overrides from previous drag operations _applyManualPositions(nodes, edges); + computePorts(nodes, edges); _nodeMap = nodes; _edges = edges; _bounds = _calcBounds(nodes); @@ -229,6 +241,7 @@ function _renderGraph(container) { _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); _initNodeDrag(nodeGroup, edgeGroup); + _initPortDrag(svgEl, nodeGroup, edgeGroup); _initRubberBand(svgEl); // Edge click: select edge and its endpoints @@ -239,6 +252,15 @@ function _renderGraph(container) { _onEdgeClick(edgePath, nodeGroup, edgeGroup); }); + // Edge right-click: detach connection + edgeGroup.addEventListener('contextmenu', (e) => { + const edgePath = e.target.closest('.graph-edge'); + if (!edgePath) return; + e.preventDefault(); + e.stopPropagation(); + _onEdgeContextMenu(edgePath, e, container); + }); + const searchInput = container.querySelector('.graph-search-input'); if (searchInput) { searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value)); @@ -247,6 +269,7 @@ function _renderGraph(container) { // Deselect on click on empty space (not after a pan gesture) svgEl.addEventListener('click', (e) => { + _dismissEdgeContextMenu(); if (_canvas.wasPanning) return; if (e.shiftKey) return; // Shift+click reserved for rubber-band if (!e.target.closest('.graph-node')) { @@ -274,6 +297,7 @@ function _renderGraph(container) { function _deselect(nodeGroup, edgeGroup) { _selectedIds.clear(); + _selectedEdge = null; if (nodeGroup) { updateSelection(nodeGroup, _selectedIds); nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); @@ -766,11 +790,15 @@ function _onKeydown(e) { _deselect(ng, eg); } } - // Delete key → delete single selected node - if (e.key === 'Delete' && _selectedIds.size === 1) { - const nodeId = [..._selectedIds][0]; - const node = _nodeMap.get(nodeId); - if (node) _onDeleteNode(node); + // Delete key → detach selected edge or delete single selected node + if (e.key === 'Delete') { + if (_selectedEdge) { + _detachSelectedEdge(); + } else if (_selectedIds.size === 1) { + const nodeId = [..._selectedIds][0]; + const node = _nodeMap.get(nodeId); + if (node) _onDeleteNode(node); + } } // Ctrl+A → select all if ((e.ctrlKey || e.metaKey) && e.key === 'a') { @@ -797,6 +825,15 @@ function _selectAll() { function _onEdgeClick(edgePath, nodeGroup, edgeGroup) { const fromId = edgePath.getAttribute('data-from'); const toId = edgePath.getAttribute('data-to'); + const field = edgePath.getAttribute('data-field') || ''; + + // Track selected edge for Delete key detach + const toNode = _nodeMap?.get(toId); + if (toNode && isEditableEdge(field)) { + _selectedEdge = { from: fromId, to: toId, field, targetKind: toNode.kind }; + } else { + _selectedEdge = null; + } _selectedIds.clear(); _selectedIds.add(fromId); @@ -827,6 +864,7 @@ function _initNodeDrag(nodeGroup, edgeGroup) { const nodeEl = e.target.closest('.graph-node'); if (!nodeEl) return; if (e.target.closest('.graph-node-overlay-btn')) return; + if (e.target.closest('.graph-port-out')) return; // handled by port drag const nodeId = nodeEl.getAttribute('data-id'); const node = _nodeMap.get(nodeId); @@ -1089,6 +1127,203 @@ function _escHtml(s) { return d.innerHTML; } +/* ── Port drag (connect/reconnect) ── */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function _initPortDrag(svgEl, nodeGroup, edgeGroup) { + // Capture-phase on output ports to prevent node drag + nodeGroup.addEventListener('pointerdown', (e) => { + const port = e.target.closest('.graph-port-out'); + if (!port || e.button !== 0) return; + + e.stopPropagation(); + e.preventDefault(); + + const sourceNodeId = port.getAttribute('data-node-id'); + const sourceKind = port.getAttribute('data-node-kind'); + const portType = port.getAttribute('data-port-type'); + const sourceNode = _nodeMap?.get(sourceNodeId); + if (!sourceNode) return; + + // Compute start position in graph coords (output port = right side of node) + const portY = sourceNode.outputPorts?.ports?.[portType] ?? sourceNode.height / 2; + const startX = sourceNode.x + sourceNode.width; + const startY = sourceNode.y + portY; + + // Create temporary drag edge in SVG + const dragPath = document.createElementNS(SVG_NS, 'path'); + dragPath.setAttribute('class', 'graph-drag-edge'); + dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`); + const root = svgEl.querySelector('.graph-root'); + root.appendChild(dragPath); + + _connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath }; + + if (_canvas) _canvas.blockPan = true; + svgEl.classList.add('connecting'); + + // Highlight compatible input ports + const compatible = getCompatibleInputs(sourceKind); + const compatibleSet = new Set(compatible.map(c => `${c.targetKind}:${c.edgeType}`)); + + nodeGroup.querySelectorAll('.graph-port-in').forEach(p => { + const nKind = p.getAttribute('data-node-kind'); + const pType = p.getAttribute('data-port-type'); + const nId = p.getAttribute('data-node-id'); + // Don't connect to self + if (nId === sourceNodeId) { + p.classList.add('graph-port-incompatible'); + return; + } + if (compatibleSet.has(`${nKind}:${pType}`)) { + p.classList.add('graph-port-compatible'); + } else { + p.classList.add('graph-port-incompatible'); + } + }); + }, true); // capture phase to beat node drag + + if (!_connectListenersAdded) { + window.addEventListener('pointermove', _onConnectPointerMove); + window.addEventListener('pointerup', _onConnectPointerUp); + _connectListenersAdded = true; + } +} + +function _onConnectPointerMove(e) { + if (!_connectState || !_canvas) return; + + const gp = _canvas.screenToGraph(e.clientX, e.clientY); + const { startX, startY, dragPath } = _connectState; + const dx = Math.abs(gp.x - startX) * 0.4; + dragPath.setAttribute('d', + `M ${startX} ${startY} C ${startX + dx} ${startY} ${gp.x - dx} ${gp.y} ${gp.x} ${gp.y}` + ); + + // Highlight drop target port + const svgEl = document.querySelector('.graph-svg'); + if (!svgEl) return; + const elem = document.elementFromPoint(e.clientX, e.clientY); + const port = elem?.closest?.('.graph-port-compatible'); + + svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target')); + if (port) port.classList.add('graph-port-drop-target'); +} + +function _onConnectPointerUp(e) { + if (!_connectState) return; + + const { sourceNodeId, sourceKind, portType, dragPath } = _connectState; + + // Clean up drag edge + dragPath.remove(); + const svgEl = document.querySelector('.graph-svg'); + if (svgEl) svgEl.classList.remove('connecting'); + if (_canvas) _canvas.blockPan = false; + + // Clean up port highlights + const nodeGroup = document.querySelector('.graph-nodes'); + if (nodeGroup) { + nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => { + p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target'); + }); + } + + // Check if dropped on a compatible input port + const elem = document.elementFromPoint(e.clientX, e.clientY); + const targetPort = elem?.closest?.('.graph-port-in'); + if (targetPort) { + const targetNodeId = targetPort.getAttribute('data-node-id'); + const targetKind = targetPort.getAttribute('data-node-kind'); + const targetPortType = targetPort.getAttribute('data-port-type'); + + if (targetNodeId !== sourceNodeId) { + // Find the matching connection + const matches = findConnection(targetKind, sourceKind, targetPortType); + if (matches.length === 1) { + _doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId); + } else if (matches.length > 1) { + // Multiple possible fields (e.g., template → picture_source could be capture or pp template) + // Resolve by source kind + const exact = matches.find(m => m.sourceKind === sourceKind); + if (exact) { + _doConnect(targetNodeId, targetKind, exact.field, sourceNodeId); + } + } + } + } + + _connectState = null; +} + +async function _doConnect(targetId, targetKind, field, sourceId) { + const ok = await updateConnection(targetId, targetKind, field, sourceId); + if (ok) { + showToast(t('graph.connection_updated') || 'Connection updated', 'success'); + await loadGraphEditor(); + } else { + showToast(t('graph.connection_failed') || 'Failed to update connection', 'error'); + } +} + +/* ── Edge context menu (right-click to detach) ── */ + +function _onEdgeContextMenu(edgePath, e, container) { + _dismissEdgeContextMenu(); + + const field = edgePath.getAttribute('data-field') || ''; + if (!isEditableEdge(field)) return; // nested fields can't be detached from graph + + const toId = edgePath.getAttribute('data-to'); + const toNode = _nodeMap?.get(toId); + if (!toNode) return; + + const menu = document.createElement('div'); + menu.className = 'graph-edge-menu'; + menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`; + menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`; + + const btn = document.createElement('button'); + btn.className = 'graph-edge-menu-item danger'; + btn.textContent = t('graph.disconnect') || 'Disconnect'; + btn.addEventListener('click', async () => { + _dismissEdgeContextMenu(); + const ok = await detachConnection(toId, toNode.kind, field); + if (ok) { + showToast(t('graph.connection_removed') || 'Connection removed', 'success'); + await loadGraphEditor(); + } else { + showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error'); + } + }); + menu.appendChild(btn); + + container.querySelector('.graph-container').appendChild(menu); + _edgeContextMenu = menu; +} + +function _dismissEdgeContextMenu() { + if (_edgeContextMenu) { + _edgeContextMenu.remove(); + _edgeContextMenu = null; + } +} + +async function _detachSelectedEdge() { + if (!_selectedEdge) return; + const { to, field, targetKind } = _selectedEdge; + _selectedEdge = null; + + const ok = await detachConnection(to, targetKind, field); + if (ok) { + showToast(t('graph.connection_removed') || 'Connection removed', 'success'); + await loadGraphEditor(); + } else { + showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error'); + } +} + // Re-render graph when language changes (toolbar titles, legend, search placeholder use t()) document.addEventListener('languageChanged', () => { if (_initialized && _nodeMap) { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 847acec..8530775 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1397,5 +1397,10 @@ "graph.minimap": "Minimap", "graph.relayout": "Re-layout", "graph.empty": "No entities yet", - "graph.empty.hint": "Create devices, sources, and targets to see them here." + "graph.empty.hint": "Create devices, sources, and targets to see them here.", + "graph.disconnect": "Disconnect", + "graph.connection_updated": "Connection updated", + "graph.connection_failed": "Failed to update connection", + "graph.connection_removed": "Connection removed", + "graph.disconnect_failed": "Failed to disconnect" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 71dc28c..8800487 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1397,5 +1397,10 @@ "graph.minimap": "Миникарта", "graph.relayout": "Перестроить", "graph.empty": "Ещё нет сущностей", - "graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь." + "graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь.", + "graph.disconnect": "Отключить", + "graph.connection_updated": "Соединение обновлено", + "graph.connection_failed": "Не удалось обновить соединение", + "graph.connection_removed": "Соединение удалено", + "graph.disconnect_failed": "Не удалось отключить" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index d3fa679..f54af42 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1397,5 +1397,10 @@ "graph.minimap": "小地图", "graph.relayout": "重新布局", "graph.empty": "暂无实体", - "graph.empty.hint": "创建设备、源和目标后即可在此查看。" + "graph.empty.hint": "创建设备、源和目标后即可在此查看。", + "graph.disconnect": "断开连接", + "graph.connection_updated": "连接已更新", + "graph.connection_failed": "更新连接失败", + "graph.connection_removed": "连接已移除", + "graph.disconnect_failed": "断开连接失败" } diff --git a/server/src/wled_controller/storage/audio_source_store.py b/server/src/wled_controller/storage/audio_source_store.py index 7177b83..46ca5f5 100644 --- a/server/src/wled_controller/storage/audio_source_store.py +++ b/server/src/wled_controller/storage/audio_source_store.py @@ -10,6 +10,7 @@ from wled_controller.storage.audio_source import ( MultichannelAudioSource, ) from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.storage.utils import resolve_ref from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -112,15 +113,18 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): if is_loopback is not None: source.is_loopback = bool(is_loopback) if audio_template_id is not None: - source.audio_template_id = audio_template_id + source.audio_template_id = resolve_ref(audio_template_id, source.audio_template_id) elif isinstance(source, MonoAudioSource): if audio_source_id is not None: - parent = self._items.get(audio_source_id) - if not parent: - raise ValueError(f"Parent audio source not found: {audio_source_id}") - if not isinstance(parent, MultichannelAudioSource): - raise ValueError("Mono sources must reference a multichannel source") - source.audio_source_id = audio_source_id + resolved = resolve_ref(audio_source_id, source.audio_source_id) + if resolved is not None: + # Validate parent exists and is multichannel + parent = self._items.get(resolved) + if not parent: + raise ValueError(f"Parent audio source not found: {resolved}") + if not isinstance(parent, MultichannelAudioSource): + raise ValueError("Mono sources must reference a multichannel source") + source.audio_source_id = resolved if channel is not None: source.channel = channel diff --git a/server/src/wled_controller/storage/automation_store.py b/server/src/wled_controller/storage/automation_store.py index 09d4bc1..5073f80 100644 --- a/server/src/wled_controller/storage/automation_store.py +++ b/server/src/wled_controller/storage/automation_store.py @@ -84,11 +84,11 @@ class AutomationStore(BaseJsonStore[Automation]): if conditions is not None: automation.conditions = conditions if scene_preset_id != "__unset__": - automation.scene_preset_id = scene_preset_id + automation.scene_preset_id = None if scene_preset_id == "" else scene_preset_id if deactivation_mode is not None: automation.deactivation_mode = deactivation_mode if deactivation_scene_preset_id != "__unset__": - automation.deactivation_scene_preset_id = deactivation_scene_preset_id + automation.deactivation_scene_preset_id = None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id if tags is not None: automation.tags = tags diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index b822542..7d07588 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -6,6 +6,7 @@ from typing import List, Optional from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.storage.utils import resolve_ref from wled_controller.storage.color_strip_source import ( AdvancedPictureColorStripSource, ApiInputColorStripSource, @@ -390,14 +391,14 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]): source.description = description if clock_id is not None: - source.clock_id = clock_id if clock_id else None + source.clock_id = resolve_ref(clock_id, source.clock_id) if tags is not None: source.tags = tags if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): if picture_source_id is not None and isinstance(source, PictureColorStripSource): - source.picture_source_id = picture_source_id + source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id) if fps is not None: source.fps = fps if brightness is not None: @@ -447,7 +448,7 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]): if visualization_mode is not None: source.visualization_mode = visualization_mode if audio_source_id is not None: - source.audio_source_id = audio_source_id + source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id) if sensitivity is not None: source.sensitivity = float(sensitivity) if smoothing is not None: diff --git a/server/src/wled_controller/storage/key_colors_output_target.py b/server/src/wled_controller/storage/key_colors_output_target.py index c119fa9..84fcc40 100644 --- a/server/src/wled_controller/storage/key_colors_output_target.py +++ b/server/src/wled_controller/storage/key_colors_output_target.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from typing import List, Optional from wled_controller.storage.output_target import OutputTarget +from wled_controller.storage.utils import resolve_ref @dataclass @@ -105,7 +106,7 @@ class KeyColorsOutputTarget(OutputTarget): """Apply mutable field updates for KC targets.""" super().update_fields(name=name, description=description, tags=tags) if picture_source_id is not None: - self.picture_source_id = picture_source_id + self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id) if key_colors_settings is not None: self.settings = key_colors_settings diff --git a/server/src/wled_controller/storage/picture_source_store.py b/server/src/wled_controller/storage/picture_source_store.py index 336f09a..e205a39 100644 --- a/server/src/wled_controller/storage/picture_source_store.py +++ b/server/src/wled_controller/storage/picture_source_store.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from typing import List, Optional, Set from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.storage.utils import resolve_ref from wled_controller.storage.picture_source import ( PictureSource, ProcessedPictureSource, @@ -183,7 +184,8 @@ class PictureSourceStore(BaseJsonStore[PictureSource]): stream = self.get(stream_id) # If changing source_stream_id on a processed stream, check for cycles - if source_stream_id is not None and isinstance(stream, ProcessedPictureSource): + # (skip validation when clearing via empty string) + if source_stream_id is not None and source_stream_id != "" and isinstance(stream, ProcessedPictureSource): if source_stream_id not in self._items: raise ValueError(f"Source stream not found: {source_stream_id}") if self._detect_cycle(source_stream_id, exclude_stream_id=stream_id): @@ -201,14 +203,14 @@ class PictureSourceStore(BaseJsonStore[PictureSource]): if display_index is not None: stream.display_index = display_index if capture_template_id is not None: - stream.capture_template_id = capture_template_id + stream.capture_template_id = resolve_ref(capture_template_id, stream.capture_template_id) if target_fps is not None: stream.target_fps = target_fps elif isinstance(stream, ProcessedPictureSource): if source_stream_id is not None: - stream.source_stream_id = source_stream_id + stream.source_stream_id = resolve_ref(source_stream_id, stream.source_stream_id) if postprocessing_template_id is not None: - stream.postprocessing_template_id = postprocessing_template_id + stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id) elif isinstance(stream, StaticImagePictureSource): if image_source is not None: stream.image_source = image_source diff --git a/server/src/wled_controller/storage/utils.py b/server/src/wled_controller/storage/utils.py new file mode 100644 index 0000000..f4e1660 --- /dev/null +++ b/server/src/wled_controller/storage/utils.py @@ -0,0 +1,23 @@ +"""Shared utilities for storage layer.""" + +from typing import Optional + + +def resolve_ref(new_value: Optional[str], current_value: Optional[str]) -> Optional[str]: + """Resolve a reference field update. + + Handles three cases for nullable reference ID fields: + - new_value == '' -> clear to None (detach) + - new_value is None -> keep current value (no change) + - otherwise -> use new_value + + Args: + new_value: The incoming value from the API update request. + current_value: The current value stored on the entity. + + Returns: + The resolved value to assign to the field. + """ + if new_value == "": + return None + return new_value if new_value is not None else current_value diff --git a/server/src/wled_controller/storage/value_source_store.py b/server/src/wled_controller/storage/value_source_store.py index e2a290f..9baa031 100644 --- a/server/src/wled_controller/storage/value_source_store.py +++ b/server/src/wled_controller/storage/value_source_store.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from typing import List, Optional from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.storage.utils import resolve_ref from wled_controller.storage.value_source import ( AdaptiveValueSource, AnimatedValueSource, @@ -179,7 +180,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]): source.max_value = max_value elif isinstance(source, AudioValueSource): if audio_source_id is not None: - source.audio_source_id = audio_source_id + source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id) if mode is not None: source.mode = mode if sensitivity is not None: @@ -198,7 +199,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]): raise ValueError("Time of day schedule requires at least 2 points") source.schedule = schedule if picture_source_id is not None: - source.picture_source_id = picture_source_id + source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id) if scene_behavior is not None: source.scene_behavior = scene_behavior if sensitivity is not None: diff --git a/server/src/wled_controller/storage/wled_output_target.py b/server/src/wled_controller/storage/wled_output_target.py index f99bb4b..77fcea0 100644 --- a/server/src/wled_controller/storage/wled_output_target.py +++ b/server/src/wled_controller/storage/wled_output_target.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from typing import List, Optional from wled_controller.storage.output_target import OutputTarget +from wled_controller.storage.utils import resolve_ref DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds @@ -68,11 +69,11 @@ class WledOutputTarget(OutputTarget): """Apply mutable field updates for WLED targets.""" super().update_fields(name=name, description=description, tags=tags) if device_id is not None: - self.device_id = device_id + self.device_id = resolve_ref(device_id, self.device_id) if color_strip_source_id is not None: - self.color_strip_source_id = color_strip_source_id + self.color_strip_source_id = resolve_ref(color_strip_source_id, self.color_strip_source_id) if brightness_value_source_id is not None: - self.brightness_value_source_id = brightness_value_source_id + self.brightness_value_source_id = resolve_ref(brightness_value_source_id, self.brightness_value_source_id) if fps is not None: self.fps = fps if keepalive_interval is not None: