@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.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  FLOWCHART_DOCUMENT_VERSION: () => FLOWCHART_DOCUMENT_VERSION,
24
24
  FLOWCHART_EDGE_MARKERS: () => FLOWCHART_EDGE_MARKERS,
25
25
  FLOWCHART_EDGE_ROUTES: () => FLOWCHART_EDGE_ROUTES,
26
+ FLOWCHART_KEYBOARD_SHORTCUTS: () => FLOWCHART_KEYBOARD_SHORTCUTS,
26
27
  FLOWCHART_NODE_TYPES: () => FLOWCHART_NODE_TYPES,
27
28
  FLOWCHART_PORTS: () => FLOWCHART_PORTS,
28
29
  LAYOUT_MODES: () => LAYOUT_MODES,
@@ -220,6 +221,134 @@ function computeLayout(documentData, mode = "tree", options = {}) {
220
221
  }
221
222
  var LAYOUT_MODES = ["tree", "radial", "grid"];
222
223
 
224
+ // src/mindmap.js
225
+ var DIRECTION_PORTS = { right: "right", left: "left", down: "bottom", up: "top" };
226
+ var PORT_DIRECTIONS = { right: "right", left: "left", bottom: "down", top: "up" };
227
+ var OPPOSITE_DIRECTIONS = { right: "left", left: "right", down: "up", up: "down" };
228
+ var DIRECTION_VECTORS = {
229
+ right: { x: 1, y: 0 },
230
+ left: { x: -1, y: 0 },
231
+ down: { x: 0, y: 1 },
232
+ up: { x: 0, y: -1 }
233
+ };
234
+ var BRANCH_GAP = 72;
235
+ var SIBLING_GAP = 24;
236
+ var COLLISION_GAP = 12;
237
+ var COLLISION_STEP = 12;
238
+ var MAX_COLLISION_STEPS = 400;
239
+ var BRANCH_RING_STEP = 48;
240
+ var MAX_BRANCH_RINGS = 8;
241
+ var NAVIGATION_CONE = Math.tan(Math.PI / 3);
242
+ function centerOf(node) {
243
+ return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
244
+ }
245
+ function oppositeDirection(direction) {
246
+ return OPPOSITE_DIRECTIONS[direction] || "left";
247
+ }
248
+ function findSpatialNeighbor(nodes, fromNode, direction) {
249
+ const vector = DIRECTION_VECTORS[direction];
250
+ if (!vector || !fromNode) return null;
251
+ const origin = centerOf(fromNode);
252
+ let bestCone = null;
253
+ let bestPlane = null;
254
+ nodes.forEach((node) => {
255
+ if (node.id === fromNode.id) return;
256
+ const center = centerOf(node);
257
+ const dx = center.x - origin.x;
258
+ const dy = center.y - origin.y;
259
+ const along = dx * vector.x + dy * vector.y;
260
+ if (along <= 0) return;
261
+ const across = Math.abs(dx * vector.y - dy * vector.x);
262
+ const score = along + across * 2;
263
+ if (across <= along * NAVIGATION_CONE && (!bestCone || score < bestCone.score)) {
264
+ bestCone = { node, score };
265
+ }
266
+ if (!bestPlane || score < bestPlane.score) bestPlane = { node, score };
267
+ });
268
+ return (bestCone || bestPlane)?.node ?? null;
269
+ }
270
+ function findNearestNode(nodes, point) {
271
+ let best = null;
272
+ nodes.forEach((node) => {
273
+ const center = centerOf(node);
274
+ const distance = Math.hypot(center.x - point.x, center.y - point.y);
275
+ if (!best || distance < best.distance) best = { node, distance };
276
+ });
277
+ return best?.node ?? null;
278
+ }
279
+ function overlapsAny(rect, nodes) {
280
+ return nodes.some(
281
+ (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
282
+ );
283
+ }
284
+ function placeBranchNode({
285
+ nodes,
286
+ parent,
287
+ siblings = [],
288
+ direction,
289
+ size,
290
+ anchor = null,
291
+ before = false
292
+ }) {
293
+ const horizontal = direction === "right" || direction === "left";
294
+ const cross = horizontal ? "y" : "x";
295
+ const crossSize = horizontal ? "height" : "width";
296
+ const lastSibling = siblings.reduce(
297
+ (last, node) => !last || node[cross] + node[crossSize] > last[cross] + last[crossSize] ? node : last,
298
+ null
299
+ );
300
+ const reference = anchor || lastSibling;
301
+ const rect = { x: 0, y: 0, width: size.width, height: size.height };
302
+ if (reference) {
303
+ if (direction === "right") rect.x = reference.x;
304
+ if (direction === "left") rect.x = reference.x + reference.width - size.width;
305
+ if (direction === "down") rect.y = reference.y;
306
+ if (direction === "up") rect.y = reference.y + reference.height - size.height;
307
+ rect[cross] = before ? reference[cross] - SIBLING_GAP - size[crossSize] : reference[cross] + reference[crossSize] + SIBLING_GAP;
308
+ } else {
309
+ if (direction === "right") rect.x = parent.x + parent.width + BRANCH_GAP;
310
+ if (direction === "left") rect.x = parent.x - BRANCH_GAP - size.width;
311
+ if (direction === "down") rect.y = parent.y + parent.height + BRANCH_GAP;
312
+ if (direction === "up") rect.y = parent.y - BRANCH_GAP - size.height;
313
+ rect[cross] = parent[cross] + parent[crossSize] / 2 - size[crossSize] / 2;
314
+ }
315
+ const crossSign = reference ? before ? -1 : 1 : 0;
316
+ const result = nearestFreeSlot(
317
+ rect,
318
+ nodes,
319
+ cross,
320
+ horizontal ? "x" : "y",
321
+ DIRECTION_VECTORS[direction],
322
+ crossSign
323
+ );
324
+ return { x: Math.round(result.x * 100) / 100, y: Math.round(result.y * 100) / 100 };
325
+ }
326
+ function crossOffsetAt(index, crossSign) {
327
+ if (crossSign) return crossSign * COLLISION_STEP * index;
328
+ return COLLISION_STEP * Math.ceil(index / 2) * (index % 2 ? 1 : -1);
329
+ }
330
+ function nearestFreeSlot(rect, nodes, cross, main, vector, crossSign) {
331
+ const origin = { ...rect };
332
+ const outward = vector.x + vector.y;
333
+ let best = null;
334
+ for (let ring = 0; ring <= MAX_BRANCH_RINGS; ring += 1) {
335
+ const mainOffset = ring * BRANCH_RING_STEP;
336
+ if (best && mainOffset >= best.cost) break;
337
+ const candidate = { ...origin, [main]: origin[main] + outward * mainOffset };
338
+ for (let index = 0; index <= MAX_COLLISION_STEPS; index += 1) {
339
+ const crossOffset = crossOffsetAt(index, crossSign);
340
+ const cost = Math.hypot(mainOffset, crossOffset);
341
+ if (best && cost >= best.cost) break;
342
+ candidate[cross] = origin[cross] + crossOffset;
343
+ if (!overlapsAny(candidate, nodes)) {
344
+ best = { rect: { ...candidate }, cost };
345
+ break;
346
+ }
347
+ }
348
+ }
349
+ return best ? best.rect : origin;
350
+ }
351
+
223
352
  // src/core.js
224
353
  var SVG_NS = "http://www.w3.org/2000/svg";
225
354
  var TOOLBAR_ICON_PATHS = {
@@ -229,6 +358,7 @@ var TOOLBAR_ICON_PATHS = {
229
358
  "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",
230
359
  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",
231
360
  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",
361
+ 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",
232
362
  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"
233
363
  };
234
364
  var TOOLBAR_ACTIONS = [
@@ -280,21 +410,32 @@ var DEFAULT_EDGE_STROKE_WIDTH = 2.25;
280
410
  var MIN_EDGE_STROKE_WIDTH = 1.25;
281
411
  var MAX_EDGE_STROKE_WIDTH = 6;
282
412
  var CONNECTION_PORT_RADIUS = 6;
413
+ var PORT_HANDLE_OFFSET = 12;
283
414
  var CONNECTION_PORT_HIT_RADIUS = 14;
415
+ var PORT_CLICK_SLOP = 4;
416
+ var RESIZE_HANDLE_SIZE = 9;
417
+ var SELECTION_OUTSET = 4;
418
+ var GUIDE_SNAP_DISTANCE = 6;
419
+ var GUIDE_OVERHANG = 12;
420
+ var COLLAPSE_TOGGLE_OFFSET = 36;
421
+ var MINIMAP_WIDTH = 180;
422
+ var MINIMAP_HEIGHT = 120;
423
+ var MINIMAP_MIN_CANVAS_WIDTH = 480;
424
+ var SELECTION_BOX_TYPES = /* @__PURE__ */ new Set(["circle", "diamond", "label"]);
284
425
  var RECONNECT_ENDPOINT_RADIUS = 7;
285
426
  var RECONNECT_ENDPOINT_HIT_RADIUS = 12;
286
427
  var EDGE_HIT_STROKE_MIN = 16;
287
428
  var CONNECTION_SNAP_PADDING = 36;
288
429
  var CONNECTION_HYSTERESIS = 16;
289
430
  var CONNECTION_CENTER_LOCK_RADIUS = 18;
290
- var RESIZE_PORT_GAP = 34;
431
+ var RESIZE_PORT_GAP = 28;
291
432
  var EDGE_STROKE_PRESETS = [
292
433
  { id: "thin", label: "Thin", width: 1.75 },
293
434
  { id: "medium", label: "Medium", width: DEFAULT_EDGE_STROKE_WIDTH },
294
435
  { id: "bold", label: "Bold", width: 3.5 }
295
436
  ];
296
- var VD_FLOWCHART_VERSION = "1.3.0";
297
- var FLOWCHART_DOCUMENT_VERSION = "1.2.0";
437
+ var VD_FLOWCHART_VERSION = "1.4.0";
438
+ var FLOWCHART_DOCUMENT_VERSION = "1.3.0";
298
439
  var FLOWCHART_NODE_TYPES = [
299
440
  "rounded-rect",
300
441
  "rect",
@@ -307,7 +448,65 @@ var FLOWCHART_NODE_TYPES = [
307
448
  var FLOWCHART_PORTS = ["top", "right", "bottom", "left"];
308
449
  var FLOWCHART_EDGE_MARKERS = ["none", "arrow", "dot"];
309
450
  var FLOWCHART_EDGE_ROUTES = ["curve", "straight", "orthogonal"];
310
- var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update"]);
451
+ var FLOWCHART_KEYBOARD_SHORTCUTS = ["mindmap", "basic"];
452
+ function normalizeKeyboardShortcuts(value) {
453
+ return value === "basic" ? "basic" : "mindmap";
454
+ }
455
+ var ARROW_DIRECTIONS = {
456
+ ArrowRight: "right",
457
+ ArrowLeft: "left",
458
+ ArrowDown: "down",
459
+ ArrowUp: "up"
460
+ };
461
+ function getShortcutRows(mode, readonly) {
462
+ const navigation = [
463
+ ["Arrow keys", "Select the nearest node in that direction"],
464
+ ["Shift + Arrow", "Select a connection on that side (repeat to cycle)"],
465
+ ["Shift + click, Shift + drag", "Add nodes to the selection, or select with a box"],
466
+ ["Cmd/Ctrl + A", "Select all nodes"]
467
+ ];
468
+ const view = [
469
+ ["Cmd/Ctrl + = or -", "Zoom in or out"],
470
+ ["Cmd/Ctrl + 0", "Zoom to 100%"],
471
+ ["Shift + 1", "Fit the diagram to the view"]
472
+ ];
473
+ const help = [["?", "Show or hide this list"]];
474
+ if (readonly) {
475
+ return [...navigation, ...view, ...help, ["Tab", "Leave the canvas"]];
476
+ }
477
+ const mindmap = mode === "mindmap" ? [
478
+ ["Tab", "Add a child node"],
479
+ ["Enter", "Add a sibling below (Shift + Enter: above)"],
480
+ ["F2, Space, or type", "Edit the label"],
481
+ ["Enter while editing", "Save the label (Shift + Enter: new line)"],
482
+ ["Tab while editing", "Save and add a child"]
483
+ ] : [
484
+ ["Enter or F2", "Edit the label"],
485
+ ["Cmd/Ctrl + Enter", "Save the label"]
486
+ ];
487
+ return [
488
+ ...navigation,
489
+ ...mindmap,
490
+ [
491
+ mode === "mindmap" ? "Enter, F2, or type on a connection" : "Enter or F2 on a connection",
492
+ "Edit the connection label"
493
+ ],
494
+ ["Cmd/Ctrl + /", "Collapse or expand the branch"],
495
+ ["Alt + Arrow", "Nudge the node (add Shift for 1 px)"],
496
+ ["Delete or Backspace", "Delete the selection"],
497
+ ["Cmd/Ctrl + D", "Duplicate the node"],
498
+ ["Cmd/Ctrl + Z, Shift + Cmd/Ctrl + Z", "Undo, redo"],
499
+ ["Cmd/Ctrl + C, X, V", "Copy, cut, paste"],
500
+ ...view,
501
+ ["Esc", "Cancel editing, then the tool, then deselect"],
502
+ [
503
+ mode === "mindmap" ? "Shift + Tab, or Esc then Tab" : "Tab or Shift + Tab",
504
+ "Leave the canvas"
505
+ ],
506
+ ...help
507
+ ];
508
+ }
509
+ var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update", "node:nudge"]);
311
510
  var DEFAULT_EDGE_ROUTE = "curve";
312
511
  var ORTHOGONAL_STUB_LENGTH = 32;
313
512
  var ORTHOGONAL_CORNER_RADIUS = 12;
@@ -481,7 +680,7 @@ function normalizeViewport(viewport) {
481
680
  function normalizeNode(rawNode, index, usedIds) {
482
681
  const type = normalizeNodeType(rawNode?.type);
483
682
  const spec = getNodeSpec(type);
484
- return {
683
+ const node = {
485
684
  id: ensureUniqueId(rawNode?.id, "node", usedIds),
486
685
  type,
487
686
  x: toFiniteNumber(rawNode?.x, index * 28),
@@ -491,6 +690,37 @@ function normalizeNode(rawNode, index, usedIds) {
491
690
  text: rawNode?.text == null ? spec.text : String(rawNode.text),
492
691
  data: isPlainObject(rawNode?.data) ? deepClone(rawNode.data) : {}
493
692
  };
693
+ if (rawNode?.collapsed === true) node.collapsed = true;
694
+ return node;
695
+ }
696
+ function computeHiddenNodeIds(nodes, edges) {
697
+ const collapsed = new Set(nodes.filter((node) => node.collapsed).map((node) => node.id));
698
+ if (!collapsed.size) return /* @__PURE__ */ new Set();
699
+ const outgoing = new Map(nodes.map((node) => [node.id, []]));
700
+ edges.forEach((edge) => {
701
+ if (edge.from.nodeId === edge.to.nodeId) return;
702
+ outgoing.get(edge.from.nodeId)?.push(edge.to.nodeId);
703
+ });
704
+ const candidates = /* @__PURE__ */ new Set();
705
+ collapsed.forEach((rootId) => {
706
+ const queue2 = [...outgoing.get(rootId) || []];
707
+ for (let index = 0; index < queue2.length; index += 1) {
708
+ const id = queue2[index];
709
+ if (id === rootId || candidates.has(id)) continue;
710
+ candidates.add(id);
711
+ queue2.push(...outgoing.get(id) || []);
712
+ }
713
+ });
714
+ const queue = nodes.filter((node) => !candidates.has(node.id) && !collapsed.has(node.id)).map((node) => node.id);
715
+ for (let index = 0; index < queue.length; index += 1) {
716
+ const id = queue[index];
717
+ (outgoing.get(id) || []).forEach((childId) => {
718
+ if (!candidates.has(childId)) return;
719
+ candidates.delete(childId);
720
+ if (!collapsed.has(childId)) queue.push(childId);
721
+ });
722
+ }
723
+ return candidates;
494
724
  }
495
725
  function normalizeEndpoint(rawEndpoint, fallbackPort) {
496
726
  return {
@@ -643,6 +873,15 @@ function getPortPosition(node, port) {
643
873
  return { x: node.x, y: node.y + node.height / 2 };
644
874
  }
645
875
  }
876
+ function getPortHandlePosition(node, port, scale = 1) {
877
+ const point = getPortPosition(node, port);
878
+ const normal = getPortNormal(port);
879
+ const offset = PORT_HANDLE_OFFSET / scale;
880
+ return {
881
+ x: formatNumber(point.x + normal.x * offset),
882
+ y: formatNumber(point.y + normal.y * offset)
883
+ };
884
+ }
646
885
  function getNearestPort(node, point) {
647
886
  return FLOWCHART_PORTS.reduce((best, port) => {
648
887
  const portPoint = getPortPosition(node, port);
@@ -1010,6 +1249,68 @@ function getResizeCursor(handle) {
1010
1249
  if (handle === "ne" || handle === "sw") return "nesw-resize";
1011
1250
  return "nwse-resize";
1012
1251
  }
1252
+ function collectGuideLines(nodes) {
1253
+ const x = [];
1254
+ const y = [];
1255
+ nodes.forEach((node) => {
1256
+ const right = node.x + node.width;
1257
+ const bottom = node.y + node.height;
1258
+ [node.x, node.x + node.width / 2, right].forEach(
1259
+ (value) => x.push({ value, start: node.y, end: bottom })
1260
+ );
1261
+ [node.y, node.y + node.height / 2, bottom].forEach(
1262
+ (value) => y.push({ value, start: node.x, end: right })
1263
+ );
1264
+ });
1265
+ const byValue = (a, b) => a.value - b.value;
1266
+ return { x: x.sort(byValue), y: y.sort(byValue) };
1267
+ }
1268
+ function lowerBound(lines, value) {
1269
+ let low = 0;
1270
+ let high = lines.length;
1271
+ while (low < high) {
1272
+ const middle = low + high >> 1;
1273
+ if (lines[middle].value < value) low = middle + 1;
1274
+ else high = middle;
1275
+ }
1276
+ return low;
1277
+ }
1278
+ function snapToGuides(lines, candidates, threshold) {
1279
+ let best = null;
1280
+ candidates.forEach((candidate) => {
1281
+ const index = lowerBound(lines, candidate);
1282
+ [lines[index - 1], lines[index]].forEach((line) => {
1283
+ if (!line) return;
1284
+ const offset = line.value - candidate;
1285
+ if (Math.abs(offset) <= threshold && (!best || Math.abs(offset) < Math.abs(best.offset))) {
1286
+ best = { offset };
1287
+ }
1288
+ });
1289
+ });
1290
+ if (!best) return null;
1291
+ const positions = candidates.map((candidate) => candidate + best.offset);
1292
+ best.lines = lines.filter(
1293
+ (line) => positions.some((position) => Math.abs(line.value - position) < 0.5)
1294
+ );
1295
+ return best;
1296
+ }
1297
+ function guideSegments(axis, lines, box) {
1298
+ const byValue = /* @__PURE__ */ new Map();
1299
+ lines.forEach((line) => {
1300
+ const current = byValue.get(line.value);
1301
+ byValue.set(line.value, {
1302
+ start: Math.min(current?.start ?? Infinity, line.start),
1303
+ end: Math.max(current?.end ?? -Infinity, line.end)
1304
+ });
1305
+ });
1306
+ const boxStart = axis === "x" ? box.top : box.left;
1307
+ const boxEnd = boxStart + (axis === "x" ? box.height : box.width);
1308
+ return [...byValue].map(([value, extent]) => {
1309
+ const start = Math.min(extent.start, boxStart) - GUIDE_OVERHANG;
1310
+ const end = Math.max(extent.end, boxEnd) + GUIDE_OVERHANG;
1311
+ return axis === "x" ? { x1: value, y1: start, x2: value, y2: end } : { x1: start, y1: value, x2: end, y2: value };
1312
+ });
1313
+ }
1013
1314
  function getBounds(nodes) {
1014
1315
  if (!nodes.length) {
1015
1316
  return { left: 0, top: 0, right: 0, bottom: 0 };
@@ -1166,6 +1467,7 @@ var VdFlowchart = class {
1166
1467
  this.documentData = normalizeDocument(options.data || {});
1167
1468
  this.listeners = {};
1168
1469
  this.selection = null;
1470
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
1169
1471
  this.interaction = null;
1170
1472
  this.activeTool = null;
1171
1473
  this.layoutMode = "tree";
@@ -1173,6 +1475,7 @@ var VdFlowchart = class {
1173
1475
  this.destroyed = false;
1174
1476
  this.lastNodePointer = null;
1175
1477
  this.reconnectEdgeId = null;
1478
+ this.edgeCycleAnchor = null;
1176
1479
  this.clipboard = null;
1177
1480
  this.gridPatternId = nextId("flowchart-grid");
1178
1481
  this.markerIds = /* @__PURE__ */ new Map();
@@ -1182,6 +1485,16 @@ var VdFlowchart = class {
1182
1485
  this.history = [];
1183
1486
  this.historyIndex = -1;
1184
1487
  this.isApplyingHistory = false;
1488
+ this.historyBatchDepth = 0;
1489
+ this.historyBatchDirty = false;
1490
+ this.coalesceInsertedText = false;
1491
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
1492
+ this.snapGuides = options.snapGuides !== false;
1493
+ this.minimapEnabled = options.minimap !== false;
1494
+ this.autoLayout = Boolean(options.autoLayout);
1495
+ this.minimapFrame = 0;
1496
+ this.minimapDrag = null;
1497
+ this.shortcutsHelpOpen = false;
1185
1498
  this.autoFit = Boolean(options.autoFit);
1186
1499
  this.readyEmitted = false;
1187
1500
  this.resizeObserver = null;
@@ -1222,6 +1535,7 @@ var VdFlowchart = class {
1222
1535
  if (!ready) return false;
1223
1536
  this.readyEmitted = true;
1224
1537
  if (this.autoFit) this.fitView();
1538
+ else this.scheduleMinimap();
1225
1539
  this.emit("ready", this);
1226
1540
  return true;
1227
1541
  };
@@ -1291,8 +1605,25 @@ var VdFlowchart = class {
1291
1605
  className: "vd-flowchart-toolbar-label",
1292
1606
  text: "100%"
1293
1607
  });
1608
+ this.shortcutsButton = createElement("button", {
1609
+ className: "vd-flowchart-btn vd-flowchart-icon-btn vd-flowchart-shortcuts-btn",
1610
+ title: "Keyboard shortcuts (?)",
1611
+ text: "?"
1612
+ });
1613
+ this.shortcutsButton.setAttribute("type", "button");
1614
+ this.shortcutsButton.setAttribute("data-flowchart-action", "shortcuts");
1615
+ this.shortcutsButton.setAttribute("aria-label", "Keyboard shortcuts");
1616
+ this.shortcutsButton.setAttribute("aria-expanded", "false");
1617
+ this.minimapButton = createToolbarButton({
1618
+ action: "minimap",
1619
+ label: "Toggle minimap",
1620
+ icon: "minimap"
1621
+ });
1622
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
1294
1623
  toolbarLeft.appendChild(this.arrangeSelect);
1295
1624
  toolbarLeft.appendChild(this.clearButton);
1625
+ toolbarRight.appendChild(this.minimapButton);
1626
+ toolbarRight.appendChild(this.shortcutsButton);
1296
1627
  toolbarRight.appendChild(this.zoomLabel);
1297
1628
  this.toolbar.appendChild(toolbarLeft);
1298
1629
  this.toolbar.appendChild(toolbarRight);
@@ -1371,13 +1702,40 @@ var VdFlowchart = class {
1371
1702
  this.previewLayer = svgEl("g", { class: "vd-flowchart-preview" });
1372
1703
  this.nodesLayer = svgEl("g", { class: "vd-flowchart-nodes" });
1373
1704
  this.overlayLayer = svgEl("g", { class: "vd-flowchart-overlay" });
1705
+ this.guidesLayer = svgEl("g", { class: "vd-flowchart-guides" });
1374
1706
  this.world.appendChild(this.gridRect);
1375
1707
  this.world.appendChild(this.edgesLayer);
1376
1708
  this.world.appendChild(this.previewLayer);
1377
1709
  this.world.appendChild(this.nodesLayer);
1378
1710
  this.world.appendChild(this.overlayLayer);
1711
+ this.world.appendChild(this.guidesLayer);
1379
1712
  this.svg.appendChild(this.world);
1380
1713
  this.canvasEl.appendChild(this.svg);
1714
+ this.shortcutsHelp = createElement("div", { className: "vd-flowchart-shortcuts" });
1715
+ this.shortcutsHelp.id = nextId("flowchart-shortcuts");
1716
+ this.shortcutsHelp.setAttribute("role", "dialog");
1717
+ this.shortcutsHelp.setAttribute("aria-label", "Keyboard shortcuts");
1718
+ this.shortcutsHelp.hidden = true;
1719
+ this.shortcutsHelp.addEventListener("pointerdown", (event) => event.stopPropagation());
1720
+ this.shortcutsHelp.addEventListener("wheel", (event) => event.stopPropagation());
1721
+ this.shortcutsButton.setAttribute("aria-controls", this.shortcutsHelp.id);
1722
+ this.canvasEl.appendChild(this.shortcutsHelp);
1723
+ this.minimapEl = createElement("div", { className: "vd-flowchart-minimap" });
1724
+ this.minimapEl.setAttribute("aria-hidden", "true");
1725
+ this.minimapEl.hidden = true;
1726
+ this.minimapSvg = svgEl("svg", {
1727
+ class: "vd-flowchart-minimap-svg",
1728
+ width: MINIMAP_WIDTH,
1729
+ height: MINIMAP_HEIGHT,
1730
+ viewBox: `0 0 ${MINIMAP_WIDTH} ${MINIMAP_HEIGHT}`
1731
+ });
1732
+ this.minimapEl.appendChild(this.minimapSvg);
1733
+ this.minimapEl.addEventListener("pointerdown", (event) => this.handleMinimapPointer(event));
1734
+ this.minimapEl.addEventListener("pointermove", (event) => this.handleMinimapPointer(event));
1735
+ this.minimapEl.addEventListener("pointerup", (event) => this.endMinimapDrag(event));
1736
+ this.minimapEl.addEventListener("pointercancel", (event) => this.endMinimapDrag(event));
1737
+ this.minimapEl.addEventListener("wheel", (event) => event.stopPropagation());
1738
+ this.canvasEl.appendChild(this.minimapEl);
1381
1739
  this.inspectorPanel = createElement("aside", {
1382
1740
  className: "vd-flowchart-panel vd-flowchart-panel--inspector"
1383
1741
  });
@@ -1485,11 +1843,15 @@ var VdFlowchart = class {
1485
1843
  this.graphOutline.appendChild(this.graphList);
1486
1844
  this.root.appendChild(this.graphOutline);
1487
1845
  this.canvasEl.setAttribute("role", "group");
1846
+ this.syncCanvasLabel();
1847
+ this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1848
+ }
1849
+ syncCanvasLabel() {
1850
+ 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.";
1488
1851
  this.canvasEl.setAttribute(
1489
1852
  "aria-label",
1490
- "Diagram canvas. Arrow keys select nodes; Enter edits a label. Graph outline provides connections."
1853
+ `Diagram canvas. ${keys} Press ? for all shortcuts. Graph outline provides connections.`
1491
1854
  );
1492
- this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1493
1855
  }
1494
1856
  renderGraphOutline() {
1495
1857
  if (!this.graphOutline) return;
@@ -1499,8 +1861,9 @@ var VdFlowchart = class {
1499
1861
  const selected = this.selection?.kind === "node" ? this.selection.id : null;
1500
1862
  this.graphStatus.textContent = selected ? `Selected: ${labels.get(selected)}. ${nodes.length} nodes, ${edges.length} connections.` : `${nodes.length} nodes, ${edges.length} connections.`;
1501
1863
  if (!this.graphOutline.open) return;
1864
+ const hidden = this.getHiddenNodeIds();
1502
1865
  const signature = JSON.stringify([
1503
- nodes.map((node) => [node.id, node.text]),
1866
+ nodes.map((node) => [node.id, node.text, Boolean(node.collapsed), hidden.has(node.id)]),
1504
1867
  edges.map((edge) => [edge.id, edge.from.nodeId, edge.to.nodeId, edge.label])
1505
1868
  ]);
1506
1869
  if (signature !== this.graphSignature) {
@@ -1526,8 +1889,10 @@ var VdFlowchart = class {
1526
1889
  const from = incoming.get(node.id);
1527
1890
  const relationships = [
1528
1891
  to.length ? `Connects to ${to.join(", ")}.` : "No outgoing connections.",
1529
- from.length ? `Connected from ${from.join(", ")}.` : ""
1530
- ];
1892
+ from.length ? `Connected from ${from.join(", ")}.` : "",
1893
+ node.collapsed ? "Branch collapsed." : "",
1894
+ hidden.has(node.id) ? "Hidden in a collapsed branch." : ""
1895
+ ].filter(Boolean);
1531
1896
  this.graphList.appendChild(
1532
1897
  createElement("li", { text: `${labels.get(node.id)}. ${relationships.join(" ")}` })
1533
1898
  );
@@ -1612,6 +1977,157 @@ var VdFlowchart = class {
1612
1977
  if (action === "undo") this.undo();
1613
1978
  if (action === "redo") this.redo();
1614
1979
  if (action === "clear" && !this.readonly) this.clear();
1980
+ if (action === "shortcuts") this.toggleShortcutsHelp();
1981
+ if (action === "minimap") this.setMinimapEnabled(!this.minimapEnabled);
1982
+ }
1983
+ setMinimapEnabled(enabled) {
1984
+ this.minimapEnabled = Boolean(enabled);
1985
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
1986
+ this.drawMinimap();
1987
+ return this;
1988
+ }
1989
+ scheduleMinimap() {
1990
+ if (this.minimapFrame || !hasWindow()) return;
1991
+ const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => setTimeout(callback, 16);
1992
+ this.minimapFrame = raf(() => {
1993
+ this.minimapFrame = 0;
1994
+ this.drawMinimap();
1995
+ });
1996
+ }
1997
+ // World-to-minimap mapping covering every visible node and the viewport.
1998
+ getMinimapTransform() {
1999
+ const viewport = this.documentData.viewport;
2000
+ const width = this.canvasEl.clientWidth;
2001
+ const height = this.canvasEl.clientHeight;
2002
+ const view = {
2003
+ left: -viewport.x / viewport.scale,
2004
+ top: -viewport.y / viewport.scale,
2005
+ right: (width - viewport.x) / viewport.scale,
2006
+ bottom: (height - viewport.y) / viewport.scale
2007
+ };
2008
+ const content = getBounds(this.getVisibleNodes());
2009
+ const left = Math.min(content.left, view.left);
2010
+ const top = Math.min(content.top, view.top);
2011
+ const spanX = Math.max(1, Math.max(content.right, view.right) - left);
2012
+ const spanY = Math.max(1, Math.max(content.bottom, view.bottom) - top);
2013
+ const padding = 8;
2014
+ const scale = Math.min(
2015
+ (MINIMAP_WIDTH - padding * 2) / spanX,
2016
+ (MINIMAP_HEIGHT - padding * 2) / spanY
2017
+ );
2018
+ return {
2019
+ scale,
2020
+ offsetX: (MINIMAP_WIDTH - spanX * scale) / 2 - left * scale,
2021
+ offsetY: (MINIMAP_HEIGHT - spanY * scale) / 2 - top * scale,
2022
+ view
2023
+ };
2024
+ }
2025
+ drawMinimap() {
2026
+ if (this.destroyed || !this.minimapEl) return;
2027
+ const nodes = this.getVisibleNodes();
2028
+ const visible = this.minimapEnabled && nodes.length > 0 && this.canvasEl.clientWidth >= MINIMAP_MIN_CANVAS_WIDTH;
2029
+ this.minimapEl.hidden = !visible;
2030
+ if (!visible) return;
2031
+ const transform = this.minimapDrag?.transform || this.getMinimapTransform();
2032
+ const view = this.getMinimapTransform().view;
2033
+ const map = (x, y) => ({
2034
+ x: formatNumber(x * transform.scale + transform.offsetX),
2035
+ y: formatNumber(y * transform.scale + transform.offsetY)
2036
+ });
2037
+ clearChildren(this.minimapSvg);
2038
+ nodes.forEach((node) => {
2039
+ const point = map(node.x, node.y);
2040
+ const selected = this.isNodeSelected(node.id);
2041
+ this.minimapSvg.appendChild(
2042
+ svgEl("rect", {
2043
+ class: `vd-flowchart-minimap-node${selected ? " is-selected" : ""}`,
2044
+ x: point.x,
2045
+ y: point.y,
2046
+ width: formatNumber(Math.max(1.5, node.width * transform.scale)),
2047
+ height: formatNumber(Math.max(1.5, node.height * transform.scale)),
2048
+ rx: 1.5
2049
+ })
2050
+ );
2051
+ });
2052
+ const corner = map(view.left, view.top);
2053
+ this.minimapSvg.appendChild(
2054
+ svgEl("rect", {
2055
+ class: "vd-flowchart-minimap-viewport",
2056
+ x: corner.x,
2057
+ y: corner.y,
2058
+ width: formatNumber((view.right - view.left) * transform.scale),
2059
+ height: formatNumber((view.bottom - view.top) * transform.scale)
2060
+ })
2061
+ );
2062
+ }
2063
+ // Centre the main view on the world point under the minimap pointer. The
2064
+ // mapping is frozen for the whole drag so the map does not shift under it.
2065
+ handleMinimapPointer(event) {
2066
+ event.stopPropagation();
2067
+ if (event.type === "pointerdown") {
2068
+ if (event.button != null && event.button !== 0) return;
2069
+ event.preventDefault();
2070
+ this.minimapDrag = { pointerId: event.pointerId, transform: this.getMinimapTransform() };
2071
+ try {
2072
+ this.minimapEl.setPointerCapture?.(event.pointerId);
2073
+ } catch {
2074
+ }
2075
+ }
2076
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2077
+ const rect = this.minimapEl.getBoundingClientRect();
2078
+ const { transform } = this.minimapDrag;
2079
+ this.centerViewOn(
2080
+ (event.clientX - rect.left - transform.offsetX) / transform.scale,
2081
+ (event.clientY - rect.top - transform.offsetY) / transform.scale
2082
+ );
2083
+ }
2084
+ endMinimapDrag(event) {
2085
+ event.stopPropagation();
2086
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2087
+ this.minimapDrag = null;
2088
+ this.syncJsonTextarea();
2089
+ this.emitViewportChange("viewport:pan");
2090
+ this.drawMinimap();
2091
+ }
2092
+ centerViewOn(worldX, worldY) {
2093
+ const viewport = this.documentData.viewport;
2094
+ const width = this.canvasEl.clientWidth || 800;
2095
+ const height = this.canvasEl.clientHeight || 560;
2096
+ viewport.x = formatNumber(width / 2 - worldX * viewport.scale);
2097
+ viewport.y = formatNumber(height / 2 - worldY * viewport.scale);
2098
+ this.render({ inspector: false, json: false });
2099
+ return this;
2100
+ }
2101
+ toggleShortcutsHelp(force) {
2102
+ const open = typeof force === "boolean" ? force : !this.shortcutsHelpOpen;
2103
+ this.shortcutsHelpOpen = open;
2104
+ this.shortcutsButton.setAttribute("aria-expanded", open ? "true" : "false");
2105
+ if (open) this.renderShortcutsHelp();
2106
+ this.shortcutsHelp.hidden = !open;
2107
+ if (!open && this.shortcutsHelp.contains(document.activeElement)) this.canvasEl.focus();
2108
+ return this;
2109
+ }
2110
+ renderShortcutsHelp() {
2111
+ clearChildren(this.shortcutsHelp);
2112
+ const header = createElement("div", { className: "vd-flowchart-shortcuts-header" });
2113
+ header.appendChild(
2114
+ createElement("h4", { className: "vd-flowchart-panel-title", text: "Keyboard shortcuts" })
2115
+ );
2116
+ const close = createElement("button", {
2117
+ className: "vd-flowchart-btn vd-flowchart-icon-btn",
2118
+ text: "\xD7"
2119
+ });
2120
+ close.setAttribute("type", "button");
2121
+ close.setAttribute("aria-label", "Close keyboard shortcuts");
2122
+ close.addEventListener("click", () => this.toggleShortcutsHelp(false));
2123
+ header.appendChild(close);
2124
+ this.shortcutsHelp.appendChild(header);
2125
+ const list = createElement("dl", { className: "vd-flowchart-shortcuts-list" });
2126
+ getShortcutRows(this.keyboardShortcuts, this.readonly).forEach(([keys, action]) => {
2127
+ list.appendChild(createElement("dt", { text: keys }));
2128
+ list.appendChild(createElement("dd", { text: action }));
2129
+ });
2130
+ this.shortcutsHelp.appendChild(list);
1615
2131
  }
1616
2132
  handleArrangeChange(event) {
1617
2133
  if (this.readonly) return;
@@ -1720,38 +2236,48 @@ var VdFlowchart = class {
1720
2236
  );
1721
2237
  }
1722
2238
  }
2239
+ // Keys the editor consumes stop here, so page-level shortcuts (a docs
2240
+ // site's "/" search, a host's Escape handler) do not also fire.
1723
2241
  handleKeyDown(event) {
1724
- if (event.target && (event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA" || event.target.tagName === "SELECT")) {
2242
+ const alreadyHandled = event.defaultPrevented;
2243
+ this.handleEditorKey(event);
2244
+ if (!alreadyHandled && event.defaultPrevented) event.stopPropagation();
2245
+ }
2246
+ handleEditorKey(event) {
2247
+ const target = event.target;
2248
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) {
1725
2249
  return;
1726
2250
  }
1727
- if (event.target === this.canvasEl) {
1728
- const nodes = this.documentData.nodes;
1729
- const index = nodes.findIndex((node) => node.id === this.selection?.id);
1730
- let next = -1;
1731
- if (event.key === "ArrowDown" || event.key === "ArrowRight")
1732
- next = (index + 1) % nodes.length;
1733
- if (event.key === "ArrowUp" || event.key === "ArrowLeft")
1734
- next = index < 0 ? nodes.length - 1 : (index - 1 + nodes.length) % nodes.length;
1735
- if (event.key === "Home") next = 0;
1736
- if (event.key === "End") next = nodes.length - 1;
1737
- if (nodes[next]) {
1738
- event.preventDefault();
1739
- this.selectNode(nodes[next].id);
1740
- return;
1741
- }
1742
- if (event.key === "Enter" && !this.readonly && this.selection?.kind === "node") {
1743
- event.preventDefault();
1744
- this.startTextEdit(this.selection.id);
1745
- return;
1746
- }
2251
+ if (event.isComposing) return;
2252
+ const key = event.key;
2253
+ const modKey = event.metaKey || event.ctrlKey;
2254
+ if (key === "Escape" && this.shortcutsHelpOpen) {
2255
+ event.preventDefault();
2256
+ this.toggleShortcutsHelp(false);
2257
+ return;
2258
+ }
2259
+ if (target === this.canvasEl && this.handleCanvasKeyDown(event)) {
2260
+ event.preventDefault();
2261
+ return;
1747
2262
  }
1748
2263
  if (this.readonly) return;
1749
- if (event.key === "Escape" && this.activeTool) {
2264
+ if (key === "Escape") {
2265
+ if (this.activeTool) this.setActiveTool(null);
2266
+ else if (this.reconnectEdgeId) {
2267
+ this.reconnectEdgeId = null;
2268
+ this.render({ inspector: false, json: false });
2269
+ } else if (this.selection) this.select(null);
2270
+ else return;
1750
2271
  event.preventDefault();
1751
- this.setActiveTool(null);
1752
2272
  return;
1753
2273
  }
1754
- const modKey = event.metaKey || event.ctrlKey;
2274
+ if (modKey && (key === "d" || key === "D")) {
2275
+ if (this.selection?.kind === "node") {
2276
+ event.preventDefault();
2277
+ this.duplicateSelection();
2278
+ }
2279
+ return;
2280
+ }
1755
2281
  if (modKey && (event.key === "z" || event.key === "Z")) {
1756
2282
  event.preventDefault();
1757
2283
  if (event.shiftKey) {
@@ -1790,11 +2316,248 @@ var VdFlowchart = class {
1790
2316
  if (!this.selection) return;
1791
2317
  if (event.key === "Backspace" || event.key === "Delete") {
1792
2318
  event.preventDefault();
2319
+ if (this.selection.kind === "node" && !this.hasMultiSelection()) {
2320
+ const removedId = this.selection.id;
2321
+ const parent = this.getParentNode(removedId);
2322
+ const sibling = parent ? this.getChildNodes(parent.id).find((node) => node.id !== removedId) : null;
2323
+ if (this.deleteSelection()) {
2324
+ const next = parent && this.findNode(parent.id) || sibling;
2325
+ if (next) this.select({ kind: "node", id: next.id });
2326
+ }
2327
+ return;
2328
+ }
1793
2329
  this.deleteSelection();
1794
2330
  }
1795
2331
  }
2332
+ // Keys that only apply while the canvas itself has focus. Returns true when
2333
+ // the key was handled (the caller prevents the default action).
2334
+ handleCanvasKeyDown(event) {
2335
+ const key = event.key;
2336
+ const modKey = event.metaKey || event.ctrlKey;
2337
+ const mindmap = this.keyboardShortcuts === "mindmap";
2338
+ const selectedNode = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2339
+ const selectedEdge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2340
+ const movable = !this.readonly && Boolean(selectedNode);
2341
+ const editable = movable && !this.hasMultiSelection();
2342
+ if (key === "?" && !modKey && !event.altKey) {
2343
+ this.toggleShortcutsHelp();
2344
+ return true;
2345
+ }
2346
+ if (modKey && !event.altKey) {
2347
+ if (key === "=" || key === "+") return Boolean(this.zoomIn());
2348
+ if (key === "-" || key === "_") return Boolean(this.zoomOut());
2349
+ if (key === "0") return Boolean(this.zoomTo(1));
2350
+ if (key === "a" || key === "A") {
2351
+ this.selectNodes(this.getVisibleNodes().map((node) => node.id));
2352
+ return true;
2353
+ }
2354
+ if (key === "/" && selectedNode && !this.hasMultiSelection()) {
2355
+ this.toggleCollapsed(selectedNode.id);
2356
+ return true;
2357
+ }
2358
+ return false;
2359
+ }
2360
+ if (event.shiftKey && !event.altKey && (key === "!" || event.code === "Digit1")) {
2361
+ return Boolean(this.fitView());
2362
+ }
2363
+ const direction = ARROW_DIRECTIONS[key];
2364
+ if (direction) {
2365
+ if (event.altKey) {
2366
+ if (movable) {
2367
+ this.nudgeNodes(this.getSelectedNodeIds(), direction, event.shiftKey ? 1 : this.gridSize);
2368
+ }
2369
+ return true;
2370
+ }
2371
+ if (event.shiftKey) {
2372
+ const anchorId = selectedNode?.id ?? (selectedEdge ? this.edgeCycleAnchor : null);
2373
+ return anchorId ? this.selectSideEdge(anchorId, direction) : false;
2374
+ }
2375
+ this.navigateSelection(direction);
2376
+ return true;
2377
+ }
2378
+ const nodes = this.getVisibleNodes();
2379
+ if ((key === "Home" || key === "End") && nodes.length) {
2380
+ this.selectNode(nodes[key === "Home" ? 0 : nodes.length - 1].id);
2381
+ return true;
2382
+ }
2383
+ if (selectedEdge && !this.readonly && !event.altKey) {
2384
+ if (key === "F2" || key === "Enter" || key === " " && mindmap) {
2385
+ return this.startEdgeLabelEdit(selectedEdge.id);
2386
+ }
2387
+ if (mindmap && key.length === 1 && key !== " ") {
2388
+ return this.startEdgeLabelEdit(selectedEdge.id, { initialText: key });
2389
+ }
2390
+ return false;
2391
+ }
2392
+ if (!editable || event.altKey) return false;
2393
+ if (key === "F2" || key === "Enter" && !mindmap || key === " " && mindmap) {
2394
+ this.startTextEdit(selectedNode.id);
2395
+ return true;
2396
+ }
2397
+ if (!mindmap) return false;
2398
+ if (key === "Tab" && !event.shiftKey) {
2399
+ this.insertBranchNode(selectedNode.id, { edit: true });
2400
+ return true;
2401
+ }
2402
+ if (key === "Enter") {
2403
+ this.insertSiblingNode(selectedNode.id, { before: event.shiftKey, edit: true });
2404
+ return true;
2405
+ }
2406
+ if (key.length === 1 && key !== " " && isNodeTextEditable(selectedNode)) {
2407
+ this.startTextEdit(selectedNode.id, { initialText: key });
2408
+ return true;
2409
+ }
2410
+ return false;
2411
+ }
2412
+ // Move the selection to the nearest node in `direction`; with nothing (or an
2413
+ // edge) selected, start from the node closest to the centre of the view.
2414
+ navigateSelection(direction) {
2415
+ const nodes = this.getVisibleNodes();
2416
+ if (!nodes.length) return false;
2417
+ const current = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2418
+ const edge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2419
+ const next = current ? findSpatialNeighbor(nodes, current, direction) : edge ? this.getEdgeEndInDirection(edge, direction) : findNearestNode(nodes, this.getViewportCenter());
2420
+ if (!next) return false;
2421
+ this.selectNode(next.id);
2422
+ this.revealNode(next.id);
2423
+ return true;
2424
+ }
2425
+ // The end of `edge` that best matches `direction`, measured from the label
2426
+ // anchor so either end can be reached from a selected connection.
2427
+ getEdgeEndInDirection(edge, direction) {
2428
+ const fromNode = this.findNode(edge.from.nodeId);
2429
+ const toNode = this.findNode(edge.to.nodeId);
2430
+ if (!fromNode || !toNode) return fromNode || toNode;
2431
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
2432
+ const vector = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2433
+ const score = (node) => (node.x + node.width / 2 - labelX) * vector[0] + (node.y + node.height / 2 - labelY) * vector[1];
2434
+ return score(toNode) >= score(fromNode) ? toNode : fromNode;
2435
+ }
2436
+ // Connections attached to `nodeId` on the `direction` side, outgoing or
2437
+ // incoming, in document order.
2438
+ getSideEdges(nodeId, direction) {
2439
+ const port = DIRECTION_PORTS[direction];
2440
+ return this.documentData.edges.filter(
2441
+ (edge) => edge.from.nodeId === nodeId && edge.from.port === port || edge.to.nodeId === nodeId && edge.to.port === port
2442
+ );
2443
+ }
2444
+ // Select a connection on one side of a node; repeating cycles through them.
2445
+ selectSideEdge(nodeId, direction) {
2446
+ const edges = this.getSideEdges(nodeId, direction);
2447
+ if (!edges.length) return false;
2448
+ const currentIndex = this.selection?.kind === "edge" ? edges.findIndex((edge) => edge.id === this.selection.id) : -1;
2449
+ const next = edges[(currentIndex + 1) % edges.length];
2450
+ this.select({ kind: "edge", id: next.id });
2451
+ this.edgeCycleAnchor = nodeId;
2452
+ return true;
2453
+ }
2454
+ nudgeNode(nodeId, direction, distance) {
2455
+ const [node] = this.nudgeNodes([nodeId], direction, distance);
2456
+ return node || null;
2457
+ }
2458
+ // Move nodes together; repeated nudges of the same selection coalesce into
2459
+ // one undo step.
2460
+ nudgeNodes(nodeIds, direction, distance) {
2461
+ const step = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2462
+ if (this.readonly || !step) return [];
2463
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2464
+ if (!nodes.length) return [];
2465
+ nodes.forEach((node) => {
2466
+ node.x = formatNumber(node.x + step[0] * distance);
2467
+ node.y = formatNumber(node.y + step[1] * distance);
2468
+ });
2469
+ this.render({ inspector: true, json: true });
2470
+ this.emitChange(
2471
+ "node:nudge",
2472
+ nodes.length > 1 ? { nodeIds: nodes.map((node) => node.id) } : { node: deepClone(nodes[0]) }
2473
+ );
2474
+ return nodes.map((node) => deepClone(node));
2475
+ }
2476
+ zoomTo(scale) {
2477
+ const viewport = this.documentData.viewport;
2478
+ const width = this.canvasEl.clientWidth || 800;
2479
+ const height = this.canvasEl.clientHeight || 560;
2480
+ this.scaleAround(
2481
+ clamp(scale, MIN_SCALE, MAX_SCALE) / viewport.scale,
2482
+ width / 2,
2483
+ height / 2,
2484
+ "viewport:zoom"
2485
+ );
2486
+ return this;
2487
+ }
2488
+ duplicateSelection() {
2489
+ if (this.readonly || this.selection?.kind !== "node") return null;
2490
+ if (this.hasMultiSelection()) return this.pasteNodeGroup(this.captureNodeGroup());
2491
+ const node = this.findNode(this.selection.id);
2492
+ return node ? this.pasteNodeData(node) : null;
2493
+ }
2494
+ // Selected nodes plus the connections that run between them.
2495
+ captureNodeGroup() {
2496
+ const ids = new Set(this.getSelectedNodeIds());
2497
+ return {
2498
+ nodes: deepClone(this.documentData.nodes.filter((node) => ids.has(node.id))),
2499
+ edges: deepClone(
2500
+ this.documentData.edges.filter(
2501
+ (edge) => ids.has(edge.from.nodeId) && ids.has(edge.to.nodeId)
2502
+ )
2503
+ )
2504
+ };
2505
+ }
2506
+ // Paste a captured group offset by one step, remapping ids so internal
2507
+ // connections follow their copies. One change, one undo step.
2508
+ pasteNodeGroup(group) {
2509
+ if (this.readonly || !group?.nodes?.length) return [];
2510
+ const usedNodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2511
+ const idMap = /* @__PURE__ */ new Map();
2512
+ const nodes = group.nodes.map((data, index) => {
2513
+ const node = normalizeNode(
2514
+ { ...data, id: void 0, x: data.x + 24, y: data.y + 24 },
2515
+ this.documentData.nodes.length + index,
2516
+ usedNodeIds
2517
+ );
2518
+ idMap.set(data.id, node.id);
2519
+ return node;
2520
+ });
2521
+ this.documentData.nodes.push(...nodes);
2522
+ const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2523
+ const usedEdgeIds = new Set(this.documentData.edges.map((edge) => edge.id));
2524
+ group.edges.forEach((data, index) => {
2525
+ const edge = normalizeEdge(
2526
+ {
2527
+ ...data,
2528
+ id: void 0,
2529
+ from: { ...data.from, nodeId: idMap.get(data.from.nodeId) },
2530
+ to: { ...data.to, nodeId: idMap.get(data.to.nodeId) }
2531
+ },
2532
+ this.documentData.edges.length + index,
2533
+ nodeIds,
2534
+ usedEdgeIds
2535
+ );
2536
+ if (edge) this.documentData.edges.push(edge);
2537
+ });
2538
+ const ids = nodes.map((node) => node.id);
2539
+ this.selectNodes(ids);
2540
+ this.emitChange("nodes:add", { nodeIds: ids });
2541
+ return nodes.map((node) => deepClone(node));
2542
+ }
2543
+ pasteNodeData(data) {
2544
+ const usedIds = new Set(this.documentData.nodes.map((node2) => node2.id));
2545
+ const node = normalizeNode(
2546
+ { ...data, id: void 0, collapsed: void 0, x: data.x + 24, y: data.y + 24 },
2547
+ this.documentData.nodes.length,
2548
+ usedIds
2549
+ );
2550
+ this.documentData.nodes.push(node);
2551
+ this.select({ kind: "node", id: node.id });
2552
+ this.emitChange("node:add", { node: deepClone(node) });
2553
+ return deepClone(node);
2554
+ }
1796
2555
  copySelection() {
1797
2556
  if (!this.selection) return false;
2557
+ if (this.hasMultiSelection()) {
2558
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2559
+ return true;
2560
+ }
1798
2561
  if (this.selection.kind === "node") {
1799
2562
  const node = this.findNode(this.selection.id);
1800
2563
  if (!node) return false;
@@ -1809,22 +2572,14 @@ var VdFlowchart = class {
1809
2572
  pasteClipboard() {
1810
2573
  if (!this.clipboard || this.readonly) return false;
1811
2574
  if (this.clipboard.kind === "node") {
1812
- const usedIds2 = new Set(this.documentData.nodes.map((node2) => node2.id));
1813
- const node = normalizeNode(
1814
- {
1815
- ...this.clipboard.data,
1816
- id: void 0,
1817
- x: this.clipboard.data.x + 24,
1818
- y: this.clipboard.data.y + 24
1819
- },
1820
- this.documentData.nodes.length,
1821
- usedIds2
1822
- );
1823
- this.documentData.nodes.push(node);
1824
- this.select({ kind: "node", id: node.id });
1825
- this.emitChange("node:add", { node: deepClone(node) });
2575
+ this.pasteNodeData(this.clipboard.data);
1826
2576
  return true;
1827
2577
  }
2578
+ if (this.clipboard.kind === "nodes") {
2579
+ const pasted = this.pasteNodeGroup(this.clipboard.data);
2580
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2581
+ return pasted.length > 0;
2582
+ }
1828
2583
  const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
1829
2584
  const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
1830
2585
  const edge = normalizeEdge(
@@ -1851,16 +2606,16 @@ var VdFlowchart = class {
1851
2606
  const edgeTarget = event.target.closest("[data-edge-id]");
1852
2607
  if (edgeTarget && !event.target.closest("[data-edge-endpoint]")) {
1853
2608
  const edgeId = edgeTarget.getAttribute("data-edge-id");
1854
- if (edgeId && this.findEdge(edgeId)) {
1855
- this.reconnectEdgeId = edgeId;
1856
- this.select({ kind: "edge", id: edgeId });
2609
+ if (edgeId && this.startEdgeLabelEdit(edgeId)) {
1857
2610
  event.preventDefault();
1858
2611
  event.stopPropagation();
1859
2612
  return;
1860
2613
  }
1861
2614
  }
1862
2615
  const nodeTarget = event.target.closest("[data-node-id]");
1863
- if (!nodeTarget || event.target.closest("[data-edge-id]")) return;
2616
+ if (!nodeTarget || event.target.closest("[data-edge-id]") || event.target.closest("[data-collapse-toggle]")) {
2617
+ return;
2618
+ }
1864
2619
  const nodeId = nodeTarget.getAttribute("data-node-id");
1865
2620
  if (this.startTextEdit(nodeId)) {
1866
2621
  event.preventDefault();
@@ -1870,7 +2625,7 @@ var VdFlowchart = class {
1870
2625
  handleClick(event) {
1871
2626
  if (this.readonly || event.detail < 2) return;
1872
2627
  const nodeTarget = event.target.closest("[data-node-id]");
1873
- if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]"))
2628
+ if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]") || event.target.closest("[data-collapse-toggle]"))
1874
2629
  return;
1875
2630
  if (this.startTextEdit(nodeTarget.getAttribute("data-node-id"))) {
1876
2631
  event.preventDefault();
@@ -1883,6 +2638,13 @@ var VdFlowchart = class {
1883
2638
  this.stopTextEdit({ commit: true });
1884
2639
  }
1885
2640
  this.canvasEl.focus();
2641
+ const collapseTarget = event.target.closest("[data-collapse-toggle]");
2642
+ if (collapseTarget) {
2643
+ this.lastNodePointer = null;
2644
+ this.toggleCollapsed(collapseTarget.getAttribute("data-collapse-toggle"));
2645
+ event.preventDefault();
2646
+ return;
2647
+ }
1886
2648
  const nodeTarget = event.target.closest("[data-node-id]");
1887
2649
  const portTarget = event.target.closest("[data-port]");
1888
2650
  const resizeTarget = event.target.closest("[data-resize-handle]");
@@ -1918,6 +2680,13 @@ var VdFlowchart = class {
1918
2680
  event.preventDefault();
1919
2681
  return;
1920
2682
  }
2683
+ if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && event.shiftKey) {
2684
+ this.reconnectEdgeId = null;
2685
+ this.lastNodePointer = null;
2686
+ this.toggleNodeSelection(nodeTarget.getAttribute("data-node-id"));
2687
+ event.preventDefault();
2688
+ return;
2689
+ }
1921
2690
  if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && !this.readonly) {
1922
2691
  if (this.activeTool === "arrow") {
1923
2692
  this.select({ kind: "node", id: nodeTarget.getAttribute("data-node-id") });
@@ -1944,6 +2713,9 @@ var VdFlowchart = class {
1944
2713
  this.interaction = {
1945
2714
  kind: "connect",
1946
2715
  pointerId: event.pointerId,
2716
+ startClientX: event.clientX,
2717
+ startClientY: event.clientY,
2718
+ moved: false,
1947
2719
  source: { nodeId, port },
1948
2720
  target: null,
1949
2721
  previousSnap: null,
@@ -1994,22 +2766,27 @@ var VdFlowchart = class {
1994
2766
  if (nodeTarget) {
1995
2767
  this.reconnectEdgeId = null;
1996
2768
  const nodeId = nodeTarget.getAttribute("data-node-id");
1997
- this.select({ kind: "node", id: nodeId });
2769
+ const groupIds = this.hasMultiSelection() && this.isNodeSelected(nodeId) ? this.getSelectedNodeIds() : [nodeId];
2770
+ if (groupIds.length > 1) this.selectNodes(groupIds, { primary: nodeId });
2771
+ else this.select({ kind: "node", id: nodeId });
1998
2772
  if (!this.readonly) {
1999
- const node = this.findNode(nodeId);
2000
- const world = this.clientToWorld(event.clientX, event.clientY);
2001
- this.interaction = {
2002
- kind: "drag-node",
2003
- pointerId: event.pointerId,
2004
- nodeId,
2005
- offsetX: world.x - node.x,
2006
- offsetY: world.y - node.y,
2007
- moved: false
2008
- };
2773
+ this.interaction = this.createDragInteraction(event, nodeId, groupIds);
2009
2774
  this.capturePointer(event.pointerId);
2010
2775
  }
2011
2776
  return;
2012
2777
  }
2778
+ if (event.shiftKey) {
2779
+ const startWorld = this.clientToWorld(event.clientX, event.clientY);
2780
+ this.interaction = {
2781
+ kind: "marquee",
2782
+ pointerId: event.pointerId,
2783
+ startWorld,
2784
+ baseIds: this.selection?.kind === "node" ? this.getSelectedNodeIds() : []
2785
+ };
2786
+ this.capturePointer(event.pointerId);
2787
+ event.preventDefault();
2788
+ return;
2789
+ }
2013
2790
  this.select(null);
2014
2791
  this.reconnectEdgeId = null;
2015
2792
  this.interaction = {
@@ -2024,20 +2801,123 @@ var VdFlowchart = class {
2024
2801
  this.capturePointer(event.pointerId);
2025
2802
  event.preventDefault();
2026
2803
  }
2804
+ createDragInteraction(event, nodeId, nodeIds) {
2805
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2806
+ const moving = new Set(nodes.map((node) => node.id));
2807
+ return {
2808
+ kind: "drag-node",
2809
+ pointerId: event.pointerId,
2810
+ nodeId,
2811
+ nodeIds: nodes.map((node) => node.id),
2812
+ startWorld: this.clientToWorld(event.clientX, event.clientY),
2813
+ origins: new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }])),
2814
+ bounds: getBounds(nodes),
2815
+ guideLines: this.snapGuides ? collectGuideLines(this.getVisibleNodes().filter((node) => !moving.has(node.id))) : null,
2816
+ snapDisabled: false,
2817
+ moved: false
2818
+ };
2819
+ }
2820
+ // Translate every dragged node by the pointer delta, snapping the group's
2821
+ // bounds to the nearest alignment guide on each axis. Alt turns snapping
2822
+ // off for the rest of the gesture.
2823
+ moveDraggedNodes(event) {
2824
+ const interaction = this.interaction;
2825
+ const world = this.clientToWorld(event.clientX, event.clientY);
2826
+ let dx = world.x - interaction.startWorld.x;
2827
+ let dy = world.y - interaction.startWorld.y;
2828
+ if (event.altKey) interaction.snapDisabled = true;
2829
+ let guides = [];
2830
+ if (interaction.guideLines && !interaction.snapDisabled) {
2831
+ const box = interaction.bounds;
2832
+ const threshold = GUIDE_SNAP_DISTANCE / this.documentData.viewport.scale;
2833
+ const left = box.left + dx;
2834
+ const top = box.top + dy;
2835
+ const width = box.right - box.left;
2836
+ const height = box.bottom - box.top;
2837
+ const snapX = snapToGuides(
2838
+ interaction.guideLines.x,
2839
+ [left, left + width / 2, left + width],
2840
+ threshold
2841
+ );
2842
+ const snapY = snapToGuides(
2843
+ interaction.guideLines.y,
2844
+ [top, top + height / 2, top + height],
2845
+ threshold
2846
+ );
2847
+ if (snapX) dx += snapX.offset;
2848
+ if (snapY) dy += snapY.offset;
2849
+ const snapped = { left: box.left + dx, top: box.top + dy, width, height };
2850
+ if (snapX) guides = guides.concat(guideSegments("x", snapX.lines, snapped));
2851
+ if (snapY) guides = guides.concat(guideSegments("y", snapY.lines, snapped));
2852
+ }
2853
+ let changed = false;
2854
+ interaction.nodeIds.forEach((id) => {
2855
+ const node = this.findNode(id);
2856
+ const origin = interaction.origins.get(id);
2857
+ if (!node || !origin) return;
2858
+ const nextX = formatNumber(origin.x + dx);
2859
+ const nextY = formatNumber(origin.y + dy);
2860
+ if (nextX === node.x && nextY === node.y) return;
2861
+ node.x = nextX;
2862
+ node.y = nextY;
2863
+ changed = true;
2864
+ this.renderDraggedNode(node);
2865
+ });
2866
+ if (changed) interaction.moved = true;
2867
+ this.renderGuides(guides);
2868
+ }
2869
+ // Live marquee: select every visible node the box touches, added to the
2870
+ // selection the gesture started from.
2871
+ updateMarquee(event) {
2872
+ const interaction = this.interaction;
2873
+ const world = this.clientToWorld(event.clientX, event.clientY);
2874
+ const box = {
2875
+ left: Math.min(interaction.startWorld.x, world.x),
2876
+ top: Math.min(interaction.startWorld.y, world.y),
2877
+ right: Math.max(interaction.startWorld.x, world.x),
2878
+ bottom: Math.max(interaction.startWorld.y, world.y)
2879
+ };
2880
+ clearChildren(this.guidesLayer);
2881
+ this.guidesLayer.appendChild(
2882
+ svgEl("rect", {
2883
+ class: "vd-flowchart-marquee",
2884
+ x: formatNumber(box.left),
2885
+ y: formatNumber(box.top),
2886
+ width: formatNumber(box.right - box.left),
2887
+ height: formatNumber(box.bottom - box.top)
2888
+ })
2889
+ );
2890
+ const hits = this.getVisibleNodes().filter(
2891
+ (node) => node.x <= box.right && node.x + node.width >= box.left && node.y <= box.bottom && node.y + node.height >= box.top
2892
+ ).map((node) => node.id);
2893
+ const ids = [.../* @__PURE__ */ new Set([...interaction.baseIds, ...hits])];
2894
+ const key = ids.join(",");
2895
+ if (key === interaction.lastKey) return;
2896
+ interaction.lastKey = key;
2897
+ this.selectNodes(ids, { primary: hits[hits.length - 1] });
2898
+ }
2899
+ renderGuides(guides) {
2900
+ clearChildren(this.guidesLayer);
2901
+ guides.forEach((guide) => {
2902
+ this.guidesLayer.appendChild(
2903
+ svgEl("line", {
2904
+ class: "vd-flowchart-guide",
2905
+ x1: guide.x1,
2906
+ y1: guide.y1,
2907
+ x2: guide.x2,
2908
+ y2: guide.y2
2909
+ })
2910
+ );
2911
+ });
2912
+ }
2027
2913
  handlePointerMove(event) {
2028
2914
  if (!this.interaction || this.interaction.pointerId !== event.pointerId) return;
2029
2915
  if (this.interaction.kind === "drag-node") {
2030
- const node = this.findNode(this.interaction.nodeId);
2031
- if (!node) return;
2032
- const world = this.clientToWorld(event.clientX, event.clientY);
2033
- const nextX = formatNumber(world.x - this.interaction.offsetX);
2034
- const nextY = formatNumber(world.y - this.interaction.offsetY);
2035
- if (nextX !== node.x || nextY !== node.y) {
2036
- node.x = nextX;
2037
- node.y = nextY;
2038
- this.interaction.moved = true;
2039
- this.renderDraggedNode(node);
2040
- }
2916
+ this.moveDraggedNodes(event);
2917
+ return;
2918
+ }
2919
+ if (this.interaction.kind === "marquee") {
2920
+ this.updateMarquee(event);
2041
2921
  return;
2042
2922
  }
2043
2923
  if (this.interaction.kind === "resize-node") {
@@ -2072,6 +2952,14 @@ var VdFlowchart = class {
2072
2952
  return;
2073
2953
  }
2074
2954
  if (this.interaction.kind === "connect") {
2955
+ if (!this.interaction.moved) {
2956
+ const travel = Math.hypot(
2957
+ event.clientX - this.interaction.startClientX,
2958
+ event.clientY - this.interaction.startClientY
2959
+ );
2960
+ if (!(travel > PORT_CLICK_SLOP)) return;
2961
+ this.interaction.moved = true;
2962
+ }
2075
2963
  const world = this.clientToWorld(event.clientX, event.clientY);
2076
2964
  const snapTarget = this.findConnectionTarget(
2077
2965
  world,
@@ -2158,10 +3046,19 @@ var VdFlowchart = class {
2158
3046
  }
2159
3047
  return;
2160
3048
  }
3049
+ if (interaction.kind === "marquee") {
3050
+ clearChildren(this.guidesLayer);
3051
+ return;
3052
+ }
2161
3053
  if (interaction.kind === "drag-node") {
3054
+ clearChildren(this.guidesLayer);
2162
3055
  if (interaction.moved) {
2163
3056
  this.render({ inspector: true, json: true });
2164
- this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3057
+ if (interaction.nodeIds.length > 1) {
3058
+ this.emitChange("nodes:move", { nodeIds: [...interaction.nodeIds] });
3059
+ } else {
3060
+ this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3061
+ }
2165
3062
  } else {
2166
3063
  this.render({ inspector: false, json: false });
2167
3064
  }
@@ -2177,6 +3074,14 @@ var VdFlowchart = class {
2177
3074
  return;
2178
3075
  }
2179
3076
  if (interaction.kind === "connect") {
3077
+ if (!interaction.moved && this.activeTool !== "arrow") {
3078
+ this.render({ inspector: false, json: false });
3079
+ this.insertBranchNode(interaction.source.nodeId, {
3080
+ direction: PORT_DIRECTIONS[interaction.source.port],
3081
+ edit: true
3082
+ });
3083
+ return;
3084
+ }
2180
3085
  const world = this.clientToWorld(event.clientX || 0, event.clientY || 0);
2181
3086
  const snapTarget = interaction.target ? { node: this.findNode(interaction.target.nodeId), port: interaction.target.port } : this.findConnectionTarget(
2182
3087
  world,
@@ -2252,7 +3157,7 @@ var VdFlowchart = class {
2252
3157
  if (this.readonly) return false;
2253
3158
  if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") return true;
2254
3159
  if (this.activeTool === "arrow") return true;
2255
- return this.selection?.kind === "node" && this.selection.id === node.id;
3160
+ return this.selection?.kind === "node" && this.selection.id === node.id && !this.hasMultiSelection();
2256
3161
  }
2257
3162
  getMarkerId(markerType, position, strokeWidth) {
2258
3163
  const width = normalizeEdgeStrokeWidth(strokeWidth);
@@ -2275,7 +3180,7 @@ var VdFlowchart = class {
2275
3180
  findConnectionTarget(worldPoint, excludeNodeId, previousSnap = null, referencePoint = null) {
2276
3181
  const snapPadding = CONNECTION_SNAP_PADDING / this.documentData.viewport.scale;
2277
3182
  const hysteresisMargin = CONNECTION_HYSTERESIS / this.documentData.viewport.scale;
2278
- const best = this.documentData.nodes.reduce((candidate, node) => {
3183
+ const best = this.getVisibleNodes().reduce((candidate, node) => {
2279
3184
  if (node.id === excludeNodeId) return candidate;
2280
3185
  const distanceToBounds2 = getDistanceToNodeBounds(node, worldPoint);
2281
3186
  if (distanceToBounds2 > snapPadding) return candidate;
@@ -2384,7 +3289,10 @@ var VdFlowchart = class {
2384
3289
  updateToolbarLabel() {
2385
3290
  this.zoomLabel.textContent = `${Math.round(this.documentData.viewport.scale * 100)}%`;
2386
3291
  }
2387
- startTextEdit(nodeId) {
3292
+ // Options: `initialText` replaces the label with typed text (type-to-edit);
3293
+ // `inserted` marks a node created by a keyboard/handle insertion so naming it
3294
+ // shares the insertion's undo step.
3295
+ startTextEdit(nodeId, options = {}) {
2388
3296
  if (this.readonly) return false;
2389
3297
  const node = this.findNode(nodeId);
2390
3298
  if (!node || !isNodeTextEditable(node)) return false;
@@ -2396,9 +3304,10 @@ var VdFlowchart = class {
2396
3304
  }
2397
3305
  this.stopTextEdit({ commit: true });
2398
3306
  this.select({ kind: "node", id: node.id });
3307
+ const replacing = typeof options.initialText === "string";
2399
3308
  const textarea = createElement("textarea", {
2400
3309
  className: `vd-flowchart-text-editor vd-flowchart-text-editor--${node.type}`,
2401
- value: node.text,
3310
+ value: replacing ? options.initialText : node.text,
2402
3311
  rows: 1
2403
3312
  });
2404
3313
  textarea.setAttribute("data-node-id", node.id);
@@ -2407,19 +3316,7 @@ var VdFlowchart = class {
2407
3316
  textarea.addEventListener("input", () => this.positionTextEditor());
2408
3317
  textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
2409
3318
  textarea.addEventListener("dblclick", (event) => event.stopPropagation());
2410
- textarea.addEventListener("keydown", (event) => {
2411
- if (event.key === "Escape") {
2412
- event.preventDefault();
2413
- this.stopTextEdit({ commit: false });
2414
- this.canvasEl.focus();
2415
- return;
2416
- }
2417
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
2418
- event.preventDefault();
2419
- this.stopTextEdit({ commit: true });
2420
- this.canvasEl.focus();
2421
- }
2422
- });
3319
+ textarea.addEventListener("keydown", (event) => this.handleTextEditorKeyDown(event, node));
2423
3320
  textarea.addEventListener("blur", () => {
2424
3321
  if (this.textEditor?.textarea === textarea) {
2425
3322
  this.stopTextEdit({ commit: true });
@@ -2428,18 +3325,97 @@ var VdFlowchart = class {
2428
3325
  this.textEditor = {
2429
3326
  nodeId: node.id,
2430
3327
  previousText: node.text,
3328
+ inserted: Boolean(options.inserted),
2431
3329
  textarea
2432
3330
  };
2433
3331
  this.canvasEl.appendChild(textarea);
2434
3332
  this.render({ inspector: true, json: false });
2435
- window.requestAnimationFrame(() => {
2436
- if (this.textEditor?.textarea === textarea) {
2437
- textarea.focus();
3333
+ const focusEditor = () => {
3334
+ if (this.textEditor?.textarea !== textarea) return;
3335
+ textarea.focus();
3336
+ if (replacing) {
3337
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3338
+ } else {
2438
3339
  textarea.select();
2439
3340
  }
3341
+ };
3342
+ focusEditor();
3343
+ window.requestAnimationFrame(() => {
3344
+ if (document.activeElement !== textarea) focusEditor();
3345
+ });
3346
+ return true;
3347
+ }
3348
+ // Inline label editor for a connection, placed on the label anchor
3349
+ // (curve midpoint). Labels render on one line, so Enter always saves.
3350
+ startEdgeLabelEdit(edgeId, options = {}) {
3351
+ if (this.readonly) return false;
3352
+ const edge = this.findEdge(sanitizeId(edgeId));
3353
+ if (!edge) return false;
3354
+ this.stopTextEdit({ commit: true });
3355
+ this.select({ kind: "edge", id: edge.id });
3356
+ const replacing = typeof options.initialText === "string";
3357
+ const textarea = createElement("textarea", {
3358
+ className: "vd-flowchart-text-editor vd-flowchart-text-editor--edge",
3359
+ value: replacing ? options.initialText : edge.label,
3360
+ rows: 1
3361
+ });
3362
+ textarea.setAttribute("data-edge-id", edge.id);
3363
+ textarea.setAttribute("aria-label", "Edit connection label");
3364
+ textarea.spellcheck = false;
3365
+ textarea.addEventListener("input", () => this.positionTextEditor());
3366
+ textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
3367
+ textarea.addEventListener("dblclick", (event) => event.stopPropagation());
3368
+ textarea.addEventListener("keydown", (event) => {
3369
+ if (event.isComposing) return;
3370
+ if (event.key === "Escape" || event.key === "Enter") {
3371
+ event.preventDefault();
3372
+ this.stopTextEdit({ commit: event.key === "Enter" });
3373
+ this.canvasEl.focus();
3374
+ }
3375
+ });
3376
+ textarea.addEventListener("blur", () => {
3377
+ if (this.textEditor?.textarea === textarea) this.stopTextEdit({ commit: true });
3378
+ });
3379
+ this.textEditor = { nodeId: null, edgeId: edge.id, previousText: edge.label, textarea };
3380
+ this.canvasEl.appendChild(textarea);
3381
+ this.render({ inspector: true, json: false });
3382
+ const focusEditor = () => {
3383
+ if (this.textEditor?.textarea !== textarea) return;
3384
+ textarea.focus();
3385
+ if (replacing) textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3386
+ else textarea.select();
3387
+ };
3388
+ focusEditor();
3389
+ window.requestAnimationFrame(() => {
3390
+ if (document.activeElement !== textarea) focusEditor();
2440
3391
  });
2441
3392
  return true;
2442
3393
  }
3394
+ handleTextEditorKeyDown(event, node) {
3395
+ if (event.isComposing) return;
3396
+ const mindmap = this.keyboardShortcuts === "mindmap";
3397
+ if (event.key === "Escape") {
3398
+ event.preventDefault();
3399
+ this.stopTextEdit({ commit: false });
3400
+ this.canvasEl.focus();
3401
+ return;
3402
+ }
3403
+ if (event.key === "Enter") {
3404
+ const modKey = event.ctrlKey || event.metaKey;
3405
+ const commits = modKey || mindmap && node.type !== "textbox" && !event.shiftKey;
3406
+ if (!commits) return;
3407
+ event.preventDefault();
3408
+ this.stopTextEdit({ commit: true });
3409
+ this.canvasEl.focus();
3410
+ return;
3411
+ }
3412
+ if (event.key === "Tab" && mindmap && !event.shiftKey && !event.altKey && !event.ctrlKey) {
3413
+ event.preventDefault();
3414
+ this.stopTextEdit({ commit: true });
3415
+ this.canvasEl.focus();
3416
+ this.insertBranchNode(node.id, { edit: true });
3417
+ }
3418
+ }
2443
3419
  stopTextEdit(options = {}) {
2444
3420
  if (!this.textEditor) return false;
2445
3421
  const commit = options.commit !== false;
@@ -2447,12 +3423,26 @@ var VdFlowchart = class {
2447
3423
  const nextText = editor.textarea.value;
2448
3424
  this.textEditor = null;
2449
3425
  editor.textarea.remove();
3426
+ if (editor.edgeId) {
3427
+ const label = nextText.replace(/\s*\n\s*/g, " ").trim();
3428
+ if (commit && !this.readonly && label !== editor.previousText) {
3429
+ this.updateEdge(editor.edgeId, { label }, { inspector: true, reason: "edge:update" });
3430
+ } else {
3431
+ this.render({ scene: true, inspector: false, json: false });
3432
+ }
3433
+ return true;
3434
+ }
2450
3435
  if (commit && !this.readonly && nextText !== editor.previousText) {
2451
- this.updateNode(
2452
- editor.nodeId,
2453
- { text: nextText },
2454
- { inspector: true, reason: "node:update" }
2455
- );
3436
+ this.coalesceInsertedText = editor.inserted;
3437
+ try {
3438
+ this.updateNode(
3439
+ editor.nodeId,
3440
+ { text: nextText },
3441
+ { inspector: true, reason: "node:update" }
3442
+ );
3443
+ } finally {
3444
+ this.coalesceInsertedText = false;
3445
+ }
2456
3446
  } else {
2457
3447
  this.render({ scene: true, inspector: false, json: false });
2458
3448
  }
@@ -2460,6 +3450,10 @@ var VdFlowchart = class {
2460
3450
  }
2461
3451
  positionTextEditor() {
2462
3452
  if (!this.textEditor) return;
3453
+ if (this.textEditor.edgeId) {
3454
+ this.positionEdgeLabelEditor();
3455
+ return;
3456
+ }
2463
3457
  const node = this.findNode(this.textEditor.nodeId);
2464
3458
  if (!node) {
2465
3459
  this.stopTextEdit({ commit: false });
@@ -2483,6 +3477,28 @@ var VdFlowchart = class {
2483
3477
  textarea.style.lineHeight = `${formatNumber(lineHeight)}px`;
2484
3478
  textarea.style.padding = `${formatNumber(verticalPadding)}px ${formatNumber(horizontalPadding)}px`;
2485
3479
  }
3480
+ positionEdgeLabelEditor() {
3481
+ const edge = this.findEdge(this.textEditor.edgeId);
3482
+ const fromNode = edge && this.findNode(edge.from.nodeId);
3483
+ const toNode = edge && this.findNode(edge.to.nodeId);
3484
+ if (!edge || !fromNode || !toNode) {
3485
+ this.stopTextEdit({ commit: false });
3486
+ return;
3487
+ }
3488
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
3489
+ const viewport = this.documentData.viewport;
3490
+ const textarea = this.textEditor.textarea;
3491
+ const fontSize = Math.max(11, 13 * viewport.scale);
3492
+ const width = clamp(textarea.value.length * fontSize * 0.62 + 28, 96, 280);
3493
+ const height = fontSize * 1.4 + 12;
3494
+ textarea.style.left = `${formatNumber(viewport.x + labelX * viewport.scale - width / 2)}px`;
3495
+ textarea.style.top = `${formatNumber(viewport.y + (labelY - 8) * viewport.scale - height / 2)}px`;
3496
+ textarea.style.width = `${formatNumber(width)}px`;
3497
+ textarea.style.height = `${formatNumber(height)}px`;
3498
+ textarea.style.fontSize = `${formatNumber(fontSize)}px`;
3499
+ textarea.style.lineHeight = `${formatNumber(fontSize * 1.4)}px`;
3500
+ textarea.style.padding = "6px 10px";
3501
+ }
2486
3502
  render(options = {}) {
2487
3503
  if (this.destroyed) return;
2488
3504
  const shouldRenderScene = options.scene !== false;
@@ -2495,8 +3511,10 @@ var VdFlowchart = class {
2495
3511
  this.updateToolbarLabel();
2496
3512
  this.updatePaletteState();
2497
3513
  this.positionTextEditor();
3514
+ this.scheduleMinimap();
2498
3515
  }
2499
3516
  renderScene() {
3517
+ this.syncSelectedNodeIds();
2500
3518
  const viewport = this.documentData.viewport;
2501
3519
  this.world.setAttribute(
2502
3520
  "transform",
@@ -2506,7 +3524,12 @@ var VdFlowchart = class {
2506
3524
  clearChildren(this.previewLayer);
2507
3525
  clearChildren(this.nodesLayer);
2508
3526
  clearChildren(this.overlayLayer);
2509
- const nodeMap = new Map(this.documentData.nodes.map((node) => [node.id, node]));
3527
+ const hidden = this.getHiddenNodeIds();
3528
+ this.sceneHidden = hidden;
3529
+ this.sceneOutgoing = this.getOutgoingMap();
3530
+ const nodeMap = new Map(
3531
+ this.documentData.nodes.filter((node) => !hidden.has(node.id)).map((node) => [node.id, node])
3532
+ );
2510
3533
  this.sceneNodeMap = nodeMap;
2511
3534
  this.nodeElements = /* @__PURE__ */ new Map();
2512
3535
  this.edgeElements = /* @__PURE__ */ new Map();
@@ -2526,6 +3549,7 @@ var VdFlowchart = class {
2526
3549
  this.previewLayer.appendChild(this.renderPreviewEdge(this.interaction));
2527
3550
  }
2528
3551
  this.documentData.nodes.forEach((node) => {
3552
+ if (hidden.has(node.id)) return;
2529
3553
  const element = this.renderNode(node);
2530
3554
  this.nodesLayer.appendChild(element);
2531
3555
  this.nodeElements.set(node.id, element);
@@ -2613,7 +3637,7 @@ var VdFlowchart = class {
2613
3637
  });
2614
3638
  group.appendChild(visiblePath);
2615
3639
  group.appendChild(hitPath);
2616
- if (edge.label) {
3640
+ if (edge.label && this.textEditor?.edgeId !== edge.id) {
2617
3641
  const label = svgEl("text", {
2618
3642
  class: "vd-flowchart-edge-label",
2619
3643
  x: edgePath.labelX,
@@ -2674,8 +3698,9 @@ var VdFlowchart = class {
2674
3698
  });
2675
3699
  }
2676
3700
  renderNode(node) {
2677
- const selected = this.selection?.kind === "node" && this.selection.id === node.id;
2678
- const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeId === node.id;
3701
+ const selected = this.isNodeSelected(node.id);
3702
+ const soloSelected = selected && !this.hasMultiSelection();
3703
+ const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeIds.includes(node.id);
2679
3704
  const resizing = this.interaction?.kind === "resize-node" && this.interaction.nodeId === node.id;
2680
3705
  const editing = this.textEditor?.nodeId === node.id;
2681
3706
  const group = svgEl("g", {
@@ -2696,12 +3721,29 @@ var VdFlowchart = class {
2696
3721
  group.appendChild(hitbox);
2697
3722
  group.appendChild(this.renderNodeShape(node));
2698
3723
  group.appendChild(this.renderNodeText(node));
2699
- if (selected && !this.readonly && isNodeResizable(node)) {
3724
+ const scale = this.documentData.viewport.scale || 1;
3725
+ if (soloSelected && !this.readonly && SELECTION_BOX_TYPES.has(node.type)) {
3726
+ const outset = SELECTION_OUTSET / scale;
3727
+ group.appendChild(
3728
+ svgEl("rect", {
3729
+ class: "vd-flowchart-selection-box",
3730
+ x: -outset,
3731
+ y: -outset,
3732
+ width: node.width + outset * 2,
3733
+ height: node.height + outset * 2
3734
+ })
3735
+ );
3736
+ }
3737
+ if (soloSelected && !this.readonly && isNodeResizable(node)) {
2700
3738
  group.appendChild(this.renderResizeControls(node));
2701
3739
  }
2702
- const scale = this.documentData.viewport.scale || 1;
3740
+ const collapseToggle = this.renderCollapseToggle(node, soloSelected);
3741
+ if (this.readonly) {
3742
+ if (collapseToggle) group.appendChild(collapseToggle);
3743
+ return group;
3744
+ }
2703
3745
  FLOWCHART_PORTS.forEach((port) => {
2704
- const position = getPortPosition({ ...node, x: 0, y: 0 }, port);
3746
+ const position = getPortHandlePosition({ ...node, x: 0, y: 0 }, port, scale);
2705
3747
  const portGroup = svgEl("g", {
2706
3748
  class: `vd-flowchart-port-group${portsVisible ? " is-visible" : ""}`,
2707
3749
  "data-node-id": node.id,
@@ -2723,15 +3765,68 @@ var VdFlowchart = class {
2723
3765
  r: CONNECTION_PORT_RADIUS / scale
2724
3766
  })
2725
3767
  );
3768
+ const arm = (CONNECTION_PORT_RADIUS - 2.5) / scale;
3769
+ portGroup.appendChild(
3770
+ svgEl("path", {
3771
+ class: "vd-flowchart-port-plus",
3772
+ 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)}`
3773
+ })
3774
+ );
2726
3775
  group.appendChild(portGroup);
2727
3776
  });
3777
+ if (collapseToggle) group.appendChild(collapseToggle);
3778
+ return group;
3779
+ }
3780
+ // "+N" on a collapsed node, or "−" on a single selected node with children,
3781
+ // placed beyond the connection handle on the side its branch leaves from.
3782
+ renderCollapseToggle(node, soloSelected) {
3783
+ const outgoing = this.sceneOutgoing || this.getOutgoingMap();
3784
+ const childIds = (outgoing.get(node.id) || []).filter((id) => id !== node.id);
3785
+ if (!childIds.length) return null;
3786
+ const count = node.collapsed ? this.countCollapsedDescendants(
3787
+ node.id,
3788
+ this.sceneHidden || this.getHiddenNodeIds(),
3789
+ outgoing
3790
+ ) : 0;
3791
+ if (!node.collapsed && !(soloSelected && !this.readonly)) return null;
3792
+ const sides = /* @__PURE__ */ new Map();
3793
+ this.documentData.edges.forEach((edge) => {
3794
+ if (edge.from.nodeId !== node.id || edge.to.nodeId === node.id) return;
3795
+ sides.set(edge.from.port, (sides.get(edge.from.port) || 0) + 1);
3796
+ });
3797
+ const port = [...sides].sort((a, b) => b[1] - a[1])[0]?.[0] || DIRECTION_PORTS[this.getGrowthDirection(node)];
3798
+ const scale = this.documentData.viewport.scale || 1;
3799
+ const anchor = getPortPosition({ ...node, x: 0, y: 0 }, port);
3800
+ const normal = getPortNormal(port);
3801
+ const offset = COLLAPSE_TOGGLE_OFFSET / scale;
3802
+ const cx = formatNumber(anchor.x + normal.x * offset);
3803
+ const cy = formatNumber(anchor.y + normal.y * offset);
3804
+ const label = node.collapsed ? `+${count}` : "\u2212";
3805
+ const radius = (node.collapsed && label.length > 2 ? 12 : 9) / scale;
3806
+ const group = svgEl("g", {
3807
+ class: `vd-flowchart-collapse-toggle${node.collapsed ? " is-collapsed" : ""}`,
3808
+ "data-collapse-toggle": node.id
3809
+ });
3810
+ const title = svgEl("title");
3811
+ title.textContent = node.collapsed ? `Expand ${count} hidden nodes` : "Collapse branch";
3812
+ group.appendChild(title);
3813
+ group.appendChild(svgEl("circle", { cx, cy, r: formatNumber(radius) }));
3814
+ const text = svgEl("text", {
3815
+ x: cx,
3816
+ y: cy,
3817
+ "font-size": formatNumber(10 / scale),
3818
+ "text-anchor": "middle",
3819
+ "dominant-baseline": "central"
3820
+ });
3821
+ text.textContent = label;
3822
+ group.appendChild(text);
2728
3823
  return group;
2729
3824
  }
2730
3825
  renderResizeControls(node) {
2731
3826
  const scale = this.documentData.viewport.scale || 1;
2732
3827
  const zone = 14 / scale;
2733
3828
  const cornerZone = 22 / scale;
2734
- const handleSize = 9 / scale;
3829
+ const handleSize = RESIZE_HANDLE_SIZE / scale;
2735
3830
  const gap = RESIZE_PORT_GAP / scale;
2736
3831
  const group = svgEl("g", { class: "vd-flowchart-resize-controls" });
2737
3832
  const zones = {
@@ -2761,12 +3856,6 @@ var VdFlowchart = class {
2761
3856
  };
2762
3857
  RESIZE_HANDLES.forEach((handle) => {
2763
3858
  const zoneRect = zones[handle];
2764
- const position = getResizeHandlePosition(node, handle);
2765
- const dotOffset = 8 / scale;
2766
- if (handle.includes("e")) position.x += dotOffset;
2767
- if (handle.includes("w")) position.x -= dotOffset;
2768
- if (handle.includes("n")) position.y -= dotOffset;
2769
- if (handle.includes("s")) position.y += dotOffset;
2770
3859
  const cursor = getResizeCursor(handle);
2771
3860
  const zoneSegments = [];
2772
3861
  if (handle === "e" || handle === "w") {
@@ -2801,14 +3890,19 @@ var VdFlowchart = class {
2801
3890
  });
2802
3891
  group.appendChild(hit);
2803
3892
  });
3893
+ if (handle.length === 1) return;
3894
+ const position = getResizeHandlePosition(node, handle);
3895
+ const outset = SELECTION_OUTSET / scale;
3896
+ position.x += handle.includes("e") ? outset : -outset;
3897
+ position.y += handle.includes("s") ? outset : -outset;
2804
3898
  const dot = svgEl("rect", {
2805
3899
  class: "vd-flowchart-resize-handle",
2806
3900
  x: position.x - handleSize / 2,
2807
3901
  y: position.y - handleSize / 2,
2808
3902
  width: handleSize,
2809
3903
  height: handleSize,
2810
- rx: handleSize / 3,
2811
- ry: handleSize / 3,
3904
+ rx: handleSize / 5,
3905
+ ry: handleSize / 5,
2812
3906
  "data-node-id": node.id,
2813
3907
  "data-resize-handle": handle,
2814
3908
  style: `cursor: ${cursor}`
@@ -2937,6 +4031,16 @@ var VdFlowchart = class {
2937
4031
  );
2938
4032
  return;
2939
4033
  }
4034
+ if (this.hasMultiSelection()) {
4035
+ this.selectionMeta.textContent = `${this.selectedNodeIds.size} nodes selected`;
4036
+ this.selectionFields.appendChild(
4037
+ createElement("p", {
4038
+ className: "vd-flowchart-selection-empty",
4039
+ text: "Drag, nudge with Alt+Arrow, copy, duplicate, or delete them together."
4040
+ })
4041
+ );
4042
+ return;
4043
+ }
2940
4044
  if (this.selection.kind === "node") {
2941
4045
  const node = this.findNode(this.selection.id);
2942
4046
  if (!node) {
@@ -3051,16 +4155,69 @@ var VdFlowchart = class {
3051
4155
  }
3052
4156
  select(selection) {
3053
4157
  const next = selection && selection.id && selection.kind ? { kind: selection.kind, id: selection.id } : null;
3054
- const previousKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3055
- const nextKey = next ? `${next.kind}:${next.id}` : "";
3056
- this.selection = next;
3057
- if (previousKey !== nextKey) {
4158
+ this.applySelection(next, next?.kind === "node" ? [next.id] : []);
4159
+ }
4160
+ // Single funnel for selection changes: `selection` is the primary item and
4161
+ // `selectedNodeIds` every selected node (the primary one included).
4162
+ applySelection(primary, nodeIds) {
4163
+ const previousKey = this.getSelectionKey();
4164
+ this.selection = primary;
4165
+ this.selectedNodeIds = new Set(nodeIds);
4166
+ if (primary?.kind === "node") this.selectedNodeIds.add(primary.id);
4167
+ if (previousKey !== this.getSelectionKey()) {
4168
+ this.edgeCycleAnchor = null;
3058
4169
  this.render({ scene: true, inspector: true, json: false });
3059
- this.emit("select", { selection: this.getSelectionSnapshot() });
4170
+ this.emit("select", {
4171
+ selection: this.getSelectionSnapshot(),
4172
+ nodeIds: this.getSelectedNodeIds()
4173
+ });
3060
4174
  return;
3061
4175
  }
3062
4176
  this.render({ scene: true, inspector: false, json: false });
3063
4177
  }
4178
+ getSelectionKey() {
4179
+ const primary = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
4180
+ return `${primary}|${[...this.selectedNodeIds || []].sort().join(",")}`;
4181
+ }
4182
+ // Keep the set valid after mutations that bypass applySelection (undo,
4183
+ // load, removal): drop missing nodes and keep the primary node in the set.
4184
+ syncSelectedNodeIds() {
4185
+ if (this.selection?.kind !== "node") {
4186
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
4187
+ return;
4188
+ }
4189
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4190
+ const hidden = this.getHiddenNodeIds();
4191
+ this.selectedNodeIds = new Set(
4192
+ [...this.selectedNodeIds || []].filter((id) => existing.has(id) && !hidden.has(id))
4193
+ );
4194
+ this.selectedNodeIds.add(this.selection.id);
4195
+ }
4196
+ getSelectedNodeIds() {
4197
+ return [...this.selectedNodeIds || []];
4198
+ }
4199
+ isNodeSelected(nodeId) {
4200
+ return Boolean(this.selectedNodeIds?.has(nodeId));
4201
+ }
4202
+ hasMultiSelection() {
4203
+ return (this.selectedNodeIds?.size || 0) > 1;
4204
+ }
4205
+ /** Select several nodes; `primary` (default: the last id) drives single-node UI. */
4206
+ selectNodes(nodeIds, options = {}) {
4207
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4208
+ const ids = [...new Set((nodeIds || []).map(sanitizeId))].filter((id) => existing.has(id));
4209
+ const primaryId = ids.includes(options.primary) ? options.primary : ids[ids.length - 1];
4210
+ this.applySelection(primaryId ? { kind: "node", id: primaryId } : null, ids);
4211
+ return this;
4212
+ }
4213
+ toggleNodeSelection(nodeId) {
4214
+ const ids = this.selection?.kind === "node" ? this.getSelectedNodeIds() : [];
4215
+ if (ids.includes(nodeId)) {
4216
+ const rest = ids.filter((id) => id !== nodeId);
4217
+ return this.selectNodes(rest, { primary: rest[rest.length - 1] });
4218
+ }
4219
+ return this.selectNodes([...ids, nodeId], { primary: nodeId });
4220
+ }
3064
4221
  selectNode(nodeId) {
3065
4222
  this.select({ kind: "node", id: sanitizeId(nodeId) });
3066
4223
  return this;
@@ -3081,6 +4238,69 @@ var VdFlowchart = class {
3081
4238
  findNode(nodeId) {
3082
4239
  return this.documentData.nodes.find((node) => node.id === nodeId) || null;
3083
4240
  }
4241
+ getHiddenNodeIds() {
4242
+ return computeHiddenNodeIds(this.documentData.nodes, this.documentData.edges);
4243
+ }
4244
+ getVisibleNodes() {
4245
+ const hidden = this.getHiddenNodeIds();
4246
+ return hidden.size ? this.documentData.nodes.filter((node) => !hidden.has(node.id)) : this.documentData.nodes;
4247
+ }
4248
+ isNodeHidden(nodeId) {
4249
+ return this.getHiddenNodeIds().has(nodeId);
4250
+ }
4251
+ getOutgoingMap() {
4252
+ const outgoing = /* @__PURE__ */ new Map();
4253
+ this.documentData.edges.forEach((edge) => {
4254
+ if (!outgoing.has(edge.from.nodeId)) outgoing.set(edge.from.nodeId, []);
4255
+ outgoing.get(edge.from.nodeId).push(edge.to.nodeId);
4256
+ });
4257
+ return outgoing;
4258
+ }
4259
+ // Hidden nodes under one collapsed node (its "+N" count).
4260
+ countCollapsedDescendants(nodeId, hidden = this.getHiddenNodeIds(), outgoing = this.getOutgoingMap()) {
4261
+ const seen = /* @__PURE__ */ new Set();
4262
+ const queue = [nodeId];
4263
+ for (let index = 0; index < queue.length; index += 1) {
4264
+ (outgoing.get(queue[index]) || []).forEach((childId) => {
4265
+ if (seen.has(childId) || !hidden.has(childId)) return;
4266
+ seen.add(childId);
4267
+ queue.push(childId);
4268
+ });
4269
+ }
4270
+ return seen.size;
4271
+ }
4272
+ setCollapsed(nodeId, collapsed) {
4273
+ if (this.readonly) return false;
4274
+ const node = this.findNode(sanitizeId(nodeId));
4275
+ if (!node) return false;
4276
+ const next = Boolean(collapsed);
4277
+ if (Boolean(node.collapsed) === next) return false;
4278
+ if (next && !this.getChildNodes(node.id).length) return false;
4279
+ if (next) node.collapsed = true;
4280
+ else delete node.collapsed;
4281
+ if (this.selection?.kind === "node") {
4282
+ const hidden = this.getHiddenNodeIds();
4283
+ const visibleIds = this.getSelectedNodeIds().filter((id) => !hidden.has(id));
4284
+ if (hidden.has(this.selection.id)) {
4285
+ this.select({ kind: "node", id: node.id });
4286
+ } else if (visibleIds.length !== this.selectedNodeIds.size) {
4287
+ this.selectNodes(visibleIds, { primary: this.selection.id });
4288
+ }
4289
+ } else if (this.selection?.kind === "edge") {
4290
+ const edge = this.findEdge(this.selection.id);
4291
+ const hidden = this.getHiddenNodeIds();
4292
+ if (edge && (hidden.has(edge.from.nodeId) || hidden.has(edge.to.nodeId))) {
4293
+ this.select({ kind: "node", id: node.id });
4294
+ }
4295
+ }
4296
+ this.render({ inspector: true, json: true });
4297
+ this.emitChange("node:collapse", { node: deepClone(node) });
4298
+ return true;
4299
+ }
4300
+ toggleCollapsed(nodeId) {
4301
+ const node = this.findNode(sanitizeId(nodeId));
4302
+ return node ? this.setCollapsed(node.id, !node.collapsed) : false;
4303
+ }
3084
4304
  findEdge(edgeId) {
3085
4305
  return this.documentData.edges.find((edge) => edge.id === edgeId) || null;
3086
4306
  }
@@ -3091,7 +4311,8 @@ var VdFlowchart = class {
3091
4311
  }
3092
4312
  emitChange(reason, extra = {}) {
3093
4313
  if (this.historyEnabled && !this.isApplyingHistory) {
3094
- this.recordHistory(reason);
4314
+ if (this.historyBatchDepth > 0) this.historyBatchDirty = true;
4315
+ else this.recordHistory(reason);
3095
4316
  }
3096
4317
  this.syncJsonTextarea();
3097
4318
  this.emit("change", {
@@ -3135,7 +4356,9 @@ var VdFlowchart = class {
3135
4356
  const snapshot = this.toJSON();
3136
4357
  const targetKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3137
4358
  const top = this.history[this.historyIndex];
3138
- const canCoalesce = Boolean(top) && this.historyIndex === this.history.length - 1 && COALESCING_REASONS.has(reason) && top.reason === reason && top.targetKey === targetKey;
4359
+ const atTip = Boolean(top) && this.historyIndex === this.history.length - 1;
4360
+ const namesInsertedNode = this.coalesceInsertedText && top?.reason === "node:insert" && reason === "node:update";
4361
+ const canCoalesce = atTip && top.targetKey === targetKey && (namesInsertedNode || COALESCING_REASONS.has(reason) && top.reason === reason);
3139
4362
  if (canCoalesce) {
3140
4363
  top.snapshot = snapshot;
3141
4364
  return;
@@ -3251,19 +4474,180 @@ var VdFlowchart = class {
3251
4474
  const parent = this.findNode(sanitizeId(parentId));
3252
4475
  if (!parent) return null;
3253
4476
  const { direction = "right", distance, angle, edge: edgeOptions, ...nodeOptions } = options;
3254
- const node = this.addNode({
3255
- ...nodeOptions,
3256
- relativeTo: { node: parent.id, direction, distance, angle }
4477
+ return this.batchHistory("node:add-child", () => {
4478
+ const node = this.addNode({
4479
+ ...nodeOptions,
4480
+ relativeTo: { node: parent.id, direction, distance, angle }
4481
+ });
4482
+ const edge = this.addEdge({
4483
+ from: parent.id,
4484
+ to: node.id,
4485
+ autoPort: true,
4486
+ endMarker: "arrow",
4487
+ ...isPlainObject(edgeOptions) ? edgeOptions : {}
4488
+ });
4489
+ this.select({ kind: "node", id: node.id });
4490
+ return { node, edge };
4491
+ });
4492
+ }
4493
+ // Run `fn` with history recording suspended, then record a single entry for
4494
+ // everything it changed (change events still fire for each mutation).
4495
+ batchHistory(reason, fn) {
4496
+ this.historyBatchDepth += 1;
4497
+ let result;
4498
+ try {
4499
+ result = fn();
4500
+ } finally {
4501
+ this.historyBatchDepth -= 1;
4502
+ }
4503
+ if (this.historyBatchDepth === 0 && this.historyBatchDirty) {
4504
+ this.historyBatchDirty = false;
4505
+ if (this.historyEnabled && !this.isApplyingHistory) this.recordHistory(reason);
4506
+ }
4507
+ return result;
4508
+ }
4509
+ // Mind-map structure: a node's parent is the source of its first incoming
4510
+ // edge; its children are the targets of its outgoing edges. The side a
4511
+ // branch is on is the port its edge leaves the parent from, which is what
4512
+ // the user sees (centre geometry is ambiguous for diagonal nodes).
4513
+ getParentEdge(nodeId) {
4514
+ return this.documentData.edges.find(
4515
+ (edge) => edge.to.nodeId === nodeId && edge.from.nodeId !== nodeId
4516
+ ) || null;
4517
+ }
4518
+ getParentNode(nodeId) {
4519
+ const edge = this.getParentEdge(nodeId);
4520
+ return edge ? this.findNode(edge.from.nodeId) : null;
4521
+ }
4522
+ getChildNodes(nodeId, direction = null) {
4523
+ const port = direction ? DIRECTION_PORTS[direction] : null;
4524
+ const ids = /* @__PURE__ */ new Set();
4525
+ this.documentData.edges.forEach((edge) => {
4526
+ if (edge.from.nodeId !== nodeId || edge.to.nodeId === nodeId) return;
4527
+ if (!port || edge.from.port === port) ids.add(edge.to.nodeId);
3257
4528
  });
3258
- const edge = this.addEdge({
3259
- from: parent.id,
3260
- to: node.id,
3261
- autoPort: true,
3262
- endMarker: "arrow",
3263
- ...isPlainObject(edgeOptions) ? edgeOptions : {}
4529
+ return this.documentData.nodes.filter((node) => ids.has(node.id));
4530
+ }
4531
+ // Direction a branch grows from `node`: the side its parent edge leaves
4532
+ // from, or the emptier horizontal side for a root.
4533
+ getGrowthDirection(node) {
4534
+ const parentEdge = this.getParentEdge(node.id);
4535
+ if (parentEdge) return PORT_DIRECTIONS[parentEdge.from.port] || "right";
4536
+ const right = this.getChildNodes(node.id, "right").length;
4537
+ const left = this.getChildNodes(node.id, "left").length;
4538
+ return left < right ? "left" : "right";
4539
+ }
4540
+ // Shape for a new branch node: copy a sibling when there is one, otherwise
4541
+ // the parent's box shape, falling back to a rounded step.
4542
+ getBranchTemplate(parent, sibling) {
4543
+ const source = sibling || (["rounded-rect", "rect", "textbox"].includes(parent.type) ? parent : null) || null;
4544
+ if (source) return { type: source.type, width: source.width, height: source.height };
4545
+ const spec = DEFAULT_NODE_SPECS["rounded-rect"];
4546
+ return { type: "rounded-rect", width: spec.width, height: spec.height };
4547
+ }
4548
+ /**
4549
+ * Add a node connected from `parentId` on the `direction` side (default: the
4550
+ * branch's growth direction), stacked after `anchorId` or the last sibling on
4551
+ * that side. The node and its edge are one undo step; the new edge copies a
4552
+ * sibling's (or the parent's incoming) edge style. With `edit: true` the
4553
+ * label editor opens with the text selected.
4554
+ */
4555
+ insertBranchNode(parentId, options = {}) {
4556
+ if (this.readonly) return null;
4557
+ const parent = this.findNode(sanitizeId(parentId));
4558
+ if (!parent) return null;
4559
+ const direction = DIRECTION_PORTS[options.direction] ? options.direction : this.getGrowthDirection(parent);
4560
+ const siblings = this.getChildNodes(parent.id, direction);
4561
+ const anchor = options.anchorId ? this.findNode(sanitizeId(options.anchorId)) : null;
4562
+ const lastSibling = siblings[siblings.length - 1] || null;
4563
+ const template = this.getBranchTemplate(parent, anchor || lastSibling);
4564
+ const position = placeBranchNode({
4565
+ nodes: this.documentData.nodes,
4566
+ parent,
4567
+ siblings,
4568
+ direction,
4569
+ size: template,
4570
+ anchor,
4571
+ before: Boolean(options.before)
3264
4572
  });
3265
- this.select({ kind: "node", id: node.id });
3266
- return { node, edge };
4573
+ const styleSource = this.documentData.edges.find(
4574
+ (edge) => edge.from.nodeId === parent.id && edge.to.nodeId === (anchor || lastSibling)?.id
4575
+ ) || this.documentData.edges.find((edge) => edge.to.nodeId === parent.id);
4576
+ const edgeStyle = styleSource ? {
4577
+ route: styleSource.route,
4578
+ strokeWidth: styleSource.strokeWidth,
4579
+ startMarker: styleSource.startMarker,
4580
+ endMarker: styleSource.endMarker
4581
+ } : { endMarker: "arrow" };
4582
+ const result = this.batchHistory("node:insert", () => {
4583
+ if (parent.collapsed) delete parent.collapsed;
4584
+ const node = this.addNode({ ...template, x: position.x, y: position.y });
4585
+ const edge = this.addEdge({
4586
+ from: { nodeId: parent.id, port: DIRECTION_PORTS[direction] },
4587
+ to: { nodeId: node.id, port: DIRECTION_PORTS[oppositeDirection(direction)] },
4588
+ ...edgeStyle
4589
+ });
4590
+ if (edge && anchor) this.placeEdgeBySibling(edge.id, parent.id, anchor.id, options.before);
4591
+ if (this.autoLayout && this.layoutMode !== "grid") this.layout(this.layoutMode);
4592
+ this.select({ kind: "node", id: node.id });
4593
+ return { node, edge };
4594
+ });
4595
+ this.revealNode(result.node.id);
4596
+ if (options.edit) this.startTextEdit(result.node.id, { inserted: true });
4597
+ return result;
4598
+ }
4599
+ // Tree and radial layouts order children by connection order, so a sibling's
4600
+ // connection goes right after (or before) its anchor's instead of last.
4601
+ placeEdgeBySibling(edgeId, parentId, anchorId, before = false) {
4602
+ const edges = this.documentData.edges;
4603
+ const fromIndex = edges.findIndex((edge) => edge.id === edgeId);
4604
+ if (fromIndex === -1) return;
4605
+ const [moved] = edges.splice(fromIndex, 1);
4606
+ const anchorIndex = edges.findIndex(
4607
+ (edge) => edge.from.nodeId === parentId && edge.to.nodeId === anchorId
4608
+ );
4609
+ if (anchorIndex === -1) edges.push(moved);
4610
+ else edges.splice(before ? anchorIndex : anchorIndex + 1, 0, moved);
4611
+ }
4612
+ // Insert a sibling after (or before) `nodeId`; a node without a parent gets a
4613
+ // child instead, which is what Enter on a mind-map root means.
4614
+ insertSiblingNode(nodeId, options = {}) {
4615
+ if (this.readonly) return null;
4616
+ const node = this.findNode(sanitizeId(nodeId));
4617
+ if (!node) return null;
4618
+ const parentEdge = this.getParentEdge(node.id);
4619
+ const parent = parentEdge ? this.findNode(parentEdge.from.nodeId) : null;
4620
+ if (!parent) return this.insertBranchNode(node.id, { edit: options.edit });
4621
+ return this.insertBranchNode(parent.id, {
4622
+ direction: PORT_DIRECTIONS[parentEdge.from.port],
4623
+ anchorId: node.id,
4624
+ before: Boolean(options.before),
4625
+ edit: options.edit
4626
+ });
4627
+ }
4628
+ // Pan just enough to bring a node fully into view (with a small margin).
4629
+ revealNode(nodeId) {
4630
+ const node = this.findNode(nodeId);
4631
+ const width = this.canvasEl.clientWidth;
4632
+ const height = this.canvasEl.clientHeight;
4633
+ if (!node || !width || !height) return;
4634
+ const viewport = this.documentData.viewport;
4635
+ const margin = 32;
4636
+ const left = viewport.x + node.x * viewport.scale;
4637
+ const top = viewport.y + node.y * viewport.scale;
4638
+ const right = left + node.width * viewport.scale;
4639
+ const bottom = top + node.height * viewport.scale;
4640
+ let dx = 0;
4641
+ let dy = 0;
4642
+ if (right > width - margin) dx = width - margin - right;
4643
+ if (left + dx < margin) dx = margin - left;
4644
+ if (bottom > height - margin) dy = height - margin - bottom;
4645
+ if (top + dy < margin) dy = margin - top;
4646
+ if (!dx && !dy) return;
4647
+ viewport.x = formatNumber(viewport.x + dx);
4648
+ viewport.y = formatNumber(viewport.y + dy);
4649
+ this.render({ inspector: false, json: true });
4650
+ this.emitViewportChange("viewport:pan");
3267
4651
  }
3268
4652
  // Arrange nodes with a built-in layout. Positions are computed by the pure
3269
4653
  // computeLayout() module, then applied in place (NOT via load(), which would
@@ -3511,6 +4895,9 @@ var VdFlowchart = class {
3511
4895
  }
3512
4896
  deleteSelection() {
3513
4897
  if (!this.selection || this.readonly) return false;
4898
+ if (this.hasMultiSelection()) {
4899
+ return this.removeNodeIds(new Set(this.getSelectedNodeIds()), "node:remove");
4900
+ }
3514
4901
  if (this.selection.kind === "node") return this.removeNode(this.selection.id);
3515
4902
  return this.removeEdge(this.selection.id);
3516
4903
  }
@@ -3539,10 +4926,11 @@ var VdFlowchart = class {
3539
4926
  return this;
3540
4927
  }
3541
4928
  fitView() {
3542
- if (!this.documentData.nodes.length) {
4929
+ const nodes = this.getVisibleNodes();
4930
+ if (!nodes.length) {
3543
4931
  return this.resetView();
3544
4932
  }
3545
- const bounds = getBounds(this.documentData.nodes);
4933
+ const bounds = getBounds(nodes);
3546
4934
  const width = this.canvasEl.clientWidth || 800;
3547
4935
  const height = this.canvasEl.clientHeight || 560;
3548
4936
  const padding = 80;
@@ -3600,6 +4988,14 @@ var VdFlowchart = class {
3600
4988
  pattern.querySelector("path").setAttribute("d", `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`);
3601
4989
  }
3602
4990
  if ("autoFit" in options) this.autoFit = Boolean(options.autoFit);
4991
+ if ("keyboardShortcuts" in options) {
4992
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
4993
+ }
4994
+ if ("snapGuides" in options) this.snapGuides = options.snapGuides !== false;
4995
+ if ("minimap" in options) this.setMinimapEnabled(options.minimap !== false);
4996
+ if ("autoLayout" in options) this.autoLayout = Boolean(options.autoLayout);
4997
+ this.syncCanvasLabel();
4998
+ if (this.shortcutsHelpOpen) this.renderShortcutsHelp();
3603
4999
  if ("history" in options && options.history !== false !== this.historyEnabled) {
3604
5000
  this.historyEnabled = options.history !== false;
3605
5001
  this.history = [];
@@ -3648,6 +5044,10 @@ var VdFlowchart = class {
3648
5044
  destroy() {
3649
5045
  if (this.destroyed) return;
3650
5046
  this.destroyed = true;
5047
+ if (this.minimapFrame && typeof cancelAnimationFrame === "function") {
5048
+ cancelAnimationFrame(this.minimapFrame);
5049
+ }
5050
+ this.minimapFrame = 0;
3651
5051
  this.activeTool = null;
3652
5052
  this.stopTextEdit({ commit: false });
3653
5053
  this.resizeObserver?.disconnect();
@@ -3664,6 +5064,17 @@ var VdFlowchart = class {
3664
5064
 
3665
5065
  // src/vue.js
3666
5066
  var FORWARDED_EVENTS = ["change", "select", "viewport", "connect", "ready"];
5067
+ var EDITOR_OPTION_KEYS = [
5068
+ "readonly",
5069
+ "gridSize",
5070
+ "autoFit",
5071
+ "history",
5072
+ "historyLimit",
5073
+ "keyboardShortcuts",
5074
+ "snapGuides",
5075
+ "minimap",
5076
+ "autoLayout"
5077
+ ];
3667
5078
  var VdFlowchart2 = (0, import_vue.defineComponent)({
3668
5079
  name: "VdFlowchart",
3669
5080
  props: {
@@ -3680,21 +5091,26 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
3680
5091
  /** Enable the built-in undo/redo history (default true). */
3681
5092
  history: { type: Boolean, default: true },
3682
5093
  /** Maximum number of history entries to retain. */
3683
- historyLimit: { type: Number, default: void 0 }
5094
+ historyLimit: { type: Number, default: void 0 },
5095
+ /** Keyboard model: 'mindmap' (Tab child, Enter sibling) or 'basic'. */
5096
+ keyboardShortcuts: { type: String, default: "mindmap" },
5097
+ /** Snap dragged nodes to other nodes' edges and centres (default true). */
5098
+ snapGuides: { type: Boolean, default: true },
5099
+ /** Show the overview minimap in the canvas corner (default true). */
5100
+ minimap: { type: Boolean, default: true },
5101
+ /** Re-run the current layout after keyboard or handle insertions (default false). */
5102
+ autoLayout: { type: Boolean, default: false }
3684
5103
  },
3685
5104
  emits: ["change", "select", "viewport", "connect", "ready"],
3686
5105
  setup(props, { emit, expose }) {
3687
5106
  const el = (0, import_vue.ref)(null);
3688
5107
  let instance = null;
5108
+ const editorOptions = () => Object.fromEntries(EDITOR_OPTION_KEYS.map((key) => [key, props[key]]));
3689
5109
  const create = () => {
3690
5110
  instance = new VdFlowchart({
3691
5111
  element: el.value,
3692
5112
  data: props.data,
3693
- readonly: props.readonly,
3694
- gridSize: props.gridSize,
3695
- autoFit: props.autoFit,
3696
- history: props.history,
3697
- historyLimit: props.historyLimit
5113
+ ...editorOptions()
3698
5114
  });
3699
5115
  FORWARDED_EVENTS.forEach((name) => {
3700
5116
  instance.on(name, (payload) => emit(name, payload));
@@ -3714,16 +5130,9 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
3714
5130
  { deep: true }
3715
5131
  );
3716
5132
  (0, import_vue.watch)(
3717
- () => [props.readonly, props.gridSize, props.autoFit, props.history, props.historyLimit],
5133
+ () => EDITOR_OPTION_KEYS.map((key) => props[key]),
3718
5134
  () => {
3719
- if (!instance) return;
3720
- instance.updateOptions({
3721
- readonly: props.readonly,
3722
- gridSize: props.gridSize,
3723
- autoFit: props.autoFit,
3724
- history: props.history,
3725
- historyLimit: props.historyLimit
3726
- });
5135
+ if (instance) instance.updateOptions(editorOptions());
3727
5136
  }
3728
5137
  );
3729
5138
  (0, import_vue.onBeforeUnmount)(() => {