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:
@@ -365,6 +365,7 @@
|
|||||||
fill: none;
|
fill: none;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
transition: opacity 0.15s, stroke-width 0.15s;
|
transition: opacity 0.15s, stroke-width 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +374,11 @@
|
|||||||
stroke-width: 3;
|
stroke-width: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wider invisible hit area for thin edges */
|
||||||
|
.graph-edge {
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
.graph-edge-arrow {
|
.graph-edge-arrow {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ let _dragListenersAdded = false;
|
|||||||
// Manual position overrides (persisted in memory; cleared on relayout)
|
// Manual position overrides (persisted in memory; cleared on relayout)
|
||||||
let _manualPositions = new Map();
|
let _manualPositions = new Map();
|
||||||
|
|
||||||
|
// Rubber-band selection state
|
||||||
|
let _rubberBand = null;
|
||||||
|
let _rubberBandListenersAdded = false;
|
||||||
|
|
||||||
// Minimap position/size persisted in localStorage
|
// Minimap position/size persisted in localStorage
|
||||||
const _MM_KEY = 'graph_minimap';
|
const _MM_KEY = 'graph_minimap';
|
||||||
function _loadMinimapRect() {
|
function _loadMinimapRect() {
|
||||||
@@ -206,6 +210,15 @@ function _renderGraph(container) {
|
|||||||
_initMinimap(container.querySelector('.graph-minimap'));
|
_initMinimap(container.querySelector('.graph-minimap'));
|
||||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||||
_initNodeDrag(nodeGroup, edgeGroup);
|
_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');
|
const searchInput = container.querySelector('.graph-search-input');
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
@@ -216,6 +229,7 @@ function _renderGraph(container) {
|
|||||||
// Deselect on click on empty space (not after a pan gesture)
|
// Deselect on click on empty space (not after a pan gesture)
|
||||||
svgEl.addEventListener('click', (e) => {
|
svgEl.addEventListener('click', (e) => {
|
||||||
if (_canvas.wasPanning) return;
|
if (_canvas.wasPanning) return;
|
||||||
|
if (e.shiftKey) return; // Shift+click reserved for rubber-band
|
||||||
if (!e.target.closest('.graph-node')) {
|
if (!e.target.closest('.graph-node')) {
|
||||||
_deselect(nodeGroup, edgeGroup);
|
_deselect(nodeGroup, edgeGroup);
|
||||||
}
|
}
|
||||||
@@ -313,6 +327,7 @@ function _graphHTML() {
|
|||||||
<g class="graph-root">
|
<g class="graph-root">
|
||||||
<g class="graph-edges"></g>
|
<g class="graph-edges"></g>
|
||||||
<g class="graph-nodes"></g>
|
<g class="graph-nodes"></g>
|
||||||
|
<rect class="graph-selection-rect" x="0" y="0" width="0" height="0" style="display:none"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
@@ -663,40 +678,99 @@ function _onKeydown(e) {
|
|||||||
_deselect(ng, eg);
|
_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;
|
const DRAG_DEAD_ZONE = 4;
|
||||||
|
|
||||||
function _initNodeDrag(nodeGroup, edgeGroup) {
|
function _initNodeDrag(nodeGroup, edgeGroup) {
|
||||||
// Event delegation on node group for pointerdown
|
|
||||||
nodeGroup.addEventListener('pointerdown', (e) => {
|
nodeGroup.addEventListener('pointerdown', (e) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const nodeEl = e.target.closest('.graph-node');
|
const nodeEl = e.target.closest('.graph-node');
|
||||||
if (!nodeEl) return;
|
if (!nodeEl) return;
|
||||||
// Don't start drag from overlay buttons
|
|
||||||
if (e.target.closest('.graph-node-overlay-btn')) return;
|
if (e.target.closest('.graph-node-overlay-btn')) return;
|
||||||
|
|
||||||
const nodeId = nodeEl.getAttribute('data-id');
|
const nodeId = nodeEl.getAttribute('data-id');
|
||||||
const node = _nodeMap.get(nodeId);
|
const node = _nodeMap.get(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
|
// Multi-node drag: if dragged node is part of a multi-selection
|
||||||
|
if (_selectedIds.size > 1 && _selectedIds.has(nodeId)) {
|
||||||
_dragState = {
|
_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,
|
nodeId,
|
||||||
el: nodeEl,
|
el: nodeEl,
|
||||||
startClient: { x: e.clientX, y: e.clientY },
|
startClient: { x: e.clientX, y: e.clientY },
|
||||||
startNode: { x: node.x, y: node.y },
|
startNode: { x: node.x, y: node.y },
|
||||||
dragging: false,
|
dragging: false,
|
||||||
pointerId: e.pointerId,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 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();
|
e.stopPropagation();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Window-level move/up listeners (added once, reused across re-renders)
|
|
||||||
if (!_dragListenersAdded) {
|
if (!_dragListenersAdded) {
|
||||||
window.addEventListener('pointermove', _onDragPointerMove);
|
window.addEventListener('pointermove', _onDragPointerMove);
|
||||||
window.addEventListener('pointerup', _onDragPointerUp);
|
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;
|
if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return;
|
||||||
_dragState.dragging = true;
|
_dragState.dragging = true;
|
||||||
if (_canvas) _canvas.blockPan = true;
|
if (_canvas) _canvas.blockPan = true;
|
||||||
|
if (_dragState.multi) {
|
||||||
|
_dragState.nodes.forEach(n => n.el?.classList.add('dragging'));
|
||||||
|
} else {
|
||||||
_dragState.el.classList.add('dragging');
|
_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;
|
if (!_canvas) return;
|
||||||
const gdx = dx / _canvas.zoom;
|
const gdx = dx / _canvas.zoom;
|
||||||
const gdy = dy / _canvas.zoom;
|
const gdy = dy / _canvas.zoom;
|
||||||
|
|
||||||
|
const edgeGroup = document.querySelector('.graph-edges');
|
||||||
|
|
||||||
|
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);
|
const node = _nodeMap.get(_dragState.nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
node.x = _dragState.startNode.x + gdx;
|
node.x = _dragState.startNode.x + gdx;
|
||||||
node.y = _dragState.startNode.y + gdy;
|
node.y = _dragState.startNode.y + gdy;
|
||||||
_dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
_dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
||||||
|
|
||||||
// Update connected edges
|
|
||||||
const edgeGroup = document.querySelector('.graph-edges');
|
|
||||||
if (edgeGroup) {
|
if (edgeGroup) {
|
||||||
updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
||||||
updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update minimap node position
|
|
||||||
_updateMinimapNode(_dragState.nodeId, node);
|
_updateMinimapNode(_dragState.nodeId, node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _onDragPointerUp() {
|
function _onDragPointerUp() {
|
||||||
if (!_dragState) return;
|
if (!_dragState) return;
|
||||||
|
|
||||||
if (_dragState.dragging) {
|
if (_dragState.dragging) {
|
||||||
_dragState.el.classList.remove('dragging');
|
|
||||||
if (_canvas) _canvas.blockPan = false;
|
if (_canvas) _canvas.blockPan = false;
|
||||||
_justDragged = true;
|
_justDragged = true;
|
||||||
requestAnimationFrame(() => { _justDragged = false; });
|
requestAnimationFrame(() => { _justDragged = false; });
|
||||||
|
|
||||||
// Save manual position
|
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);
|
const node = _nodeMap.get(_dragState.nodeId);
|
||||||
if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y });
|
if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y });
|
||||||
|
}
|
||||||
|
|
||||||
// Recalc bounds for view clamping
|
|
||||||
_bounds = _calcBounds(_nodeMap);
|
_bounds = _calcBounds(_nodeMap);
|
||||||
if (_canvas && _bounds) _canvas.setBounds(_bounds);
|
if (_canvas && _bounds) _canvas.setBounds(_bounds);
|
||||||
|
|
||||||
@@ -768,6 +866,92 @@ function _onDragPointerUp() {
|
|||||||
_dragState = null;
|
_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) {
|
function _updateMinimapNode(nodeId, node) {
|
||||||
const mm = document.querySelector('.graph-minimap');
|
const mm = document.querySelector('.graph-minimap');
|
||||||
if (!mm) return;
|
if (!mm) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user