From a54e2ab8b0b17664ea3ad1842635a97e10a0e0c3 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 13 Mar 2026 15:30:09 +0300 Subject: [PATCH] Add rubber-band selection, multi-node drag, edge click, and keyboard shortcuts - Shift+drag on empty space draws selection rectangle to select multiple nodes - Multi-node drag: dragging a selected node moves all selected nodes together - Click edge to highlight it and its connected nodes - Delete key removes single selected node, Ctrl+A selects all - Edges now have pointer cursor for click affordance Co-Authored-By: Claude Opus 4.6 --- .../static/css/graph-editor.css | 6 + .../static/js/features/graph-editor.js | 252 +++++++++++++++--- 2 files changed, 224 insertions(+), 34 deletions(-) diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index c6548c4..a4ea199 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -365,6 +365,7 @@ fill: none; stroke-width: 2; opacity: 0.6; + cursor: pointer; transition: opacity 0.15s, stroke-width 0.15s; } @@ -373,6 +374,11 @@ stroke-width: 3; } +/* Wider invisible hit area for thin edges */ +.graph-edge { + stroke-linecap: round; +} + .graph-edge-arrow { fill: currentColor; opacity: 0.6; 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 464c5fd..5fb1a24 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -36,6 +36,10 @@ let _dragListenersAdded = false; // Manual position overrides (persisted in memory; cleared on relayout) let _manualPositions = new Map(); +// Rubber-band selection state +let _rubberBand = null; +let _rubberBandListenersAdded = false; + // Minimap position/size persisted in localStorage const _MM_KEY = 'graph_minimap'; function _loadMinimapRect() { @@ -206,6 +210,15 @@ function _renderGraph(container) { _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); _initNodeDrag(nodeGroup, edgeGroup); + _initRubberBand(svgEl); + + // Edge click: select edge and its endpoints + edgeGroup.addEventListener('click', (e) => { + const edgePath = e.target.closest('.graph-edge'); + if (!edgePath) return; + e.stopPropagation(); + _onEdgeClick(edgePath, nodeGroup, edgeGroup); + }); const searchInput = container.querySelector('.graph-search-input'); if (searchInput) { @@ -216,6 +229,7 @@ function _renderGraph(container) { // Deselect on click on empty space (not after a pan gesture) svgEl.addEventListener('click', (e) => { if (_canvas.wasPanning) return; + if (e.shiftKey) return; // Shift+click reserved for rubber-band if (!e.target.closest('.graph-node')) { _deselect(nodeGroup, edgeGroup); } @@ -313,6 +327,7 @@ function _graphHTML() { + @@ -663,40 +678,99 @@ 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); + } + // Ctrl+A → select all + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + _selectAll(); + } } -/* ── Node dragging ── */ +function _selectAll() { + if (!_nodeMap) return; + _selectedIds.clear(); + for (const id of _nodeMap.keys()) _selectedIds.add(id); + const ng = document.querySelector('.graph-nodes'); + if (ng) { + updateSelection(ng, _selectedIds); + ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); + } + const eg = document.querySelector('.graph-edges'); + if (eg) clearEdgeHighlights(eg); +} + +/* ── Edge click ── */ + +function _onEdgeClick(edgePath, nodeGroup, edgeGroup) { + const fromId = edgePath.getAttribute('data-from'); + const toId = edgePath.getAttribute('data-to'); + + _selectedIds.clear(); + _selectedIds.add(fromId); + _selectedIds.add(toId); + + if (nodeGroup) { + updateSelection(nodeGroup, _selectedIds); + nodeGroup.querySelectorAll('.graph-node').forEach(n => { + n.style.opacity = _selectedIds.has(n.getAttribute('data-id')) ? '1' : '0.25'; + }); + } + if (edgeGroup) { + edgeGroup.querySelectorAll('.graph-edge').forEach(p => { + const isThis = p === edgePath; + p.classList.toggle('highlighted', isThis); + p.classList.toggle('dimmed', !isThis); + }); + } +} + +/* ── Node dragging (supports multi-node) ── */ const DRAG_DEAD_ZONE = 4; function _initNodeDrag(nodeGroup, edgeGroup) { - // Event delegation on node group for pointerdown nodeGroup.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; const nodeEl = e.target.closest('.graph-node'); if (!nodeEl) return; - // Don't start drag from overlay buttons if (e.target.closest('.graph-node-overlay-btn')) return; const nodeId = nodeEl.getAttribute('data-id'); const node = _nodeMap.get(nodeId); if (!node) return; - _dragState = { - nodeId, - el: nodeEl, - startClient: { x: e.clientX, y: e.clientY }, - startNode: { x: node.x, y: node.y }, - dragging: false, - pointerId: e.pointerId, - }; + // Multi-node drag: if dragged node is part of a multi-selection + if (_selectedIds.size > 1 && _selectedIds.has(nodeId)) { + _dragState = { + multi: true, + nodes: [..._selectedIds].map(id => ({ + id, + el: nodeGroup.querySelector(`.graph-node[data-id="${id}"]`), + startX: _nodeMap.get(id)?.x || 0, + startY: _nodeMap.get(id)?.y || 0, + })).filter(n => n.el), + startClient: { x: e.clientX, y: e.clientY }, + dragging: false, + }; + } else { + _dragState = { + multi: false, + nodeId, + el: nodeEl, + startClient: { x: e.clientX, y: e.clientY }, + startNode: { x: node.x, y: node.y }, + dragging: false, + }; + } - // Prevent canvas from starting a pan (canvas checks blockPan on left-click non-node) - // But we also need to stop propagation so the canvas's onPointerDown doesn't fire e.stopPropagation(); }); - // Window-level move/up listeners (added once, reused across re-renders) if (!_dragListenersAdded) { window.addEventListener('pointermove', _onDragPointerMove); window.addEventListener('pointerup', _onDragPointerUp); @@ -714,45 +788,69 @@ function _onDragPointerMove(e) { if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return; _dragState.dragging = true; if (_canvas) _canvas.blockPan = true; - _dragState.el.classList.add('dragging'); + if (_dragState.multi) { + _dragState.nodes.forEach(n => n.el?.classList.add('dragging')); + } else { + _dragState.el.classList.add('dragging'); + // Clear chain highlights during single-node drag + const eg = document.querySelector('.graph-edges'); + if (eg) clearEdgeHighlights(eg); + const ng = document.querySelector('.graph-nodes'); + if (ng) ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); + } } if (!_canvas) return; const gdx = dx / _canvas.zoom; const gdy = dy / _canvas.zoom; - const node = _nodeMap.get(_dragState.nodeId); - if (!node) return; - - node.x = _dragState.startNode.x + gdx; - node.y = _dragState.startNode.y + gdy; - _dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`); - - // Update connected edges const edgeGroup = document.querySelector('.graph-edges'); - if (edgeGroup) { - updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); - updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); - } - // Update minimap node position - _updateMinimapNode(_dragState.nodeId, node); + if (_dragState.multi) { + for (const item of _dragState.nodes) { + const node = _nodeMap.get(item.id); + if (!node) continue; + node.x = item.startX + gdx; + node.y = item.startY + gdy; + if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`); + if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges); + _updateMinimapNode(item.id, node); + } + if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges); + } else { + const node = _nodeMap.get(_dragState.nodeId); + if (!node) return; + node.x = _dragState.startNode.x + gdx; + node.y = _dragState.startNode.y + gdy; + _dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`); + if (edgeGroup) { + updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); + updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); + } + _updateMinimapNode(_dragState.nodeId, node); + } } function _onDragPointerUp() { if (!_dragState) return; if (_dragState.dragging) { - _dragState.el.classList.remove('dragging'); if (_canvas) _canvas.blockPan = false; _justDragged = true; requestAnimationFrame(() => { _justDragged = false; }); - // Save manual position - const node = _nodeMap.get(_dragState.nodeId); - if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y }); + if (_dragState.multi) { + _dragState.nodes.forEach(n => { + if (n.el) n.el.classList.remove('dragging'); + const node = _nodeMap.get(n.id); + if (node) _manualPositions.set(n.id, { x: node.x, y: node.y }); + }); + } else { + _dragState.el.classList.remove('dragging'); + const node = _nodeMap.get(_dragState.nodeId); + if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y }); + } - // Recalc bounds for view clamping _bounds = _calcBounds(_nodeMap); if (_canvas && _bounds) _canvas.setBounds(_bounds); @@ -768,6 +866,92 @@ function _onDragPointerUp() { _dragState = null; } +/* ── Rubber-band selection (Shift+drag on empty space) ── */ + +function _initRubberBand(svgEl) { + // Capture-phase: intercept Shift+click on empty space before canvas panning + svgEl.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !e.shiftKey) return; + if (e.target.closest('.graph-node')) return; + + e.stopPropagation(); + e.preventDefault(); + + _rubberBand = { + startGraph: _canvas.screenToGraph(e.clientX, e.clientY), + startClient: { x: e.clientX, y: e.clientY }, + active: false, + }; + }, true); // capture phase + + if (!_rubberBandListenersAdded) { + window.addEventListener('pointermove', _onRubberBandMove); + window.addEventListener('pointerup', _onRubberBandUp); + _rubberBandListenersAdded = true; + } +} + +function _onRubberBandMove(e) { + if (!_rubberBand || !_canvas) return; + + if (!_rubberBand.active) { + const dx = e.clientX - _rubberBand.startClient.x; + const dy = e.clientY - _rubberBand.startClient.y; + if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return; + _rubberBand.active = true; + } + + const gp = _canvas.screenToGraph(e.clientX, e.clientY); + const s = _rubberBand.startGraph; + const x = Math.min(s.x, gp.x), y = Math.min(s.y, gp.y); + const w = Math.abs(gp.x - s.x), h = Math.abs(gp.y - s.y); + + const rect = document.querySelector('.graph-selection-rect'); + if (rect) { + rect.setAttribute('x', x); + rect.setAttribute('y', y); + rect.setAttribute('width', w); + rect.setAttribute('height', h); + rect.style.display = ''; + } +} + +function _onRubberBandUp() { + if (!_rubberBand) return; + + const rect = document.querySelector('.graph-selection-rect'); + + if (_rubberBand.active && rect && _nodeMap) { + const rx = parseFloat(rect.getAttribute('x')); + const ry = parseFloat(rect.getAttribute('y')); + const rw = parseFloat(rect.getAttribute('width')); + const rh = parseFloat(rect.getAttribute('height')); + + _selectedIds.clear(); + for (const node of _nodeMap.values()) { + if (node.x + node.width > rx && node.x < rx + rw && + node.y + node.height > ry && node.y < ry + rh) { + _selectedIds.add(node.id); + } + } + + const ng = document.querySelector('.graph-nodes'); + const eg = document.querySelector('.graph-edges'); + if (ng) { + updateSelection(ng, _selectedIds); + ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); + } + if (eg) clearEdgeHighlights(eg); + } + + if (rect) { + rect.style.display = 'none'; + rect.setAttribute('width', '0'); + rect.setAttribute('height', '0'); + } + _rubberBand = null; +} + function _updateMinimapNode(nodeId, node) { const mm = document.querySelector('.graph-minimap'); if (!mm) return;