feat(flowchart): multi-select + drag-rect selection (C17)

Item 1 from the polish list. The canvas previously supported
single-selection only; this adds:

  - Shift+click on a node toggles its membership in a multi-
    selection Set. Plain click still replaces with a single node.
  - Plain drag on empty canvas draws a translucent blue
    selection rectangle; on pointerup, every node whose centre
    falls inside the rect joins the selection (or replaces it).
  - Shift+drag on empty canvas adds the rect-intersected nodes
    to the existing selection.
  - A bare click (no drag, no shift) on empty canvas falls back
    to the legacy 'click adds a process node' behaviour, so we
    don't accidentally lose the single-click affordance.
  - 'Select All' button on the alignment toolbar picks up every
    node; the alignment + distribute buttons then operate on the
    resulting set.
  - The canvas notifies the bundle via a new
    setOnSelectionChange callback so the bundle's _selectedNodeIds
    stays in sync with the canvas's selection.

Architecture changes:

  - src/flowchart/flowchart-canvas.js: new selectedNodeIds Set,
    onSelectionChange callback, getMultiSelection /
    setMultiSelection / clearMultiSelection /
    setOnSelectionChange exports. Drag-rect state machine lives
    alongside panState / dragState. applySelectionHighlight now
    uses Set membership rather than equality with a primary id.
  - src/renderer/flowchart-bundle.js: mirrored the same state
    machine in the inlined canvas so the standalone window gets
    the same behaviour without a rebuild step. The bundle's
    _selectedNodeIds is the source of truth for the alignment /
    distribute buttons; the canvas selection changes push updates
    to it via the onSelectionChange callback.

Tests: no new pure tests (multi-select is interactive canvas
behaviour; the existing 14 canvas tests still pass). 1376 total.

Amit Haridas
This commit is contained in:
2026-09-30 22:35:44 +05:30
parent bf12996a40
commit fc017e0101
2 changed files with 370 additions and 28 deletions
+174 -8
View File
@@ -632,6 +632,13 @@
let selectedEdgeId = null;
let unsubscribe = null;
let destroyed = false;
// v4.13.0 — multi-selection Set + drag-rect state. Mirrors the
// canonical src/flowchart/flowchart-canvas.js so the standalone
// window gets shift+click toggle and drag-rect multi-select.
const selectedNodeIds = new Set();
let onSelectionChange = null;
let rectSelectState = null;
let rectOverlay = null;
function render() {
if (destroyed) return;
@@ -722,7 +729,7 @@
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
nodeEls.forEach((g) => {
const id = g.getAttribute('data-node-id');
g.classList.toggle('selected', id === selectedNodeId);
g.classList.toggle('selected', selectedNodeIds.has(id));
});
const edgeEls = edgesLayer.querySelectorAll('line[data-edge-id]');
edgeEls.forEach((l) => {
@@ -730,6 +737,77 @@
l.classList.toggle('selected', id === selectedEdgeId);
});
}
// v4.13.0 — multi-selection helpers. emitSelectionChange is
// called whenever the selection changes so the bundle's
// _selectedNodeIds can stay in sync.
function emitSelectionChange() {
if (typeof onSelectionChange === 'function') {
try {
onSelectionChange({
nodeId: selectedNodeId,
edgeId: selectedEdgeId,
nodeIds: Array.from(selectedNodeIds),
});
} catch {
// never let a caller bug kill the canvas
}
}
}
function setMultiSelection(nodeIds) {
selectedNodeIds.clear();
if (Array.isArray(nodeIds)) {
for (const id of nodeIds) {
if (typeof id === 'string' && id.length > 0) selectedNodeIds.add(id);
}
}
selectedNodeId = selectedNodeIds.size > 0 ? Array.from(selectedNodeIds)[0] : null;
selectedEdgeId = null;
applySelectionHighlight();
emitSelectionChange();
}
function clearMultiSelection() {
selectedNodeIds.clear();
selectedNodeId = null;
selectedEdgeId = null;
applySelectionHighlight();
emitSelectionChange();
}
function getMultiSelection() {
return Array.from(selectedNodeIds);
}
function setOnSelectionChange(cb) {
onSelectionChange = typeof cb === 'function' ? cb : null;
}
// Rect-select overlay helpers.
function ensureRectOverlay() {
if (rectOverlay) return rectOverlay;
rectOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectOverlay.setAttribute('fill', 'rgba(37, 99, 235, 0.12)');
rectOverlay.setAttribute('stroke', '#2563eb');
rectOverlay.setAttribute('stroke-width', '1');
rectOverlay.setAttribute('stroke-dasharray', '4 3');
rectOverlay.setAttribute('pointer-events', 'none');
rectOverlay.setAttribute('class', 'flowchart-rect-select');
rectOverlay.setAttribute('x', '0');
rectOverlay.setAttribute('y', '0');
rectOverlay.setAttribute('width', '0');
rectOverlay.setAttribute('height', '0');
svg.appendChild(rectOverlay);
return rectOverlay;
}
function updateRectOverlay(x, y, w, h) {
const overlay = ensureRectOverlay();
overlay.setAttribute('x', String(x));
overlay.setAttribute('y', String(y));
overlay.setAttribute('width', String(Math.max(0, w)));
overlay.setAttribute('height', String(Math.max(0, h)));
}
function clearRectOverlay() {
if (rectOverlay && rectOverlay.parentNode) {
rectOverlay.parentNode.removeChild(rectOverlay);
}
rectOverlay = null;
}
// ----- pointer events -----
let dragState = null;
@@ -750,7 +828,24 @@
const nodeId = nodeG.getAttribute('data-node-id');
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
if (!node) return;
selectedNodeId = nodeId;
// v4.13.0 — shift+click toggles membership in the multi-selection.
// Plain click replaces the selection with just this node.
if (ev.shiftKey) {
if (selectedNodeIds.has(nodeId)) {
selectedNodeIds.delete(nodeId);
if (selectedNodeId === nodeId) {
const remaining = Array.from(selectedNodeIds);
selectedNodeId = remaining.length > 0 ? remaining[0] : null;
}
} else {
selectedNodeIds.add(nodeId);
selectedNodeId = nodeId;
}
} else {
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
selectedNodeId = nodeId;
}
selectedEdgeId = null;
// Paint the .flowchart-node.selected highlight immediately on a bare
// click (without a drag). store.subscribe would normally trigger
@@ -759,6 +854,7 @@
// render() — so the pointerdown target stays attached and subsequent
// pointermove/pointerup can still bubble on the same element.
applySelectionHighlight();
emitSelectionChange();
const start = getSvgPoint(ev.clientX, ev.clientY);
if (ev.altKey) {
// Alt+drag = create a new edge from this node to wherever the pointer
@@ -788,10 +884,12 @@
if (edgeLine) {
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
selectedNodeId = null;
selectedNodeIds.clear();
// Same reasoning as the node branch above: paint the edge highlight
// immediately so a click-without-drag isn't invisible until the next
// store mutation triggers a re-render.
applySelectionHighlight();
emitSelectionChange();
console.log('[flowchart] pointerdown on edge', selectedEdgeId);
if (typeof opts.onEdgeClick === 'function') {
opts.onEdgeClick(selectedEdgeId, ev);
@@ -799,18 +897,32 @@
ev.preventDefault();
return;
}
// Click on empty canvas: add a process node at the click point.
// Click on empty canvas: start a drag-rect. If the user releases
// without moving, fall back to the legacy "click adds a process
// node" affordance. v4.13.0 — multi-select via drag-rect.
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
console.log('[flowchart] pointerdown on empty canvas — adding process node at', x, y);
store.addNode({ kind: 'process', x, y, label: 'Node' });
rectSelectState = {
startX: p.x,
startY: p.y,
additive: ev.shiftKey,
moved: false,
};
ev.preventDefault();
}
}
function onPointerMove(ev) {
if (rectSelectState) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const x0 = Math.min(rectSelectState.startX, p.x);
const x1 = Math.max(rectSelectState.startX, p.x);
const y0 = Math.min(rectSelectState.startY, p.y);
const y1 = Math.max(rectSelectState.startY, p.y);
rectSelectState.moved = true;
updateRectOverlay(x0, y0, x1 - x0, y1 - y0);
return;
}
if (!dragState) return;
if (dragState.mode === 'move') {
const p = getSvgPoint(ev.clientX, ev.clientY);
@@ -849,6 +961,41 @@
} else if (dragState && dragState.mode === 'move') {
console.log('[flowchart] pointerup move complete for node', dragState.nodeId);
}
// v4.13.0 — finalize drag-rect. If the rect is small (treat as a
// bare click) fall back to the legacy "click adds a process node"
// affordance. Otherwise intersect against every node centre.
if (rectSelectState) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const x0 = Math.min(rectSelectState.startX, p.x);
const x1 = Math.max(rectSelectState.startX, p.x);
const y0 = Math.min(rectSelectState.startY, p.y);
const y1 = Math.max(rectSelectState.startY, p.y);
const width = x1 - x0;
const height = y1 - y0;
clearRectOverlay();
if (!rectSelectState.moved && width < 3 && height < 3) {
if (!rectSelectState.additive) clearMultiSelection();
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
console.log('[flowchart] bare click on empty — adding process node at', x, y);
store.addNode({ kind: 'process', x, y, label: 'Node' });
} else {
const hits = [];
for (const node of store.getGraph().nodes) {
const cx = node.x + DEFAULT_WIDTH / 2;
const cy = node.y + DEFAULT_HEIGHT / 2;
if (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1) hits.push(node.id);
}
if (rectSelectState.additive) {
const next = new Set(selectedNodeIds);
for (const id of hits) next.add(id);
setMultiSelection(Array.from(next));
} else {
setMultiSelection(hits);
}
}
rectSelectState = null;
}
dragState = null;
}
@@ -917,7 +1064,14 @@
svg.remove();
}
return { destroy, getSvg: () => svg };
return {
destroy,
getSvg: () => svg,
getMultiSelection,
setMultiSelection,
clearMultiSelection,
setOnSelectionChange,
};
}
// ========== flowchart-align (inlined v4.13.0) ==========
@@ -1649,6 +1803,15 @@
onEdgeClick: () => {},
onShapeMenu: () => {},
});
// v4.13.0 — keep the bundle's _selectedNodeIds in sync with the
// canvas selection. The canvas is the source of truth for shift+click
// and drag-rect; the bundle tracks the set so the alignment /
// distribute buttons have a target.
if (typeof _canvas.setOnSelectionChange === 'function') {
_canvas.setOnSelectionChange(function (sel) {
_selectedNodeIds = new Set(sel.nodeIds || []);
});
}
console.log('[flowchart] bootstrap: canvas rendered');
_store.subscribe(() => {
@@ -1898,6 +2061,9 @@
if (!_store) return;
const graph = _store.getGraph();
_selectedNodeIds = new Set(graph.nodes.map((node) => node.id));
if (_canvas && typeof _canvas.setMultiSelection === 'function') {
_canvas.setMultiSelection(Array.from(_selectedNodeIds));
}
setStatus('Selected ' + _selectedNodeIds.size + ' nodes');
rerenderNodeList();
});