@vanduo-oss/vd3-flowchart 1.2.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
@@ -20,8 +20,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.js
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ FLOWCHART_DOCUMENT_VERSION: () => FLOWCHART_DOCUMENT_VERSION,
23
24
  FLOWCHART_EDGE_MARKERS: () => FLOWCHART_EDGE_MARKERS,
24
25
  FLOWCHART_EDGE_ROUTES: () => FLOWCHART_EDGE_ROUTES,
26
+ FLOWCHART_KEYBOARD_SHORTCUTS: () => FLOWCHART_KEYBOARD_SHORTCUTS,
25
27
  FLOWCHART_NODE_TYPES: () => FLOWCHART_NODE_TYPES,
26
28
  FLOWCHART_PORTS: () => FLOWCHART_PORTS,
27
29
  LAYOUT_MODES: () => LAYOUT_MODES,
@@ -219,6 +221,134 @@ function computeLayout(documentData, mode = "tree", options = {}) {
219
221
  }
220
222
  var LAYOUT_MODES = ["tree", "radial", "grid"];
221
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
+
222
352
  // src/core.js
223
353
  var SVG_NS = "http://www.w3.org/2000/svg";
224
354
  var TOOLBAR_ICON_PATHS = {
@@ -228,6 +358,7 @@ var TOOLBAR_ICON_PATHS = {
228
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",
229
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",
230
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",
231
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"
232
363
  };
233
364
  var TOOLBAR_ACTIONS = [
@@ -279,20 +410,32 @@ var DEFAULT_EDGE_STROKE_WIDTH = 2.25;
279
410
  var MIN_EDGE_STROKE_WIDTH = 1.25;
280
411
  var MAX_EDGE_STROKE_WIDTH = 6;
281
412
  var CONNECTION_PORT_RADIUS = 6;
413
+ var PORT_HANDLE_OFFSET = 12;
282
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"]);
283
425
  var RECONNECT_ENDPOINT_RADIUS = 7;
284
426
  var RECONNECT_ENDPOINT_HIT_RADIUS = 12;
285
427
  var EDGE_HIT_STROKE_MIN = 16;
286
428
  var CONNECTION_SNAP_PADDING = 36;
287
429
  var CONNECTION_HYSTERESIS = 16;
288
430
  var CONNECTION_CENTER_LOCK_RADIUS = 18;
289
- var RESIZE_PORT_GAP = 34;
431
+ var RESIZE_PORT_GAP = 28;
290
432
  var EDGE_STROKE_PRESETS = [
291
433
  { id: "thin", label: "Thin", width: 1.75 },
292
434
  { id: "medium", label: "Medium", width: DEFAULT_EDGE_STROKE_WIDTH },
293
435
  { id: "bold", label: "Bold", width: 3.5 }
294
436
  ];
295
- var VD_FLOWCHART_VERSION = "1.2.0";
437
+ var VD_FLOWCHART_VERSION = "1.4.0";
438
+ var FLOWCHART_DOCUMENT_VERSION = "1.3.0";
296
439
  var FLOWCHART_NODE_TYPES = [
297
440
  "rounded-rect",
298
441
  "rect",
@@ -305,7 +448,65 @@ var FLOWCHART_NODE_TYPES = [
305
448
  var FLOWCHART_PORTS = ["top", "right", "bottom", "left"];
306
449
  var FLOWCHART_EDGE_MARKERS = ["none", "arrow", "dot"];
307
450
  var FLOWCHART_EDGE_ROUTES = ["curve", "straight", "orthogonal"];
308
- 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"]);
309
510
  var DEFAULT_EDGE_ROUTE = "curve";
310
511
  var ORTHOGONAL_STUB_LENGTH = 32;
311
512
  var ORTHOGONAL_CORNER_RADIUS = 12;
@@ -479,7 +680,7 @@ function normalizeViewport(viewport) {
479
680
  function normalizeNode(rawNode, index, usedIds) {
480
681
  const type = normalizeNodeType(rawNode?.type);
481
682
  const spec = getNodeSpec(type);
482
- return {
683
+ const node = {
483
684
  id: ensureUniqueId(rawNode?.id, "node", usedIds),
484
685
  type,
485
686
  x: toFiniteNumber(rawNode?.x, index * 28),
@@ -489,6 +690,37 @@ function normalizeNode(rawNode, index, usedIds) {
489
690
  text: rawNode?.text == null ? spec.text : String(rawNode.text),
490
691
  data: isPlainObject(rawNode?.data) ? deepClone(rawNode.data) : {}
491
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;
492
724
  }
493
725
  function normalizeEndpoint(rawEndpoint, fallbackPort) {
494
726
  return {
@@ -529,17 +761,45 @@ function normalizeEdge(rawEdge, index, nodeIds, usedIds) {
529
761
  syncEdgeKind(edge);
530
762
  return edge;
531
763
  }
764
+ function parseDocumentVersion(value) {
765
+ const raw = typeof value === "number" && Number.isFinite(value) ? String(value) : typeof value === "string" ? value.trim() : "";
766
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(raw);
767
+ if (!match) return null;
768
+ return {
769
+ major: Number(match[1]),
770
+ minor: Number(match[2] ?? 0),
771
+ patch: Number(match[3] ?? 0)
772
+ };
773
+ }
774
+ function isSupportedDocumentVersion(value) {
775
+ const parsed = parseDocumentVersion(value);
776
+ const max = parseDocumentVersion(FLOWCHART_DOCUMENT_VERSION);
777
+ if (!parsed || !max || parsed.major !== max.major) return false;
778
+ if (parsed.minor > max.minor) return false;
779
+ if (parsed.minor === max.minor && parsed.patch > max.patch) return false;
780
+ return true;
781
+ }
532
782
  function normalizeDocument(input) {
533
783
  let source = input;
534
784
  if (typeof source === "string") {
535
785
  try {
536
786
  source = JSON.parse(source);
537
787
  } catch {
538
- source = {};
788
+ throw new TypeError("Invalid flowchart JSON. The current document was not changed.");
539
789
  }
540
790
  }
541
791
  if (!isPlainObject(source)) {
542
- source = {};
792
+ throw new TypeError("A flowchart document must be an object.");
793
+ }
794
+ if (source.version !== void 0 && !isSupportedDocumentVersion(source.version)) {
795
+ throw new RangeError(
796
+ `Unsupported flowchart document version: ${String(source.version)}. Supported through ${FLOWCHART_DOCUMENT_VERSION}.`
797
+ );
798
+ }
799
+ for (const key of ["nodes", "edges"]) {
800
+ if (source[key] !== void 0 && !Array.isArray(source[key])) {
801
+ throw new TypeError(`Flowchart ${key} must be an array.`);
802
+ }
543
803
  }
544
804
  const usedNodeIds = /* @__PURE__ */ new Set();
545
805
  const nodes = (Array.isArray(source.nodes) ? source.nodes : []).slice(0, MAX_NODES).map((node, index) => normalizeNode(node, index, usedNodeIds));
@@ -547,7 +807,7 @@ function normalizeDocument(input) {
547
807
  const usedEdgeIds = /* @__PURE__ */ new Set();
548
808
  const edges = (Array.isArray(source.edges) ? source.edges : []).slice(0, MAX_EDGES).map((edge, index) => normalizeEdge(edge, index, nodeIds, usedEdgeIds)).filter(Boolean);
549
809
  return {
550
- version: VD_FLOWCHART_VERSION,
810
+ version: FLOWCHART_DOCUMENT_VERSION,
551
811
  viewport: normalizeViewport(source.viewport),
552
812
  nodes,
553
813
  edges
@@ -613,6 +873,15 @@ function getPortPosition(node, port) {
613
873
  return { x: node.x, y: node.y + node.height / 2 };
614
874
  }
615
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
+ }
616
885
  function getNearestPort(node, point) {
617
886
  return FLOWCHART_PORTS.reduce((best, port) => {
618
887
  const portPoint = getPortPosition(node, port);
@@ -980,6 +1249,68 @@ function getResizeCursor(handle) {
980
1249
  if (handle === "ne" || handle === "sw") return "nesw-resize";
981
1250
  return "nwse-resize";
982
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
+ }
983
1314
  function getBounds(nodes) {
984
1315
  if (!nodes.length) {
985
1316
  return { left: 0, top: 0, right: 0, bottom: 0 };
@@ -1002,6 +1333,8 @@ function getBounds(nodes) {
1002
1333
  function createField(labelText, control) {
1003
1334
  const wrapper = createElement("div", { className: "vd-flowchart-field" });
1004
1335
  const label = createElement("label", { text: labelText });
1336
+ control.id || (control.id = nextId("flowchart-field"));
1337
+ label.htmlFor = control.id;
1005
1338
  wrapper.appendChild(label);
1006
1339
  wrapper.appendChild(control);
1007
1340
  return wrapper;
@@ -1134,6 +1467,7 @@ var VdFlowchart = class {
1134
1467
  this.documentData = normalizeDocument(options.data || {});
1135
1468
  this.listeners = {};
1136
1469
  this.selection = null;
1470
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
1137
1471
  this.interaction = null;
1138
1472
  this.activeTool = null;
1139
1473
  this.layoutMode = "tree";
@@ -1141,6 +1475,7 @@ var VdFlowchart = class {
1141
1475
  this.destroyed = false;
1142
1476
  this.lastNodePointer = null;
1143
1477
  this.reconnectEdgeId = null;
1478
+ this.edgeCycleAnchor = null;
1144
1479
  this.clipboard = null;
1145
1480
  this.gridPatternId = nextId("flowchart-grid");
1146
1481
  this.markerIds = /* @__PURE__ */ new Map();
@@ -1150,6 +1485,16 @@ var VdFlowchart = class {
1150
1485
  this.history = [];
1151
1486
  this.historyIndex = -1;
1152
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;
1153
1498
  this.autoFit = Boolean(options.autoFit);
1154
1499
  this.readyEmitted = false;
1155
1500
  this.resizeObserver = null;
@@ -1190,6 +1535,7 @@ var VdFlowchart = class {
1190
1535
  if (!ready) return false;
1191
1536
  this.readyEmitted = true;
1192
1537
  if (this.autoFit) this.fitView();
1538
+ else this.scheduleMinimap();
1193
1539
  this.emit("ready", this);
1194
1540
  return true;
1195
1541
  };
@@ -1259,8 +1605,25 @@ var VdFlowchart = class {
1259
1605
  className: "vd-flowchart-toolbar-label",
1260
1606
  text: "100%"
1261
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");
1262
1623
  toolbarLeft.appendChild(this.arrangeSelect);
1263
1624
  toolbarLeft.appendChild(this.clearButton);
1625
+ toolbarRight.appendChild(this.minimapButton);
1626
+ toolbarRight.appendChild(this.shortcutsButton);
1264
1627
  toolbarRight.appendChild(this.zoomLabel);
1265
1628
  this.toolbar.appendChild(toolbarLeft);
1266
1629
  this.toolbar.appendChild(toolbarRight);
@@ -1339,13 +1702,40 @@ var VdFlowchart = class {
1339
1702
  this.previewLayer = svgEl("g", { class: "vd-flowchart-preview" });
1340
1703
  this.nodesLayer = svgEl("g", { class: "vd-flowchart-nodes" });
1341
1704
  this.overlayLayer = svgEl("g", { class: "vd-flowchart-overlay" });
1705
+ this.guidesLayer = svgEl("g", { class: "vd-flowchart-guides" });
1342
1706
  this.world.appendChild(this.gridRect);
1343
1707
  this.world.appendChild(this.edgesLayer);
1344
1708
  this.world.appendChild(this.previewLayer);
1345
1709
  this.world.appendChild(this.nodesLayer);
1346
1710
  this.world.appendChild(this.overlayLayer);
1711
+ this.world.appendChild(this.guidesLayer);
1347
1712
  this.svg.appendChild(this.world);
1348
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);
1349
1739
  this.inspectorPanel = createElement("aside", {
1350
1740
  className: "vd-flowchart-panel vd-flowchart-panel--inspector"
1351
1741
  });
@@ -1386,14 +1776,134 @@ var VdFlowchart = class {
1386
1776
  this.jsonActions.appendChild(this.loadJsonButton);
1387
1777
  this.jsonPanel.appendChild(this.jsonTextarea);
1388
1778
  this.jsonPanel.appendChild(this.jsonActions);
1779
+ this.jsonStatus = createElement("p", { className: "vd-flowchart-json-status" });
1780
+ this.jsonStatus.setAttribute("role", "status");
1781
+ this.jsonPanel.appendChild(this.jsonStatus);
1389
1782
  this.inspectorPanel.appendChild(this.jsonPanel);
1390
1783
  this.body.appendChild(this.palettePanel);
1391
1784
  this.body.appendChild(this.canvasEl);
1392
1785
  this.body.appendChild(this.inspectorPanel);
1393
1786
  this.root.appendChild(this.toolbar);
1394
1787
  this.root.appendChild(this.body);
1788
+ this.buildGraphOutline();
1395
1789
  this.element.appendChild(this.root);
1396
1790
  }
1791
+ buildGraphOutline() {
1792
+ this.graphOutline = createElement("details", { className: "vd-flowchart-outline" });
1793
+ this.graphOutline.appendChild(createElement("summary", { text: "Graph outline" }));
1794
+ this.graphOutline.addEventListener("toggle", () => {
1795
+ if (this.graphOutline.open) this.renderGraphOutline();
1796
+ });
1797
+ this.graphStatus = createElement("p", { className: "vd-flowchart-sr-only" });
1798
+ this.graphStatus.setAttribute("role", "status");
1799
+ this.canvasEl.appendChild(this.graphStatus);
1800
+ this.graphControls = createElement("div", { className: "vd-flowchart-outline-controls" });
1801
+ this.graphNodeSelect = createElement("select");
1802
+ this.graphNodeSelect.setAttribute("aria-label", "Selected node");
1803
+ this.graphNodeSelect.addEventListener(
1804
+ "change",
1805
+ () => this.selectNode(this.graphNodeSelect.value)
1806
+ );
1807
+ this.graphTargetSelect = createElement("select");
1808
+ this.graphTargetSelect.setAttribute("aria-label", "Connection target");
1809
+ this.graphTargetSelect.addEventListener("change", () => this.renderGraphOutline());
1810
+ this.graphConnectButton = createElement("button", {
1811
+ text: "Connect nodes",
1812
+ type: "button",
1813
+ className: "vd-flowchart-btn"
1814
+ });
1815
+ this.graphConnectButton.addEventListener("click", () => {
1816
+ if (this.readonly || this.graphNodeSelect.value === this.graphTargetSelect.value) {
1817
+ return;
1818
+ }
1819
+ this.addEdge({
1820
+ from: this.graphNodeSelect.value,
1821
+ to: this.graphTargetSelect.value,
1822
+ autoPort: true
1823
+ });
1824
+ });
1825
+ this.graphEditButton = createElement("button", {
1826
+ text: "Edit selected label",
1827
+ type: "button",
1828
+ className: "vd-flowchart-btn"
1829
+ });
1830
+ this.graphEditButton.addEventListener(
1831
+ "click",
1832
+ () => this.startTextEdit(this.graphNodeSelect.value)
1833
+ );
1834
+ this.graphControls.append(
1835
+ createField("Selected node", this.graphNodeSelect),
1836
+ createField("Connection target", this.graphTargetSelect),
1837
+ this.graphConnectButton,
1838
+ this.graphEditButton
1839
+ );
1840
+ this.graphOutline.appendChild(this.graphControls);
1841
+ this.graphList = createElement("ul");
1842
+ this.graphList.setAttribute("aria-label", "Nodes and connections");
1843
+ this.graphOutline.appendChild(this.graphList);
1844
+ this.root.appendChild(this.graphOutline);
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.";
1851
+ this.canvasEl.setAttribute(
1852
+ "aria-label",
1853
+ `Diagram canvas. ${keys} Press ? for all shortcuts. Graph outline provides connections.`
1854
+ );
1855
+ }
1856
+ renderGraphOutline() {
1857
+ if (!this.graphOutline) return;
1858
+ const nodes = this.documentData.nodes;
1859
+ const edges = this.documentData.edges;
1860
+ const labels = new Map(nodes.map((node) => [node.id, node.text || node.id]));
1861
+ const selected = this.selection?.kind === "node" ? this.selection.id : null;
1862
+ this.graphStatus.textContent = selected ? `Selected: ${labels.get(selected)}. ${nodes.length} nodes, ${edges.length} connections.` : `${nodes.length} nodes, ${edges.length} connections.`;
1863
+ if (!this.graphOutline.open) return;
1864
+ const hidden = this.getHiddenNodeIds();
1865
+ const signature = JSON.stringify([
1866
+ nodes.map((node) => [node.id, node.text, Boolean(node.collapsed), hidden.has(node.id)]),
1867
+ edges.map((edge) => [edge.id, edge.from.nodeId, edge.to.nodeId, edge.label])
1868
+ ]);
1869
+ if (signature !== this.graphSignature) {
1870
+ this.graphSignature = signature;
1871
+ for (const select of [this.graphNodeSelect, this.graphTargetSelect]) {
1872
+ const previous = select.value;
1873
+ select.replaceChildren();
1874
+ for (const node of nodes)
1875
+ select.appendChild(
1876
+ createElement("option", { value: node.id, text: labels.get(node.id) })
1877
+ );
1878
+ if (labels.has(previous)) select.value = previous;
1879
+ }
1880
+ this.graphList.replaceChildren();
1881
+ const outgoing = new Map(nodes.map((node) => [node.id, []]));
1882
+ const incoming = new Map(nodes.map((node) => [node.id, []]));
1883
+ for (const edge of edges) {
1884
+ outgoing.get(edge.from.nodeId)?.push(`${labels.get(edge.to.nodeId)}${edge.label ? ` (${edge.label})` : ""}`);
1885
+ incoming.get(edge.to.nodeId)?.push(labels.get(edge.from.nodeId));
1886
+ }
1887
+ for (const node of nodes) {
1888
+ const to = outgoing.get(node.id);
1889
+ const from = incoming.get(node.id);
1890
+ const relationships = [
1891
+ to.length ? `Connects to ${to.join(", ")}.` : "No outgoing connections.",
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);
1896
+ this.graphList.appendChild(
1897
+ createElement("li", { text: `${labels.get(node.id)}. ${relationships.join(" ")}` })
1898
+ );
1899
+ }
1900
+ }
1901
+ if (selected) this.graphNodeSelect.value = selected;
1902
+ this.graphNodeSelect.disabled = nodes.length === 0;
1903
+ this.graphTargetSelect.disabled = this.readonly || nodes.length < 2;
1904
+ this.graphConnectButton.disabled = this.readonly || nodes.length < 2 || this.graphNodeSelect.value === this.graphTargetSelect.value;
1905
+ this.graphEditButton.disabled = this.readonly || nodes.length === 0;
1906
+ }
1397
1907
  bindEvents() {
1398
1908
  this.toolbar.addEventListener("click", this.handleToolbarClick);
1399
1909
  this.arrangeSelect.addEventListener("change", this.handleArrangeChange);
@@ -1467,6 +1977,157 @@ var VdFlowchart = class {
1467
1977
  if (action === "undo") this.undo();
1468
1978
  if (action === "redo") this.redo();
1469
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);
1470
2131
  }
1471
2132
  handleArrangeChange(event) {
1472
2133
  if (this.readonly) return;
@@ -1500,7 +2161,12 @@ var VdFlowchart = class {
1500
2161
  return;
1501
2162
  }
1502
2163
  if (action === "load" && !this.readonly) {
1503
- this.load(this.jsonTextarea.value);
2164
+ try {
2165
+ this.load(this.jsonTextarea.value);
2166
+ this.jsonStatus.textContent = "";
2167
+ } catch (error) {
2168
+ this.jsonStatus.textContent = error.message;
2169
+ }
1504
2170
  }
1505
2171
  }
1506
2172
  handleSelectionFieldInput(event) {
@@ -1570,17 +2236,48 @@ var VdFlowchart = class {
1570
2236
  );
1571
2237
  }
1572
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.
1573
2241
  handleKeyDown(event) {
1574
- if (this.readonly) return;
1575
- 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)) {
1576
2249
  return;
1577
2250
  }
1578
- if (event.key === "Escape" && this.activeTool) {
2251
+ if (event.isComposing) return;
2252
+ const key = event.key;
2253
+ const modKey = event.metaKey || event.ctrlKey;
2254
+ if (key === "Escape" && this.shortcutsHelpOpen) {
1579
2255
  event.preventDefault();
1580
- this.setActiveTool(null);
2256
+ this.toggleShortcutsHelp(false);
2257
+ return;
2258
+ }
2259
+ if (target === this.canvasEl && this.handleCanvasKeyDown(event)) {
2260
+ event.preventDefault();
2261
+ return;
2262
+ }
2263
+ if (this.readonly) return;
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;
2271
+ event.preventDefault();
2272
+ return;
2273
+ }
2274
+ if (modKey && (key === "d" || key === "D")) {
2275
+ if (this.selection?.kind === "node") {
2276
+ event.preventDefault();
2277
+ this.duplicateSelection();
2278
+ }
1581
2279
  return;
1582
2280
  }
1583
- const modKey = event.metaKey || event.ctrlKey;
1584
2281
  if (modKey && (event.key === "z" || event.key === "Z")) {
1585
2282
  event.preventDefault();
1586
2283
  if (event.shiftKey) {
@@ -1619,11 +2316,248 @@ var VdFlowchart = class {
1619
2316
  if (!this.selection) return;
1620
2317
  if (event.key === "Backspace" || event.key === "Delete") {
1621
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
+ }
1622
2329
  this.deleteSelection();
1623
2330
  }
1624
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
+ }
1625
2555
  copySelection() {
1626
2556
  if (!this.selection) return false;
2557
+ if (this.hasMultiSelection()) {
2558
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2559
+ return true;
2560
+ }
1627
2561
  if (this.selection.kind === "node") {
1628
2562
  const node = this.findNode(this.selection.id);
1629
2563
  if (!node) return false;
@@ -1638,22 +2572,14 @@ var VdFlowchart = class {
1638
2572
  pasteClipboard() {
1639
2573
  if (!this.clipboard || this.readonly) return false;
1640
2574
  if (this.clipboard.kind === "node") {
1641
- const usedIds2 = new Set(this.documentData.nodes.map((node2) => node2.id));
1642
- const node = normalizeNode(
1643
- {
1644
- ...this.clipboard.data,
1645
- id: void 0,
1646
- x: this.clipboard.data.x + 24,
1647
- y: this.clipboard.data.y + 24
1648
- },
1649
- this.documentData.nodes.length,
1650
- usedIds2
1651
- );
1652
- this.documentData.nodes.push(node);
1653
- this.select({ kind: "node", id: node.id });
1654
- this.emitChange("node:add", { node: deepClone(node) });
2575
+ this.pasteNodeData(this.clipboard.data);
1655
2576
  return true;
1656
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
+ }
1657
2583
  const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
1658
2584
  const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
1659
2585
  const edge = normalizeEdge(
@@ -1680,16 +2606,16 @@ var VdFlowchart = class {
1680
2606
  const edgeTarget = event.target.closest("[data-edge-id]");
1681
2607
  if (edgeTarget && !event.target.closest("[data-edge-endpoint]")) {
1682
2608
  const edgeId = edgeTarget.getAttribute("data-edge-id");
1683
- if (edgeId && this.findEdge(edgeId)) {
1684
- this.reconnectEdgeId = edgeId;
1685
- this.select({ kind: "edge", id: edgeId });
2609
+ if (edgeId && this.startEdgeLabelEdit(edgeId)) {
1686
2610
  event.preventDefault();
1687
2611
  event.stopPropagation();
1688
2612
  return;
1689
2613
  }
1690
2614
  }
1691
2615
  const nodeTarget = event.target.closest("[data-node-id]");
1692
- 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
+ }
1693
2619
  const nodeId = nodeTarget.getAttribute("data-node-id");
1694
2620
  if (this.startTextEdit(nodeId)) {
1695
2621
  event.preventDefault();
@@ -1699,7 +2625,7 @@ var VdFlowchart = class {
1699
2625
  handleClick(event) {
1700
2626
  if (this.readonly || event.detail < 2) return;
1701
2627
  const nodeTarget = event.target.closest("[data-node-id]");
1702
- 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]"))
1703
2629
  return;
1704
2630
  if (this.startTextEdit(nodeTarget.getAttribute("data-node-id"))) {
1705
2631
  event.preventDefault();
@@ -1712,6 +2638,13 @@ var VdFlowchart = class {
1712
2638
  this.stopTextEdit({ commit: true });
1713
2639
  }
1714
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
+ }
1715
2648
  const nodeTarget = event.target.closest("[data-node-id]");
1716
2649
  const portTarget = event.target.closest("[data-port]");
1717
2650
  const resizeTarget = event.target.closest("[data-resize-handle]");
@@ -1747,6 +2680,13 @@ var VdFlowchart = class {
1747
2680
  event.preventDefault();
1748
2681
  return;
1749
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
+ }
1750
2690
  if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && !this.readonly) {
1751
2691
  if (this.activeTool === "arrow") {
1752
2692
  this.select({ kind: "node", id: nodeTarget.getAttribute("data-node-id") });
@@ -1773,6 +2713,9 @@ var VdFlowchart = class {
1773
2713
  this.interaction = {
1774
2714
  kind: "connect",
1775
2715
  pointerId: event.pointerId,
2716
+ startClientX: event.clientX,
2717
+ startClientY: event.clientY,
2718
+ moved: false,
1776
2719
  source: { nodeId, port },
1777
2720
  target: null,
1778
2721
  previousSnap: null,
@@ -1823,22 +2766,27 @@ var VdFlowchart = class {
1823
2766
  if (nodeTarget) {
1824
2767
  this.reconnectEdgeId = null;
1825
2768
  const nodeId = nodeTarget.getAttribute("data-node-id");
1826
- 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 });
1827
2772
  if (!this.readonly) {
1828
- const node = this.findNode(nodeId);
1829
- const world = this.clientToWorld(event.clientX, event.clientY);
1830
- this.interaction = {
1831
- kind: "drag-node",
1832
- pointerId: event.pointerId,
1833
- nodeId,
1834
- offsetX: world.x - node.x,
1835
- offsetY: world.y - node.y,
1836
- moved: false
1837
- };
2773
+ this.interaction = this.createDragInteraction(event, nodeId, groupIds);
1838
2774
  this.capturePointer(event.pointerId);
1839
2775
  }
1840
2776
  return;
1841
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
+ }
1842
2790
  this.select(null);
1843
2791
  this.reconnectEdgeId = null;
1844
2792
  this.interaction = {
@@ -1853,20 +2801,123 @@ var VdFlowchart = class {
1853
2801
  this.capturePointer(event.pointerId);
1854
2802
  event.preventDefault();
1855
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
+ }
1856
2913
  handlePointerMove(event) {
1857
2914
  if (!this.interaction || this.interaction.pointerId !== event.pointerId) return;
1858
2915
  if (this.interaction.kind === "drag-node") {
1859
- const node = this.findNode(this.interaction.nodeId);
1860
- if (!node) return;
1861
- const world = this.clientToWorld(event.clientX, event.clientY);
1862
- const nextX = formatNumber(world.x - this.interaction.offsetX);
1863
- const nextY = formatNumber(world.y - this.interaction.offsetY);
1864
- if (nextX !== node.x || nextY !== node.y) {
1865
- node.x = nextX;
1866
- node.y = nextY;
1867
- this.interaction.moved = true;
1868
- this.render({ inspector: false, json: false });
1869
- }
2916
+ this.moveDraggedNodes(event);
2917
+ return;
2918
+ }
2919
+ if (this.interaction.kind === "marquee") {
2920
+ this.updateMarquee(event);
1870
2921
  return;
1871
2922
  }
1872
2923
  if (this.interaction.kind === "resize-node") {
@@ -1901,6 +2952,14 @@ var VdFlowchart = class {
1901
2952
  return;
1902
2953
  }
1903
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
+ }
1904
2963
  const world = this.clientToWorld(event.clientX, event.clientY);
1905
2964
  const snapTarget = this.findConnectionTarget(
1906
2965
  world,
@@ -1987,10 +3046,19 @@ var VdFlowchart = class {
1987
3046
  }
1988
3047
  return;
1989
3048
  }
3049
+ if (interaction.kind === "marquee") {
3050
+ clearChildren(this.guidesLayer);
3051
+ return;
3052
+ }
1990
3053
  if (interaction.kind === "drag-node") {
3054
+ clearChildren(this.guidesLayer);
1991
3055
  if (interaction.moved) {
1992
3056
  this.render({ inspector: true, json: true });
1993
- 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
+ }
1994
3062
  } else {
1995
3063
  this.render({ inspector: false, json: false });
1996
3064
  }
@@ -2006,6 +3074,14 @@ var VdFlowchart = class {
2006
3074
  return;
2007
3075
  }
2008
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
+ }
2009
3085
  const world = this.clientToWorld(event.clientX || 0, event.clientY || 0);
2010
3086
  const snapTarget = interaction.target ? { node: this.findNode(interaction.target.nodeId), port: interaction.target.port } : this.findConnectionTarget(
2011
3087
  world,
@@ -2081,7 +3157,7 @@ var VdFlowchart = class {
2081
3157
  if (this.readonly) return false;
2082
3158
  if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") return true;
2083
3159
  if (this.activeTool === "arrow") return true;
2084
- return this.selection?.kind === "node" && this.selection.id === node.id;
3160
+ return this.selection?.kind === "node" && this.selection.id === node.id && !this.hasMultiSelection();
2085
3161
  }
2086
3162
  getMarkerId(markerType, position, strokeWidth) {
2087
3163
  const width = normalizeEdgeStrokeWidth(strokeWidth);
@@ -2104,7 +3180,7 @@ var VdFlowchart = class {
2104
3180
  findConnectionTarget(worldPoint, excludeNodeId, previousSnap = null, referencePoint = null) {
2105
3181
  const snapPadding = CONNECTION_SNAP_PADDING / this.documentData.viewport.scale;
2106
3182
  const hysteresisMargin = CONNECTION_HYSTERESIS / this.documentData.viewport.scale;
2107
- const best = this.documentData.nodes.reduce((candidate, node) => {
3183
+ const best = this.getVisibleNodes().reduce((candidate, node) => {
2108
3184
  if (node.id === excludeNodeId) return candidate;
2109
3185
  const distanceToBounds2 = getDistanceToNodeBounds(node, worldPoint);
2110
3186
  if (distanceToBounds2 > snapPadding) return candidate;
@@ -2213,7 +3289,10 @@ var VdFlowchart = class {
2213
3289
  updateToolbarLabel() {
2214
3290
  this.zoomLabel.textContent = `${Math.round(this.documentData.viewport.scale * 100)}%`;
2215
3291
  }
2216
- 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 = {}) {
2217
3296
  if (this.readonly) return false;
2218
3297
  const node = this.findNode(nodeId);
2219
3298
  if (!node || !isNodeTextEditable(node)) return false;
@@ -2225,9 +3304,10 @@ var VdFlowchart = class {
2225
3304
  }
2226
3305
  this.stopTextEdit({ commit: true });
2227
3306
  this.select({ kind: "node", id: node.id });
3307
+ const replacing = typeof options.initialText === "string";
2228
3308
  const textarea = createElement("textarea", {
2229
3309
  className: `vd-flowchart-text-editor vd-flowchart-text-editor--${node.type}`,
2230
- value: node.text,
3310
+ value: replacing ? options.initialText : node.text,
2231
3311
  rows: 1
2232
3312
  });
2233
3313
  textarea.setAttribute("data-node-id", node.id);
@@ -2236,17 +3316,7 @@ var VdFlowchart = class {
2236
3316
  textarea.addEventListener("input", () => this.positionTextEditor());
2237
3317
  textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
2238
3318
  textarea.addEventListener("dblclick", (event) => event.stopPropagation());
2239
- textarea.addEventListener("keydown", (event) => {
2240
- if (event.key === "Escape") {
2241
- event.preventDefault();
2242
- this.stopTextEdit({ commit: false });
2243
- return;
2244
- }
2245
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
2246
- event.preventDefault();
2247
- this.stopTextEdit({ commit: true });
2248
- }
2249
- });
3319
+ textarea.addEventListener("keydown", (event) => this.handleTextEditorKeyDown(event, node));
2250
3320
  textarea.addEventListener("blur", () => {
2251
3321
  if (this.textEditor?.textarea === textarea) {
2252
3322
  this.stopTextEdit({ commit: true });
@@ -2255,18 +3325,97 @@ var VdFlowchart = class {
2255
3325
  this.textEditor = {
2256
3326
  nodeId: node.id,
2257
3327
  previousText: node.text,
3328
+ inserted: Boolean(options.inserted),
2258
3329
  textarea
2259
3330
  };
2260
3331
  this.canvasEl.appendChild(textarea);
2261
3332
  this.render({ inspector: true, json: false });
2262
- window.requestAnimationFrame(() => {
2263
- if (this.textEditor?.textarea === textarea) {
2264
- 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 {
2265
3339
  textarea.select();
2266
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();
2267
3391
  });
2268
3392
  return true;
2269
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
+ }
2270
3419
  stopTextEdit(options = {}) {
2271
3420
  if (!this.textEditor) return false;
2272
3421
  const commit = options.commit !== false;
@@ -2274,12 +3423,26 @@ var VdFlowchart = class {
2274
3423
  const nextText = editor.textarea.value;
2275
3424
  this.textEditor = null;
2276
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
+ }
2277
3435
  if (commit && !this.readonly && nextText !== editor.previousText) {
2278
- this.updateNode(
2279
- editor.nodeId,
2280
- { text: nextText },
2281
- { inspector: true, reason: "node:update" }
2282
- );
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
+ }
2283
3446
  } else {
2284
3447
  this.render({ scene: true, inspector: false, json: false });
2285
3448
  }
@@ -2287,6 +3450,10 @@ var VdFlowchart = class {
2287
3450
  }
2288
3451
  positionTextEditor() {
2289
3452
  if (!this.textEditor) return;
3453
+ if (this.textEditor.edgeId) {
3454
+ this.positionEdgeLabelEditor();
3455
+ return;
3456
+ }
2290
3457
  const node = this.findNode(this.textEditor.nodeId);
2291
3458
  if (!node) {
2292
3459
  this.stopTextEdit({ commit: false });
@@ -2310,6 +3477,28 @@ var VdFlowchart = class {
2310
3477
  textarea.style.lineHeight = `${formatNumber(lineHeight)}px`;
2311
3478
  textarea.style.padding = `${formatNumber(verticalPadding)}px ${formatNumber(horizontalPadding)}px`;
2312
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
+ }
2313
3502
  render(options = {}) {
2314
3503
  if (this.destroyed) return;
2315
3504
  const shouldRenderScene = options.scene !== false;
@@ -2318,11 +3507,14 @@ var VdFlowchart = class {
2318
3507
  if (shouldRenderScene) this.renderScene();
2319
3508
  if (shouldRenderInspector) this.renderSelectionPanel();
2320
3509
  if (shouldRenderJson) this.syncJsonTextarea();
3510
+ if (shouldRenderInspector) this.renderGraphOutline();
2321
3511
  this.updateToolbarLabel();
2322
3512
  this.updatePaletteState();
2323
3513
  this.positionTextEditor();
3514
+ this.scheduleMinimap();
2324
3515
  }
2325
3516
  renderScene() {
3517
+ this.syncSelectedNodeIds();
2326
3518
  const viewport = this.documentData.viewport;
2327
3519
  this.world.setAttribute(
2328
3520
  "transform",
@@ -2332,16 +3524,35 @@ var VdFlowchart = class {
2332
3524
  clearChildren(this.previewLayer);
2333
3525
  clearChildren(this.nodesLayer);
2334
3526
  clearChildren(this.overlayLayer);
2335
- 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
+ );
3533
+ this.sceneNodeMap = nodeMap;
3534
+ this.nodeElements = /* @__PURE__ */ new Map();
3535
+ this.edgeElements = /* @__PURE__ */ new Map();
3536
+ this.incidentEdges = /* @__PURE__ */ new Map();
2336
3537
  this.documentData.edges.forEach((edge) => {
2337
3538
  const edgeElement = this.renderEdge(edge, nodeMap);
2338
- if (edgeElement) this.edgesLayer.appendChild(edgeElement);
3539
+ if (edgeElement) {
3540
+ this.edgesLayer.appendChild(edgeElement);
3541
+ this.edgeElements.set(edge.id, edgeElement);
3542
+ for (const id of /* @__PURE__ */ new Set([edge.from.nodeId, edge.to.nodeId])) {
3543
+ if (!this.incidentEdges.has(id)) this.incidentEdges.set(id, []);
3544
+ this.incidentEdges.get(id).push(edge);
3545
+ }
3546
+ }
2339
3547
  });
2340
3548
  if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") {
2341
3549
  this.previewLayer.appendChild(this.renderPreviewEdge(this.interaction));
2342
3550
  }
2343
3551
  this.documentData.nodes.forEach((node) => {
2344
- this.nodesLayer.appendChild(this.renderNode(node));
3552
+ if (hidden.has(node.id)) return;
3553
+ const element = this.renderNode(node);
3554
+ this.nodesLayer.appendChild(element);
3555
+ this.nodeElements.set(node.id, element);
2345
3556
  });
2346
3557
  if (this.reconnectEdgeId && !this.readonly) {
2347
3558
  const reconnectEdge = this.findEdge(this.reconnectEdgeId);
@@ -2357,6 +3568,25 @@ var VdFlowchart = class {
2357
3568
  }
2358
3569
  this.syncConnectingState();
2359
3570
  }
3571
+ // Drag frames change one translation and its connected paths. Keep unrelated
3572
+ // SVG elements intact; the release event still refreshes inspector/history.
3573
+ renderDraggedNode(node) {
3574
+ const element = this.nodeElements?.get(node.id);
3575
+ if (!element) {
3576
+ this.render({ inspector: false, json: false });
3577
+ return;
3578
+ }
3579
+ element.setAttribute("transform", `translate(${formatNumber(node.x)} ${formatNumber(node.y)})`);
3580
+ element.classList.add("is-dragging");
3581
+ for (const edge of this.incidentEdges.get(node.id) || []) {
3582
+ const previous = this.edgeElements.get(edge.id);
3583
+ const next = this.renderEdge(edge, this.sceneNodeMap);
3584
+ if (previous && next) {
3585
+ previous.replaceWith(next);
3586
+ this.edgeElements.set(edge.id, next);
3587
+ }
3588
+ }
3589
+ }
2360
3590
  applyEdgeMarkers(pathElement, edge) {
2361
3591
  if (edge.startMarker && edge.startMarker !== "none") {
2362
3592
  pathElement.setAttribute(
@@ -2407,7 +3637,7 @@ var VdFlowchart = class {
2407
3637
  });
2408
3638
  group.appendChild(visiblePath);
2409
3639
  group.appendChild(hitPath);
2410
- if (edge.label) {
3640
+ if (edge.label && this.textEditor?.edgeId !== edge.id) {
2411
3641
  const label = svgEl("text", {
2412
3642
  class: "vd-flowchart-edge-label",
2413
3643
  x: edgePath.labelX,
@@ -2468,8 +3698,9 @@ var VdFlowchart = class {
2468
3698
  });
2469
3699
  }
2470
3700
  renderNode(node) {
2471
- const selected = this.selection?.kind === "node" && this.selection.id === node.id;
2472
- 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);
2473
3704
  const resizing = this.interaction?.kind === "resize-node" && this.interaction.nodeId === node.id;
2474
3705
  const editing = this.textEditor?.nodeId === node.id;
2475
3706
  const group = svgEl("g", {
@@ -2490,12 +3721,29 @@ var VdFlowchart = class {
2490
3721
  group.appendChild(hitbox);
2491
3722
  group.appendChild(this.renderNodeShape(node));
2492
3723
  group.appendChild(this.renderNodeText(node));
2493
- 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)) {
2494
3738
  group.appendChild(this.renderResizeControls(node));
2495
3739
  }
2496
- 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
+ }
2497
3745
  FLOWCHART_PORTS.forEach((port) => {
2498
- const position = getPortPosition({ ...node, x: 0, y: 0 }, port);
3746
+ const position = getPortHandlePosition({ ...node, x: 0, y: 0 }, port, scale);
2499
3747
  const portGroup = svgEl("g", {
2500
3748
  class: `vd-flowchart-port-group${portsVisible ? " is-visible" : ""}`,
2501
3749
  "data-node-id": node.id,
@@ -2517,15 +3765,68 @@ var VdFlowchart = class {
2517
3765
  r: CONNECTION_PORT_RADIUS / scale
2518
3766
  })
2519
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
+ );
2520
3775
  group.appendChild(portGroup);
2521
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);
2522
3823
  return group;
2523
3824
  }
2524
3825
  renderResizeControls(node) {
2525
3826
  const scale = this.documentData.viewport.scale || 1;
2526
3827
  const zone = 14 / scale;
2527
3828
  const cornerZone = 22 / scale;
2528
- const handleSize = 9 / scale;
3829
+ const handleSize = RESIZE_HANDLE_SIZE / scale;
2529
3830
  const gap = RESIZE_PORT_GAP / scale;
2530
3831
  const group = svgEl("g", { class: "vd-flowchart-resize-controls" });
2531
3832
  const zones = {
@@ -2555,12 +3856,6 @@ var VdFlowchart = class {
2555
3856
  };
2556
3857
  RESIZE_HANDLES.forEach((handle) => {
2557
3858
  const zoneRect = zones[handle];
2558
- const position = getResizeHandlePosition(node, handle);
2559
- const dotOffset = 8 / scale;
2560
- if (handle.includes("e")) position.x += dotOffset;
2561
- if (handle.includes("w")) position.x -= dotOffset;
2562
- if (handle.includes("n")) position.y -= dotOffset;
2563
- if (handle.includes("s")) position.y += dotOffset;
2564
3859
  const cursor = getResizeCursor(handle);
2565
3860
  const zoneSegments = [];
2566
3861
  if (handle === "e" || handle === "w") {
@@ -2595,14 +3890,19 @@ var VdFlowchart = class {
2595
3890
  });
2596
3891
  group.appendChild(hit);
2597
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;
2598
3898
  const dot = svgEl("rect", {
2599
3899
  class: "vd-flowchart-resize-handle",
2600
3900
  x: position.x - handleSize / 2,
2601
3901
  y: position.y - handleSize / 2,
2602
3902
  width: handleSize,
2603
3903
  height: handleSize,
2604
- rx: handleSize / 3,
2605
- ry: handleSize / 3,
3904
+ rx: handleSize / 5,
3905
+ ry: handleSize / 5,
2606
3906
  "data-node-id": node.id,
2607
3907
  "data-resize-handle": handle,
2608
3908
  style: `cursor: ${cursor}`
@@ -2731,6 +4031,16 @@ var VdFlowchart = class {
2731
4031
  );
2732
4032
  return;
2733
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
+ }
2734
4044
  if (this.selection.kind === "node") {
2735
4045
  const node = this.findNode(this.selection.id);
2736
4046
  if (!node) {
@@ -2845,16 +4155,69 @@ var VdFlowchart = class {
2845
4155
  }
2846
4156
  select(selection) {
2847
4157
  const next = selection && selection.id && selection.kind ? { kind: selection.kind, id: selection.id } : null;
2848
- const previousKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
2849
- const nextKey = next ? `${next.kind}:${next.id}` : "";
2850
- this.selection = next;
2851
- 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;
2852
4169
  this.render({ scene: true, inspector: true, json: false });
2853
- this.emit("select", { selection: this.getSelectionSnapshot() });
4170
+ this.emit("select", {
4171
+ selection: this.getSelectionSnapshot(),
4172
+ nodeIds: this.getSelectedNodeIds()
4173
+ });
2854
4174
  return;
2855
4175
  }
2856
4176
  this.render({ scene: true, inspector: false, json: false });
2857
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
+ }
2858
4221
  selectNode(nodeId) {
2859
4222
  this.select({ kind: "node", id: sanitizeId(nodeId) });
2860
4223
  return this;
@@ -2875,6 +4238,69 @@ var VdFlowchart = class {
2875
4238
  findNode(nodeId) {
2876
4239
  return this.documentData.nodes.find((node) => node.id === nodeId) || null;
2877
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
+ }
2878
4304
  findEdge(edgeId) {
2879
4305
  return this.documentData.edges.find((edge) => edge.id === edgeId) || null;
2880
4306
  }
@@ -2885,7 +4311,8 @@ var VdFlowchart = class {
2885
4311
  }
2886
4312
  emitChange(reason, extra = {}) {
2887
4313
  if (this.historyEnabled && !this.isApplyingHistory) {
2888
- this.recordHistory(reason);
4314
+ if (this.historyBatchDepth > 0) this.historyBatchDirty = true;
4315
+ else this.recordHistory(reason);
2889
4316
  }
2890
4317
  this.syncJsonTextarea();
2891
4318
  this.emit("change", {
@@ -2929,7 +4356,9 @@ var VdFlowchart = class {
2929
4356
  const snapshot = this.toJSON();
2930
4357
  const targetKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
2931
4358
  const top = this.history[this.historyIndex];
2932
- 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);
2933
4362
  if (canCoalesce) {
2934
4363
  top.snapshot = snapshot;
2935
4364
  return;
@@ -3045,19 +4474,180 @@ var VdFlowchart = class {
3045
4474
  const parent = this.findNode(sanitizeId(parentId));
3046
4475
  if (!parent) return null;
3047
4476
  const { direction = "right", distance, angle, edge: edgeOptions, ...nodeOptions } = options;
3048
- const node = this.addNode({
3049
- ...nodeOptions,
3050
- 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 };
3051
4491
  });
3052
- const edge = this.addEdge({
3053
- from: parent.id,
3054
- to: node.id,
3055
- autoPort: true,
3056
- endMarker: "arrow",
3057
- ...isPlainObject(edgeOptions) ? edgeOptions : {}
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);
3058
4528
  });
3059
- this.select({ kind: "node", id: node.id });
3060
- return { node, edge };
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)
4572
+ });
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");
3061
4651
  }
3062
4652
  // Arrange nodes with a built-in layout. Positions are computed by the pure
3063
4653
  // computeLayout() module, then applied in place (NOT via load(), which would
@@ -3305,6 +4895,9 @@ var VdFlowchart = class {
3305
4895
  }
3306
4896
  deleteSelection() {
3307
4897
  if (!this.selection || this.readonly) return false;
4898
+ if (this.hasMultiSelection()) {
4899
+ return this.removeNodeIds(new Set(this.getSelectedNodeIds()), "node:remove");
4900
+ }
3308
4901
  if (this.selection.kind === "node") return this.removeNode(this.selection.id);
3309
4902
  return this.removeEdge(this.selection.id);
3310
4903
  }
@@ -3333,10 +4926,11 @@ var VdFlowchart = class {
3333
4926
  return this;
3334
4927
  }
3335
4928
  fitView() {
3336
- if (!this.documentData.nodes.length) {
4929
+ const nodes = this.getVisibleNodes();
4930
+ if (!nodes.length) {
3337
4931
  return this.resetView();
3338
4932
  }
3339
- const bounds = getBounds(this.documentData.nodes);
4933
+ const bounds = getBounds(nodes);
3340
4934
  const width = this.canvasEl.clientWidth || 800;
3341
4935
  const height = this.canvasEl.clientHeight || 560;
3342
4936
  const padding = 80;
@@ -3371,20 +4965,77 @@ var VdFlowchart = class {
3371
4965
  this.emitChange("clear");
3372
4966
  return this;
3373
4967
  }
4968
+ updateOptions(options = {}) {
4969
+ if (this.destroyed) return this;
4970
+ if ("readonly" in options) {
4971
+ if (options.readonly && !this.readonly) {
4972
+ this.stopTextEdit({ commit: true });
4973
+ this.interaction = null;
4974
+ this.activeTool = null;
4975
+ this.reconnectEdgeId = null;
4976
+ }
4977
+ this.readonly = Boolean(options.readonly);
4978
+ this.root.classList.toggle("vd-flowchart-readonly", this.readonly);
4979
+ this.arrangeSelect.disabled = this.readonly;
4980
+ this.clearButton.disabled = this.readonly;
4981
+ this.loadJsonButton.disabled = this.readonly;
4982
+ }
4983
+ if ("gridSize" in options) {
4984
+ this.gridSize = clamp(toFiniteNumber(options.gridSize, DEFAULT_GRID_SIZE), 12, 64);
4985
+ const pattern = this.svg.querySelector("pattern");
4986
+ pattern.setAttribute("width", this.gridSize);
4987
+ pattern.setAttribute("height", this.gridSize);
4988
+ pattern.querySelector("path").setAttribute("d", `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`);
4989
+ }
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();
4999
+ if ("history" in options && options.history !== false !== this.historyEnabled) {
5000
+ this.historyEnabled = options.history !== false;
5001
+ this.history = [];
5002
+ this.historyIndex = -1;
5003
+ this.seedHistory();
5004
+ }
5005
+ if ("historyLimit" in options) {
5006
+ this.historyLimit = Math.max(1, Math.floor(toFiniteNumber(options.historyLimit, 100)));
5007
+ if (this.history.length > this.historyLimit) {
5008
+ const start = Math.max(0, this.historyIndex - this.historyLimit + 1);
5009
+ this.history = this.history.slice(start, start + this.historyLimit);
5010
+ this.historyIndex -= start;
5011
+ }
5012
+ }
5013
+ this.render();
5014
+ this.updateHistoryButtons();
5015
+ return this;
5016
+ }
3374
5017
  load(data, options = {}) {
5018
+ const documentData = normalizeDocument(data);
5019
+ if (options.silent && JSON.stringify(documentData) === JSON.stringify(this.documentData)) {
5020
+ return this;
5021
+ }
3375
5022
  this.stopTextEdit({ commit: false });
3376
5023
  this.activeTool = null;
3377
5024
  this.reconnectEdgeId = null;
3378
5025
  const previousSelection = this.selection;
3379
- this.documentData = normalizeDocument(data);
5026
+ this.documentData = documentData;
3380
5027
  this.selection = this.resolvePreservedSelection(previousSelection, options.preserveSelection);
3381
5028
  this.render();
3382
- this.emitChange("load");
5029
+ if (options.silent) {
5030
+ if (this.historyEnabled) this.recordHistory("load");
5031
+ } else {
5032
+ this.emitChange("load");
5033
+ }
3383
5034
  return this;
3384
5035
  }
3385
5036
  toJSON() {
3386
5037
  return deepClone({
3387
- version: VD_FLOWCHART_VERSION,
5038
+ version: FLOWCHART_DOCUMENT_VERSION,
3388
5039
  viewport: this.documentData.viewport,
3389
5040
  nodes: this.documentData.nodes,
3390
5041
  edges: this.documentData.edges
@@ -3393,11 +5044,19 @@ var VdFlowchart = class {
3393
5044
  destroy() {
3394
5045
  if (this.destroyed) return;
3395
5046
  this.destroyed = true;
5047
+ if (this.minimapFrame && typeof cancelAnimationFrame === "function") {
5048
+ cancelAnimationFrame(this.minimapFrame);
5049
+ }
5050
+ this.minimapFrame = 0;
3396
5051
  this.activeTool = null;
3397
5052
  this.stopTextEdit({ commit: false });
3398
5053
  this.resizeObserver?.disconnect();
3399
5054
  this.resizeObserver = null;
3400
5055
  this.unbindEvents();
5056
+ this.sceneNodeMap?.clear();
5057
+ this.nodeElements?.clear();
5058
+ this.edgeElements?.clear();
5059
+ this.incidentEdges?.clear();
3401
5060
  this.element.innerHTML = "";
3402
5061
  this.element.classList.remove("vd-flowchart-host");
3403
5062
  }
@@ -3405,6 +5064,17 @@ var VdFlowchart = class {
3405
5064
 
3406
5065
  // src/vue.js
3407
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
+ ];
3408
5078
  var VdFlowchart2 = (0, import_vue.defineComponent)({
3409
5079
  name: "VdFlowchart",
3410
5080
  props: {
@@ -3421,21 +5091,26 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
3421
5091
  /** Enable the built-in undo/redo history (default true). */
3422
5092
  history: { type: Boolean, default: true },
3423
5093
  /** Maximum number of history entries to retain. */
3424
- 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 }
3425
5103
  },
3426
5104
  emits: ["change", "select", "viewport", "connect", "ready"],
3427
5105
  setup(props, { emit, expose }) {
3428
5106
  const el = (0, import_vue.ref)(null);
3429
5107
  let instance = null;
5108
+ const editorOptions = () => Object.fromEntries(EDITOR_OPTION_KEYS.map((key) => [key, props[key]]));
3430
5109
  const create = () => {
3431
5110
  instance = new VdFlowchart({
3432
5111
  element: el.value,
3433
5112
  data: props.data,
3434
- readonly: props.readonly,
3435
- gridSize: props.gridSize,
3436
- autoFit: props.autoFit,
3437
- history: props.history,
3438
- historyLimit: props.historyLimit
5113
+ ...editorOptions()
3439
5114
  });
3440
5115
  FORWARDED_EVENTS.forEach((name) => {
3441
5116
  instance.on(name, (payload) => emit(name, payload));
@@ -3449,17 +5124,15 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
3449
5124
  () => props.data,
3450
5125
  (next) => {
3451
5126
  if (instance && typeof instance.load === "function") {
3452
- instance.load(next, { preserveSelection: props.preserveSelection });
5127
+ instance.load(next, { preserveSelection: props.preserveSelection, silent: true });
3453
5128
  }
3454
5129
  },
3455
5130
  { deep: true }
3456
5131
  );
3457
5132
  (0, import_vue.watch)(
3458
- () => [props.readonly, props.gridSize, props.autoFit, props.history, props.historyLimit],
5133
+ () => EDITOR_OPTION_KEYS.map((key) => props[key]),
3459
5134
  () => {
3460
- if (!instance) return;
3461
- instance.destroy();
3462
- create();
5135
+ if (instance) instance.updateOptions(editorOptions());
3463
5136
  }
3464
5137
  );
3465
5138
  (0, import_vue.onBeforeUnmount)(() => {