@vanduo-oss/vd3-flowchart 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  FLOWCHART_DOCUMENT_VERSION: () => FLOWCHART_DOCUMENT_VERSION,
24
24
  FLOWCHART_EDGE_MARKERS: () => FLOWCHART_EDGE_MARKERS,
25
25
  FLOWCHART_EDGE_ROUTES: () => FLOWCHART_EDGE_ROUTES,
26
+ FLOWCHART_KEYBOARD_SHORTCUTS: () => FLOWCHART_KEYBOARD_SHORTCUTS,
26
27
  FLOWCHART_NODE_TYPES: () => FLOWCHART_NODE_TYPES,
27
28
  FLOWCHART_PORTS: () => FLOWCHART_PORTS,
28
29
  LAYOUT_MODES: () => LAYOUT_MODES,
@@ -220,6 +221,134 @@ function computeLayout(documentData, mode = "tree", options = {}) {
220
221
  }
221
222
  var LAYOUT_MODES = ["tree", "radial", "grid"];
222
223
 
224
+ // src/mindmap.js
225
+ var DIRECTION_PORTS = { right: "right", left: "left", down: "bottom", up: "top" };
226
+ var PORT_DIRECTIONS = { right: "right", left: "left", bottom: "down", top: "up" };
227
+ var OPPOSITE_DIRECTIONS = { right: "left", left: "right", down: "up", up: "down" };
228
+ var DIRECTION_VECTORS = {
229
+ right: { x: 1, y: 0 },
230
+ left: { x: -1, y: 0 },
231
+ down: { x: 0, y: 1 },
232
+ up: { x: 0, y: -1 }
233
+ };
234
+ var BRANCH_GAP = 72;
235
+ var SIBLING_GAP = 24;
236
+ var COLLISION_GAP = 12;
237
+ var COLLISION_STEP = 12;
238
+ var MAX_COLLISION_STEPS = 400;
239
+ var BRANCH_RING_STEP = 48;
240
+ var MAX_BRANCH_RINGS = 8;
241
+ var NAVIGATION_CONE = Math.tan(Math.PI / 3);
242
+ function centerOf(node) {
243
+ return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
244
+ }
245
+ function oppositeDirection(direction) {
246
+ return OPPOSITE_DIRECTIONS[direction] || "left";
247
+ }
248
+ function findSpatialNeighbor(nodes, fromNode, direction) {
249
+ const vector = DIRECTION_VECTORS[direction];
250
+ if (!vector || !fromNode) return null;
251
+ const origin = centerOf(fromNode);
252
+ let bestCone = null;
253
+ let bestPlane = null;
254
+ nodes.forEach((node) => {
255
+ if (node.id === fromNode.id) return;
256
+ const center = centerOf(node);
257
+ const dx = center.x - origin.x;
258
+ const dy = center.y - origin.y;
259
+ const along = dx * vector.x + dy * vector.y;
260
+ if (along <= 0) return;
261
+ const across = Math.abs(dx * vector.y - dy * vector.x);
262
+ const score = along + across * 2;
263
+ if (across <= along * NAVIGATION_CONE && (!bestCone || score < bestCone.score)) {
264
+ bestCone = { node, score };
265
+ }
266
+ if (!bestPlane || score < bestPlane.score) bestPlane = { node, score };
267
+ });
268
+ return (bestCone || bestPlane)?.node ?? null;
269
+ }
270
+ function findNearestNode(nodes, point) {
271
+ let best = null;
272
+ nodes.forEach((node) => {
273
+ const center = centerOf(node);
274
+ const distance = Math.hypot(center.x - point.x, center.y - point.y);
275
+ if (!best || distance < best.distance) best = { node, distance };
276
+ });
277
+ return best?.node ?? null;
278
+ }
279
+ function overlapsAny(rect, nodes) {
280
+ return nodes.some(
281
+ (node) => rect.x < node.x + node.width + COLLISION_GAP && rect.x + rect.width + COLLISION_GAP > node.x && rect.y < node.y + node.height + COLLISION_GAP && rect.y + rect.height + COLLISION_GAP > node.y
282
+ );
283
+ }
284
+ function placeBranchNode({
285
+ nodes,
286
+ parent,
287
+ siblings = [],
288
+ direction,
289
+ size,
290
+ anchor = null,
291
+ before = false
292
+ }) {
293
+ const horizontal = direction === "right" || direction === "left";
294
+ const cross = horizontal ? "y" : "x";
295
+ const crossSize = horizontal ? "height" : "width";
296
+ const lastSibling = siblings.reduce(
297
+ (last, node) => !last || node[cross] + node[crossSize] > last[cross] + last[crossSize] ? node : last,
298
+ null
299
+ );
300
+ const reference = anchor || lastSibling;
301
+ const rect = { x: 0, y: 0, width: size.width, height: size.height };
302
+ if (reference) {
303
+ if (direction === "right") rect.x = reference.x;
304
+ if (direction === "left") rect.x = reference.x + reference.width - size.width;
305
+ if (direction === "down") rect.y = reference.y;
306
+ if (direction === "up") rect.y = reference.y + reference.height - size.height;
307
+ rect[cross] = before ? reference[cross] - SIBLING_GAP - size[crossSize] : reference[cross] + reference[crossSize] + SIBLING_GAP;
308
+ } else {
309
+ if (direction === "right") rect.x = parent.x + parent.width + BRANCH_GAP;
310
+ if (direction === "left") rect.x = parent.x - BRANCH_GAP - size.width;
311
+ if (direction === "down") rect.y = parent.y + parent.height + BRANCH_GAP;
312
+ if (direction === "up") rect.y = parent.y - BRANCH_GAP - size.height;
313
+ rect[cross] = parent[cross] + parent[crossSize] / 2 - size[crossSize] / 2;
314
+ }
315
+ const crossSign = reference ? before ? -1 : 1 : 0;
316
+ const result = nearestFreeSlot(
317
+ rect,
318
+ nodes,
319
+ cross,
320
+ horizontal ? "x" : "y",
321
+ DIRECTION_VECTORS[direction],
322
+ crossSign
323
+ );
324
+ return { x: Math.round(result.x * 100) / 100, y: Math.round(result.y * 100) / 100 };
325
+ }
326
+ function crossOffsetAt(index, crossSign) {
327
+ if (crossSign) return crossSign * COLLISION_STEP * index;
328
+ return COLLISION_STEP * Math.ceil(index / 2) * (index % 2 ? 1 : -1);
329
+ }
330
+ function nearestFreeSlot(rect, nodes, cross, main, vector, crossSign) {
331
+ const origin = { ...rect };
332
+ const outward = vector.x + vector.y;
333
+ let best = null;
334
+ for (let ring = 0; ring <= MAX_BRANCH_RINGS; ring += 1) {
335
+ const mainOffset = ring * BRANCH_RING_STEP;
336
+ if (best && mainOffset >= best.cost) break;
337
+ const candidate = { ...origin, [main]: origin[main] + outward * mainOffset };
338
+ for (let index = 0; index <= MAX_COLLISION_STEPS; index += 1) {
339
+ const crossOffset = crossOffsetAt(index, crossSign);
340
+ const cost = Math.hypot(mainOffset, crossOffset);
341
+ if (best && cost >= best.cost) break;
342
+ candidate[cross] = origin[cross] + crossOffset;
343
+ if (!overlapsAny(candidate, nodes)) {
344
+ best = { rect: { ...candidate }, cost };
345
+ break;
346
+ }
347
+ }
348
+ }
349
+ return best ? best.rect : origin;
350
+ }
351
+
223
352
  // src/core.js
224
353
  var SVG_NS = "http://www.w3.org/2000/svg";
225
354
  var TOOLBAR_ICON_PATHS = {
@@ -229,6 +358,7 @@ var TOOLBAR_ICON_PATHS = {
229
358
  "fit-view": "M216,48V88a8,8,0,0,1-16,0V67.31l-42.34,42.35a8,8,0,0,1-11.32-11.32L188.69,56H168a8,8,0,0,1,0-16h40A8,8,0,0,1,216,48ZM98.34,146.34,56,188.69V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H88a8,8,0,0,0,0-16H67.31l42.35-42.34a8,8,0,0,0-11.32-11.32Zm11.32-36.68L67.31,56H88a8,8,0,0,0,0-16H48a8,8,0,0,0-8,8V88a8,8,0,0,0,16,0V67.31l42.34,42.35a8,8,0,0,0,11.32-11.32ZM208,160a8,8,0,0,0-8,8v20.69l-42.34-42.35a8,8,0,0,0-11.32,11.32L188.69,200H168a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,208,160Z",
230
359
  undo: "M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z",
231
360
  redo: "M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z",
361
+ minimap: "M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z",
232
362
  clear: "M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"
233
363
  };
234
364
  var TOOLBAR_ACTIONS = [
@@ -268,11 +398,12 @@ function createToolbarButton({ action, label, icon, disabled = false }) {
268
398
  return button;
269
399
  }
270
400
  var DEFAULT_GRID_SIZE = 24;
271
- var MIN_SCALE = 0.35;
272
- var MAX_SCALE = 3;
273
- var MIN_NODE_SIZE = 56;
274
- var MAX_NODE_SIZE = 420;
275
- var WORLD_EXTENT = 12e3;
401
+ var DEFAULT_MIN_SCALE = 0.35;
402
+ var DEFAULT_MAX_SCALE = 3;
403
+ var DEFAULT_MIN_NODE_SIZE = 56;
404
+ var DEFAULT_MAX_NODE_SIZE = 420;
405
+ var DEFAULT_WORLD_EXTENT = 12e3;
406
+ var WIDE_WORLD_EXTENT = 24e4;
276
407
  var MAX_NODES = 1e4;
277
408
  var MAX_EDGES = 1e4;
278
409
  var RESIZE_HANDLES = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
@@ -280,21 +411,32 @@ var DEFAULT_EDGE_STROKE_WIDTH = 2.25;
280
411
  var MIN_EDGE_STROKE_WIDTH = 1.25;
281
412
  var MAX_EDGE_STROKE_WIDTH = 6;
282
413
  var CONNECTION_PORT_RADIUS = 6;
414
+ var PORT_HANDLE_OFFSET = 12;
283
415
  var CONNECTION_PORT_HIT_RADIUS = 14;
416
+ var PORT_CLICK_SLOP = 4;
417
+ var RESIZE_HANDLE_SIZE = 9;
418
+ var SELECTION_OUTSET = 4;
419
+ var GUIDE_SNAP_DISTANCE = 6;
420
+ var GUIDE_OVERHANG = 12;
421
+ var COLLAPSE_TOGGLE_OFFSET = 36;
422
+ var MINIMAP_WIDTH = 180;
423
+ var MINIMAP_HEIGHT = 120;
424
+ var MINIMAP_MIN_CANVAS_WIDTH = 480;
425
+ var SELECTION_BOX_TYPES = /* @__PURE__ */ new Set(["circle", "diamond", "label"]);
284
426
  var RECONNECT_ENDPOINT_RADIUS = 7;
285
427
  var RECONNECT_ENDPOINT_HIT_RADIUS = 12;
286
428
  var EDGE_HIT_STROKE_MIN = 16;
287
429
  var CONNECTION_SNAP_PADDING = 36;
288
430
  var CONNECTION_HYSTERESIS = 16;
289
431
  var CONNECTION_CENTER_LOCK_RADIUS = 18;
290
- var RESIZE_PORT_GAP = 34;
432
+ var RESIZE_PORT_GAP = 28;
291
433
  var EDGE_STROKE_PRESETS = [
292
434
  { id: "thin", label: "Thin", width: 1.75 },
293
435
  { id: "medium", label: "Medium", width: DEFAULT_EDGE_STROKE_WIDTH },
294
436
  { id: "bold", label: "Bold", width: 3.5 }
295
437
  ];
296
- var VD_FLOWCHART_VERSION = "1.3.0";
297
- var FLOWCHART_DOCUMENT_VERSION = "1.2.0";
438
+ var VD_FLOWCHART_VERSION = "1.5.0";
439
+ var FLOWCHART_DOCUMENT_VERSION = "1.3.0";
298
440
  var FLOWCHART_NODE_TYPES = [
299
441
  "rounded-rect",
300
442
  "rect",
@@ -307,7 +449,65 @@ var FLOWCHART_NODE_TYPES = [
307
449
  var FLOWCHART_PORTS = ["top", "right", "bottom", "left"];
308
450
  var FLOWCHART_EDGE_MARKERS = ["none", "arrow", "dot"];
309
451
  var FLOWCHART_EDGE_ROUTES = ["curve", "straight", "orthogonal"];
310
- var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update"]);
452
+ var FLOWCHART_KEYBOARD_SHORTCUTS = ["mindmap", "basic"];
453
+ function normalizeKeyboardShortcuts(value) {
454
+ return value === "basic" ? "basic" : "mindmap";
455
+ }
456
+ var ARROW_DIRECTIONS = {
457
+ ArrowRight: "right",
458
+ ArrowLeft: "left",
459
+ ArrowDown: "down",
460
+ ArrowUp: "up"
461
+ };
462
+ function getShortcutRows(mode, readonly) {
463
+ const navigation = [
464
+ ["Arrow keys", "Select the nearest node in that direction"],
465
+ ["Shift + Arrow", "Select a connection on that side (repeat to cycle)"],
466
+ ["Shift + click, Shift + drag", "Add nodes to the selection, or select with a box"],
467
+ ["Cmd/Ctrl + A", "Select all nodes"]
468
+ ];
469
+ const view = [
470
+ ["Cmd/Ctrl + = or -", "Zoom in or out"],
471
+ ["Cmd/Ctrl + 0", "Zoom to 100%"],
472
+ ["Shift + 1", "Fit the diagram to the view"]
473
+ ];
474
+ const help = [["?", "Show or hide this list"]];
475
+ if (readonly) {
476
+ return [...navigation, ...view, ...help, ["Tab", "Leave the canvas"]];
477
+ }
478
+ const mindmap = mode === "mindmap" ? [
479
+ ["Tab", "Add a child node"],
480
+ ["Enter", "Add a sibling below (Shift + Enter: above)"],
481
+ ["F2, Space, or type", "Edit the label"],
482
+ ["Enter while editing", "Save the label (Shift + Enter: new line)"],
483
+ ["Tab while editing", "Save and add a child"]
484
+ ] : [
485
+ ["Enter or F2", "Edit the label"],
486
+ ["Cmd/Ctrl + Enter", "Save the label"]
487
+ ];
488
+ return [
489
+ ...navigation,
490
+ ...mindmap,
491
+ [
492
+ mode === "mindmap" ? "Enter, F2, or type on a connection" : "Enter or F2 on a connection",
493
+ "Edit the connection label"
494
+ ],
495
+ ["Cmd/Ctrl + /", "Collapse or expand the branch"],
496
+ ["Alt + Arrow", "Nudge the node (add Shift for 1 px)"],
497
+ ["Delete or Backspace", "Delete the selection"],
498
+ ["Cmd/Ctrl + D", "Duplicate the node"],
499
+ ["Cmd/Ctrl + Z, Shift + Cmd/Ctrl + Z", "Undo, redo"],
500
+ ["Cmd/Ctrl + C, X, V", "Copy, cut, paste"],
501
+ ...view,
502
+ ["Esc", "Cancel editing, then the tool, then deselect"],
503
+ [
504
+ mode === "mindmap" ? "Shift + Tab, or Esc then Tab" : "Tab or Shift + Tab",
505
+ "Leave the canvas"
506
+ ],
507
+ ...help
508
+ ];
509
+ }
510
+ var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update", "node:nudge"]);
311
511
  var DEFAULT_EDGE_ROUTE = "curve";
312
512
  var ORTHOGONAL_STUB_LENGTH = 32;
313
513
  var ORTHOGONAL_CORNER_RADIUS = 12;
@@ -376,6 +576,17 @@ function deepClone(value) {
376
576
  function formatNumber(value) {
377
577
  return Number(value.toFixed(2));
378
578
  }
579
+ function formatScale(value) {
580
+ return Number(toFiniteNumber(value, 1).toFixed(4));
581
+ }
582
+ function normalizeBoundPair(min, max, fallbackMin, fallbackMax) {
583
+ const low = Number(min);
584
+ const high = Number(max);
585
+ if (!Number.isFinite(low) || !Number.isFinite(high) || low <= 0 || high <= 0 || low >= high) {
586
+ return { min: fallbackMin, max: fallbackMax };
587
+ }
588
+ return { min: low, max: high };
589
+ }
379
590
  function sanitizeId(value) {
380
591
  if (value == null) return "";
381
592
  return String(value).trim();
@@ -433,21 +644,36 @@ function normalizeNodeType(type) {
433
644
  function getNodeSpec(type) {
434
645
  return DEFAULT_NODE_SPECS[normalizeNodeType(type)];
435
646
  }
436
- function getNodeSizeBounds(type) {
647
+ function nodeVisualScale(node) {
648
+ if (!node || node.type === "junction") return 1;
649
+ const spec = getNodeSpec(node.type);
650
+ const width = spec.width > 0 ? toFiniteNumber(node.width, spec.width) / spec.width : 1;
651
+ const height = spec.height > 0 ? toFiniteNumber(node.height, spec.height) / spec.height : 1;
652
+ return clamp(Math.min(1, width, height), 0.01, 1);
653
+ }
654
+ function edgeVisualScale(fromNode, toNode) {
655
+ if (fromNode && toNode) return Math.min(nodeVisualScale(fromNode), nodeVisualScale(toNode));
656
+ if (fromNode) return nodeVisualScale(fromNode);
657
+ if (toNode) return nodeVisualScale(toNode);
658
+ return 1;
659
+ }
660
+ function getNodeSizeBounds(type, sizeBounds) {
437
661
  const spec = getNodeSpec(type);
662
+ const min = sizeBounds?.min ?? DEFAULT_MIN_NODE_SIZE;
663
+ const max = sizeBounds?.max ?? DEFAULT_MAX_NODE_SIZE;
438
664
  return {
439
- minWidth: spec.minWidth ?? MIN_NODE_SIZE,
440
- minHeight: spec.minHeight ?? MIN_NODE_SIZE,
441
- maxWidth: spec.maxWidth ?? MAX_NODE_SIZE,
442
- maxHeight: spec.maxHeight ?? MAX_NODE_SIZE
665
+ minWidth: spec.minWidth ?? min,
666
+ minHeight: spec.minHeight ?? min,
667
+ maxWidth: spec.maxWidth ?? max,
668
+ maxHeight: spec.maxHeight ?? max
443
669
  };
444
670
  }
445
- function clampNodeWidth(type, width, fallback) {
446
- const bounds = getNodeSizeBounds(type);
671
+ function clampNodeWidth(type, width, fallback, sizeBounds) {
672
+ const bounds = getNodeSizeBounds(type, sizeBounds);
447
673
  return clamp(toFiniteNumber(width, fallback), bounds.minWidth, bounds.maxWidth);
448
674
  }
449
- function clampNodeHeight(type, height, fallback) {
450
- const bounds = getNodeSizeBounds(type);
675
+ function clampNodeHeight(type, height, fallback, sizeBounds) {
676
+ const bounds = getNodeSizeBounds(type, sizeBounds);
451
677
  return clamp(toFiniteNumber(height, fallback), bounds.minHeight, bounds.maxHeight);
452
678
  }
453
679
  function isNodeResizable(nodeOrType) {
@@ -471,26 +697,59 @@ function getStrokePresetWidth(presetId) {
471
697
  const preset = EDGE_STROKE_PRESETS.find((item) => item.id === presetId);
472
698
  return preset?.width ?? DEFAULT_EDGE_STROKE_WIDTH;
473
699
  }
474
- function normalizeViewport(viewport) {
700
+ function normalizeViewport(viewport, scaleBounds) {
701
+ const min = scaleBounds?.min ?? DEFAULT_MIN_SCALE;
702
+ const max = scaleBounds?.max ?? DEFAULT_MAX_SCALE;
475
703
  return {
476
704
  x: toFiniteNumber(viewport?.x, 0),
477
705
  y: toFiniteNumber(viewport?.y, 0),
478
- scale: clamp(toFiniteNumber(viewport?.scale, 1), MIN_SCALE, MAX_SCALE)
706
+ scale: clamp(formatScale(toFiniteNumber(viewport?.scale, 1)), min, max)
479
707
  };
480
708
  }
481
- function normalizeNode(rawNode, index, usedIds) {
709
+ function normalizeNode(rawNode, index, usedIds, sizeBounds) {
482
710
  const type = normalizeNodeType(rawNode?.type);
483
711
  const spec = getNodeSpec(type);
484
- return {
712
+ const node = {
485
713
  id: ensureUniqueId(rawNode?.id, "node", usedIds),
486
714
  type,
487
715
  x: toFiniteNumber(rawNode?.x, index * 28),
488
716
  y: toFiniteNumber(rawNode?.y, index * 18),
489
- width: clampNodeWidth(type, rawNode?.width, spec.width),
490
- height: clampNodeHeight(type, rawNode?.height, spec.height),
717
+ width: clampNodeWidth(type, rawNode?.width, spec.width, sizeBounds),
718
+ height: clampNodeHeight(type, rawNode?.height, spec.height, sizeBounds),
491
719
  text: rawNode?.text == null ? spec.text : String(rawNode.text),
492
720
  data: isPlainObject(rawNode?.data) ? deepClone(rawNode.data) : {}
493
721
  };
722
+ if (rawNode?.collapsed === true) node.collapsed = true;
723
+ return node;
724
+ }
725
+ function computeHiddenNodeIds(nodes, edges) {
726
+ const collapsed = new Set(nodes.filter((node) => node.collapsed).map((node) => node.id));
727
+ if (!collapsed.size) return /* @__PURE__ */ new Set();
728
+ const outgoing = new Map(nodes.map((node) => [node.id, []]));
729
+ edges.forEach((edge) => {
730
+ if (edge.from.nodeId === edge.to.nodeId) return;
731
+ outgoing.get(edge.from.nodeId)?.push(edge.to.nodeId);
732
+ });
733
+ const candidates = /* @__PURE__ */ new Set();
734
+ collapsed.forEach((rootId) => {
735
+ const queue2 = [...outgoing.get(rootId) || []];
736
+ for (let index = 0; index < queue2.length; index += 1) {
737
+ const id = queue2[index];
738
+ if (id === rootId || candidates.has(id)) continue;
739
+ candidates.add(id);
740
+ queue2.push(...outgoing.get(id) || []);
741
+ }
742
+ });
743
+ const queue = nodes.filter((node) => !candidates.has(node.id) && !collapsed.has(node.id)).map((node) => node.id);
744
+ for (let index = 0; index < queue.length; index += 1) {
745
+ const id = queue[index];
746
+ (outgoing.get(id) || []).forEach((childId) => {
747
+ if (!candidates.has(childId)) return;
748
+ candidates.delete(childId);
749
+ if (!collapsed.has(childId)) queue.push(childId);
750
+ });
751
+ }
752
+ return candidates;
494
753
  }
495
754
  function normalizeEndpoint(rawEndpoint, fallbackPort) {
496
755
  return {
@@ -549,7 +808,7 @@ function isSupportedDocumentVersion(value) {
549
808
  if (parsed.minor === max.minor && parsed.patch > max.patch) return false;
550
809
  return true;
551
810
  }
552
- function normalizeDocument(input) {
811
+ function normalizeDocument(input, limits = {}) {
553
812
  let source = input;
554
813
  if (typeof source === "string") {
555
814
  try {
@@ -572,13 +831,13 @@ function normalizeDocument(input) {
572
831
  }
573
832
  }
574
833
  const usedNodeIds = /* @__PURE__ */ new Set();
575
- const nodes = (Array.isArray(source.nodes) ? source.nodes : []).slice(0, MAX_NODES).map((node, index) => normalizeNode(node, index, usedNodeIds));
834
+ const nodes = (Array.isArray(source.nodes) ? source.nodes : []).slice(0, MAX_NODES).map((node, index) => normalizeNode(node, index, usedNodeIds, limits.sizeBounds));
576
835
  const nodeIds = new Set(nodes.map((node) => node.id));
577
836
  const usedEdgeIds = /* @__PURE__ */ new Set();
578
837
  const edges = (Array.isArray(source.edges) ? source.edges : []).slice(0, MAX_EDGES).map((edge, index) => normalizeEdge(edge, index, nodeIds, usedEdgeIds)).filter(Boolean);
579
838
  return {
580
839
  version: FLOWCHART_DOCUMENT_VERSION,
581
- viewport: normalizeViewport(source.viewport),
840
+ viewport: normalizeViewport(source.viewport, limits.scaleBounds),
582
841
  nodes,
583
842
  edges
584
843
  };
@@ -643,6 +902,15 @@ function getPortPosition(node, port) {
643
902
  return { x: node.x, y: node.y + node.height / 2 };
644
903
  }
645
904
  }
905
+ function getPortHandlePosition(node, port, scale = 1) {
906
+ const point = getPortPosition(node, port);
907
+ const normal = getPortNormal(port);
908
+ const offset = PORT_HANDLE_OFFSET / scale;
909
+ return {
910
+ x: formatNumber(point.x + normal.x * offset),
911
+ y: formatNumber(point.y + normal.y * offset)
912
+ };
913
+ }
646
914
  function getNearestPort(node, point) {
647
915
  return FLOWCHART_PORTS.reduce((best, port) => {
648
916
  const portPoint = getPortPosition(node, port);
@@ -786,19 +1054,30 @@ function getPolylineLabelPoint(points) {
786
1054
  const last = points[points.length - 1] || { x: 0, y: 0 };
787
1055
  return { x: formatNumber(last.x), y: formatNumber(last.y) };
788
1056
  }
789
- function curveControlPoint(point, port, target, curvature) {
1057
+ function routeMetrics(edgeScale = 1) {
1058
+ const scale = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
1059
+ return {
1060
+ scale,
1061
+ minCurveArm: MIN_CURVE_ARM * scale,
1062
+ maxCurveArm: MAX_CURVE_ARM * scale,
1063
+ stub: ORTHOGONAL_STUB_LENGTH * scale,
1064
+ corner: ORTHOGONAL_CORNER_RADIUS * scale,
1065
+ clearance: ORTHOGONAL_CLEARANCE * scale
1066
+ };
1067
+ }
1068
+ function curveControlPoint(point, port, target, curvature, metrics = routeMetrics(1)) {
790
1069
  const normal = getPortNormal(port);
791
1070
  const dx = target.x - point.x;
792
1071
  const dy = target.y - point.y;
793
1072
  const distance = Math.hypot(dx, dy) || 1;
794
1073
  const align = (normal.x * dx + normal.y * dy) / distance;
795
1074
  const ease = 0.55 + 0.45 * align;
796
- const arm = clamp(distance * curvature * ease, MIN_CURVE_ARM, MAX_CURVE_ARM);
1075
+ const arm = clamp(distance * curvature * ease, metrics.minCurveArm, metrics.maxCurveArm);
797
1076
  return { x: point.x + normal.x * arm, y: point.y + normal.y * arm };
798
1077
  }
799
- function buildCurvePath(fromPoint, toPoint, fromPort = "right", toPort = "left") {
800
- const controlA = curveControlPoint(fromPoint, fromPort, toPoint, EDGE_CURVATURE);
801
- const controlB = curveControlPoint(toPoint, toPort, fromPoint, EDGE_CURVATURE);
1078
+ function buildCurvePath(fromPoint, toPoint, fromPort = "right", toPort = "left", metrics = routeMetrics(1)) {
1079
+ const controlA = curveControlPoint(fromPoint, fromPort, toPoint, EDGE_CURVATURE, metrics);
1080
+ const controlB = curveControlPoint(toPoint, toPort, fromPoint, EDGE_CURVATURE, metrics);
802
1081
  const labelX = 0.125 * fromPoint.x + 0.375 * controlA.x + 0.375 * controlB.x + 0.125 * toPoint.x;
803
1082
  const labelY = 0.125 * fromPoint.y + 0.375 * controlA.y + 0.375 * controlB.y + 0.125 * toPoint.y;
804
1083
  return {
@@ -860,11 +1139,11 @@ function getOrthogonalMidpoints(fromStub, toStub, fromPort, toPort) {
860
1139
  }
861
1140
  return [{ x: fromStub.x, y: toStub.y }];
862
1141
  }
863
- function getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect) {
1142
+ function getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect, metrics = routeMetrics(1)) {
864
1143
  const fromNormal = getPortNormal(fromPort);
865
1144
  const toNormal = getPortNormal(toPort);
866
- const fromStub = offsetPoint(fromPoint, fromNormal, ORTHOGONAL_STUB_LENGTH);
867
- const toStub = offsetPoint(toPoint, toNormal, ORTHOGONAL_STUB_LENGTH);
1145
+ const fromStub = offsetPoint(fromPoint, fromNormal, metrics.stub);
1146
+ const toStub = offsetPoint(toPoint, toNormal, metrics.stub);
868
1147
  const sourceHorizontal = isHorizontalPort(fromPort);
869
1148
  const targetHorizontal = isHorizontalPort(toPort);
870
1149
  const mids = [];
@@ -904,13 +1183,13 @@ function getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toR
904
1183
  );
905
1184
  return points;
906
1185
  }
907
- function buildOrthogonalPath(fromPoint, toPoint, fromPort = "right", toPort = "left", fromRect = null, toRect = null) {
1186
+ function buildOrthogonalPath(fromPoint, toPoint, fromPort = "right", toPort = "left", fromRect = null, toRect = null, metrics = routeMetrics(1)) {
908
1187
  let points;
909
1188
  if (fromRect && toRect) {
910
- points = getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect);
1189
+ points = getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect, metrics);
911
1190
  } else {
912
- const fromStub = offsetPoint(fromPoint, getPortNormal(fromPort), ORTHOGONAL_STUB_LENGTH);
913
- const toStub = offsetPoint(toPoint, getPortNormal(toPort), ORTHOGONAL_STUB_LENGTH);
1191
+ const fromStub = offsetPoint(fromPoint, getPortNormal(fromPort), metrics.stub);
1192
+ const toStub = offsetPoint(toPoint, getPortNormal(toPort), metrics.stub);
914
1193
  points = [];
915
1194
  [
916
1195
  fromPoint,
@@ -922,7 +1201,7 @@ function buildOrthogonalPath(fromPoint, toPoint, fromPort = "right", toPort = "l
922
1201
  }
923
1202
  const label = getPolylineLabelPoint(points);
924
1203
  return {
925
- d: buildRoundedPath(points, ORTHOGONAL_CORNER_RADIUS),
1204
+ d: buildRoundedPath(points, metrics.corner),
926
1205
  labelX: label.x,
927
1206
  labelY: label.y
928
1207
  };
@@ -933,6 +1212,7 @@ function buildEdgePath(edge, fromNode = null, toNode = null) {
933
1212
  const fromPoint = edge?.fromPoint || (fromNode ? getPortPosition(fromNode, fromPort) : null);
934
1213
  const toPoint = edge?.toPoint || (toNode ? getPortPosition(toNode, toPort) : null);
935
1214
  const route = normalizeEdgeRoute(edge?.route);
1215
+ const metrics = routeMetrics(edgeVisualScale(fromNode, toNode));
936
1216
  if (!fromPoint || !toPoint) {
937
1217
  return { d: "", labelX: 0, labelY: 0 };
938
1218
  }
@@ -940,15 +1220,17 @@ function buildEdgePath(edge, fromNode = null, toNode = null) {
940
1220
  return buildStraightPath(fromPoint, toPoint);
941
1221
  }
942
1222
  if (route === "orthogonal") {
943
- const fromRect = !edge?.fromPoint && fromNode ? getNodeRectBounds(fromNode, ORTHOGONAL_CLEARANCE) : null;
944
- const toRect = !edge?.toPoint && toNode ? getNodeRectBounds(toNode, ORTHOGONAL_CLEARANCE) : null;
945
- return buildOrthogonalPath(fromPoint, toPoint, fromPort, toPort, fromRect, toRect);
1223
+ const fromRect = !edge?.fromPoint && fromNode ? getNodeRectBounds(fromNode, metrics.clearance) : null;
1224
+ const toRect = !edge?.toPoint && toNode ? getNodeRectBounds(toNode, metrics.clearance) : null;
1225
+ return buildOrthogonalPath(fromPoint, toPoint, fromPort, toPort, fromRect, toRect, metrics);
946
1226
  }
947
- return buildCurvePath(fromPoint, toPoint, fromPort, toPort);
1227
+ return buildCurvePath(fromPoint, toPoint, fromPort, toPort, metrics);
948
1228
  }
949
- function createArrowMarker(id, strokeWidth, reversed = false) {
950
- const size = formatNumber(Math.max(9, strokeWidth * 4.4));
951
- const refInset = formatNumber(Math.max(1.5, strokeWidth * 0.85));
1229
+ function createArrowMarker(id, strokeWidth, reversed = false, edgeScale = 1) {
1230
+ const scale = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
1231
+ const effective = Math.max(0.02, strokeWidth * scale);
1232
+ const size = formatNumber(Math.max(9 * scale, effective * 4.4));
1233
+ const refInset = formatNumber(Math.max(1.5 * scale, effective * 0.85));
952
1234
  const marker = svgEl("marker", {
953
1235
  id,
954
1236
  markerWidth: size,
@@ -966,9 +1248,11 @@ function createArrowMarker(id, strokeWidth, reversed = false) {
966
1248
  );
967
1249
  return marker;
968
1250
  }
969
- function createDotMarker(id, strokeWidth) {
970
- const size = formatNumber(Math.max(8, strokeWidth * 3.3));
971
- const radius = formatNumber(Math.max(2.75, strokeWidth * 1.45));
1251
+ function createDotMarker(id, strokeWidth, edgeScale = 1) {
1252
+ const scale = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
1253
+ const effective = Math.max(0.02, strokeWidth * scale);
1254
+ const size = formatNumber(Math.max(8 * scale, effective * 3.3));
1255
+ const radius = formatNumber(Math.max(2.75 * scale, effective * 1.45));
972
1256
  const marker = svgEl("marker", {
973
1257
  id,
974
1258
  markerWidth: size,
@@ -1010,6 +1294,68 @@ function getResizeCursor(handle) {
1010
1294
  if (handle === "ne" || handle === "sw") return "nesw-resize";
1011
1295
  return "nwse-resize";
1012
1296
  }
1297
+ function collectGuideLines(nodes) {
1298
+ const x = [];
1299
+ const y = [];
1300
+ nodes.forEach((node) => {
1301
+ const right = node.x + node.width;
1302
+ const bottom = node.y + node.height;
1303
+ [node.x, node.x + node.width / 2, right].forEach(
1304
+ (value) => x.push({ value, start: node.y, end: bottom })
1305
+ );
1306
+ [node.y, node.y + node.height / 2, bottom].forEach(
1307
+ (value) => y.push({ value, start: node.x, end: right })
1308
+ );
1309
+ });
1310
+ const byValue = (a, b) => a.value - b.value;
1311
+ return { x: x.sort(byValue), y: y.sort(byValue) };
1312
+ }
1313
+ function lowerBound(lines, value) {
1314
+ let low = 0;
1315
+ let high = lines.length;
1316
+ while (low < high) {
1317
+ const middle = low + high >> 1;
1318
+ if (lines[middle].value < value) low = middle + 1;
1319
+ else high = middle;
1320
+ }
1321
+ return low;
1322
+ }
1323
+ function snapToGuides(lines, candidates, threshold) {
1324
+ let best = null;
1325
+ candidates.forEach((candidate) => {
1326
+ const index = lowerBound(lines, candidate);
1327
+ [lines[index - 1], lines[index]].forEach((line) => {
1328
+ if (!line) return;
1329
+ const offset = line.value - candidate;
1330
+ if (Math.abs(offset) <= threshold && (!best || Math.abs(offset) < Math.abs(best.offset))) {
1331
+ best = { offset };
1332
+ }
1333
+ });
1334
+ });
1335
+ if (!best) return null;
1336
+ const positions = candidates.map((candidate) => candidate + best.offset);
1337
+ best.lines = lines.filter(
1338
+ (line) => positions.some((position) => Math.abs(line.value - position) < 0.5)
1339
+ );
1340
+ return best;
1341
+ }
1342
+ function guideSegments(axis, lines, box) {
1343
+ const byValue = /* @__PURE__ */ new Map();
1344
+ lines.forEach((line) => {
1345
+ const current = byValue.get(line.value);
1346
+ byValue.set(line.value, {
1347
+ start: Math.min(current?.start ?? Infinity, line.start),
1348
+ end: Math.max(current?.end ?? -Infinity, line.end)
1349
+ });
1350
+ });
1351
+ const boxStart = axis === "x" ? box.top : box.left;
1352
+ const boxEnd = boxStart + (axis === "x" ? box.height : box.width);
1353
+ return [...byValue].map(([value, extent]) => {
1354
+ const start = Math.min(extent.start, boxStart) - GUIDE_OVERHANG;
1355
+ const end = Math.max(extent.end, boxEnd) + GUIDE_OVERHANG;
1356
+ return axis === "x" ? { x1: value, y1: start, x2: value, y2: end } : { x1: start, y1: value, x2: end, y2: value };
1357
+ });
1358
+ }
1013
1359
  function getBounds(nodes) {
1014
1360
  if (!nodes.length) {
1015
1361
  return { left: 0, top: 0, right: 0, bottom: 0 };
@@ -1163,9 +1509,26 @@ var VdFlowchart = class {
1163
1509
  this.element = resolveElement(options.element || options.target);
1164
1510
  this.readonly = Boolean(options.readonly);
1165
1511
  this.gridSize = clamp(toFiniteNumber(options.gridSize, DEFAULT_GRID_SIZE), 12, 64);
1166
- this.documentData = normalizeDocument(options.data || {});
1512
+ const scaleBounds = normalizeBoundPair(
1513
+ options.minScale,
1514
+ options.maxScale,
1515
+ DEFAULT_MIN_SCALE,
1516
+ DEFAULT_MAX_SCALE
1517
+ );
1518
+ const sizeBounds = normalizeBoundPair(
1519
+ options.minNodeSize,
1520
+ options.maxNodeSize,
1521
+ DEFAULT_MIN_NODE_SIZE,
1522
+ DEFAULT_MAX_NODE_SIZE
1523
+ );
1524
+ this.minScale = scaleBounds.min;
1525
+ this.maxScale = scaleBounds.max;
1526
+ this.minNodeSize = sizeBounds.min;
1527
+ this.maxNodeSize = sizeBounds.max;
1528
+ this.documentData = normalizeDocument(options.data || {}, { scaleBounds, sizeBounds });
1167
1529
  this.listeners = {};
1168
1530
  this.selection = null;
1531
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
1169
1532
  this.interaction = null;
1170
1533
  this.activeTool = null;
1171
1534
  this.layoutMode = "tree";
@@ -1173,6 +1536,7 @@ var VdFlowchart = class {
1173
1536
  this.destroyed = false;
1174
1537
  this.lastNodePointer = null;
1175
1538
  this.reconnectEdgeId = null;
1539
+ this.edgeCycleAnchor = null;
1176
1540
  this.clipboard = null;
1177
1541
  this.gridPatternId = nextId("flowchart-grid");
1178
1542
  this.markerIds = /* @__PURE__ */ new Map();
@@ -1182,6 +1546,16 @@ var VdFlowchart = class {
1182
1546
  this.history = [];
1183
1547
  this.historyIndex = -1;
1184
1548
  this.isApplyingHistory = false;
1549
+ this.historyBatchDepth = 0;
1550
+ this.historyBatchDirty = false;
1551
+ this.coalesceInsertedText = false;
1552
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
1553
+ this.snapGuides = options.snapGuides !== false;
1554
+ this.minimapEnabled = options.minimap !== false;
1555
+ this.autoLayout = Boolean(options.autoLayout);
1556
+ this.minimapFrame = 0;
1557
+ this.minimapDrag = null;
1558
+ this.shortcutsHelpOpen = false;
1185
1559
  this.autoFit = Boolean(options.autoFit);
1186
1560
  this.readyEmitted = false;
1187
1561
  this.resizeObserver = null;
@@ -1222,6 +1596,7 @@ var VdFlowchart = class {
1222
1596
  if (!ready) return false;
1223
1597
  this.readyEmitted = true;
1224
1598
  if (this.autoFit) this.fitView();
1599
+ else this.scheduleMinimap();
1225
1600
  this.emit("ready", this);
1226
1601
  return true;
1227
1602
  };
@@ -1291,8 +1666,25 @@ var VdFlowchart = class {
1291
1666
  className: "vd-flowchart-toolbar-label",
1292
1667
  text: "100%"
1293
1668
  });
1669
+ this.shortcutsButton = createElement("button", {
1670
+ className: "vd-flowchart-btn vd-flowchart-icon-btn vd-flowchart-shortcuts-btn",
1671
+ title: "Keyboard shortcuts (?)",
1672
+ text: "?"
1673
+ });
1674
+ this.shortcutsButton.setAttribute("type", "button");
1675
+ this.shortcutsButton.setAttribute("data-flowchart-action", "shortcuts");
1676
+ this.shortcutsButton.setAttribute("aria-label", "Keyboard shortcuts");
1677
+ this.shortcutsButton.setAttribute("aria-expanded", "false");
1678
+ this.minimapButton = createToolbarButton({
1679
+ action: "minimap",
1680
+ label: "Toggle minimap",
1681
+ icon: "minimap"
1682
+ });
1683
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
1294
1684
  toolbarLeft.appendChild(this.arrangeSelect);
1295
1685
  toolbarLeft.appendChild(this.clearButton);
1686
+ toolbarRight.appendChild(this.minimapButton);
1687
+ toolbarRight.appendChild(this.shortcutsButton);
1296
1688
  toolbarRight.appendChild(this.zoomLabel);
1297
1689
  this.toolbar.appendChild(toolbarLeft);
1298
1690
  this.toolbar.appendChild(toolbarRight);
@@ -1361,23 +1753,50 @@ var VdFlowchart = class {
1361
1753
  this.world = svgEl("g", { class: "vd-flowchart-world" });
1362
1754
  this.gridRect = svgEl("rect", {
1363
1755
  class: "vd-flowchart-grid",
1364
- x: -WORLD_EXTENT / 2,
1365
- y: -WORLD_EXTENT / 2,
1366
- width: WORLD_EXTENT,
1367
- height: WORLD_EXTENT,
1756
+ x: -DEFAULT_WORLD_EXTENT / 2,
1757
+ y: -DEFAULT_WORLD_EXTENT / 2,
1758
+ width: DEFAULT_WORLD_EXTENT,
1759
+ height: DEFAULT_WORLD_EXTENT,
1368
1760
  fill: `url(#${this.gridPatternId})`
1369
1761
  });
1370
1762
  this.edgesLayer = svgEl("g", { class: "vd-flowchart-edges" });
1371
1763
  this.previewLayer = svgEl("g", { class: "vd-flowchart-preview" });
1372
1764
  this.nodesLayer = svgEl("g", { class: "vd-flowchart-nodes" });
1373
1765
  this.overlayLayer = svgEl("g", { class: "vd-flowchart-overlay" });
1766
+ this.guidesLayer = svgEl("g", { class: "vd-flowchart-guides" });
1374
1767
  this.world.appendChild(this.gridRect);
1375
1768
  this.world.appendChild(this.edgesLayer);
1376
1769
  this.world.appendChild(this.previewLayer);
1377
1770
  this.world.appendChild(this.nodesLayer);
1378
1771
  this.world.appendChild(this.overlayLayer);
1772
+ this.world.appendChild(this.guidesLayer);
1379
1773
  this.svg.appendChild(this.world);
1380
1774
  this.canvasEl.appendChild(this.svg);
1775
+ this.shortcutsHelp = createElement("div", { className: "vd-flowchart-shortcuts" });
1776
+ this.shortcutsHelp.id = nextId("flowchart-shortcuts");
1777
+ this.shortcutsHelp.setAttribute("role", "dialog");
1778
+ this.shortcutsHelp.setAttribute("aria-label", "Keyboard shortcuts");
1779
+ this.shortcutsHelp.hidden = true;
1780
+ this.shortcutsHelp.addEventListener("pointerdown", (event) => event.stopPropagation());
1781
+ this.shortcutsHelp.addEventListener("wheel", (event) => event.stopPropagation());
1782
+ this.shortcutsButton.setAttribute("aria-controls", this.shortcutsHelp.id);
1783
+ this.canvasEl.appendChild(this.shortcutsHelp);
1784
+ this.minimapEl = createElement("div", { className: "vd-flowchart-minimap" });
1785
+ this.minimapEl.setAttribute("aria-hidden", "true");
1786
+ this.minimapEl.hidden = true;
1787
+ this.minimapSvg = svgEl("svg", {
1788
+ class: "vd-flowchart-minimap-svg",
1789
+ width: MINIMAP_WIDTH,
1790
+ height: MINIMAP_HEIGHT,
1791
+ viewBox: `0 0 ${MINIMAP_WIDTH} ${MINIMAP_HEIGHT}`
1792
+ });
1793
+ this.minimapEl.appendChild(this.minimapSvg);
1794
+ this.minimapEl.addEventListener("pointerdown", (event) => this.handleMinimapPointer(event));
1795
+ this.minimapEl.addEventListener("pointermove", (event) => this.handleMinimapPointer(event));
1796
+ this.minimapEl.addEventListener("pointerup", (event) => this.endMinimapDrag(event));
1797
+ this.minimapEl.addEventListener("pointercancel", (event) => this.endMinimapDrag(event));
1798
+ this.minimapEl.addEventListener("wheel", (event) => event.stopPropagation());
1799
+ this.canvasEl.appendChild(this.minimapEl);
1381
1800
  this.inspectorPanel = createElement("aside", {
1382
1801
  className: "vd-flowchart-panel vd-flowchart-panel--inspector"
1383
1802
  });
@@ -1485,11 +1904,15 @@ var VdFlowchart = class {
1485
1904
  this.graphOutline.appendChild(this.graphList);
1486
1905
  this.root.appendChild(this.graphOutline);
1487
1906
  this.canvasEl.setAttribute("role", "group");
1907
+ this.syncCanvasLabel();
1908
+ this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1909
+ }
1910
+ syncCanvasLabel() {
1911
+ const keys = this.readonly || this.keyboardShortcuts === "basic" ? "Arrow keys select nodes; Enter or F2 edits a label." : "Arrow keys select nodes; Tab adds a child, Enter adds a sibling, F2 edits a label.";
1488
1912
  this.canvasEl.setAttribute(
1489
1913
  "aria-label",
1490
- "Diagram canvas. Arrow keys select nodes; Enter edits a label. Graph outline provides connections."
1914
+ `Diagram canvas. ${keys} Press ? for all shortcuts. Graph outline provides connections.`
1491
1915
  );
1492
- this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1493
1916
  }
1494
1917
  renderGraphOutline() {
1495
1918
  if (!this.graphOutline) return;
@@ -1499,8 +1922,9 @@ var VdFlowchart = class {
1499
1922
  const selected = this.selection?.kind === "node" ? this.selection.id : null;
1500
1923
  this.graphStatus.textContent = selected ? `Selected: ${labels.get(selected)}. ${nodes.length} nodes, ${edges.length} connections.` : `${nodes.length} nodes, ${edges.length} connections.`;
1501
1924
  if (!this.graphOutline.open) return;
1925
+ const hidden = this.getHiddenNodeIds();
1502
1926
  const signature = JSON.stringify([
1503
- nodes.map((node) => [node.id, node.text]),
1927
+ nodes.map((node) => [node.id, node.text, Boolean(node.collapsed), hidden.has(node.id)]),
1504
1928
  edges.map((edge) => [edge.id, edge.from.nodeId, edge.to.nodeId, edge.label])
1505
1929
  ]);
1506
1930
  if (signature !== this.graphSignature) {
@@ -1526,8 +1950,10 @@ var VdFlowchart = class {
1526
1950
  const from = incoming.get(node.id);
1527
1951
  const relationships = [
1528
1952
  to.length ? `Connects to ${to.join(", ")}.` : "No outgoing connections.",
1529
- from.length ? `Connected from ${from.join(", ")}.` : ""
1530
- ];
1953
+ from.length ? `Connected from ${from.join(", ")}.` : "",
1954
+ node.collapsed ? "Branch collapsed." : "",
1955
+ hidden.has(node.id) ? "Hidden in a collapsed branch." : ""
1956
+ ].filter(Boolean);
1531
1957
  this.graphList.appendChild(
1532
1958
  createElement("li", { text: `${labels.get(node.id)}. ${relationships.join(" ")}` })
1533
1959
  );
@@ -1612,6 +2038,157 @@ var VdFlowchart = class {
1612
2038
  if (action === "undo") this.undo();
1613
2039
  if (action === "redo") this.redo();
1614
2040
  if (action === "clear" && !this.readonly) this.clear();
2041
+ if (action === "shortcuts") this.toggleShortcutsHelp();
2042
+ if (action === "minimap") this.setMinimapEnabled(!this.minimapEnabled);
2043
+ }
2044
+ setMinimapEnabled(enabled) {
2045
+ this.minimapEnabled = Boolean(enabled);
2046
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
2047
+ this.drawMinimap();
2048
+ return this;
2049
+ }
2050
+ scheduleMinimap() {
2051
+ if (this.minimapFrame || !hasWindow()) return;
2052
+ const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => setTimeout(callback, 16);
2053
+ this.minimapFrame = raf(() => {
2054
+ this.minimapFrame = 0;
2055
+ this.drawMinimap();
2056
+ });
2057
+ }
2058
+ // World-to-minimap mapping covering every visible node and the viewport.
2059
+ getMinimapTransform() {
2060
+ const viewport = this.documentData.viewport;
2061
+ const width = this.canvasEl.clientWidth;
2062
+ const height = this.canvasEl.clientHeight;
2063
+ const view = {
2064
+ left: -viewport.x / viewport.scale,
2065
+ top: -viewport.y / viewport.scale,
2066
+ right: (width - viewport.x) / viewport.scale,
2067
+ bottom: (height - viewport.y) / viewport.scale
2068
+ };
2069
+ const content = getBounds(this.getVisibleNodes());
2070
+ const left = Math.min(content.left, view.left);
2071
+ const top = Math.min(content.top, view.top);
2072
+ const spanX = Math.max(1, Math.max(content.right, view.right) - left);
2073
+ const spanY = Math.max(1, Math.max(content.bottom, view.bottom) - top);
2074
+ const padding = 8;
2075
+ const scale = Math.min(
2076
+ (MINIMAP_WIDTH - padding * 2) / spanX,
2077
+ (MINIMAP_HEIGHT - padding * 2) / spanY
2078
+ );
2079
+ return {
2080
+ scale,
2081
+ offsetX: (MINIMAP_WIDTH - spanX * scale) / 2 - left * scale,
2082
+ offsetY: (MINIMAP_HEIGHT - spanY * scale) / 2 - top * scale,
2083
+ view
2084
+ };
2085
+ }
2086
+ drawMinimap() {
2087
+ if (this.destroyed || !this.minimapEl) return;
2088
+ const nodes = this.getVisibleNodes();
2089
+ const visible = this.minimapEnabled && nodes.length > 0 && this.canvasEl.clientWidth >= MINIMAP_MIN_CANVAS_WIDTH;
2090
+ this.minimapEl.hidden = !visible;
2091
+ if (!visible) return;
2092
+ const transform = this.minimapDrag?.transform || this.getMinimapTransform();
2093
+ const view = this.getMinimapTransform().view;
2094
+ const map = (x, y) => ({
2095
+ x: formatNumber(x * transform.scale + transform.offsetX),
2096
+ y: formatNumber(y * transform.scale + transform.offsetY)
2097
+ });
2098
+ clearChildren(this.minimapSvg);
2099
+ nodes.forEach((node) => {
2100
+ const point = map(node.x, node.y);
2101
+ const selected = this.isNodeSelected(node.id);
2102
+ this.minimapSvg.appendChild(
2103
+ svgEl("rect", {
2104
+ class: `vd-flowchart-minimap-node${selected ? " is-selected" : ""}`,
2105
+ x: point.x,
2106
+ y: point.y,
2107
+ width: formatNumber(Math.max(1.5, node.width * transform.scale)),
2108
+ height: formatNumber(Math.max(1.5, node.height * transform.scale)),
2109
+ rx: 1.5
2110
+ })
2111
+ );
2112
+ });
2113
+ const corner = map(view.left, view.top);
2114
+ this.minimapSvg.appendChild(
2115
+ svgEl("rect", {
2116
+ class: "vd-flowchart-minimap-viewport",
2117
+ x: corner.x,
2118
+ y: corner.y,
2119
+ width: formatNumber((view.right - view.left) * transform.scale),
2120
+ height: formatNumber((view.bottom - view.top) * transform.scale)
2121
+ })
2122
+ );
2123
+ }
2124
+ // Centre the main view on the world point under the minimap pointer. The
2125
+ // mapping is frozen for the whole drag so the map does not shift under it.
2126
+ handleMinimapPointer(event) {
2127
+ event.stopPropagation();
2128
+ if (event.type === "pointerdown") {
2129
+ if (event.button != null && event.button !== 0) return;
2130
+ event.preventDefault();
2131
+ this.minimapDrag = { pointerId: event.pointerId, transform: this.getMinimapTransform() };
2132
+ try {
2133
+ this.minimapEl.setPointerCapture?.(event.pointerId);
2134
+ } catch {
2135
+ }
2136
+ }
2137
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2138
+ const rect = this.minimapEl.getBoundingClientRect();
2139
+ const { transform } = this.minimapDrag;
2140
+ this.centerViewOn(
2141
+ (event.clientX - rect.left - transform.offsetX) / transform.scale,
2142
+ (event.clientY - rect.top - transform.offsetY) / transform.scale
2143
+ );
2144
+ }
2145
+ endMinimapDrag(event) {
2146
+ event.stopPropagation();
2147
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2148
+ this.minimapDrag = null;
2149
+ this.syncJsonTextarea();
2150
+ this.emitViewportChange("viewport:pan");
2151
+ this.drawMinimap();
2152
+ }
2153
+ centerViewOn(worldX, worldY) {
2154
+ const viewport = this.documentData.viewport;
2155
+ const width = this.canvasEl.clientWidth || 800;
2156
+ const height = this.canvasEl.clientHeight || 560;
2157
+ viewport.x = formatNumber(width / 2 - worldX * viewport.scale);
2158
+ viewport.y = formatNumber(height / 2 - worldY * viewport.scale);
2159
+ this.render({ inspector: false, json: false });
2160
+ return this;
2161
+ }
2162
+ toggleShortcutsHelp(force) {
2163
+ const open = typeof force === "boolean" ? force : !this.shortcutsHelpOpen;
2164
+ this.shortcutsHelpOpen = open;
2165
+ this.shortcutsButton.setAttribute("aria-expanded", open ? "true" : "false");
2166
+ if (open) this.renderShortcutsHelp();
2167
+ this.shortcutsHelp.hidden = !open;
2168
+ if (!open && this.shortcutsHelp.contains(document.activeElement)) this.canvasEl.focus();
2169
+ return this;
2170
+ }
2171
+ renderShortcutsHelp() {
2172
+ clearChildren(this.shortcutsHelp);
2173
+ const header = createElement("div", { className: "vd-flowchart-shortcuts-header" });
2174
+ header.appendChild(
2175
+ createElement("h4", { className: "vd-flowchart-panel-title", text: "Keyboard shortcuts" })
2176
+ );
2177
+ const close = createElement("button", {
2178
+ className: "vd-flowchart-btn vd-flowchart-icon-btn",
2179
+ text: "\xD7"
2180
+ });
2181
+ close.setAttribute("type", "button");
2182
+ close.setAttribute("aria-label", "Close keyboard shortcuts");
2183
+ close.addEventListener("click", () => this.toggleShortcutsHelp(false));
2184
+ header.appendChild(close);
2185
+ this.shortcutsHelp.appendChild(header);
2186
+ const list = createElement("dl", { className: "vd-flowchart-shortcuts-list" });
2187
+ getShortcutRows(this.keyboardShortcuts, this.readonly).forEach(([keys, action]) => {
2188
+ list.appendChild(createElement("dt", { text: keys }));
2189
+ list.appendChild(createElement("dd", { text: action }));
2190
+ });
2191
+ this.shortcutsHelp.appendChild(list);
1615
2192
  }
1616
2193
  handleArrangeChange(event) {
1617
2194
  if (this.readonly) return;
@@ -1720,38 +2297,48 @@ var VdFlowchart = class {
1720
2297
  );
1721
2298
  }
1722
2299
  }
2300
+ // Keys the editor consumes stop here, so page-level shortcuts (a docs
2301
+ // site's "/" search, a host's Escape handler) do not also fire.
1723
2302
  handleKeyDown(event) {
1724
- if (event.target && (event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA" || event.target.tagName === "SELECT")) {
2303
+ const alreadyHandled = event.defaultPrevented;
2304
+ this.handleEditorKey(event);
2305
+ if (!alreadyHandled && event.defaultPrevented) event.stopPropagation();
2306
+ }
2307
+ handleEditorKey(event) {
2308
+ const target = event.target;
2309
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) {
1725
2310
  return;
1726
2311
  }
1727
- if (event.target === this.canvasEl) {
1728
- const nodes = this.documentData.nodes;
1729
- const index = nodes.findIndex((node) => node.id === this.selection?.id);
1730
- let next = -1;
1731
- if (event.key === "ArrowDown" || event.key === "ArrowRight")
1732
- next = (index + 1) % nodes.length;
1733
- if (event.key === "ArrowUp" || event.key === "ArrowLeft")
1734
- next = index < 0 ? nodes.length - 1 : (index - 1 + nodes.length) % nodes.length;
1735
- if (event.key === "Home") next = 0;
1736
- if (event.key === "End") next = nodes.length - 1;
1737
- if (nodes[next]) {
1738
- event.preventDefault();
1739
- this.selectNode(nodes[next].id);
1740
- return;
1741
- }
1742
- if (event.key === "Enter" && !this.readonly && this.selection?.kind === "node") {
1743
- event.preventDefault();
1744
- this.startTextEdit(this.selection.id);
1745
- return;
1746
- }
2312
+ if (event.isComposing) return;
2313
+ const key = event.key;
2314
+ const modKey = event.metaKey || event.ctrlKey;
2315
+ if (key === "Escape" && this.shortcutsHelpOpen) {
2316
+ event.preventDefault();
2317
+ this.toggleShortcutsHelp(false);
2318
+ return;
2319
+ }
2320
+ if (target === this.canvasEl && this.handleCanvasKeyDown(event)) {
2321
+ event.preventDefault();
2322
+ return;
1747
2323
  }
1748
2324
  if (this.readonly) return;
1749
- if (event.key === "Escape" && this.activeTool) {
2325
+ if (key === "Escape") {
2326
+ if (this.activeTool) this.setActiveTool(null);
2327
+ else if (this.reconnectEdgeId) {
2328
+ this.reconnectEdgeId = null;
2329
+ this.render({ inspector: false, json: false });
2330
+ } else if (this.selection) this.select(null);
2331
+ else return;
1750
2332
  event.preventDefault();
1751
- this.setActiveTool(null);
1752
2333
  return;
1753
2334
  }
1754
- const modKey = event.metaKey || event.ctrlKey;
2335
+ if (modKey && (key === "d" || key === "D")) {
2336
+ if (this.selection?.kind === "node") {
2337
+ event.preventDefault();
2338
+ this.duplicateSelection();
2339
+ }
2340
+ return;
2341
+ }
1755
2342
  if (modKey && (event.key === "z" || event.key === "Z")) {
1756
2343
  event.preventDefault();
1757
2344
  if (event.shiftKey) {
@@ -1790,11 +2377,250 @@ var VdFlowchart = class {
1790
2377
  if (!this.selection) return;
1791
2378
  if (event.key === "Backspace" || event.key === "Delete") {
1792
2379
  event.preventDefault();
2380
+ if (this.selection.kind === "node" && !this.hasMultiSelection()) {
2381
+ const removedId = this.selection.id;
2382
+ const parent = this.getParentNode(removedId);
2383
+ const sibling = parent ? this.getChildNodes(parent.id).find((node) => node.id !== removedId) : null;
2384
+ if (this.deleteSelection()) {
2385
+ const next = parent && this.findNode(parent.id) || sibling;
2386
+ if (next) this.select({ kind: "node", id: next.id });
2387
+ }
2388
+ return;
2389
+ }
1793
2390
  this.deleteSelection();
1794
2391
  }
1795
2392
  }
2393
+ // Keys that only apply while the canvas itself has focus. Returns true when
2394
+ // the key was handled (the caller prevents the default action).
2395
+ handleCanvasKeyDown(event) {
2396
+ const key = event.key;
2397
+ const modKey = event.metaKey || event.ctrlKey;
2398
+ const mindmap = this.keyboardShortcuts === "mindmap";
2399
+ const selectedNode = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2400
+ const selectedEdge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2401
+ const movable = !this.readonly && Boolean(selectedNode);
2402
+ const editable = movable && !this.hasMultiSelection();
2403
+ if (key === "?" && !modKey && !event.altKey) {
2404
+ this.toggleShortcutsHelp();
2405
+ return true;
2406
+ }
2407
+ if (modKey && !event.altKey) {
2408
+ if (key === "=" || key === "+") return Boolean(this.zoomIn());
2409
+ if (key === "-" || key === "_") return Boolean(this.zoomOut());
2410
+ if (key === "0") return Boolean(this.zoomTo(1));
2411
+ if (key === "a" || key === "A") {
2412
+ this.selectNodes(this.getVisibleNodes().map((node) => node.id));
2413
+ return true;
2414
+ }
2415
+ if (key === "/" && selectedNode && !this.hasMultiSelection()) {
2416
+ this.toggleCollapsed(selectedNode.id);
2417
+ return true;
2418
+ }
2419
+ return false;
2420
+ }
2421
+ if (event.shiftKey && !event.altKey && (key === "!" || event.code === "Digit1")) {
2422
+ return Boolean(this.fitView());
2423
+ }
2424
+ const direction = ARROW_DIRECTIONS[key];
2425
+ if (direction) {
2426
+ if (event.altKey) {
2427
+ if (movable) {
2428
+ this.nudgeNodes(this.getSelectedNodeIds(), direction, event.shiftKey ? 1 : this.gridSize);
2429
+ }
2430
+ return true;
2431
+ }
2432
+ if (event.shiftKey) {
2433
+ const anchorId = selectedNode?.id ?? (selectedEdge ? this.edgeCycleAnchor : null);
2434
+ return anchorId ? this.selectSideEdge(anchorId, direction) : false;
2435
+ }
2436
+ this.navigateSelection(direction);
2437
+ return true;
2438
+ }
2439
+ const nodes = this.getVisibleNodes();
2440
+ if ((key === "Home" || key === "End") && nodes.length) {
2441
+ this.selectNode(nodes[key === "Home" ? 0 : nodes.length - 1].id);
2442
+ return true;
2443
+ }
2444
+ if (selectedEdge && !this.readonly && !event.altKey) {
2445
+ if (key === "F2" || key === "Enter" || key === " " && mindmap) {
2446
+ return this.startEdgeLabelEdit(selectedEdge.id);
2447
+ }
2448
+ if (mindmap && key.length === 1 && key !== " ") {
2449
+ return this.startEdgeLabelEdit(selectedEdge.id, { initialText: key });
2450
+ }
2451
+ return false;
2452
+ }
2453
+ if (!editable || event.altKey) return false;
2454
+ if (key === "F2" || key === "Enter" && !mindmap || key === " " && mindmap) {
2455
+ this.startTextEdit(selectedNode.id);
2456
+ return true;
2457
+ }
2458
+ if (!mindmap) return false;
2459
+ if (key === "Tab" && !event.shiftKey) {
2460
+ this.insertBranchNode(selectedNode.id, { edit: true });
2461
+ return true;
2462
+ }
2463
+ if (key === "Enter") {
2464
+ this.insertSiblingNode(selectedNode.id, { before: event.shiftKey, edit: true });
2465
+ return true;
2466
+ }
2467
+ if (key.length === 1 && key !== " " && isNodeTextEditable(selectedNode)) {
2468
+ this.startTextEdit(selectedNode.id, { initialText: key });
2469
+ return true;
2470
+ }
2471
+ return false;
2472
+ }
2473
+ // Move the selection to the nearest node in `direction`; with nothing (or an
2474
+ // edge) selected, start from the node closest to the centre of the view.
2475
+ navigateSelection(direction) {
2476
+ const nodes = this.getVisibleNodes();
2477
+ if (!nodes.length) return false;
2478
+ const current = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2479
+ const edge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2480
+ const next = current ? findSpatialNeighbor(nodes, current, direction) : edge ? this.getEdgeEndInDirection(edge, direction) : findNearestNode(nodes, this.getViewportCenter());
2481
+ if (!next) return false;
2482
+ this.selectNode(next.id);
2483
+ this.revealNode(next.id);
2484
+ return true;
2485
+ }
2486
+ // The end of `edge` that best matches `direction`, measured from the label
2487
+ // anchor so either end can be reached from a selected connection.
2488
+ getEdgeEndInDirection(edge, direction) {
2489
+ const fromNode = this.findNode(edge.from.nodeId);
2490
+ const toNode = this.findNode(edge.to.nodeId);
2491
+ if (!fromNode || !toNode) return fromNode || toNode;
2492
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
2493
+ const vector = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2494
+ const score = (node) => (node.x + node.width / 2 - labelX) * vector[0] + (node.y + node.height / 2 - labelY) * vector[1];
2495
+ return score(toNode) >= score(fromNode) ? toNode : fromNode;
2496
+ }
2497
+ // Connections attached to `nodeId` on the `direction` side, outgoing or
2498
+ // incoming, in document order.
2499
+ getSideEdges(nodeId, direction) {
2500
+ const port = DIRECTION_PORTS[direction];
2501
+ return this.documentData.edges.filter(
2502
+ (edge) => edge.from.nodeId === nodeId && edge.from.port === port || edge.to.nodeId === nodeId && edge.to.port === port
2503
+ );
2504
+ }
2505
+ // Select a connection on one side of a node; repeating cycles through them.
2506
+ selectSideEdge(nodeId, direction) {
2507
+ const edges = this.getSideEdges(nodeId, direction);
2508
+ if (!edges.length) return false;
2509
+ const currentIndex = this.selection?.kind === "edge" ? edges.findIndex((edge) => edge.id === this.selection.id) : -1;
2510
+ const next = edges[(currentIndex + 1) % edges.length];
2511
+ this.select({ kind: "edge", id: next.id });
2512
+ this.edgeCycleAnchor = nodeId;
2513
+ return true;
2514
+ }
2515
+ nudgeNode(nodeId, direction, distance) {
2516
+ const [node] = this.nudgeNodes([nodeId], direction, distance);
2517
+ return node || null;
2518
+ }
2519
+ // Move nodes together; repeated nudges of the same selection coalesce into
2520
+ // one undo step.
2521
+ nudgeNodes(nodeIds, direction, distance) {
2522
+ const step = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2523
+ if (this.readonly || !step) return [];
2524
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2525
+ if (!nodes.length) return [];
2526
+ nodes.forEach((node) => {
2527
+ node.x = formatNumber(node.x + step[0] * distance);
2528
+ node.y = formatNumber(node.y + step[1] * distance);
2529
+ });
2530
+ this.render({ inspector: true, json: true });
2531
+ this.emitChange(
2532
+ "node:nudge",
2533
+ nodes.length > 1 ? { nodeIds: nodes.map((node) => node.id) } : { node: deepClone(nodes[0]) }
2534
+ );
2535
+ return nodes.map((node) => deepClone(node));
2536
+ }
2537
+ zoomTo(scale) {
2538
+ const viewport = this.documentData.viewport;
2539
+ const width = this.canvasEl.clientWidth || 800;
2540
+ const height = this.canvasEl.clientHeight || 560;
2541
+ this.scaleAround(
2542
+ clamp(formatScale(scale), this.minScale, this.maxScale) / viewport.scale,
2543
+ width / 2,
2544
+ height / 2,
2545
+ "viewport:zoom"
2546
+ );
2547
+ return this;
2548
+ }
2549
+ duplicateSelection() {
2550
+ if (this.readonly || this.selection?.kind !== "node") return null;
2551
+ if (this.hasMultiSelection()) return this.pasteNodeGroup(this.captureNodeGroup());
2552
+ const node = this.findNode(this.selection.id);
2553
+ return node ? this.pasteNodeData(node) : null;
2554
+ }
2555
+ // Selected nodes plus the connections that run between them.
2556
+ captureNodeGroup() {
2557
+ const ids = new Set(this.getSelectedNodeIds());
2558
+ return {
2559
+ nodes: deepClone(this.documentData.nodes.filter((node) => ids.has(node.id))),
2560
+ edges: deepClone(
2561
+ this.documentData.edges.filter(
2562
+ (edge) => ids.has(edge.from.nodeId) && ids.has(edge.to.nodeId)
2563
+ )
2564
+ )
2565
+ };
2566
+ }
2567
+ // Paste a captured group offset by one step, remapping ids so internal
2568
+ // connections follow their copies. One change, one undo step.
2569
+ pasteNodeGroup(group) {
2570
+ if (this.readonly || !group?.nodes?.length) return [];
2571
+ const usedNodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2572
+ const idMap = /* @__PURE__ */ new Map();
2573
+ const nodes = group.nodes.map((data, index) => {
2574
+ const node = normalizeNode(
2575
+ { ...data, id: void 0, x: data.x + 24, y: data.y + 24 },
2576
+ this.documentData.nodes.length + index,
2577
+ usedNodeIds,
2578
+ this.nodeSizeBounds()
2579
+ );
2580
+ idMap.set(data.id, node.id);
2581
+ return node;
2582
+ });
2583
+ this.documentData.nodes.push(...nodes);
2584
+ const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2585
+ const usedEdgeIds = new Set(this.documentData.edges.map((edge) => edge.id));
2586
+ group.edges.forEach((data, index) => {
2587
+ const edge = normalizeEdge(
2588
+ {
2589
+ ...data,
2590
+ id: void 0,
2591
+ from: { ...data.from, nodeId: idMap.get(data.from.nodeId) },
2592
+ to: { ...data.to, nodeId: idMap.get(data.to.nodeId) }
2593
+ },
2594
+ this.documentData.edges.length + index,
2595
+ nodeIds,
2596
+ usedEdgeIds
2597
+ );
2598
+ if (edge) this.documentData.edges.push(edge);
2599
+ });
2600
+ const ids = nodes.map((node) => node.id);
2601
+ this.selectNodes(ids);
2602
+ this.emitChange("nodes:add", { nodeIds: ids });
2603
+ return nodes.map((node) => deepClone(node));
2604
+ }
2605
+ pasteNodeData(data) {
2606
+ const usedIds = new Set(this.documentData.nodes.map((node2) => node2.id));
2607
+ const node = normalizeNode(
2608
+ { ...data, id: void 0, collapsed: void 0, x: data.x + 24, y: data.y + 24 },
2609
+ this.documentData.nodes.length,
2610
+ usedIds,
2611
+ this.nodeSizeBounds()
2612
+ );
2613
+ this.documentData.nodes.push(node);
2614
+ this.select({ kind: "node", id: node.id });
2615
+ this.emitChange("node:add", { node: deepClone(node) });
2616
+ return deepClone(node);
2617
+ }
1796
2618
  copySelection() {
1797
2619
  if (!this.selection) return false;
2620
+ if (this.hasMultiSelection()) {
2621
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2622
+ return true;
2623
+ }
1798
2624
  if (this.selection.kind === "node") {
1799
2625
  const node = this.findNode(this.selection.id);
1800
2626
  if (!node) return false;
@@ -1809,22 +2635,14 @@ var VdFlowchart = class {
1809
2635
  pasteClipboard() {
1810
2636
  if (!this.clipboard || this.readonly) return false;
1811
2637
  if (this.clipboard.kind === "node") {
1812
- const usedIds2 = new Set(this.documentData.nodes.map((node2) => node2.id));
1813
- const node = normalizeNode(
1814
- {
1815
- ...this.clipboard.data,
1816
- id: void 0,
1817
- x: this.clipboard.data.x + 24,
1818
- y: this.clipboard.data.y + 24
1819
- },
1820
- this.documentData.nodes.length,
1821
- usedIds2
1822
- );
1823
- this.documentData.nodes.push(node);
1824
- this.select({ kind: "node", id: node.id });
1825
- this.emitChange("node:add", { node: deepClone(node) });
2638
+ this.pasteNodeData(this.clipboard.data);
1826
2639
  return true;
1827
2640
  }
2641
+ if (this.clipboard.kind === "nodes") {
2642
+ const pasted = this.pasteNodeGroup(this.clipboard.data);
2643
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2644
+ return pasted.length > 0;
2645
+ }
1828
2646
  const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
1829
2647
  const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
1830
2648
  const edge = normalizeEdge(
@@ -1851,16 +2669,16 @@ var VdFlowchart = class {
1851
2669
  const edgeTarget = event.target.closest("[data-edge-id]");
1852
2670
  if (edgeTarget && !event.target.closest("[data-edge-endpoint]")) {
1853
2671
  const edgeId = edgeTarget.getAttribute("data-edge-id");
1854
- if (edgeId && this.findEdge(edgeId)) {
1855
- this.reconnectEdgeId = edgeId;
1856
- this.select({ kind: "edge", id: edgeId });
2672
+ if (edgeId && this.startEdgeLabelEdit(edgeId)) {
1857
2673
  event.preventDefault();
1858
2674
  event.stopPropagation();
1859
2675
  return;
1860
2676
  }
1861
2677
  }
1862
2678
  const nodeTarget = event.target.closest("[data-node-id]");
1863
- if (!nodeTarget || event.target.closest("[data-edge-id]")) return;
2679
+ if (!nodeTarget || event.target.closest("[data-edge-id]") || event.target.closest("[data-collapse-toggle]")) {
2680
+ return;
2681
+ }
1864
2682
  const nodeId = nodeTarget.getAttribute("data-node-id");
1865
2683
  if (this.startTextEdit(nodeId)) {
1866
2684
  event.preventDefault();
@@ -1870,7 +2688,7 @@ var VdFlowchart = class {
1870
2688
  handleClick(event) {
1871
2689
  if (this.readonly || event.detail < 2) return;
1872
2690
  const nodeTarget = event.target.closest("[data-node-id]");
1873
- if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]"))
2691
+ if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]") || event.target.closest("[data-collapse-toggle]"))
1874
2692
  return;
1875
2693
  if (this.startTextEdit(nodeTarget.getAttribute("data-node-id"))) {
1876
2694
  event.preventDefault();
@@ -1883,6 +2701,13 @@ var VdFlowchart = class {
1883
2701
  this.stopTextEdit({ commit: true });
1884
2702
  }
1885
2703
  this.canvasEl.focus();
2704
+ const collapseTarget = event.target.closest("[data-collapse-toggle]");
2705
+ if (collapseTarget) {
2706
+ this.lastNodePointer = null;
2707
+ this.toggleCollapsed(collapseTarget.getAttribute("data-collapse-toggle"));
2708
+ event.preventDefault();
2709
+ return;
2710
+ }
1886
2711
  const nodeTarget = event.target.closest("[data-node-id]");
1887
2712
  const portTarget = event.target.closest("[data-port]");
1888
2713
  const resizeTarget = event.target.closest("[data-resize-handle]");
@@ -1918,6 +2743,13 @@ var VdFlowchart = class {
1918
2743
  event.preventDefault();
1919
2744
  return;
1920
2745
  }
2746
+ if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && event.shiftKey) {
2747
+ this.reconnectEdgeId = null;
2748
+ this.lastNodePointer = null;
2749
+ this.toggleNodeSelection(nodeTarget.getAttribute("data-node-id"));
2750
+ event.preventDefault();
2751
+ return;
2752
+ }
1921
2753
  if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && !this.readonly) {
1922
2754
  if (this.activeTool === "arrow") {
1923
2755
  this.select({ kind: "node", id: nodeTarget.getAttribute("data-node-id") });
@@ -1944,6 +2776,9 @@ var VdFlowchart = class {
1944
2776
  this.interaction = {
1945
2777
  kind: "connect",
1946
2778
  pointerId: event.pointerId,
2779
+ startClientX: event.clientX,
2780
+ startClientY: event.clientY,
2781
+ moved: false,
1947
2782
  source: { nodeId, port },
1948
2783
  target: null,
1949
2784
  previousSnap: null,
@@ -1994,22 +2829,27 @@ var VdFlowchart = class {
1994
2829
  if (nodeTarget) {
1995
2830
  this.reconnectEdgeId = null;
1996
2831
  const nodeId = nodeTarget.getAttribute("data-node-id");
1997
- this.select({ kind: "node", id: nodeId });
2832
+ const groupIds = this.hasMultiSelection() && this.isNodeSelected(nodeId) ? this.getSelectedNodeIds() : [nodeId];
2833
+ if (groupIds.length > 1) this.selectNodes(groupIds, { primary: nodeId });
2834
+ else this.select({ kind: "node", id: nodeId });
1998
2835
  if (!this.readonly) {
1999
- const node = this.findNode(nodeId);
2000
- const world = this.clientToWorld(event.clientX, event.clientY);
2001
- this.interaction = {
2002
- kind: "drag-node",
2003
- pointerId: event.pointerId,
2004
- nodeId,
2005
- offsetX: world.x - node.x,
2006
- offsetY: world.y - node.y,
2007
- moved: false
2008
- };
2836
+ this.interaction = this.createDragInteraction(event, nodeId, groupIds);
2009
2837
  this.capturePointer(event.pointerId);
2010
2838
  }
2011
2839
  return;
2012
2840
  }
2841
+ if (event.shiftKey) {
2842
+ const startWorld = this.clientToWorld(event.clientX, event.clientY);
2843
+ this.interaction = {
2844
+ kind: "marquee",
2845
+ pointerId: event.pointerId,
2846
+ startWorld,
2847
+ baseIds: this.selection?.kind === "node" ? this.getSelectedNodeIds() : []
2848
+ };
2849
+ this.capturePointer(event.pointerId);
2850
+ event.preventDefault();
2851
+ return;
2852
+ }
2013
2853
  this.select(null);
2014
2854
  this.reconnectEdgeId = null;
2015
2855
  this.interaction = {
@@ -2024,20 +2864,123 @@ var VdFlowchart = class {
2024
2864
  this.capturePointer(event.pointerId);
2025
2865
  event.preventDefault();
2026
2866
  }
2867
+ createDragInteraction(event, nodeId, nodeIds) {
2868
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2869
+ const moving = new Set(nodes.map((node) => node.id));
2870
+ return {
2871
+ kind: "drag-node",
2872
+ pointerId: event.pointerId,
2873
+ nodeId,
2874
+ nodeIds: nodes.map((node) => node.id),
2875
+ startWorld: this.clientToWorld(event.clientX, event.clientY),
2876
+ origins: new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }])),
2877
+ bounds: getBounds(nodes),
2878
+ guideLines: this.snapGuides ? collectGuideLines(this.getVisibleNodes().filter((node) => !moving.has(node.id))) : null,
2879
+ snapDisabled: false,
2880
+ moved: false
2881
+ };
2882
+ }
2883
+ // Translate every dragged node by the pointer delta, snapping the group's
2884
+ // bounds to the nearest alignment guide on each axis. Alt turns snapping
2885
+ // off for the rest of the gesture.
2886
+ moveDraggedNodes(event) {
2887
+ const interaction = this.interaction;
2888
+ const world = this.clientToWorld(event.clientX, event.clientY);
2889
+ let dx = world.x - interaction.startWorld.x;
2890
+ let dy = world.y - interaction.startWorld.y;
2891
+ if (event.altKey) interaction.snapDisabled = true;
2892
+ let guides = [];
2893
+ if (interaction.guideLines && !interaction.snapDisabled) {
2894
+ const box = interaction.bounds;
2895
+ const threshold = GUIDE_SNAP_DISTANCE / this.documentData.viewport.scale;
2896
+ const left = box.left + dx;
2897
+ const top = box.top + dy;
2898
+ const width = box.right - box.left;
2899
+ const height = box.bottom - box.top;
2900
+ const snapX = snapToGuides(
2901
+ interaction.guideLines.x,
2902
+ [left, left + width / 2, left + width],
2903
+ threshold
2904
+ );
2905
+ const snapY = snapToGuides(
2906
+ interaction.guideLines.y,
2907
+ [top, top + height / 2, top + height],
2908
+ threshold
2909
+ );
2910
+ if (snapX) dx += snapX.offset;
2911
+ if (snapY) dy += snapY.offset;
2912
+ const snapped = { left: box.left + dx, top: box.top + dy, width, height };
2913
+ if (snapX) guides = guides.concat(guideSegments("x", snapX.lines, snapped));
2914
+ if (snapY) guides = guides.concat(guideSegments("y", snapY.lines, snapped));
2915
+ }
2916
+ let changed = false;
2917
+ interaction.nodeIds.forEach((id) => {
2918
+ const node = this.findNode(id);
2919
+ const origin = interaction.origins.get(id);
2920
+ if (!node || !origin) return;
2921
+ const nextX = formatNumber(origin.x + dx);
2922
+ const nextY = formatNumber(origin.y + dy);
2923
+ if (nextX === node.x && nextY === node.y) return;
2924
+ node.x = nextX;
2925
+ node.y = nextY;
2926
+ changed = true;
2927
+ this.renderDraggedNode(node);
2928
+ });
2929
+ if (changed) interaction.moved = true;
2930
+ this.renderGuides(guides);
2931
+ }
2932
+ // Live marquee: select every visible node the box touches, added to the
2933
+ // selection the gesture started from.
2934
+ updateMarquee(event) {
2935
+ const interaction = this.interaction;
2936
+ const world = this.clientToWorld(event.clientX, event.clientY);
2937
+ const box = {
2938
+ left: Math.min(interaction.startWorld.x, world.x),
2939
+ top: Math.min(interaction.startWorld.y, world.y),
2940
+ right: Math.max(interaction.startWorld.x, world.x),
2941
+ bottom: Math.max(interaction.startWorld.y, world.y)
2942
+ };
2943
+ clearChildren(this.guidesLayer);
2944
+ this.guidesLayer.appendChild(
2945
+ svgEl("rect", {
2946
+ class: "vd-flowchart-marquee",
2947
+ x: formatNumber(box.left),
2948
+ y: formatNumber(box.top),
2949
+ width: formatNumber(box.right - box.left),
2950
+ height: formatNumber(box.bottom - box.top)
2951
+ })
2952
+ );
2953
+ const hits = this.getVisibleNodes().filter(
2954
+ (node) => node.x <= box.right && node.x + node.width >= box.left && node.y <= box.bottom && node.y + node.height >= box.top
2955
+ ).map((node) => node.id);
2956
+ const ids = [.../* @__PURE__ */ new Set([...interaction.baseIds, ...hits])];
2957
+ const key = ids.join(",");
2958
+ if (key === interaction.lastKey) return;
2959
+ interaction.lastKey = key;
2960
+ this.selectNodes(ids, { primary: hits[hits.length - 1] });
2961
+ }
2962
+ renderGuides(guides) {
2963
+ clearChildren(this.guidesLayer);
2964
+ guides.forEach((guide) => {
2965
+ this.guidesLayer.appendChild(
2966
+ svgEl("line", {
2967
+ class: "vd-flowchart-guide",
2968
+ x1: guide.x1,
2969
+ y1: guide.y1,
2970
+ x2: guide.x2,
2971
+ y2: guide.y2
2972
+ })
2973
+ );
2974
+ });
2975
+ }
2027
2976
  handlePointerMove(event) {
2028
2977
  if (!this.interaction || this.interaction.pointerId !== event.pointerId) return;
2029
2978
  if (this.interaction.kind === "drag-node") {
2030
- const node = this.findNode(this.interaction.nodeId);
2031
- if (!node) return;
2032
- const world = this.clientToWorld(event.clientX, event.clientY);
2033
- const nextX = formatNumber(world.x - this.interaction.offsetX);
2034
- const nextY = formatNumber(world.y - this.interaction.offsetY);
2035
- if (nextX !== node.x || nextY !== node.y) {
2036
- node.x = nextX;
2037
- node.y = nextY;
2038
- this.interaction.moved = true;
2039
- this.renderDraggedNode(node);
2040
- }
2979
+ this.moveDraggedNodes(event);
2980
+ return;
2981
+ }
2982
+ if (this.interaction.kind === "marquee") {
2983
+ this.updateMarquee(event);
2041
2984
  return;
2042
2985
  }
2043
2986
  if (this.interaction.kind === "resize-node") {
@@ -2072,6 +3015,14 @@ var VdFlowchart = class {
2072
3015
  return;
2073
3016
  }
2074
3017
  if (this.interaction.kind === "connect") {
3018
+ if (!this.interaction.moved) {
3019
+ const travel = Math.hypot(
3020
+ event.clientX - this.interaction.startClientX,
3021
+ event.clientY - this.interaction.startClientY
3022
+ );
3023
+ if (!(travel > PORT_CLICK_SLOP)) return;
3024
+ this.interaction.moved = true;
3025
+ }
2075
3026
  const world = this.clientToWorld(event.clientX, event.clientY);
2076
3027
  const snapTarget = this.findConnectionTarget(
2077
3028
  world,
@@ -2158,10 +3109,19 @@ var VdFlowchart = class {
2158
3109
  }
2159
3110
  return;
2160
3111
  }
3112
+ if (interaction.kind === "marquee") {
3113
+ clearChildren(this.guidesLayer);
3114
+ return;
3115
+ }
2161
3116
  if (interaction.kind === "drag-node") {
3117
+ clearChildren(this.guidesLayer);
2162
3118
  if (interaction.moved) {
2163
3119
  this.render({ inspector: true, json: true });
2164
- this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3120
+ if (interaction.nodeIds.length > 1) {
3121
+ this.emitChange("nodes:move", { nodeIds: [...interaction.nodeIds] });
3122
+ } else {
3123
+ this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3124
+ }
2165
3125
  } else {
2166
3126
  this.render({ inspector: false, json: false });
2167
3127
  }
@@ -2177,6 +3137,14 @@ var VdFlowchart = class {
2177
3137
  return;
2178
3138
  }
2179
3139
  if (interaction.kind === "connect") {
3140
+ if (!interaction.moved && this.activeTool !== "arrow") {
3141
+ this.render({ inspector: false, json: false });
3142
+ this.insertBranchNode(interaction.source.nodeId, {
3143
+ direction: PORT_DIRECTIONS[interaction.source.port],
3144
+ edit: true
3145
+ });
3146
+ return;
3147
+ }
2180
3148
  const world = this.clientToWorld(event.clientX || 0, event.clientY || 0);
2181
3149
  const snapTarget = interaction.target ? { node: this.findNode(interaction.target.nodeId), port: interaction.target.port } : this.findConnectionTarget(
2182
3150
  world,
@@ -2252,16 +3220,17 @@ var VdFlowchart = class {
2252
3220
  if (this.readonly) return false;
2253
3221
  if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") return true;
2254
3222
  if (this.activeTool === "arrow") return true;
2255
- return this.selection?.kind === "node" && this.selection.id === node.id;
3223
+ return this.selection?.kind === "node" && this.selection.id === node.id && !this.hasMultiSelection();
2256
3224
  }
2257
- getMarkerId(markerType, position, strokeWidth) {
3225
+ getMarkerId(markerType, position, strokeWidth, edgeScale = 1) {
2258
3226
  const width = normalizeEdgeStrokeWidth(strokeWidth);
2259
- const key = `${markerType}:${position}:${width}`;
3227
+ const scale = clamp(formatNumber(Math.max(edgeScale, 0.01)), 0.01, 1);
3228
+ const key = `${markerType}:${position}:${width}:${scale}`;
2260
3229
  if (this.markerIds.has(key)) {
2261
3230
  return this.markerIds.get(key);
2262
3231
  }
2263
3232
  const markerId = nextId(`flowchart-${markerType}-${position}`);
2264
- const marker = markerType === "dot" ? createDotMarker(markerId, width) : createArrowMarker(markerId, width, position === "start");
3233
+ const marker = markerType === "dot" ? createDotMarker(markerId, width, scale) : createArrowMarker(markerId, width, position === "start", scale);
2265
3234
  this.markerDefs.appendChild(marker);
2266
3235
  this.markerIds.set(key, markerId);
2267
3236
  return markerId;
@@ -2269,13 +3238,20 @@ var VdFlowchart = class {
2269
3238
  getEdgeStrokeWidth(edge) {
2270
3239
  return normalizeEdgeStrokeWidth(edge?.strokeWidth);
2271
3240
  }
2272
- getEdgeHitStrokeWidth(edge) {
2273
- return formatNumber(Math.max(EDGE_HIT_STROKE_MIN, this.getEdgeStrokeWidth(edge) + 12));
3241
+ getEdgeHitStrokeWidth(edge, edgeScale = 1) {
3242
+ const visual = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
3243
+ const base = this.getEdgeStrokeWidth(edge);
3244
+ const view = Math.max(this.documentData?.viewport?.scale || 1, 1e-4);
3245
+ const legacy = Math.max(EDGE_HIT_STROKE_MIN, base + 12);
3246
+ if (visual >= 0.999 && view >= DEFAULT_MIN_SCALE && view <= DEFAULT_MAX_SCALE) {
3247
+ return formatNumber(legacy);
3248
+ }
3249
+ return formatNumber(Math.max(base * visual, EDGE_HIT_STROKE_MIN / view));
2274
3250
  }
2275
3251
  findConnectionTarget(worldPoint, excludeNodeId, previousSnap = null, referencePoint = null) {
2276
3252
  const snapPadding = CONNECTION_SNAP_PADDING / this.documentData.viewport.scale;
2277
3253
  const hysteresisMargin = CONNECTION_HYSTERESIS / this.documentData.viewport.scale;
2278
- const best = this.documentData.nodes.reduce((candidate, node) => {
3254
+ const best = this.getVisibleNodes().reduce((candidate, node) => {
2279
3255
  if (node.id === excludeNodeId) return candidate;
2280
3256
  const distanceToBounds2 = getDistanceToNodeBounds(node, worldPoint);
2281
3257
  if (distanceToBounds2 > snapPadding) return candidate;
@@ -2327,17 +3303,37 @@ var VdFlowchart = class {
2327
3303
  let width = original.width;
2328
3304
  let height = original.height;
2329
3305
  if (handle.includes("e")) {
2330
- width = clampNodeWidth(nextType, original.width + deltaX, original.width);
3306
+ width = clampNodeWidth(
3307
+ nextType,
3308
+ original.width + deltaX,
3309
+ original.width,
3310
+ this.nodeSizeBounds()
3311
+ );
2331
3312
  }
2332
3313
  if (handle.includes("s")) {
2333
- height = clampNodeHeight(nextType, original.height + deltaY, original.height);
3314
+ height = clampNodeHeight(
3315
+ nextType,
3316
+ original.height + deltaY,
3317
+ original.height,
3318
+ this.nodeSizeBounds()
3319
+ );
2334
3320
  }
2335
3321
  if (handle.includes("w")) {
2336
- width = clampNodeWidth(nextType, original.width - deltaX, original.width);
3322
+ width = clampNodeWidth(
3323
+ nextType,
3324
+ original.width - deltaX,
3325
+ original.width,
3326
+ this.nodeSizeBounds()
3327
+ );
2337
3328
  x = original.x + original.width - width;
2338
3329
  }
2339
3330
  if (handle.includes("n")) {
2340
- height = clampNodeHeight(nextType, original.height - deltaY, original.height);
3331
+ height = clampNodeHeight(
3332
+ nextType,
3333
+ original.height - deltaY,
3334
+ original.height,
3335
+ this.nodeSizeBounds()
3336
+ );
2341
3337
  y = original.y + original.height - height;
2342
3338
  }
2343
3339
  return {
@@ -2373,7 +3369,7 @@ var VdFlowchart = class {
2373
3369
  scaleAround(factor, localX, localY, reason) {
2374
3370
  const viewport = this.documentData.viewport;
2375
3371
  const anchor = this.localToWorld(localX, localY);
2376
- const nextScale = clamp(formatNumber(viewport.scale * factor), MIN_SCALE, MAX_SCALE);
3372
+ const nextScale = clamp(formatScale(viewport.scale * factor), this.minScale, this.maxScale);
2377
3373
  if (nextScale === viewport.scale) return;
2378
3374
  viewport.scale = nextScale;
2379
3375
  viewport.x = formatNumber(localX - anchor.x * nextScale);
@@ -2384,7 +3380,10 @@ var VdFlowchart = class {
2384
3380
  updateToolbarLabel() {
2385
3381
  this.zoomLabel.textContent = `${Math.round(this.documentData.viewport.scale * 100)}%`;
2386
3382
  }
2387
- startTextEdit(nodeId) {
3383
+ // Options: `initialText` replaces the label with typed text (type-to-edit);
3384
+ // `inserted` marks a node created by a keyboard/handle insertion so naming it
3385
+ // shares the insertion's undo step.
3386
+ startTextEdit(nodeId, options = {}) {
2388
3387
  if (this.readonly) return false;
2389
3388
  const node = this.findNode(nodeId);
2390
3389
  if (!node || !isNodeTextEditable(node)) return false;
@@ -2396,9 +3395,10 @@ var VdFlowchart = class {
2396
3395
  }
2397
3396
  this.stopTextEdit({ commit: true });
2398
3397
  this.select({ kind: "node", id: node.id });
3398
+ const replacing = typeof options.initialText === "string";
2399
3399
  const textarea = createElement("textarea", {
2400
3400
  className: `vd-flowchart-text-editor vd-flowchart-text-editor--${node.type}`,
2401
- value: node.text,
3401
+ value: replacing ? options.initialText : node.text,
2402
3402
  rows: 1
2403
3403
  });
2404
3404
  textarea.setAttribute("data-node-id", node.id);
@@ -2407,19 +3407,7 @@ var VdFlowchart = class {
2407
3407
  textarea.addEventListener("input", () => this.positionTextEditor());
2408
3408
  textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
2409
3409
  textarea.addEventListener("dblclick", (event) => event.stopPropagation());
2410
- textarea.addEventListener("keydown", (event) => {
2411
- if (event.key === "Escape") {
2412
- event.preventDefault();
2413
- this.stopTextEdit({ commit: false });
2414
- this.canvasEl.focus();
2415
- return;
2416
- }
2417
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
2418
- event.preventDefault();
2419
- this.stopTextEdit({ commit: true });
2420
- this.canvasEl.focus();
2421
- }
2422
- });
3410
+ textarea.addEventListener("keydown", (event) => this.handleTextEditorKeyDown(event, node));
2423
3411
  textarea.addEventListener("blur", () => {
2424
3412
  if (this.textEditor?.textarea === textarea) {
2425
3413
  this.stopTextEdit({ commit: true });
@@ -2428,18 +3416,97 @@ var VdFlowchart = class {
2428
3416
  this.textEditor = {
2429
3417
  nodeId: node.id,
2430
3418
  previousText: node.text,
3419
+ inserted: Boolean(options.inserted),
2431
3420
  textarea
2432
3421
  };
2433
3422
  this.canvasEl.appendChild(textarea);
2434
3423
  this.render({ inspector: true, json: false });
2435
- window.requestAnimationFrame(() => {
2436
- if (this.textEditor?.textarea === textarea) {
2437
- textarea.focus();
3424
+ const focusEditor = () => {
3425
+ if (this.textEditor?.textarea !== textarea) return;
3426
+ textarea.focus();
3427
+ if (replacing) {
3428
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3429
+ } else {
2438
3430
  textarea.select();
2439
3431
  }
3432
+ };
3433
+ focusEditor();
3434
+ window.requestAnimationFrame(() => {
3435
+ if (document.activeElement !== textarea) focusEditor();
3436
+ });
3437
+ return true;
3438
+ }
3439
+ // Inline label editor for a connection, placed on the label anchor
3440
+ // (curve midpoint). Labels render on one line, so Enter always saves.
3441
+ startEdgeLabelEdit(edgeId, options = {}) {
3442
+ if (this.readonly) return false;
3443
+ const edge = this.findEdge(sanitizeId(edgeId));
3444
+ if (!edge) return false;
3445
+ this.stopTextEdit({ commit: true });
3446
+ this.select({ kind: "edge", id: edge.id });
3447
+ const replacing = typeof options.initialText === "string";
3448
+ const textarea = createElement("textarea", {
3449
+ className: "vd-flowchart-text-editor vd-flowchart-text-editor--edge",
3450
+ value: replacing ? options.initialText : edge.label,
3451
+ rows: 1
3452
+ });
3453
+ textarea.setAttribute("data-edge-id", edge.id);
3454
+ textarea.setAttribute("aria-label", "Edit connection label");
3455
+ textarea.spellcheck = false;
3456
+ textarea.addEventListener("input", () => this.positionTextEditor());
3457
+ textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
3458
+ textarea.addEventListener("dblclick", (event) => event.stopPropagation());
3459
+ textarea.addEventListener("keydown", (event) => {
3460
+ if (event.isComposing) return;
3461
+ if (event.key === "Escape" || event.key === "Enter") {
3462
+ event.preventDefault();
3463
+ this.stopTextEdit({ commit: event.key === "Enter" });
3464
+ this.canvasEl.focus();
3465
+ }
3466
+ });
3467
+ textarea.addEventListener("blur", () => {
3468
+ if (this.textEditor?.textarea === textarea) this.stopTextEdit({ commit: true });
3469
+ });
3470
+ this.textEditor = { nodeId: null, edgeId: edge.id, previousText: edge.label, textarea };
3471
+ this.canvasEl.appendChild(textarea);
3472
+ this.render({ inspector: true, json: false });
3473
+ const focusEditor = () => {
3474
+ if (this.textEditor?.textarea !== textarea) return;
3475
+ textarea.focus();
3476
+ if (replacing) textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3477
+ else textarea.select();
3478
+ };
3479
+ focusEditor();
3480
+ window.requestAnimationFrame(() => {
3481
+ if (document.activeElement !== textarea) focusEditor();
2440
3482
  });
2441
3483
  return true;
2442
3484
  }
3485
+ handleTextEditorKeyDown(event, node) {
3486
+ if (event.isComposing) return;
3487
+ const mindmap = this.keyboardShortcuts === "mindmap";
3488
+ if (event.key === "Escape") {
3489
+ event.preventDefault();
3490
+ this.stopTextEdit({ commit: false });
3491
+ this.canvasEl.focus();
3492
+ return;
3493
+ }
3494
+ if (event.key === "Enter") {
3495
+ const modKey = event.ctrlKey || event.metaKey;
3496
+ const commits = modKey || mindmap && node.type !== "textbox" && !event.shiftKey;
3497
+ if (!commits) return;
3498
+ event.preventDefault();
3499
+ this.stopTextEdit({ commit: true });
3500
+ this.canvasEl.focus();
3501
+ return;
3502
+ }
3503
+ if (event.key === "Tab" && mindmap && !event.shiftKey && !event.altKey && !event.ctrlKey) {
3504
+ event.preventDefault();
3505
+ this.stopTextEdit({ commit: true });
3506
+ this.canvasEl.focus();
3507
+ this.insertBranchNode(node.id, { edit: true });
3508
+ }
3509
+ }
2443
3510
  stopTextEdit(options = {}) {
2444
3511
  if (!this.textEditor) return false;
2445
3512
  const commit = options.commit !== false;
@@ -2447,12 +3514,26 @@ var VdFlowchart = class {
2447
3514
  const nextText = editor.textarea.value;
2448
3515
  this.textEditor = null;
2449
3516
  editor.textarea.remove();
3517
+ if (editor.edgeId) {
3518
+ const label = nextText.replace(/\s*\n\s*/g, " ").trim();
3519
+ if (commit && !this.readonly && label !== editor.previousText) {
3520
+ this.updateEdge(editor.edgeId, { label }, { inspector: true, reason: "edge:update" });
3521
+ } else {
3522
+ this.render({ scene: true, inspector: false, json: false });
3523
+ }
3524
+ return true;
3525
+ }
2450
3526
  if (commit && !this.readonly && nextText !== editor.previousText) {
2451
- this.updateNode(
2452
- editor.nodeId,
2453
- { text: nextText },
2454
- { inspector: true, reason: "node:update" }
2455
- );
3527
+ this.coalesceInsertedText = editor.inserted;
3528
+ try {
3529
+ this.updateNode(
3530
+ editor.nodeId,
3531
+ { text: nextText },
3532
+ { inspector: true, reason: "node:update" }
3533
+ );
3534
+ } finally {
3535
+ this.coalesceInsertedText = false;
3536
+ }
2456
3537
  } else {
2457
3538
  this.render({ scene: true, inspector: false, json: false });
2458
3539
  }
@@ -2460,6 +3541,10 @@ var VdFlowchart = class {
2460
3541
  }
2461
3542
  positionTextEditor() {
2462
3543
  if (!this.textEditor) return;
3544
+ if (this.textEditor.edgeId) {
3545
+ this.positionEdgeLabelEditor();
3546
+ return;
3547
+ }
2463
3548
  const node = this.findNode(this.textEditor.nodeId);
2464
3549
  if (!node) {
2465
3550
  this.stopTextEdit({ commit: false });
@@ -2467,24 +3552,50 @@ var VdFlowchart = class {
2467
3552
  }
2468
3553
  const viewport = this.documentData.viewport;
2469
3554
  const scale = viewport.scale;
3555
+ const visual = nodeVisualScale(node);
2470
3556
  const textarea = this.textEditor.textarea;
2471
3557
  const width = node.width * scale;
2472
3558
  const height = node.height * scale;
2473
3559
  const metrics = getNodeFontMetrics(node);
2474
- const lineHeight = metrics.lineHeight * scale;
3560
+ const lineHeight = metrics.lineHeight * visual * scale;
2475
3561
  const lineCount = Math.max(1, textarea.value.split(/\r?\n/).length);
2476
- const horizontalPadding = (node.type === "textbox" ? 16 : 12) * scale;
2477
- const verticalPadding = node.type === "textbox" ? 12 * scale : Math.max(4, (height - lineCount * lineHeight) / 2);
3562
+ const horizontalPadding = (node.type === "textbox" ? 16 : 12) * visual * scale;
3563
+ const verticalPadding = node.type === "textbox" ? 12 * visual * scale : Math.max(4 * visual, (height - lineCount * lineHeight) / 2);
2478
3564
  textarea.style.left = `${formatNumber(viewport.x + node.x * scale)}px`;
2479
3565
  textarea.style.top = `${formatNumber(viewport.y + node.y * scale)}px`;
2480
3566
  textarea.style.width = `${formatNumber(width)}px`;
2481
3567
  textarea.style.height = `${formatNumber(height)}px`;
2482
- textarea.style.fontSize = `${formatNumber(metrics.fontSize * scale)}px`;
3568
+ textarea.style.fontSize = `${formatNumber(metrics.fontSize * visual * scale)}px`;
2483
3569
  textarea.style.lineHeight = `${formatNumber(lineHeight)}px`;
2484
3570
  textarea.style.padding = `${formatNumber(verticalPadding)}px ${formatNumber(horizontalPadding)}px`;
2485
3571
  }
3572
+ positionEdgeLabelEditor() {
3573
+ const edge = this.findEdge(this.textEditor.edgeId);
3574
+ const fromNode = edge && this.findNode(edge.from.nodeId);
3575
+ const toNode = edge && this.findNode(edge.to.nodeId);
3576
+ if (!edge || !fromNode || !toNode) {
3577
+ this.stopTextEdit({ commit: false });
3578
+ return;
3579
+ }
3580
+ const visual = edgeVisualScale(fromNode, toNode);
3581
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
3582
+ const viewport = this.documentData.viewport;
3583
+ const textarea = this.textEditor.textarea;
3584
+ const fontSize = Math.max(11 * visual, 13 * visual * viewport.scale);
3585
+ const width = clamp(textarea.value.length * fontSize * 0.62 + 28 * visual, 96 * visual, 280);
3586
+ const height = fontSize * 1.4 + 12 * visual;
3587
+ const labelOffset = 8 * visual;
3588
+ textarea.style.left = `${formatNumber(viewport.x + labelX * viewport.scale - width / 2)}px`;
3589
+ textarea.style.top = `${formatNumber(viewport.y + (labelY - labelOffset) * viewport.scale - height / 2)}px`;
3590
+ textarea.style.width = `${formatNumber(width)}px`;
3591
+ textarea.style.height = `${formatNumber(height)}px`;
3592
+ textarea.style.fontSize = `${formatNumber(fontSize)}px`;
3593
+ textarea.style.lineHeight = `${formatNumber(fontSize * 1.4)}px`;
3594
+ textarea.style.padding = "6px 10px";
3595
+ }
2486
3596
  render(options = {}) {
2487
3597
  if (this.destroyed) return;
3598
+ this.syncGrid();
2488
3599
  const shouldRenderScene = options.scene !== false;
2489
3600
  const shouldRenderInspector = options.inspector !== false;
2490
3601
  const shouldRenderJson = options.json !== false;
@@ -2495,8 +3606,10 @@ var VdFlowchart = class {
2495
3606
  this.updateToolbarLabel();
2496
3607
  this.updatePaletteState();
2497
3608
  this.positionTextEditor();
3609
+ this.scheduleMinimap();
2498
3610
  }
2499
3611
  renderScene() {
3612
+ this.syncSelectedNodeIds();
2500
3613
  const viewport = this.documentData.viewport;
2501
3614
  this.world.setAttribute(
2502
3615
  "transform",
@@ -2506,7 +3619,14 @@ var VdFlowchart = class {
2506
3619
  clearChildren(this.previewLayer);
2507
3620
  clearChildren(this.nodesLayer);
2508
3621
  clearChildren(this.overlayLayer);
2509
- const nodeMap = new Map(this.documentData.nodes.map((node) => [node.id, node]));
3622
+ clearChildren(this.markerDefs);
3623
+ this.markerIds = /* @__PURE__ */ new Map();
3624
+ const hidden = this.getHiddenNodeIds();
3625
+ this.sceneHidden = hidden;
3626
+ this.sceneOutgoing = this.getOutgoingMap();
3627
+ const nodeMap = new Map(
3628
+ this.documentData.nodes.filter((node) => !hidden.has(node.id)).map((node) => [node.id, node])
3629
+ );
2510
3630
  this.sceneNodeMap = nodeMap;
2511
3631
  this.nodeElements = /* @__PURE__ */ new Map();
2512
3632
  this.edgeElements = /* @__PURE__ */ new Map();
@@ -2526,6 +3646,7 @@ var VdFlowchart = class {
2526
3646
  this.previewLayer.appendChild(this.renderPreviewEdge(this.interaction));
2527
3647
  }
2528
3648
  this.documentData.nodes.forEach((node) => {
3649
+ if (hidden.has(node.id)) return;
2529
3650
  const element = this.renderNode(node);
2530
3651
  this.nodesLayer.appendChild(element);
2531
3652
  this.nodeElements.set(node.id, element);
@@ -2563,17 +3684,18 @@ var VdFlowchart = class {
2563
3684
  }
2564
3685
  }
2565
3686
  }
2566
- applyEdgeMarkers(pathElement, edge) {
3687
+ applyEdgeMarkers(pathElement, edge, edgeScale = 1) {
3688
+ const width = this.getEdgeStrokeWidth(edge);
2567
3689
  if (edge.startMarker && edge.startMarker !== "none") {
2568
3690
  pathElement.setAttribute(
2569
3691
  "marker-start",
2570
- `url(#${this.getMarkerId(edge.startMarker, "start", this.getEdgeStrokeWidth(edge))})`
3692
+ `url(#${this.getMarkerId(edge.startMarker, "start", width, edgeScale)})`
2571
3693
  );
2572
3694
  }
2573
3695
  if (edge.endMarker && edge.endMarker !== "none") {
2574
3696
  pathElement.setAttribute(
2575
3697
  "marker-end",
2576
- `url(#${this.getMarkerId(edge.endMarker, "end", this.getEdgeStrokeWidth(edge))})`
3698
+ `url(#${this.getMarkerId(edge.endMarker, "end", width, edgeScale)})`
2577
3699
  );
2578
3700
  }
2579
3701
  }
@@ -2581,6 +3703,7 @@ var VdFlowchart = class {
2581
3703
  const fromNode = nodeMap.get(edge.from.nodeId);
2582
3704
  const toNode = nodeMap.get(edge.to.nodeId);
2583
3705
  if (!fromNode || !toNode) return null;
3706
+ const visual = edgeVisualScale(fromNode, toNode);
2584
3707
  const edgePath = buildEdgePath(edge, fromNode, toNode);
2585
3708
  const selected = this.selection?.kind === "edge" && this.selection.id === edge.id;
2586
3709
  const inReconnectMode = this.reconnectEdgeId === edge.id;
@@ -2588,13 +3711,14 @@ var VdFlowchart = class {
2588
3711
  class: `vd-flowchart-edge${selected ? " is-selected" : ""}${inReconnectMode ? " is-reconnecting" : ""}`,
2589
3712
  "data-edge-id": edge.id
2590
3713
  });
2591
- const strokeWidth = this.getEdgeStrokeWidth(edge);
3714
+ const baseStroke = this.getEdgeStrokeWidth(edge);
3715
+ const strokeWidth = visual < 0.999 ? formatNumber(Math.max(0.02, baseStroke * visual)) : baseStroke;
2592
3716
  if (selected) {
2593
3717
  group.appendChild(
2594
3718
  svgEl("path", {
2595
3719
  class: "vd-flowchart-edge-selection",
2596
3720
  d: edgePath.d,
2597
- "stroke-width": formatNumber(strokeWidth + 7)
3721
+ "stroke-width": visual < 0.999 ? formatNumber((baseStroke + 7) * visual) : formatNumber(strokeWidth + 7)
2598
3722
  })
2599
3723
  );
2600
3724
  }
@@ -2604,23 +3728,28 @@ var VdFlowchart = class {
2604
3728
  "stroke-width": strokeWidth,
2605
3729
  "data-edge-id": edge.id
2606
3730
  });
2607
- this.applyEdgeMarkers(visiblePath, edge);
3731
+ this.applyEdgeMarkers(visiblePath, edge, visual);
2608
3732
  const hitPath = svgEl("path", {
2609
3733
  class: "vd-flowchart-edge-hit",
2610
3734
  d: edgePath.d,
2611
- "stroke-width": this.getEdgeHitStrokeWidth(edge),
3735
+ "stroke-width": this.getEdgeHitStrokeWidth(edge, visual),
2612
3736
  "data-edge-id": edge.id
2613
3737
  });
2614
3738
  group.appendChild(visiblePath);
2615
3739
  group.appendChild(hitPath);
2616
- if (edge.label) {
3740
+ if (edge.label && this.textEditor?.edgeId !== edge.id) {
3741
+ const labelOffset = visual < 0.999 ? formatNumber(8 * visual) : 8;
2617
3742
  const label = svgEl("text", {
2618
3743
  class: "vd-flowchart-edge-label",
2619
3744
  x: edgePath.labelX,
2620
- y: edgePath.labelY - 8,
3745
+ y: edgePath.labelY - labelOffset,
2621
3746
  "text-anchor": "middle",
2622
3747
  "data-edge-id": edge.id
2623
3748
  });
3749
+ if (visual < 0.999) {
3750
+ label.style.fontSize = `${formatNumber(Math.max(0.06, 12 * visual))}px`;
3751
+ label.style.strokeWidth = `${formatNumber(Math.max(0.03, 5 * visual))}px`;
3752
+ }
2624
3753
  label.textContent = edge.label;
2625
3754
  group.appendChild(label);
2626
3755
  }
@@ -2659,23 +3788,32 @@ var VdFlowchart = class {
2659
3788
  renderPreviewEdge(interaction) {
2660
3789
  const fromPort = interaction.fromPort || interaction.source?.port || "right";
2661
3790
  const toPort = interaction.toPort || interaction.target?.port || "left";
2662
- const edgePath = buildEdgePath({
2663
- strokeWidth: interaction.strokeWidth,
2664
- route: interaction.route,
2665
- fromPoint: interaction.fromPoint,
2666
- toPoint: interaction.toPoint,
2667
- from: { port: fromPort },
2668
- to: { port: toPort }
2669
- });
3791
+ const fromNode = interaction.source?.nodeId ? this.findNode(interaction.source.nodeId) : null;
3792
+ const toNode = interaction.target?.nodeId ? this.findNode(interaction.target.nodeId) : null;
3793
+ const visual = edgeVisualScale(fromNode, toNode);
3794
+ const edgePath = buildEdgePath(
3795
+ {
3796
+ strokeWidth: interaction.strokeWidth,
3797
+ route: interaction.route,
3798
+ fromPoint: interaction.fromPoint,
3799
+ toPoint: interaction.toPoint,
3800
+ from: { port: fromPort },
3801
+ to: { port: toPort }
3802
+ },
3803
+ fromNode,
3804
+ toNode
3805
+ );
3806
+ const baseStroke = this.getEdgeStrokeWidth(interaction);
2670
3807
  return svgEl("path", {
2671
3808
  class: "vd-flowchart-preview-path",
2672
3809
  d: edgePath.d,
2673
- "stroke-width": this.getEdgeStrokeWidth(interaction)
3810
+ "stroke-width": visual < 0.999 ? formatNumber(Math.max(0.02, baseStroke * visual)) : baseStroke
2674
3811
  });
2675
3812
  }
2676
3813
  renderNode(node) {
2677
- const selected = this.selection?.kind === "node" && this.selection.id === node.id;
2678
- const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeId === node.id;
3814
+ const selected = this.isNodeSelected(node.id);
3815
+ const soloSelected = selected && !this.hasMultiSelection();
3816
+ const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeIds.includes(node.id);
2679
3817
  const resizing = this.interaction?.kind === "resize-node" && this.interaction.nodeId === node.id;
2680
3818
  const editing = this.textEditor?.nodeId === node.id;
2681
3819
  const group = svgEl("g", {
@@ -2684,24 +3822,45 @@ var VdFlowchart = class {
2684
3822
  "data-node-id": node.id
2685
3823
  });
2686
3824
  const portsVisible = this.shouldShowNodePorts(node);
3825
+ const visual = nodeVisualScale(node);
3826
+ const corner = node.type === "rounded-rect" || node.type === "textbox" ? 16 : node.type === "junction" ? node.width / 2 : 6;
2687
3827
  const hitbox = svgEl("rect", {
2688
3828
  x: 0,
2689
3829
  y: 0,
2690
3830
  width: node.width,
2691
3831
  height: node.height,
2692
- rx: node.type === "rounded-rect" || node.type === "textbox" ? 16 : node.type === "junction" ? node.width / 2 : 6,
3832
+ rx: visual < 0.999 && node.type !== "junction" ? formatNumber(corner * visual) : corner,
2693
3833
  fill: "transparent",
2694
3834
  "data-node-id": node.id
2695
3835
  });
2696
3836
  group.appendChild(hitbox);
2697
- group.appendChild(this.renderNodeShape(node));
3837
+ const shape = this.renderNodeShape(node);
3838
+ this.applyScaledNodeShape(node, shape);
3839
+ group.appendChild(shape);
2698
3840
  group.appendChild(this.renderNodeText(node));
2699
- if (selected && !this.readonly && isNodeResizable(node)) {
3841
+ const scale = this.documentData.viewport.scale || 1;
3842
+ if (soloSelected && !this.readonly && SELECTION_BOX_TYPES.has(node.type)) {
3843
+ const outset = SELECTION_OUTSET / scale;
3844
+ group.appendChild(
3845
+ svgEl("rect", {
3846
+ class: "vd-flowchart-selection-box",
3847
+ x: -outset,
3848
+ y: -outset,
3849
+ width: node.width + outset * 2,
3850
+ height: node.height + outset * 2
3851
+ })
3852
+ );
3853
+ }
3854
+ if (soloSelected && !this.readonly && isNodeResizable(node)) {
2700
3855
  group.appendChild(this.renderResizeControls(node));
2701
3856
  }
2702
- const scale = this.documentData.viewport.scale || 1;
3857
+ const collapseToggle = this.renderCollapseToggle(node, soloSelected);
3858
+ if (this.readonly) {
3859
+ if (collapseToggle) group.appendChild(collapseToggle);
3860
+ return group;
3861
+ }
2703
3862
  FLOWCHART_PORTS.forEach((port) => {
2704
- const position = getPortPosition({ ...node, x: 0, y: 0 }, port);
3863
+ const position = getPortHandlePosition({ ...node, x: 0, y: 0 }, port, scale);
2705
3864
  const portGroup = svgEl("g", {
2706
3865
  class: `vd-flowchart-port-group${portsVisible ? " is-visible" : ""}`,
2707
3866
  "data-node-id": node.id,
@@ -2723,15 +3882,68 @@ var VdFlowchart = class {
2723
3882
  r: CONNECTION_PORT_RADIUS / scale
2724
3883
  })
2725
3884
  );
3885
+ const arm = (CONNECTION_PORT_RADIUS - 2.5) / scale;
3886
+ portGroup.appendChild(
3887
+ svgEl("path", {
3888
+ class: "vd-flowchart-port-plus",
3889
+ 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)}`
3890
+ })
3891
+ );
2726
3892
  group.appendChild(portGroup);
2727
3893
  });
3894
+ if (collapseToggle) group.appendChild(collapseToggle);
3895
+ return group;
3896
+ }
3897
+ // "+N" on a collapsed node, or "−" on a single selected node with children,
3898
+ // placed beyond the connection handle on the side its branch leaves from.
3899
+ renderCollapseToggle(node, soloSelected) {
3900
+ const outgoing = this.sceneOutgoing || this.getOutgoingMap();
3901
+ const childIds = (outgoing.get(node.id) || []).filter((id) => id !== node.id);
3902
+ if (!childIds.length) return null;
3903
+ const count = node.collapsed ? this.countCollapsedDescendants(
3904
+ node.id,
3905
+ this.sceneHidden || this.getHiddenNodeIds(),
3906
+ outgoing
3907
+ ) : 0;
3908
+ if (!node.collapsed && !(soloSelected && !this.readonly)) return null;
3909
+ const sides = /* @__PURE__ */ new Map();
3910
+ this.documentData.edges.forEach((edge) => {
3911
+ if (edge.from.nodeId !== node.id || edge.to.nodeId === node.id) return;
3912
+ sides.set(edge.from.port, (sides.get(edge.from.port) || 0) + 1);
3913
+ });
3914
+ const port = [...sides].sort((a, b) => b[1] - a[1])[0]?.[0] || DIRECTION_PORTS[this.getGrowthDirection(node)];
3915
+ const scale = this.documentData.viewport.scale || 1;
3916
+ const anchor = getPortPosition({ ...node, x: 0, y: 0 }, port);
3917
+ const normal = getPortNormal(port);
3918
+ const offset = COLLAPSE_TOGGLE_OFFSET / scale;
3919
+ const cx = formatNumber(anchor.x + normal.x * offset);
3920
+ const cy = formatNumber(anchor.y + normal.y * offset);
3921
+ const label = node.collapsed ? `+${count}` : "\u2212";
3922
+ const radius = (node.collapsed && label.length > 2 ? 12 : 9) / scale;
3923
+ const group = svgEl("g", {
3924
+ class: `vd-flowchart-collapse-toggle${node.collapsed ? " is-collapsed" : ""}`,
3925
+ "data-collapse-toggle": node.id
3926
+ });
3927
+ const title = svgEl("title");
3928
+ title.textContent = node.collapsed ? `Expand ${count} hidden nodes` : "Collapse branch";
3929
+ group.appendChild(title);
3930
+ group.appendChild(svgEl("circle", { cx, cy, r: formatNumber(radius) }));
3931
+ const text = svgEl("text", {
3932
+ x: cx,
3933
+ y: cy,
3934
+ "font-size": formatNumber(10 / scale),
3935
+ "text-anchor": "middle",
3936
+ "dominant-baseline": "central"
3937
+ });
3938
+ text.textContent = label;
3939
+ group.appendChild(text);
2728
3940
  return group;
2729
3941
  }
2730
3942
  renderResizeControls(node) {
2731
3943
  const scale = this.documentData.viewport.scale || 1;
2732
3944
  const zone = 14 / scale;
2733
3945
  const cornerZone = 22 / scale;
2734
- const handleSize = 9 / scale;
3946
+ const handleSize = RESIZE_HANDLE_SIZE / scale;
2735
3947
  const gap = RESIZE_PORT_GAP / scale;
2736
3948
  const group = svgEl("g", { class: "vd-flowchart-resize-controls" });
2737
3949
  const zones = {
@@ -2761,12 +3973,6 @@ var VdFlowchart = class {
2761
3973
  };
2762
3974
  RESIZE_HANDLES.forEach((handle) => {
2763
3975
  const zoneRect = zones[handle];
2764
- const position = getResizeHandlePosition(node, handle);
2765
- const dotOffset = 8 / scale;
2766
- if (handle.includes("e")) position.x += dotOffset;
2767
- if (handle.includes("w")) position.x -= dotOffset;
2768
- if (handle.includes("n")) position.y -= dotOffset;
2769
- if (handle.includes("s")) position.y += dotOffset;
2770
3976
  const cursor = getResizeCursor(handle);
2771
3977
  const zoneSegments = [];
2772
3978
  if (handle === "e" || handle === "w") {
@@ -2801,14 +4007,19 @@ var VdFlowchart = class {
2801
4007
  });
2802
4008
  group.appendChild(hit);
2803
4009
  });
4010
+ if (handle.length === 1) return;
4011
+ const position = getResizeHandlePosition(node, handle);
4012
+ const outset = SELECTION_OUTSET / scale;
4013
+ position.x += handle.includes("e") ? outset : -outset;
4014
+ position.y += handle.includes("s") ? outset : -outset;
2804
4015
  const dot = svgEl("rect", {
2805
4016
  class: "vd-flowchart-resize-handle",
2806
4017
  x: position.x - handleSize / 2,
2807
4018
  y: position.y - handleSize / 2,
2808
4019
  width: handleSize,
2809
4020
  height: handleSize,
2810
- rx: handleSize / 3,
2811
- ry: handleSize / 3,
4021
+ rx: handleSize / 5,
4022
+ ry: handleSize / 5,
2812
4023
  "data-node-id": node.id,
2813
4024
  "data-resize-handle": handle,
2814
4025
  style: `cursor: ${cursor}`
@@ -2817,6 +4028,21 @@ var VdFlowchart = class {
2817
4028
  });
2818
4029
  return group;
2819
4030
  }
4031
+ applyScaledNodeShape(node, shape) {
4032
+ const visual = nodeVisualScale(node);
4033
+ if (!shape || visual >= 0.999) return;
4034
+ const baseStroke = node.type === "junction" ? 1.25 : 1.5;
4035
+ shape.style.strokeWidth = String(formatNumber(Math.max(0.03, baseStroke * visual)));
4036
+ if (node.type === "textbox") {
4037
+ shape.style.strokeDasharray = `${formatNumber(8 * visual)} ${formatNumber(5 * visual)}`;
4038
+ }
4039
+ const baseRadius = node.type === "rounded-rect" ? 18 : node.type === "textbox" ? 14 : node.type === "rect" ? 2 : 0;
4040
+ if (baseRadius > 0) {
4041
+ const scaled = String(formatNumber(baseRadius * visual));
4042
+ shape.setAttribute("rx", scaled);
4043
+ shape.setAttribute("ry", scaled);
4044
+ }
4045
+ }
2820
4046
  renderNodeShape(node) {
2821
4047
  const baseClass = `vd-flowchart-node-shape vd-flowchart-node-shape--${node.type}`;
2822
4048
  if (node.type === "rounded-rect") {
@@ -2893,21 +4119,28 @@ var VdFlowchart = class {
2893
4119
  if (node.type === "junction" || !node.text) {
2894
4120
  return textElement;
2895
4121
  }
4122
+ const visual = nodeVisualScale(node);
4123
+ const metrics = getNodeFontMetrics(node);
4124
+ const lineHeight = visual < 0.999 ? Math.max(0.06, metrics.lineHeight * visual) : metrics.lineHeight;
4125
+ if (visual < 0.999) {
4126
+ textElement.style.fontSize = `${formatNumber(Math.max(0.05, metrics.fontSize * visual))}px`;
4127
+ }
4128
+ const equivalentWidth = visual < 0.999 ? node.width / visual : node.width;
2896
4129
  if (node.type === "textbox") {
2897
- const lines2 = wrapText(node.text, estimateChars(node.width - 32));
2898
- const { lineHeight: lineHeight2 } = getNodeFontMetrics(node);
4130
+ const lines2 = wrapText(node.text, estimateChars(equivalentWidth - 32));
4131
+ const padX = visual < 0.999 ? formatNumber(16 * visual) : 16;
4132
+ const startY2 = visual < 0.999 ? formatNumber(28 * visual) : 28;
2899
4133
  lines2.forEach((line, index) => {
2900
4134
  const span = svgEl("tspan", {
2901
- x: 16,
2902
- y: 28 + index * lineHeight2
4135
+ x: padX,
4136
+ y: visual < 0.999 ? formatNumber(startY2 + index * lineHeight) : startY2 + index * lineHeight
2903
4137
  });
2904
4138
  span.textContent = line;
2905
4139
  textElement.appendChild(span);
2906
4140
  });
2907
4141
  return textElement;
2908
4142
  }
2909
- const lines = wrapText(node.text, estimateChars(node.width));
2910
- const { lineHeight } = getNodeFontMetrics(node);
4143
+ const lines = wrapText(node.text, estimateChars(equivalentWidth));
2911
4144
  const totalHeight = (lines.length - 1) * lineHeight;
2912
4145
  const startY = node.height / 2 - totalHeight / 2;
2913
4146
  textElement.setAttribute("text-anchor", "middle");
@@ -2915,7 +4148,7 @@ var VdFlowchart = class {
2915
4148
  lines.forEach((line, index) => {
2916
4149
  const span = svgEl("tspan", {
2917
4150
  x: node.width / 2,
2918
- y: startY + index * lineHeight,
4151
+ y: visual < 0.999 ? formatNumber(startY + index * lineHeight) : startY + index * lineHeight,
2919
4152
  "text-anchor": "middle",
2920
4153
  "dominant-baseline": "middle"
2921
4154
  });
@@ -2937,6 +4170,16 @@ var VdFlowchart = class {
2937
4170
  );
2938
4171
  return;
2939
4172
  }
4173
+ if (this.hasMultiSelection()) {
4174
+ this.selectionMeta.textContent = `${this.selectedNodeIds.size} nodes selected`;
4175
+ this.selectionFields.appendChild(
4176
+ createElement("p", {
4177
+ className: "vd-flowchart-selection-empty",
4178
+ text: "Drag, nudge with Alt+Arrow, copy, duplicate, or delete them together."
4179
+ })
4180
+ );
4181
+ return;
4182
+ }
2940
4183
  if (this.selection.kind === "node") {
2941
4184
  const node = this.findNode(this.selection.id);
2942
4185
  if (!node) {
@@ -2957,12 +4200,12 @@ var VdFlowchart = class {
2957
4200
  textArea.setAttribute("data-field", "node-text");
2958
4201
  const widthInput = createElement("input", { value: node.width, type: "number" });
2959
4202
  widthInput.setAttribute("data-field", "node-width");
2960
- widthInput.setAttribute("min", String(MIN_NODE_SIZE));
2961
- widthInput.setAttribute("max", String(MAX_NODE_SIZE));
4203
+ widthInput.setAttribute("min", String(this.minNodeSize));
4204
+ widthInput.setAttribute("max", String(this.maxNodeSize));
2962
4205
  const heightInput = createElement("input", { value: node.height, type: "number" });
2963
4206
  heightInput.setAttribute("data-field", "node-height");
2964
- heightInput.setAttribute("min", String(MIN_NODE_SIZE));
2965
- heightInput.setAttribute("max", String(MAX_NODE_SIZE));
4207
+ heightInput.setAttribute("min", String(this.minNodeSize));
4208
+ heightInput.setAttribute("max", String(this.maxNodeSize));
2966
4209
  this.selectionFields.appendChild(createField("Type", typeSelect));
2967
4210
  if (isNodeTextEditable(node)) {
2968
4211
  this.selectionFields.appendChild(createField("Text", textArea));
@@ -3051,16 +4294,69 @@ var VdFlowchart = class {
3051
4294
  }
3052
4295
  select(selection) {
3053
4296
  const next = selection && selection.id && selection.kind ? { kind: selection.kind, id: selection.id } : null;
3054
- const previousKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3055
- const nextKey = next ? `${next.kind}:${next.id}` : "";
3056
- this.selection = next;
3057
- if (previousKey !== nextKey) {
4297
+ this.applySelection(next, next?.kind === "node" ? [next.id] : []);
4298
+ }
4299
+ // Single funnel for selection changes: `selection` is the primary item and
4300
+ // `selectedNodeIds` every selected node (the primary one included).
4301
+ applySelection(primary, nodeIds) {
4302
+ const previousKey = this.getSelectionKey();
4303
+ this.selection = primary;
4304
+ this.selectedNodeIds = new Set(nodeIds);
4305
+ if (primary?.kind === "node") this.selectedNodeIds.add(primary.id);
4306
+ if (previousKey !== this.getSelectionKey()) {
4307
+ this.edgeCycleAnchor = null;
3058
4308
  this.render({ scene: true, inspector: true, json: false });
3059
- this.emit("select", { selection: this.getSelectionSnapshot() });
4309
+ this.emit("select", {
4310
+ selection: this.getSelectionSnapshot(),
4311
+ nodeIds: this.getSelectedNodeIds()
4312
+ });
3060
4313
  return;
3061
4314
  }
3062
4315
  this.render({ scene: true, inspector: false, json: false });
3063
4316
  }
4317
+ getSelectionKey() {
4318
+ const primary = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
4319
+ return `${primary}|${[...this.selectedNodeIds || []].sort().join(",")}`;
4320
+ }
4321
+ // Keep the set valid after mutations that bypass applySelection (undo,
4322
+ // load, removal): drop missing nodes and keep the primary node in the set.
4323
+ syncSelectedNodeIds() {
4324
+ if (this.selection?.kind !== "node") {
4325
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
4326
+ return;
4327
+ }
4328
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4329
+ const hidden = this.getHiddenNodeIds();
4330
+ this.selectedNodeIds = new Set(
4331
+ [...this.selectedNodeIds || []].filter((id) => existing.has(id) && !hidden.has(id))
4332
+ );
4333
+ this.selectedNodeIds.add(this.selection.id);
4334
+ }
4335
+ getSelectedNodeIds() {
4336
+ return [...this.selectedNodeIds || []];
4337
+ }
4338
+ isNodeSelected(nodeId) {
4339
+ return Boolean(this.selectedNodeIds?.has(nodeId));
4340
+ }
4341
+ hasMultiSelection() {
4342
+ return (this.selectedNodeIds?.size || 0) > 1;
4343
+ }
4344
+ /** Select several nodes; `primary` (default: the last id) drives single-node UI. */
4345
+ selectNodes(nodeIds, options = {}) {
4346
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4347
+ const ids = [...new Set((nodeIds || []).map(sanitizeId))].filter((id) => existing.has(id));
4348
+ const primaryId = ids.includes(options.primary) ? options.primary : ids[ids.length - 1];
4349
+ this.applySelection(primaryId ? { kind: "node", id: primaryId } : null, ids);
4350
+ return this;
4351
+ }
4352
+ toggleNodeSelection(nodeId) {
4353
+ const ids = this.selection?.kind === "node" ? this.getSelectedNodeIds() : [];
4354
+ if (ids.includes(nodeId)) {
4355
+ const rest = ids.filter((id) => id !== nodeId);
4356
+ return this.selectNodes(rest, { primary: rest[rest.length - 1] });
4357
+ }
4358
+ return this.selectNodes([...ids, nodeId], { primary: nodeId });
4359
+ }
3064
4360
  selectNode(nodeId) {
3065
4361
  this.select({ kind: "node", id: sanitizeId(nodeId) });
3066
4362
  return this;
@@ -3081,6 +4377,69 @@ var VdFlowchart = class {
3081
4377
  findNode(nodeId) {
3082
4378
  return this.documentData.nodes.find((node) => node.id === nodeId) || null;
3083
4379
  }
4380
+ getHiddenNodeIds() {
4381
+ return computeHiddenNodeIds(this.documentData.nodes, this.documentData.edges);
4382
+ }
4383
+ getVisibleNodes() {
4384
+ const hidden = this.getHiddenNodeIds();
4385
+ return hidden.size ? this.documentData.nodes.filter((node) => !hidden.has(node.id)) : this.documentData.nodes;
4386
+ }
4387
+ isNodeHidden(nodeId) {
4388
+ return this.getHiddenNodeIds().has(nodeId);
4389
+ }
4390
+ getOutgoingMap() {
4391
+ const outgoing = /* @__PURE__ */ new Map();
4392
+ this.documentData.edges.forEach((edge) => {
4393
+ if (!outgoing.has(edge.from.nodeId)) outgoing.set(edge.from.nodeId, []);
4394
+ outgoing.get(edge.from.nodeId).push(edge.to.nodeId);
4395
+ });
4396
+ return outgoing;
4397
+ }
4398
+ // Hidden nodes under one collapsed node (its "+N" count).
4399
+ countCollapsedDescendants(nodeId, hidden = this.getHiddenNodeIds(), outgoing = this.getOutgoingMap()) {
4400
+ const seen = /* @__PURE__ */ new Set();
4401
+ const queue = [nodeId];
4402
+ for (let index = 0; index < queue.length; index += 1) {
4403
+ (outgoing.get(queue[index]) || []).forEach((childId) => {
4404
+ if (seen.has(childId) || !hidden.has(childId)) return;
4405
+ seen.add(childId);
4406
+ queue.push(childId);
4407
+ });
4408
+ }
4409
+ return seen.size;
4410
+ }
4411
+ setCollapsed(nodeId, collapsed) {
4412
+ if (this.readonly) return false;
4413
+ const node = this.findNode(sanitizeId(nodeId));
4414
+ if (!node) return false;
4415
+ const next = Boolean(collapsed);
4416
+ if (Boolean(node.collapsed) === next) return false;
4417
+ if (next && !this.getChildNodes(node.id).length) return false;
4418
+ if (next) node.collapsed = true;
4419
+ else delete node.collapsed;
4420
+ if (this.selection?.kind === "node") {
4421
+ const hidden = this.getHiddenNodeIds();
4422
+ const visibleIds = this.getSelectedNodeIds().filter((id) => !hidden.has(id));
4423
+ if (hidden.has(this.selection.id)) {
4424
+ this.select({ kind: "node", id: node.id });
4425
+ } else if (visibleIds.length !== this.selectedNodeIds.size) {
4426
+ this.selectNodes(visibleIds, { primary: this.selection.id });
4427
+ }
4428
+ } else if (this.selection?.kind === "edge") {
4429
+ const edge = this.findEdge(this.selection.id);
4430
+ const hidden = this.getHiddenNodeIds();
4431
+ if (edge && (hidden.has(edge.from.nodeId) || hidden.has(edge.to.nodeId))) {
4432
+ this.select({ kind: "node", id: node.id });
4433
+ }
4434
+ }
4435
+ this.render({ inspector: true, json: true });
4436
+ this.emitChange("node:collapse", { node: deepClone(node) });
4437
+ return true;
4438
+ }
4439
+ toggleCollapsed(nodeId) {
4440
+ const node = this.findNode(sanitizeId(nodeId));
4441
+ return node ? this.setCollapsed(node.id, !node.collapsed) : false;
4442
+ }
3084
4443
  findEdge(edgeId) {
3085
4444
  return this.documentData.edges.find((edge) => edge.id === edgeId) || null;
3086
4445
  }
@@ -3091,7 +4450,8 @@ var VdFlowchart = class {
3091
4450
  }
3092
4451
  emitChange(reason, extra = {}) {
3093
4452
  if (this.historyEnabled && !this.isApplyingHistory) {
3094
- this.recordHistory(reason);
4453
+ if (this.historyBatchDepth > 0) this.historyBatchDirty = true;
4454
+ else this.recordHistory(reason);
3095
4455
  }
3096
4456
  this.syncJsonTextarea();
3097
4457
  this.emit("change", {
@@ -3135,7 +4495,9 @@ var VdFlowchart = class {
3135
4495
  const snapshot = this.toJSON();
3136
4496
  const targetKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3137
4497
  const top = this.history[this.historyIndex];
3138
- const canCoalesce = Boolean(top) && this.historyIndex === this.history.length - 1 && COALESCING_REASONS.has(reason) && top.reason === reason && top.targetKey === targetKey;
4498
+ const atTip = Boolean(top) && this.historyIndex === this.history.length - 1;
4499
+ const namesInsertedNode = this.coalesceInsertedText && top?.reason === "node:insert" && reason === "node:update";
4500
+ const canCoalesce = atTip && top.targetKey === targetKey && (namesInsertedNode || COALESCING_REASONS.has(reason) && top.reason === reason);
3139
4501
  if (canCoalesce) {
3140
4502
  top.snapshot = snapshot;
3141
4503
  return;
@@ -3157,8 +4519,8 @@ var VdFlowchart = class {
3157
4519
  this.reconnectEdgeId = null;
3158
4520
  const currentViewport = deepClone(this.documentData.viewport);
3159
4521
  const previousSelection = this.selection;
3160
- this.documentData = normalizeDocument(snapshot);
3161
- this.documentData.viewport = normalizeViewport(currentViewport);
4522
+ this.documentData = normalizeDocument(snapshot, this.normalizationLimits());
4523
+ this.documentData.viewport = normalizeViewport(currentViewport, this.scaleBounds());
3162
4524
  this.selection = this.resolvePreservedSelection(previousSelection, true);
3163
4525
  this.render();
3164
4526
  this.emitChange(reason);
@@ -3235,7 +4597,8 @@ var VdFlowchart = class {
3235
4597
  y: partialNode.y == null ? fallbackY : partialNode.y
3236
4598
  },
3237
4599
  this.documentData.nodes.length,
3238
- usedIds
4600
+ usedIds,
4601
+ this.nodeSizeBounds()
3239
4602
  );
3240
4603
  this.documentData.nodes.push(node);
3241
4604
  this.select({ kind: "node", id: node.id });
@@ -3251,19 +4614,180 @@ var VdFlowchart = class {
3251
4614
  const parent = this.findNode(sanitizeId(parentId));
3252
4615
  if (!parent) return null;
3253
4616
  const { direction = "right", distance, angle, edge: edgeOptions, ...nodeOptions } = options;
3254
- const node = this.addNode({
3255
- ...nodeOptions,
3256
- relativeTo: { node: parent.id, direction, distance, angle }
4617
+ return this.batchHistory("node:add-child", () => {
4618
+ const node = this.addNode({
4619
+ ...nodeOptions,
4620
+ relativeTo: { node: parent.id, direction, distance, angle }
4621
+ });
4622
+ const edge = this.addEdge({
4623
+ from: parent.id,
4624
+ to: node.id,
4625
+ autoPort: true,
4626
+ endMarker: "arrow",
4627
+ ...isPlainObject(edgeOptions) ? edgeOptions : {}
4628
+ });
4629
+ this.select({ kind: "node", id: node.id });
4630
+ return { node, edge };
3257
4631
  });
3258
- const edge = this.addEdge({
3259
- from: parent.id,
3260
- to: node.id,
3261
- autoPort: true,
3262
- endMarker: "arrow",
3263
- ...isPlainObject(edgeOptions) ? edgeOptions : {}
4632
+ }
4633
+ // Run `fn` with history recording suspended, then record a single entry for
4634
+ // everything it changed (change events still fire for each mutation).
4635
+ batchHistory(reason, fn) {
4636
+ this.historyBatchDepth += 1;
4637
+ let result;
4638
+ try {
4639
+ result = fn();
4640
+ } finally {
4641
+ this.historyBatchDepth -= 1;
4642
+ }
4643
+ if (this.historyBatchDepth === 0 && this.historyBatchDirty) {
4644
+ this.historyBatchDirty = false;
4645
+ if (this.historyEnabled && !this.isApplyingHistory) this.recordHistory(reason);
4646
+ }
4647
+ return result;
4648
+ }
4649
+ // Mind-map structure: a node's parent is the source of its first incoming
4650
+ // edge; its children are the targets of its outgoing edges. The side a
4651
+ // branch is on is the port its edge leaves the parent from, which is what
4652
+ // the user sees (centre geometry is ambiguous for diagonal nodes).
4653
+ getParentEdge(nodeId) {
4654
+ return this.documentData.edges.find(
4655
+ (edge) => edge.to.nodeId === nodeId && edge.from.nodeId !== nodeId
4656
+ ) || null;
4657
+ }
4658
+ getParentNode(nodeId) {
4659
+ const edge = this.getParentEdge(nodeId);
4660
+ return edge ? this.findNode(edge.from.nodeId) : null;
4661
+ }
4662
+ getChildNodes(nodeId, direction = null) {
4663
+ const port = direction ? DIRECTION_PORTS[direction] : null;
4664
+ const ids = /* @__PURE__ */ new Set();
4665
+ this.documentData.edges.forEach((edge) => {
4666
+ if (edge.from.nodeId !== nodeId || edge.to.nodeId === nodeId) return;
4667
+ if (!port || edge.from.port === port) ids.add(edge.to.nodeId);
4668
+ });
4669
+ return this.documentData.nodes.filter((node) => ids.has(node.id));
4670
+ }
4671
+ // Direction a branch grows from `node`: the side its parent edge leaves
4672
+ // from, or the emptier horizontal side for a root.
4673
+ getGrowthDirection(node) {
4674
+ const parentEdge = this.getParentEdge(node.id);
4675
+ if (parentEdge) return PORT_DIRECTIONS[parentEdge.from.port] || "right";
4676
+ const right = this.getChildNodes(node.id, "right").length;
4677
+ const left = this.getChildNodes(node.id, "left").length;
4678
+ return left < right ? "left" : "right";
4679
+ }
4680
+ // Shape for a new branch node: copy a sibling when there is one, otherwise
4681
+ // the parent's box shape, falling back to a rounded step.
4682
+ getBranchTemplate(parent, sibling) {
4683
+ const source = sibling || (["rounded-rect", "rect", "textbox"].includes(parent.type) ? parent : null) || null;
4684
+ if (source) return { type: source.type, width: source.width, height: source.height };
4685
+ const spec = DEFAULT_NODE_SPECS["rounded-rect"];
4686
+ return { type: "rounded-rect", width: spec.width, height: spec.height };
4687
+ }
4688
+ /**
4689
+ * Add a node connected from `parentId` on the `direction` side (default: the
4690
+ * branch's growth direction), stacked after `anchorId` or the last sibling on
4691
+ * that side. The node and its edge are one undo step; the new edge copies a
4692
+ * sibling's (or the parent's incoming) edge style. With `edit: true` the
4693
+ * label editor opens with the text selected.
4694
+ */
4695
+ insertBranchNode(parentId, options = {}) {
4696
+ if (this.readonly) return null;
4697
+ const parent = this.findNode(sanitizeId(parentId));
4698
+ if (!parent) return null;
4699
+ const direction = DIRECTION_PORTS[options.direction] ? options.direction : this.getGrowthDirection(parent);
4700
+ const siblings = this.getChildNodes(parent.id, direction);
4701
+ const anchor = options.anchorId ? this.findNode(sanitizeId(options.anchorId)) : null;
4702
+ const lastSibling = siblings[siblings.length - 1] || null;
4703
+ const template = this.getBranchTemplate(parent, anchor || lastSibling);
4704
+ const position = placeBranchNode({
4705
+ nodes: this.documentData.nodes,
4706
+ parent,
4707
+ siblings,
4708
+ direction,
4709
+ size: template,
4710
+ anchor,
4711
+ before: Boolean(options.before)
4712
+ });
4713
+ const styleSource = this.documentData.edges.find(
4714
+ (edge) => edge.from.nodeId === parent.id && edge.to.nodeId === (anchor || lastSibling)?.id
4715
+ ) || this.documentData.edges.find((edge) => edge.to.nodeId === parent.id);
4716
+ const edgeStyle = styleSource ? {
4717
+ route: styleSource.route,
4718
+ strokeWidth: styleSource.strokeWidth,
4719
+ startMarker: styleSource.startMarker,
4720
+ endMarker: styleSource.endMarker
4721
+ } : { endMarker: "arrow" };
4722
+ const result = this.batchHistory("node:insert", () => {
4723
+ if (parent.collapsed) delete parent.collapsed;
4724
+ const node = this.addNode({ ...template, x: position.x, y: position.y });
4725
+ const edge = this.addEdge({
4726
+ from: { nodeId: parent.id, port: DIRECTION_PORTS[direction] },
4727
+ to: { nodeId: node.id, port: DIRECTION_PORTS[oppositeDirection(direction)] },
4728
+ ...edgeStyle
4729
+ });
4730
+ if (edge && anchor) this.placeEdgeBySibling(edge.id, parent.id, anchor.id, options.before);
4731
+ if (this.autoLayout && this.layoutMode !== "grid") this.layout(this.layoutMode);
4732
+ this.select({ kind: "node", id: node.id });
4733
+ return { node, edge };
4734
+ });
4735
+ this.revealNode(result.node.id);
4736
+ if (options.edit) this.startTextEdit(result.node.id, { inserted: true });
4737
+ return result;
4738
+ }
4739
+ // Tree and radial layouts order children by connection order, so a sibling's
4740
+ // connection goes right after (or before) its anchor's instead of last.
4741
+ placeEdgeBySibling(edgeId, parentId, anchorId, before = false) {
4742
+ const edges = this.documentData.edges;
4743
+ const fromIndex = edges.findIndex((edge) => edge.id === edgeId);
4744
+ if (fromIndex === -1) return;
4745
+ const [moved] = edges.splice(fromIndex, 1);
4746
+ const anchorIndex = edges.findIndex(
4747
+ (edge) => edge.from.nodeId === parentId && edge.to.nodeId === anchorId
4748
+ );
4749
+ if (anchorIndex === -1) edges.push(moved);
4750
+ else edges.splice(before ? anchorIndex : anchorIndex + 1, 0, moved);
4751
+ }
4752
+ // Insert a sibling after (or before) `nodeId`; a node without a parent gets a
4753
+ // child instead, which is what Enter on a mind-map root means.
4754
+ insertSiblingNode(nodeId, options = {}) {
4755
+ if (this.readonly) return null;
4756
+ const node = this.findNode(sanitizeId(nodeId));
4757
+ if (!node) return null;
4758
+ const parentEdge = this.getParentEdge(node.id);
4759
+ const parent = parentEdge ? this.findNode(parentEdge.from.nodeId) : null;
4760
+ if (!parent) return this.insertBranchNode(node.id, { edit: options.edit });
4761
+ return this.insertBranchNode(parent.id, {
4762
+ direction: PORT_DIRECTIONS[parentEdge.from.port],
4763
+ anchorId: node.id,
4764
+ before: Boolean(options.before),
4765
+ edit: options.edit
3264
4766
  });
3265
- this.select({ kind: "node", id: node.id });
3266
- return { node, edge };
4767
+ }
4768
+ // Pan just enough to bring a node fully into view (with a small margin).
4769
+ revealNode(nodeId) {
4770
+ const node = this.findNode(nodeId);
4771
+ const width = this.canvasEl.clientWidth;
4772
+ const height = this.canvasEl.clientHeight;
4773
+ if (!node || !width || !height) return;
4774
+ const viewport = this.documentData.viewport;
4775
+ const margin = 32;
4776
+ const left = viewport.x + node.x * viewport.scale;
4777
+ const top = viewport.y + node.y * viewport.scale;
4778
+ const right = left + node.width * viewport.scale;
4779
+ const bottom = top + node.height * viewport.scale;
4780
+ let dx = 0;
4781
+ let dy = 0;
4782
+ if (right > width - margin) dx = width - margin - right;
4783
+ if (left + dx < margin) dx = margin - left;
4784
+ if (bottom > height - margin) dy = height - margin - bottom;
4785
+ if (top + dy < margin) dy = margin - top;
4786
+ if (!dx && !dy) return;
4787
+ viewport.x = formatNumber(viewport.x + dx);
4788
+ viewport.y = formatNumber(viewport.y + dy);
4789
+ this.render({ inspector: false, json: true });
4790
+ this.emitViewportChange("viewport:pan");
3267
4791
  }
3268
4792
  // Arrange nodes with a built-in layout. Positions are computed by the pure
3269
4793
  // computeLayout() module, then applied in place (NOT via load(), which would
@@ -3327,8 +4851,8 @@ var VdFlowchart = class {
3327
4851
  node.x = patch.x == null ? node.x : formatNumber(toFiniteNumber(patch.x, node.x));
3328
4852
  node.y = patch.y == null ? node.y : formatNumber(toFiniteNumber(patch.y, node.y));
3329
4853
  node.text = isNodeTextEditable(nextType) ? patch.text == null ? node.text : String(patch.text) : getNodeSpec(nextType).text;
3330
- node.width = clampNodeWidth(nextType, patch.width, node.width);
3331
- node.height = clampNodeHeight(nextType, patch.height, node.height);
4854
+ node.width = clampNodeWidth(nextType, patch.width, node.width, this.nodeSizeBounds());
4855
+ node.height = clampNodeHeight(nextType, patch.height, node.height, this.nodeSizeBounds());
3332
4856
  if (isPlainObject(patch.data)) {
3333
4857
  node.data = deepClone(patch.data);
3334
4858
  }
@@ -3511,11 +5035,51 @@ var VdFlowchart = class {
3511
5035
  }
3512
5036
  deleteSelection() {
3513
5037
  if (!this.selection || this.readonly) return false;
5038
+ if (this.hasMultiSelection()) {
5039
+ return this.removeNodeIds(new Set(this.getSelectedNodeIds()), "node:remove");
5040
+ }
3514
5041
  if (this.selection.kind === "node") return this.removeNode(this.selection.id);
3515
5042
  return this.removeEdge(this.selection.id);
3516
5043
  }
5044
+ nodeSizeBounds() {
5045
+ return { min: this.minNodeSize, max: this.maxNodeSize };
5046
+ }
5047
+ scaleBounds() {
5048
+ return { min: this.minScale, max: this.maxScale };
5049
+ }
5050
+ normalizationLimits() {
5051
+ return { scaleBounds: this.scaleBounds(), sizeBounds: this.nodeSizeBounds() };
5052
+ }
5053
+ hasWideCamera() {
5054
+ return this.minScale < DEFAULT_MIN_SCALE || this.maxScale > DEFAULT_MAX_SCALE;
5055
+ }
5056
+ syncGrid() {
5057
+ const wide = this.hasWideCamera();
5058
+ const extent = wide ? WIDE_WORLD_EXTENT : DEFAULT_WORLD_EXTENT;
5059
+ this.gridRect?.setAttribute("x", String(-extent / 2));
5060
+ this.gridRect?.setAttribute("y", String(-extent / 2));
5061
+ this.gridRect?.setAttribute("width", String(extent));
5062
+ this.gridRect?.setAttribute("height", String(extent));
5063
+ const pattern = this.svg?.querySelector("pattern");
5064
+ const path = pattern?.querySelector("path");
5065
+ if (!pattern || !path) return;
5066
+ let tile = this.gridSize;
5067
+ if (wide) {
5068
+ const scale = Math.max(this.minScale, this.documentData?.viewport?.scale || 1);
5069
+ while (tile * scale < 12 && tile < extent / 8) tile *= 4;
5070
+ while (tile * scale > 48 && tile > 1.5) tile /= 4;
5071
+ tile = formatNumber(tile);
5072
+ path.setAttribute("vector-effect", "non-scaling-stroke");
5073
+ } else {
5074
+ path.removeAttribute("vector-effect");
5075
+ }
5076
+ path.setAttribute("stroke-width", "1");
5077
+ pattern.setAttribute("width", String(tile));
5078
+ pattern.setAttribute("height", String(tile));
5079
+ path.setAttribute("d", `M ${tile} 0 L 0 0 0 ${tile}`);
5080
+ }
3517
5081
  setViewport(viewport) {
3518
- this.documentData.viewport = normalizeViewport(viewport);
5082
+ this.documentData.viewport = normalizeViewport(viewport, this.scaleBounds());
3519
5083
  this.render({ inspector: false, json: true });
3520
5084
  this.emitViewportChange("viewport:set");
3521
5085
  return this;
@@ -3533,45 +5097,80 @@ var VdFlowchart = class {
3533
5097
  return this;
3534
5098
  }
3535
5099
  resetView() {
3536
- this.documentData.viewport = normalizeViewport({ x: 0, y: 0, scale: 1 });
5100
+ this.documentData.viewport = normalizeViewport({ x: 0, y: 0, scale: 1 }, this.scaleBounds());
3537
5101
  this.render({ inspector: false, json: true });
3538
5102
  this.emitViewportChange("viewport:reset");
3539
5103
  return this;
3540
5104
  }
3541
5105
  fitView() {
3542
- if (!this.documentData.nodes.length) {
5106
+ const nodes = this.getVisibleNodes();
5107
+ if (!nodes.length) {
3543
5108
  return this.resetView();
3544
5109
  }
3545
- const bounds = getBounds(this.documentData.nodes);
5110
+ const bounds = getBounds(nodes);
3546
5111
  const width = this.canvasEl.clientWidth || 800;
3547
5112
  const height = this.canvasEl.clientHeight || 560;
3548
5113
  const padding = 80;
3549
5114
  const contentWidth = Math.max(1, bounds.right - bounds.left);
3550
5115
  const contentHeight = Math.max(1, bounds.bottom - bounds.top);
3551
- const scale = clamp(
3552
- Math.min((width - padding * 2) / contentWidth, (height - padding * 2) / contentHeight),
3553
- MIN_SCALE,
3554
- MAX_SCALE
5116
+ const scale = formatScale(
5117
+ clamp(
5118
+ Math.min((width - padding * 2) / contentWidth, (height - padding * 2) / contentHeight),
5119
+ this.minScale,
5120
+ this.maxScale
5121
+ )
3555
5122
  );
3556
5123
  this.documentData.viewport = {
3557
5124
  x: formatNumber(width / 2 - (bounds.left + bounds.right) / 2 * scale),
3558
5125
  y: formatNumber(height / 2 - (bounds.top + bounds.bottom) / 2 * scale),
3559
- scale: formatNumber(scale)
5126
+ scale
3560
5127
  };
3561
5128
  this.render({ inspector: false, json: true });
3562
5129
  this.emitViewportChange("viewport:fit");
3563
5130
  return this;
3564
5131
  }
5132
+ fitNode(nodeId, padding = 48) {
5133
+ const node = this.findNode(nodeId);
5134
+ if (!node || this.isNodeHidden(node.id)) return this;
5135
+ const pad = Math.max(0, toFiniteNumber(padding, 48));
5136
+ const width = this.canvasEl.clientWidth || 800;
5137
+ const height = this.canvasEl.clientHeight || 560;
5138
+ const availableWidth = Math.max(1, width - pad * 2);
5139
+ const availableHeight = Math.max(1, height - pad * 2);
5140
+ const scale = formatScale(
5141
+ clamp(
5142
+ Math.min(
5143
+ availableWidth / Math.max(1, node.width),
5144
+ availableHeight / Math.max(1, node.height)
5145
+ ),
5146
+ this.minScale,
5147
+ this.maxScale
5148
+ )
5149
+ );
5150
+ const centerX = node.x + node.width / 2;
5151
+ const centerY = node.y + node.height / 2;
5152
+ this.documentData.viewport = {
5153
+ x: formatNumber(width / 2 - centerX * scale),
5154
+ y: formatNumber(height / 2 - centerY * scale),
5155
+ scale
5156
+ };
5157
+ this.render({ inspector: false, json: true });
5158
+ this.emitViewportChange("viewport:fit-node");
5159
+ return this;
5160
+ }
3565
5161
  clear() {
3566
5162
  this.stopTextEdit({ commit: false });
3567
5163
  this.activeTool = null;
3568
5164
  this.reconnectEdgeId = null;
3569
5165
  this.clipboard = null;
3570
- this.documentData = normalizeDocument({
3571
- nodes: [],
3572
- edges: [],
3573
- viewport: { x: 0, y: 0, scale: 1 }
3574
- });
5166
+ this.documentData = normalizeDocument(
5167
+ {
5168
+ nodes: [],
5169
+ edges: [],
5170
+ viewport: { x: 0, y: 0, scale: 1 }
5171
+ },
5172
+ this.normalizationLimits()
5173
+ );
3575
5174
  this.selection = null;
3576
5175
  this.render();
3577
5176
  this.emitChange("clear");
@@ -3600,6 +5199,41 @@ var VdFlowchart = class {
3600
5199
  pattern.querySelector("path").setAttribute("d", `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`);
3601
5200
  }
3602
5201
  if ("autoFit" in options) this.autoFit = Boolean(options.autoFit);
5202
+ if ("keyboardShortcuts" in options) {
5203
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
5204
+ }
5205
+ if ("snapGuides" in options) this.snapGuides = options.snapGuides !== false;
5206
+ if ("minimap" in options) this.setMinimapEnabled(options.minimap !== false);
5207
+ if ("autoLayout" in options) this.autoLayout = Boolean(options.autoLayout);
5208
+ if ("minScale" in options || "maxScale" in options || "minNodeSize" in options || "maxNodeSize" in options) {
5209
+ const nextScale = normalizeBoundPair(
5210
+ "minScale" in options ? options.minScale : this.minScale,
5211
+ "maxScale" in options ? options.maxScale : this.maxScale,
5212
+ DEFAULT_MIN_SCALE,
5213
+ DEFAULT_MAX_SCALE
5214
+ );
5215
+ const nextSize = normalizeBoundPair(
5216
+ "minNodeSize" in options ? options.minNodeSize : this.minNodeSize,
5217
+ "maxNodeSize" in options ? options.maxNodeSize : this.maxNodeSize,
5218
+ DEFAULT_MIN_NODE_SIZE,
5219
+ DEFAULT_MAX_NODE_SIZE
5220
+ );
5221
+ this.minScale = nextScale.min;
5222
+ this.maxScale = nextScale.max;
5223
+ this.minNodeSize = nextSize.min;
5224
+ this.maxNodeSize = nextSize.max;
5225
+ const clamped = clamp(
5226
+ formatScale(this.documentData.viewport.scale),
5227
+ this.minScale,
5228
+ this.maxScale
5229
+ );
5230
+ if (clamped !== this.documentData.viewport.scale) {
5231
+ this.documentData.viewport.scale = clamped;
5232
+ this.pendingBoundsEvent = true;
5233
+ }
5234
+ }
5235
+ this.syncCanvasLabel();
5236
+ if (this.shortcutsHelpOpen) this.renderShortcutsHelp();
3603
5237
  if ("history" in options && options.history !== false !== this.historyEnabled) {
3604
5238
  this.historyEnabled = options.history !== false;
3605
5239
  this.history = [];
@@ -3615,11 +5249,15 @@ var VdFlowchart = class {
3615
5249
  }
3616
5250
  }
3617
5251
  this.render();
5252
+ if (this.pendingBoundsEvent) {
5253
+ this.pendingBoundsEvent = false;
5254
+ this.emitViewportChange("viewport:bounds");
5255
+ }
3618
5256
  this.updateHistoryButtons();
3619
5257
  return this;
3620
5258
  }
3621
5259
  load(data, options = {}) {
3622
- const documentData = normalizeDocument(data);
5260
+ const documentData = normalizeDocument(data, this.normalizationLimits());
3623
5261
  if (options.silent && JSON.stringify(documentData) === JSON.stringify(this.documentData)) {
3624
5262
  return this;
3625
5263
  }
@@ -3648,6 +5286,10 @@ var VdFlowchart = class {
3648
5286
  destroy() {
3649
5287
  if (this.destroyed) return;
3650
5288
  this.destroyed = true;
5289
+ if (this.minimapFrame && typeof cancelAnimationFrame === "function") {
5290
+ cancelAnimationFrame(this.minimapFrame);
5291
+ }
5292
+ this.minimapFrame = 0;
3651
5293
  this.activeTool = null;
3652
5294
  this.stopTextEdit({ commit: false });
3653
5295
  this.resizeObserver?.disconnect();
@@ -3664,6 +5306,21 @@ var VdFlowchart = class {
3664
5306
 
3665
5307
  // src/vue.js
3666
5308
  var FORWARDED_EVENTS = ["change", "select", "viewport", "connect", "ready"];
5309
+ var EDITOR_OPTION_KEYS = [
5310
+ "readonly",
5311
+ "gridSize",
5312
+ "autoFit",
5313
+ "history",
5314
+ "historyLimit",
5315
+ "keyboardShortcuts",
5316
+ "snapGuides",
5317
+ "minimap",
5318
+ "autoLayout",
5319
+ "minScale",
5320
+ "maxScale",
5321
+ "minNodeSize",
5322
+ "maxNodeSize"
5323
+ ];
3667
5324
  var VdFlowchart2 = (0, import_vue.defineComponent)({
3668
5325
  name: "VdFlowchart",
3669
5326
  props: {
@@ -3680,21 +5337,42 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
3680
5337
  /** Enable the built-in undo/redo history (default true). */
3681
5338
  history: { type: Boolean, default: true },
3682
5339
  /** Maximum number of history entries to retain. */
3683
- historyLimit: { type: Number, default: void 0 }
5340
+ historyLimit: { type: Number, default: void 0 },
5341
+ /** Keyboard model: 'mindmap' (Tab child, Enter sibling) or 'basic'. */
5342
+ keyboardShortcuts: { type: String, default: "mindmap" },
5343
+ /** Snap dragged nodes to other nodes' edges and centres (default true). */
5344
+ snapGuides: { type: Boolean, default: true },
5345
+ /** Show the overview minimap in the canvas corner (default true). */
5346
+ minimap: { type: Boolean, default: true },
5347
+ /** Re-run the current layout after keyboard or handle insertions (default false). */
5348
+ autoLayout: { type: Boolean, default: false },
5349
+ /** Lowest camera scale. Defaults to 0.35. Set it together with maxScale. */
5350
+ minScale: { type: Number, default: void 0 },
5351
+ /** Highest camera scale. Defaults to 3. Set it together with minScale. */
5352
+ maxScale: { type: Number, default: void 0 },
5353
+ /** Smallest resizable-node side. Defaults to 56. Set it together with maxNodeSize. */
5354
+ minNodeSize: { type: Number, default: void 0 },
5355
+ /** Largest resizable-node side. Defaults to 420. Set it together with minNodeSize. */
5356
+ maxNodeSize: { type: Number, default: void 0 }
3684
5357
  },
3685
5358
  emits: ["change", "select", "viewport", "connect", "ready"],
3686
5359
  setup(props, { emit, expose }) {
3687
5360
  const el = (0, import_vue.ref)(null);
3688
5361
  let instance = null;
5362
+ const editorOptions = () => Object.fromEntries(
5363
+ EDITOR_OPTION_KEYS.flatMap((key) => {
5364
+ const value = props[key];
5365
+ if ((key === "minScale" || key === "maxScale" || key === "minNodeSize" || key === "maxNodeSize") && value == null) {
5366
+ return [];
5367
+ }
5368
+ return [[key, value]];
5369
+ })
5370
+ );
3689
5371
  const create = () => {
3690
5372
  instance = new VdFlowchart({
3691
5373
  element: el.value,
3692
5374
  data: props.data,
3693
- readonly: props.readonly,
3694
- gridSize: props.gridSize,
3695
- autoFit: props.autoFit,
3696
- history: props.history,
3697
- historyLimit: props.historyLimit
5375
+ ...editorOptions()
3698
5376
  });
3699
5377
  FORWARDED_EVENTS.forEach((name) => {
3700
5378
  instance.on(name, (payload) => emit(name, payload));
@@ -3714,16 +5392,9 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
3714
5392
  { deep: true }
3715
5393
  );
3716
5394
  (0, import_vue.watch)(
3717
- () => [props.readonly, props.gridSize, props.autoFit, props.history, props.historyLimit],
5395
+ () => EDITOR_OPTION_KEYS.map((key) => props[key]),
3718
5396
  () => {
3719
- if (!instance) return;
3720
- instance.updateOptions({
3721
- readonly: props.readonly,
3722
- gridSize: props.gridSize,
3723
- autoFit: props.autoFit,
3724
- history: props.history,
3725
- historyLimit: props.historyLimit
3726
- });
5397
+ if (instance) instance.updateOptions(editorOptions());
3727
5398
  }
3728
5399
  );
3729
5400
  (0, import_vue.onBeforeUnmount)(() => {
@@ -3738,7 +5409,8 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
3738
5409
  redo: () => instance?.redo(),
3739
5410
  canUndo: () => Boolean(instance?.canUndo()),
3740
5411
  canRedo: () => Boolean(instance?.canRedo()),
3741
- layout: (mode, options) => instance?.layout(mode, options)
5412
+ layout: (mode, options) => instance?.layout(mode, options),
5413
+ fitNode: (nodeId, padding) => instance?.fitNode(nodeId, padding)
3742
5414
  });
3743
5415
  return () => (0, import_vue.h)("div", { ref: el, class: "vd-flowchart" });
3744
5416
  }