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