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 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:30:09 +03:00
parent 6d85385dbb
commit a54e2ab8b0
2 changed files with 224 additions and 34 deletions

View File

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

View File

@@ -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() {
<g class="graph-root">
<g class="graph-edges"></g>
<g class="graph-nodes"></g>
<rect class="graph-selection-rect" x="0" y="0" width="0" height="0" style="display:none"/>
</g>
</svg>
@@ -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;