@vanduo-oss/vd3-flowchart 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -185,6 +185,134 @@ function computeLayout(documentData, mode = "tree", options = {}) {
185
185
  }
186
186
  var LAYOUT_MODES = ["tree", "radial", "grid"];
187
187
 
188
+ // src/mindmap.js
189
+ var DIRECTION_PORTS = { right: "right", left: "left", down: "bottom", up: "top" };
190
+ var PORT_DIRECTIONS = { right: "right", left: "left", bottom: "down", top: "up" };
191
+ var OPPOSITE_DIRECTIONS = { right: "left", left: "right", down: "up", up: "down" };
192
+ var DIRECTION_VECTORS = {
193
+ right: { x: 1, y: 0 },
194
+ left: { x: -1, y: 0 },
195
+ down: { x: 0, y: 1 },
196
+ up: { x: 0, y: -1 }
197
+ };
198
+ var BRANCH_GAP = 72;
199
+ var SIBLING_GAP = 24;
200
+ var COLLISION_GAP = 12;
201
+ var COLLISION_STEP = 12;
202
+ var MAX_COLLISION_STEPS = 400;
203
+ var BRANCH_RING_STEP = 48;
204
+ var MAX_BRANCH_RINGS = 8;
205
+ var NAVIGATION_CONE = Math.tan(Math.PI / 3);
206
+ function centerOf(node) {
207
+ return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
208
+ }
209
+ function oppositeDirection(direction) {
210
+ return OPPOSITE_DIRECTIONS[direction] || "left";
211
+ }
212
+ function findSpatialNeighbor(nodes, fromNode, direction) {
213
+ const vector = DIRECTION_VECTORS[direction];
214
+ if (!vector || !fromNode) return null;
215
+ const origin = centerOf(fromNode);
216
+ let bestCone = null;
217
+ let bestPlane = null;
218
+ nodes.forEach((node) => {
219
+ if (node.id === fromNode.id) return;
220
+ const center = centerOf(node);
221
+ const dx = center.x - origin.x;
222
+ const dy = center.y - origin.y;
223
+ const along = dx * vector.x + dy * vector.y;
224
+ if (along <= 0) return;
225
+ const across = Math.abs(dx * vector.y - dy * vector.x);
226
+ const score = along + across * 2;
227
+ if (across <= along * NAVIGATION_CONE && (!bestCone || score < bestCone.score)) {
228
+ bestCone = { node, score };
229
+ }
230
+ if (!bestPlane || score < bestPlane.score) bestPlane = { node, score };
231
+ });
232
+ return (bestCone || bestPlane)?.node ?? null;
233
+ }
234
+ function findNearestNode(nodes, point) {
235
+ let best = null;
236
+ nodes.forEach((node) => {
237
+ const center = centerOf(node);
238
+ const distance = Math.hypot(center.x - point.x, center.y - point.y);
239
+ if (!best || distance < best.distance) best = { node, distance };
240
+ });
241
+ return best?.node ?? null;
242
+ }
243
+ function overlapsAny(rect, nodes) {
244
+ return nodes.some(
245
+ (node) => rect.x < node.x + node.width + COLLISION_GAP && rect.x + rect.width + COLLISION_GAP > node.x && rect.y < node.y + node.height + COLLISION_GAP && rect.y + rect.height + COLLISION_GAP > node.y
246
+ );
247
+ }
248
+ function placeBranchNode({
249
+ nodes,
250
+ parent,
251
+ siblings = [],
252
+ direction,
253
+ size,
254
+ anchor = null,
255
+ before = false
256
+ }) {
257
+ const horizontal = direction === "right" || direction === "left";
258
+ const cross = horizontal ? "y" : "x";
259
+ const crossSize = horizontal ? "height" : "width";
260
+ const lastSibling = siblings.reduce(
261
+ (last, node) => !last || node[cross] + node[crossSize] > last[cross] + last[crossSize] ? node : last,
262
+ null
263
+ );
264
+ const reference = anchor || lastSibling;
265
+ const rect = { x: 0, y: 0, width: size.width, height: size.height };
266
+ if (reference) {
267
+ if (direction === "right") rect.x = reference.x;
268
+ if (direction === "left") rect.x = reference.x + reference.width - size.width;
269
+ if (direction === "down") rect.y = reference.y;
270
+ if (direction === "up") rect.y = reference.y + reference.height - size.height;
271
+ rect[cross] = before ? reference[cross] - SIBLING_GAP - size[crossSize] : reference[cross] + reference[crossSize] + SIBLING_GAP;
272
+ } else {
273
+ if (direction === "right") rect.x = parent.x + parent.width + BRANCH_GAP;
274
+ if (direction === "left") rect.x = parent.x - BRANCH_GAP - size.width;
275
+ if (direction === "down") rect.y = parent.y + parent.height + BRANCH_GAP;
276
+ if (direction === "up") rect.y = parent.y - BRANCH_GAP - size.height;
277
+ rect[cross] = parent[cross] + parent[crossSize] / 2 - size[crossSize] / 2;
278
+ }
279
+ const crossSign = reference ? before ? -1 : 1 : 0;
280
+ const result = nearestFreeSlot(
281
+ rect,
282
+ nodes,
283
+ cross,
284
+ horizontal ? "x" : "y",
285
+ DIRECTION_VECTORS[direction],
286
+ crossSign
287
+ );
288
+ return { x: Math.round(result.x * 100) / 100, y: Math.round(result.y * 100) / 100 };
289
+ }
290
+ function crossOffsetAt(index, crossSign) {
291
+ if (crossSign) return crossSign * COLLISION_STEP * index;
292
+ return COLLISION_STEP * Math.ceil(index / 2) * (index % 2 ? 1 : -1);
293
+ }
294
+ function nearestFreeSlot(rect, nodes, cross, main, vector, crossSign) {
295
+ const origin = { ...rect };
296
+ const outward = vector.x + vector.y;
297
+ let best = null;
298
+ for (let ring = 0; ring <= MAX_BRANCH_RINGS; ring += 1) {
299
+ const mainOffset = ring * BRANCH_RING_STEP;
300
+ if (best && mainOffset >= best.cost) break;
301
+ const candidate = { ...origin, [main]: origin[main] + outward * mainOffset };
302
+ for (let index = 0; index <= MAX_COLLISION_STEPS; index += 1) {
303
+ const crossOffset = crossOffsetAt(index, crossSign);
304
+ const cost = Math.hypot(mainOffset, crossOffset);
305
+ if (best && cost >= best.cost) break;
306
+ candidate[cross] = origin[cross] + crossOffset;
307
+ if (!overlapsAny(candidate, nodes)) {
308
+ best = { rect: { ...candidate }, cost };
309
+ break;
310
+ }
311
+ }
312
+ }
313
+ return best ? best.rect : origin;
314
+ }
315
+
188
316
  // src/core.js
189
317
  var SVG_NS = "http://www.w3.org/2000/svg";
190
318
  var TOOLBAR_ICON_PATHS = {
@@ -194,6 +322,7 @@ var TOOLBAR_ICON_PATHS = {
194
322
  "fit-view": "M216,48V88a8,8,0,0,1-16,0V67.31l-42.34,42.35a8,8,0,0,1-11.32-11.32L188.69,56H168a8,8,0,0,1,0-16h40A8,8,0,0,1,216,48ZM98.34,146.34,56,188.69V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H88a8,8,0,0,0,0-16H67.31l42.35-42.34a8,8,0,0,0-11.32-11.32Zm11.32-36.68L67.31,56H88a8,8,0,0,0,0-16H48a8,8,0,0,0-8,8V88a8,8,0,0,0,16,0V67.31l42.34,42.35a8,8,0,0,0,11.32-11.32ZM208,160a8,8,0,0,0-8,8v20.69l-42.34-42.35a8,8,0,0,0-11.32,11.32L188.69,200H168a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,208,160Z",
195
323
  undo: "M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z",
196
324
  redo: "M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z",
325
+ minimap: "M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z",
197
326
  clear: "M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"
198
327
  };
199
328
  var TOOLBAR_ACTIONS = [
@@ -245,21 +374,32 @@ var DEFAULT_EDGE_STROKE_WIDTH = 2.25;
245
374
  var MIN_EDGE_STROKE_WIDTH = 1.25;
246
375
  var MAX_EDGE_STROKE_WIDTH = 6;
247
376
  var CONNECTION_PORT_RADIUS = 6;
377
+ var PORT_HANDLE_OFFSET = 12;
248
378
  var CONNECTION_PORT_HIT_RADIUS = 14;
379
+ var PORT_CLICK_SLOP = 4;
380
+ var RESIZE_HANDLE_SIZE = 9;
381
+ var SELECTION_OUTSET = 4;
382
+ var GUIDE_SNAP_DISTANCE = 6;
383
+ var GUIDE_OVERHANG = 12;
384
+ var COLLAPSE_TOGGLE_OFFSET = 36;
385
+ var MINIMAP_WIDTH = 180;
386
+ var MINIMAP_HEIGHT = 120;
387
+ var MINIMAP_MIN_CANVAS_WIDTH = 480;
388
+ var SELECTION_BOX_TYPES = /* @__PURE__ */ new Set(["circle", "diamond", "label"]);
249
389
  var RECONNECT_ENDPOINT_RADIUS = 7;
250
390
  var RECONNECT_ENDPOINT_HIT_RADIUS = 12;
251
391
  var EDGE_HIT_STROKE_MIN = 16;
252
392
  var CONNECTION_SNAP_PADDING = 36;
253
393
  var CONNECTION_HYSTERESIS = 16;
254
394
  var CONNECTION_CENTER_LOCK_RADIUS = 18;
255
- var RESIZE_PORT_GAP = 34;
395
+ var RESIZE_PORT_GAP = 28;
256
396
  var EDGE_STROKE_PRESETS = [
257
397
  { id: "thin", label: "Thin", width: 1.75 },
258
398
  { id: "medium", label: "Medium", width: DEFAULT_EDGE_STROKE_WIDTH },
259
399
  { id: "bold", label: "Bold", width: 3.5 }
260
400
  ];
261
- var VD_FLOWCHART_VERSION = "1.3.0";
262
- var FLOWCHART_DOCUMENT_VERSION = "1.2.0";
401
+ var VD_FLOWCHART_VERSION = "1.4.0";
402
+ var FLOWCHART_DOCUMENT_VERSION = "1.3.0";
263
403
  var FLOWCHART_NODE_TYPES = [
264
404
  "rounded-rect",
265
405
  "rect",
@@ -272,7 +412,65 @@ var FLOWCHART_NODE_TYPES = [
272
412
  var FLOWCHART_PORTS = ["top", "right", "bottom", "left"];
273
413
  var FLOWCHART_EDGE_MARKERS = ["none", "arrow", "dot"];
274
414
  var FLOWCHART_EDGE_ROUTES = ["curve", "straight", "orthogonal"];
275
- var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update"]);
415
+ var FLOWCHART_KEYBOARD_SHORTCUTS = ["mindmap", "basic"];
416
+ function normalizeKeyboardShortcuts(value) {
417
+ return value === "basic" ? "basic" : "mindmap";
418
+ }
419
+ var ARROW_DIRECTIONS = {
420
+ ArrowRight: "right",
421
+ ArrowLeft: "left",
422
+ ArrowDown: "down",
423
+ ArrowUp: "up"
424
+ };
425
+ function getShortcutRows(mode, readonly) {
426
+ const navigation = [
427
+ ["Arrow keys", "Select the nearest node in that direction"],
428
+ ["Shift + Arrow", "Select a connection on that side (repeat to cycle)"],
429
+ ["Shift + click, Shift + drag", "Add nodes to the selection, or select with a box"],
430
+ ["Cmd/Ctrl + A", "Select all nodes"]
431
+ ];
432
+ const view = [
433
+ ["Cmd/Ctrl + = or -", "Zoom in or out"],
434
+ ["Cmd/Ctrl + 0", "Zoom to 100%"],
435
+ ["Shift + 1", "Fit the diagram to the view"]
436
+ ];
437
+ const help = [["?", "Show or hide this list"]];
438
+ if (readonly) {
439
+ return [...navigation, ...view, ...help, ["Tab", "Leave the canvas"]];
440
+ }
441
+ const mindmap = mode === "mindmap" ? [
442
+ ["Tab", "Add a child node"],
443
+ ["Enter", "Add a sibling below (Shift + Enter: above)"],
444
+ ["F2, Space, or type", "Edit the label"],
445
+ ["Enter while editing", "Save the label (Shift + Enter: new line)"],
446
+ ["Tab while editing", "Save and add a child"]
447
+ ] : [
448
+ ["Enter or F2", "Edit the label"],
449
+ ["Cmd/Ctrl + Enter", "Save the label"]
450
+ ];
451
+ return [
452
+ ...navigation,
453
+ ...mindmap,
454
+ [
455
+ mode === "mindmap" ? "Enter, F2, or type on a connection" : "Enter or F2 on a connection",
456
+ "Edit the connection label"
457
+ ],
458
+ ["Cmd/Ctrl + /", "Collapse or expand the branch"],
459
+ ["Alt + Arrow", "Nudge the node (add Shift for 1 px)"],
460
+ ["Delete or Backspace", "Delete the selection"],
461
+ ["Cmd/Ctrl + D", "Duplicate the node"],
462
+ ["Cmd/Ctrl + Z, Shift + Cmd/Ctrl + Z", "Undo, redo"],
463
+ ["Cmd/Ctrl + C, X, V", "Copy, cut, paste"],
464
+ ...view,
465
+ ["Esc", "Cancel editing, then the tool, then deselect"],
466
+ [
467
+ mode === "mindmap" ? "Shift + Tab, or Esc then Tab" : "Tab or Shift + Tab",
468
+ "Leave the canvas"
469
+ ],
470
+ ...help
471
+ ];
472
+ }
473
+ var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update", "node:nudge"]);
276
474
  var DEFAULT_EDGE_ROUTE = "curve";
277
475
  var ORTHOGONAL_STUB_LENGTH = 32;
278
476
  var ORTHOGONAL_CORNER_RADIUS = 12;
@@ -446,7 +644,7 @@ function normalizeViewport(viewport) {
446
644
  function normalizeNode(rawNode, index, usedIds) {
447
645
  const type = normalizeNodeType(rawNode?.type);
448
646
  const spec = getNodeSpec(type);
449
- return {
647
+ const node = {
450
648
  id: ensureUniqueId(rawNode?.id, "node", usedIds),
451
649
  type,
452
650
  x: toFiniteNumber(rawNode?.x, index * 28),
@@ -456,6 +654,37 @@ function normalizeNode(rawNode, index, usedIds) {
456
654
  text: rawNode?.text == null ? spec.text : String(rawNode.text),
457
655
  data: isPlainObject(rawNode?.data) ? deepClone(rawNode.data) : {}
458
656
  };
657
+ if (rawNode?.collapsed === true) node.collapsed = true;
658
+ return node;
659
+ }
660
+ function computeHiddenNodeIds(nodes, edges) {
661
+ const collapsed = new Set(nodes.filter((node) => node.collapsed).map((node) => node.id));
662
+ if (!collapsed.size) return /* @__PURE__ */ new Set();
663
+ const outgoing = new Map(nodes.map((node) => [node.id, []]));
664
+ edges.forEach((edge) => {
665
+ if (edge.from.nodeId === edge.to.nodeId) return;
666
+ outgoing.get(edge.from.nodeId)?.push(edge.to.nodeId);
667
+ });
668
+ const candidates = /* @__PURE__ */ new Set();
669
+ collapsed.forEach((rootId) => {
670
+ const queue2 = [...outgoing.get(rootId) || []];
671
+ for (let index = 0; index < queue2.length; index += 1) {
672
+ const id = queue2[index];
673
+ if (id === rootId || candidates.has(id)) continue;
674
+ candidates.add(id);
675
+ queue2.push(...outgoing.get(id) || []);
676
+ }
677
+ });
678
+ const queue = nodes.filter((node) => !candidates.has(node.id) && !collapsed.has(node.id)).map((node) => node.id);
679
+ for (let index = 0; index < queue.length; index += 1) {
680
+ const id = queue[index];
681
+ (outgoing.get(id) || []).forEach((childId) => {
682
+ if (!candidates.has(childId)) return;
683
+ candidates.delete(childId);
684
+ if (!collapsed.has(childId)) queue.push(childId);
685
+ });
686
+ }
687
+ return candidates;
459
688
  }
460
689
  function normalizeEndpoint(rawEndpoint, fallbackPort) {
461
690
  return {
@@ -608,6 +837,15 @@ function getPortPosition(node, port) {
608
837
  return { x: node.x, y: node.y + node.height / 2 };
609
838
  }
610
839
  }
840
+ function getPortHandlePosition(node, port, scale = 1) {
841
+ const point = getPortPosition(node, port);
842
+ const normal = getPortNormal(port);
843
+ const offset = PORT_HANDLE_OFFSET / scale;
844
+ return {
845
+ x: formatNumber(point.x + normal.x * offset),
846
+ y: formatNumber(point.y + normal.y * offset)
847
+ };
848
+ }
611
849
  function getNearestPort(node, point) {
612
850
  return FLOWCHART_PORTS.reduce((best, port) => {
613
851
  const portPoint = getPortPosition(node, port);
@@ -975,6 +1213,68 @@ function getResizeCursor(handle) {
975
1213
  if (handle === "ne" || handle === "sw") return "nesw-resize";
976
1214
  return "nwse-resize";
977
1215
  }
1216
+ function collectGuideLines(nodes) {
1217
+ const x = [];
1218
+ const y = [];
1219
+ nodes.forEach((node) => {
1220
+ const right = node.x + node.width;
1221
+ const bottom = node.y + node.height;
1222
+ [node.x, node.x + node.width / 2, right].forEach(
1223
+ (value) => x.push({ value, start: node.y, end: bottom })
1224
+ );
1225
+ [node.y, node.y + node.height / 2, bottom].forEach(
1226
+ (value) => y.push({ value, start: node.x, end: right })
1227
+ );
1228
+ });
1229
+ const byValue = (a, b) => a.value - b.value;
1230
+ return { x: x.sort(byValue), y: y.sort(byValue) };
1231
+ }
1232
+ function lowerBound(lines, value) {
1233
+ let low = 0;
1234
+ let high = lines.length;
1235
+ while (low < high) {
1236
+ const middle = low + high >> 1;
1237
+ if (lines[middle].value < value) low = middle + 1;
1238
+ else high = middle;
1239
+ }
1240
+ return low;
1241
+ }
1242
+ function snapToGuides(lines, candidates, threshold) {
1243
+ let best = null;
1244
+ candidates.forEach((candidate) => {
1245
+ const index = lowerBound(lines, candidate);
1246
+ [lines[index - 1], lines[index]].forEach((line) => {
1247
+ if (!line) return;
1248
+ const offset = line.value - candidate;
1249
+ if (Math.abs(offset) <= threshold && (!best || Math.abs(offset) < Math.abs(best.offset))) {
1250
+ best = { offset };
1251
+ }
1252
+ });
1253
+ });
1254
+ if (!best) return null;
1255
+ const positions = candidates.map((candidate) => candidate + best.offset);
1256
+ best.lines = lines.filter(
1257
+ (line) => positions.some((position) => Math.abs(line.value - position) < 0.5)
1258
+ );
1259
+ return best;
1260
+ }
1261
+ function guideSegments(axis, lines, box) {
1262
+ const byValue = /* @__PURE__ */ new Map();
1263
+ lines.forEach((line) => {
1264
+ const current = byValue.get(line.value);
1265
+ byValue.set(line.value, {
1266
+ start: Math.min(current?.start ?? Infinity, line.start),
1267
+ end: Math.max(current?.end ?? -Infinity, line.end)
1268
+ });
1269
+ });
1270
+ const boxStart = axis === "x" ? box.top : box.left;
1271
+ const boxEnd = boxStart + (axis === "x" ? box.height : box.width);
1272
+ return [...byValue].map(([value, extent]) => {
1273
+ const start = Math.min(extent.start, boxStart) - GUIDE_OVERHANG;
1274
+ const end = Math.max(extent.end, boxEnd) + GUIDE_OVERHANG;
1275
+ return axis === "x" ? { x1: value, y1: start, x2: value, y2: end } : { x1: start, y1: value, x2: end, y2: value };
1276
+ });
1277
+ }
978
1278
  function getBounds(nodes) {
979
1279
  if (!nodes.length) {
980
1280
  return { left: 0, top: 0, right: 0, bottom: 0 };
@@ -1131,6 +1431,7 @@ var VdFlowchart = class {
1131
1431
  this.documentData = normalizeDocument(options.data || {});
1132
1432
  this.listeners = {};
1133
1433
  this.selection = null;
1434
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
1134
1435
  this.interaction = null;
1135
1436
  this.activeTool = null;
1136
1437
  this.layoutMode = "tree";
@@ -1138,6 +1439,7 @@ var VdFlowchart = class {
1138
1439
  this.destroyed = false;
1139
1440
  this.lastNodePointer = null;
1140
1441
  this.reconnectEdgeId = null;
1442
+ this.edgeCycleAnchor = null;
1141
1443
  this.clipboard = null;
1142
1444
  this.gridPatternId = nextId("flowchart-grid");
1143
1445
  this.markerIds = /* @__PURE__ */ new Map();
@@ -1147,6 +1449,16 @@ var VdFlowchart = class {
1147
1449
  this.history = [];
1148
1450
  this.historyIndex = -1;
1149
1451
  this.isApplyingHistory = false;
1452
+ this.historyBatchDepth = 0;
1453
+ this.historyBatchDirty = false;
1454
+ this.coalesceInsertedText = false;
1455
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
1456
+ this.snapGuides = options.snapGuides !== false;
1457
+ this.minimapEnabled = options.minimap !== false;
1458
+ this.autoLayout = Boolean(options.autoLayout);
1459
+ this.minimapFrame = 0;
1460
+ this.minimapDrag = null;
1461
+ this.shortcutsHelpOpen = false;
1150
1462
  this.autoFit = Boolean(options.autoFit);
1151
1463
  this.readyEmitted = false;
1152
1464
  this.resizeObserver = null;
@@ -1187,6 +1499,7 @@ var VdFlowchart = class {
1187
1499
  if (!ready) return false;
1188
1500
  this.readyEmitted = true;
1189
1501
  if (this.autoFit) this.fitView();
1502
+ else this.scheduleMinimap();
1190
1503
  this.emit("ready", this);
1191
1504
  return true;
1192
1505
  };
@@ -1256,8 +1569,25 @@ var VdFlowchart = class {
1256
1569
  className: "vd-flowchart-toolbar-label",
1257
1570
  text: "100%"
1258
1571
  });
1572
+ this.shortcutsButton = createElement("button", {
1573
+ className: "vd-flowchart-btn vd-flowchart-icon-btn vd-flowchart-shortcuts-btn",
1574
+ title: "Keyboard shortcuts (?)",
1575
+ text: "?"
1576
+ });
1577
+ this.shortcutsButton.setAttribute("type", "button");
1578
+ this.shortcutsButton.setAttribute("data-flowchart-action", "shortcuts");
1579
+ this.shortcutsButton.setAttribute("aria-label", "Keyboard shortcuts");
1580
+ this.shortcutsButton.setAttribute("aria-expanded", "false");
1581
+ this.minimapButton = createToolbarButton({
1582
+ action: "minimap",
1583
+ label: "Toggle minimap",
1584
+ icon: "minimap"
1585
+ });
1586
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
1259
1587
  toolbarLeft.appendChild(this.arrangeSelect);
1260
1588
  toolbarLeft.appendChild(this.clearButton);
1589
+ toolbarRight.appendChild(this.minimapButton);
1590
+ toolbarRight.appendChild(this.shortcutsButton);
1261
1591
  toolbarRight.appendChild(this.zoomLabel);
1262
1592
  this.toolbar.appendChild(toolbarLeft);
1263
1593
  this.toolbar.appendChild(toolbarRight);
@@ -1336,13 +1666,40 @@ var VdFlowchart = class {
1336
1666
  this.previewLayer = svgEl("g", { class: "vd-flowchart-preview" });
1337
1667
  this.nodesLayer = svgEl("g", { class: "vd-flowchart-nodes" });
1338
1668
  this.overlayLayer = svgEl("g", { class: "vd-flowchart-overlay" });
1669
+ this.guidesLayer = svgEl("g", { class: "vd-flowchart-guides" });
1339
1670
  this.world.appendChild(this.gridRect);
1340
1671
  this.world.appendChild(this.edgesLayer);
1341
1672
  this.world.appendChild(this.previewLayer);
1342
1673
  this.world.appendChild(this.nodesLayer);
1343
1674
  this.world.appendChild(this.overlayLayer);
1675
+ this.world.appendChild(this.guidesLayer);
1344
1676
  this.svg.appendChild(this.world);
1345
1677
  this.canvasEl.appendChild(this.svg);
1678
+ this.shortcutsHelp = createElement("div", { className: "vd-flowchart-shortcuts" });
1679
+ this.shortcutsHelp.id = nextId("flowchart-shortcuts");
1680
+ this.shortcutsHelp.setAttribute("role", "dialog");
1681
+ this.shortcutsHelp.setAttribute("aria-label", "Keyboard shortcuts");
1682
+ this.shortcutsHelp.hidden = true;
1683
+ this.shortcutsHelp.addEventListener("pointerdown", (event) => event.stopPropagation());
1684
+ this.shortcutsHelp.addEventListener("wheel", (event) => event.stopPropagation());
1685
+ this.shortcutsButton.setAttribute("aria-controls", this.shortcutsHelp.id);
1686
+ this.canvasEl.appendChild(this.shortcutsHelp);
1687
+ this.minimapEl = createElement("div", { className: "vd-flowchart-minimap" });
1688
+ this.minimapEl.setAttribute("aria-hidden", "true");
1689
+ this.minimapEl.hidden = true;
1690
+ this.minimapSvg = svgEl("svg", {
1691
+ class: "vd-flowchart-minimap-svg",
1692
+ width: MINIMAP_WIDTH,
1693
+ height: MINIMAP_HEIGHT,
1694
+ viewBox: `0 0 ${MINIMAP_WIDTH} ${MINIMAP_HEIGHT}`
1695
+ });
1696
+ this.minimapEl.appendChild(this.minimapSvg);
1697
+ this.minimapEl.addEventListener("pointerdown", (event) => this.handleMinimapPointer(event));
1698
+ this.minimapEl.addEventListener("pointermove", (event) => this.handleMinimapPointer(event));
1699
+ this.minimapEl.addEventListener("pointerup", (event) => this.endMinimapDrag(event));
1700
+ this.minimapEl.addEventListener("pointercancel", (event) => this.endMinimapDrag(event));
1701
+ this.minimapEl.addEventListener("wheel", (event) => event.stopPropagation());
1702
+ this.canvasEl.appendChild(this.minimapEl);
1346
1703
  this.inspectorPanel = createElement("aside", {
1347
1704
  className: "vd-flowchart-panel vd-flowchart-panel--inspector"
1348
1705
  });
@@ -1450,11 +1807,15 @@ var VdFlowchart = class {
1450
1807
  this.graphOutline.appendChild(this.graphList);
1451
1808
  this.root.appendChild(this.graphOutline);
1452
1809
  this.canvasEl.setAttribute("role", "group");
1810
+ this.syncCanvasLabel();
1811
+ this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1812
+ }
1813
+ syncCanvasLabel() {
1814
+ const keys = this.readonly || this.keyboardShortcuts === "basic" ? "Arrow keys select nodes; Enter or F2 edits a label." : "Arrow keys select nodes; Tab adds a child, Enter adds a sibling, F2 edits a label.";
1453
1815
  this.canvasEl.setAttribute(
1454
1816
  "aria-label",
1455
- "Diagram canvas. Arrow keys select nodes; Enter edits a label. Graph outline provides connections."
1817
+ `Diagram canvas. ${keys} Press ? for all shortcuts. Graph outline provides connections.`
1456
1818
  );
1457
- this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1458
1819
  }
1459
1820
  renderGraphOutline() {
1460
1821
  if (!this.graphOutline) return;
@@ -1464,8 +1825,9 @@ var VdFlowchart = class {
1464
1825
  const selected = this.selection?.kind === "node" ? this.selection.id : null;
1465
1826
  this.graphStatus.textContent = selected ? `Selected: ${labels.get(selected)}. ${nodes.length} nodes, ${edges.length} connections.` : `${nodes.length} nodes, ${edges.length} connections.`;
1466
1827
  if (!this.graphOutline.open) return;
1828
+ const hidden = this.getHiddenNodeIds();
1467
1829
  const signature = JSON.stringify([
1468
- nodes.map((node) => [node.id, node.text]),
1830
+ nodes.map((node) => [node.id, node.text, Boolean(node.collapsed), hidden.has(node.id)]),
1469
1831
  edges.map((edge) => [edge.id, edge.from.nodeId, edge.to.nodeId, edge.label])
1470
1832
  ]);
1471
1833
  if (signature !== this.graphSignature) {
@@ -1491,8 +1853,10 @@ var VdFlowchart = class {
1491
1853
  const from = incoming.get(node.id);
1492
1854
  const relationships = [
1493
1855
  to.length ? `Connects to ${to.join(", ")}.` : "No outgoing connections.",
1494
- from.length ? `Connected from ${from.join(", ")}.` : ""
1495
- ];
1856
+ from.length ? `Connected from ${from.join(", ")}.` : "",
1857
+ node.collapsed ? "Branch collapsed." : "",
1858
+ hidden.has(node.id) ? "Hidden in a collapsed branch." : ""
1859
+ ].filter(Boolean);
1496
1860
  this.graphList.appendChild(
1497
1861
  createElement("li", { text: `${labels.get(node.id)}. ${relationships.join(" ")}` })
1498
1862
  );
@@ -1577,6 +1941,157 @@ var VdFlowchart = class {
1577
1941
  if (action === "undo") this.undo();
1578
1942
  if (action === "redo") this.redo();
1579
1943
  if (action === "clear" && !this.readonly) this.clear();
1944
+ if (action === "shortcuts") this.toggleShortcutsHelp();
1945
+ if (action === "minimap") this.setMinimapEnabled(!this.minimapEnabled);
1946
+ }
1947
+ setMinimapEnabled(enabled) {
1948
+ this.minimapEnabled = Boolean(enabled);
1949
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
1950
+ this.drawMinimap();
1951
+ return this;
1952
+ }
1953
+ scheduleMinimap() {
1954
+ if (this.minimapFrame || !hasWindow()) return;
1955
+ const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => setTimeout(callback, 16);
1956
+ this.minimapFrame = raf(() => {
1957
+ this.minimapFrame = 0;
1958
+ this.drawMinimap();
1959
+ });
1960
+ }
1961
+ // World-to-minimap mapping covering every visible node and the viewport.
1962
+ getMinimapTransform() {
1963
+ const viewport = this.documentData.viewport;
1964
+ const width = this.canvasEl.clientWidth;
1965
+ const height = this.canvasEl.clientHeight;
1966
+ const view = {
1967
+ left: -viewport.x / viewport.scale,
1968
+ top: -viewport.y / viewport.scale,
1969
+ right: (width - viewport.x) / viewport.scale,
1970
+ bottom: (height - viewport.y) / viewport.scale
1971
+ };
1972
+ const content = getBounds(this.getVisibleNodes());
1973
+ const left = Math.min(content.left, view.left);
1974
+ const top = Math.min(content.top, view.top);
1975
+ const spanX = Math.max(1, Math.max(content.right, view.right) - left);
1976
+ const spanY = Math.max(1, Math.max(content.bottom, view.bottom) - top);
1977
+ const padding = 8;
1978
+ const scale = Math.min(
1979
+ (MINIMAP_WIDTH - padding * 2) / spanX,
1980
+ (MINIMAP_HEIGHT - padding * 2) / spanY
1981
+ );
1982
+ return {
1983
+ scale,
1984
+ offsetX: (MINIMAP_WIDTH - spanX * scale) / 2 - left * scale,
1985
+ offsetY: (MINIMAP_HEIGHT - spanY * scale) / 2 - top * scale,
1986
+ view
1987
+ };
1988
+ }
1989
+ drawMinimap() {
1990
+ if (this.destroyed || !this.minimapEl) return;
1991
+ const nodes = this.getVisibleNodes();
1992
+ const visible = this.minimapEnabled && nodes.length > 0 && this.canvasEl.clientWidth >= MINIMAP_MIN_CANVAS_WIDTH;
1993
+ this.minimapEl.hidden = !visible;
1994
+ if (!visible) return;
1995
+ const transform = this.minimapDrag?.transform || this.getMinimapTransform();
1996
+ const view = this.getMinimapTransform().view;
1997
+ const map = (x, y) => ({
1998
+ x: formatNumber(x * transform.scale + transform.offsetX),
1999
+ y: formatNumber(y * transform.scale + transform.offsetY)
2000
+ });
2001
+ clearChildren(this.minimapSvg);
2002
+ nodes.forEach((node) => {
2003
+ const point = map(node.x, node.y);
2004
+ const selected = this.isNodeSelected(node.id);
2005
+ this.minimapSvg.appendChild(
2006
+ svgEl("rect", {
2007
+ class: `vd-flowchart-minimap-node${selected ? " is-selected" : ""}`,
2008
+ x: point.x,
2009
+ y: point.y,
2010
+ width: formatNumber(Math.max(1.5, node.width * transform.scale)),
2011
+ height: formatNumber(Math.max(1.5, node.height * transform.scale)),
2012
+ rx: 1.5
2013
+ })
2014
+ );
2015
+ });
2016
+ const corner = map(view.left, view.top);
2017
+ this.minimapSvg.appendChild(
2018
+ svgEl("rect", {
2019
+ class: "vd-flowchart-minimap-viewport",
2020
+ x: corner.x,
2021
+ y: corner.y,
2022
+ width: formatNumber((view.right - view.left) * transform.scale),
2023
+ height: formatNumber((view.bottom - view.top) * transform.scale)
2024
+ })
2025
+ );
2026
+ }
2027
+ // Centre the main view on the world point under the minimap pointer. The
2028
+ // mapping is frozen for the whole drag so the map does not shift under it.
2029
+ handleMinimapPointer(event) {
2030
+ event.stopPropagation();
2031
+ if (event.type === "pointerdown") {
2032
+ if (event.button != null && event.button !== 0) return;
2033
+ event.preventDefault();
2034
+ this.minimapDrag = { pointerId: event.pointerId, transform: this.getMinimapTransform() };
2035
+ try {
2036
+ this.minimapEl.setPointerCapture?.(event.pointerId);
2037
+ } catch {
2038
+ }
2039
+ }
2040
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2041
+ const rect = this.minimapEl.getBoundingClientRect();
2042
+ const { transform } = this.minimapDrag;
2043
+ this.centerViewOn(
2044
+ (event.clientX - rect.left - transform.offsetX) / transform.scale,
2045
+ (event.clientY - rect.top - transform.offsetY) / transform.scale
2046
+ );
2047
+ }
2048
+ endMinimapDrag(event) {
2049
+ event.stopPropagation();
2050
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2051
+ this.minimapDrag = null;
2052
+ this.syncJsonTextarea();
2053
+ this.emitViewportChange("viewport:pan");
2054
+ this.drawMinimap();
2055
+ }
2056
+ centerViewOn(worldX, worldY) {
2057
+ const viewport = this.documentData.viewport;
2058
+ const width = this.canvasEl.clientWidth || 800;
2059
+ const height = this.canvasEl.clientHeight || 560;
2060
+ viewport.x = formatNumber(width / 2 - worldX * viewport.scale);
2061
+ viewport.y = formatNumber(height / 2 - worldY * viewport.scale);
2062
+ this.render({ inspector: false, json: false });
2063
+ return this;
2064
+ }
2065
+ toggleShortcutsHelp(force) {
2066
+ const open = typeof force === "boolean" ? force : !this.shortcutsHelpOpen;
2067
+ this.shortcutsHelpOpen = open;
2068
+ this.shortcutsButton.setAttribute("aria-expanded", open ? "true" : "false");
2069
+ if (open) this.renderShortcutsHelp();
2070
+ this.shortcutsHelp.hidden = !open;
2071
+ if (!open && this.shortcutsHelp.contains(document.activeElement)) this.canvasEl.focus();
2072
+ return this;
2073
+ }
2074
+ renderShortcutsHelp() {
2075
+ clearChildren(this.shortcutsHelp);
2076
+ const header = createElement("div", { className: "vd-flowchart-shortcuts-header" });
2077
+ header.appendChild(
2078
+ createElement("h4", { className: "vd-flowchart-panel-title", text: "Keyboard shortcuts" })
2079
+ );
2080
+ const close = createElement("button", {
2081
+ className: "vd-flowchart-btn vd-flowchart-icon-btn",
2082
+ text: "\xD7"
2083
+ });
2084
+ close.setAttribute("type", "button");
2085
+ close.setAttribute("aria-label", "Close keyboard shortcuts");
2086
+ close.addEventListener("click", () => this.toggleShortcutsHelp(false));
2087
+ header.appendChild(close);
2088
+ this.shortcutsHelp.appendChild(header);
2089
+ const list = createElement("dl", { className: "vd-flowchart-shortcuts-list" });
2090
+ getShortcutRows(this.keyboardShortcuts, this.readonly).forEach(([keys, action]) => {
2091
+ list.appendChild(createElement("dt", { text: keys }));
2092
+ list.appendChild(createElement("dd", { text: action }));
2093
+ });
2094
+ this.shortcutsHelp.appendChild(list);
1580
2095
  }
1581
2096
  handleArrangeChange(event) {
1582
2097
  if (this.readonly) return;
@@ -1685,38 +2200,48 @@ var VdFlowchart = class {
1685
2200
  );
1686
2201
  }
1687
2202
  }
2203
+ // Keys the editor consumes stop here, so page-level shortcuts (a docs
2204
+ // site's "/" search, a host's Escape handler) do not also fire.
1688
2205
  handleKeyDown(event) {
1689
- if (event.target && (event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA" || event.target.tagName === "SELECT")) {
2206
+ const alreadyHandled = event.defaultPrevented;
2207
+ this.handleEditorKey(event);
2208
+ if (!alreadyHandled && event.defaultPrevented) event.stopPropagation();
2209
+ }
2210
+ handleEditorKey(event) {
2211
+ const target = event.target;
2212
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) {
1690
2213
  return;
1691
2214
  }
1692
- if (event.target === this.canvasEl) {
1693
- const nodes = this.documentData.nodes;
1694
- const index = nodes.findIndex((node) => node.id === this.selection?.id);
1695
- let next = -1;
1696
- if (event.key === "ArrowDown" || event.key === "ArrowRight")
1697
- next = (index + 1) % nodes.length;
1698
- if (event.key === "ArrowUp" || event.key === "ArrowLeft")
1699
- next = index < 0 ? nodes.length - 1 : (index - 1 + nodes.length) % nodes.length;
1700
- if (event.key === "Home") next = 0;
1701
- if (event.key === "End") next = nodes.length - 1;
1702
- if (nodes[next]) {
1703
- event.preventDefault();
1704
- this.selectNode(nodes[next].id);
1705
- return;
1706
- }
1707
- if (event.key === "Enter" && !this.readonly && this.selection?.kind === "node") {
1708
- event.preventDefault();
1709
- this.startTextEdit(this.selection.id);
1710
- return;
1711
- }
2215
+ if (event.isComposing) return;
2216
+ const key = event.key;
2217
+ const modKey = event.metaKey || event.ctrlKey;
2218
+ if (key === "Escape" && this.shortcutsHelpOpen) {
2219
+ event.preventDefault();
2220
+ this.toggleShortcutsHelp(false);
2221
+ return;
2222
+ }
2223
+ if (target === this.canvasEl && this.handleCanvasKeyDown(event)) {
2224
+ event.preventDefault();
2225
+ return;
1712
2226
  }
1713
2227
  if (this.readonly) return;
1714
- if (event.key === "Escape" && this.activeTool) {
2228
+ if (key === "Escape") {
2229
+ if (this.activeTool) this.setActiveTool(null);
2230
+ else if (this.reconnectEdgeId) {
2231
+ this.reconnectEdgeId = null;
2232
+ this.render({ inspector: false, json: false });
2233
+ } else if (this.selection) this.select(null);
2234
+ else return;
1715
2235
  event.preventDefault();
1716
- this.setActiveTool(null);
1717
2236
  return;
1718
2237
  }
1719
- const modKey = event.metaKey || event.ctrlKey;
2238
+ if (modKey && (key === "d" || key === "D")) {
2239
+ if (this.selection?.kind === "node") {
2240
+ event.preventDefault();
2241
+ this.duplicateSelection();
2242
+ }
2243
+ return;
2244
+ }
1720
2245
  if (modKey && (event.key === "z" || event.key === "Z")) {
1721
2246
  event.preventDefault();
1722
2247
  if (event.shiftKey) {
@@ -1755,11 +2280,248 @@ var VdFlowchart = class {
1755
2280
  if (!this.selection) return;
1756
2281
  if (event.key === "Backspace" || event.key === "Delete") {
1757
2282
  event.preventDefault();
2283
+ if (this.selection.kind === "node" && !this.hasMultiSelection()) {
2284
+ const removedId = this.selection.id;
2285
+ const parent = this.getParentNode(removedId);
2286
+ const sibling = parent ? this.getChildNodes(parent.id).find((node) => node.id !== removedId) : null;
2287
+ if (this.deleteSelection()) {
2288
+ const next = parent && this.findNode(parent.id) || sibling;
2289
+ if (next) this.select({ kind: "node", id: next.id });
2290
+ }
2291
+ return;
2292
+ }
1758
2293
  this.deleteSelection();
1759
2294
  }
1760
2295
  }
2296
+ // Keys that only apply while the canvas itself has focus. Returns true when
2297
+ // the key was handled (the caller prevents the default action).
2298
+ handleCanvasKeyDown(event) {
2299
+ const key = event.key;
2300
+ const modKey = event.metaKey || event.ctrlKey;
2301
+ const mindmap = this.keyboardShortcuts === "mindmap";
2302
+ const selectedNode = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2303
+ const selectedEdge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2304
+ const movable = !this.readonly && Boolean(selectedNode);
2305
+ const editable = movable && !this.hasMultiSelection();
2306
+ if (key === "?" && !modKey && !event.altKey) {
2307
+ this.toggleShortcutsHelp();
2308
+ return true;
2309
+ }
2310
+ if (modKey && !event.altKey) {
2311
+ if (key === "=" || key === "+") return Boolean(this.zoomIn());
2312
+ if (key === "-" || key === "_") return Boolean(this.zoomOut());
2313
+ if (key === "0") return Boolean(this.zoomTo(1));
2314
+ if (key === "a" || key === "A") {
2315
+ this.selectNodes(this.getVisibleNodes().map((node) => node.id));
2316
+ return true;
2317
+ }
2318
+ if (key === "/" && selectedNode && !this.hasMultiSelection()) {
2319
+ this.toggleCollapsed(selectedNode.id);
2320
+ return true;
2321
+ }
2322
+ return false;
2323
+ }
2324
+ if (event.shiftKey && !event.altKey && (key === "!" || event.code === "Digit1")) {
2325
+ return Boolean(this.fitView());
2326
+ }
2327
+ const direction = ARROW_DIRECTIONS[key];
2328
+ if (direction) {
2329
+ if (event.altKey) {
2330
+ if (movable) {
2331
+ this.nudgeNodes(this.getSelectedNodeIds(), direction, event.shiftKey ? 1 : this.gridSize);
2332
+ }
2333
+ return true;
2334
+ }
2335
+ if (event.shiftKey) {
2336
+ const anchorId = selectedNode?.id ?? (selectedEdge ? this.edgeCycleAnchor : null);
2337
+ return anchorId ? this.selectSideEdge(anchorId, direction) : false;
2338
+ }
2339
+ this.navigateSelection(direction);
2340
+ return true;
2341
+ }
2342
+ const nodes = this.getVisibleNodes();
2343
+ if ((key === "Home" || key === "End") && nodes.length) {
2344
+ this.selectNode(nodes[key === "Home" ? 0 : nodes.length - 1].id);
2345
+ return true;
2346
+ }
2347
+ if (selectedEdge && !this.readonly && !event.altKey) {
2348
+ if (key === "F2" || key === "Enter" || key === " " && mindmap) {
2349
+ return this.startEdgeLabelEdit(selectedEdge.id);
2350
+ }
2351
+ if (mindmap && key.length === 1 && key !== " ") {
2352
+ return this.startEdgeLabelEdit(selectedEdge.id, { initialText: key });
2353
+ }
2354
+ return false;
2355
+ }
2356
+ if (!editable || event.altKey) return false;
2357
+ if (key === "F2" || key === "Enter" && !mindmap || key === " " && mindmap) {
2358
+ this.startTextEdit(selectedNode.id);
2359
+ return true;
2360
+ }
2361
+ if (!mindmap) return false;
2362
+ if (key === "Tab" && !event.shiftKey) {
2363
+ this.insertBranchNode(selectedNode.id, { edit: true });
2364
+ return true;
2365
+ }
2366
+ if (key === "Enter") {
2367
+ this.insertSiblingNode(selectedNode.id, { before: event.shiftKey, edit: true });
2368
+ return true;
2369
+ }
2370
+ if (key.length === 1 && key !== " " && isNodeTextEditable(selectedNode)) {
2371
+ this.startTextEdit(selectedNode.id, { initialText: key });
2372
+ return true;
2373
+ }
2374
+ return false;
2375
+ }
2376
+ // Move the selection to the nearest node in `direction`; with nothing (or an
2377
+ // edge) selected, start from the node closest to the centre of the view.
2378
+ navigateSelection(direction) {
2379
+ const nodes = this.getVisibleNodes();
2380
+ if (!nodes.length) return false;
2381
+ const current = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2382
+ const edge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2383
+ const next = current ? findSpatialNeighbor(nodes, current, direction) : edge ? this.getEdgeEndInDirection(edge, direction) : findNearestNode(nodes, this.getViewportCenter());
2384
+ if (!next) return false;
2385
+ this.selectNode(next.id);
2386
+ this.revealNode(next.id);
2387
+ return true;
2388
+ }
2389
+ // The end of `edge` that best matches `direction`, measured from the label
2390
+ // anchor so either end can be reached from a selected connection.
2391
+ getEdgeEndInDirection(edge, direction) {
2392
+ const fromNode = this.findNode(edge.from.nodeId);
2393
+ const toNode = this.findNode(edge.to.nodeId);
2394
+ if (!fromNode || !toNode) return fromNode || toNode;
2395
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
2396
+ const vector = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2397
+ const score = (node) => (node.x + node.width / 2 - labelX) * vector[0] + (node.y + node.height / 2 - labelY) * vector[1];
2398
+ return score(toNode) >= score(fromNode) ? toNode : fromNode;
2399
+ }
2400
+ // Connections attached to `nodeId` on the `direction` side, outgoing or
2401
+ // incoming, in document order.
2402
+ getSideEdges(nodeId, direction) {
2403
+ const port = DIRECTION_PORTS[direction];
2404
+ return this.documentData.edges.filter(
2405
+ (edge) => edge.from.nodeId === nodeId && edge.from.port === port || edge.to.nodeId === nodeId && edge.to.port === port
2406
+ );
2407
+ }
2408
+ // Select a connection on one side of a node; repeating cycles through them.
2409
+ selectSideEdge(nodeId, direction) {
2410
+ const edges = this.getSideEdges(nodeId, direction);
2411
+ if (!edges.length) return false;
2412
+ const currentIndex = this.selection?.kind === "edge" ? edges.findIndex((edge) => edge.id === this.selection.id) : -1;
2413
+ const next = edges[(currentIndex + 1) % edges.length];
2414
+ this.select({ kind: "edge", id: next.id });
2415
+ this.edgeCycleAnchor = nodeId;
2416
+ return true;
2417
+ }
2418
+ nudgeNode(nodeId, direction, distance) {
2419
+ const [node] = this.nudgeNodes([nodeId], direction, distance);
2420
+ return node || null;
2421
+ }
2422
+ // Move nodes together; repeated nudges of the same selection coalesce into
2423
+ // one undo step.
2424
+ nudgeNodes(nodeIds, direction, distance) {
2425
+ const step = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2426
+ if (this.readonly || !step) return [];
2427
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2428
+ if (!nodes.length) return [];
2429
+ nodes.forEach((node) => {
2430
+ node.x = formatNumber(node.x + step[0] * distance);
2431
+ node.y = formatNumber(node.y + step[1] * distance);
2432
+ });
2433
+ this.render({ inspector: true, json: true });
2434
+ this.emitChange(
2435
+ "node:nudge",
2436
+ nodes.length > 1 ? { nodeIds: nodes.map((node) => node.id) } : { node: deepClone(nodes[0]) }
2437
+ );
2438
+ return nodes.map((node) => deepClone(node));
2439
+ }
2440
+ zoomTo(scale) {
2441
+ const viewport = this.documentData.viewport;
2442
+ const width = this.canvasEl.clientWidth || 800;
2443
+ const height = this.canvasEl.clientHeight || 560;
2444
+ this.scaleAround(
2445
+ clamp(scale, MIN_SCALE, MAX_SCALE) / viewport.scale,
2446
+ width / 2,
2447
+ height / 2,
2448
+ "viewport:zoom"
2449
+ );
2450
+ return this;
2451
+ }
2452
+ duplicateSelection() {
2453
+ if (this.readonly || this.selection?.kind !== "node") return null;
2454
+ if (this.hasMultiSelection()) return this.pasteNodeGroup(this.captureNodeGroup());
2455
+ const node = this.findNode(this.selection.id);
2456
+ return node ? this.pasteNodeData(node) : null;
2457
+ }
2458
+ // Selected nodes plus the connections that run between them.
2459
+ captureNodeGroup() {
2460
+ const ids = new Set(this.getSelectedNodeIds());
2461
+ return {
2462
+ nodes: deepClone(this.documentData.nodes.filter((node) => ids.has(node.id))),
2463
+ edges: deepClone(
2464
+ this.documentData.edges.filter(
2465
+ (edge) => ids.has(edge.from.nodeId) && ids.has(edge.to.nodeId)
2466
+ )
2467
+ )
2468
+ };
2469
+ }
2470
+ // Paste a captured group offset by one step, remapping ids so internal
2471
+ // connections follow their copies. One change, one undo step.
2472
+ pasteNodeGroup(group) {
2473
+ if (this.readonly || !group?.nodes?.length) return [];
2474
+ const usedNodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2475
+ const idMap = /* @__PURE__ */ new Map();
2476
+ const nodes = group.nodes.map((data, index) => {
2477
+ const node = normalizeNode(
2478
+ { ...data, id: void 0, x: data.x + 24, y: data.y + 24 },
2479
+ this.documentData.nodes.length + index,
2480
+ usedNodeIds
2481
+ );
2482
+ idMap.set(data.id, node.id);
2483
+ return node;
2484
+ });
2485
+ this.documentData.nodes.push(...nodes);
2486
+ const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2487
+ const usedEdgeIds = new Set(this.documentData.edges.map((edge) => edge.id));
2488
+ group.edges.forEach((data, index) => {
2489
+ const edge = normalizeEdge(
2490
+ {
2491
+ ...data,
2492
+ id: void 0,
2493
+ from: { ...data.from, nodeId: idMap.get(data.from.nodeId) },
2494
+ to: { ...data.to, nodeId: idMap.get(data.to.nodeId) }
2495
+ },
2496
+ this.documentData.edges.length + index,
2497
+ nodeIds,
2498
+ usedEdgeIds
2499
+ );
2500
+ if (edge) this.documentData.edges.push(edge);
2501
+ });
2502
+ const ids = nodes.map((node) => node.id);
2503
+ this.selectNodes(ids);
2504
+ this.emitChange("nodes:add", { nodeIds: ids });
2505
+ return nodes.map((node) => deepClone(node));
2506
+ }
2507
+ pasteNodeData(data) {
2508
+ const usedIds = new Set(this.documentData.nodes.map((node2) => node2.id));
2509
+ const node = normalizeNode(
2510
+ { ...data, id: void 0, collapsed: void 0, x: data.x + 24, y: data.y + 24 },
2511
+ this.documentData.nodes.length,
2512
+ usedIds
2513
+ );
2514
+ this.documentData.nodes.push(node);
2515
+ this.select({ kind: "node", id: node.id });
2516
+ this.emitChange("node:add", { node: deepClone(node) });
2517
+ return deepClone(node);
2518
+ }
1761
2519
  copySelection() {
1762
2520
  if (!this.selection) return false;
2521
+ if (this.hasMultiSelection()) {
2522
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2523
+ return true;
2524
+ }
1763
2525
  if (this.selection.kind === "node") {
1764
2526
  const node = this.findNode(this.selection.id);
1765
2527
  if (!node) return false;
@@ -1774,22 +2536,14 @@ var VdFlowchart = class {
1774
2536
  pasteClipboard() {
1775
2537
  if (!this.clipboard || this.readonly) return false;
1776
2538
  if (this.clipboard.kind === "node") {
1777
- const usedIds2 = new Set(this.documentData.nodes.map((node2) => node2.id));
1778
- const node = normalizeNode(
1779
- {
1780
- ...this.clipboard.data,
1781
- id: void 0,
1782
- x: this.clipboard.data.x + 24,
1783
- y: this.clipboard.data.y + 24
1784
- },
1785
- this.documentData.nodes.length,
1786
- usedIds2
1787
- );
1788
- this.documentData.nodes.push(node);
1789
- this.select({ kind: "node", id: node.id });
1790
- this.emitChange("node:add", { node: deepClone(node) });
2539
+ this.pasteNodeData(this.clipboard.data);
1791
2540
  return true;
1792
2541
  }
2542
+ if (this.clipboard.kind === "nodes") {
2543
+ const pasted = this.pasteNodeGroup(this.clipboard.data);
2544
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2545
+ return pasted.length > 0;
2546
+ }
1793
2547
  const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
1794
2548
  const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
1795
2549
  const edge = normalizeEdge(
@@ -1816,16 +2570,16 @@ var VdFlowchart = class {
1816
2570
  const edgeTarget = event.target.closest("[data-edge-id]");
1817
2571
  if (edgeTarget && !event.target.closest("[data-edge-endpoint]")) {
1818
2572
  const edgeId = edgeTarget.getAttribute("data-edge-id");
1819
- if (edgeId && this.findEdge(edgeId)) {
1820
- this.reconnectEdgeId = edgeId;
1821
- this.select({ kind: "edge", id: edgeId });
2573
+ if (edgeId && this.startEdgeLabelEdit(edgeId)) {
1822
2574
  event.preventDefault();
1823
2575
  event.stopPropagation();
1824
2576
  return;
1825
2577
  }
1826
2578
  }
1827
2579
  const nodeTarget = event.target.closest("[data-node-id]");
1828
- if (!nodeTarget || event.target.closest("[data-edge-id]")) return;
2580
+ if (!nodeTarget || event.target.closest("[data-edge-id]") || event.target.closest("[data-collapse-toggle]")) {
2581
+ return;
2582
+ }
1829
2583
  const nodeId = nodeTarget.getAttribute("data-node-id");
1830
2584
  if (this.startTextEdit(nodeId)) {
1831
2585
  event.preventDefault();
@@ -1835,7 +2589,7 @@ var VdFlowchart = class {
1835
2589
  handleClick(event) {
1836
2590
  if (this.readonly || event.detail < 2) return;
1837
2591
  const nodeTarget = event.target.closest("[data-node-id]");
1838
- if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]"))
2592
+ if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]") || event.target.closest("[data-collapse-toggle]"))
1839
2593
  return;
1840
2594
  if (this.startTextEdit(nodeTarget.getAttribute("data-node-id"))) {
1841
2595
  event.preventDefault();
@@ -1848,6 +2602,13 @@ var VdFlowchart = class {
1848
2602
  this.stopTextEdit({ commit: true });
1849
2603
  }
1850
2604
  this.canvasEl.focus();
2605
+ const collapseTarget = event.target.closest("[data-collapse-toggle]");
2606
+ if (collapseTarget) {
2607
+ this.lastNodePointer = null;
2608
+ this.toggleCollapsed(collapseTarget.getAttribute("data-collapse-toggle"));
2609
+ event.preventDefault();
2610
+ return;
2611
+ }
1851
2612
  const nodeTarget = event.target.closest("[data-node-id]");
1852
2613
  const portTarget = event.target.closest("[data-port]");
1853
2614
  const resizeTarget = event.target.closest("[data-resize-handle]");
@@ -1883,6 +2644,13 @@ var VdFlowchart = class {
1883
2644
  event.preventDefault();
1884
2645
  return;
1885
2646
  }
2647
+ if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && event.shiftKey) {
2648
+ this.reconnectEdgeId = null;
2649
+ this.lastNodePointer = null;
2650
+ this.toggleNodeSelection(nodeTarget.getAttribute("data-node-id"));
2651
+ event.preventDefault();
2652
+ return;
2653
+ }
1886
2654
  if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && !this.readonly) {
1887
2655
  if (this.activeTool === "arrow") {
1888
2656
  this.select({ kind: "node", id: nodeTarget.getAttribute("data-node-id") });
@@ -1909,6 +2677,9 @@ var VdFlowchart = class {
1909
2677
  this.interaction = {
1910
2678
  kind: "connect",
1911
2679
  pointerId: event.pointerId,
2680
+ startClientX: event.clientX,
2681
+ startClientY: event.clientY,
2682
+ moved: false,
1912
2683
  source: { nodeId, port },
1913
2684
  target: null,
1914
2685
  previousSnap: null,
@@ -1959,22 +2730,27 @@ var VdFlowchart = class {
1959
2730
  if (nodeTarget) {
1960
2731
  this.reconnectEdgeId = null;
1961
2732
  const nodeId = nodeTarget.getAttribute("data-node-id");
1962
- this.select({ kind: "node", id: nodeId });
2733
+ const groupIds = this.hasMultiSelection() && this.isNodeSelected(nodeId) ? this.getSelectedNodeIds() : [nodeId];
2734
+ if (groupIds.length > 1) this.selectNodes(groupIds, { primary: nodeId });
2735
+ else this.select({ kind: "node", id: nodeId });
1963
2736
  if (!this.readonly) {
1964
- const node = this.findNode(nodeId);
1965
- const world = this.clientToWorld(event.clientX, event.clientY);
1966
- this.interaction = {
1967
- kind: "drag-node",
1968
- pointerId: event.pointerId,
1969
- nodeId,
1970
- offsetX: world.x - node.x,
1971
- offsetY: world.y - node.y,
1972
- moved: false
1973
- };
2737
+ this.interaction = this.createDragInteraction(event, nodeId, groupIds);
1974
2738
  this.capturePointer(event.pointerId);
1975
2739
  }
1976
2740
  return;
1977
2741
  }
2742
+ if (event.shiftKey) {
2743
+ const startWorld = this.clientToWorld(event.clientX, event.clientY);
2744
+ this.interaction = {
2745
+ kind: "marquee",
2746
+ pointerId: event.pointerId,
2747
+ startWorld,
2748
+ baseIds: this.selection?.kind === "node" ? this.getSelectedNodeIds() : []
2749
+ };
2750
+ this.capturePointer(event.pointerId);
2751
+ event.preventDefault();
2752
+ return;
2753
+ }
1978
2754
  this.select(null);
1979
2755
  this.reconnectEdgeId = null;
1980
2756
  this.interaction = {
@@ -1989,20 +2765,123 @@ var VdFlowchart = class {
1989
2765
  this.capturePointer(event.pointerId);
1990
2766
  event.preventDefault();
1991
2767
  }
2768
+ createDragInteraction(event, nodeId, nodeIds) {
2769
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2770
+ const moving = new Set(nodes.map((node) => node.id));
2771
+ return {
2772
+ kind: "drag-node",
2773
+ pointerId: event.pointerId,
2774
+ nodeId,
2775
+ nodeIds: nodes.map((node) => node.id),
2776
+ startWorld: this.clientToWorld(event.clientX, event.clientY),
2777
+ origins: new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }])),
2778
+ bounds: getBounds(nodes),
2779
+ guideLines: this.snapGuides ? collectGuideLines(this.getVisibleNodes().filter((node) => !moving.has(node.id))) : null,
2780
+ snapDisabled: false,
2781
+ moved: false
2782
+ };
2783
+ }
2784
+ // Translate every dragged node by the pointer delta, snapping the group's
2785
+ // bounds to the nearest alignment guide on each axis. Alt turns snapping
2786
+ // off for the rest of the gesture.
2787
+ moveDraggedNodes(event) {
2788
+ const interaction = this.interaction;
2789
+ const world = this.clientToWorld(event.clientX, event.clientY);
2790
+ let dx = world.x - interaction.startWorld.x;
2791
+ let dy = world.y - interaction.startWorld.y;
2792
+ if (event.altKey) interaction.snapDisabled = true;
2793
+ let guides = [];
2794
+ if (interaction.guideLines && !interaction.snapDisabled) {
2795
+ const box = interaction.bounds;
2796
+ const threshold = GUIDE_SNAP_DISTANCE / this.documentData.viewport.scale;
2797
+ const left = box.left + dx;
2798
+ const top = box.top + dy;
2799
+ const width = box.right - box.left;
2800
+ const height = box.bottom - box.top;
2801
+ const snapX = snapToGuides(
2802
+ interaction.guideLines.x,
2803
+ [left, left + width / 2, left + width],
2804
+ threshold
2805
+ );
2806
+ const snapY = snapToGuides(
2807
+ interaction.guideLines.y,
2808
+ [top, top + height / 2, top + height],
2809
+ threshold
2810
+ );
2811
+ if (snapX) dx += snapX.offset;
2812
+ if (snapY) dy += snapY.offset;
2813
+ const snapped = { left: box.left + dx, top: box.top + dy, width, height };
2814
+ if (snapX) guides = guides.concat(guideSegments("x", snapX.lines, snapped));
2815
+ if (snapY) guides = guides.concat(guideSegments("y", snapY.lines, snapped));
2816
+ }
2817
+ let changed = false;
2818
+ interaction.nodeIds.forEach((id) => {
2819
+ const node = this.findNode(id);
2820
+ const origin = interaction.origins.get(id);
2821
+ if (!node || !origin) return;
2822
+ const nextX = formatNumber(origin.x + dx);
2823
+ const nextY = formatNumber(origin.y + dy);
2824
+ if (nextX === node.x && nextY === node.y) return;
2825
+ node.x = nextX;
2826
+ node.y = nextY;
2827
+ changed = true;
2828
+ this.renderDraggedNode(node);
2829
+ });
2830
+ if (changed) interaction.moved = true;
2831
+ this.renderGuides(guides);
2832
+ }
2833
+ // Live marquee: select every visible node the box touches, added to the
2834
+ // selection the gesture started from.
2835
+ updateMarquee(event) {
2836
+ const interaction = this.interaction;
2837
+ const world = this.clientToWorld(event.clientX, event.clientY);
2838
+ const box = {
2839
+ left: Math.min(interaction.startWorld.x, world.x),
2840
+ top: Math.min(interaction.startWorld.y, world.y),
2841
+ right: Math.max(interaction.startWorld.x, world.x),
2842
+ bottom: Math.max(interaction.startWorld.y, world.y)
2843
+ };
2844
+ clearChildren(this.guidesLayer);
2845
+ this.guidesLayer.appendChild(
2846
+ svgEl("rect", {
2847
+ class: "vd-flowchart-marquee",
2848
+ x: formatNumber(box.left),
2849
+ y: formatNumber(box.top),
2850
+ width: formatNumber(box.right - box.left),
2851
+ height: formatNumber(box.bottom - box.top)
2852
+ })
2853
+ );
2854
+ const hits = this.getVisibleNodes().filter(
2855
+ (node) => node.x <= box.right && node.x + node.width >= box.left && node.y <= box.bottom && node.y + node.height >= box.top
2856
+ ).map((node) => node.id);
2857
+ const ids = [.../* @__PURE__ */ new Set([...interaction.baseIds, ...hits])];
2858
+ const key = ids.join(",");
2859
+ if (key === interaction.lastKey) return;
2860
+ interaction.lastKey = key;
2861
+ this.selectNodes(ids, { primary: hits[hits.length - 1] });
2862
+ }
2863
+ renderGuides(guides) {
2864
+ clearChildren(this.guidesLayer);
2865
+ guides.forEach((guide) => {
2866
+ this.guidesLayer.appendChild(
2867
+ svgEl("line", {
2868
+ class: "vd-flowchart-guide",
2869
+ x1: guide.x1,
2870
+ y1: guide.y1,
2871
+ x2: guide.x2,
2872
+ y2: guide.y2
2873
+ })
2874
+ );
2875
+ });
2876
+ }
1992
2877
  handlePointerMove(event) {
1993
2878
  if (!this.interaction || this.interaction.pointerId !== event.pointerId) return;
1994
2879
  if (this.interaction.kind === "drag-node") {
1995
- const node = this.findNode(this.interaction.nodeId);
1996
- if (!node) return;
1997
- const world = this.clientToWorld(event.clientX, event.clientY);
1998
- const nextX = formatNumber(world.x - this.interaction.offsetX);
1999
- const nextY = formatNumber(world.y - this.interaction.offsetY);
2000
- if (nextX !== node.x || nextY !== node.y) {
2001
- node.x = nextX;
2002
- node.y = nextY;
2003
- this.interaction.moved = true;
2004
- this.renderDraggedNode(node);
2005
- }
2880
+ this.moveDraggedNodes(event);
2881
+ return;
2882
+ }
2883
+ if (this.interaction.kind === "marquee") {
2884
+ this.updateMarquee(event);
2006
2885
  return;
2007
2886
  }
2008
2887
  if (this.interaction.kind === "resize-node") {
@@ -2037,6 +2916,14 @@ var VdFlowchart = class {
2037
2916
  return;
2038
2917
  }
2039
2918
  if (this.interaction.kind === "connect") {
2919
+ if (!this.interaction.moved) {
2920
+ const travel = Math.hypot(
2921
+ event.clientX - this.interaction.startClientX,
2922
+ event.clientY - this.interaction.startClientY
2923
+ );
2924
+ if (!(travel > PORT_CLICK_SLOP)) return;
2925
+ this.interaction.moved = true;
2926
+ }
2040
2927
  const world = this.clientToWorld(event.clientX, event.clientY);
2041
2928
  const snapTarget = this.findConnectionTarget(
2042
2929
  world,
@@ -2123,10 +3010,19 @@ var VdFlowchart = class {
2123
3010
  }
2124
3011
  return;
2125
3012
  }
3013
+ if (interaction.kind === "marquee") {
3014
+ clearChildren(this.guidesLayer);
3015
+ return;
3016
+ }
2126
3017
  if (interaction.kind === "drag-node") {
3018
+ clearChildren(this.guidesLayer);
2127
3019
  if (interaction.moved) {
2128
3020
  this.render({ inspector: true, json: true });
2129
- this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3021
+ if (interaction.nodeIds.length > 1) {
3022
+ this.emitChange("nodes:move", { nodeIds: [...interaction.nodeIds] });
3023
+ } else {
3024
+ this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3025
+ }
2130
3026
  } else {
2131
3027
  this.render({ inspector: false, json: false });
2132
3028
  }
@@ -2142,6 +3038,14 @@ var VdFlowchart = class {
2142
3038
  return;
2143
3039
  }
2144
3040
  if (interaction.kind === "connect") {
3041
+ if (!interaction.moved && this.activeTool !== "arrow") {
3042
+ this.render({ inspector: false, json: false });
3043
+ this.insertBranchNode(interaction.source.nodeId, {
3044
+ direction: PORT_DIRECTIONS[interaction.source.port],
3045
+ edit: true
3046
+ });
3047
+ return;
3048
+ }
2145
3049
  const world = this.clientToWorld(event.clientX || 0, event.clientY || 0);
2146
3050
  const snapTarget = interaction.target ? { node: this.findNode(interaction.target.nodeId), port: interaction.target.port } : this.findConnectionTarget(
2147
3051
  world,
@@ -2217,7 +3121,7 @@ var VdFlowchart = class {
2217
3121
  if (this.readonly) return false;
2218
3122
  if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") return true;
2219
3123
  if (this.activeTool === "arrow") return true;
2220
- return this.selection?.kind === "node" && this.selection.id === node.id;
3124
+ return this.selection?.kind === "node" && this.selection.id === node.id && !this.hasMultiSelection();
2221
3125
  }
2222
3126
  getMarkerId(markerType, position, strokeWidth) {
2223
3127
  const width = normalizeEdgeStrokeWidth(strokeWidth);
@@ -2240,7 +3144,7 @@ var VdFlowchart = class {
2240
3144
  findConnectionTarget(worldPoint, excludeNodeId, previousSnap = null, referencePoint = null) {
2241
3145
  const snapPadding = CONNECTION_SNAP_PADDING / this.documentData.viewport.scale;
2242
3146
  const hysteresisMargin = CONNECTION_HYSTERESIS / this.documentData.viewport.scale;
2243
- const best = this.documentData.nodes.reduce((candidate, node) => {
3147
+ const best = this.getVisibleNodes().reduce((candidate, node) => {
2244
3148
  if (node.id === excludeNodeId) return candidate;
2245
3149
  const distanceToBounds2 = getDistanceToNodeBounds(node, worldPoint);
2246
3150
  if (distanceToBounds2 > snapPadding) return candidate;
@@ -2349,7 +3253,10 @@ var VdFlowchart = class {
2349
3253
  updateToolbarLabel() {
2350
3254
  this.zoomLabel.textContent = `${Math.round(this.documentData.viewport.scale * 100)}%`;
2351
3255
  }
2352
- startTextEdit(nodeId) {
3256
+ // Options: `initialText` replaces the label with typed text (type-to-edit);
3257
+ // `inserted` marks a node created by a keyboard/handle insertion so naming it
3258
+ // shares the insertion's undo step.
3259
+ startTextEdit(nodeId, options = {}) {
2353
3260
  if (this.readonly) return false;
2354
3261
  const node = this.findNode(nodeId);
2355
3262
  if (!node || !isNodeTextEditable(node)) return false;
@@ -2361,9 +3268,10 @@ var VdFlowchart = class {
2361
3268
  }
2362
3269
  this.stopTextEdit({ commit: true });
2363
3270
  this.select({ kind: "node", id: node.id });
3271
+ const replacing = typeof options.initialText === "string";
2364
3272
  const textarea = createElement("textarea", {
2365
3273
  className: `vd-flowchart-text-editor vd-flowchart-text-editor--${node.type}`,
2366
- value: node.text,
3274
+ value: replacing ? options.initialText : node.text,
2367
3275
  rows: 1
2368
3276
  });
2369
3277
  textarea.setAttribute("data-node-id", node.id);
@@ -2372,19 +3280,7 @@ var VdFlowchart = class {
2372
3280
  textarea.addEventListener("input", () => this.positionTextEditor());
2373
3281
  textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
2374
3282
  textarea.addEventListener("dblclick", (event) => event.stopPropagation());
2375
- textarea.addEventListener("keydown", (event) => {
2376
- if (event.key === "Escape") {
2377
- event.preventDefault();
2378
- this.stopTextEdit({ commit: false });
2379
- this.canvasEl.focus();
2380
- return;
2381
- }
2382
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
2383
- event.preventDefault();
2384
- this.stopTextEdit({ commit: true });
2385
- this.canvasEl.focus();
2386
- }
2387
- });
3283
+ textarea.addEventListener("keydown", (event) => this.handleTextEditorKeyDown(event, node));
2388
3284
  textarea.addEventListener("blur", () => {
2389
3285
  if (this.textEditor?.textarea === textarea) {
2390
3286
  this.stopTextEdit({ commit: true });
@@ -2393,18 +3289,97 @@ var VdFlowchart = class {
2393
3289
  this.textEditor = {
2394
3290
  nodeId: node.id,
2395
3291
  previousText: node.text,
3292
+ inserted: Boolean(options.inserted),
2396
3293
  textarea
2397
3294
  };
2398
3295
  this.canvasEl.appendChild(textarea);
2399
3296
  this.render({ inspector: true, json: false });
2400
- window.requestAnimationFrame(() => {
2401
- if (this.textEditor?.textarea === textarea) {
2402
- textarea.focus();
3297
+ const focusEditor = () => {
3298
+ if (this.textEditor?.textarea !== textarea) return;
3299
+ textarea.focus();
3300
+ if (replacing) {
3301
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3302
+ } else {
2403
3303
  textarea.select();
2404
3304
  }
3305
+ };
3306
+ focusEditor();
3307
+ window.requestAnimationFrame(() => {
3308
+ if (document.activeElement !== textarea) focusEditor();
3309
+ });
3310
+ return true;
3311
+ }
3312
+ // Inline label editor for a connection, placed on the label anchor
3313
+ // (curve midpoint). Labels render on one line, so Enter always saves.
3314
+ startEdgeLabelEdit(edgeId, options = {}) {
3315
+ if (this.readonly) return false;
3316
+ const edge = this.findEdge(sanitizeId(edgeId));
3317
+ if (!edge) return false;
3318
+ this.stopTextEdit({ commit: true });
3319
+ this.select({ kind: "edge", id: edge.id });
3320
+ const replacing = typeof options.initialText === "string";
3321
+ const textarea = createElement("textarea", {
3322
+ className: "vd-flowchart-text-editor vd-flowchart-text-editor--edge",
3323
+ value: replacing ? options.initialText : edge.label,
3324
+ rows: 1
3325
+ });
3326
+ textarea.setAttribute("data-edge-id", edge.id);
3327
+ textarea.setAttribute("aria-label", "Edit connection label");
3328
+ textarea.spellcheck = false;
3329
+ textarea.addEventListener("input", () => this.positionTextEditor());
3330
+ textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
3331
+ textarea.addEventListener("dblclick", (event) => event.stopPropagation());
3332
+ textarea.addEventListener("keydown", (event) => {
3333
+ if (event.isComposing) return;
3334
+ if (event.key === "Escape" || event.key === "Enter") {
3335
+ event.preventDefault();
3336
+ this.stopTextEdit({ commit: event.key === "Enter" });
3337
+ this.canvasEl.focus();
3338
+ }
3339
+ });
3340
+ textarea.addEventListener("blur", () => {
3341
+ if (this.textEditor?.textarea === textarea) this.stopTextEdit({ commit: true });
3342
+ });
3343
+ this.textEditor = { nodeId: null, edgeId: edge.id, previousText: edge.label, textarea };
3344
+ this.canvasEl.appendChild(textarea);
3345
+ this.render({ inspector: true, json: false });
3346
+ const focusEditor = () => {
3347
+ if (this.textEditor?.textarea !== textarea) return;
3348
+ textarea.focus();
3349
+ if (replacing) textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3350
+ else textarea.select();
3351
+ };
3352
+ focusEditor();
3353
+ window.requestAnimationFrame(() => {
3354
+ if (document.activeElement !== textarea) focusEditor();
2405
3355
  });
2406
3356
  return true;
2407
3357
  }
3358
+ handleTextEditorKeyDown(event, node) {
3359
+ if (event.isComposing) return;
3360
+ const mindmap = this.keyboardShortcuts === "mindmap";
3361
+ if (event.key === "Escape") {
3362
+ event.preventDefault();
3363
+ this.stopTextEdit({ commit: false });
3364
+ this.canvasEl.focus();
3365
+ return;
3366
+ }
3367
+ if (event.key === "Enter") {
3368
+ const modKey = event.ctrlKey || event.metaKey;
3369
+ const commits = modKey || mindmap && node.type !== "textbox" && !event.shiftKey;
3370
+ if (!commits) return;
3371
+ event.preventDefault();
3372
+ this.stopTextEdit({ commit: true });
3373
+ this.canvasEl.focus();
3374
+ return;
3375
+ }
3376
+ if (event.key === "Tab" && mindmap && !event.shiftKey && !event.altKey && !event.ctrlKey) {
3377
+ event.preventDefault();
3378
+ this.stopTextEdit({ commit: true });
3379
+ this.canvasEl.focus();
3380
+ this.insertBranchNode(node.id, { edit: true });
3381
+ }
3382
+ }
2408
3383
  stopTextEdit(options = {}) {
2409
3384
  if (!this.textEditor) return false;
2410
3385
  const commit = options.commit !== false;
@@ -2412,12 +3387,26 @@ var VdFlowchart = class {
2412
3387
  const nextText = editor.textarea.value;
2413
3388
  this.textEditor = null;
2414
3389
  editor.textarea.remove();
3390
+ if (editor.edgeId) {
3391
+ const label = nextText.replace(/\s*\n\s*/g, " ").trim();
3392
+ if (commit && !this.readonly && label !== editor.previousText) {
3393
+ this.updateEdge(editor.edgeId, { label }, { inspector: true, reason: "edge:update" });
3394
+ } else {
3395
+ this.render({ scene: true, inspector: false, json: false });
3396
+ }
3397
+ return true;
3398
+ }
2415
3399
  if (commit && !this.readonly && nextText !== editor.previousText) {
2416
- this.updateNode(
2417
- editor.nodeId,
2418
- { text: nextText },
2419
- { inspector: true, reason: "node:update" }
2420
- );
3400
+ this.coalesceInsertedText = editor.inserted;
3401
+ try {
3402
+ this.updateNode(
3403
+ editor.nodeId,
3404
+ { text: nextText },
3405
+ { inspector: true, reason: "node:update" }
3406
+ );
3407
+ } finally {
3408
+ this.coalesceInsertedText = false;
3409
+ }
2421
3410
  } else {
2422
3411
  this.render({ scene: true, inspector: false, json: false });
2423
3412
  }
@@ -2425,6 +3414,10 @@ var VdFlowchart = class {
2425
3414
  }
2426
3415
  positionTextEditor() {
2427
3416
  if (!this.textEditor) return;
3417
+ if (this.textEditor.edgeId) {
3418
+ this.positionEdgeLabelEditor();
3419
+ return;
3420
+ }
2428
3421
  const node = this.findNode(this.textEditor.nodeId);
2429
3422
  if (!node) {
2430
3423
  this.stopTextEdit({ commit: false });
@@ -2448,6 +3441,28 @@ var VdFlowchart = class {
2448
3441
  textarea.style.lineHeight = `${formatNumber(lineHeight)}px`;
2449
3442
  textarea.style.padding = `${formatNumber(verticalPadding)}px ${formatNumber(horizontalPadding)}px`;
2450
3443
  }
3444
+ positionEdgeLabelEditor() {
3445
+ const edge = this.findEdge(this.textEditor.edgeId);
3446
+ const fromNode = edge && this.findNode(edge.from.nodeId);
3447
+ const toNode = edge && this.findNode(edge.to.nodeId);
3448
+ if (!edge || !fromNode || !toNode) {
3449
+ this.stopTextEdit({ commit: false });
3450
+ return;
3451
+ }
3452
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
3453
+ const viewport = this.documentData.viewport;
3454
+ const textarea = this.textEditor.textarea;
3455
+ const fontSize = Math.max(11, 13 * viewport.scale);
3456
+ const width = clamp(textarea.value.length * fontSize * 0.62 + 28, 96, 280);
3457
+ const height = fontSize * 1.4 + 12;
3458
+ textarea.style.left = `${formatNumber(viewport.x + labelX * viewport.scale - width / 2)}px`;
3459
+ textarea.style.top = `${formatNumber(viewport.y + (labelY - 8) * viewport.scale - height / 2)}px`;
3460
+ textarea.style.width = `${formatNumber(width)}px`;
3461
+ textarea.style.height = `${formatNumber(height)}px`;
3462
+ textarea.style.fontSize = `${formatNumber(fontSize)}px`;
3463
+ textarea.style.lineHeight = `${formatNumber(fontSize * 1.4)}px`;
3464
+ textarea.style.padding = "6px 10px";
3465
+ }
2451
3466
  render(options = {}) {
2452
3467
  if (this.destroyed) return;
2453
3468
  const shouldRenderScene = options.scene !== false;
@@ -2460,8 +3475,10 @@ var VdFlowchart = class {
2460
3475
  this.updateToolbarLabel();
2461
3476
  this.updatePaletteState();
2462
3477
  this.positionTextEditor();
3478
+ this.scheduleMinimap();
2463
3479
  }
2464
3480
  renderScene() {
3481
+ this.syncSelectedNodeIds();
2465
3482
  const viewport = this.documentData.viewport;
2466
3483
  this.world.setAttribute(
2467
3484
  "transform",
@@ -2471,7 +3488,12 @@ var VdFlowchart = class {
2471
3488
  clearChildren(this.previewLayer);
2472
3489
  clearChildren(this.nodesLayer);
2473
3490
  clearChildren(this.overlayLayer);
2474
- const nodeMap = new Map(this.documentData.nodes.map((node) => [node.id, node]));
3491
+ const hidden = this.getHiddenNodeIds();
3492
+ this.sceneHidden = hidden;
3493
+ this.sceneOutgoing = this.getOutgoingMap();
3494
+ const nodeMap = new Map(
3495
+ this.documentData.nodes.filter((node) => !hidden.has(node.id)).map((node) => [node.id, node])
3496
+ );
2475
3497
  this.sceneNodeMap = nodeMap;
2476
3498
  this.nodeElements = /* @__PURE__ */ new Map();
2477
3499
  this.edgeElements = /* @__PURE__ */ new Map();
@@ -2491,6 +3513,7 @@ var VdFlowchart = class {
2491
3513
  this.previewLayer.appendChild(this.renderPreviewEdge(this.interaction));
2492
3514
  }
2493
3515
  this.documentData.nodes.forEach((node) => {
3516
+ if (hidden.has(node.id)) return;
2494
3517
  const element = this.renderNode(node);
2495
3518
  this.nodesLayer.appendChild(element);
2496
3519
  this.nodeElements.set(node.id, element);
@@ -2578,7 +3601,7 @@ var VdFlowchart = class {
2578
3601
  });
2579
3602
  group.appendChild(visiblePath);
2580
3603
  group.appendChild(hitPath);
2581
- if (edge.label) {
3604
+ if (edge.label && this.textEditor?.edgeId !== edge.id) {
2582
3605
  const label = svgEl("text", {
2583
3606
  class: "vd-flowchart-edge-label",
2584
3607
  x: edgePath.labelX,
@@ -2639,8 +3662,9 @@ var VdFlowchart = class {
2639
3662
  });
2640
3663
  }
2641
3664
  renderNode(node) {
2642
- const selected = this.selection?.kind === "node" && this.selection.id === node.id;
2643
- const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeId === node.id;
3665
+ const selected = this.isNodeSelected(node.id);
3666
+ const soloSelected = selected && !this.hasMultiSelection();
3667
+ const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeIds.includes(node.id);
2644
3668
  const resizing = this.interaction?.kind === "resize-node" && this.interaction.nodeId === node.id;
2645
3669
  const editing = this.textEditor?.nodeId === node.id;
2646
3670
  const group = svgEl("g", {
@@ -2661,12 +3685,29 @@ var VdFlowchart = class {
2661
3685
  group.appendChild(hitbox);
2662
3686
  group.appendChild(this.renderNodeShape(node));
2663
3687
  group.appendChild(this.renderNodeText(node));
2664
- if (selected && !this.readonly && isNodeResizable(node)) {
3688
+ const scale = this.documentData.viewport.scale || 1;
3689
+ if (soloSelected && !this.readonly && SELECTION_BOX_TYPES.has(node.type)) {
3690
+ const outset = SELECTION_OUTSET / scale;
3691
+ group.appendChild(
3692
+ svgEl("rect", {
3693
+ class: "vd-flowchart-selection-box",
3694
+ x: -outset,
3695
+ y: -outset,
3696
+ width: node.width + outset * 2,
3697
+ height: node.height + outset * 2
3698
+ })
3699
+ );
3700
+ }
3701
+ if (soloSelected && !this.readonly && isNodeResizable(node)) {
2665
3702
  group.appendChild(this.renderResizeControls(node));
2666
3703
  }
2667
- const scale = this.documentData.viewport.scale || 1;
3704
+ const collapseToggle = this.renderCollapseToggle(node, soloSelected);
3705
+ if (this.readonly) {
3706
+ if (collapseToggle) group.appendChild(collapseToggle);
3707
+ return group;
3708
+ }
2668
3709
  FLOWCHART_PORTS.forEach((port) => {
2669
- const position = getPortPosition({ ...node, x: 0, y: 0 }, port);
3710
+ const position = getPortHandlePosition({ ...node, x: 0, y: 0 }, port, scale);
2670
3711
  const portGroup = svgEl("g", {
2671
3712
  class: `vd-flowchart-port-group${portsVisible ? " is-visible" : ""}`,
2672
3713
  "data-node-id": node.id,
@@ -2688,15 +3729,68 @@ var VdFlowchart = class {
2688
3729
  r: CONNECTION_PORT_RADIUS / scale
2689
3730
  })
2690
3731
  );
3732
+ const arm = (CONNECTION_PORT_RADIUS - 2.5) / scale;
3733
+ portGroup.appendChild(
3734
+ svgEl("path", {
3735
+ class: "vd-flowchart-port-plus",
3736
+ d: `M ${formatNumber(position.x - arm)} ${formatNumber(position.y)} H ${formatNumber(position.x + arm)} M ${formatNumber(position.x)} ${formatNumber(position.y - arm)} V ${formatNumber(position.y + arm)}`
3737
+ })
3738
+ );
2691
3739
  group.appendChild(portGroup);
2692
3740
  });
3741
+ if (collapseToggle) group.appendChild(collapseToggle);
3742
+ return group;
3743
+ }
3744
+ // "+N" on a collapsed node, or "−" on a single selected node with children,
3745
+ // placed beyond the connection handle on the side its branch leaves from.
3746
+ renderCollapseToggle(node, soloSelected) {
3747
+ const outgoing = this.sceneOutgoing || this.getOutgoingMap();
3748
+ const childIds = (outgoing.get(node.id) || []).filter((id) => id !== node.id);
3749
+ if (!childIds.length) return null;
3750
+ const count = node.collapsed ? this.countCollapsedDescendants(
3751
+ node.id,
3752
+ this.sceneHidden || this.getHiddenNodeIds(),
3753
+ outgoing
3754
+ ) : 0;
3755
+ if (!node.collapsed && !(soloSelected && !this.readonly)) return null;
3756
+ const sides = /* @__PURE__ */ new Map();
3757
+ this.documentData.edges.forEach((edge) => {
3758
+ if (edge.from.nodeId !== node.id || edge.to.nodeId === node.id) return;
3759
+ sides.set(edge.from.port, (sides.get(edge.from.port) || 0) + 1);
3760
+ });
3761
+ const port = [...sides].sort((a, b) => b[1] - a[1])[0]?.[0] || DIRECTION_PORTS[this.getGrowthDirection(node)];
3762
+ const scale = this.documentData.viewport.scale || 1;
3763
+ const anchor = getPortPosition({ ...node, x: 0, y: 0 }, port);
3764
+ const normal = getPortNormal(port);
3765
+ const offset = COLLAPSE_TOGGLE_OFFSET / scale;
3766
+ const cx = formatNumber(anchor.x + normal.x * offset);
3767
+ const cy = formatNumber(anchor.y + normal.y * offset);
3768
+ const label = node.collapsed ? `+${count}` : "\u2212";
3769
+ const radius = (node.collapsed && label.length > 2 ? 12 : 9) / scale;
3770
+ const group = svgEl("g", {
3771
+ class: `vd-flowchart-collapse-toggle${node.collapsed ? " is-collapsed" : ""}`,
3772
+ "data-collapse-toggle": node.id
3773
+ });
3774
+ const title = svgEl("title");
3775
+ title.textContent = node.collapsed ? `Expand ${count} hidden nodes` : "Collapse branch";
3776
+ group.appendChild(title);
3777
+ group.appendChild(svgEl("circle", { cx, cy, r: formatNumber(radius) }));
3778
+ const text = svgEl("text", {
3779
+ x: cx,
3780
+ y: cy,
3781
+ "font-size": formatNumber(10 / scale),
3782
+ "text-anchor": "middle",
3783
+ "dominant-baseline": "central"
3784
+ });
3785
+ text.textContent = label;
3786
+ group.appendChild(text);
2693
3787
  return group;
2694
3788
  }
2695
3789
  renderResizeControls(node) {
2696
3790
  const scale = this.documentData.viewport.scale || 1;
2697
3791
  const zone = 14 / scale;
2698
3792
  const cornerZone = 22 / scale;
2699
- const handleSize = 9 / scale;
3793
+ const handleSize = RESIZE_HANDLE_SIZE / scale;
2700
3794
  const gap = RESIZE_PORT_GAP / scale;
2701
3795
  const group = svgEl("g", { class: "vd-flowchart-resize-controls" });
2702
3796
  const zones = {
@@ -2726,12 +3820,6 @@ var VdFlowchart = class {
2726
3820
  };
2727
3821
  RESIZE_HANDLES.forEach((handle) => {
2728
3822
  const zoneRect = zones[handle];
2729
- const position = getResizeHandlePosition(node, handle);
2730
- const dotOffset = 8 / scale;
2731
- if (handle.includes("e")) position.x += dotOffset;
2732
- if (handle.includes("w")) position.x -= dotOffset;
2733
- if (handle.includes("n")) position.y -= dotOffset;
2734
- if (handle.includes("s")) position.y += dotOffset;
2735
3823
  const cursor = getResizeCursor(handle);
2736
3824
  const zoneSegments = [];
2737
3825
  if (handle === "e" || handle === "w") {
@@ -2766,14 +3854,19 @@ var VdFlowchart = class {
2766
3854
  });
2767
3855
  group.appendChild(hit);
2768
3856
  });
3857
+ if (handle.length === 1) return;
3858
+ const position = getResizeHandlePosition(node, handle);
3859
+ const outset = SELECTION_OUTSET / scale;
3860
+ position.x += handle.includes("e") ? outset : -outset;
3861
+ position.y += handle.includes("s") ? outset : -outset;
2769
3862
  const dot = svgEl("rect", {
2770
3863
  class: "vd-flowchart-resize-handle",
2771
3864
  x: position.x - handleSize / 2,
2772
3865
  y: position.y - handleSize / 2,
2773
3866
  width: handleSize,
2774
3867
  height: handleSize,
2775
- rx: handleSize / 3,
2776
- ry: handleSize / 3,
3868
+ rx: handleSize / 5,
3869
+ ry: handleSize / 5,
2777
3870
  "data-node-id": node.id,
2778
3871
  "data-resize-handle": handle,
2779
3872
  style: `cursor: ${cursor}`
@@ -2902,6 +3995,16 @@ var VdFlowchart = class {
2902
3995
  );
2903
3996
  return;
2904
3997
  }
3998
+ if (this.hasMultiSelection()) {
3999
+ this.selectionMeta.textContent = `${this.selectedNodeIds.size} nodes selected`;
4000
+ this.selectionFields.appendChild(
4001
+ createElement("p", {
4002
+ className: "vd-flowchart-selection-empty",
4003
+ text: "Drag, nudge with Alt+Arrow, copy, duplicate, or delete them together."
4004
+ })
4005
+ );
4006
+ return;
4007
+ }
2905
4008
  if (this.selection.kind === "node") {
2906
4009
  const node = this.findNode(this.selection.id);
2907
4010
  if (!node) {
@@ -3016,16 +4119,69 @@ var VdFlowchart = class {
3016
4119
  }
3017
4120
  select(selection) {
3018
4121
  const next = selection && selection.id && selection.kind ? { kind: selection.kind, id: selection.id } : null;
3019
- const previousKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3020
- const nextKey = next ? `${next.kind}:${next.id}` : "";
3021
- this.selection = next;
3022
- if (previousKey !== nextKey) {
4122
+ this.applySelection(next, next?.kind === "node" ? [next.id] : []);
4123
+ }
4124
+ // Single funnel for selection changes: `selection` is the primary item and
4125
+ // `selectedNodeIds` every selected node (the primary one included).
4126
+ applySelection(primary, nodeIds) {
4127
+ const previousKey = this.getSelectionKey();
4128
+ this.selection = primary;
4129
+ this.selectedNodeIds = new Set(nodeIds);
4130
+ if (primary?.kind === "node") this.selectedNodeIds.add(primary.id);
4131
+ if (previousKey !== this.getSelectionKey()) {
4132
+ this.edgeCycleAnchor = null;
3023
4133
  this.render({ scene: true, inspector: true, json: false });
3024
- this.emit("select", { selection: this.getSelectionSnapshot() });
4134
+ this.emit("select", {
4135
+ selection: this.getSelectionSnapshot(),
4136
+ nodeIds: this.getSelectedNodeIds()
4137
+ });
3025
4138
  return;
3026
4139
  }
3027
4140
  this.render({ scene: true, inspector: false, json: false });
3028
4141
  }
4142
+ getSelectionKey() {
4143
+ const primary = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
4144
+ return `${primary}|${[...this.selectedNodeIds || []].sort().join(",")}`;
4145
+ }
4146
+ // Keep the set valid after mutations that bypass applySelection (undo,
4147
+ // load, removal): drop missing nodes and keep the primary node in the set.
4148
+ syncSelectedNodeIds() {
4149
+ if (this.selection?.kind !== "node") {
4150
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
4151
+ return;
4152
+ }
4153
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4154
+ const hidden = this.getHiddenNodeIds();
4155
+ this.selectedNodeIds = new Set(
4156
+ [...this.selectedNodeIds || []].filter((id) => existing.has(id) && !hidden.has(id))
4157
+ );
4158
+ this.selectedNodeIds.add(this.selection.id);
4159
+ }
4160
+ getSelectedNodeIds() {
4161
+ return [...this.selectedNodeIds || []];
4162
+ }
4163
+ isNodeSelected(nodeId) {
4164
+ return Boolean(this.selectedNodeIds?.has(nodeId));
4165
+ }
4166
+ hasMultiSelection() {
4167
+ return (this.selectedNodeIds?.size || 0) > 1;
4168
+ }
4169
+ /** Select several nodes; `primary` (default: the last id) drives single-node UI. */
4170
+ selectNodes(nodeIds, options = {}) {
4171
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4172
+ const ids = [...new Set((nodeIds || []).map(sanitizeId))].filter((id) => existing.has(id));
4173
+ const primaryId = ids.includes(options.primary) ? options.primary : ids[ids.length - 1];
4174
+ this.applySelection(primaryId ? { kind: "node", id: primaryId } : null, ids);
4175
+ return this;
4176
+ }
4177
+ toggleNodeSelection(nodeId) {
4178
+ const ids = this.selection?.kind === "node" ? this.getSelectedNodeIds() : [];
4179
+ if (ids.includes(nodeId)) {
4180
+ const rest = ids.filter((id) => id !== nodeId);
4181
+ return this.selectNodes(rest, { primary: rest[rest.length - 1] });
4182
+ }
4183
+ return this.selectNodes([...ids, nodeId], { primary: nodeId });
4184
+ }
3029
4185
  selectNode(nodeId) {
3030
4186
  this.select({ kind: "node", id: sanitizeId(nodeId) });
3031
4187
  return this;
@@ -3046,6 +4202,69 @@ var VdFlowchart = class {
3046
4202
  findNode(nodeId) {
3047
4203
  return this.documentData.nodes.find((node) => node.id === nodeId) || null;
3048
4204
  }
4205
+ getHiddenNodeIds() {
4206
+ return computeHiddenNodeIds(this.documentData.nodes, this.documentData.edges);
4207
+ }
4208
+ getVisibleNodes() {
4209
+ const hidden = this.getHiddenNodeIds();
4210
+ return hidden.size ? this.documentData.nodes.filter((node) => !hidden.has(node.id)) : this.documentData.nodes;
4211
+ }
4212
+ isNodeHidden(nodeId) {
4213
+ return this.getHiddenNodeIds().has(nodeId);
4214
+ }
4215
+ getOutgoingMap() {
4216
+ const outgoing = /* @__PURE__ */ new Map();
4217
+ this.documentData.edges.forEach((edge) => {
4218
+ if (!outgoing.has(edge.from.nodeId)) outgoing.set(edge.from.nodeId, []);
4219
+ outgoing.get(edge.from.nodeId).push(edge.to.nodeId);
4220
+ });
4221
+ return outgoing;
4222
+ }
4223
+ // Hidden nodes under one collapsed node (its "+N" count).
4224
+ countCollapsedDescendants(nodeId, hidden = this.getHiddenNodeIds(), outgoing = this.getOutgoingMap()) {
4225
+ const seen = /* @__PURE__ */ new Set();
4226
+ const queue = [nodeId];
4227
+ for (let index = 0; index < queue.length; index += 1) {
4228
+ (outgoing.get(queue[index]) || []).forEach((childId) => {
4229
+ if (seen.has(childId) || !hidden.has(childId)) return;
4230
+ seen.add(childId);
4231
+ queue.push(childId);
4232
+ });
4233
+ }
4234
+ return seen.size;
4235
+ }
4236
+ setCollapsed(nodeId, collapsed) {
4237
+ if (this.readonly) return false;
4238
+ const node = this.findNode(sanitizeId(nodeId));
4239
+ if (!node) return false;
4240
+ const next = Boolean(collapsed);
4241
+ if (Boolean(node.collapsed) === next) return false;
4242
+ if (next && !this.getChildNodes(node.id).length) return false;
4243
+ if (next) node.collapsed = true;
4244
+ else delete node.collapsed;
4245
+ if (this.selection?.kind === "node") {
4246
+ const hidden = this.getHiddenNodeIds();
4247
+ const visibleIds = this.getSelectedNodeIds().filter((id) => !hidden.has(id));
4248
+ if (hidden.has(this.selection.id)) {
4249
+ this.select({ kind: "node", id: node.id });
4250
+ } else if (visibleIds.length !== this.selectedNodeIds.size) {
4251
+ this.selectNodes(visibleIds, { primary: this.selection.id });
4252
+ }
4253
+ } else if (this.selection?.kind === "edge") {
4254
+ const edge = this.findEdge(this.selection.id);
4255
+ const hidden = this.getHiddenNodeIds();
4256
+ if (edge && (hidden.has(edge.from.nodeId) || hidden.has(edge.to.nodeId))) {
4257
+ this.select({ kind: "node", id: node.id });
4258
+ }
4259
+ }
4260
+ this.render({ inspector: true, json: true });
4261
+ this.emitChange("node:collapse", { node: deepClone(node) });
4262
+ return true;
4263
+ }
4264
+ toggleCollapsed(nodeId) {
4265
+ const node = this.findNode(sanitizeId(nodeId));
4266
+ return node ? this.setCollapsed(node.id, !node.collapsed) : false;
4267
+ }
3049
4268
  findEdge(edgeId) {
3050
4269
  return this.documentData.edges.find((edge) => edge.id === edgeId) || null;
3051
4270
  }
@@ -3056,7 +4275,8 @@ var VdFlowchart = class {
3056
4275
  }
3057
4276
  emitChange(reason, extra = {}) {
3058
4277
  if (this.historyEnabled && !this.isApplyingHistory) {
3059
- this.recordHistory(reason);
4278
+ if (this.historyBatchDepth > 0) this.historyBatchDirty = true;
4279
+ else this.recordHistory(reason);
3060
4280
  }
3061
4281
  this.syncJsonTextarea();
3062
4282
  this.emit("change", {
@@ -3100,7 +4320,9 @@ var VdFlowchart = class {
3100
4320
  const snapshot = this.toJSON();
3101
4321
  const targetKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3102
4322
  const top = this.history[this.historyIndex];
3103
- const canCoalesce = Boolean(top) && this.historyIndex === this.history.length - 1 && COALESCING_REASONS.has(reason) && top.reason === reason && top.targetKey === targetKey;
4323
+ const atTip = Boolean(top) && this.historyIndex === this.history.length - 1;
4324
+ const namesInsertedNode = this.coalesceInsertedText && top?.reason === "node:insert" && reason === "node:update";
4325
+ const canCoalesce = atTip && top.targetKey === targetKey && (namesInsertedNode || COALESCING_REASONS.has(reason) && top.reason === reason);
3104
4326
  if (canCoalesce) {
3105
4327
  top.snapshot = snapshot;
3106
4328
  return;
@@ -3216,19 +4438,180 @@ var VdFlowchart = class {
3216
4438
  const parent = this.findNode(sanitizeId(parentId));
3217
4439
  if (!parent) return null;
3218
4440
  const { direction = "right", distance, angle, edge: edgeOptions, ...nodeOptions } = options;
3219
- const node = this.addNode({
3220
- ...nodeOptions,
3221
- relativeTo: { node: parent.id, direction, distance, angle }
4441
+ return this.batchHistory("node:add-child", () => {
4442
+ const node = this.addNode({
4443
+ ...nodeOptions,
4444
+ relativeTo: { node: parent.id, direction, distance, angle }
4445
+ });
4446
+ const edge = this.addEdge({
4447
+ from: parent.id,
4448
+ to: node.id,
4449
+ autoPort: true,
4450
+ endMarker: "arrow",
4451
+ ...isPlainObject(edgeOptions) ? edgeOptions : {}
4452
+ });
4453
+ this.select({ kind: "node", id: node.id });
4454
+ return { node, edge };
4455
+ });
4456
+ }
4457
+ // Run `fn` with history recording suspended, then record a single entry for
4458
+ // everything it changed (change events still fire for each mutation).
4459
+ batchHistory(reason, fn) {
4460
+ this.historyBatchDepth += 1;
4461
+ let result;
4462
+ try {
4463
+ result = fn();
4464
+ } finally {
4465
+ this.historyBatchDepth -= 1;
4466
+ }
4467
+ if (this.historyBatchDepth === 0 && this.historyBatchDirty) {
4468
+ this.historyBatchDirty = false;
4469
+ if (this.historyEnabled && !this.isApplyingHistory) this.recordHistory(reason);
4470
+ }
4471
+ return result;
4472
+ }
4473
+ // Mind-map structure: a node's parent is the source of its first incoming
4474
+ // edge; its children are the targets of its outgoing edges. The side a
4475
+ // branch is on is the port its edge leaves the parent from, which is what
4476
+ // the user sees (centre geometry is ambiguous for diagonal nodes).
4477
+ getParentEdge(nodeId) {
4478
+ return this.documentData.edges.find(
4479
+ (edge) => edge.to.nodeId === nodeId && edge.from.nodeId !== nodeId
4480
+ ) || null;
4481
+ }
4482
+ getParentNode(nodeId) {
4483
+ const edge = this.getParentEdge(nodeId);
4484
+ return edge ? this.findNode(edge.from.nodeId) : null;
4485
+ }
4486
+ getChildNodes(nodeId, direction = null) {
4487
+ const port = direction ? DIRECTION_PORTS[direction] : null;
4488
+ const ids = /* @__PURE__ */ new Set();
4489
+ this.documentData.edges.forEach((edge) => {
4490
+ if (edge.from.nodeId !== nodeId || edge.to.nodeId === nodeId) return;
4491
+ if (!port || edge.from.port === port) ids.add(edge.to.nodeId);
3222
4492
  });
3223
- const edge = this.addEdge({
3224
- from: parent.id,
3225
- to: node.id,
3226
- autoPort: true,
3227
- endMarker: "arrow",
3228
- ...isPlainObject(edgeOptions) ? edgeOptions : {}
4493
+ return this.documentData.nodes.filter((node) => ids.has(node.id));
4494
+ }
4495
+ // Direction a branch grows from `node`: the side its parent edge leaves
4496
+ // from, or the emptier horizontal side for a root.
4497
+ getGrowthDirection(node) {
4498
+ const parentEdge = this.getParentEdge(node.id);
4499
+ if (parentEdge) return PORT_DIRECTIONS[parentEdge.from.port] || "right";
4500
+ const right = this.getChildNodes(node.id, "right").length;
4501
+ const left = this.getChildNodes(node.id, "left").length;
4502
+ return left < right ? "left" : "right";
4503
+ }
4504
+ // Shape for a new branch node: copy a sibling when there is one, otherwise
4505
+ // the parent's box shape, falling back to a rounded step.
4506
+ getBranchTemplate(parent, sibling) {
4507
+ const source = sibling || (["rounded-rect", "rect", "textbox"].includes(parent.type) ? parent : null) || null;
4508
+ if (source) return { type: source.type, width: source.width, height: source.height };
4509
+ const spec = DEFAULT_NODE_SPECS["rounded-rect"];
4510
+ return { type: "rounded-rect", width: spec.width, height: spec.height };
4511
+ }
4512
+ /**
4513
+ * Add a node connected from `parentId` on the `direction` side (default: the
4514
+ * branch's growth direction), stacked after `anchorId` or the last sibling on
4515
+ * that side. The node and its edge are one undo step; the new edge copies a
4516
+ * sibling's (or the parent's incoming) edge style. With `edit: true` the
4517
+ * label editor opens with the text selected.
4518
+ */
4519
+ insertBranchNode(parentId, options = {}) {
4520
+ if (this.readonly) return null;
4521
+ const parent = this.findNode(sanitizeId(parentId));
4522
+ if (!parent) return null;
4523
+ const direction = DIRECTION_PORTS[options.direction] ? options.direction : this.getGrowthDirection(parent);
4524
+ const siblings = this.getChildNodes(parent.id, direction);
4525
+ const anchor = options.anchorId ? this.findNode(sanitizeId(options.anchorId)) : null;
4526
+ const lastSibling = siblings[siblings.length - 1] || null;
4527
+ const template = this.getBranchTemplate(parent, anchor || lastSibling);
4528
+ const position = placeBranchNode({
4529
+ nodes: this.documentData.nodes,
4530
+ parent,
4531
+ siblings,
4532
+ direction,
4533
+ size: template,
4534
+ anchor,
4535
+ before: Boolean(options.before)
3229
4536
  });
3230
- this.select({ kind: "node", id: node.id });
3231
- return { node, edge };
4537
+ const styleSource = this.documentData.edges.find(
4538
+ (edge) => edge.from.nodeId === parent.id && edge.to.nodeId === (anchor || lastSibling)?.id
4539
+ ) || this.documentData.edges.find((edge) => edge.to.nodeId === parent.id);
4540
+ const edgeStyle = styleSource ? {
4541
+ route: styleSource.route,
4542
+ strokeWidth: styleSource.strokeWidth,
4543
+ startMarker: styleSource.startMarker,
4544
+ endMarker: styleSource.endMarker
4545
+ } : { endMarker: "arrow" };
4546
+ const result = this.batchHistory("node:insert", () => {
4547
+ if (parent.collapsed) delete parent.collapsed;
4548
+ const node = this.addNode({ ...template, x: position.x, y: position.y });
4549
+ const edge = this.addEdge({
4550
+ from: { nodeId: parent.id, port: DIRECTION_PORTS[direction] },
4551
+ to: { nodeId: node.id, port: DIRECTION_PORTS[oppositeDirection(direction)] },
4552
+ ...edgeStyle
4553
+ });
4554
+ if (edge && anchor) this.placeEdgeBySibling(edge.id, parent.id, anchor.id, options.before);
4555
+ if (this.autoLayout && this.layoutMode !== "grid") this.layout(this.layoutMode);
4556
+ this.select({ kind: "node", id: node.id });
4557
+ return { node, edge };
4558
+ });
4559
+ this.revealNode(result.node.id);
4560
+ if (options.edit) this.startTextEdit(result.node.id, { inserted: true });
4561
+ return result;
4562
+ }
4563
+ // Tree and radial layouts order children by connection order, so a sibling's
4564
+ // connection goes right after (or before) its anchor's instead of last.
4565
+ placeEdgeBySibling(edgeId, parentId, anchorId, before = false) {
4566
+ const edges = this.documentData.edges;
4567
+ const fromIndex = edges.findIndex((edge) => edge.id === edgeId);
4568
+ if (fromIndex === -1) return;
4569
+ const [moved] = edges.splice(fromIndex, 1);
4570
+ const anchorIndex = edges.findIndex(
4571
+ (edge) => edge.from.nodeId === parentId && edge.to.nodeId === anchorId
4572
+ );
4573
+ if (anchorIndex === -1) edges.push(moved);
4574
+ else edges.splice(before ? anchorIndex : anchorIndex + 1, 0, moved);
4575
+ }
4576
+ // Insert a sibling after (or before) `nodeId`; a node without a parent gets a
4577
+ // child instead, which is what Enter on a mind-map root means.
4578
+ insertSiblingNode(nodeId, options = {}) {
4579
+ if (this.readonly) return null;
4580
+ const node = this.findNode(sanitizeId(nodeId));
4581
+ if (!node) return null;
4582
+ const parentEdge = this.getParentEdge(node.id);
4583
+ const parent = parentEdge ? this.findNode(parentEdge.from.nodeId) : null;
4584
+ if (!parent) return this.insertBranchNode(node.id, { edit: options.edit });
4585
+ return this.insertBranchNode(parent.id, {
4586
+ direction: PORT_DIRECTIONS[parentEdge.from.port],
4587
+ anchorId: node.id,
4588
+ before: Boolean(options.before),
4589
+ edit: options.edit
4590
+ });
4591
+ }
4592
+ // Pan just enough to bring a node fully into view (with a small margin).
4593
+ revealNode(nodeId) {
4594
+ const node = this.findNode(nodeId);
4595
+ const width = this.canvasEl.clientWidth;
4596
+ const height = this.canvasEl.clientHeight;
4597
+ if (!node || !width || !height) return;
4598
+ const viewport = this.documentData.viewport;
4599
+ const margin = 32;
4600
+ const left = viewport.x + node.x * viewport.scale;
4601
+ const top = viewport.y + node.y * viewport.scale;
4602
+ const right = left + node.width * viewport.scale;
4603
+ const bottom = top + node.height * viewport.scale;
4604
+ let dx = 0;
4605
+ let dy = 0;
4606
+ if (right > width - margin) dx = width - margin - right;
4607
+ if (left + dx < margin) dx = margin - left;
4608
+ if (bottom > height - margin) dy = height - margin - bottom;
4609
+ if (top + dy < margin) dy = margin - top;
4610
+ if (!dx && !dy) return;
4611
+ viewport.x = formatNumber(viewport.x + dx);
4612
+ viewport.y = formatNumber(viewport.y + dy);
4613
+ this.render({ inspector: false, json: true });
4614
+ this.emitViewportChange("viewport:pan");
3232
4615
  }
3233
4616
  // Arrange nodes with a built-in layout. Positions are computed by the pure
3234
4617
  // computeLayout() module, then applied in place (NOT via load(), which would
@@ -3476,6 +4859,9 @@ var VdFlowchart = class {
3476
4859
  }
3477
4860
  deleteSelection() {
3478
4861
  if (!this.selection || this.readonly) return false;
4862
+ if (this.hasMultiSelection()) {
4863
+ return this.removeNodeIds(new Set(this.getSelectedNodeIds()), "node:remove");
4864
+ }
3479
4865
  if (this.selection.kind === "node") return this.removeNode(this.selection.id);
3480
4866
  return this.removeEdge(this.selection.id);
3481
4867
  }
@@ -3504,10 +4890,11 @@ var VdFlowchart = class {
3504
4890
  return this;
3505
4891
  }
3506
4892
  fitView() {
3507
- if (!this.documentData.nodes.length) {
4893
+ const nodes = this.getVisibleNodes();
4894
+ if (!nodes.length) {
3508
4895
  return this.resetView();
3509
4896
  }
3510
- const bounds = getBounds(this.documentData.nodes);
4897
+ const bounds = getBounds(nodes);
3511
4898
  const width = this.canvasEl.clientWidth || 800;
3512
4899
  const height = this.canvasEl.clientHeight || 560;
3513
4900
  const padding = 80;
@@ -3565,6 +4952,14 @@ var VdFlowchart = class {
3565
4952
  pattern.querySelector("path").setAttribute("d", `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`);
3566
4953
  }
3567
4954
  if ("autoFit" in options) this.autoFit = Boolean(options.autoFit);
4955
+ if ("keyboardShortcuts" in options) {
4956
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
4957
+ }
4958
+ if ("snapGuides" in options) this.snapGuides = options.snapGuides !== false;
4959
+ if ("minimap" in options) this.setMinimapEnabled(options.minimap !== false);
4960
+ if ("autoLayout" in options) this.autoLayout = Boolean(options.autoLayout);
4961
+ this.syncCanvasLabel();
4962
+ if (this.shortcutsHelpOpen) this.renderShortcutsHelp();
3568
4963
  if ("history" in options && options.history !== false !== this.historyEnabled) {
3569
4964
  this.historyEnabled = options.history !== false;
3570
4965
  this.history = [];
@@ -3613,6 +5008,10 @@ var VdFlowchart = class {
3613
5008
  destroy() {
3614
5009
  if (this.destroyed) return;
3615
5010
  this.destroyed = true;
5011
+ if (this.minimapFrame && typeof cancelAnimationFrame === "function") {
5012
+ cancelAnimationFrame(this.minimapFrame);
5013
+ }
5014
+ this.minimapFrame = 0;
3616
5015
  this.activeTool = null;
3617
5016
  this.stopTextEdit({ commit: false });
3618
5017
  this.resizeObserver?.disconnect();
@@ -3629,6 +5028,17 @@ var VdFlowchart = class {
3629
5028
 
3630
5029
  // src/vue.js
3631
5030
  var FORWARDED_EVENTS = ["change", "select", "viewport", "connect", "ready"];
5031
+ var EDITOR_OPTION_KEYS = [
5032
+ "readonly",
5033
+ "gridSize",
5034
+ "autoFit",
5035
+ "history",
5036
+ "historyLimit",
5037
+ "keyboardShortcuts",
5038
+ "snapGuides",
5039
+ "minimap",
5040
+ "autoLayout"
5041
+ ];
3632
5042
  var VdFlowchart2 = defineComponent({
3633
5043
  name: "VdFlowchart",
3634
5044
  props: {
@@ -3645,21 +5055,26 @@ var VdFlowchart2 = defineComponent({
3645
5055
  /** Enable the built-in undo/redo history (default true). */
3646
5056
  history: { type: Boolean, default: true },
3647
5057
  /** Maximum number of history entries to retain. */
3648
- historyLimit: { type: Number, default: void 0 }
5058
+ historyLimit: { type: Number, default: void 0 },
5059
+ /** Keyboard model: 'mindmap' (Tab child, Enter sibling) or 'basic'. */
5060
+ keyboardShortcuts: { type: String, default: "mindmap" },
5061
+ /** Snap dragged nodes to other nodes' edges and centres (default true). */
5062
+ snapGuides: { type: Boolean, default: true },
5063
+ /** Show the overview minimap in the canvas corner (default true). */
5064
+ minimap: { type: Boolean, default: true },
5065
+ /** Re-run the current layout after keyboard or handle insertions (default false). */
5066
+ autoLayout: { type: Boolean, default: false }
3649
5067
  },
3650
5068
  emits: ["change", "select", "viewport", "connect", "ready"],
3651
5069
  setup(props, { emit, expose }) {
3652
5070
  const el = ref(null);
3653
5071
  let instance = null;
5072
+ const editorOptions = () => Object.fromEntries(EDITOR_OPTION_KEYS.map((key) => [key, props[key]]));
3654
5073
  const create = () => {
3655
5074
  instance = new VdFlowchart({
3656
5075
  element: el.value,
3657
5076
  data: props.data,
3658
- readonly: props.readonly,
3659
- gridSize: props.gridSize,
3660
- autoFit: props.autoFit,
3661
- history: props.history,
3662
- historyLimit: props.historyLimit
5077
+ ...editorOptions()
3663
5078
  });
3664
5079
  FORWARDED_EVENTS.forEach((name) => {
3665
5080
  instance.on(name, (payload) => emit(name, payload));
@@ -3679,16 +5094,9 @@ var VdFlowchart2 = defineComponent({
3679
5094
  { deep: true }
3680
5095
  );
3681
5096
  watch(
3682
- () => [props.readonly, props.gridSize, props.autoFit, props.history, props.historyLimit],
5097
+ () => EDITOR_OPTION_KEYS.map((key) => props[key]),
3683
5098
  () => {
3684
- if (!instance) return;
3685
- instance.updateOptions({
3686
- readonly: props.readonly,
3687
- gridSize: props.gridSize,
3688
- autoFit: props.autoFit,
3689
- history: props.history,
3690
- historyLimit: props.historyLimit
3691
- });
5099
+ if (instance) instance.updateOptions(editorOptions());
3692
5100
  }
3693
5101
  );
3694
5102
  onBeforeUnmount(() => {
@@ -3712,6 +5120,7 @@ export {
3712
5120
  FLOWCHART_DOCUMENT_VERSION,
3713
5121
  FLOWCHART_EDGE_MARKERS,
3714
5122
  FLOWCHART_EDGE_ROUTES,
5123
+ FLOWCHART_KEYBOARD_SHORTCUTS,
3715
5124
  FLOWCHART_NODE_TYPES,
3716
5125
  FLOWCHART_PORTS,
3717
5126
  LAYOUT_MODES,