mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
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:
@@ -131,19 +131,89 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let panState = null; // v4.13.0 — drag-to-pan state on empty canvas
|
let panState = null; // v4.13.0 — drag-to-pan state on empty canvas
|
||||||
|
// v4.13.0 — multi-selection Set. Shift+click toggles membership;
|
||||||
// v4.13.0 — selection lives on the canvas, not in the DOM. The controller
|
// drag-rect on empty background replaces it with the intersected set.
|
||||||
// asks via getSelection() rather than reading .selected class names.
|
// selectedNodeId remains the "primary" (last-clicked) for backward
|
||||||
function setSelection({ nodeId = null, edgeId = null } = {}) {
|
// compat with the rest of the bundle; getMultiSelection returns the
|
||||||
if (selectedNodeId !== nodeId || selectedEdgeId !== edgeId) {
|
// full set for the alignment / distribute buttons.
|
||||||
selectedNodeId = nodeId;
|
const selectedNodeIds = new Set();
|
||||||
selectedEdgeId = edgeId;
|
let onSelectionChange = null;
|
||||||
applySelectionHighlight();
|
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 setSelection({ nodeId = null, edgeId = null, additive = false } = {}) {
|
||||||
|
if (!additive) {
|
||||||
|
// Plain click — replace selection with the single node (or clear).
|
||||||
|
if (edgeId !== undefined) selectedEdgeId = edgeId;
|
||||||
|
if (nodeId !== undefined) {
|
||||||
|
selectedNodeId = nodeId;
|
||||||
|
selectedNodeIds.clear();
|
||||||
|
if (nodeId !== null) selectedNodeIds.add(nodeId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Shift+click — toggle membership of the clicked node. The edge
|
||||||
|
// selection is single-only (no multi-edge for now).
|
||||||
|
if (nodeId) {
|
||||||
|
if (selectedNodeIds.has(nodeId)) {
|
||||||
|
if (selectedNodeId === nodeId) {
|
||||||
|
// pick a different node as the new primary if any
|
||||||
|
const remaining = Array.from(selectedNodeIds).filter((id) => id !== nodeId);
|
||||||
|
selectedNodeId = remaining.length > 0 ? remaining[0] : null;
|
||||||
|
}
|
||||||
|
selectedNodeIds.delete(nodeId);
|
||||||
|
} else {
|
||||||
|
selectedNodeId = nodeId;
|
||||||
|
selectedNodeIds.add(nodeId);
|
||||||
|
}
|
||||||
|
selectedEdgeId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applySelectionHighlight();
|
||||||
|
emitSelectionChange();
|
||||||
|
}
|
||||||
function getSelection() {
|
function getSelection() {
|
||||||
return { nodeId: selectedNodeId, edgeId: selectedEdgeId };
|
return { nodeId: selectedNodeId, edgeId: selectedEdgeId };
|
||||||
}
|
}
|
||||||
|
function getMultiSelection() {
|
||||||
|
return Array.from(selectedNodeIds);
|
||||||
|
}
|
||||||
|
function setOnSelectionChange(cb) {
|
||||||
|
onSelectionChange = typeof cb === 'function' ? cb : null;
|
||||||
|
}
|
||||||
|
function clearMultiSelection() {
|
||||||
|
selectedNodeIds.clear();
|
||||||
|
selectedNodeId = null;
|
||||||
|
selectedEdgeId = null;
|
||||||
|
applySelectionHighlight();
|
||||||
|
emitSelectionChange();
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
// v4.13.0 — drag-rect state. While the user drags on empty canvas,
|
||||||
|
// draw a translucent selection rectangle; on pointerup, replace the
|
||||||
|
// multi-selection with every node whose centre falls inside the rect.
|
||||||
|
let rectSelectState = null;
|
||||||
|
let rectOverlay = null;
|
||||||
|
|
||||||
// v4.13.0 — apply the current viewport to the content <g>'s transform.
|
// v4.13.0 — apply the current viewport to the content <g>'s transform.
|
||||||
function applyView() {
|
function applyView() {
|
||||||
@@ -274,7 +344,7 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
|
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
|
||||||
nodeEls.forEach((g) => {
|
nodeEls.forEach((g) => {
|
||||||
const id = g.getAttribute('data-node-id');
|
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]');
|
const edgeEls = edgesLayer.querySelectorAll('line[data-edge-id]');
|
||||||
edgeEls.forEach((l) => {
|
edgeEls.forEach((l) => {
|
||||||
@@ -323,7 +393,9 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
const nodeId = nodeG.getAttribute('data-node-id');
|
const nodeId = nodeG.getAttribute('data-node-id');
|
||||||
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
|
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
setSelection({ nodeId });
|
// v4.13.0 — shift+click toggles the node in the multi-selection;
|
||||||
|
// plain click replaces the selection with just this node.
|
||||||
|
setSelection({ nodeId, additive: ev.shiftKey });
|
||||||
const start = getSvgPoint(ev.clientX, ev.clientY);
|
const start = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
if (ev.altKey) {
|
if (ev.altKey) {
|
||||||
// Alt+drag = create a new edge from this node to wherever the pointer
|
// Alt+drag = create a new edge from this node to wherever the pointer
|
||||||
@@ -371,8 +443,9 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Click on empty canvas: middle-mouse OR Space-held = pan; otherwise
|
// Click on empty canvas: middle-mouse OR Space-held = pan; otherwise
|
||||||
// prompt for a new node. v4.13.0 — empty-canvas drag-pan replaces the
|
// start a drag-rect for multi-select. If the user releases without
|
||||||
// old auto-add-process behaviour.
|
// dragging, fall back to the existing "create node at click" behaviour
|
||||||
|
// so we don't accidentally lose the single-click affordance.
|
||||||
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
|
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
|
||||||
const isPan = ev.button === 1 || ev.shiftKey; // middle OR shift-drag
|
const isPan = ev.button === 1 || ev.shiftKey; // middle OR shift-drag
|
||||||
if (isPan) {
|
if (isPan) {
|
||||||
@@ -383,17 +456,53 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
|
// Start a drag-rect. We track the start coords; pointermove decides
|
||||||
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
|
// whether the user is dragging (rect-select) or just clicked (treat
|
||||||
const finalX = snapEnabled ? vpSnap(x, GRID_SIZE) : x;
|
// as onEmptyClick to create a node).
|
||||||
const finalY = snapEnabled ? vpSnap(y, GRID_SIZE) : y;
|
rectSelectState = {
|
||||||
if (typeof opts.onEmptyClick === 'function') {
|
startX: p.x,
|
||||||
opts.onEmptyClick(finalX, finalY, ev);
|
startY: p.y,
|
||||||
}
|
additive: ev.shiftKey,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.13.0 — render the drag-rect overlay. Drawn in screen coords
|
||||||
|
// (independent of viewport scale) so the visible thickness feels right
|
||||||
|
// even at low zoom.
|
||||||
|
function ensureRectOverlay() {
|
||||||
|
if (rectOverlay) return rectOverlay;
|
||||||
|
rectOverlay = svgEl('rect', {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
fill: 'rgba(37, 99, 235, 0.12)',
|
||||||
|
stroke: '#2563eb',
|
||||||
|
'stroke-width': 1,
|
||||||
|
'stroke-dasharray': '4 3',
|
||||||
|
'pointer-events': 'none',
|
||||||
|
class: 'flowchart-rect-select',
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// v4.13.0 — connect-mode preview line. Re-anchored each move to the source
|
// v4.13.0 — connect-mode preview line. Re-anchored each move to the source
|
||||||
// node's boundary in the direction of the pointer.
|
// node's boundary in the direction of the pointer.
|
||||||
let previewLine = null;
|
let previewLine = null;
|
||||||
@@ -430,6 +539,16 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
applyView();
|
applyView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
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) return;
|
||||||
if (dragState.mode === 'resize') {
|
if (dragState.mode === 'resize') {
|
||||||
// Width delta in screen units, then convert to SVG via the
|
// Width delta in screen units, then convert to SVG via the
|
||||||
@@ -491,6 +610,54 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// v4.13.0 — finalize drag-rect. If the rect is small (treat as a
|
||||||
|
// bare click) fall back to the existing onEmptyClick affordance so
|
||||||
|
// the user can still click-to-create a node. Otherwise intersect
|
||||||
|
// the rect against every node's centre and replace (or, on shift,
|
||||||
|
// merge into) the multi-selection.
|
||||||
|
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) {
|
||||||
|
// Bare click on empty canvas — preserve the legacy "click to
|
||||||
|
// add a node" behaviour. First clear the selection so a new
|
||||||
|
// node isn't created while something else is selected.
|
||||||
|
if (!rectSelectState.additive) clearMultiSelection();
|
||||||
|
const finalX = snapEnabled
|
||||||
|
? vpSnap(Math.max(0, p.x - DEFAULT_WIDTH / 2), GRID_SIZE)
|
||||||
|
: Math.max(0, p.x - DEFAULT_WIDTH / 2);
|
||||||
|
const finalY = snapEnabled
|
||||||
|
? vpSnap(Math.max(0, p.y - DEFAULT_HEIGHT / 2), GRID_SIZE)
|
||||||
|
: Math.max(0, p.y - DEFAULT_HEIGHT / 2);
|
||||||
|
if (typeof opts.onEmptyClick === 'function') {
|
||||||
|
opts.onEmptyClick(finalX, finalY, ev);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hits = [];
|
||||||
|
const nodes = store.getGraph().nodes;
|
||||||
|
for (const node of nodes) {
|
||||||
|
const cx = node.x + nodeWidth(node) / 2;
|
||||||
|
const cy = node.y + DEFAULT_HEIGHT / 2;
|
||||||
|
if (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1) hits.push(node.id);
|
||||||
|
}
|
||||||
|
if (rectSelectState.additive) {
|
||||||
|
// shift-drag rect — add hits to the existing selection
|
||||||
|
const next = new Set(selectedNodeIds);
|
||||||
|
for (const id of hits) next.add(id);
|
||||||
|
setMultiSelection(Array.from(next));
|
||||||
|
} else {
|
||||||
|
// plain drag-rect — replace selection with hits (or clear)
|
||||||
|
setMultiSelection(hits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rectSelectState = null;
|
||||||
|
}
|
||||||
removePreviewLine();
|
removePreviewLine();
|
||||||
dragState = null;
|
dragState = null;
|
||||||
}
|
}
|
||||||
@@ -561,7 +728,16 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
svg.remove();
|
svg.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { destroy, getSvg: () => svg, getSelection, setSnapEnabled };
|
return {
|
||||||
|
destroy,
|
||||||
|
getSvg: () => svg,
|
||||||
|
getSelection,
|
||||||
|
getMultiSelection,
|
||||||
|
setMultiSelection,
|
||||||
|
clearMultiSelection,
|
||||||
|
setOnSelectionChange,
|
||||||
|
setSnapEnabled,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// v4.9.6 UMD wrapper — same CommonJS export shape + browser global
|
// v4.9.6 UMD wrapper — same CommonJS export shape + browser global
|
||||||
|
|||||||
@@ -632,6 +632,13 @@
|
|||||||
let selectedEdgeId = null;
|
let selectedEdgeId = null;
|
||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
let destroyed = false;
|
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() {
|
function render() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
@@ -722,7 +729,7 @@
|
|||||||
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
|
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
|
||||||
nodeEls.forEach((g) => {
|
nodeEls.forEach((g) => {
|
||||||
const id = g.getAttribute('data-node-id');
|
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]');
|
const edgeEls = edgesLayer.querySelectorAll('line[data-edge-id]');
|
||||||
edgeEls.forEach((l) => {
|
edgeEls.forEach((l) => {
|
||||||
@@ -730,6 +737,77 @@
|
|||||||
l.classList.toggle('selected', id === selectedEdgeId);
|
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 -----
|
// ----- pointer events -----
|
||||||
let dragState = null;
|
let dragState = null;
|
||||||
@@ -750,7 +828,24 @@
|
|||||||
const nodeId = nodeG.getAttribute('data-node-id');
|
const nodeId = nodeG.getAttribute('data-node-id');
|
||||||
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
|
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
|
||||||
if (!node) return;
|
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;
|
selectedEdgeId = null;
|
||||||
// Paint the .flowchart-node.selected highlight immediately on a bare
|
// Paint the .flowchart-node.selected highlight immediately on a bare
|
||||||
// click (without a drag). store.subscribe would normally trigger
|
// click (without a drag). store.subscribe would normally trigger
|
||||||
@@ -759,6 +854,7 @@
|
|||||||
// render() — so the pointerdown target stays attached and subsequent
|
// render() — so the pointerdown target stays attached and subsequent
|
||||||
// pointermove/pointerup can still bubble on the same element.
|
// pointermove/pointerup can still bubble on the same element.
|
||||||
applySelectionHighlight();
|
applySelectionHighlight();
|
||||||
|
emitSelectionChange();
|
||||||
const start = getSvgPoint(ev.clientX, ev.clientY);
|
const start = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
if (ev.altKey) {
|
if (ev.altKey) {
|
||||||
// Alt+drag = create a new edge from this node to wherever the pointer
|
// Alt+drag = create a new edge from this node to wherever the pointer
|
||||||
@@ -788,10 +884,12 @@
|
|||||||
if (edgeLine) {
|
if (edgeLine) {
|
||||||
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
|
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
|
||||||
selectedNodeId = null;
|
selectedNodeId = null;
|
||||||
|
selectedNodeIds.clear();
|
||||||
// Same reasoning as the node branch above: paint the edge highlight
|
// Same reasoning as the node branch above: paint the edge highlight
|
||||||
// immediately so a click-without-drag isn't invisible until the next
|
// immediately so a click-without-drag isn't invisible until the next
|
||||||
// store mutation triggers a re-render.
|
// store mutation triggers a re-render.
|
||||||
applySelectionHighlight();
|
applySelectionHighlight();
|
||||||
|
emitSelectionChange();
|
||||||
console.log('[flowchart] pointerdown on edge', selectedEdgeId);
|
console.log('[flowchart] pointerdown on edge', selectedEdgeId);
|
||||||
if (typeof opts.onEdgeClick === 'function') {
|
if (typeof opts.onEdgeClick === 'function') {
|
||||||
opts.onEdgeClick(selectedEdgeId, ev);
|
opts.onEdgeClick(selectedEdgeId, ev);
|
||||||
@@ -799,18 +897,32 @@
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
return;
|
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) {
|
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
|
||||||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
|
rectSelectState = {
|
||||||
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
|
startX: p.x,
|
||||||
console.log('[flowchart] pointerdown on empty canvas — adding process node at', x, y);
|
startY: p.y,
|
||||||
store.addNode({ kind: 'process', x, y, label: 'Node' });
|
additive: ev.shiftKey,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(ev) {
|
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) return;
|
||||||
if (dragState.mode === 'move') {
|
if (dragState.mode === 'move') {
|
||||||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
@@ -849,6 +961,41 @@
|
|||||||
} else if (dragState && dragState.mode === 'move') {
|
} else if (dragState && dragState.mode === 'move') {
|
||||||
console.log('[flowchart] pointerup move complete for node', dragState.nodeId);
|
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;
|
dragState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,7 +1064,14 @@
|
|||||||
svg.remove();
|
svg.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { destroy, getSvg: () => svg };
|
return {
|
||||||
|
destroy,
|
||||||
|
getSvg: () => svg,
|
||||||
|
getMultiSelection,
|
||||||
|
setMultiSelection,
|
||||||
|
clearMultiSelection,
|
||||||
|
setOnSelectionChange,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== flowchart-align (inlined v4.13.0) ==========
|
// ========== flowchart-align (inlined v4.13.0) ==========
|
||||||
@@ -1649,6 +1803,15 @@
|
|||||||
onEdgeClick: () => {},
|
onEdgeClick: () => {},
|
||||||
onShapeMenu: () => {},
|
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');
|
console.log('[flowchart] bootstrap: canvas rendered');
|
||||||
|
|
||||||
_store.subscribe(() => {
|
_store.subscribe(() => {
|
||||||
@@ -1898,6 +2061,9 @@
|
|||||||
if (!_store) return;
|
if (!_store) return;
|
||||||
const graph = _store.getGraph();
|
const graph = _store.getGraph();
|
||||||
_selectedNodeIds = new Set(graph.nodes.map((node) => node.id));
|
_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');
|
setStatus('Selected ' + _selectedNodeIds.size + ' nodes');
|
||||||
rerenderNodeList();
|
rerenderNodeList();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user