@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.js CHANGED
@@ -185,6 +185,134 @@ function computeLayout(documentData, mode = "tree", options = {}) {
185
185
  }
186
186
  var LAYOUT_MODES = ["tree", "radial", "grid"];
187
187
 
188
+ // src/mindmap.js
189
+ var DIRECTION_PORTS = { right: "right", left: "left", down: "bottom", up: "top" };
190
+ var PORT_DIRECTIONS = { right: "right", left: "left", bottom: "down", top: "up" };
191
+ var OPPOSITE_DIRECTIONS = { right: "left", left: "right", down: "up", up: "down" };
192
+ var DIRECTION_VECTORS = {
193
+ right: { x: 1, y: 0 },
194
+ left: { x: -1, y: 0 },
195
+ down: { x: 0, y: 1 },
196
+ up: { x: 0, y: -1 }
197
+ };
198
+ var BRANCH_GAP = 72;
199
+ var SIBLING_GAP = 24;
200
+ var COLLISION_GAP = 12;
201
+ var COLLISION_STEP = 12;
202
+ var MAX_COLLISION_STEPS = 400;
203
+ var BRANCH_RING_STEP = 48;
204
+ var MAX_BRANCH_RINGS = 8;
205
+ var NAVIGATION_CONE = Math.tan(Math.PI / 3);
206
+ function centerOf(node) {
207
+ return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
208
+ }
209
+ function oppositeDirection(direction) {
210
+ return OPPOSITE_DIRECTIONS[direction] || "left";
211
+ }
212
+ function findSpatialNeighbor(nodes, fromNode, direction) {
213
+ const vector = DIRECTION_VECTORS[direction];
214
+ if (!vector || !fromNode) return null;
215
+ const origin = centerOf(fromNode);
216
+ let bestCone = null;
217
+ let bestPlane = null;
218
+ nodes.forEach((node) => {
219
+ if (node.id === fromNode.id) return;
220
+ const center = centerOf(node);
221
+ const dx = center.x - origin.x;
222
+ const dy = center.y - origin.y;
223
+ const along = dx * vector.x + dy * vector.y;
224
+ if (along <= 0) return;
225
+ const across = Math.abs(dx * vector.y - dy * vector.x);
226
+ const score = along + across * 2;
227
+ if (across <= along * NAVIGATION_CONE && (!bestCone || score < bestCone.score)) {
228
+ bestCone = { node, score };
229
+ }
230
+ if (!bestPlane || score < bestPlane.score) bestPlane = { node, score };
231
+ });
232
+ return (bestCone || bestPlane)?.node ?? null;
233
+ }
234
+ function findNearestNode(nodes, point) {
235
+ let best = null;
236
+ nodes.forEach((node) => {
237
+ const center = centerOf(node);
238
+ const distance = Math.hypot(center.x - point.x, center.y - point.y);
239
+ if (!best || distance < best.distance) best = { node, distance };
240
+ });
241
+ return best?.node ?? null;
242
+ }
243
+ function overlapsAny(rect, nodes) {
244
+ return nodes.some(
245
+ (node) => rect.x < node.x + node.width + COLLISION_GAP && rect.x + rect.width + COLLISION_GAP > node.x && rect.y < node.y + node.height + COLLISION_GAP && rect.y + rect.height + COLLISION_GAP > node.y
246
+ );
247
+ }
248
+ function placeBranchNode({
249
+ nodes,
250
+ parent,
251
+ siblings = [],
252
+ direction,
253
+ size,
254
+ anchor = null,
255
+ before = false
256
+ }) {
257
+ const horizontal = direction === "right" || direction === "left";
258
+ const cross = horizontal ? "y" : "x";
259
+ const crossSize = horizontal ? "height" : "width";
260
+ const lastSibling = siblings.reduce(
261
+ (last, node) => !last || node[cross] + node[crossSize] > last[cross] + last[crossSize] ? node : last,
262
+ null
263
+ );
264
+ const reference = anchor || lastSibling;
265
+ const rect = { x: 0, y: 0, width: size.width, height: size.height };
266
+ if (reference) {
267
+ if (direction === "right") rect.x = reference.x;
268
+ if (direction === "left") rect.x = reference.x + reference.width - size.width;
269
+ if (direction === "down") rect.y = reference.y;
270
+ if (direction === "up") rect.y = reference.y + reference.height - size.height;
271
+ rect[cross] = before ? reference[cross] - SIBLING_GAP - size[crossSize] : reference[cross] + reference[crossSize] + SIBLING_GAP;
272
+ } else {
273
+ if (direction === "right") rect.x = parent.x + parent.width + BRANCH_GAP;
274
+ if (direction === "left") rect.x = parent.x - BRANCH_GAP - size.width;
275
+ if (direction === "down") rect.y = parent.y + parent.height + BRANCH_GAP;
276
+ if (direction === "up") rect.y = parent.y - BRANCH_GAP - size.height;
277
+ rect[cross] = parent[cross] + parent[crossSize] / 2 - size[crossSize] / 2;
278
+ }
279
+ const crossSign = reference ? before ? -1 : 1 : 0;
280
+ const result = nearestFreeSlot(
281
+ rect,
282
+ nodes,
283
+ cross,
284
+ horizontal ? "x" : "y",
285
+ DIRECTION_VECTORS[direction],
286
+ crossSign
287
+ );
288
+ return { x: Math.round(result.x * 100) / 100, y: Math.round(result.y * 100) / 100 };
289
+ }
290
+ function crossOffsetAt(index, crossSign) {
291
+ if (crossSign) return crossSign * COLLISION_STEP * index;
292
+ return COLLISION_STEP * Math.ceil(index / 2) * (index % 2 ? 1 : -1);
293
+ }
294
+ function nearestFreeSlot(rect, nodes, cross, main, vector, crossSign) {
295
+ const origin = { ...rect };
296
+ const outward = vector.x + vector.y;
297
+ let best = null;
298
+ for (let ring = 0; ring <= MAX_BRANCH_RINGS; ring += 1) {
299
+ const mainOffset = ring * BRANCH_RING_STEP;
300
+ if (best && mainOffset >= best.cost) break;
301
+ const candidate = { ...origin, [main]: origin[main] + outward * mainOffset };
302
+ for (let index = 0; index <= MAX_COLLISION_STEPS; index += 1) {
303
+ const crossOffset = crossOffsetAt(index, crossSign);
304
+ const cost = Math.hypot(mainOffset, crossOffset);
305
+ if (best && cost >= best.cost) break;
306
+ candidate[cross] = origin[cross] + crossOffset;
307
+ if (!overlapsAny(candidate, nodes)) {
308
+ best = { rect: { ...candidate }, cost };
309
+ break;
310
+ }
311
+ }
312
+ }
313
+ return best ? best.rect : origin;
314
+ }
315
+
188
316
  // src/core.js
189
317
  var SVG_NS = "http://www.w3.org/2000/svg";
190
318
  var TOOLBAR_ICON_PATHS = {
@@ -194,6 +322,7 @@ var TOOLBAR_ICON_PATHS = {
194
322
  "fit-view": "M216,48V88a8,8,0,0,1-16,0V67.31l-42.34,42.35a8,8,0,0,1-11.32-11.32L188.69,56H168a8,8,0,0,1,0-16h40A8,8,0,0,1,216,48ZM98.34,146.34,56,188.69V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H88a8,8,0,0,0,0-16H67.31l42.35-42.34a8,8,0,0,0-11.32-11.32Zm11.32-36.68L67.31,56H88a8,8,0,0,0,0-16H48a8,8,0,0,0-8,8V88a8,8,0,0,0,16,0V67.31l42.34,42.35a8,8,0,0,0,11.32-11.32ZM208,160a8,8,0,0,0-8,8v20.69l-42.34-42.35a8,8,0,0,0-11.32,11.32L188.69,200H168a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,208,160Z",
195
323
  undo: "M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z",
196
324
  redo: "M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z",
325
+ minimap: "M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z",
197
326
  clear: "M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"
198
327
  };
199
328
  var TOOLBAR_ACTIONS = [
@@ -233,11 +362,12 @@ function createToolbarButton({ action, label, icon, disabled = false }) {
233
362
  return button;
234
363
  }
235
364
  var DEFAULT_GRID_SIZE = 24;
236
- var MIN_SCALE = 0.35;
237
- var MAX_SCALE = 3;
238
- var MIN_NODE_SIZE = 56;
239
- var MAX_NODE_SIZE = 420;
240
- var WORLD_EXTENT = 12e3;
365
+ var DEFAULT_MIN_SCALE = 0.35;
366
+ var DEFAULT_MAX_SCALE = 3;
367
+ var DEFAULT_MIN_NODE_SIZE = 56;
368
+ var DEFAULT_MAX_NODE_SIZE = 420;
369
+ var DEFAULT_WORLD_EXTENT = 12e3;
370
+ var WIDE_WORLD_EXTENT = 24e4;
241
371
  var MAX_NODES = 1e4;
242
372
  var MAX_EDGES = 1e4;
243
373
  var RESIZE_HANDLES = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
@@ -245,21 +375,32 @@ var DEFAULT_EDGE_STROKE_WIDTH = 2.25;
245
375
  var MIN_EDGE_STROKE_WIDTH = 1.25;
246
376
  var MAX_EDGE_STROKE_WIDTH = 6;
247
377
  var CONNECTION_PORT_RADIUS = 6;
378
+ var PORT_HANDLE_OFFSET = 12;
248
379
  var CONNECTION_PORT_HIT_RADIUS = 14;
380
+ var PORT_CLICK_SLOP = 4;
381
+ var RESIZE_HANDLE_SIZE = 9;
382
+ var SELECTION_OUTSET = 4;
383
+ var GUIDE_SNAP_DISTANCE = 6;
384
+ var GUIDE_OVERHANG = 12;
385
+ var COLLAPSE_TOGGLE_OFFSET = 36;
386
+ var MINIMAP_WIDTH = 180;
387
+ var MINIMAP_HEIGHT = 120;
388
+ var MINIMAP_MIN_CANVAS_WIDTH = 480;
389
+ var SELECTION_BOX_TYPES = /* @__PURE__ */ new Set(["circle", "diamond", "label"]);
249
390
  var RECONNECT_ENDPOINT_RADIUS = 7;
250
391
  var RECONNECT_ENDPOINT_HIT_RADIUS = 12;
251
392
  var EDGE_HIT_STROKE_MIN = 16;
252
393
  var CONNECTION_SNAP_PADDING = 36;
253
394
  var CONNECTION_HYSTERESIS = 16;
254
395
  var CONNECTION_CENTER_LOCK_RADIUS = 18;
255
- var RESIZE_PORT_GAP = 34;
396
+ var RESIZE_PORT_GAP = 28;
256
397
  var EDGE_STROKE_PRESETS = [
257
398
  { id: "thin", label: "Thin", width: 1.75 },
258
399
  { id: "medium", label: "Medium", width: DEFAULT_EDGE_STROKE_WIDTH },
259
400
  { id: "bold", label: "Bold", width: 3.5 }
260
401
  ];
261
- var VD_FLOWCHART_VERSION = "1.3.0";
262
- var FLOWCHART_DOCUMENT_VERSION = "1.2.0";
402
+ var VD_FLOWCHART_VERSION = "1.5.0";
403
+ var FLOWCHART_DOCUMENT_VERSION = "1.3.0";
263
404
  var FLOWCHART_NODE_TYPES = [
264
405
  "rounded-rect",
265
406
  "rect",
@@ -272,7 +413,65 @@ var FLOWCHART_NODE_TYPES = [
272
413
  var FLOWCHART_PORTS = ["top", "right", "bottom", "left"];
273
414
  var FLOWCHART_EDGE_MARKERS = ["none", "arrow", "dot"];
274
415
  var FLOWCHART_EDGE_ROUTES = ["curve", "straight", "orthogonal"];
275
- var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update"]);
416
+ var FLOWCHART_KEYBOARD_SHORTCUTS = ["mindmap", "basic"];
417
+ function normalizeKeyboardShortcuts(value) {
418
+ return value === "basic" ? "basic" : "mindmap";
419
+ }
420
+ var ARROW_DIRECTIONS = {
421
+ ArrowRight: "right",
422
+ ArrowLeft: "left",
423
+ ArrowDown: "down",
424
+ ArrowUp: "up"
425
+ };
426
+ function getShortcutRows(mode, readonly) {
427
+ const navigation = [
428
+ ["Arrow keys", "Select the nearest node in that direction"],
429
+ ["Shift + Arrow", "Select a connection on that side (repeat to cycle)"],
430
+ ["Shift + click, Shift + drag", "Add nodes to the selection, or select with a box"],
431
+ ["Cmd/Ctrl + A", "Select all nodes"]
432
+ ];
433
+ const view = [
434
+ ["Cmd/Ctrl + = or -", "Zoom in or out"],
435
+ ["Cmd/Ctrl + 0", "Zoom to 100%"],
436
+ ["Shift + 1", "Fit the diagram to the view"]
437
+ ];
438
+ const help = [["?", "Show or hide this list"]];
439
+ if (readonly) {
440
+ return [...navigation, ...view, ...help, ["Tab", "Leave the canvas"]];
441
+ }
442
+ const mindmap = mode === "mindmap" ? [
443
+ ["Tab", "Add a child node"],
444
+ ["Enter", "Add a sibling below (Shift + Enter: above)"],
445
+ ["F2, Space, or type", "Edit the label"],
446
+ ["Enter while editing", "Save the label (Shift + Enter: new line)"],
447
+ ["Tab while editing", "Save and add a child"]
448
+ ] : [
449
+ ["Enter or F2", "Edit the label"],
450
+ ["Cmd/Ctrl + Enter", "Save the label"]
451
+ ];
452
+ return [
453
+ ...navigation,
454
+ ...mindmap,
455
+ [
456
+ mode === "mindmap" ? "Enter, F2, or type on a connection" : "Enter or F2 on a connection",
457
+ "Edit the connection label"
458
+ ],
459
+ ["Cmd/Ctrl + /", "Collapse or expand the branch"],
460
+ ["Alt + Arrow", "Nudge the node (add Shift for 1 px)"],
461
+ ["Delete or Backspace", "Delete the selection"],
462
+ ["Cmd/Ctrl + D", "Duplicate the node"],
463
+ ["Cmd/Ctrl + Z, Shift + Cmd/Ctrl + Z", "Undo, redo"],
464
+ ["Cmd/Ctrl + C, X, V", "Copy, cut, paste"],
465
+ ...view,
466
+ ["Esc", "Cancel editing, then the tool, then deselect"],
467
+ [
468
+ mode === "mindmap" ? "Shift + Tab, or Esc then Tab" : "Tab or Shift + Tab",
469
+ "Leave the canvas"
470
+ ],
471
+ ...help
472
+ ];
473
+ }
474
+ var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update", "node:nudge"]);
276
475
  var DEFAULT_EDGE_ROUTE = "curve";
277
476
  var ORTHOGONAL_STUB_LENGTH = 32;
278
477
  var ORTHOGONAL_CORNER_RADIUS = 12;
@@ -341,6 +540,17 @@ function deepClone(value) {
341
540
  function formatNumber(value) {
342
541
  return Number(value.toFixed(2));
343
542
  }
543
+ function formatScale(value) {
544
+ return Number(toFiniteNumber(value, 1).toFixed(4));
545
+ }
546
+ function normalizeBoundPair(min, max, fallbackMin, fallbackMax) {
547
+ const low = Number(min);
548
+ const high = Number(max);
549
+ if (!Number.isFinite(low) || !Number.isFinite(high) || low <= 0 || high <= 0 || low >= high) {
550
+ return { min: fallbackMin, max: fallbackMax };
551
+ }
552
+ return { min: low, max: high };
553
+ }
344
554
  function sanitizeId(value) {
345
555
  if (value == null) return "";
346
556
  return String(value).trim();
@@ -398,21 +608,36 @@ function normalizeNodeType(type) {
398
608
  function getNodeSpec(type) {
399
609
  return DEFAULT_NODE_SPECS[normalizeNodeType(type)];
400
610
  }
401
- function getNodeSizeBounds(type) {
611
+ function nodeVisualScale(node) {
612
+ if (!node || node.type === "junction") return 1;
613
+ const spec = getNodeSpec(node.type);
614
+ const width = spec.width > 0 ? toFiniteNumber(node.width, spec.width) / spec.width : 1;
615
+ const height = spec.height > 0 ? toFiniteNumber(node.height, spec.height) / spec.height : 1;
616
+ return clamp(Math.min(1, width, height), 0.01, 1);
617
+ }
618
+ function edgeVisualScale(fromNode, toNode) {
619
+ if (fromNode && toNode) return Math.min(nodeVisualScale(fromNode), nodeVisualScale(toNode));
620
+ if (fromNode) return nodeVisualScale(fromNode);
621
+ if (toNode) return nodeVisualScale(toNode);
622
+ return 1;
623
+ }
624
+ function getNodeSizeBounds(type, sizeBounds) {
402
625
  const spec = getNodeSpec(type);
626
+ const min = sizeBounds?.min ?? DEFAULT_MIN_NODE_SIZE;
627
+ const max = sizeBounds?.max ?? DEFAULT_MAX_NODE_SIZE;
403
628
  return {
404
- minWidth: spec.minWidth ?? MIN_NODE_SIZE,
405
- minHeight: spec.minHeight ?? MIN_NODE_SIZE,
406
- maxWidth: spec.maxWidth ?? MAX_NODE_SIZE,
407
- maxHeight: spec.maxHeight ?? MAX_NODE_SIZE
629
+ minWidth: spec.minWidth ?? min,
630
+ minHeight: spec.minHeight ?? min,
631
+ maxWidth: spec.maxWidth ?? max,
632
+ maxHeight: spec.maxHeight ?? max
408
633
  };
409
634
  }
410
- function clampNodeWidth(type, width, fallback) {
411
- const bounds = getNodeSizeBounds(type);
635
+ function clampNodeWidth(type, width, fallback, sizeBounds) {
636
+ const bounds = getNodeSizeBounds(type, sizeBounds);
412
637
  return clamp(toFiniteNumber(width, fallback), bounds.minWidth, bounds.maxWidth);
413
638
  }
414
- function clampNodeHeight(type, height, fallback) {
415
- const bounds = getNodeSizeBounds(type);
639
+ function clampNodeHeight(type, height, fallback, sizeBounds) {
640
+ const bounds = getNodeSizeBounds(type, sizeBounds);
416
641
  return clamp(toFiniteNumber(height, fallback), bounds.minHeight, bounds.maxHeight);
417
642
  }
418
643
  function isNodeResizable(nodeOrType) {
@@ -436,26 +661,59 @@ function getStrokePresetWidth(presetId) {
436
661
  const preset = EDGE_STROKE_PRESETS.find((item) => item.id === presetId);
437
662
  return preset?.width ?? DEFAULT_EDGE_STROKE_WIDTH;
438
663
  }
439
- function normalizeViewport(viewport) {
664
+ function normalizeViewport(viewport, scaleBounds) {
665
+ const min = scaleBounds?.min ?? DEFAULT_MIN_SCALE;
666
+ const max = scaleBounds?.max ?? DEFAULT_MAX_SCALE;
440
667
  return {
441
668
  x: toFiniteNumber(viewport?.x, 0),
442
669
  y: toFiniteNumber(viewport?.y, 0),
443
- scale: clamp(toFiniteNumber(viewport?.scale, 1), MIN_SCALE, MAX_SCALE)
670
+ scale: clamp(formatScale(toFiniteNumber(viewport?.scale, 1)), min, max)
444
671
  };
445
672
  }
446
- function normalizeNode(rawNode, index, usedIds) {
673
+ function normalizeNode(rawNode, index, usedIds, sizeBounds) {
447
674
  const type = normalizeNodeType(rawNode?.type);
448
675
  const spec = getNodeSpec(type);
449
- return {
676
+ const node = {
450
677
  id: ensureUniqueId(rawNode?.id, "node", usedIds),
451
678
  type,
452
679
  x: toFiniteNumber(rawNode?.x, index * 28),
453
680
  y: toFiniteNumber(rawNode?.y, index * 18),
454
- width: clampNodeWidth(type, rawNode?.width, spec.width),
455
- height: clampNodeHeight(type, rawNode?.height, spec.height),
681
+ width: clampNodeWidth(type, rawNode?.width, spec.width, sizeBounds),
682
+ height: clampNodeHeight(type, rawNode?.height, spec.height, sizeBounds),
456
683
  text: rawNode?.text == null ? spec.text : String(rawNode.text),
457
684
  data: isPlainObject(rawNode?.data) ? deepClone(rawNode.data) : {}
458
685
  };
686
+ if (rawNode?.collapsed === true) node.collapsed = true;
687
+ return node;
688
+ }
689
+ function computeHiddenNodeIds(nodes, edges) {
690
+ const collapsed = new Set(nodes.filter((node) => node.collapsed).map((node) => node.id));
691
+ if (!collapsed.size) return /* @__PURE__ */ new Set();
692
+ const outgoing = new Map(nodes.map((node) => [node.id, []]));
693
+ edges.forEach((edge) => {
694
+ if (edge.from.nodeId === edge.to.nodeId) return;
695
+ outgoing.get(edge.from.nodeId)?.push(edge.to.nodeId);
696
+ });
697
+ const candidates = /* @__PURE__ */ new Set();
698
+ collapsed.forEach((rootId) => {
699
+ const queue2 = [...outgoing.get(rootId) || []];
700
+ for (let index = 0; index < queue2.length; index += 1) {
701
+ const id = queue2[index];
702
+ if (id === rootId || candidates.has(id)) continue;
703
+ candidates.add(id);
704
+ queue2.push(...outgoing.get(id) || []);
705
+ }
706
+ });
707
+ const queue = nodes.filter((node) => !candidates.has(node.id) && !collapsed.has(node.id)).map((node) => node.id);
708
+ for (let index = 0; index < queue.length; index += 1) {
709
+ const id = queue[index];
710
+ (outgoing.get(id) || []).forEach((childId) => {
711
+ if (!candidates.has(childId)) return;
712
+ candidates.delete(childId);
713
+ if (!collapsed.has(childId)) queue.push(childId);
714
+ });
715
+ }
716
+ return candidates;
459
717
  }
460
718
  function normalizeEndpoint(rawEndpoint, fallbackPort) {
461
719
  return {
@@ -514,7 +772,7 @@ function isSupportedDocumentVersion(value) {
514
772
  if (parsed.minor === max.minor && parsed.patch > max.patch) return false;
515
773
  return true;
516
774
  }
517
- function normalizeDocument(input) {
775
+ function normalizeDocument(input, limits = {}) {
518
776
  let source = input;
519
777
  if (typeof source === "string") {
520
778
  try {
@@ -537,13 +795,13 @@ function normalizeDocument(input) {
537
795
  }
538
796
  }
539
797
  const usedNodeIds = /* @__PURE__ */ new Set();
540
- const nodes = (Array.isArray(source.nodes) ? source.nodes : []).slice(0, MAX_NODES).map((node, index) => normalizeNode(node, index, usedNodeIds));
798
+ const nodes = (Array.isArray(source.nodes) ? source.nodes : []).slice(0, MAX_NODES).map((node, index) => normalizeNode(node, index, usedNodeIds, limits.sizeBounds));
541
799
  const nodeIds = new Set(nodes.map((node) => node.id));
542
800
  const usedEdgeIds = /* @__PURE__ */ new Set();
543
801
  const edges = (Array.isArray(source.edges) ? source.edges : []).slice(0, MAX_EDGES).map((edge, index) => normalizeEdge(edge, index, nodeIds, usedEdgeIds)).filter(Boolean);
544
802
  return {
545
803
  version: FLOWCHART_DOCUMENT_VERSION,
546
- viewport: normalizeViewport(source.viewport),
804
+ viewport: normalizeViewport(source.viewport, limits.scaleBounds),
547
805
  nodes,
548
806
  edges
549
807
  };
@@ -608,6 +866,15 @@ function getPortPosition(node, port) {
608
866
  return { x: node.x, y: node.y + node.height / 2 };
609
867
  }
610
868
  }
869
+ function getPortHandlePosition(node, port, scale = 1) {
870
+ const point = getPortPosition(node, port);
871
+ const normal = getPortNormal(port);
872
+ const offset = PORT_HANDLE_OFFSET / scale;
873
+ return {
874
+ x: formatNumber(point.x + normal.x * offset),
875
+ y: formatNumber(point.y + normal.y * offset)
876
+ };
877
+ }
611
878
  function getNearestPort(node, point) {
612
879
  return FLOWCHART_PORTS.reduce((best, port) => {
613
880
  const portPoint = getPortPosition(node, port);
@@ -751,19 +1018,30 @@ function getPolylineLabelPoint(points) {
751
1018
  const last = points[points.length - 1] || { x: 0, y: 0 };
752
1019
  return { x: formatNumber(last.x), y: formatNumber(last.y) };
753
1020
  }
754
- function curveControlPoint(point, port, target, curvature) {
1021
+ function routeMetrics(edgeScale = 1) {
1022
+ const scale = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
1023
+ return {
1024
+ scale,
1025
+ minCurveArm: MIN_CURVE_ARM * scale,
1026
+ maxCurveArm: MAX_CURVE_ARM * scale,
1027
+ stub: ORTHOGONAL_STUB_LENGTH * scale,
1028
+ corner: ORTHOGONAL_CORNER_RADIUS * scale,
1029
+ clearance: ORTHOGONAL_CLEARANCE * scale
1030
+ };
1031
+ }
1032
+ function curveControlPoint(point, port, target, curvature, metrics = routeMetrics(1)) {
755
1033
  const normal = getPortNormal(port);
756
1034
  const dx = target.x - point.x;
757
1035
  const dy = target.y - point.y;
758
1036
  const distance = Math.hypot(dx, dy) || 1;
759
1037
  const align = (normal.x * dx + normal.y * dy) / distance;
760
1038
  const ease = 0.55 + 0.45 * align;
761
- const arm = clamp(distance * curvature * ease, MIN_CURVE_ARM, MAX_CURVE_ARM);
1039
+ const arm = clamp(distance * curvature * ease, metrics.minCurveArm, metrics.maxCurveArm);
762
1040
  return { x: point.x + normal.x * arm, y: point.y + normal.y * arm };
763
1041
  }
764
- function buildCurvePath(fromPoint, toPoint, fromPort = "right", toPort = "left") {
765
- const controlA = curveControlPoint(fromPoint, fromPort, toPoint, EDGE_CURVATURE);
766
- const controlB = curveControlPoint(toPoint, toPort, fromPoint, EDGE_CURVATURE);
1042
+ function buildCurvePath(fromPoint, toPoint, fromPort = "right", toPort = "left", metrics = routeMetrics(1)) {
1043
+ const controlA = curveControlPoint(fromPoint, fromPort, toPoint, EDGE_CURVATURE, metrics);
1044
+ const controlB = curveControlPoint(toPoint, toPort, fromPoint, EDGE_CURVATURE, metrics);
767
1045
  const labelX = 0.125 * fromPoint.x + 0.375 * controlA.x + 0.375 * controlB.x + 0.125 * toPoint.x;
768
1046
  const labelY = 0.125 * fromPoint.y + 0.375 * controlA.y + 0.375 * controlB.y + 0.125 * toPoint.y;
769
1047
  return {
@@ -825,11 +1103,11 @@ function getOrthogonalMidpoints(fromStub, toStub, fromPort, toPort) {
825
1103
  }
826
1104
  return [{ x: fromStub.x, y: toStub.y }];
827
1105
  }
828
- function getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect) {
1106
+ function getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect, metrics = routeMetrics(1)) {
829
1107
  const fromNormal = getPortNormal(fromPort);
830
1108
  const toNormal = getPortNormal(toPort);
831
- const fromStub = offsetPoint(fromPoint, fromNormal, ORTHOGONAL_STUB_LENGTH);
832
- const toStub = offsetPoint(toPoint, toNormal, ORTHOGONAL_STUB_LENGTH);
1109
+ const fromStub = offsetPoint(fromPoint, fromNormal, metrics.stub);
1110
+ const toStub = offsetPoint(toPoint, toNormal, metrics.stub);
833
1111
  const sourceHorizontal = isHorizontalPort(fromPort);
834
1112
  const targetHorizontal = isHorizontalPort(toPort);
835
1113
  const mids = [];
@@ -869,13 +1147,13 @@ function getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toR
869
1147
  );
870
1148
  return points;
871
1149
  }
872
- function buildOrthogonalPath(fromPoint, toPoint, fromPort = "right", toPort = "left", fromRect = null, toRect = null) {
1150
+ function buildOrthogonalPath(fromPoint, toPoint, fromPort = "right", toPort = "left", fromRect = null, toRect = null, metrics = routeMetrics(1)) {
873
1151
  let points;
874
1152
  if (fromRect && toRect) {
875
- points = getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect);
1153
+ points = getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect, metrics);
876
1154
  } else {
877
- const fromStub = offsetPoint(fromPoint, getPortNormal(fromPort), ORTHOGONAL_STUB_LENGTH);
878
- const toStub = offsetPoint(toPoint, getPortNormal(toPort), ORTHOGONAL_STUB_LENGTH);
1155
+ const fromStub = offsetPoint(fromPoint, getPortNormal(fromPort), metrics.stub);
1156
+ const toStub = offsetPoint(toPoint, getPortNormal(toPort), metrics.stub);
879
1157
  points = [];
880
1158
  [
881
1159
  fromPoint,
@@ -887,7 +1165,7 @@ function buildOrthogonalPath(fromPoint, toPoint, fromPort = "right", toPort = "l
887
1165
  }
888
1166
  const label = getPolylineLabelPoint(points);
889
1167
  return {
890
- d: buildRoundedPath(points, ORTHOGONAL_CORNER_RADIUS),
1168
+ d: buildRoundedPath(points, metrics.corner),
891
1169
  labelX: label.x,
892
1170
  labelY: label.y
893
1171
  };
@@ -898,6 +1176,7 @@ function buildEdgePath(edge, fromNode = null, toNode = null) {
898
1176
  const fromPoint = edge?.fromPoint || (fromNode ? getPortPosition(fromNode, fromPort) : null);
899
1177
  const toPoint = edge?.toPoint || (toNode ? getPortPosition(toNode, toPort) : null);
900
1178
  const route = normalizeEdgeRoute(edge?.route);
1179
+ const metrics = routeMetrics(edgeVisualScale(fromNode, toNode));
901
1180
  if (!fromPoint || !toPoint) {
902
1181
  return { d: "", labelX: 0, labelY: 0 };
903
1182
  }
@@ -905,15 +1184,17 @@ function buildEdgePath(edge, fromNode = null, toNode = null) {
905
1184
  return buildStraightPath(fromPoint, toPoint);
906
1185
  }
907
1186
  if (route === "orthogonal") {
908
- const fromRect = !edge?.fromPoint && fromNode ? getNodeRectBounds(fromNode, ORTHOGONAL_CLEARANCE) : null;
909
- const toRect = !edge?.toPoint && toNode ? getNodeRectBounds(toNode, ORTHOGONAL_CLEARANCE) : null;
910
- return buildOrthogonalPath(fromPoint, toPoint, fromPort, toPort, fromRect, toRect);
1187
+ const fromRect = !edge?.fromPoint && fromNode ? getNodeRectBounds(fromNode, metrics.clearance) : null;
1188
+ const toRect = !edge?.toPoint && toNode ? getNodeRectBounds(toNode, metrics.clearance) : null;
1189
+ return buildOrthogonalPath(fromPoint, toPoint, fromPort, toPort, fromRect, toRect, metrics);
911
1190
  }
912
- return buildCurvePath(fromPoint, toPoint, fromPort, toPort);
1191
+ return buildCurvePath(fromPoint, toPoint, fromPort, toPort, metrics);
913
1192
  }
914
- function createArrowMarker(id, strokeWidth, reversed = false) {
915
- const size = formatNumber(Math.max(9, strokeWidth * 4.4));
916
- const refInset = formatNumber(Math.max(1.5, strokeWidth * 0.85));
1193
+ function createArrowMarker(id, strokeWidth, reversed = false, edgeScale = 1) {
1194
+ const scale = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
1195
+ const effective = Math.max(0.02, strokeWidth * scale);
1196
+ const size = formatNumber(Math.max(9 * scale, effective * 4.4));
1197
+ const refInset = formatNumber(Math.max(1.5 * scale, effective * 0.85));
917
1198
  const marker = svgEl("marker", {
918
1199
  id,
919
1200
  markerWidth: size,
@@ -931,9 +1212,11 @@ function createArrowMarker(id, strokeWidth, reversed = false) {
931
1212
  );
932
1213
  return marker;
933
1214
  }
934
- function createDotMarker(id, strokeWidth) {
935
- const size = formatNumber(Math.max(8, strokeWidth * 3.3));
936
- const radius = formatNumber(Math.max(2.75, strokeWidth * 1.45));
1215
+ function createDotMarker(id, strokeWidth, edgeScale = 1) {
1216
+ const scale = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
1217
+ const effective = Math.max(0.02, strokeWidth * scale);
1218
+ const size = formatNumber(Math.max(8 * scale, effective * 3.3));
1219
+ const radius = formatNumber(Math.max(2.75 * scale, effective * 1.45));
937
1220
  const marker = svgEl("marker", {
938
1221
  id,
939
1222
  markerWidth: size,
@@ -975,6 +1258,68 @@ function getResizeCursor(handle) {
975
1258
  if (handle === "ne" || handle === "sw") return "nesw-resize";
976
1259
  return "nwse-resize";
977
1260
  }
1261
+ function collectGuideLines(nodes) {
1262
+ const x = [];
1263
+ const y = [];
1264
+ nodes.forEach((node) => {
1265
+ const right = node.x + node.width;
1266
+ const bottom = node.y + node.height;
1267
+ [node.x, node.x + node.width / 2, right].forEach(
1268
+ (value) => x.push({ value, start: node.y, end: bottom })
1269
+ );
1270
+ [node.y, node.y + node.height / 2, bottom].forEach(
1271
+ (value) => y.push({ value, start: node.x, end: right })
1272
+ );
1273
+ });
1274
+ const byValue = (a, b) => a.value - b.value;
1275
+ return { x: x.sort(byValue), y: y.sort(byValue) };
1276
+ }
1277
+ function lowerBound(lines, value) {
1278
+ let low = 0;
1279
+ let high = lines.length;
1280
+ while (low < high) {
1281
+ const middle = low + high >> 1;
1282
+ if (lines[middle].value < value) low = middle + 1;
1283
+ else high = middle;
1284
+ }
1285
+ return low;
1286
+ }
1287
+ function snapToGuides(lines, candidates, threshold) {
1288
+ let best = null;
1289
+ candidates.forEach((candidate) => {
1290
+ const index = lowerBound(lines, candidate);
1291
+ [lines[index - 1], lines[index]].forEach((line) => {
1292
+ if (!line) return;
1293
+ const offset = line.value - candidate;
1294
+ if (Math.abs(offset) <= threshold && (!best || Math.abs(offset) < Math.abs(best.offset))) {
1295
+ best = { offset };
1296
+ }
1297
+ });
1298
+ });
1299
+ if (!best) return null;
1300
+ const positions = candidates.map((candidate) => candidate + best.offset);
1301
+ best.lines = lines.filter(
1302
+ (line) => positions.some((position) => Math.abs(line.value - position) < 0.5)
1303
+ );
1304
+ return best;
1305
+ }
1306
+ function guideSegments(axis, lines, box) {
1307
+ const byValue = /* @__PURE__ */ new Map();
1308
+ lines.forEach((line) => {
1309
+ const current = byValue.get(line.value);
1310
+ byValue.set(line.value, {
1311
+ start: Math.min(current?.start ?? Infinity, line.start),
1312
+ end: Math.max(current?.end ?? -Infinity, line.end)
1313
+ });
1314
+ });
1315
+ const boxStart = axis === "x" ? box.top : box.left;
1316
+ const boxEnd = boxStart + (axis === "x" ? box.height : box.width);
1317
+ return [...byValue].map(([value, extent]) => {
1318
+ const start = Math.min(extent.start, boxStart) - GUIDE_OVERHANG;
1319
+ const end = Math.max(extent.end, boxEnd) + GUIDE_OVERHANG;
1320
+ return axis === "x" ? { x1: value, y1: start, x2: value, y2: end } : { x1: start, y1: value, x2: end, y2: value };
1321
+ });
1322
+ }
978
1323
  function getBounds(nodes) {
979
1324
  if (!nodes.length) {
980
1325
  return { left: 0, top: 0, right: 0, bottom: 0 };
@@ -1128,9 +1473,26 @@ var VdFlowchart = class {
1128
1473
  this.element = resolveElement(options.element || options.target);
1129
1474
  this.readonly = Boolean(options.readonly);
1130
1475
  this.gridSize = clamp(toFiniteNumber(options.gridSize, DEFAULT_GRID_SIZE), 12, 64);
1131
- this.documentData = normalizeDocument(options.data || {});
1476
+ const scaleBounds = normalizeBoundPair(
1477
+ options.minScale,
1478
+ options.maxScale,
1479
+ DEFAULT_MIN_SCALE,
1480
+ DEFAULT_MAX_SCALE
1481
+ );
1482
+ const sizeBounds = normalizeBoundPair(
1483
+ options.minNodeSize,
1484
+ options.maxNodeSize,
1485
+ DEFAULT_MIN_NODE_SIZE,
1486
+ DEFAULT_MAX_NODE_SIZE
1487
+ );
1488
+ this.minScale = scaleBounds.min;
1489
+ this.maxScale = scaleBounds.max;
1490
+ this.minNodeSize = sizeBounds.min;
1491
+ this.maxNodeSize = sizeBounds.max;
1492
+ this.documentData = normalizeDocument(options.data || {}, { scaleBounds, sizeBounds });
1132
1493
  this.listeners = {};
1133
1494
  this.selection = null;
1495
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
1134
1496
  this.interaction = null;
1135
1497
  this.activeTool = null;
1136
1498
  this.layoutMode = "tree";
@@ -1138,6 +1500,7 @@ var VdFlowchart = class {
1138
1500
  this.destroyed = false;
1139
1501
  this.lastNodePointer = null;
1140
1502
  this.reconnectEdgeId = null;
1503
+ this.edgeCycleAnchor = null;
1141
1504
  this.clipboard = null;
1142
1505
  this.gridPatternId = nextId("flowchart-grid");
1143
1506
  this.markerIds = /* @__PURE__ */ new Map();
@@ -1147,6 +1510,16 @@ var VdFlowchart = class {
1147
1510
  this.history = [];
1148
1511
  this.historyIndex = -1;
1149
1512
  this.isApplyingHistory = false;
1513
+ this.historyBatchDepth = 0;
1514
+ this.historyBatchDirty = false;
1515
+ this.coalesceInsertedText = false;
1516
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
1517
+ this.snapGuides = options.snapGuides !== false;
1518
+ this.minimapEnabled = options.minimap !== false;
1519
+ this.autoLayout = Boolean(options.autoLayout);
1520
+ this.minimapFrame = 0;
1521
+ this.minimapDrag = null;
1522
+ this.shortcutsHelpOpen = false;
1150
1523
  this.autoFit = Boolean(options.autoFit);
1151
1524
  this.readyEmitted = false;
1152
1525
  this.resizeObserver = null;
@@ -1187,6 +1560,7 @@ var VdFlowchart = class {
1187
1560
  if (!ready) return false;
1188
1561
  this.readyEmitted = true;
1189
1562
  if (this.autoFit) this.fitView();
1563
+ else this.scheduleMinimap();
1190
1564
  this.emit("ready", this);
1191
1565
  return true;
1192
1566
  };
@@ -1256,8 +1630,25 @@ var VdFlowchart = class {
1256
1630
  className: "vd-flowchart-toolbar-label",
1257
1631
  text: "100%"
1258
1632
  });
1633
+ this.shortcutsButton = createElement("button", {
1634
+ className: "vd-flowchart-btn vd-flowchart-icon-btn vd-flowchart-shortcuts-btn",
1635
+ title: "Keyboard shortcuts (?)",
1636
+ text: "?"
1637
+ });
1638
+ this.shortcutsButton.setAttribute("type", "button");
1639
+ this.shortcutsButton.setAttribute("data-flowchart-action", "shortcuts");
1640
+ this.shortcutsButton.setAttribute("aria-label", "Keyboard shortcuts");
1641
+ this.shortcutsButton.setAttribute("aria-expanded", "false");
1642
+ this.minimapButton = createToolbarButton({
1643
+ action: "minimap",
1644
+ label: "Toggle minimap",
1645
+ icon: "minimap"
1646
+ });
1647
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
1259
1648
  toolbarLeft.appendChild(this.arrangeSelect);
1260
1649
  toolbarLeft.appendChild(this.clearButton);
1650
+ toolbarRight.appendChild(this.minimapButton);
1651
+ toolbarRight.appendChild(this.shortcutsButton);
1261
1652
  toolbarRight.appendChild(this.zoomLabel);
1262
1653
  this.toolbar.appendChild(toolbarLeft);
1263
1654
  this.toolbar.appendChild(toolbarRight);
@@ -1326,23 +1717,50 @@ var VdFlowchart = class {
1326
1717
  this.world = svgEl("g", { class: "vd-flowchart-world" });
1327
1718
  this.gridRect = svgEl("rect", {
1328
1719
  class: "vd-flowchart-grid",
1329
- x: -WORLD_EXTENT / 2,
1330
- y: -WORLD_EXTENT / 2,
1331
- width: WORLD_EXTENT,
1332
- height: WORLD_EXTENT,
1720
+ x: -DEFAULT_WORLD_EXTENT / 2,
1721
+ y: -DEFAULT_WORLD_EXTENT / 2,
1722
+ width: DEFAULT_WORLD_EXTENT,
1723
+ height: DEFAULT_WORLD_EXTENT,
1333
1724
  fill: `url(#${this.gridPatternId})`
1334
1725
  });
1335
1726
  this.edgesLayer = svgEl("g", { class: "vd-flowchart-edges" });
1336
1727
  this.previewLayer = svgEl("g", { class: "vd-flowchart-preview" });
1337
1728
  this.nodesLayer = svgEl("g", { class: "vd-flowchart-nodes" });
1338
1729
  this.overlayLayer = svgEl("g", { class: "vd-flowchart-overlay" });
1730
+ this.guidesLayer = svgEl("g", { class: "vd-flowchart-guides" });
1339
1731
  this.world.appendChild(this.gridRect);
1340
1732
  this.world.appendChild(this.edgesLayer);
1341
1733
  this.world.appendChild(this.previewLayer);
1342
1734
  this.world.appendChild(this.nodesLayer);
1343
1735
  this.world.appendChild(this.overlayLayer);
1736
+ this.world.appendChild(this.guidesLayer);
1344
1737
  this.svg.appendChild(this.world);
1345
1738
  this.canvasEl.appendChild(this.svg);
1739
+ this.shortcutsHelp = createElement("div", { className: "vd-flowchart-shortcuts" });
1740
+ this.shortcutsHelp.id = nextId("flowchart-shortcuts");
1741
+ this.shortcutsHelp.setAttribute("role", "dialog");
1742
+ this.shortcutsHelp.setAttribute("aria-label", "Keyboard shortcuts");
1743
+ this.shortcutsHelp.hidden = true;
1744
+ this.shortcutsHelp.addEventListener("pointerdown", (event) => event.stopPropagation());
1745
+ this.shortcutsHelp.addEventListener("wheel", (event) => event.stopPropagation());
1746
+ this.shortcutsButton.setAttribute("aria-controls", this.shortcutsHelp.id);
1747
+ this.canvasEl.appendChild(this.shortcutsHelp);
1748
+ this.minimapEl = createElement("div", { className: "vd-flowchart-minimap" });
1749
+ this.minimapEl.setAttribute("aria-hidden", "true");
1750
+ this.minimapEl.hidden = true;
1751
+ this.minimapSvg = svgEl("svg", {
1752
+ class: "vd-flowchart-minimap-svg",
1753
+ width: MINIMAP_WIDTH,
1754
+ height: MINIMAP_HEIGHT,
1755
+ viewBox: `0 0 ${MINIMAP_WIDTH} ${MINIMAP_HEIGHT}`
1756
+ });
1757
+ this.minimapEl.appendChild(this.minimapSvg);
1758
+ this.minimapEl.addEventListener("pointerdown", (event) => this.handleMinimapPointer(event));
1759
+ this.minimapEl.addEventListener("pointermove", (event) => this.handleMinimapPointer(event));
1760
+ this.minimapEl.addEventListener("pointerup", (event) => this.endMinimapDrag(event));
1761
+ this.minimapEl.addEventListener("pointercancel", (event) => this.endMinimapDrag(event));
1762
+ this.minimapEl.addEventListener("wheel", (event) => event.stopPropagation());
1763
+ this.canvasEl.appendChild(this.minimapEl);
1346
1764
  this.inspectorPanel = createElement("aside", {
1347
1765
  className: "vd-flowchart-panel vd-flowchart-panel--inspector"
1348
1766
  });
@@ -1450,11 +1868,15 @@ var VdFlowchart = class {
1450
1868
  this.graphOutline.appendChild(this.graphList);
1451
1869
  this.root.appendChild(this.graphOutline);
1452
1870
  this.canvasEl.setAttribute("role", "group");
1871
+ this.syncCanvasLabel();
1872
+ this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1873
+ }
1874
+ syncCanvasLabel() {
1875
+ const keys = this.readonly || this.keyboardShortcuts === "basic" ? "Arrow keys select nodes; Enter or F2 edits a label." : "Arrow keys select nodes; Tab adds a child, Enter adds a sibling, F2 edits a label.";
1453
1876
  this.canvasEl.setAttribute(
1454
1877
  "aria-label",
1455
- "Diagram canvas. Arrow keys select nodes; Enter edits a label. Graph outline provides connections."
1878
+ `Diagram canvas. ${keys} Press ? for all shortcuts. Graph outline provides connections.`
1456
1879
  );
1457
- this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
1458
1880
  }
1459
1881
  renderGraphOutline() {
1460
1882
  if (!this.graphOutline) return;
@@ -1464,8 +1886,9 @@ var VdFlowchart = class {
1464
1886
  const selected = this.selection?.kind === "node" ? this.selection.id : null;
1465
1887
  this.graphStatus.textContent = selected ? `Selected: ${labels.get(selected)}. ${nodes.length} nodes, ${edges.length} connections.` : `${nodes.length} nodes, ${edges.length} connections.`;
1466
1888
  if (!this.graphOutline.open) return;
1889
+ const hidden = this.getHiddenNodeIds();
1467
1890
  const signature = JSON.stringify([
1468
- nodes.map((node) => [node.id, node.text]),
1891
+ nodes.map((node) => [node.id, node.text, Boolean(node.collapsed), hidden.has(node.id)]),
1469
1892
  edges.map((edge) => [edge.id, edge.from.nodeId, edge.to.nodeId, edge.label])
1470
1893
  ]);
1471
1894
  if (signature !== this.graphSignature) {
@@ -1491,8 +1914,10 @@ var VdFlowchart = class {
1491
1914
  const from = incoming.get(node.id);
1492
1915
  const relationships = [
1493
1916
  to.length ? `Connects to ${to.join(", ")}.` : "No outgoing connections.",
1494
- from.length ? `Connected from ${from.join(", ")}.` : ""
1495
- ];
1917
+ from.length ? `Connected from ${from.join(", ")}.` : "",
1918
+ node.collapsed ? "Branch collapsed." : "",
1919
+ hidden.has(node.id) ? "Hidden in a collapsed branch." : ""
1920
+ ].filter(Boolean);
1496
1921
  this.graphList.appendChild(
1497
1922
  createElement("li", { text: `${labels.get(node.id)}. ${relationships.join(" ")}` })
1498
1923
  );
@@ -1577,6 +2002,157 @@ var VdFlowchart = class {
1577
2002
  if (action === "undo") this.undo();
1578
2003
  if (action === "redo") this.redo();
1579
2004
  if (action === "clear" && !this.readonly) this.clear();
2005
+ if (action === "shortcuts") this.toggleShortcutsHelp();
2006
+ if (action === "minimap") this.setMinimapEnabled(!this.minimapEnabled);
2007
+ }
2008
+ setMinimapEnabled(enabled) {
2009
+ this.minimapEnabled = Boolean(enabled);
2010
+ this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
2011
+ this.drawMinimap();
2012
+ return this;
2013
+ }
2014
+ scheduleMinimap() {
2015
+ if (this.minimapFrame || !hasWindow()) return;
2016
+ const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => setTimeout(callback, 16);
2017
+ this.minimapFrame = raf(() => {
2018
+ this.minimapFrame = 0;
2019
+ this.drawMinimap();
2020
+ });
2021
+ }
2022
+ // World-to-minimap mapping covering every visible node and the viewport.
2023
+ getMinimapTransform() {
2024
+ const viewport = this.documentData.viewport;
2025
+ const width = this.canvasEl.clientWidth;
2026
+ const height = this.canvasEl.clientHeight;
2027
+ const view = {
2028
+ left: -viewport.x / viewport.scale,
2029
+ top: -viewport.y / viewport.scale,
2030
+ right: (width - viewport.x) / viewport.scale,
2031
+ bottom: (height - viewport.y) / viewport.scale
2032
+ };
2033
+ const content = getBounds(this.getVisibleNodes());
2034
+ const left = Math.min(content.left, view.left);
2035
+ const top = Math.min(content.top, view.top);
2036
+ const spanX = Math.max(1, Math.max(content.right, view.right) - left);
2037
+ const spanY = Math.max(1, Math.max(content.bottom, view.bottom) - top);
2038
+ const padding = 8;
2039
+ const scale = Math.min(
2040
+ (MINIMAP_WIDTH - padding * 2) / spanX,
2041
+ (MINIMAP_HEIGHT - padding * 2) / spanY
2042
+ );
2043
+ return {
2044
+ scale,
2045
+ offsetX: (MINIMAP_WIDTH - spanX * scale) / 2 - left * scale,
2046
+ offsetY: (MINIMAP_HEIGHT - spanY * scale) / 2 - top * scale,
2047
+ view
2048
+ };
2049
+ }
2050
+ drawMinimap() {
2051
+ if (this.destroyed || !this.minimapEl) return;
2052
+ const nodes = this.getVisibleNodes();
2053
+ const visible = this.minimapEnabled && nodes.length > 0 && this.canvasEl.clientWidth >= MINIMAP_MIN_CANVAS_WIDTH;
2054
+ this.minimapEl.hidden = !visible;
2055
+ if (!visible) return;
2056
+ const transform = this.minimapDrag?.transform || this.getMinimapTransform();
2057
+ const view = this.getMinimapTransform().view;
2058
+ const map = (x, y) => ({
2059
+ x: formatNumber(x * transform.scale + transform.offsetX),
2060
+ y: formatNumber(y * transform.scale + transform.offsetY)
2061
+ });
2062
+ clearChildren(this.minimapSvg);
2063
+ nodes.forEach((node) => {
2064
+ const point = map(node.x, node.y);
2065
+ const selected = this.isNodeSelected(node.id);
2066
+ this.minimapSvg.appendChild(
2067
+ svgEl("rect", {
2068
+ class: `vd-flowchart-minimap-node${selected ? " is-selected" : ""}`,
2069
+ x: point.x,
2070
+ y: point.y,
2071
+ width: formatNumber(Math.max(1.5, node.width * transform.scale)),
2072
+ height: formatNumber(Math.max(1.5, node.height * transform.scale)),
2073
+ rx: 1.5
2074
+ })
2075
+ );
2076
+ });
2077
+ const corner = map(view.left, view.top);
2078
+ this.minimapSvg.appendChild(
2079
+ svgEl("rect", {
2080
+ class: "vd-flowchart-minimap-viewport",
2081
+ x: corner.x,
2082
+ y: corner.y,
2083
+ width: formatNumber((view.right - view.left) * transform.scale),
2084
+ height: formatNumber((view.bottom - view.top) * transform.scale)
2085
+ })
2086
+ );
2087
+ }
2088
+ // Centre the main view on the world point under the minimap pointer. The
2089
+ // mapping is frozen for the whole drag so the map does not shift under it.
2090
+ handleMinimapPointer(event) {
2091
+ event.stopPropagation();
2092
+ if (event.type === "pointerdown") {
2093
+ if (event.button != null && event.button !== 0) return;
2094
+ event.preventDefault();
2095
+ this.minimapDrag = { pointerId: event.pointerId, transform: this.getMinimapTransform() };
2096
+ try {
2097
+ this.minimapEl.setPointerCapture?.(event.pointerId);
2098
+ } catch {
2099
+ }
2100
+ }
2101
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2102
+ const rect = this.minimapEl.getBoundingClientRect();
2103
+ const { transform } = this.minimapDrag;
2104
+ this.centerViewOn(
2105
+ (event.clientX - rect.left - transform.offsetX) / transform.scale,
2106
+ (event.clientY - rect.top - transform.offsetY) / transform.scale
2107
+ );
2108
+ }
2109
+ endMinimapDrag(event) {
2110
+ event.stopPropagation();
2111
+ if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
2112
+ this.minimapDrag = null;
2113
+ this.syncJsonTextarea();
2114
+ this.emitViewportChange("viewport:pan");
2115
+ this.drawMinimap();
2116
+ }
2117
+ centerViewOn(worldX, worldY) {
2118
+ const viewport = this.documentData.viewport;
2119
+ const width = this.canvasEl.clientWidth || 800;
2120
+ const height = this.canvasEl.clientHeight || 560;
2121
+ viewport.x = formatNumber(width / 2 - worldX * viewport.scale);
2122
+ viewport.y = formatNumber(height / 2 - worldY * viewport.scale);
2123
+ this.render({ inspector: false, json: false });
2124
+ return this;
2125
+ }
2126
+ toggleShortcutsHelp(force) {
2127
+ const open = typeof force === "boolean" ? force : !this.shortcutsHelpOpen;
2128
+ this.shortcutsHelpOpen = open;
2129
+ this.shortcutsButton.setAttribute("aria-expanded", open ? "true" : "false");
2130
+ if (open) this.renderShortcutsHelp();
2131
+ this.shortcutsHelp.hidden = !open;
2132
+ if (!open && this.shortcutsHelp.contains(document.activeElement)) this.canvasEl.focus();
2133
+ return this;
2134
+ }
2135
+ renderShortcutsHelp() {
2136
+ clearChildren(this.shortcutsHelp);
2137
+ const header = createElement("div", { className: "vd-flowchart-shortcuts-header" });
2138
+ header.appendChild(
2139
+ createElement("h4", { className: "vd-flowchart-panel-title", text: "Keyboard shortcuts" })
2140
+ );
2141
+ const close = createElement("button", {
2142
+ className: "vd-flowchart-btn vd-flowchart-icon-btn",
2143
+ text: "\xD7"
2144
+ });
2145
+ close.setAttribute("type", "button");
2146
+ close.setAttribute("aria-label", "Close keyboard shortcuts");
2147
+ close.addEventListener("click", () => this.toggleShortcutsHelp(false));
2148
+ header.appendChild(close);
2149
+ this.shortcutsHelp.appendChild(header);
2150
+ const list = createElement("dl", { className: "vd-flowchart-shortcuts-list" });
2151
+ getShortcutRows(this.keyboardShortcuts, this.readonly).forEach(([keys, action]) => {
2152
+ list.appendChild(createElement("dt", { text: keys }));
2153
+ list.appendChild(createElement("dd", { text: action }));
2154
+ });
2155
+ this.shortcutsHelp.appendChild(list);
1580
2156
  }
1581
2157
  handleArrangeChange(event) {
1582
2158
  if (this.readonly) return;
@@ -1685,38 +2261,48 @@ var VdFlowchart = class {
1685
2261
  );
1686
2262
  }
1687
2263
  }
2264
+ // Keys the editor consumes stop here, so page-level shortcuts (a docs
2265
+ // site's "/" search, a host's Escape handler) do not also fire.
1688
2266
  handleKeyDown(event) {
1689
- if (event.target && (event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA" || event.target.tagName === "SELECT")) {
2267
+ const alreadyHandled = event.defaultPrevented;
2268
+ this.handleEditorKey(event);
2269
+ if (!alreadyHandled && event.defaultPrevented) event.stopPropagation();
2270
+ }
2271
+ handleEditorKey(event) {
2272
+ const target = event.target;
2273
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) {
1690
2274
  return;
1691
2275
  }
1692
- if (event.target === this.canvasEl) {
1693
- const nodes = this.documentData.nodes;
1694
- const index = nodes.findIndex((node) => node.id === this.selection?.id);
1695
- let next = -1;
1696
- if (event.key === "ArrowDown" || event.key === "ArrowRight")
1697
- next = (index + 1) % nodes.length;
1698
- if (event.key === "ArrowUp" || event.key === "ArrowLeft")
1699
- next = index < 0 ? nodes.length - 1 : (index - 1 + nodes.length) % nodes.length;
1700
- if (event.key === "Home") next = 0;
1701
- if (event.key === "End") next = nodes.length - 1;
1702
- if (nodes[next]) {
1703
- event.preventDefault();
1704
- this.selectNode(nodes[next].id);
1705
- return;
1706
- }
1707
- if (event.key === "Enter" && !this.readonly && this.selection?.kind === "node") {
1708
- event.preventDefault();
1709
- this.startTextEdit(this.selection.id);
1710
- return;
1711
- }
2276
+ if (event.isComposing) return;
2277
+ const key = event.key;
2278
+ const modKey = event.metaKey || event.ctrlKey;
2279
+ if (key === "Escape" && this.shortcutsHelpOpen) {
2280
+ event.preventDefault();
2281
+ this.toggleShortcutsHelp(false);
2282
+ return;
2283
+ }
2284
+ if (target === this.canvasEl && this.handleCanvasKeyDown(event)) {
2285
+ event.preventDefault();
2286
+ return;
1712
2287
  }
1713
2288
  if (this.readonly) return;
1714
- if (event.key === "Escape" && this.activeTool) {
2289
+ if (key === "Escape") {
2290
+ if (this.activeTool) this.setActiveTool(null);
2291
+ else if (this.reconnectEdgeId) {
2292
+ this.reconnectEdgeId = null;
2293
+ this.render({ inspector: false, json: false });
2294
+ } else if (this.selection) this.select(null);
2295
+ else return;
1715
2296
  event.preventDefault();
1716
- this.setActiveTool(null);
1717
2297
  return;
1718
2298
  }
1719
- const modKey = event.metaKey || event.ctrlKey;
2299
+ if (modKey && (key === "d" || key === "D")) {
2300
+ if (this.selection?.kind === "node") {
2301
+ event.preventDefault();
2302
+ this.duplicateSelection();
2303
+ }
2304
+ return;
2305
+ }
1720
2306
  if (modKey && (event.key === "z" || event.key === "Z")) {
1721
2307
  event.preventDefault();
1722
2308
  if (event.shiftKey) {
@@ -1755,11 +2341,250 @@ var VdFlowchart = class {
1755
2341
  if (!this.selection) return;
1756
2342
  if (event.key === "Backspace" || event.key === "Delete") {
1757
2343
  event.preventDefault();
2344
+ if (this.selection.kind === "node" && !this.hasMultiSelection()) {
2345
+ const removedId = this.selection.id;
2346
+ const parent = this.getParentNode(removedId);
2347
+ const sibling = parent ? this.getChildNodes(parent.id).find((node) => node.id !== removedId) : null;
2348
+ if (this.deleteSelection()) {
2349
+ const next = parent && this.findNode(parent.id) || sibling;
2350
+ if (next) this.select({ kind: "node", id: next.id });
2351
+ }
2352
+ return;
2353
+ }
1758
2354
  this.deleteSelection();
1759
2355
  }
1760
2356
  }
2357
+ // Keys that only apply while the canvas itself has focus. Returns true when
2358
+ // the key was handled (the caller prevents the default action).
2359
+ handleCanvasKeyDown(event) {
2360
+ const key = event.key;
2361
+ const modKey = event.metaKey || event.ctrlKey;
2362
+ const mindmap = this.keyboardShortcuts === "mindmap";
2363
+ const selectedNode = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2364
+ const selectedEdge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2365
+ const movable = !this.readonly && Boolean(selectedNode);
2366
+ const editable = movable && !this.hasMultiSelection();
2367
+ if (key === "?" && !modKey && !event.altKey) {
2368
+ this.toggleShortcutsHelp();
2369
+ return true;
2370
+ }
2371
+ if (modKey && !event.altKey) {
2372
+ if (key === "=" || key === "+") return Boolean(this.zoomIn());
2373
+ if (key === "-" || key === "_") return Boolean(this.zoomOut());
2374
+ if (key === "0") return Boolean(this.zoomTo(1));
2375
+ if (key === "a" || key === "A") {
2376
+ this.selectNodes(this.getVisibleNodes().map((node) => node.id));
2377
+ return true;
2378
+ }
2379
+ if (key === "/" && selectedNode && !this.hasMultiSelection()) {
2380
+ this.toggleCollapsed(selectedNode.id);
2381
+ return true;
2382
+ }
2383
+ return false;
2384
+ }
2385
+ if (event.shiftKey && !event.altKey && (key === "!" || event.code === "Digit1")) {
2386
+ return Boolean(this.fitView());
2387
+ }
2388
+ const direction = ARROW_DIRECTIONS[key];
2389
+ if (direction) {
2390
+ if (event.altKey) {
2391
+ if (movable) {
2392
+ this.nudgeNodes(this.getSelectedNodeIds(), direction, event.shiftKey ? 1 : this.gridSize);
2393
+ }
2394
+ return true;
2395
+ }
2396
+ if (event.shiftKey) {
2397
+ const anchorId = selectedNode?.id ?? (selectedEdge ? this.edgeCycleAnchor : null);
2398
+ return anchorId ? this.selectSideEdge(anchorId, direction) : false;
2399
+ }
2400
+ this.navigateSelection(direction);
2401
+ return true;
2402
+ }
2403
+ const nodes = this.getVisibleNodes();
2404
+ if ((key === "Home" || key === "End") && nodes.length) {
2405
+ this.selectNode(nodes[key === "Home" ? 0 : nodes.length - 1].id);
2406
+ return true;
2407
+ }
2408
+ if (selectedEdge && !this.readonly && !event.altKey) {
2409
+ if (key === "F2" || key === "Enter" || key === " " && mindmap) {
2410
+ return this.startEdgeLabelEdit(selectedEdge.id);
2411
+ }
2412
+ if (mindmap && key.length === 1 && key !== " ") {
2413
+ return this.startEdgeLabelEdit(selectedEdge.id, { initialText: key });
2414
+ }
2415
+ return false;
2416
+ }
2417
+ if (!editable || event.altKey) return false;
2418
+ if (key === "F2" || key === "Enter" && !mindmap || key === " " && mindmap) {
2419
+ this.startTextEdit(selectedNode.id);
2420
+ return true;
2421
+ }
2422
+ if (!mindmap) return false;
2423
+ if (key === "Tab" && !event.shiftKey) {
2424
+ this.insertBranchNode(selectedNode.id, { edit: true });
2425
+ return true;
2426
+ }
2427
+ if (key === "Enter") {
2428
+ this.insertSiblingNode(selectedNode.id, { before: event.shiftKey, edit: true });
2429
+ return true;
2430
+ }
2431
+ if (key.length === 1 && key !== " " && isNodeTextEditable(selectedNode)) {
2432
+ this.startTextEdit(selectedNode.id, { initialText: key });
2433
+ return true;
2434
+ }
2435
+ return false;
2436
+ }
2437
+ // Move the selection to the nearest node in `direction`; with nothing (or an
2438
+ // edge) selected, start from the node closest to the centre of the view.
2439
+ navigateSelection(direction) {
2440
+ const nodes = this.getVisibleNodes();
2441
+ if (!nodes.length) return false;
2442
+ const current = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
2443
+ const edge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
2444
+ const next = current ? findSpatialNeighbor(nodes, current, direction) : edge ? this.getEdgeEndInDirection(edge, direction) : findNearestNode(nodes, this.getViewportCenter());
2445
+ if (!next) return false;
2446
+ this.selectNode(next.id);
2447
+ this.revealNode(next.id);
2448
+ return true;
2449
+ }
2450
+ // The end of `edge` that best matches `direction`, measured from the label
2451
+ // anchor so either end can be reached from a selected connection.
2452
+ getEdgeEndInDirection(edge, direction) {
2453
+ const fromNode = this.findNode(edge.from.nodeId);
2454
+ const toNode = this.findNode(edge.to.nodeId);
2455
+ if (!fromNode || !toNode) return fromNode || toNode;
2456
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
2457
+ const vector = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2458
+ const score = (node) => (node.x + node.width / 2 - labelX) * vector[0] + (node.y + node.height / 2 - labelY) * vector[1];
2459
+ return score(toNode) >= score(fromNode) ? toNode : fromNode;
2460
+ }
2461
+ // Connections attached to `nodeId` on the `direction` side, outgoing or
2462
+ // incoming, in document order.
2463
+ getSideEdges(nodeId, direction) {
2464
+ const port = DIRECTION_PORTS[direction];
2465
+ return this.documentData.edges.filter(
2466
+ (edge) => edge.from.nodeId === nodeId && edge.from.port === port || edge.to.nodeId === nodeId && edge.to.port === port
2467
+ );
2468
+ }
2469
+ // Select a connection on one side of a node; repeating cycles through them.
2470
+ selectSideEdge(nodeId, direction) {
2471
+ const edges = this.getSideEdges(nodeId, direction);
2472
+ if (!edges.length) return false;
2473
+ const currentIndex = this.selection?.kind === "edge" ? edges.findIndex((edge) => edge.id === this.selection.id) : -1;
2474
+ const next = edges[(currentIndex + 1) % edges.length];
2475
+ this.select({ kind: "edge", id: next.id });
2476
+ this.edgeCycleAnchor = nodeId;
2477
+ return true;
2478
+ }
2479
+ nudgeNode(nodeId, direction, distance) {
2480
+ const [node] = this.nudgeNodes([nodeId], direction, distance);
2481
+ return node || null;
2482
+ }
2483
+ // Move nodes together; repeated nudges of the same selection coalesce into
2484
+ // one undo step.
2485
+ nudgeNodes(nodeIds, direction, distance) {
2486
+ const step = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
2487
+ if (this.readonly || !step) return [];
2488
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2489
+ if (!nodes.length) return [];
2490
+ nodes.forEach((node) => {
2491
+ node.x = formatNumber(node.x + step[0] * distance);
2492
+ node.y = formatNumber(node.y + step[1] * distance);
2493
+ });
2494
+ this.render({ inspector: true, json: true });
2495
+ this.emitChange(
2496
+ "node:nudge",
2497
+ nodes.length > 1 ? { nodeIds: nodes.map((node) => node.id) } : { node: deepClone(nodes[0]) }
2498
+ );
2499
+ return nodes.map((node) => deepClone(node));
2500
+ }
2501
+ zoomTo(scale) {
2502
+ const viewport = this.documentData.viewport;
2503
+ const width = this.canvasEl.clientWidth || 800;
2504
+ const height = this.canvasEl.clientHeight || 560;
2505
+ this.scaleAround(
2506
+ clamp(formatScale(scale), this.minScale, this.maxScale) / viewport.scale,
2507
+ width / 2,
2508
+ height / 2,
2509
+ "viewport:zoom"
2510
+ );
2511
+ return this;
2512
+ }
2513
+ duplicateSelection() {
2514
+ if (this.readonly || this.selection?.kind !== "node") return null;
2515
+ if (this.hasMultiSelection()) return this.pasteNodeGroup(this.captureNodeGroup());
2516
+ const node = this.findNode(this.selection.id);
2517
+ return node ? this.pasteNodeData(node) : null;
2518
+ }
2519
+ // Selected nodes plus the connections that run between them.
2520
+ captureNodeGroup() {
2521
+ const ids = new Set(this.getSelectedNodeIds());
2522
+ return {
2523
+ nodes: deepClone(this.documentData.nodes.filter((node) => ids.has(node.id))),
2524
+ edges: deepClone(
2525
+ this.documentData.edges.filter(
2526
+ (edge) => ids.has(edge.from.nodeId) && ids.has(edge.to.nodeId)
2527
+ )
2528
+ )
2529
+ };
2530
+ }
2531
+ // Paste a captured group offset by one step, remapping ids so internal
2532
+ // connections follow their copies. One change, one undo step.
2533
+ pasteNodeGroup(group) {
2534
+ if (this.readonly || !group?.nodes?.length) return [];
2535
+ const usedNodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2536
+ const idMap = /* @__PURE__ */ new Map();
2537
+ const nodes = group.nodes.map((data, index) => {
2538
+ const node = normalizeNode(
2539
+ { ...data, id: void 0, x: data.x + 24, y: data.y + 24 },
2540
+ this.documentData.nodes.length + index,
2541
+ usedNodeIds,
2542
+ this.nodeSizeBounds()
2543
+ );
2544
+ idMap.set(data.id, node.id);
2545
+ return node;
2546
+ });
2547
+ this.documentData.nodes.push(...nodes);
2548
+ const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
2549
+ const usedEdgeIds = new Set(this.documentData.edges.map((edge) => edge.id));
2550
+ group.edges.forEach((data, index) => {
2551
+ const edge = normalizeEdge(
2552
+ {
2553
+ ...data,
2554
+ id: void 0,
2555
+ from: { ...data.from, nodeId: idMap.get(data.from.nodeId) },
2556
+ to: { ...data.to, nodeId: idMap.get(data.to.nodeId) }
2557
+ },
2558
+ this.documentData.edges.length + index,
2559
+ nodeIds,
2560
+ usedEdgeIds
2561
+ );
2562
+ if (edge) this.documentData.edges.push(edge);
2563
+ });
2564
+ const ids = nodes.map((node) => node.id);
2565
+ this.selectNodes(ids);
2566
+ this.emitChange("nodes:add", { nodeIds: ids });
2567
+ return nodes.map((node) => deepClone(node));
2568
+ }
2569
+ pasteNodeData(data) {
2570
+ const usedIds = new Set(this.documentData.nodes.map((node2) => node2.id));
2571
+ const node = normalizeNode(
2572
+ { ...data, id: void 0, collapsed: void 0, x: data.x + 24, y: data.y + 24 },
2573
+ this.documentData.nodes.length,
2574
+ usedIds,
2575
+ this.nodeSizeBounds()
2576
+ );
2577
+ this.documentData.nodes.push(node);
2578
+ this.select({ kind: "node", id: node.id });
2579
+ this.emitChange("node:add", { node: deepClone(node) });
2580
+ return deepClone(node);
2581
+ }
1761
2582
  copySelection() {
1762
2583
  if (!this.selection) return false;
2584
+ if (this.hasMultiSelection()) {
2585
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2586
+ return true;
2587
+ }
1763
2588
  if (this.selection.kind === "node") {
1764
2589
  const node = this.findNode(this.selection.id);
1765
2590
  if (!node) return false;
@@ -1774,22 +2599,14 @@ var VdFlowchart = class {
1774
2599
  pasteClipboard() {
1775
2600
  if (!this.clipboard || this.readonly) return false;
1776
2601
  if (this.clipboard.kind === "node") {
1777
- const usedIds2 = new Set(this.documentData.nodes.map((node2) => node2.id));
1778
- const node = normalizeNode(
1779
- {
1780
- ...this.clipboard.data,
1781
- id: void 0,
1782
- x: this.clipboard.data.x + 24,
1783
- y: this.clipboard.data.y + 24
1784
- },
1785
- this.documentData.nodes.length,
1786
- usedIds2
1787
- );
1788
- this.documentData.nodes.push(node);
1789
- this.select({ kind: "node", id: node.id });
1790
- this.emitChange("node:add", { node: deepClone(node) });
2602
+ this.pasteNodeData(this.clipboard.data);
1791
2603
  return true;
1792
2604
  }
2605
+ if (this.clipboard.kind === "nodes") {
2606
+ const pasted = this.pasteNodeGroup(this.clipboard.data);
2607
+ this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
2608
+ return pasted.length > 0;
2609
+ }
1793
2610
  const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
1794
2611
  const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
1795
2612
  const edge = normalizeEdge(
@@ -1816,16 +2633,16 @@ var VdFlowchart = class {
1816
2633
  const edgeTarget = event.target.closest("[data-edge-id]");
1817
2634
  if (edgeTarget && !event.target.closest("[data-edge-endpoint]")) {
1818
2635
  const edgeId = edgeTarget.getAttribute("data-edge-id");
1819
- if (edgeId && this.findEdge(edgeId)) {
1820
- this.reconnectEdgeId = edgeId;
1821
- this.select({ kind: "edge", id: edgeId });
2636
+ if (edgeId && this.startEdgeLabelEdit(edgeId)) {
1822
2637
  event.preventDefault();
1823
2638
  event.stopPropagation();
1824
2639
  return;
1825
2640
  }
1826
2641
  }
1827
2642
  const nodeTarget = event.target.closest("[data-node-id]");
1828
- if (!nodeTarget || event.target.closest("[data-edge-id]")) return;
2643
+ if (!nodeTarget || event.target.closest("[data-edge-id]") || event.target.closest("[data-collapse-toggle]")) {
2644
+ return;
2645
+ }
1829
2646
  const nodeId = nodeTarget.getAttribute("data-node-id");
1830
2647
  if (this.startTextEdit(nodeId)) {
1831
2648
  event.preventDefault();
@@ -1835,7 +2652,7 @@ var VdFlowchart = class {
1835
2652
  handleClick(event) {
1836
2653
  if (this.readonly || event.detail < 2) return;
1837
2654
  const nodeTarget = event.target.closest("[data-node-id]");
1838
- if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]"))
2655
+ if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]") || event.target.closest("[data-collapse-toggle]"))
1839
2656
  return;
1840
2657
  if (this.startTextEdit(nodeTarget.getAttribute("data-node-id"))) {
1841
2658
  event.preventDefault();
@@ -1848,6 +2665,13 @@ var VdFlowchart = class {
1848
2665
  this.stopTextEdit({ commit: true });
1849
2666
  }
1850
2667
  this.canvasEl.focus();
2668
+ const collapseTarget = event.target.closest("[data-collapse-toggle]");
2669
+ if (collapseTarget) {
2670
+ this.lastNodePointer = null;
2671
+ this.toggleCollapsed(collapseTarget.getAttribute("data-collapse-toggle"));
2672
+ event.preventDefault();
2673
+ return;
2674
+ }
1851
2675
  const nodeTarget = event.target.closest("[data-node-id]");
1852
2676
  const portTarget = event.target.closest("[data-port]");
1853
2677
  const resizeTarget = event.target.closest("[data-resize-handle]");
@@ -1883,6 +2707,13 @@ var VdFlowchart = class {
1883
2707
  event.preventDefault();
1884
2708
  return;
1885
2709
  }
2710
+ if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && event.shiftKey) {
2711
+ this.reconnectEdgeId = null;
2712
+ this.lastNodePointer = null;
2713
+ this.toggleNodeSelection(nodeTarget.getAttribute("data-node-id"));
2714
+ event.preventDefault();
2715
+ return;
2716
+ }
1886
2717
  if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && !this.readonly) {
1887
2718
  if (this.activeTool === "arrow") {
1888
2719
  this.select({ kind: "node", id: nodeTarget.getAttribute("data-node-id") });
@@ -1909,6 +2740,9 @@ var VdFlowchart = class {
1909
2740
  this.interaction = {
1910
2741
  kind: "connect",
1911
2742
  pointerId: event.pointerId,
2743
+ startClientX: event.clientX,
2744
+ startClientY: event.clientY,
2745
+ moved: false,
1912
2746
  source: { nodeId, port },
1913
2747
  target: null,
1914
2748
  previousSnap: null,
@@ -1959,22 +2793,27 @@ var VdFlowchart = class {
1959
2793
  if (nodeTarget) {
1960
2794
  this.reconnectEdgeId = null;
1961
2795
  const nodeId = nodeTarget.getAttribute("data-node-id");
1962
- this.select({ kind: "node", id: nodeId });
2796
+ const groupIds = this.hasMultiSelection() && this.isNodeSelected(nodeId) ? this.getSelectedNodeIds() : [nodeId];
2797
+ if (groupIds.length > 1) this.selectNodes(groupIds, { primary: nodeId });
2798
+ else this.select({ kind: "node", id: nodeId });
1963
2799
  if (!this.readonly) {
1964
- const node = this.findNode(nodeId);
1965
- const world = this.clientToWorld(event.clientX, event.clientY);
1966
- this.interaction = {
1967
- kind: "drag-node",
1968
- pointerId: event.pointerId,
1969
- nodeId,
1970
- offsetX: world.x - node.x,
1971
- offsetY: world.y - node.y,
1972
- moved: false
1973
- };
2800
+ this.interaction = this.createDragInteraction(event, nodeId, groupIds);
1974
2801
  this.capturePointer(event.pointerId);
1975
2802
  }
1976
2803
  return;
1977
2804
  }
2805
+ if (event.shiftKey) {
2806
+ const startWorld = this.clientToWorld(event.clientX, event.clientY);
2807
+ this.interaction = {
2808
+ kind: "marquee",
2809
+ pointerId: event.pointerId,
2810
+ startWorld,
2811
+ baseIds: this.selection?.kind === "node" ? this.getSelectedNodeIds() : []
2812
+ };
2813
+ this.capturePointer(event.pointerId);
2814
+ event.preventDefault();
2815
+ return;
2816
+ }
1978
2817
  this.select(null);
1979
2818
  this.reconnectEdgeId = null;
1980
2819
  this.interaction = {
@@ -1989,20 +2828,123 @@ var VdFlowchart = class {
1989
2828
  this.capturePointer(event.pointerId);
1990
2829
  event.preventDefault();
1991
2830
  }
2831
+ createDragInteraction(event, nodeId, nodeIds) {
2832
+ const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
2833
+ const moving = new Set(nodes.map((node) => node.id));
2834
+ return {
2835
+ kind: "drag-node",
2836
+ pointerId: event.pointerId,
2837
+ nodeId,
2838
+ nodeIds: nodes.map((node) => node.id),
2839
+ startWorld: this.clientToWorld(event.clientX, event.clientY),
2840
+ origins: new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }])),
2841
+ bounds: getBounds(nodes),
2842
+ guideLines: this.snapGuides ? collectGuideLines(this.getVisibleNodes().filter((node) => !moving.has(node.id))) : null,
2843
+ snapDisabled: false,
2844
+ moved: false
2845
+ };
2846
+ }
2847
+ // Translate every dragged node by the pointer delta, snapping the group's
2848
+ // bounds to the nearest alignment guide on each axis. Alt turns snapping
2849
+ // off for the rest of the gesture.
2850
+ moveDraggedNodes(event) {
2851
+ const interaction = this.interaction;
2852
+ const world = this.clientToWorld(event.clientX, event.clientY);
2853
+ let dx = world.x - interaction.startWorld.x;
2854
+ let dy = world.y - interaction.startWorld.y;
2855
+ if (event.altKey) interaction.snapDisabled = true;
2856
+ let guides = [];
2857
+ if (interaction.guideLines && !interaction.snapDisabled) {
2858
+ const box = interaction.bounds;
2859
+ const threshold = GUIDE_SNAP_DISTANCE / this.documentData.viewport.scale;
2860
+ const left = box.left + dx;
2861
+ const top = box.top + dy;
2862
+ const width = box.right - box.left;
2863
+ const height = box.bottom - box.top;
2864
+ const snapX = snapToGuides(
2865
+ interaction.guideLines.x,
2866
+ [left, left + width / 2, left + width],
2867
+ threshold
2868
+ );
2869
+ const snapY = snapToGuides(
2870
+ interaction.guideLines.y,
2871
+ [top, top + height / 2, top + height],
2872
+ threshold
2873
+ );
2874
+ if (snapX) dx += snapX.offset;
2875
+ if (snapY) dy += snapY.offset;
2876
+ const snapped = { left: box.left + dx, top: box.top + dy, width, height };
2877
+ if (snapX) guides = guides.concat(guideSegments("x", snapX.lines, snapped));
2878
+ if (snapY) guides = guides.concat(guideSegments("y", snapY.lines, snapped));
2879
+ }
2880
+ let changed = false;
2881
+ interaction.nodeIds.forEach((id) => {
2882
+ const node = this.findNode(id);
2883
+ const origin = interaction.origins.get(id);
2884
+ if (!node || !origin) return;
2885
+ const nextX = formatNumber(origin.x + dx);
2886
+ const nextY = formatNumber(origin.y + dy);
2887
+ if (nextX === node.x && nextY === node.y) return;
2888
+ node.x = nextX;
2889
+ node.y = nextY;
2890
+ changed = true;
2891
+ this.renderDraggedNode(node);
2892
+ });
2893
+ if (changed) interaction.moved = true;
2894
+ this.renderGuides(guides);
2895
+ }
2896
+ // Live marquee: select every visible node the box touches, added to the
2897
+ // selection the gesture started from.
2898
+ updateMarquee(event) {
2899
+ const interaction = this.interaction;
2900
+ const world = this.clientToWorld(event.clientX, event.clientY);
2901
+ const box = {
2902
+ left: Math.min(interaction.startWorld.x, world.x),
2903
+ top: Math.min(interaction.startWorld.y, world.y),
2904
+ right: Math.max(interaction.startWorld.x, world.x),
2905
+ bottom: Math.max(interaction.startWorld.y, world.y)
2906
+ };
2907
+ clearChildren(this.guidesLayer);
2908
+ this.guidesLayer.appendChild(
2909
+ svgEl("rect", {
2910
+ class: "vd-flowchart-marquee",
2911
+ x: formatNumber(box.left),
2912
+ y: formatNumber(box.top),
2913
+ width: formatNumber(box.right - box.left),
2914
+ height: formatNumber(box.bottom - box.top)
2915
+ })
2916
+ );
2917
+ const hits = this.getVisibleNodes().filter(
2918
+ (node) => node.x <= box.right && node.x + node.width >= box.left && node.y <= box.bottom && node.y + node.height >= box.top
2919
+ ).map((node) => node.id);
2920
+ const ids = [.../* @__PURE__ */ new Set([...interaction.baseIds, ...hits])];
2921
+ const key = ids.join(",");
2922
+ if (key === interaction.lastKey) return;
2923
+ interaction.lastKey = key;
2924
+ this.selectNodes(ids, { primary: hits[hits.length - 1] });
2925
+ }
2926
+ renderGuides(guides) {
2927
+ clearChildren(this.guidesLayer);
2928
+ guides.forEach((guide) => {
2929
+ this.guidesLayer.appendChild(
2930
+ svgEl("line", {
2931
+ class: "vd-flowchart-guide",
2932
+ x1: guide.x1,
2933
+ y1: guide.y1,
2934
+ x2: guide.x2,
2935
+ y2: guide.y2
2936
+ })
2937
+ );
2938
+ });
2939
+ }
1992
2940
  handlePointerMove(event) {
1993
2941
  if (!this.interaction || this.interaction.pointerId !== event.pointerId) return;
1994
2942
  if (this.interaction.kind === "drag-node") {
1995
- const node = this.findNode(this.interaction.nodeId);
1996
- if (!node) return;
1997
- const world = this.clientToWorld(event.clientX, event.clientY);
1998
- const nextX = formatNumber(world.x - this.interaction.offsetX);
1999
- const nextY = formatNumber(world.y - this.interaction.offsetY);
2000
- if (nextX !== node.x || nextY !== node.y) {
2001
- node.x = nextX;
2002
- node.y = nextY;
2003
- this.interaction.moved = true;
2004
- this.renderDraggedNode(node);
2005
- }
2943
+ this.moveDraggedNodes(event);
2944
+ return;
2945
+ }
2946
+ if (this.interaction.kind === "marquee") {
2947
+ this.updateMarquee(event);
2006
2948
  return;
2007
2949
  }
2008
2950
  if (this.interaction.kind === "resize-node") {
@@ -2037,6 +2979,14 @@ var VdFlowchart = class {
2037
2979
  return;
2038
2980
  }
2039
2981
  if (this.interaction.kind === "connect") {
2982
+ if (!this.interaction.moved) {
2983
+ const travel = Math.hypot(
2984
+ event.clientX - this.interaction.startClientX,
2985
+ event.clientY - this.interaction.startClientY
2986
+ );
2987
+ if (!(travel > PORT_CLICK_SLOP)) return;
2988
+ this.interaction.moved = true;
2989
+ }
2040
2990
  const world = this.clientToWorld(event.clientX, event.clientY);
2041
2991
  const snapTarget = this.findConnectionTarget(
2042
2992
  world,
@@ -2123,10 +3073,19 @@ var VdFlowchart = class {
2123
3073
  }
2124
3074
  return;
2125
3075
  }
3076
+ if (interaction.kind === "marquee") {
3077
+ clearChildren(this.guidesLayer);
3078
+ return;
3079
+ }
2126
3080
  if (interaction.kind === "drag-node") {
3081
+ clearChildren(this.guidesLayer);
2127
3082
  if (interaction.moved) {
2128
3083
  this.render({ inspector: true, json: true });
2129
- this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3084
+ if (interaction.nodeIds.length > 1) {
3085
+ this.emitChange("nodes:move", { nodeIds: [...interaction.nodeIds] });
3086
+ } else {
3087
+ this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
3088
+ }
2130
3089
  } else {
2131
3090
  this.render({ inspector: false, json: false });
2132
3091
  }
@@ -2142,6 +3101,14 @@ var VdFlowchart = class {
2142
3101
  return;
2143
3102
  }
2144
3103
  if (interaction.kind === "connect") {
3104
+ if (!interaction.moved && this.activeTool !== "arrow") {
3105
+ this.render({ inspector: false, json: false });
3106
+ this.insertBranchNode(interaction.source.nodeId, {
3107
+ direction: PORT_DIRECTIONS[interaction.source.port],
3108
+ edit: true
3109
+ });
3110
+ return;
3111
+ }
2145
3112
  const world = this.clientToWorld(event.clientX || 0, event.clientY || 0);
2146
3113
  const snapTarget = interaction.target ? { node: this.findNode(interaction.target.nodeId), port: interaction.target.port } : this.findConnectionTarget(
2147
3114
  world,
@@ -2217,16 +3184,17 @@ var VdFlowchart = class {
2217
3184
  if (this.readonly) return false;
2218
3185
  if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") return true;
2219
3186
  if (this.activeTool === "arrow") return true;
2220
- return this.selection?.kind === "node" && this.selection.id === node.id;
3187
+ return this.selection?.kind === "node" && this.selection.id === node.id && !this.hasMultiSelection();
2221
3188
  }
2222
- getMarkerId(markerType, position, strokeWidth) {
3189
+ getMarkerId(markerType, position, strokeWidth, edgeScale = 1) {
2223
3190
  const width = normalizeEdgeStrokeWidth(strokeWidth);
2224
- const key = `${markerType}:${position}:${width}`;
3191
+ const scale = clamp(formatNumber(Math.max(edgeScale, 0.01)), 0.01, 1);
3192
+ const key = `${markerType}:${position}:${width}:${scale}`;
2225
3193
  if (this.markerIds.has(key)) {
2226
3194
  return this.markerIds.get(key);
2227
3195
  }
2228
3196
  const markerId = nextId(`flowchart-${markerType}-${position}`);
2229
- const marker = markerType === "dot" ? createDotMarker(markerId, width) : createArrowMarker(markerId, width, position === "start");
3197
+ const marker = markerType === "dot" ? createDotMarker(markerId, width, scale) : createArrowMarker(markerId, width, position === "start", scale);
2230
3198
  this.markerDefs.appendChild(marker);
2231
3199
  this.markerIds.set(key, markerId);
2232
3200
  return markerId;
@@ -2234,13 +3202,20 @@ var VdFlowchart = class {
2234
3202
  getEdgeStrokeWidth(edge) {
2235
3203
  return normalizeEdgeStrokeWidth(edge?.strokeWidth);
2236
3204
  }
2237
- getEdgeHitStrokeWidth(edge) {
2238
- return formatNumber(Math.max(EDGE_HIT_STROKE_MIN, this.getEdgeStrokeWidth(edge) + 12));
3205
+ getEdgeHitStrokeWidth(edge, edgeScale = 1) {
3206
+ const visual = clamp(toFiniteNumber(edgeScale, 1), 0.01, 1);
3207
+ const base = this.getEdgeStrokeWidth(edge);
3208
+ const view = Math.max(this.documentData?.viewport?.scale || 1, 1e-4);
3209
+ const legacy = Math.max(EDGE_HIT_STROKE_MIN, base + 12);
3210
+ if (visual >= 0.999 && view >= DEFAULT_MIN_SCALE && view <= DEFAULT_MAX_SCALE) {
3211
+ return formatNumber(legacy);
3212
+ }
3213
+ return formatNumber(Math.max(base * visual, EDGE_HIT_STROKE_MIN / view));
2239
3214
  }
2240
3215
  findConnectionTarget(worldPoint, excludeNodeId, previousSnap = null, referencePoint = null) {
2241
3216
  const snapPadding = CONNECTION_SNAP_PADDING / this.documentData.viewport.scale;
2242
3217
  const hysteresisMargin = CONNECTION_HYSTERESIS / this.documentData.viewport.scale;
2243
- const best = this.documentData.nodes.reduce((candidate, node) => {
3218
+ const best = this.getVisibleNodes().reduce((candidate, node) => {
2244
3219
  if (node.id === excludeNodeId) return candidate;
2245
3220
  const distanceToBounds2 = getDistanceToNodeBounds(node, worldPoint);
2246
3221
  if (distanceToBounds2 > snapPadding) return candidate;
@@ -2292,17 +3267,37 @@ var VdFlowchart = class {
2292
3267
  let width = original.width;
2293
3268
  let height = original.height;
2294
3269
  if (handle.includes("e")) {
2295
- width = clampNodeWidth(nextType, original.width + deltaX, original.width);
3270
+ width = clampNodeWidth(
3271
+ nextType,
3272
+ original.width + deltaX,
3273
+ original.width,
3274
+ this.nodeSizeBounds()
3275
+ );
2296
3276
  }
2297
3277
  if (handle.includes("s")) {
2298
- height = clampNodeHeight(nextType, original.height + deltaY, original.height);
3278
+ height = clampNodeHeight(
3279
+ nextType,
3280
+ original.height + deltaY,
3281
+ original.height,
3282
+ this.nodeSizeBounds()
3283
+ );
2299
3284
  }
2300
3285
  if (handle.includes("w")) {
2301
- width = clampNodeWidth(nextType, original.width - deltaX, original.width);
3286
+ width = clampNodeWidth(
3287
+ nextType,
3288
+ original.width - deltaX,
3289
+ original.width,
3290
+ this.nodeSizeBounds()
3291
+ );
2302
3292
  x = original.x + original.width - width;
2303
3293
  }
2304
3294
  if (handle.includes("n")) {
2305
- height = clampNodeHeight(nextType, original.height - deltaY, original.height);
3295
+ height = clampNodeHeight(
3296
+ nextType,
3297
+ original.height - deltaY,
3298
+ original.height,
3299
+ this.nodeSizeBounds()
3300
+ );
2306
3301
  y = original.y + original.height - height;
2307
3302
  }
2308
3303
  return {
@@ -2338,7 +3333,7 @@ var VdFlowchart = class {
2338
3333
  scaleAround(factor, localX, localY, reason) {
2339
3334
  const viewport = this.documentData.viewport;
2340
3335
  const anchor = this.localToWorld(localX, localY);
2341
- const nextScale = clamp(formatNumber(viewport.scale * factor), MIN_SCALE, MAX_SCALE);
3336
+ const nextScale = clamp(formatScale(viewport.scale * factor), this.minScale, this.maxScale);
2342
3337
  if (nextScale === viewport.scale) return;
2343
3338
  viewport.scale = nextScale;
2344
3339
  viewport.x = formatNumber(localX - anchor.x * nextScale);
@@ -2349,7 +3344,10 @@ var VdFlowchart = class {
2349
3344
  updateToolbarLabel() {
2350
3345
  this.zoomLabel.textContent = `${Math.round(this.documentData.viewport.scale * 100)}%`;
2351
3346
  }
2352
- startTextEdit(nodeId) {
3347
+ // Options: `initialText` replaces the label with typed text (type-to-edit);
3348
+ // `inserted` marks a node created by a keyboard/handle insertion so naming it
3349
+ // shares the insertion's undo step.
3350
+ startTextEdit(nodeId, options = {}) {
2353
3351
  if (this.readonly) return false;
2354
3352
  const node = this.findNode(nodeId);
2355
3353
  if (!node || !isNodeTextEditable(node)) return false;
@@ -2361,9 +3359,10 @@ var VdFlowchart = class {
2361
3359
  }
2362
3360
  this.stopTextEdit({ commit: true });
2363
3361
  this.select({ kind: "node", id: node.id });
3362
+ const replacing = typeof options.initialText === "string";
2364
3363
  const textarea = createElement("textarea", {
2365
3364
  className: `vd-flowchart-text-editor vd-flowchart-text-editor--${node.type}`,
2366
- value: node.text,
3365
+ value: replacing ? options.initialText : node.text,
2367
3366
  rows: 1
2368
3367
  });
2369
3368
  textarea.setAttribute("data-node-id", node.id);
@@ -2372,19 +3371,7 @@ var VdFlowchart = class {
2372
3371
  textarea.addEventListener("input", () => this.positionTextEditor());
2373
3372
  textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
2374
3373
  textarea.addEventListener("dblclick", (event) => event.stopPropagation());
2375
- textarea.addEventListener("keydown", (event) => {
2376
- if (event.key === "Escape") {
2377
- event.preventDefault();
2378
- this.stopTextEdit({ commit: false });
2379
- this.canvasEl.focus();
2380
- return;
2381
- }
2382
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
2383
- event.preventDefault();
2384
- this.stopTextEdit({ commit: true });
2385
- this.canvasEl.focus();
2386
- }
2387
- });
3374
+ textarea.addEventListener("keydown", (event) => this.handleTextEditorKeyDown(event, node));
2388
3375
  textarea.addEventListener("blur", () => {
2389
3376
  if (this.textEditor?.textarea === textarea) {
2390
3377
  this.stopTextEdit({ commit: true });
@@ -2393,18 +3380,97 @@ var VdFlowchart = class {
2393
3380
  this.textEditor = {
2394
3381
  nodeId: node.id,
2395
3382
  previousText: node.text,
3383
+ inserted: Boolean(options.inserted),
2396
3384
  textarea
2397
3385
  };
2398
3386
  this.canvasEl.appendChild(textarea);
2399
3387
  this.render({ inspector: true, json: false });
2400
- window.requestAnimationFrame(() => {
2401
- if (this.textEditor?.textarea === textarea) {
2402
- textarea.focus();
3388
+ const focusEditor = () => {
3389
+ if (this.textEditor?.textarea !== textarea) return;
3390
+ textarea.focus();
3391
+ if (replacing) {
3392
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3393
+ } else {
2403
3394
  textarea.select();
2404
3395
  }
3396
+ };
3397
+ focusEditor();
3398
+ window.requestAnimationFrame(() => {
3399
+ if (document.activeElement !== textarea) focusEditor();
3400
+ });
3401
+ return true;
3402
+ }
3403
+ // Inline label editor for a connection, placed on the label anchor
3404
+ // (curve midpoint). Labels render on one line, so Enter always saves.
3405
+ startEdgeLabelEdit(edgeId, options = {}) {
3406
+ if (this.readonly) return false;
3407
+ const edge = this.findEdge(sanitizeId(edgeId));
3408
+ if (!edge) return false;
3409
+ this.stopTextEdit({ commit: true });
3410
+ this.select({ kind: "edge", id: edge.id });
3411
+ const replacing = typeof options.initialText === "string";
3412
+ const textarea = createElement("textarea", {
3413
+ className: "vd-flowchart-text-editor vd-flowchart-text-editor--edge",
3414
+ value: replacing ? options.initialText : edge.label,
3415
+ rows: 1
3416
+ });
3417
+ textarea.setAttribute("data-edge-id", edge.id);
3418
+ textarea.setAttribute("aria-label", "Edit connection label");
3419
+ textarea.spellcheck = false;
3420
+ textarea.addEventListener("input", () => this.positionTextEditor());
3421
+ textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
3422
+ textarea.addEventListener("dblclick", (event) => event.stopPropagation());
3423
+ textarea.addEventListener("keydown", (event) => {
3424
+ if (event.isComposing) return;
3425
+ if (event.key === "Escape" || event.key === "Enter") {
3426
+ event.preventDefault();
3427
+ this.stopTextEdit({ commit: event.key === "Enter" });
3428
+ this.canvasEl.focus();
3429
+ }
3430
+ });
3431
+ textarea.addEventListener("blur", () => {
3432
+ if (this.textEditor?.textarea === textarea) this.stopTextEdit({ commit: true });
3433
+ });
3434
+ this.textEditor = { nodeId: null, edgeId: edge.id, previousText: edge.label, textarea };
3435
+ this.canvasEl.appendChild(textarea);
3436
+ this.render({ inspector: true, json: false });
3437
+ const focusEditor = () => {
3438
+ if (this.textEditor?.textarea !== textarea) return;
3439
+ textarea.focus();
3440
+ if (replacing) textarea.setSelectionRange(textarea.value.length, textarea.value.length);
3441
+ else textarea.select();
3442
+ };
3443
+ focusEditor();
3444
+ window.requestAnimationFrame(() => {
3445
+ if (document.activeElement !== textarea) focusEditor();
2405
3446
  });
2406
3447
  return true;
2407
3448
  }
3449
+ handleTextEditorKeyDown(event, node) {
3450
+ if (event.isComposing) return;
3451
+ const mindmap = this.keyboardShortcuts === "mindmap";
3452
+ if (event.key === "Escape") {
3453
+ event.preventDefault();
3454
+ this.stopTextEdit({ commit: false });
3455
+ this.canvasEl.focus();
3456
+ return;
3457
+ }
3458
+ if (event.key === "Enter") {
3459
+ const modKey = event.ctrlKey || event.metaKey;
3460
+ const commits = modKey || mindmap && node.type !== "textbox" && !event.shiftKey;
3461
+ if (!commits) return;
3462
+ event.preventDefault();
3463
+ this.stopTextEdit({ commit: true });
3464
+ this.canvasEl.focus();
3465
+ return;
3466
+ }
3467
+ if (event.key === "Tab" && mindmap && !event.shiftKey && !event.altKey && !event.ctrlKey) {
3468
+ event.preventDefault();
3469
+ this.stopTextEdit({ commit: true });
3470
+ this.canvasEl.focus();
3471
+ this.insertBranchNode(node.id, { edit: true });
3472
+ }
3473
+ }
2408
3474
  stopTextEdit(options = {}) {
2409
3475
  if (!this.textEditor) return false;
2410
3476
  const commit = options.commit !== false;
@@ -2412,12 +3478,26 @@ var VdFlowchart = class {
2412
3478
  const nextText = editor.textarea.value;
2413
3479
  this.textEditor = null;
2414
3480
  editor.textarea.remove();
3481
+ if (editor.edgeId) {
3482
+ const label = nextText.replace(/\s*\n\s*/g, " ").trim();
3483
+ if (commit && !this.readonly && label !== editor.previousText) {
3484
+ this.updateEdge(editor.edgeId, { label }, { inspector: true, reason: "edge:update" });
3485
+ } else {
3486
+ this.render({ scene: true, inspector: false, json: false });
3487
+ }
3488
+ return true;
3489
+ }
2415
3490
  if (commit && !this.readonly && nextText !== editor.previousText) {
2416
- this.updateNode(
2417
- editor.nodeId,
2418
- { text: nextText },
2419
- { inspector: true, reason: "node:update" }
2420
- );
3491
+ this.coalesceInsertedText = editor.inserted;
3492
+ try {
3493
+ this.updateNode(
3494
+ editor.nodeId,
3495
+ { text: nextText },
3496
+ { inspector: true, reason: "node:update" }
3497
+ );
3498
+ } finally {
3499
+ this.coalesceInsertedText = false;
3500
+ }
2421
3501
  } else {
2422
3502
  this.render({ scene: true, inspector: false, json: false });
2423
3503
  }
@@ -2425,6 +3505,10 @@ var VdFlowchart = class {
2425
3505
  }
2426
3506
  positionTextEditor() {
2427
3507
  if (!this.textEditor) return;
3508
+ if (this.textEditor.edgeId) {
3509
+ this.positionEdgeLabelEditor();
3510
+ return;
3511
+ }
2428
3512
  const node = this.findNode(this.textEditor.nodeId);
2429
3513
  if (!node) {
2430
3514
  this.stopTextEdit({ commit: false });
@@ -2432,24 +3516,50 @@ var VdFlowchart = class {
2432
3516
  }
2433
3517
  const viewport = this.documentData.viewport;
2434
3518
  const scale = viewport.scale;
3519
+ const visual = nodeVisualScale(node);
2435
3520
  const textarea = this.textEditor.textarea;
2436
3521
  const width = node.width * scale;
2437
3522
  const height = node.height * scale;
2438
3523
  const metrics = getNodeFontMetrics(node);
2439
- const lineHeight = metrics.lineHeight * scale;
3524
+ const lineHeight = metrics.lineHeight * visual * scale;
2440
3525
  const lineCount = Math.max(1, textarea.value.split(/\r?\n/).length);
2441
- const horizontalPadding = (node.type === "textbox" ? 16 : 12) * scale;
2442
- const verticalPadding = node.type === "textbox" ? 12 * scale : Math.max(4, (height - lineCount * lineHeight) / 2);
3526
+ const horizontalPadding = (node.type === "textbox" ? 16 : 12) * visual * scale;
3527
+ const verticalPadding = node.type === "textbox" ? 12 * visual * scale : Math.max(4 * visual, (height - lineCount * lineHeight) / 2);
2443
3528
  textarea.style.left = `${formatNumber(viewport.x + node.x * scale)}px`;
2444
3529
  textarea.style.top = `${formatNumber(viewport.y + node.y * scale)}px`;
2445
3530
  textarea.style.width = `${formatNumber(width)}px`;
2446
3531
  textarea.style.height = `${formatNumber(height)}px`;
2447
- textarea.style.fontSize = `${formatNumber(metrics.fontSize * scale)}px`;
3532
+ textarea.style.fontSize = `${formatNumber(metrics.fontSize * visual * scale)}px`;
2448
3533
  textarea.style.lineHeight = `${formatNumber(lineHeight)}px`;
2449
3534
  textarea.style.padding = `${formatNumber(verticalPadding)}px ${formatNumber(horizontalPadding)}px`;
2450
3535
  }
3536
+ positionEdgeLabelEditor() {
3537
+ const edge = this.findEdge(this.textEditor.edgeId);
3538
+ const fromNode = edge && this.findNode(edge.from.nodeId);
3539
+ const toNode = edge && this.findNode(edge.to.nodeId);
3540
+ if (!edge || !fromNode || !toNode) {
3541
+ this.stopTextEdit({ commit: false });
3542
+ return;
3543
+ }
3544
+ const visual = edgeVisualScale(fromNode, toNode);
3545
+ const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
3546
+ const viewport = this.documentData.viewport;
3547
+ const textarea = this.textEditor.textarea;
3548
+ const fontSize = Math.max(11 * visual, 13 * visual * viewport.scale);
3549
+ const width = clamp(textarea.value.length * fontSize * 0.62 + 28 * visual, 96 * visual, 280);
3550
+ const height = fontSize * 1.4 + 12 * visual;
3551
+ const labelOffset = 8 * visual;
3552
+ textarea.style.left = `${formatNumber(viewport.x + labelX * viewport.scale - width / 2)}px`;
3553
+ textarea.style.top = `${formatNumber(viewport.y + (labelY - labelOffset) * viewport.scale - height / 2)}px`;
3554
+ textarea.style.width = `${formatNumber(width)}px`;
3555
+ textarea.style.height = `${formatNumber(height)}px`;
3556
+ textarea.style.fontSize = `${formatNumber(fontSize)}px`;
3557
+ textarea.style.lineHeight = `${formatNumber(fontSize * 1.4)}px`;
3558
+ textarea.style.padding = "6px 10px";
3559
+ }
2451
3560
  render(options = {}) {
2452
3561
  if (this.destroyed) return;
3562
+ this.syncGrid();
2453
3563
  const shouldRenderScene = options.scene !== false;
2454
3564
  const shouldRenderInspector = options.inspector !== false;
2455
3565
  const shouldRenderJson = options.json !== false;
@@ -2460,8 +3570,10 @@ var VdFlowchart = class {
2460
3570
  this.updateToolbarLabel();
2461
3571
  this.updatePaletteState();
2462
3572
  this.positionTextEditor();
3573
+ this.scheduleMinimap();
2463
3574
  }
2464
3575
  renderScene() {
3576
+ this.syncSelectedNodeIds();
2465
3577
  const viewport = this.documentData.viewport;
2466
3578
  this.world.setAttribute(
2467
3579
  "transform",
@@ -2471,7 +3583,14 @@ var VdFlowchart = class {
2471
3583
  clearChildren(this.previewLayer);
2472
3584
  clearChildren(this.nodesLayer);
2473
3585
  clearChildren(this.overlayLayer);
2474
- const nodeMap = new Map(this.documentData.nodes.map((node) => [node.id, node]));
3586
+ clearChildren(this.markerDefs);
3587
+ this.markerIds = /* @__PURE__ */ new Map();
3588
+ const hidden = this.getHiddenNodeIds();
3589
+ this.sceneHidden = hidden;
3590
+ this.sceneOutgoing = this.getOutgoingMap();
3591
+ const nodeMap = new Map(
3592
+ this.documentData.nodes.filter((node) => !hidden.has(node.id)).map((node) => [node.id, node])
3593
+ );
2475
3594
  this.sceneNodeMap = nodeMap;
2476
3595
  this.nodeElements = /* @__PURE__ */ new Map();
2477
3596
  this.edgeElements = /* @__PURE__ */ new Map();
@@ -2491,6 +3610,7 @@ var VdFlowchart = class {
2491
3610
  this.previewLayer.appendChild(this.renderPreviewEdge(this.interaction));
2492
3611
  }
2493
3612
  this.documentData.nodes.forEach((node) => {
3613
+ if (hidden.has(node.id)) return;
2494
3614
  const element = this.renderNode(node);
2495
3615
  this.nodesLayer.appendChild(element);
2496
3616
  this.nodeElements.set(node.id, element);
@@ -2528,17 +3648,18 @@ var VdFlowchart = class {
2528
3648
  }
2529
3649
  }
2530
3650
  }
2531
- applyEdgeMarkers(pathElement, edge) {
3651
+ applyEdgeMarkers(pathElement, edge, edgeScale = 1) {
3652
+ const width = this.getEdgeStrokeWidth(edge);
2532
3653
  if (edge.startMarker && edge.startMarker !== "none") {
2533
3654
  pathElement.setAttribute(
2534
3655
  "marker-start",
2535
- `url(#${this.getMarkerId(edge.startMarker, "start", this.getEdgeStrokeWidth(edge))})`
3656
+ `url(#${this.getMarkerId(edge.startMarker, "start", width, edgeScale)})`
2536
3657
  );
2537
3658
  }
2538
3659
  if (edge.endMarker && edge.endMarker !== "none") {
2539
3660
  pathElement.setAttribute(
2540
3661
  "marker-end",
2541
- `url(#${this.getMarkerId(edge.endMarker, "end", this.getEdgeStrokeWidth(edge))})`
3662
+ `url(#${this.getMarkerId(edge.endMarker, "end", width, edgeScale)})`
2542
3663
  );
2543
3664
  }
2544
3665
  }
@@ -2546,6 +3667,7 @@ var VdFlowchart = class {
2546
3667
  const fromNode = nodeMap.get(edge.from.nodeId);
2547
3668
  const toNode = nodeMap.get(edge.to.nodeId);
2548
3669
  if (!fromNode || !toNode) return null;
3670
+ const visual = edgeVisualScale(fromNode, toNode);
2549
3671
  const edgePath = buildEdgePath(edge, fromNode, toNode);
2550
3672
  const selected = this.selection?.kind === "edge" && this.selection.id === edge.id;
2551
3673
  const inReconnectMode = this.reconnectEdgeId === edge.id;
@@ -2553,13 +3675,14 @@ var VdFlowchart = class {
2553
3675
  class: `vd-flowchart-edge${selected ? " is-selected" : ""}${inReconnectMode ? " is-reconnecting" : ""}`,
2554
3676
  "data-edge-id": edge.id
2555
3677
  });
2556
- const strokeWidth = this.getEdgeStrokeWidth(edge);
3678
+ const baseStroke = this.getEdgeStrokeWidth(edge);
3679
+ const strokeWidth = visual < 0.999 ? formatNumber(Math.max(0.02, baseStroke * visual)) : baseStroke;
2557
3680
  if (selected) {
2558
3681
  group.appendChild(
2559
3682
  svgEl("path", {
2560
3683
  class: "vd-flowchart-edge-selection",
2561
3684
  d: edgePath.d,
2562
- "stroke-width": formatNumber(strokeWidth + 7)
3685
+ "stroke-width": visual < 0.999 ? formatNumber((baseStroke + 7) * visual) : formatNumber(strokeWidth + 7)
2563
3686
  })
2564
3687
  );
2565
3688
  }
@@ -2569,23 +3692,28 @@ var VdFlowchart = class {
2569
3692
  "stroke-width": strokeWidth,
2570
3693
  "data-edge-id": edge.id
2571
3694
  });
2572
- this.applyEdgeMarkers(visiblePath, edge);
3695
+ this.applyEdgeMarkers(visiblePath, edge, visual);
2573
3696
  const hitPath = svgEl("path", {
2574
3697
  class: "vd-flowchart-edge-hit",
2575
3698
  d: edgePath.d,
2576
- "stroke-width": this.getEdgeHitStrokeWidth(edge),
3699
+ "stroke-width": this.getEdgeHitStrokeWidth(edge, visual),
2577
3700
  "data-edge-id": edge.id
2578
3701
  });
2579
3702
  group.appendChild(visiblePath);
2580
3703
  group.appendChild(hitPath);
2581
- if (edge.label) {
3704
+ if (edge.label && this.textEditor?.edgeId !== edge.id) {
3705
+ const labelOffset = visual < 0.999 ? formatNumber(8 * visual) : 8;
2582
3706
  const label = svgEl("text", {
2583
3707
  class: "vd-flowchart-edge-label",
2584
3708
  x: edgePath.labelX,
2585
- y: edgePath.labelY - 8,
3709
+ y: edgePath.labelY - labelOffset,
2586
3710
  "text-anchor": "middle",
2587
3711
  "data-edge-id": edge.id
2588
3712
  });
3713
+ if (visual < 0.999) {
3714
+ label.style.fontSize = `${formatNumber(Math.max(0.06, 12 * visual))}px`;
3715
+ label.style.strokeWidth = `${formatNumber(Math.max(0.03, 5 * visual))}px`;
3716
+ }
2589
3717
  label.textContent = edge.label;
2590
3718
  group.appendChild(label);
2591
3719
  }
@@ -2624,23 +3752,32 @@ var VdFlowchart = class {
2624
3752
  renderPreviewEdge(interaction) {
2625
3753
  const fromPort = interaction.fromPort || interaction.source?.port || "right";
2626
3754
  const toPort = interaction.toPort || interaction.target?.port || "left";
2627
- const edgePath = buildEdgePath({
2628
- strokeWidth: interaction.strokeWidth,
2629
- route: interaction.route,
2630
- fromPoint: interaction.fromPoint,
2631
- toPoint: interaction.toPoint,
2632
- from: { port: fromPort },
2633
- to: { port: toPort }
2634
- });
3755
+ const fromNode = interaction.source?.nodeId ? this.findNode(interaction.source.nodeId) : null;
3756
+ const toNode = interaction.target?.nodeId ? this.findNode(interaction.target.nodeId) : null;
3757
+ const visual = edgeVisualScale(fromNode, toNode);
3758
+ const edgePath = buildEdgePath(
3759
+ {
3760
+ strokeWidth: interaction.strokeWidth,
3761
+ route: interaction.route,
3762
+ fromPoint: interaction.fromPoint,
3763
+ toPoint: interaction.toPoint,
3764
+ from: { port: fromPort },
3765
+ to: { port: toPort }
3766
+ },
3767
+ fromNode,
3768
+ toNode
3769
+ );
3770
+ const baseStroke = this.getEdgeStrokeWidth(interaction);
2635
3771
  return svgEl("path", {
2636
3772
  class: "vd-flowchart-preview-path",
2637
3773
  d: edgePath.d,
2638
- "stroke-width": this.getEdgeStrokeWidth(interaction)
3774
+ "stroke-width": visual < 0.999 ? formatNumber(Math.max(0.02, baseStroke * visual)) : baseStroke
2639
3775
  });
2640
3776
  }
2641
3777
  renderNode(node) {
2642
- const selected = this.selection?.kind === "node" && this.selection.id === node.id;
2643
- const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeId === node.id;
3778
+ const selected = this.isNodeSelected(node.id);
3779
+ const soloSelected = selected && !this.hasMultiSelection();
3780
+ const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeIds.includes(node.id);
2644
3781
  const resizing = this.interaction?.kind === "resize-node" && this.interaction.nodeId === node.id;
2645
3782
  const editing = this.textEditor?.nodeId === node.id;
2646
3783
  const group = svgEl("g", {
@@ -2649,24 +3786,45 @@ var VdFlowchart = class {
2649
3786
  "data-node-id": node.id
2650
3787
  });
2651
3788
  const portsVisible = this.shouldShowNodePorts(node);
3789
+ const visual = nodeVisualScale(node);
3790
+ const corner = node.type === "rounded-rect" || node.type === "textbox" ? 16 : node.type === "junction" ? node.width / 2 : 6;
2652
3791
  const hitbox = svgEl("rect", {
2653
3792
  x: 0,
2654
3793
  y: 0,
2655
3794
  width: node.width,
2656
3795
  height: node.height,
2657
- rx: node.type === "rounded-rect" || node.type === "textbox" ? 16 : node.type === "junction" ? node.width / 2 : 6,
3796
+ rx: visual < 0.999 && node.type !== "junction" ? formatNumber(corner * visual) : corner,
2658
3797
  fill: "transparent",
2659
3798
  "data-node-id": node.id
2660
3799
  });
2661
3800
  group.appendChild(hitbox);
2662
- group.appendChild(this.renderNodeShape(node));
3801
+ const shape = this.renderNodeShape(node);
3802
+ this.applyScaledNodeShape(node, shape);
3803
+ group.appendChild(shape);
2663
3804
  group.appendChild(this.renderNodeText(node));
2664
- if (selected && !this.readonly && isNodeResizable(node)) {
3805
+ const scale = this.documentData.viewport.scale || 1;
3806
+ if (soloSelected && !this.readonly && SELECTION_BOX_TYPES.has(node.type)) {
3807
+ const outset = SELECTION_OUTSET / scale;
3808
+ group.appendChild(
3809
+ svgEl("rect", {
3810
+ class: "vd-flowchart-selection-box",
3811
+ x: -outset,
3812
+ y: -outset,
3813
+ width: node.width + outset * 2,
3814
+ height: node.height + outset * 2
3815
+ })
3816
+ );
3817
+ }
3818
+ if (soloSelected && !this.readonly && isNodeResizable(node)) {
2665
3819
  group.appendChild(this.renderResizeControls(node));
2666
3820
  }
2667
- const scale = this.documentData.viewport.scale || 1;
3821
+ const collapseToggle = this.renderCollapseToggle(node, soloSelected);
3822
+ if (this.readonly) {
3823
+ if (collapseToggle) group.appendChild(collapseToggle);
3824
+ return group;
3825
+ }
2668
3826
  FLOWCHART_PORTS.forEach((port) => {
2669
- const position = getPortPosition({ ...node, x: 0, y: 0 }, port);
3827
+ const position = getPortHandlePosition({ ...node, x: 0, y: 0 }, port, scale);
2670
3828
  const portGroup = svgEl("g", {
2671
3829
  class: `vd-flowchart-port-group${portsVisible ? " is-visible" : ""}`,
2672
3830
  "data-node-id": node.id,
@@ -2688,15 +3846,68 @@ var VdFlowchart = class {
2688
3846
  r: CONNECTION_PORT_RADIUS / scale
2689
3847
  })
2690
3848
  );
3849
+ const arm = (CONNECTION_PORT_RADIUS - 2.5) / scale;
3850
+ portGroup.appendChild(
3851
+ svgEl("path", {
3852
+ class: "vd-flowchart-port-plus",
3853
+ 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)}`
3854
+ })
3855
+ );
2691
3856
  group.appendChild(portGroup);
2692
3857
  });
3858
+ if (collapseToggle) group.appendChild(collapseToggle);
3859
+ return group;
3860
+ }
3861
+ // "+N" on a collapsed node, or "−" on a single selected node with children,
3862
+ // placed beyond the connection handle on the side its branch leaves from.
3863
+ renderCollapseToggle(node, soloSelected) {
3864
+ const outgoing = this.sceneOutgoing || this.getOutgoingMap();
3865
+ const childIds = (outgoing.get(node.id) || []).filter((id) => id !== node.id);
3866
+ if (!childIds.length) return null;
3867
+ const count = node.collapsed ? this.countCollapsedDescendants(
3868
+ node.id,
3869
+ this.sceneHidden || this.getHiddenNodeIds(),
3870
+ outgoing
3871
+ ) : 0;
3872
+ if (!node.collapsed && !(soloSelected && !this.readonly)) return null;
3873
+ const sides = /* @__PURE__ */ new Map();
3874
+ this.documentData.edges.forEach((edge) => {
3875
+ if (edge.from.nodeId !== node.id || edge.to.nodeId === node.id) return;
3876
+ sides.set(edge.from.port, (sides.get(edge.from.port) || 0) + 1);
3877
+ });
3878
+ const port = [...sides].sort((a, b) => b[1] - a[1])[0]?.[0] || DIRECTION_PORTS[this.getGrowthDirection(node)];
3879
+ const scale = this.documentData.viewport.scale || 1;
3880
+ const anchor = getPortPosition({ ...node, x: 0, y: 0 }, port);
3881
+ const normal = getPortNormal(port);
3882
+ const offset = COLLAPSE_TOGGLE_OFFSET / scale;
3883
+ const cx = formatNumber(anchor.x + normal.x * offset);
3884
+ const cy = formatNumber(anchor.y + normal.y * offset);
3885
+ const label = node.collapsed ? `+${count}` : "\u2212";
3886
+ const radius = (node.collapsed && label.length > 2 ? 12 : 9) / scale;
3887
+ const group = svgEl("g", {
3888
+ class: `vd-flowchart-collapse-toggle${node.collapsed ? " is-collapsed" : ""}`,
3889
+ "data-collapse-toggle": node.id
3890
+ });
3891
+ const title = svgEl("title");
3892
+ title.textContent = node.collapsed ? `Expand ${count} hidden nodes` : "Collapse branch";
3893
+ group.appendChild(title);
3894
+ group.appendChild(svgEl("circle", { cx, cy, r: formatNumber(radius) }));
3895
+ const text = svgEl("text", {
3896
+ x: cx,
3897
+ y: cy,
3898
+ "font-size": formatNumber(10 / scale),
3899
+ "text-anchor": "middle",
3900
+ "dominant-baseline": "central"
3901
+ });
3902
+ text.textContent = label;
3903
+ group.appendChild(text);
2693
3904
  return group;
2694
3905
  }
2695
3906
  renderResizeControls(node) {
2696
3907
  const scale = this.documentData.viewport.scale || 1;
2697
3908
  const zone = 14 / scale;
2698
3909
  const cornerZone = 22 / scale;
2699
- const handleSize = 9 / scale;
3910
+ const handleSize = RESIZE_HANDLE_SIZE / scale;
2700
3911
  const gap = RESIZE_PORT_GAP / scale;
2701
3912
  const group = svgEl("g", { class: "vd-flowchart-resize-controls" });
2702
3913
  const zones = {
@@ -2726,12 +3937,6 @@ var VdFlowchart = class {
2726
3937
  };
2727
3938
  RESIZE_HANDLES.forEach((handle) => {
2728
3939
  const zoneRect = zones[handle];
2729
- const position = getResizeHandlePosition(node, handle);
2730
- const dotOffset = 8 / scale;
2731
- if (handle.includes("e")) position.x += dotOffset;
2732
- if (handle.includes("w")) position.x -= dotOffset;
2733
- if (handle.includes("n")) position.y -= dotOffset;
2734
- if (handle.includes("s")) position.y += dotOffset;
2735
3940
  const cursor = getResizeCursor(handle);
2736
3941
  const zoneSegments = [];
2737
3942
  if (handle === "e" || handle === "w") {
@@ -2766,14 +3971,19 @@ var VdFlowchart = class {
2766
3971
  });
2767
3972
  group.appendChild(hit);
2768
3973
  });
3974
+ if (handle.length === 1) return;
3975
+ const position = getResizeHandlePosition(node, handle);
3976
+ const outset = SELECTION_OUTSET / scale;
3977
+ position.x += handle.includes("e") ? outset : -outset;
3978
+ position.y += handle.includes("s") ? outset : -outset;
2769
3979
  const dot = svgEl("rect", {
2770
3980
  class: "vd-flowchart-resize-handle",
2771
3981
  x: position.x - handleSize / 2,
2772
3982
  y: position.y - handleSize / 2,
2773
3983
  width: handleSize,
2774
3984
  height: handleSize,
2775
- rx: handleSize / 3,
2776
- ry: handleSize / 3,
3985
+ rx: handleSize / 5,
3986
+ ry: handleSize / 5,
2777
3987
  "data-node-id": node.id,
2778
3988
  "data-resize-handle": handle,
2779
3989
  style: `cursor: ${cursor}`
@@ -2782,6 +3992,21 @@ var VdFlowchart = class {
2782
3992
  });
2783
3993
  return group;
2784
3994
  }
3995
+ applyScaledNodeShape(node, shape) {
3996
+ const visual = nodeVisualScale(node);
3997
+ if (!shape || visual >= 0.999) return;
3998
+ const baseStroke = node.type === "junction" ? 1.25 : 1.5;
3999
+ shape.style.strokeWidth = String(formatNumber(Math.max(0.03, baseStroke * visual)));
4000
+ if (node.type === "textbox") {
4001
+ shape.style.strokeDasharray = `${formatNumber(8 * visual)} ${formatNumber(5 * visual)}`;
4002
+ }
4003
+ const baseRadius = node.type === "rounded-rect" ? 18 : node.type === "textbox" ? 14 : node.type === "rect" ? 2 : 0;
4004
+ if (baseRadius > 0) {
4005
+ const scaled = String(formatNumber(baseRadius * visual));
4006
+ shape.setAttribute("rx", scaled);
4007
+ shape.setAttribute("ry", scaled);
4008
+ }
4009
+ }
2785
4010
  renderNodeShape(node) {
2786
4011
  const baseClass = `vd-flowchart-node-shape vd-flowchart-node-shape--${node.type}`;
2787
4012
  if (node.type === "rounded-rect") {
@@ -2858,21 +4083,28 @@ var VdFlowchart = class {
2858
4083
  if (node.type === "junction" || !node.text) {
2859
4084
  return textElement;
2860
4085
  }
4086
+ const visual = nodeVisualScale(node);
4087
+ const metrics = getNodeFontMetrics(node);
4088
+ const lineHeight = visual < 0.999 ? Math.max(0.06, metrics.lineHeight * visual) : metrics.lineHeight;
4089
+ if (visual < 0.999) {
4090
+ textElement.style.fontSize = `${formatNumber(Math.max(0.05, metrics.fontSize * visual))}px`;
4091
+ }
4092
+ const equivalentWidth = visual < 0.999 ? node.width / visual : node.width;
2861
4093
  if (node.type === "textbox") {
2862
- const lines2 = wrapText(node.text, estimateChars(node.width - 32));
2863
- const { lineHeight: lineHeight2 } = getNodeFontMetrics(node);
4094
+ const lines2 = wrapText(node.text, estimateChars(equivalentWidth - 32));
4095
+ const padX = visual < 0.999 ? formatNumber(16 * visual) : 16;
4096
+ const startY2 = visual < 0.999 ? formatNumber(28 * visual) : 28;
2864
4097
  lines2.forEach((line, index) => {
2865
4098
  const span = svgEl("tspan", {
2866
- x: 16,
2867
- y: 28 + index * lineHeight2
4099
+ x: padX,
4100
+ y: visual < 0.999 ? formatNumber(startY2 + index * lineHeight) : startY2 + index * lineHeight
2868
4101
  });
2869
4102
  span.textContent = line;
2870
4103
  textElement.appendChild(span);
2871
4104
  });
2872
4105
  return textElement;
2873
4106
  }
2874
- const lines = wrapText(node.text, estimateChars(node.width));
2875
- const { lineHeight } = getNodeFontMetrics(node);
4107
+ const lines = wrapText(node.text, estimateChars(equivalentWidth));
2876
4108
  const totalHeight = (lines.length - 1) * lineHeight;
2877
4109
  const startY = node.height / 2 - totalHeight / 2;
2878
4110
  textElement.setAttribute("text-anchor", "middle");
@@ -2880,7 +4112,7 @@ var VdFlowchart = class {
2880
4112
  lines.forEach((line, index) => {
2881
4113
  const span = svgEl("tspan", {
2882
4114
  x: node.width / 2,
2883
- y: startY + index * lineHeight,
4115
+ y: visual < 0.999 ? formatNumber(startY + index * lineHeight) : startY + index * lineHeight,
2884
4116
  "text-anchor": "middle",
2885
4117
  "dominant-baseline": "middle"
2886
4118
  });
@@ -2902,6 +4134,16 @@ var VdFlowchart = class {
2902
4134
  );
2903
4135
  return;
2904
4136
  }
4137
+ if (this.hasMultiSelection()) {
4138
+ this.selectionMeta.textContent = `${this.selectedNodeIds.size} nodes selected`;
4139
+ this.selectionFields.appendChild(
4140
+ createElement("p", {
4141
+ className: "vd-flowchart-selection-empty",
4142
+ text: "Drag, nudge with Alt+Arrow, copy, duplicate, or delete them together."
4143
+ })
4144
+ );
4145
+ return;
4146
+ }
2905
4147
  if (this.selection.kind === "node") {
2906
4148
  const node = this.findNode(this.selection.id);
2907
4149
  if (!node) {
@@ -2922,12 +4164,12 @@ var VdFlowchart = class {
2922
4164
  textArea.setAttribute("data-field", "node-text");
2923
4165
  const widthInput = createElement("input", { value: node.width, type: "number" });
2924
4166
  widthInput.setAttribute("data-field", "node-width");
2925
- widthInput.setAttribute("min", String(MIN_NODE_SIZE));
2926
- widthInput.setAttribute("max", String(MAX_NODE_SIZE));
4167
+ widthInput.setAttribute("min", String(this.minNodeSize));
4168
+ widthInput.setAttribute("max", String(this.maxNodeSize));
2927
4169
  const heightInput = createElement("input", { value: node.height, type: "number" });
2928
4170
  heightInput.setAttribute("data-field", "node-height");
2929
- heightInput.setAttribute("min", String(MIN_NODE_SIZE));
2930
- heightInput.setAttribute("max", String(MAX_NODE_SIZE));
4171
+ heightInput.setAttribute("min", String(this.minNodeSize));
4172
+ heightInput.setAttribute("max", String(this.maxNodeSize));
2931
4173
  this.selectionFields.appendChild(createField("Type", typeSelect));
2932
4174
  if (isNodeTextEditable(node)) {
2933
4175
  this.selectionFields.appendChild(createField("Text", textArea));
@@ -3016,16 +4258,69 @@ var VdFlowchart = class {
3016
4258
  }
3017
4259
  select(selection) {
3018
4260
  const next = selection && selection.id && selection.kind ? { kind: selection.kind, id: selection.id } : null;
3019
- const previousKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3020
- const nextKey = next ? `${next.kind}:${next.id}` : "";
3021
- this.selection = next;
3022
- if (previousKey !== nextKey) {
4261
+ this.applySelection(next, next?.kind === "node" ? [next.id] : []);
4262
+ }
4263
+ // Single funnel for selection changes: `selection` is the primary item and
4264
+ // `selectedNodeIds` every selected node (the primary one included).
4265
+ applySelection(primary, nodeIds) {
4266
+ const previousKey = this.getSelectionKey();
4267
+ this.selection = primary;
4268
+ this.selectedNodeIds = new Set(nodeIds);
4269
+ if (primary?.kind === "node") this.selectedNodeIds.add(primary.id);
4270
+ if (previousKey !== this.getSelectionKey()) {
4271
+ this.edgeCycleAnchor = null;
3023
4272
  this.render({ scene: true, inspector: true, json: false });
3024
- this.emit("select", { selection: this.getSelectionSnapshot() });
4273
+ this.emit("select", {
4274
+ selection: this.getSelectionSnapshot(),
4275
+ nodeIds: this.getSelectedNodeIds()
4276
+ });
3025
4277
  return;
3026
4278
  }
3027
4279
  this.render({ scene: true, inspector: false, json: false });
3028
4280
  }
4281
+ getSelectionKey() {
4282
+ const primary = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
4283
+ return `${primary}|${[...this.selectedNodeIds || []].sort().join(",")}`;
4284
+ }
4285
+ // Keep the set valid after mutations that bypass applySelection (undo,
4286
+ // load, removal): drop missing nodes and keep the primary node in the set.
4287
+ syncSelectedNodeIds() {
4288
+ if (this.selection?.kind !== "node") {
4289
+ this.selectedNodeIds = /* @__PURE__ */ new Set();
4290
+ return;
4291
+ }
4292
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4293
+ const hidden = this.getHiddenNodeIds();
4294
+ this.selectedNodeIds = new Set(
4295
+ [...this.selectedNodeIds || []].filter((id) => existing.has(id) && !hidden.has(id))
4296
+ );
4297
+ this.selectedNodeIds.add(this.selection.id);
4298
+ }
4299
+ getSelectedNodeIds() {
4300
+ return [...this.selectedNodeIds || []];
4301
+ }
4302
+ isNodeSelected(nodeId) {
4303
+ return Boolean(this.selectedNodeIds?.has(nodeId));
4304
+ }
4305
+ hasMultiSelection() {
4306
+ return (this.selectedNodeIds?.size || 0) > 1;
4307
+ }
4308
+ /** Select several nodes; `primary` (default: the last id) drives single-node UI. */
4309
+ selectNodes(nodeIds, options = {}) {
4310
+ const existing = new Set(this.documentData.nodes.map((node) => node.id));
4311
+ const ids = [...new Set((nodeIds || []).map(sanitizeId))].filter((id) => existing.has(id));
4312
+ const primaryId = ids.includes(options.primary) ? options.primary : ids[ids.length - 1];
4313
+ this.applySelection(primaryId ? { kind: "node", id: primaryId } : null, ids);
4314
+ return this;
4315
+ }
4316
+ toggleNodeSelection(nodeId) {
4317
+ const ids = this.selection?.kind === "node" ? this.getSelectedNodeIds() : [];
4318
+ if (ids.includes(nodeId)) {
4319
+ const rest = ids.filter((id) => id !== nodeId);
4320
+ return this.selectNodes(rest, { primary: rest[rest.length - 1] });
4321
+ }
4322
+ return this.selectNodes([...ids, nodeId], { primary: nodeId });
4323
+ }
3029
4324
  selectNode(nodeId) {
3030
4325
  this.select({ kind: "node", id: sanitizeId(nodeId) });
3031
4326
  return this;
@@ -3046,6 +4341,69 @@ var VdFlowchart = class {
3046
4341
  findNode(nodeId) {
3047
4342
  return this.documentData.nodes.find((node) => node.id === nodeId) || null;
3048
4343
  }
4344
+ getHiddenNodeIds() {
4345
+ return computeHiddenNodeIds(this.documentData.nodes, this.documentData.edges);
4346
+ }
4347
+ getVisibleNodes() {
4348
+ const hidden = this.getHiddenNodeIds();
4349
+ return hidden.size ? this.documentData.nodes.filter((node) => !hidden.has(node.id)) : this.documentData.nodes;
4350
+ }
4351
+ isNodeHidden(nodeId) {
4352
+ return this.getHiddenNodeIds().has(nodeId);
4353
+ }
4354
+ getOutgoingMap() {
4355
+ const outgoing = /* @__PURE__ */ new Map();
4356
+ this.documentData.edges.forEach((edge) => {
4357
+ if (!outgoing.has(edge.from.nodeId)) outgoing.set(edge.from.nodeId, []);
4358
+ outgoing.get(edge.from.nodeId).push(edge.to.nodeId);
4359
+ });
4360
+ return outgoing;
4361
+ }
4362
+ // Hidden nodes under one collapsed node (its "+N" count).
4363
+ countCollapsedDescendants(nodeId, hidden = this.getHiddenNodeIds(), outgoing = this.getOutgoingMap()) {
4364
+ const seen = /* @__PURE__ */ new Set();
4365
+ const queue = [nodeId];
4366
+ for (let index = 0; index < queue.length; index += 1) {
4367
+ (outgoing.get(queue[index]) || []).forEach((childId) => {
4368
+ if (seen.has(childId) || !hidden.has(childId)) return;
4369
+ seen.add(childId);
4370
+ queue.push(childId);
4371
+ });
4372
+ }
4373
+ return seen.size;
4374
+ }
4375
+ setCollapsed(nodeId, collapsed) {
4376
+ if (this.readonly) return false;
4377
+ const node = this.findNode(sanitizeId(nodeId));
4378
+ if (!node) return false;
4379
+ const next = Boolean(collapsed);
4380
+ if (Boolean(node.collapsed) === next) return false;
4381
+ if (next && !this.getChildNodes(node.id).length) return false;
4382
+ if (next) node.collapsed = true;
4383
+ else delete node.collapsed;
4384
+ if (this.selection?.kind === "node") {
4385
+ const hidden = this.getHiddenNodeIds();
4386
+ const visibleIds = this.getSelectedNodeIds().filter((id) => !hidden.has(id));
4387
+ if (hidden.has(this.selection.id)) {
4388
+ this.select({ kind: "node", id: node.id });
4389
+ } else if (visibleIds.length !== this.selectedNodeIds.size) {
4390
+ this.selectNodes(visibleIds, { primary: this.selection.id });
4391
+ }
4392
+ } else if (this.selection?.kind === "edge") {
4393
+ const edge = this.findEdge(this.selection.id);
4394
+ const hidden = this.getHiddenNodeIds();
4395
+ if (edge && (hidden.has(edge.from.nodeId) || hidden.has(edge.to.nodeId))) {
4396
+ this.select({ kind: "node", id: node.id });
4397
+ }
4398
+ }
4399
+ this.render({ inspector: true, json: true });
4400
+ this.emitChange("node:collapse", { node: deepClone(node) });
4401
+ return true;
4402
+ }
4403
+ toggleCollapsed(nodeId) {
4404
+ const node = this.findNode(sanitizeId(nodeId));
4405
+ return node ? this.setCollapsed(node.id, !node.collapsed) : false;
4406
+ }
3049
4407
  findEdge(edgeId) {
3050
4408
  return this.documentData.edges.find((edge) => edge.id === edgeId) || null;
3051
4409
  }
@@ -3056,7 +4414,8 @@ var VdFlowchart = class {
3056
4414
  }
3057
4415
  emitChange(reason, extra = {}) {
3058
4416
  if (this.historyEnabled && !this.isApplyingHistory) {
3059
- this.recordHistory(reason);
4417
+ if (this.historyBatchDepth > 0) this.historyBatchDirty = true;
4418
+ else this.recordHistory(reason);
3060
4419
  }
3061
4420
  this.syncJsonTextarea();
3062
4421
  this.emit("change", {
@@ -3100,7 +4459,9 @@ var VdFlowchart = class {
3100
4459
  const snapshot = this.toJSON();
3101
4460
  const targetKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
3102
4461
  const top = this.history[this.historyIndex];
3103
- const canCoalesce = Boolean(top) && this.historyIndex === this.history.length - 1 && COALESCING_REASONS.has(reason) && top.reason === reason && top.targetKey === targetKey;
4462
+ const atTip = Boolean(top) && this.historyIndex === this.history.length - 1;
4463
+ const namesInsertedNode = this.coalesceInsertedText && top?.reason === "node:insert" && reason === "node:update";
4464
+ const canCoalesce = atTip && top.targetKey === targetKey && (namesInsertedNode || COALESCING_REASONS.has(reason) && top.reason === reason);
3104
4465
  if (canCoalesce) {
3105
4466
  top.snapshot = snapshot;
3106
4467
  return;
@@ -3122,8 +4483,8 @@ var VdFlowchart = class {
3122
4483
  this.reconnectEdgeId = null;
3123
4484
  const currentViewport = deepClone(this.documentData.viewport);
3124
4485
  const previousSelection = this.selection;
3125
- this.documentData = normalizeDocument(snapshot);
3126
- this.documentData.viewport = normalizeViewport(currentViewport);
4486
+ this.documentData = normalizeDocument(snapshot, this.normalizationLimits());
4487
+ this.documentData.viewport = normalizeViewport(currentViewport, this.scaleBounds());
3127
4488
  this.selection = this.resolvePreservedSelection(previousSelection, true);
3128
4489
  this.render();
3129
4490
  this.emitChange(reason);
@@ -3200,7 +4561,8 @@ var VdFlowchart = class {
3200
4561
  y: partialNode.y == null ? fallbackY : partialNode.y
3201
4562
  },
3202
4563
  this.documentData.nodes.length,
3203
- usedIds
4564
+ usedIds,
4565
+ this.nodeSizeBounds()
3204
4566
  );
3205
4567
  this.documentData.nodes.push(node);
3206
4568
  this.select({ kind: "node", id: node.id });
@@ -3216,19 +4578,180 @@ var VdFlowchart = class {
3216
4578
  const parent = this.findNode(sanitizeId(parentId));
3217
4579
  if (!parent) return null;
3218
4580
  const { direction = "right", distance, angle, edge: edgeOptions, ...nodeOptions } = options;
3219
- const node = this.addNode({
3220
- ...nodeOptions,
3221
- relativeTo: { node: parent.id, direction, distance, angle }
4581
+ return this.batchHistory("node:add-child", () => {
4582
+ const node = this.addNode({
4583
+ ...nodeOptions,
4584
+ relativeTo: { node: parent.id, direction, distance, angle }
4585
+ });
4586
+ const edge = this.addEdge({
4587
+ from: parent.id,
4588
+ to: node.id,
4589
+ autoPort: true,
4590
+ endMarker: "arrow",
4591
+ ...isPlainObject(edgeOptions) ? edgeOptions : {}
4592
+ });
4593
+ this.select({ kind: "node", id: node.id });
4594
+ return { node, edge };
3222
4595
  });
3223
- const edge = this.addEdge({
3224
- from: parent.id,
3225
- to: node.id,
3226
- autoPort: true,
3227
- endMarker: "arrow",
3228
- ...isPlainObject(edgeOptions) ? edgeOptions : {}
4596
+ }
4597
+ // Run `fn` with history recording suspended, then record a single entry for
4598
+ // everything it changed (change events still fire for each mutation).
4599
+ batchHistory(reason, fn) {
4600
+ this.historyBatchDepth += 1;
4601
+ let result;
4602
+ try {
4603
+ result = fn();
4604
+ } finally {
4605
+ this.historyBatchDepth -= 1;
4606
+ }
4607
+ if (this.historyBatchDepth === 0 && this.historyBatchDirty) {
4608
+ this.historyBatchDirty = false;
4609
+ if (this.historyEnabled && !this.isApplyingHistory) this.recordHistory(reason);
4610
+ }
4611
+ return result;
4612
+ }
4613
+ // Mind-map structure: a node's parent is the source of its first incoming
4614
+ // edge; its children are the targets of its outgoing edges. The side a
4615
+ // branch is on is the port its edge leaves the parent from, which is what
4616
+ // the user sees (centre geometry is ambiguous for diagonal nodes).
4617
+ getParentEdge(nodeId) {
4618
+ return this.documentData.edges.find(
4619
+ (edge) => edge.to.nodeId === nodeId && edge.from.nodeId !== nodeId
4620
+ ) || null;
4621
+ }
4622
+ getParentNode(nodeId) {
4623
+ const edge = this.getParentEdge(nodeId);
4624
+ return edge ? this.findNode(edge.from.nodeId) : null;
4625
+ }
4626
+ getChildNodes(nodeId, direction = null) {
4627
+ const port = direction ? DIRECTION_PORTS[direction] : null;
4628
+ const ids = /* @__PURE__ */ new Set();
4629
+ this.documentData.edges.forEach((edge) => {
4630
+ if (edge.from.nodeId !== nodeId || edge.to.nodeId === nodeId) return;
4631
+ if (!port || edge.from.port === port) ids.add(edge.to.nodeId);
4632
+ });
4633
+ return this.documentData.nodes.filter((node) => ids.has(node.id));
4634
+ }
4635
+ // Direction a branch grows from `node`: the side its parent edge leaves
4636
+ // from, or the emptier horizontal side for a root.
4637
+ getGrowthDirection(node) {
4638
+ const parentEdge = this.getParentEdge(node.id);
4639
+ if (parentEdge) return PORT_DIRECTIONS[parentEdge.from.port] || "right";
4640
+ const right = this.getChildNodes(node.id, "right").length;
4641
+ const left = this.getChildNodes(node.id, "left").length;
4642
+ return left < right ? "left" : "right";
4643
+ }
4644
+ // Shape for a new branch node: copy a sibling when there is one, otherwise
4645
+ // the parent's box shape, falling back to a rounded step.
4646
+ getBranchTemplate(parent, sibling) {
4647
+ const source = sibling || (["rounded-rect", "rect", "textbox"].includes(parent.type) ? parent : null) || null;
4648
+ if (source) return { type: source.type, width: source.width, height: source.height };
4649
+ const spec = DEFAULT_NODE_SPECS["rounded-rect"];
4650
+ return { type: "rounded-rect", width: spec.width, height: spec.height };
4651
+ }
4652
+ /**
4653
+ * Add a node connected from `parentId` on the `direction` side (default: the
4654
+ * branch's growth direction), stacked after `anchorId` or the last sibling on
4655
+ * that side. The node and its edge are one undo step; the new edge copies a
4656
+ * sibling's (or the parent's incoming) edge style. With `edit: true` the
4657
+ * label editor opens with the text selected.
4658
+ */
4659
+ insertBranchNode(parentId, options = {}) {
4660
+ if (this.readonly) return null;
4661
+ const parent = this.findNode(sanitizeId(parentId));
4662
+ if (!parent) return null;
4663
+ const direction = DIRECTION_PORTS[options.direction] ? options.direction : this.getGrowthDirection(parent);
4664
+ const siblings = this.getChildNodes(parent.id, direction);
4665
+ const anchor = options.anchorId ? this.findNode(sanitizeId(options.anchorId)) : null;
4666
+ const lastSibling = siblings[siblings.length - 1] || null;
4667
+ const template = this.getBranchTemplate(parent, anchor || lastSibling);
4668
+ const position = placeBranchNode({
4669
+ nodes: this.documentData.nodes,
4670
+ parent,
4671
+ siblings,
4672
+ direction,
4673
+ size: template,
4674
+ anchor,
4675
+ before: Boolean(options.before)
4676
+ });
4677
+ const styleSource = this.documentData.edges.find(
4678
+ (edge) => edge.from.nodeId === parent.id && edge.to.nodeId === (anchor || lastSibling)?.id
4679
+ ) || this.documentData.edges.find((edge) => edge.to.nodeId === parent.id);
4680
+ const edgeStyle = styleSource ? {
4681
+ route: styleSource.route,
4682
+ strokeWidth: styleSource.strokeWidth,
4683
+ startMarker: styleSource.startMarker,
4684
+ endMarker: styleSource.endMarker
4685
+ } : { endMarker: "arrow" };
4686
+ const result = this.batchHistory("node:insert", () => {
4687
+ if (parent.collapsed) delete parent.collapsed;
4688
+ const node = this.addNode({ ...template, x: position.x, y: position.y });
4689
+ const edge = this.addEdge({
4690
+ from: { nodeId: parent.id, port: DIRECTION_PORTS[direction] },
4691
+ to: { nodeId: node.id, port: DIRECTION_PORTS[oppositeDirection(direction)] },
4692
+ ...edgeStyle
4693
+ });
4694
+ if (edge && anchor) this.placeEdgeBySibling(edge.id, parent.id, anchor.id, options.before);
4695
+ if (this.autoLayout && this.layoutMode !== "grid") this.layout(this.layoutMode);
4696
+ this.select({ kind: "node", id: node.id });
4697
+ return { node, edge };
4698
+ });
4699
+ this.revealNode(result.node.id);
4700
+ if (options.edit) this.startTextEdit(result.node.id, { inserted: true });
4701
+ return result;
4702
+ }
4703
+ // Tree and radial layouts order children by connection order, so a sibling's
4704
+ // connection goes right after (or before) its anchor's instead of last.
4705
+ placeEdgeBySibling(edgeId, parentId, anchorId, before = false) {
4706
+ const edges = this.documentData.edges;
4707
+ const fromIndex = edges.findIndex((edge) => edge.id === edgeId);
4708
+ if (fromIndex === -1) return;
4709
+ const [moved] = edges.splice(fromIndex, 1);
4710
+ const anchorIndex = edges.findIndex(
4711
+ (edge) => edge.from.nodeId === parentId && edge.to.nodeId === anchorId
4712
+ );
4713
+ if (anchorIndex === -1) edges.push(moved);
4714
+ else edges.splice(before ? anchorIndex : anchorIndex + 1, 0, moved);
4715
+ }
4716
+ // Insert a sibling after (or before) `nodeId`; a node without a parent gets a
4717
+ // child instead, which is what Enter on a mind-map root means.
4718
+ insertSiblingNode(nodeId, options = {}) {
4719
+ if (this.readonly) return null;
4720
+ const node = this.findNode(sanitizeId(nodeId));
4721
+ if (!node) return null;
4722
+ const parentEdge = this.getParentEdge(node.id);
4723
+ const parent = parentEdge ? this.findNode(parentEdge.from.nodeId) : null;
4724
+ if (!parent) return this.insertBranchNode(node.id, { edit: options.edit });
4725
+ return this.insertBranchNode(parent.id, {
4726
+ direction: PORT_DIRECTIONS[parentEdge.from.port],
4727
+ anchorId: node.id,
4728
+ before: Boolean(options.before),
4729
+ edit: options.edit
3229
4730
  });
3230
- this.select({ kind: "node", id: node.id });
3231
- return { node, edge };
4731
+ }
4732
+ // Pan just enough to bring a node fully into view (with a small margin).
4733
+ revealNode(nodeId) {
4734
+ const node = this.findNode(nodeId);
4735
+ const width = this.canvasEl.clientWidth;
4736
+ const height = this.canvasEl.clientHeight;
4737
+ if (!node || !width || !height) return;
4738
+ const viewport = this.documentData.viewport;
4739
+ const margin = 32;
4740
+ const left = viewport.x + node.x * viewport.scale;
4741
+ const top = viewport.y + node.y * viewport.scale;
4742
+ const right = left + node.width * viewport.scale;
4743
+ const bottom = top + node.height * viewport.scale;
4744
+ let dx = 0;
4745
+ let dy = 0;
4746
+ if (right > width - margin) dx = width - margin - right;
4747
+ if (left + dx < margin) dx = margin - left;
4748
+ if (bottom > height - margin) dy = height - margin - bottom;
4749
+ if (top + dy < margin) dy = margin - top;
4750
+ if (!dx && !dy) return;
4751
+ viewport.x = formatNumber(viewport.x + dx);
4752
+ viewport.y = formatNumber(viewport.y + dy);
4753
+ this.render({ inspector: false, json: true });
4754
+ this.emitViewportChange("viewport:pan");
3232
4755
  }
3233
4756
  // Arrange nodes with a built-in layout. Positions are computed by the pure
3234
4757
  // computeLayout() module, then applied in place (NOT via load(), which would
@@ -3292,8 +4815,8 @@ var VdFlowchart = class {
3292
4815
  node.x = patch.x == null ? node.x : formatNumber(toFiniteNumber(patch.x, node.x));
3293
4816
  node.y = patch.y == null ? node.y : formatNumber(toFiniteNumber(patch.y, node.y));
3294
4817
  node.text = isNodeTextEditable(nextType) ? patch.text == null ? node.text : String(patch.text) : getNodeSpec(nextType).text;
3295
- node.width = clampNodeWidth(nextType, patch.width, node.width);
3296
- node.height = clampNodeHeight(nextType, patch.height, node.height);
4818
+ node.width = clampNodeWidth(nextType, patch.width, node.width, this.nodeSizeBounds());
4819
+ node.height = clampNodeHeight(nextType, patch.height, node.height, this.nodeSizeBounds());
3297
4820
  if (isPlainObject(patch.data)) {
3298
4821
  node.data = deepClone(patch.data);
3299
4822
  }
@@ -3476,11 +4999,51 @@ var VdFlowchart = class {
3476
4999
  }
3477
5000
  deleteSelection() {
3478
5001
  if (!this.selection || this.readonly) return false;
5002
+ if (this.hasMultiSelection()) {
5003
+ return this.removeNodeIds(new Set(this.getSelectedNodeIds()), "node:remove");
5004
+ }
3479
5005
  if (this.selection.kind === "node") return this.removeNode(this.selection.id);
3480
5006
  return this.removeEdge(this.selection.id);
3481
5007
  }
5008
+ nodeSizeBounds() {
5009
+ return { min: this.minNodeSize, max: this.maxNodeSize };
5010
+ }
5011
+ scaleBounds() {
5012
+ return { min: this.minScale, max: this.maxScale };
5013
+ }
5014
+ normalizationLimits() {
5015
+ return { scaleBounds: this.scaleBounds(), sizeBounds: this.nodeSizeBounds() };
5016
+ }
5017
+ hasWideCamera() {
5018
+ return this.minScale < DEFAULT_MIN_SCALE || this.maxScale > DEFAULT_MAX_SCALE;
5019
+ }
5020
+ syncGrid() {
5021
+ const wide = this.hasWideCamera();
5022
+ const extent = wide ? WIDE_WORLD_EXTENT : DEFAULT_WORLD_EXTENT;
5023
+ this.gridRect?.setAttribute("x", String(-extent / 2));
5024
+ this.gridRect?.setAttribute("y", String(-extent / 2));
5025
+ this.gridRect?.setAttribute("width", String(extent));
5026
+ this.gridRect?.setAttribute("height", String(extent));
5027
+ const pattern = this.svg?.querySelector("pattern");
5028
+ const path = pattern?.querySelector("path");
5029
+ if (!pattern || !path) return;
5030
+ let tile = this.gridSize;
5031
+ if (wide) {
5032
+ const scale = Math.max(this.minScale, this.documentData?.viewport?.scale || 1);
5033
+ while (tile * scale < 12 && tile < extent / 8) tile *= 4;
5034
+ while (tile * scale > 48 && tile > 1.5) tile /= 4;
5035
+ tile = formatNumber(tile);
5036
+ path.setAttribute("vector-effect", "non-scaling-stroke");
5037
+ } else {
5038
+ path.removeAttribute("vector-effect");
5039
+ }
5040
+ path.setAttribute("stroke-width", "1");
5041
+ pattern.setAttribute("width", String(tile));
5042
+ pattern.setAttribute("height", String(tile));
5043
+ path.setAttribute("d", `M ${tile} 0 L 0 0 0 ${tile}`);
5044
+ }
3482
5045
  setViewport(viewport) {
3483
- this.documentData.viewport = normalizeViewport(viewport);
5046
+ this.documentData.viewport = normalizeViewport(viewport, this.scaleBounds());
3484
5047
  this.render({ inspector: false, json: true });
3485
5048
  this.emitViewportChange("viewport:set");
3486
5049
  return this;
@@ -3498,45 +5061,80 @@ var VdFlowchart = class {
3498
5061
  return this;
3499
5062
  }
3500
5063
  resetView() {
3501
- this.documentData.viewport = normalizeViewport({ x: 0, y: 0, scale: 1 });
5064
+ this.documentData.viewport = normalizeViewport({ x: 0, y: 0, scale: 1 }, this.scaleBounds());
3502
5065
  this.render({ inspector: false, json: true });
3503
5066
  this.emitViewportChange("viewport:reset");
3504
5067
  return this;
3505
5068
  }
3506
5069
  fitView() {
3507
- if (!this.documentData.nodes.length) {
5070
+ const nodes = this.getVisibleNodes();
5071
+ if (!nodes.length) {
3508
5072
  return this.resetView();
3509
5073
  }
3510
- const bounds = getBounds(this.documentData.nodes);
5074
+ const bounds = getBounds(nodes);
3511
5075
  const width = this.canvasEl.clientWidth || 800;
3512
5076
  const height = this.canvasEl.clientHeight || 560;
3513
5077
  const padding = 80;
3514
5078
  const contentWidth = Math.max(1, bounds.right - bounds.left);
3515
5079
  const contentHeight = Math.max(1, bounds.bottom - bounds.top);
3516
- const scale = clamp(
3517
- Math.min((width - padding * 2) / contentWidth, (height - padding * 2) / contentHeight),
3518
- MIN_SCALE,
3519
- MAX_SCALE
5080
+ const scale = formatScale(
5081
+ clamp(
5082
+ Math.min((width - padding * 2) / contentWidth, (height - padding * 2) / contentHeight),
5083
+ this.minScale,
5084
+ this.maxScale
5085
+ )
3520
5086
  );
3521
5087
  this.documentData.viewport = {
3522
5088
  x: formatNumber(width / 2 - (bounds.left + bounds.right) / 2 * scale),
3523
5089
  y: formatNumber(height / 2 - (bounds.top + bounds.bottom) / 2 * scale),
3524
- scale: formatNumber(scale)
5090
+ scale
3525
5091
  };
3526
5092
  this.render({ inspector: false, json: true });
3527
5093
  this.emitViewportChange("viewport:fit");
3528
5094
  return this;
3529
5095
  }
5096
+ fitNode(nodeId, padding = 48) {
5097
+ const node = this.findNode(nodeId);
5098
+ if (!node || this.isNodeHidden(node.id)) return this;
5099
+ const pad = Math.max(0, toFiniteNumber(padding, 48));
5100
+ const width = this.canvasEl.clientWidth || 800;
5101
+ const height = this.canvasEl.clientHeight || 560;
5102
+ const availableWidth = Math.max(1, width - pad * 2);
5103
+ const availableHeight = Math.max(1, height - pad * 2);
5104
+ const scale = formatScale(
5105
+ clamp(
5106
+ Math.min(
5107
+ availableWidth / Math.max(1, node.width),
5108
+ availableHeight / Math.max(1, node.height)
5109
+ ),
5110
+ this.minScale,
5111
+ this.maxScale
5112
+ )
5113
+ );
5114
+ const centerX = node.x + node.width / 2;
5115
+ const centerY = node.y + node.height / 2;
5116
+ this.documentData.viewport = {
5117
+ x: formatNumber(width / 2 - centerX * scale),
5118
+ y: formatNumber(height / 2 - centerY * scale),
5119
+ scale
5120
+ };
5121
+ this.render({ inspector: false, json: true });
5122
+ this.emitViewportChange("viewport:fit-node");
5123
+ return this;
5124
+ }
3530
5125
  clear() {
3531
5126
  this.stopTextEdit({ commit: false });
3532
5127
  this.activeTool = null;
3533
5128
  this.reconnectEdgeId = null;
3534
5129
  this.clipboard = null;
3535
- this.documentData = normalizeDocument({
3536
- nodes: [],
3537
- edges: [],
3538
- viewport: { x: 0, y: 0, scale: 1 }
3539
- });
5130
+ this.documentData = normalizeDocument(
5131
+ {
5132
+ nodes: [],
5133
+ edges: [],
5134
+ viewport: { x: 0, y: 0, scale: 1 }
5135
+ },
5136
+ this.normalizationLimits()
5137
+ );
3540
5138
  this.selection = null;
3541
5139
  this.render();
3542
5140
  this.emitChange("clear");
@@ -3565,6 +5163,41 @@ var VdFlowchart = class {
3565
5163
  pattern.querySelector("path").setAttribute("d", `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`);
3566
5164
  }
3567
5165
  if ("autoFit" in options) this.autoFit = Boolean(options.autoFit);
5166
+ if ("keyboardShortcuts" in options) {
5167
+ this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
5168
+ }
5169
+ if ("snapGuides" in options) this.snapGuides = options.snapGuides !== false;
5170
+ if ("minimap" in options) this.setMinimapEnabled(options.minimap !== false);
5171
+ if ("autoLayout" in options) this.autoLayout = Boolean(options.autoLayout);
5172
+ if ("minScale" in options || "maxScale" in options || "minNodeSize" in options || "maxNodeSize" in options) {
5173
+ const nextScale = normalizeBoundPair(
5174
+ "minScale" in options ? options.minScale : this.minScale,
5175
+ "maxScale" in options ? options.maxScale : this.maxScale,
5176
+ DEFAULT_MIN_SCALE,
5177
+ DEFAULT_MAX_SCALE
5178
+ );
5179
+ const nextSize = normalizeBoundPair(
5180
+ "minNodeSize" in options ? options.minNodeSize : this.minNodeSize,
5181
+ "maxNodeSize" in options ? options.maxNodeSize : this.maxNodeSize,
5182
+ DEFAULT_MIN_NODE_SIZE,
5183
+ DEFAULT_MAX_NODE_SIZE
5184
+ );
5185
+ this.minScale = nextScale.min;
5186
+ this.maxScale = nextScale.max;
5187
+ this.minNodeSize = nextSize.min;
5188
+ this.maxNodeSize = nextSize.max;
5189
+ const clamped = clamp(
5190
+ formatScale(this.documentData.viewport.scale),
5191
+ this.minScale,
5192
+ this.maxScale
5193
+ );
5194
+ if (clamped !== this.documentData.viewport.scale) {
5195
+ this.documentData.viewport.scale = clamped;
5196
+ this.pendingBoundsEvent = true;
5197
+ }
5198
+ }
5199
+ this.syncCanvasLabel();
5200
+ if (this.shortcutsHelpOpen) this.renderShortcutsHelp();
3568
5201
  if ("history" in options && options.history !== false !== this.historyEnabled) {
3569
5202
  this.historyEnabled = options.history !== false;
3570
5203
  this.history = [];
@@ -3580,11 +5213,15 @@ var VdFlowchart = class {
3580
5213
  }
3581
5214
  }
3582
5215
  this.render();
5216
+ if (this.pendingBoundsEvent) {
5217
+ this.pendingBoundsEvent = false;
5218
+ this.emitViewportChange("viewport:bounds");
5219
+ }
3583
5220
  this.updateHistoryButtons();
3584
5221
  return this;
3585
5222
  }
3586
5223
  load(data, options = {}) {
3587
- const documentData = normalizeDocument(data);
5224
+ const documentData = normalizeDocument(data, this.normalizationLimits());
3588
5225
  if (options.silent && JSON.stringify(documentData) === JSON.stringify(this.documentData)) {
3589
5226
  return this;
3590
5227
  }
@@ -3613,6 +5250,10 @@ var VdFlowchart = class {
3613
5250
  destroy() {
3614
5251
  if (this.destroyed) return;
3615
5252
  this.destroyed = true;
5253
+ if (this.minimapFrame && typeof cancelAnimationFrame === "function") {
5254
+ cancelAnimationFrame(this.minimapFrame);
5255
+ }
5256
+ this.minimapFrame = 0;
3616
5257
  this.activeTool = null;
3617
5258
  this.stopTextEdit({ commit: false });
3618
5259
  this.resizeObserver?.disconnect();
@@ -3629,6 +5270,21 @@ var VdFlowchart = class {
3629
5270
 
3630
5271
  // src/vue.js
3631
5272
  var FORWARDED_EVENTS = ["change", "select", "viewport", "connect", "ready"];
5273
+ var EDITOR_OPTION_KEYS = [
5274
+ "readonly",
5275
+ "gridSize",
5276
+ "autoFit",
5277
+ "history",
5278
+ "historyLimit",
5279
+ "keyboardShortcuts",
5280
+ "snapGuides",
5281
+ "minimap",
5282
+ "autoLayout",
5283
+ "minScale",
5284
+ "maxScale",
5285
+ "minNodeSize",
5286
+ "maxNodeSize"
5287
+ ];
3632
5288
  var VdFlowchart2 = defineComponent({
3633
5289
  name: "VdFlowchart",
3634
5290
  props: {
@@ -3645,21 +5301,42 @@ var VdFlowchart2 = defineComponent({
3645
5301
  /** Enable the built-in undo/redo history (default true). */
3646
5302
  history: { type: Boolean, default: true },
3647
5303
  /** Maximum number of history entries to retain. */
3648
- historyLimit: { type: Number, default: void 0 }
5304
+ historyLimit: { type: Number, default: void 0 },
5305
+ /** Keyboard model: 'mindmap' (Tab child, Enter sibling) or 'basic'. */
5306
+ keyboardShortcuts: { type: String, default: "mindmap" },
5307
+ /** Snap dragged nodes to other nodes' edges and centres (default true). */
5308
+ snapGuides: { type: Boolean, default: true },
5309
+ /** Show the overview minimap in the canvas corner (default true). */
5310
+ minimap: { type: Boolean, default: true },
5311
+ /** Re-run the current layout after keyboard or handle insertions (default false). */
5312
+ autoLayout: { type: Boolean, default: false },
5313
+ /** Lowest camera scale. Defaults to 0.35. Set it together with maxScale. */
5314
+ minScale: { type: Number, default: void 0 },
5315
+ /** Highest camera scale. Defaults to 3. Set it together with minScale. */
5316
+ maxScale: { type: Number, default: void 0 },
5317
+ /** Smallest resizable-node side. Defaults to 56. Set it together with maxNodeSize. */
5318
+ minNodeSize: { type: Number, default: void 0 },
5319
+ /** Largest resizable-node side. Defaults to 420. Set it together with minNodeSize. */
5320
+ maxNodeSize: { type: Number, default: void 0 }
3649
5321
  },
3650
5322
  emits: ["change", "select", "viewport", "connect", "ready"],
3651
5323
  setup(props, { emit, expose }) {
3652
5324
  const el = ref(null);
3653
5325
  let instance = null;
5326
+ const editorOptions = () => Object.fromEntries(
5327
+ EDITOR_OPTION_KEYS.flatMap((key) => {
5328
+ const value = props[key];
5329
+ if ((key === "minScale" || key === "maxScale" || key === "minNodeSize" || key === "maxNodeSize") && value == null) {
5330
+ return [];
5331
+ }
5332
+ return [[key, value]];
5333
+ })
5334
+ );
3654
5335
  const create = () => {
3655
5336
  instance = new VdFlowchart({
3656
5337
  element: el.value,
3657
5338
  data: props.data,
3658
- readonly: props.readonly,
3659
- gridSize: props.gridSize,
3660
- autoFit: props.autoFit,
3661
- history: props.history,
3662
- historyLimit: props.historyLimit
5339
+ ...editorOptions()
3663
5340
  });
3664
5341
  FORWARDED_EVENTS.forEach((name) => {
3665
5342
  instance.on(name, (payload) => emit(name, payload));
@@ -3679,16 +5356,9 @@ var VdFlowchart2 = defineComponent({
3679
5356
  { deep: true }
3680
5357
  );
3681
5358
  watch(
3682
- () => [props.readonly, props.gridSize, props.autoFit, props.history, props.historyLimit],
5359
+ () => EDITOR_OPTION_KEYS.map((key) => props[key]),
3683
5360
  () => {
3684
- if (!instance) return;
3685
- instance.updateOptions({
3686
- readonly: props.readonly,
3687
- gridSize: props.gridSize,
3688
- autoFit: props.autoFit,
3689
- history: props.history,
3690
- historyLimit: props.historyLimit
3691
- });
5361
+ if (instance) instance.updateOptions(editorOptions());
3692
5362
  }
3693
5363
  );
3694
5364
  onBeforeUnmount(() => {
@@ -3703,7 +5373,8 @@ var VdFlowchart2 = defineComponent({
3703
5373
  redo: () => instance?.redo(),
3704
5374
  canUndo: () => Boolean(instance?.canUndo()),
3705
5375
  canRedo: () => Boolean(instance?.canRedo()),
3706
- layout: (mode, options) => instance?.layout(mode, options)
5376
+ layout: (mode, options) => instance?.layout(mode, options),
5377
+ fitNode: (nodeId, padding) => instance?.fitNode(nodeId, padding)
3707
5378
  });
3708
5379
  return () => h("div", { ref: el, class: "vd-flowchart" });
3709
5380
  }
@@ -3712,6 +5383,7 @@ export {
3712
5383
  FLOWCHART_DOCUMENT_VERSION,
3713
5384
  FLOWCHART_EDGE_MARKERS,
3714
5385
  FLOWCHART_EDGE_ROUTES,
5386
+ FLOWCHART_KEYBOARD_SHORTCUTS,
3715
5387
  FLOWCHART_NODE_TYPES,
3716
5388
  FLOWCHART_PORTS,
3717
5389
  LAYOUT_MODES,