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;
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user