@vanduo-oss/vd3-flowchart 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +75 -9
- package/SKILL.md +56 -43
- package/dist/core.d.ts +101 -1
- package/dist/index.cjs +1803 -130
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1803 -130
- package/dist/index.js.map +4 -4
- package/dist/meta.json +30 -12
- package/dist/vd3-flowchart.css +234 -15
- package/dist/vue.d.ts +9 -0
- package/package.json +12 -6
- package/recipes/editor.vue +17 -0
- package/styles.d.ts +2 -0
package/dist/index.cjs
CHANGED
|
@@ -20,8 +20,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.js
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
FLOWCHART_DOCUMENT_VERSION: () => FLOWCHART_DOCUMENT_VERSION,
|
|
23
24
|
FLOWCHART_EDGE_MARKERS: () => FLOWCHART_EDGE_MARKERS,
|
|
24
25
|
FLOWCHART_EDGE_ROUTES: () => FLOWCHART_EDGE_ROUTES,
|
|
26
|
+
FLOWCHART_KEYBOARD_SHORTCUTS: () => FLOWCHART_KEYBOARD_SHORTCUTS,
|
|
25
27
|
FLOWCHART_NODE_TYPES: () => FLOWCHART_NODE_TYPES,
|
|
26
28
|
FLOWCHART_PORTS: () => FLOWCHART_PORTS,
|
|
27
29
|
LAYOUT_MODES: () => LAYOUT_MODES,
|
|
@@ -219,6 +221,134 @@ function computeLayout(documentData, mode = "tree", options = {}) {
|
|
|
219
221
|
}
|
|
220
222
|
var LAYOUT_MODES = ["tree", "radial", "grid"];
|
|
221
223
|
|
|
224
|
+
// src/mindmap.js
|
|
225
|
+
var DIRECTION_PORTS = { right: "right", left: "left", down: "bottom", up: "top" };
|
|
226
|
+
var PORT_DIRECTIONS = { right: "right", left: "left", bottom: "down", top: "up" };
|
|
227
|
+
var OPPOSITE_DIRECTIONS = { right: "left", left: "right", down: "up", up: "down" };
|
|
228
|
+
var DIRECTION_VECTORS = {
|
|
229
|
+
right: { x: 1, y: 0 },
|
|
230
|
+
left: { x: -1, y: 0 },
|
|
231
|
+
down: { x: 0, y: 1 },
|
|
232
|
+
up: { x: 0, y: -1 }
|
|
233
|
+
};
|
|
234
|
+
var BRANCH_GAP = 72;
|
|
235
|
+
var SIBLING_GAP = 24;
|
|
236
|
+
var COLLISION_GAP = 12;
|
|
237
|
+
var COLLISION_STEP = 12;
|
|
238
|
+
var MAX_COLLISION_STEPS = 400;
|
|
239
|
+
var BRANCH_RING_STEP = 48;
|
|
240
|
+
var MAX_BRANCH_RINGS = 8;
|
|
241
|
+
var NAVIGATION_CONE = Math.tan(Math.PI / 3);
|
|
242
|
+
function centerOf(node) {
|
|
243
|
+
return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
|
244
|
+
}
|
|
245
|
+
function oppositeDirection(direction) {
|
|
246
|
+
return OPPOSITE_DIRECTIONS[direction] || "left";
|
|
247
|
+
}
|
|
248
|
+
function findSpatialNeighbor(nodes, fromNode, direction) {
|
|
249
|
+
const vector = DIRECTION_VECTORS[direction];
|
|
250
|
+
if (!vector || !fromNode) return null;
|
|
251
|
+
const origin = centerOf(fromNode);
|
|
252
|
+
let bestCone = null;
|
|
253
|
+
let bestPlane = null;
|
|
254
|
+
nodes.forEach((node) => {
|
|
255
|
+
if (node.id === fromNode.id) return;
|
|
256
|
+
const center = centerOf(node);
|
|
257
|
+
const dx = center.x - origin.x;
|
|
258
|
+
const dy = center.y - origin.y;
|
|
259
|
+
const along = dx * vector.x + dy * vector.y;
|
|
260
|
+
if (along <= 0) return;
|
|
261
|
+
const across = Math.abs(dx * vector.y - dy * vector.x);
|
|
262
|
+
const score = along + across * 2;
|
|
263
|
+
if (across <= along * NAVIGATION_CONE && (!bestCone || score < bestCone.score)) {
|
|
264
|
+
bestCone = { node, score };
|
|
265
|
+
}
|
|
266
|
+
if (!bestPlane || score < bestPlane.score) bestPlane = { node, score };
|
|
267
|
+
});
|
|
268
|
+
return (bestCone || bestPlane)?.node ?? null;
|
|
269
|
+
}
|
|
270
|
+
function findNearestNode(nodes, point) {
|
|
271
|
+
let best = null;
|
|
272
|
+
nodes.forEach((node) => {
|
|
273
|
+
const center = centerOf(node);
|
|
274
|
+
const distance = Math.hypot(center.x - point.x, center.y - point.y);
|
|
275
|
+
if (!best || distance < best.distance) best = { node, distance };
|
|
276
|
+
});
|
|
277
|
+
return best?.node ?? null;
|
|
278
|
+
}
|
|
279
|
+
function overlapsAny(rect, nodes) {
|
|
280
|
+
return nodes.some(
|
|
281
|
+
(node) => rect.x < node.x + node.width + COLLISION_GAP && rect.x + rect.width + COLLISION_GAP > node.x && rect.y < node.y + node.height + COLLISION_GAP && rect.y + rect.height + COLLISION_GAP > node.y
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
function placeBranchNode({
|
|
285
|
+
nodes,
|
|
286
|
+
parent,
|
|
287
|
+
siblings = [],
|
|
288
|
+
direction,
|
|
289
|
+
size,
|
|
290
|
+
anchor = null,
|
|
291
|
+
before = false
|
|
292
|
+
}) {
|
|
293
|
+
const horizontal = direction === "right" || direction === "left";
|
|
294
|
+
const cross = horizontal ? "y" : "x";
|
|
295
|
+
const crossSize = horizontal ? "height" : "width";
|
|
296
|
+
const lastSibling = siblings.reduce(
|
|
297
|
+
(last, node) => !last || node[cross] + node[crossSize] > last[cross] + last[crossSize] ? node : last,
|
|
298
|
+
null
|
|
299
|
+
);
|
|
300
|
+
const reference = anchor || lastSibling;
|
|
301
|
+
const rect = { x: 0, y: 0, width: size.width, height: size.height };
|
|
302
|
+
if (reference) {
|
|
303
|
+
if (direction === "right") rect.x = reference.x;
|
|
304
|
+
if (direction === "left") rect.x = reference.x + reference.width - size.width;
|
|
305
|
+
if (direction === "down") rect.y = reference.y;
|
|
306
|
+
if (direction === "up") rect.y = reference.y + reference.height - size.height;
|
|
307
|
+
rect[cross] = before ? reference[cross] - SIBLING_GAP - size[crossSize] : reference[cross] + reference[crossSize] + SIBLING_GAP;
|
|
308
|
+
} else {
|
|
309
|
+
if (direction === "right") rect.x = parent.x + parent.width + BRANCH_GAP;
|
|
310
|
+
if (direction === "left") rect.x = parent.x - BRANCH_GAP - size.width;
|
|
311
|
+
if (direction === "down") rect.y = parent.y + parent.height + BRANCH_GAP;
|
|
312
|
+
if (direction === "up") rect.y = parent.y - BRANCH_GAP - size.height;
|
|
313
|
+
rect[cross] = parent[cross] + parent[crossSize] / 2 - size[crossSize] / 2;
|
|
314
|
+
}
|
|
315
|
+
const crossSign = reference ? before ? -1 : 1 : 0;
|
|
316
|
+
const result = nearestFreeSlot(
|
|
317
|
+
rect,
|
|
318
|
+
nodes,
|
|
319
|
+
cross,
|
|
320
|
+
horizontal ? "x" : "y",
|
|
321
|
+
DIRECTION_VECTORS[direction],
|
|
322
|
+
crossSign
|
|
323
|
+
);
|
|
324
|
+
return { x: Math.round(result.x * 100) / 100, y: Math.round(result.y * 100) / 100 };
|
|
325
|
+
}
|
|
326
|
+
function crossOffsetAt(index, crossSign) {
|
|
327
|
+
if (crossSign) return crossSign * COLLISION_STEP * index;
|
|
328
|
+
return COLLISION_STEP * Math.ceil(index / 2) * (index % 2 ? 1 : -1);
|
|
329
|
+
}
|
|
330
|
+
function nearestFreeSlot(rect, nodes, cross, main, vector, crossSign) {
|
|
331
|
+
const origin = { ...rect };
|
|
332
|
+
const outward = vector.x + vector.y;
|
|
333
|
+
let best = null;
|
|
334
|
+
for (let ring = 0; ring <= MAX_BRANCH_RINGS; ring += 1) {
|
|
335
|
+
const mainOffset = ring * BRANCH_RING_STEP;
|
|
336
|
+
if (best && mainOffset >= best.cost) break;
|
|
337
|
+
const candidate = { ...origin, [main]: origin[main] + outward * mainOffset };
|
|
338
|
+
for (let index = 0; index <= MAX_COLLISION_STEPS; index += 1) {
|
|
339
|
+
const crossOffset = crossOffsetAt(index, crossSign);
|
|
340
|
+
const cost = Math.hypot(mainOffset, crossOffset);
|
|
341
|
+
if (best && cost >= best.cost) break;
|
|
342
|
+
candidate[cross] = origin[cross] + crossOffset;
|
|
343
|
+
if (!overlapsAny(candidate, nodes)) {
|
|
344
|
+
best = { rect: { ...candidate }, cost };
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return best ? best.rect : origin;
|
|
350
|
+
}
|
|
351
|
+
|
|
222
352
|
// src/core.js
|
|
223
353
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
224
354
|
var TOOLBAR_ICON_PATHS = {
|
|
@@ -228,6 +358,7 @@ var TOOLBAR_ICON_PATHS = {
|
|
|
228
358
|
"fit-view": "M216,48V88a8,8,0,0,1-16,0V67.31l-42.34,42.35a8,8,0,0,1-11.32-11.32L188.69,56H168a8,8,0,0,1,0-16h40A8,8,0,0,1,216,48ZM98.34,146.34,56,188.69V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H88a8,8,0,0,0,0-16H67.31l42.35-42.34a8,8,0,0,0-11.32-11.32Zm11.32-36.68L67.31,56H88a8,8,0,0,0,0-16H48a8,8,0,0,0-8,8V88a8,8,0,0,0,16,0V67.31l42.34,42.35a8,8,0,0,0,11.32-11.32ZM208,160a8,8,0,0,0-8,8v20.69l-42.34-42.35a8,8,0,0,0-11.32,11.32L188.69,200H168a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,208,160Z",
|
|
229
359
|
undo: "M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z",
|
|
230
360
|
redo: "M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z",
|
|
361
|
+
minimap: "M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z",
|
|
231
362
|
clear: "M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"
|
|
232
363
|
};
|
|
233
364
|
var TOOLBAR_ACTIONS = [
|
|
@@ -279,20 +410,32 @@ var DEFAULT_EDGE_STROKE_WIDTH = 2.25;
|
|
|
279
410
|
var MIN_EDGE_STROKE_WIDTH = 1.25;
|
|
280
411
|
var MAX_EDGE_STROKE_WIDTH = 6;
|
|
281
412
|
var CONNECTION_PORT_RADIUS = 6;
|
|
413
|
+
var PORT_HANDLE_OFFSET = 12;
|
|
282
414
|
var CONNECTION_PORT_HIT_RADIUS = 14;
|
|
415
|
+
var PORT_CLICK_SLOP = 4;
|
|
416
|
+
var RESIZE_HANDLE_SIZE = 9;
|
|
417
|
+
var SELECTION_OUTSET = 4;
|
|
418
|
+
var GUIDE_SNAP_DISTANCE = 6;
|
|
419
|
+
var GUIDE_OVERHANG = 12;
|
|
420
|
+
var COLLAPSE_TOGGLE_OFFSET = 36;
|
|
421
|
+
var MINIMAP_WIDTH = 180;
|
|
422
|
+
var MINIMAP_HEIGHT = 120;
|
|
423
|
+
var MINIMAP_MIN_CANVAS_WIDTH = 480;
|
|
424
|
+
var SELECTION_BOX_TYPES = /* @__PURE__ */ new Set(["circle", "diamond", "label"]);
|
|
283
425
|
var RECONNECT_ENDPOINT_RADIUS = 7;
|
|
284
426
|
var RECONNECT_ENDPOINT_HIT_RADIUS = 12;
|
|
285
427
|
var EDGE_HIT_STROKE_MIN = 16;
|
|
286
428
|
var CONNECTION_SNAP_PADDING = 36;
|
|
287
429
|
var CONNECTION_HYSTERESIS = 16;
|
|
288
430
|
var CONNECTION_CENTER_LOCK_RADIUS = 18;
|
|
289
|
-
var RESIZE_PORT_GAP =
|
|
431
|
+
var RESIZE_PORT_GAP = 28;
|
|
290
432
|
var EDGE_STROKE_PRESETS = [
|
|
291
433
|
{ id: "thin", label: "Thin", width: 1.75 },
|
|
292
434
|
{ id: "medium", label: "Medium", width: DEFAULT_EDGE_STROKE_WIDTH },
|
|
293
435
|
{ id: "bold", label: "Bold", width: 3.5 }
|
|
294
436
|
];
|
|
295
|
-
var VD_FLOWCHART_VERSION = "1.
|
|
437
|
+
var VD_FLOWCHART_VERSION = "1.4.0";
|
|
438
|
+
var FLOWCHART_DOCUMENT_VERSION = "1.3.0";
|
|
296
439
|
var FLOWCHART_NODE_TYPES = [
|
|
297
440
|
"rounded-rect",
|
|
298
441
|
"rect",
|
|
@@ -305,7 +448,65 @@ var FLOWCHART_NODE_TYPES = [
|
|
|
305
448
|
var FLOWCHART_PORTS = ["top", "right", "bottom", "left"];
|
|
306
449
|
var FLOWCHART_EDGE_MARKERS = ["none", "arrow", "dot"];
|
|
307
450
|
var FLOWCHART_EDGE_ROUTES = ["curve", "straight", "orthogonal"];
|
|
308
|
-
var
|
|
451
|
+
var FLOWCHART_KEYBOARD_SHORTCUTS = ["mindmap", "basic"];
|
|
452
|
+
function normalizeKeyboardShortcuts(value) {
|
|
453
|
+
return value === "basic" ? "basic" : "mindmap";
|
|
454
|
+
}
|
|
455
|
+
var ARROW_DIRECTIONS = {
|
|
456
|
+
ArrowRight: "right",
|
|
457
|
+
ArrowLeft: "left",
|
|
458
|
+
ArrowDown: "down",
|
|
459
|
+
ArrowUp: "up"
|
|
460
|
+
};
|
|
461
|
+
function getShortcutRows(mode, readonly) {
|
|
462
|
+
const navigation = [
|
|
463
|
+
["Arrow keys", "Select the nearest node in that direction"],
|
|
464
|
+
["Shift + Arrow", "Select a connection on that side (repeat to cycle)"],
|
|
465
|
+
["Shift + click, Shift + drag", "Add nodes to the selection, or select with a box"],
|
|
466
|
+
["Cmd/Ctrl + A", "Select all nodes"]
|
|
467
|
+
];
|
|
468
|
+
const view = [
|
|
469
|
+
["Cmd/Ctrl + = or -", "Zoom in or out"],
|
|
470
|
+
["Cmd/Ctrl + 0", "Zoom to 100%"],
|
|
471
|
+
["Shift + 1", "Fit the diagram to the view"]
|
|
472
|
+
];
|
|
473
|
+
const help = [["?", "Show or hide this list"]];
|
|
474
|
+
if (readonly) {
|
|
475
|
+
return [...navigation, ...view, ...help, ["Tab", "Leave the canvas"]];
|
|
476
|
+
}
|
|
477
|
+
const mindmap = mode === "mindmap" ? [
|
|
478
|
+
["Tab", "Add a child node"],
|
|
479
|
+
["Enter", "Add a sibling below (Shift + Enter: above)"],
|
|
480
|
+
["F2, Space, or type", "Edit the label"],
|
|
481
|
+
["Enter while editing", "Save the label (Shift + Enter: new line)"],
|
|
482
|
+
["Tab while editing", "Save and add a child"]
|
|
483
|
+
] : [
|
|
484
|
+
["Enter or F2", "Edit the label"],
|
|
485
|
+
["Cmd/Ctrl + Enter", "Save the label"]
|
|
486
|
+
];
|
|
487
|
+
return [
|
|
488
|
+
...navigation,
|
|
489
|
+
...mindmap,
|
|
490
|
+
[
|
|
491
|
+
mode === "mindmap" ? "Enter, F2, or type on a connection" : "Enter or F2 on a connection",
|
|
492
|
+
"Edit the connection label"
|
|
493
|
+
],
|
|
494
|
+
["Cmd/Ctrl + /", "Collapse or expand the branch"],
|
|
495
|
+
["Alt + Arrow", "Nudge the node (add Shift for 1 px)"],
|
|
496
|
+
["Delete or Backspace", "Delete the selection"],
|
|
497
|
+
["Cmd/Ctrl + D", "Duplicate the node"],
|
|
498
|
+
["Cmd/Ctrl + Z, Shift + Cmd/Ctrl + Z", "Undo, redo"],
|
|
499
|
+
["Cmd/Ctrl + C, X, V", "Copy, cut, paste"],
|
|
500
|
+
...view,
|
|
501
|
+
["Esc", "Cancel editing, then the tool, then deselect"],
|
|
502
|
+
[
|
|
503
|
+
mode === "mindmap" ? "Shift + Tab, or Esc then Tab" : "Tab or Shift + Tab",
|
|
504
|
+
"Leave the canvas"
|
|
505
|
+
],
|
|
506
|
+
...help
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update", "node:nudge"]);
|
|
309
510
|
var DEFAULT_EDGE_ROUTE = "curve";
|
|
310
511
|
var ORTHOGONAL_STUB_LENGTH = 32;
|
|
311
512
|
var ORTHOGONAL_CORNER_RADIUS = 12;
|
|
@@ -479,7 +680,7 @@ function normalizeViewport(viewport) {
|
|
|
479
680
|
function normalizeNode(rawNode, index, usedIds) {
|
|
480
681
|
const type = normalizeNodeType(rawNode?.type);
|
|
481
682
|
const spec = getNodeSpec(type);
|
|
482
|
-
|
|
683
|
+
const node = {
|
|
483
684
|
id: ensureUniqueId(rawNode?.id, "node", usedIds),
|
|
484
685
|
type,
|
|
485
686
|
x: toFiniteNumber(rawNode?.x, index * 28),
|
|
@@ -489,6 +690,37 @@ function normalizeNode(rawNode, index, usedIds) {
|
|
|
489
690
|
text: rawNode?.text == null ? spec.text : String(rawNode.text),
|
|
490
691
|
data: isPlainObject(rawNode?.data) ? deepClone(rawNode.data) : {}
|
|
491
692
|
};
|
|
693
|
+
if (rawNode?.collapsed === true) node.collapsed = true;
|
|
694
|
+
return node;
|
|
695
|
+
}
|
|
696
|
+
function computeHiddenNodeIds(nodes, edges) {
|
|
697
|
+
const collapsed = new Set(nodes.filter((node) => node.collapsed).map((node) => node.id));
|
|
698
|
+
if (!collapsed.size) return /* @__PURE__ */ new Set();
|
|
699
|
+
const outgoing = new Map(nodes.map((node) => [node.id, []]));
|
|
700
|
+
edges.forEach((edge) => {
|
|
701
|
+
if (edge.from.nodeId === edge.to.nodeId) return;
|
|
702
|
+
outgoing.get(edge.from.nodeId)?.push(edge.to.nodeId);
|
|
703
|
+
});
|
|
704
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
705
|
+
collapsed.forEach((rootId) => {
|
|
706
|
+
const queue2 = [...outgoing.get(rootId) || []];
|
|
707
|
+
for (let index = 0; index < queue2.length; index += 1) {
|
|
708
|
+
const id = queue2[index];
|
|
709
|
+
if (id === rootId || candidates.has(id)) continue;
|
|
710
|
+
candidates.add(id);
|
|
711
|
+
queue2.push(...outgoing.get(id) || []);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
const queue = nodes.filter((node) => !candidates.has(node.id) && !collapsed.has(node.id)).map((node) => node.id);
|
|
715
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
716
|
+
const id = queue[index];
|
|
717
|
+
(outgoing.get(id) || []).forEach((childId) => {
|
|
718
|
+
if (!candidates.has(childId)) return;
|
|
719
|
+
candidates.delete(childId);
|
|
720
|
+
if (!collapsed.has(childId)) queue.push(childId);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
return candidates;
|
|
492
724
|
}
|
|
493
725
|
function normalizeEndpoint(rawEndpoint, fallbackPort) {
|
|
494
726
|
return {
|
|
@@ -529,17 +761,45 @@ function normalizeEdge(rawEdge, index, nodeIds, usedIds) {
|
|
|
529
761
|
syncEdgeKind(edge);
|
|
530
762
|
return edge;
|
|
531
763
|
}
|
|
764
|
+
function parseDocumentVersion(value) {
|
|
765
|
+
const raw = typeof value === "number" && Number.isFinite(value) ? String(value) : typeof value === "string" ? value.trim() : "";
|
|
766
|
+
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(raw);
|
|
767
|
+
if (!match) return null;
|
|
768
|
+
return {
|
|
769
|
+
major: Number(match[1]),
|
|
770
|
+
minor: Number(match[2] ?? 0),
|
|
771
|
+
patch: Number(match[3] ?? 0)
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function isSupportedDocumentVersion(value) {
|
|
775
|
+
const parsed = parseDocumentVersion(value);
|
|
776
|
+
const max = parseDocumentVersion(FLOWCHART_DOCUMENT_VERSION);
|
|
777
|
+
if (!parsed || !max || parsed.major !== max.major) return false;
|
|
778
|
+
if (parsed.minor > max.minor) return false;
|
|
779
|
+
if (parsed.minor === max.minor && parsed.patch > max.patch) return false;
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
532
782
|
function normalizeDocument(input) {
|
|
533
783
|
let source = input;
|
|
534
784
|
if (typeof source === "string") {
|
|
535
785
|
try {
|
|
536
786
|
source = JSON.parse(source);
|
|
537
787
|
} catch {
|
|
538
|
-
|
|
788
|
+
throw new TypeError("Invalid flowchart JSON. The current document was not changed.");
|
|
539
789
|
}
|
|
540
790
|
}
|
|
541
791
|
if (!isPlainObject(source)) {
|
|
542
|
-
|
|
792
|
+
throw new TypeError("A flowchart document must be an object.");
|
|
793
|
+
}
|
|
794
|
+
if (source.version !== void 0 && !isSupportedDocumentVersion(source.version)) {
|
|
795
|
+
throw new RangeError(
|
|
796
|
+
`Unsupported flowchart document version: ${String(source.version)}. Supported through ${FLOWCHART_DOCUMENT_VERSION}.`
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
for (const key of ["nodes", "edges"]) {
|
|
800
|
+
if (source[key] !== void 0 && !Array.isArray(source[key])) {
|
|
801
|
+
throw new TypeError(`Flowchart ${key} must be an array.`);
|
|
802
|
+
}
|
|
543
803
|
}
|
|
544
804
|
const usedNodeIds = /* @__PURE__ */ new Set();
|
|
545
805
|
const nodes = (Array.isArray(source.nodes) ? source.nodes : []).slice(0, MAX_NODES).map((node, index) => normalizeNode(node, index, usedNodeIds));
|
|
@@ -547,7 +807,7 @@ function normalizeDocument(input) {
|
|
|
547
807
|
const usedEdgeIds = /* @__PURE__ */ new Set();
|
|
548
808
|
const edges = (Array.isArray(source.edges) ? source.edges : []).slice(0, MAX_EDGES).map((edge, index) => normalizeEdge(edge, index, nodeIds, usedEdgeIds)).filter(Boolean);
|
|
549
809
|
return {
|
|
550
|
-
version:
|
|
810
|
+
version: FLOWCHART_DOCUMENT_VERSION,
|
|
551
811
|
viewport: normalizeViewport(source.viewport),
|
|
552
812
|
nodes,
|
|
553
813
|
edges
|
|
@@ -613,6 +873,15 @@ function getPortPosition(node, port) {
|
|
|
613
873
|
return { x: node.x, y: node.y + node.height / 2 };
|
|
614
874
|
}
|
|
615
875
|
}
|
|
876
|
+
function getPortHandlePosition(node, port, scale = 1) {
|
|
877
|
+
const point = getPortPosition(node, port);
|
|
878
|
+
const normal = getPortNormal(port);
|
|
879
|
+
const offset = PORT_HANDLE_OFFSET / scale;
|
|
880
|
+
return {
|
|
881
|
+
x: formatNumber(point.x + normal.x * offset),
|
|
882
|
+
y: formatNumber(point.y + normal.y * offset)
|
|
883
|
+
};
|
|
884
|
+
}
|
|
616
885
|
function getNearestPort(node, point) {
|
|
617
886
|
return FLOWCHART_PORTS.reduce((best, port) => {
|
|
618
887
|
const portPoint = getPortPosition(node, port);
|
|
@@ -980,6 +1249,68 @@ function getResizeCursor(handle) {
|
|
|
980
1249
|
if (handle === "ne" || handle === "sw") return "nesw-resize";
|
|
981
1250
|
return "nwse-resize";
|
|
982
1251
|
}
|
|
1252
|
+
function collectGuideLines(nodes) {
|
|
1253
|
+
const x = [];
|
|
1254
|
+
const y = [];
|
|
1255
|
+
nodes.forEach((node) => {
|
|
1256
|
+
const right = node.x + node.width;
|
|
1257
|
+
const bottom = node.y + node.height;
|
|
1258
|
+
[node.x, node.x + node.width / 2, right].forEach(
|
|
1259
|
+
(value) => x.push({ value, start: node.y, end: bottom })
|
|
1260
|
+
);
|
|
1261
|
+
[node.y, node.y + node.height / 2, bottom].forEach(
|
|
1262
|
+
(value) => y.push({ value, start: node.x, end: right })
|
|
1263
|
+
);
|
|
1264
|
+
});
|
|
1265
|
+
const byValue = (a, b) => a.value - b.value;
|
|
1266
|
+
return { x: x.sort(byValue), y: y.sort(byValue) };
|
|
1267
|
+
}
|
|
1268
|
+
function lowerBound(lines, value) {
|
|
1269
|
+
let low = 0;
|
|
1270
|
+
let high = lines.length;
|
|
1271
|
+
while (low < high) {
|
|
1272
|
+
const middle = low + high >> 1;
|
|
1273
|
+
if (lines[middle].value < value) low = middle + 1;
|
|
1274
|
+
else high = middle;
|
|
1275
|
+
}
|
|
1276
|
+
return low;
|
|
1277
|
+
}
|
|
1278
|
+
function snapToGuides(lines, candidates, threshold) {
|
|
1279
|
+
let best = null;
|
|
1280
|
+
candidates.forEach((candidate) => {
|
|
1281
|
+
const index = lowerBound(lines, candidate);
|
|
1282
|
+
[lines[index - 1], lines[index]].forEach((line) => {
|
|
1283
|
+
if (!line) return;
|
|
1284
|
+
const offset = line.value - candidate;
|
|
1285
|
+
if (Math.abs(offset) <= threshold && (!best || Math.abs(offset) < Math.abs(best.offset))) {
|
|
1286
|
+
best = { offset };
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
if (!best) return null;
|
|
1291
|
+
const positions = candidates.map((candidate) => candidate + best.offset);
|
|
1292
|
+
best.lines = lines.filter(
|
|
1293
|
+
(line) => positions.some((position) => Math.abs(line.value - position) < 0.5)
|
|
1294
|
+
);
|
|
1295
|
+
return best;
|
|
1296
|
+
}
|
|
1297
|
+
function guideSegments(axis, lines, box) {
|
|
1298
|
+
const byValue = /* @__PURE__ */ new Map();
|
|
1299
|
+
lines.forEach((line) => {
|
|
1300
|
+
const current = byValue.get(line.value);
|
|
1301
|
+
byValue.set(line.value, {
|
|
1302
|
+
start: Math.min(current?.start ?? Infinity, line.start),
|
|
1303
|
+
end: Math.max(current?.end ?? -Infinity, line.end)
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
const boxStart = axis === "x" ? box.top : box.left;
|
|
1307
|
+
const boxEnd = boxStart + (axis === "x" ? box.height : box.width);
|
|
1308
|
+
return [...byValue].map(([value, extent]) => {
|
|
1309
|
+
const start = Math.min(extent.start, boxStart) - GUIDE_OVERHANG;
|
|
1310
|
+
const end = Math.max(extent.end, boxEnd) + GUIDE_OVERHANG;
|
|
1311
|
+
return axis === "x" ? { x1: value, y1: start, x2: value, y2: end } : { x1: start, y1: value, x2: end, y2: value };
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
983
1314
|
function getBounds(nodes) {
|
|
984
1315
|
if (!nodes.length) {
|
|
985
1316
|
return { left: 0, top: 0, right: 0, bottom: 0 };
|
|
@@ -1002,6 +1333,8 @@ function getBounds(nodes) {
|
|
|
1002
1333
|
function createField(labelText, control) {
|
|
1003
1334
|
const wrapper = createElement("div", { className: "vd-flowchart-field" });
|
|
1004
1335
|
const label = createElement("label", { text: labelText });
|
|
1336
|
+
control.id || (control.id = nextId("flowchart-field"));
|
|
1337
|
+
label.htmlFor = control.id;
|
|
1005
1338
|
wrapper.appendChild(label);
|
|
1006
1339
|
wrapper.appendChild(control);
|
|
1007
1340
|
return wrapper;
|
|
@@ -1134,6 +1467,7 @@ var VdFlowchart = class {
|
|
|
1134
1467
|
this.documentData = normalizeDocument(options.data || {});
|
|
1135
1468
|
this.listeners = {};
|
|
1136
1469
|
this.selection = null;
|
|
1470
|
+
this.selectedNodeIds = /* @__PURE__ */ new Set();
|
|
1137
1471
|
this.interaction = null;
|
|
1138
1472
|
this.activeTool = null;
|
|
1139
1473
|
this.layoutMode = "tree";
|
|
@@ -1141,6 +1475,7 @@ var VdFlowchart = class {
|
|
|
1141
1475
|
this.destroyed = false;
|
|
1142
1476
|
this.lastNodePointer = null;
|
|
1143
1477
|
this.reconnectEdgeId = null;
|
|
1478
|
+
this.edgeCycleAnchor = null;
|
|
1144
1479
|
this.clipboard = null;
|
|
1145
1480
|
this.gridPatternId = nextId("flowchart-grid");
|
|
1146
1481
|
this.markerIds = /* @__PURE__ */ new Map();
|
|
@@ -1150,6 +1485,16 @@ var VdFlowchart = class {
|
|
|
1150
1485
|
this.history = [];
|
|
1151
1486
|
this.historyIndex = -1;
|
|
1152
1487
|
this.isApplyingHistory = false;
|
|
1488
|
+
this.historyBatchDepth = 0;
|
|
1489
|
+
this.historyBatchDirty = false;
|
|
1490
|
+
this.coalesceInsertedText = false;
|
|
1491
|
+
this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
|
|
1492
|
+
this.snapGuides = options.snapGuides !== false;
|
|
1493
|
+
this.minimapEnabled = options.minimap !== false;
|
|
1494
|
+
this.autoLayout = Boolean(options.autoLayout);
|
|
1495
|
+
this.minimapFrame = 0;
|
|
1496
|
+
this.minimapDrag = null;
|
|
1497
|
+
this.shortcutsHelpOpen = false;
|
|
1153
1498
|
this.autoFit = Boolean(options.autoFit);
|
|
1154
1499
|
this.readyEmitted = false;
|
|
1155
1500
|
this.resizeObserver = null;
|
|
@@ -1190,6 +1535,7 @@ var VdFlowchart = class {
|
|
|
1190
1535
|
if (!ready) return false;
|
|
1191
1536
|
this.readyEmitted = true;
|
|
1192
1537
|
if (this.autoFit) this.fitView();
|
|
1538
|
+
else this.scheduleMinimap();
|
|
1193
1539
|
this.emit("ready", this);
|
|
1194
1540
|
return true;
|
|
1195
1541
|
};
|
|
@@ -1259,8 +1605,25 @@ var VdFlowchart = class {
|
|
|
1259
1605
|
className: "vd-flowchart-toolbar-label",
|
|
1260
1606
|
text: "100%"
|
|
1261
1607
|
});
|
|
1608
|
+
this.shortcutsButton = createElement("button", {
|
|
1609
|
+
className: "vd-flowchart-btn vd-flowchart-icon-btn vd-flowchart-shortcuts-btn",
|
|
1610
|
+
title: "Keyboard shortcuts (?)",
|
|
1611
|
+
text: "?"
|
|
1612
|
+
});
|
|
1613
|
+
this.shortcutsButton.setAttribute("type", "button");
|
|
1614
|
+
this.shortcutsButton.setAttribute("data-flowchart-action", "shortcuts");
|
|
1615
|
+
this.shortcutsButton.setAttribute("aria-label", "Keyboard shortcuts");
|
|
1616
|
+
this.shortcutsButton.setAttribute("aria-expanded", "false");
|
|
1617
|
+
this.minimapButton = createToolbarButton({
|
|
1618
|
+
action: "minimap",
|
|
1619
|
+
label: "Toggle minimap",
|
|
1620
|
+
icon: "minimap"
|
|
1621
|
+
});
|
|
1622
|
+
this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
|
|
1262
1623
|
toolbarLeft.appendChild(this.arrangeSelect);
|
|
1263
1624
|
toolbarLeft.appendChild(this.clearButton);
|
|
1625
|
+
toolbarRight.appendChild(this.minimapButton);
|
|
1626
|
+
toolbarRight.appendChild(this.shortcutsButton);
|
|
1264
1627
|
toolbarRight.appendChild(this.zoomLabel);
|
|
1265
1628
|
this.toolbar.appendChild(toolbarLeft);
|
|
1266
1629
|
this.toolbar.appendChild(toolbarRight);
|
|
@@ -1339,13 +1702,40 @@ var VdFlowchart = class {
|
|
|
1339
1702
|
this.previewLayer = svgEl("g", { class: "vd-flowchart-preview" });
|
|
1340
1703
|
this.nodesLayer = svgEl("g", { class: "vd-flowchart-nodes" });
|
|
1341
1704
|
this.overlayLayer = svgEl("g", { class: "vd-flowchart-overlay" });
|
|
1705
|
+
this.guidesLayer = svgEl("g", { class: "vd-flowchart-guides" });
|
|
1342
1706
|
this.world.appendChild(this.gridRect);
|
|
1343
1707
|
this.world.appendChild(this.edgesLayer);
|
|
1344
1708
|
this.world.appendChild(this.previewLayer);
|
|
1345
1709
|
this.world.appendChild(this.nodesLayer);
|
|
1346
1710
|
this.world.appendChild(this.overlayLayer);
|
|
1711
|
+
this.world.appendChild(this.guidesLayer);
|
|
1347
1712
|
this.svg.appendChild(this.world);
|
|
1348
1713
|
this.canvasEl.appendChild(this.svg);
|
|
1714
|
+
this.shortcutsHelp = createElement("div", { className: "vd-flowchart-shortcuts" });
|
|
1715
|
+
this.shortcutsHelp.id = nextId("flowchart-shortcuts");
|
|
1716
|
+
this.shortcutsHelp.setAttribute("role", "dialog");
|
|
1717
|
+
this.shortcutsHelp.setAttribute("aria-label", "Keyboard shortcuts");
|
|
1718
|
+
this.shortcutsHelp.hidden = true;
|
|
1719
|
+
this.shortcutsHelp.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
1720
|
+
this.shortcutsHelp.addEventListener("wheel", (event) => event.stopPropagation());
|
|
1721
|
+
this.shortcutsButton.setAttribute("aria-controls", this.shortcutsHelp.id);
|
|
1722
|
+
this.canvasEl.appendChild(this.shortcutsHelp);
|
|
1723
|
+
this.minimapEl = createElement("div", { className: "vd-flowchart-minimap" });
|
|
1724
|
+
this.minimapEl.setAttribute("aria-hidden", "true");
|
|
1725
|
+
this.minimapEl.hidden = true;
|
|
1726
|
+
this.minimapSvg = svgEl("svg", {
|
|
1727
|
+
class: "vd-flowchart-minimap-svg",
|
|
1728
|
+
width: MINIMAP_WIDTH,
|
|
1729
|
+
height: MINIMAP_HEIGHT,
|
|
1730
|
+
viewBox: `0 0 ${MINIMAP_WIDTH} ${MINIMAP_HEIGHT}`
|
|
1731
|
+
});
|
|
1732
|
+
this.minimapEl.appendChild(this.minimapSvg);
|
|
1733
|
+
this.minimapEl.addEventListener("pointerdown", (event) => this.handleMinimapPointer(event));
|
|
1734
|
+
this.minimapEl.addEventListener("pointermove", (event) => this.handleMinimapPointer(event));
|
|
1735
|
+
this.minimapEl.addEventListener("pointerup", (event) => this.endMinimapDrag(event));
|
|
1736
|
+
this.minimapEl.addEventListener("pointercancel", (event) => this.endMinimapDrag(event));
|
|
1737
|
+
this.minimapEl.addEventListener("wheel", (event) => event.stopPropagation());
|
|
1738
|
+
this.canvasEl.appendChild(this.minimapEl);
|
|
1349
1739
|
this.inspectorPanel = createElement("aside", {
|
|
1350
1740
|
className: "vd-flowchart-panel vd-flowchart-panel--inspector"
|
|
1351
1741
|
});
|
|
@@ -1386,14 +1776,134 @@ var VdFlowchart = class {
|
|
|
1386
1776
|
this.jsonActions.appendChild(this.loadJsonButton);
|
|
1387
1777
|
this.jsonPanel.appendChild(this.jsonTextarea);
|
|
1388
1778
|
this.jsonPanel.appendChild(this.jsonActions);
|
|
1779
|
+
this.jsonStatus = createElement("p", { className: "vd-flowchart-json-status" });
|
|
1780
|
+
this.jsonStatus.setAttribute("role", "status");
|
|
1781
|
+
this.jsonPanel.appendChild(this.jsonStatus);
|
|
1389
1782
|
this.inspectorPanel.appendChild(this.jsonPanel);
|
|
1390
1783
|
this.body.appendChild(this.palettePanel);
|
|
1391
1784
|
this.body.appendChild(this.canvasEl);
|
|
1392
1785
|
this.body.appendChild(this.inspectorPanel);
|
|
1393
1786
|
this.root.appendChild(this.toolbar);
|
|
1394
1787
|
this.root.appendChild(this.body);
|
|
1788
|
+
this.buildGraphOutline();
|
|
1395
1789
|
this.element.appendChild(this.root);
|
|
1396
1790
|
}
|
|
1791
|
+
buildGraphOutline() {
|
|
1792
|
+
this.graphOutline = createElement("details", { className: "vd-flowchart-outline" });
|
|
1793
|
+
this.graphOutline.appendChild(createElement("summary", { text: "Graph outline" }));
|
|
1794
|
+
this.graphOutline.addEventListener("toggle", () => {
|
|
1795
|
+
if (this.graphOutline.open) this.renderGraphOutline();
|
|
1796
|
+
});
|
|
1797
|
+
this.graphStatus = createElement("p", { className: "vd-flowchart-sr-only" });
|
|
1798
|
+
this.graphStatus.setAttribute("role", "status");
|
|
1799
|
+
this.canvasEl.appendChild(this.graphStatus);
|
|
1800
|
+
this.graphControls = createElement("div", { className: "vd-flowchart-outline-controls" });
|
|
1801
|
+
this.graphNodeSelect = createElement("select");
|
|
1802
|
+
this.graphNodeSelect.setAttribute("aria-label", "Selected node");
|
|
1803
|
+
this.graphNodeSelect.addEventListener(
|
|
1804
|
+
"change",
|
|
1805
|
+
() => this.selectNode(this.graphNodeSelect.value)
|
|
1806
|
+
);
|
|
1807
|
+
this.graphTargetSelect = createElement("select");
|
|
1808
|
+
this.graphTargetSelect.setAttribute("aria-label", "Connection target");
|
|
1809
|
+
this.graphTargetSelect.addEventListener("change", () => this.renderGraphOutline());
|
|
1810
|
+
this.graphConnectButton = createElement("button", {
|
|
1811
|
+
text: "Connect nodes",
|
|
1812
|
+
type: "button",
|
|
1813
|
+
className: "vd-flowchart-btn"
|
|
1814
|
+
});
|
|
1815
|
+
this.graphConnectButton.addEventListener("click", () => {
|
|
1816
|
+
if (this.readonly || this.graphNodeSelect.value === this.graphTargetSelect.value) {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
this.addEdge({
|
|
1820
|
+
from: this.graphNodeSelect.value,
|
|
1821
|
+
to: this.graphTargetSelect.value,
|
|
1822
|
+
autoPort: true
|
|
1823
|
+
});
|
|
1824
|
+
});
|
|
1825
|
+
this.graphEditButton = createElement("button", {
|
|
1826
|
+
text: "Edit selected label",
|
|
1827
|
+
type: "button",
|
|
1828
|
+
className: "vd-flowchart-btn"
|
|
1829
|
+
});
|
|
1830
|
+
this.graphEditButton.addEventListener(
|
|
1831
|
+
"click",
|
|
1832
|
+
() => this.startTextEdit(this.graphNodeSelect.value)
|
|
1833
|
+
);
|
|
1834
|
+
this.graphControls.append(
|
|
1835
|
+
createField("Selected node", this.graphNodeSelect),
|
|
1836
|
+
createField("Connection target", this.graphTargetSelect),
|
|
1837
|
+
this.graphConnectButton,
|
|
1838
|
+
this.graphEditButton
|
|
1839
|
+
);
|
|
1840
|
+
this.graphOutline.appendChild(this.graphControls);
|
|
1841
|
+
this.graphList = createElement("ul");
|
|
1842
|
+
this.graphList.setAttribute("aria-label", "Nodes and connections");
|
|
1843
|
+
this.graphOutline.appendChild(this.graphList);
|
|
1844
|
+
this.root.appendChild(this.graphOutline);
|
|
1845
|
+
this.canvasEl.setAttribute("role", "group");
|
|
1846
|
+
this.syncCanvasLabel();
|
|
1847
|
+
this.jsonTextarea.setAttribute("aria-label", "Flowchart JSON");
|
|
1848
|
+
}
|
|
1849
|
+
syncCanvasLabel() {
|
|
1850
|
+
const keys = this.readonly || this.keyboardShortcuts === "basic" ? "Arrow keys select nodes; Enter or F2 edits a label." : "Arrow keys select nodes; Tab adds a child, Enter adds a sibling, F2 edits a label.";
|
|
1851
|
+
this.canvasEl.setAttribute(
|
|
1852
|
+
"aria-label",
|
|
1853
|
+
`Diagram canvas. ${keys} Press ? for all shortcuts. Graph outline provides connections.`
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
renderGraphOutline() {
|
|
1857
|
+
if (!this.graphOutline) return;
|
|
1858
|
+
const nodes = this.documentData.nodes;
|
|
1859
|
+
const edges = this.documentData.edges;
|
|
1860
|
+
const labels = new Map(nodes.map((node) => [node.id, node.text || node.id]));
|
|
1861
|
+
const selected = this.selection?.kind === "node" ? this.selection.id : null;
|
|
1862
|
+
this.graphStatus.textContent = selected ? `Selected: ${labels.get(selected)}. ${nodes.length} nodes, ${edges.length} connections.` : `${nodes.length} nodes, ${edges.length} connections.`;
|
|
1863
|
+
if (!this.graphOutline.open) return;
|
|
1864
|
+
const hidden = this.getHiddenNodeIds();
|
|
1865
|
+
const signature = JSON.stringify([
|
|
1866
|
+
nodes.map((node) => [node.id, node.text, Boolean(node.collapsed), hidden.has(node.id)]),
|
|
1867
|
+
edges.map((edge) => [edge.id, edge.from.nodeId, edge.to.nodeId, edge.label])
|
|
1868
|
+
]);
|
|
1869
|
+
if (signature !== this.graphSignature) {
|
|
1870
|
+
this.graphSignature = signature;
|
|
1871
|
+
for (const select of [this.graphNodeSelect, this.graphTargetSelect]) {
|
|
1872
|
+
const previous = select.value;
|
|
1873
|
+
select.replaceChildren();
|
|
1874
|
+
for (const node of nodes)
|
|
1875
|
+
select.appendChild(
|
|
1876
|
+
createElement("option", { value: node.id, text: labels.get(node.id) })
|
|
1877
|
+
);
|
|
1878
|
+
if (labels.has(previous)) select.value = previous;
|
|
1879
|
+
}
|
|
1880
|
+
this.graphList.replaceChildren();
|
|
1881
|
+
const outgoing = new Map(nodes.map((node) => [node.id, []]));
|
|
1882
|
+
const incoming = new Map(nodes.map((node) => [node.id, []]));
|
|
1883
|
+
for (const edge of edges) {
|
|
1884
|
+
outgoing.get(edge.from.nodeId)?.push(`${labels.get(edge.to.nodeId)}${edge.label ? ` (${edge.label})` : ""}`);
|
|
1885
|
+
incoming.get(edge.to.nodeId)?.push(labels.get(edge.from.nodeId));
|
|
1886
|
+
}
|
|
1887
|
+
for (const node of nodes) {
|
|
1888
|
+
const to = outgoing.get(node.id);
|
|
1889
|
+
const from = incoming.get(node.id);
|
|
1890
|
+
const relationships = [
|
|
1891
|
+
to.length ? `Connects to ${to.join(", ")}.` : "No outgoing connections.",
|
|
1892
|
+
from.length ? `Connected from ${from.join(", ")}.` : "",
|
|
1893
|
+
node.collapsed ? "Branch collapsed." : "",
|
|
1894
|
+
hidden.has(node.id) ? "Hidden in a collapsed branch." : ""
|
|
1895
|
+
].filter(Boolean);
|
|
1896
|
+
this.graphList.appendChild(
|
|
1897
|
+
createElement("li", { text: `${labels.get(node.id)}. ${relationships.join(" ")}` })
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (selected) this.graphNodeSelect.value = selected;
|
|
1902
|
+
this.graphNodeSelect.disabled = nodes.length === 0;
|
|
1903
|
+
this.graphTargetSelect.disabled = this.readonly || nodes.length < 2;
|
|
1904
|
+
this.graphConnectButton.disabled = this.readonly || nodes.length < 2 || this.graphNodeSelect.value === this.graphTargetSelect.value;
|
|
1905
|
+
this.graphEditButton.disabled = this.readonly || nodes.length === 0;
|
|
1906
|
+
}
|
|
1397
1907
|
bindEvents() {
|
|
1398
1908
|
this.toolbar.addEventListener("click", this.handleToolbarClick);
|
|
1399
1909
|
this.arrangeSelect.addEventListener("change", this.handleArrangeChange);
|
|
@@ -1467,6 +1977,157 @@ var VdFlowchart = class {
|
|
|
1467
1977
|
if (action === "undo") this.undo();
|
|
1468
1978
|
if (action === "redo") this.redo();
|
|
1469
1979
|
if (action === "clear" && !this.readonly) this.clear();
|
|
1980
|
+
if (action === "shortcuts") this.toggleShortcutsHelp();
|
|
1981
|
+
if (action === "minimap") this.setMinimapEnabled(!this.minimapEnabled);
|
|
1982
|
+
}
|
|
1983
|
+
setMinimapEnabled(enabled) {
|
|
1984
|
+
this.minimapEnabled = Boolean(enabled);
|
|
1985
|
+
this.minimapButton.setAttribute("aria-pressed", this.minimapEnabled ? "true" : "false");
|
|
1986
|
+
this.drawMinimap();
|
|
1987
|
+
return this;
|
|
1988
|
+
}
|
|
1989
|
+
scheduleMinimap() {
|
|
1990
|
+
if (this.minimapFrame || !hasWindow()) return;
|
|
1991
|
+
const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => setTimeout(callback, 16);
|
|
1992
|
+
this.minimapFrame = raf(() => {
|
|
1993
|
+
this.minimapFrame = 0;
|
|
1994
|
+
this.drawMinimap();
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
// World-to-minimap mapping covering every visible node and the viewport.
|
|
1998
|
+
getMinimapTransform() {
|
|
1999
|
+
const viewport = this.documentData.viewport;
|
|
2000
|
+
const width = this.canvasEl.clientWidth;
|
|
2001
|
+
const height = this.canvasEl.clientHeight;
|
|
2002
|
+
const view = {
|
|
2003
|
+
left: -viewport.x / viewport.scale,
|
|
2004
|
+
top: -viewport.y / viewport.scale,
|
|
2005
|
+
right: (width - viewport.x) / viewport.scale,
|
|
2006
|
+
bottom: (height - viewport.y) / viewport.scale
|
|
2007
|
+
};
|
|
2008
|
+
const content = getBounds(this.getVisibleNodes());
|
|
2009
|
+
const left = Math.min(content.left, view.left);
|
|
2010
|
+
const top = Math.min(content.top, view.top);
|
|
2011
|
+
const spanX = Math.max(1, Math.max(content.right, view.right) - left);
|
|
2012
|
+
const spanY = Math.max(1, Math.max(content.bottom, view.bottom) - top);
|
|
2013
|
+
const padding = 8;
|
|
2014
|
+
const scale = Math.min(
|
|
2015
|
+
(MINIMAP_WIDTH - padding * 2) / spanX,
|
|
2016
|
+
(MINIMAP_HEIGHT - padding * 2) / spanY
|
|
2017
|
+
);
|
|
2018
|
+
return {
|
|
2019
|
+
scale,
|
|
2020
|
+
offsetX: (MINIMAP_WIDTH - spanX * scale) / 2 - left * scale,
|
|
2021
|
+
offsetY: (MINIMAP_HEIGHT - spanY * scale) / 2 - top * scale,
|
|
2022
|
+
view
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
drawMinimap() {
|
|
2026
|
+
if (this.destroyed || !this.minimapEl) return;
|
|
2027
|
+
const nodes = this.getVisibleNodes();
|
|
2028
|
+
const visible = this.minimapEnabled && nodes.length > 0 && this.canvasEl.clientWidth >= MINIMAP_MIN_CANVAS_WIDTH;
|
|
2029
|
+
this.minimapEl.hidden = !visible;
|
|
2030
|
+
if (!visible) return;
|
|
2031
|
+
const transform = this.minimapDrag?.transform || this.getMinimapTransform();
|
|
2032
|
+
const view = this.getMinimapTransform().view;
|
|
2033
|
+
const map = (x, y) => ({
|
|
2034
|
+
x: formatNumber(x * transform.scale + transform.offsetX),
|
|
2035
|
+
y: formatNumber(y * transform.scale + transform.offsetY)
|
|
2036
|
+
});
|
|
2037
|
+
clearChildren(this.minimapSvg);
|
|
2038
|
+
nodes.forEach((node) => {
|
|
2039
|
+
const point = map(node.x, node.y);
|
|
2040
|
+
const selected = this.isNodeSelected(node.id);
|
|
2041
|
+
this.minimapSvg.appendChild(
|
|
2042
|
+
svgEl("rect", {
|
|
2043
|
+
class: `vd-flowchart-minimap-node${selected ? " is-selected" : ""}`,
|
|
2044
|
+
x: point.x,
|
|
2045
|
+
y: point.y,
|
|
2046
|
+
width: formatNumber(Math.max(1.5, node.width * transform.scale)),
|
|
2047
|
+
height: formatNumber(Math.max(1.5, node.height * transform.scale)),
|
|
2048
|
+
rx: 1.5
|
|
2049
|
+
})
|
|
2050
|
+
);
|
|
2051
|
+
});
|
|
2052
|
+
const corner = map(view.left, view.top);
|
|
2053
|
+
this.minimapSvg.appendChild(
|
|
2054
|
+
svgEl("rect", {
|
|
2055
|
+
class: "vd-flowchart-minimap-viewport",
|
|
2056
|
+
x: corner.x,
|
|
2057
|
+
y: corner.y,
|
|
2058
|
+
width: formatNumber((view.right - view.left) * transform.scale),
|
|
2059
|
+
height: formatNumber((view.bottom - view.top) * transform.scale)
|
|
2060
|
+
})
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
2063
|
+
// Centre the main view on the world point under the minimap pointer. The
|
|
2064
|
+
// mapping is frozen for the whole drag so the map does not shift under it.
|
|
2065
|
+
handleMinimapPointer(event) {
|
|
2066
|
+
event.stopPropagation();
|
|
2067
|
+
if (event.type === "pointerdown") {
|
|
2068
|
+
if (event.button != null && event.button !== 0) return;
|
|
2069
|
+
event.preventDefault();
|
|
2070
|
+
this.minimapDrag = { pointerId: event.pointerId, transform: this.getMinimapTransform() };
|
|
2071
|
+
try {
|
|
2072
|
+
this.minimapEl.setPointerCapture?.(event.pointerId);
|
|
2073
|
+
} catch {
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
|
|
2077
|
+
const rect = this.minimapEl.getBoundingClientRect();
|
|
2078
|
+
const { transform } = this.minimapDrag;
|
|
2079
|
+
this.centerViewOn(
|
|
2080
|
+
(event.clientX - rect.left - transform.offsetX) / transform.scale,
|
|
2081
|
+
(event.clientY - rect.top - transform.offsetY) / transform.scale
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
endMinimapDrag(event) {
|
|
2085
|
+
event.stopPropagation();
|
|
2086
|
+
if (!this.minimapDrag || this.minimapDrag.pointerId !== event.pointerId) return;
|
|
2087
|
+
this.minimapDrag = null;
|
|
2088
|
+
this.syncJsonTextarea();
|
|
2089
|
+
this.emitViewportChange("viewport:pan");
|
|
2090
|
+
this.drawMinimap();
|
|
2091
|
+
}
|
|
2092
|
+
centerViewOn(worldX, worldY) {
|
|
2093
|
+
const viewport = this.documentData.viewport;
|
|
2094
|
+
const width = this.canvasEl.clientWidth || 800;
|
|
2095
|
+
const height = this.canvasEl.clientHeight || 560;
|
|
2096
|
+
viewport.x = formatNumber(width / 2 - worldX * viewport.scale);
|
|
2097
|
+
viewport.y = formatNumber(height / 2 - worldY * viewport.scale);
|
|
2098
|
+
this.render({ inspector: false, json: false });
|
|
2099
|
+
return this;
|
|
2100
|
+
}
|
|
2101
|
+
toggleShortcutsHelp(force) {
|
|
2102
|
+
const open = typeof force === "boolean" ? force : !this.shortcutsHelpOpen;
|
|
2103
|
+
this.shortcutsHelpOpen = open;
|
|
2104
|
+
this.shortcutsButton.setAttribute("aria-expanded", open ? "true" : "false");
|
|
2105
|
+
if (open) this.renderShortcutsHelp();
|
|
2106
|
+
this.shortcutsHelp.hidden = !open;
|
|
2107
|
+
if (!open && this.shortcutsHelp.contains(document.activeElement)) this.canvasEl.focus();
|
|
2108
|
+
return this;
|
|
2109
|
+
}
|
|
2110
|
+
renderShortcutsHelp() {
|
|
2111
|
+
clearChildren(this.shortcutsHelp);
|
|
2112
|
+
const header = createElement("div", { className: "vd-flowchart-shortcuts-header" });
|
|
2113
|
+
header.appendChild(
|
|
2114
|
+
createElement("h4", { className: "vd-flowchart-panel-title", text: "Keyboard shortcuts" })
|
|
2115
|
+
);
|
|
2116
|
+
const close = createElement("button", {
|
|
2117
|
+
className: "vd-flowchart-btn vd-flowchart-icon-btn",
|
|
2118
|
+
text: "\xD7"
|
|
2119
|
+
});
|
|
2120
|
+
close.setAttribute("type", "button");
|
|
2121
|
+
close.setAttribute("aria-label", "Close keyboard shortcuts");
|
|
2122
|
+
close.addEventListener("click", () => this.toggleShortcutsHelp(false));
|
|
2123
|
+
header.appendChild(close);
|
|
2124
|
+
this.shortcutsHelp.appendChild(header);
|
|
2125
|
+
const list = createElement("dl", { className: "vd-flowchart-shortcuts-list" });
|
|
2126
|
+
getShortcutRows(this.keyboardShortcuts, this.readonly).forEach(([keys, action]) => {
|
|
2127
|
+
list.appendChild(createElement("dt", { text: keys }));
|
|
2128
|
+
list.appendChild(createElement("dd", { text: action }));
|
|
2129
|
+
});
|
|
2130
|
+
this.shortcutsHelp.appendChild(list);
|
|
1470
2131
|
}
|
|
1471
2132
|
handleArrangeChange(event) {
|
|
1472
2133
|
if (this.readonly) return;
|
|
@@ -1500,7 +2161,12 @@ var VdFlowchart = class {
|
|
|
1500
2161
|
return;
|
|
1501
2162
|
}
|
|
1502
2163
|
if (action === "load" && !this.readonly) {
|
|
1503
|
-
|
|
2164
|
+
try {
|
|
2165
|
+
this.load(this.jsonTextarea.value);
|
|
2166
|
+
this.jsonStatus.textContent = "";
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
this.jsonStatus.textContent = error.message;
|
|
2169
|
+
}
|
|
1504
2170
|
}
|
|
1505
2171
|
}
|
|
1506
2172
|
handleSelectionFieldInput(event) {
|
|
@@ -1570,17 +2236,48 @@ var VdFlowchart = class {
|
|
|
1570
2236
|
);
|
|
1571
2237
|
}
|
|
1572
2238
|
}
|
|
2239
|
+
// Keys the editor consumes stop here, so page-level shortcuts (a docs
|
|
2240
|
+
// site's "/" search, a host's Escape handler) do not also fire.
|
|
1573
2241
|
handleKeyDown(event) {
|
|
1574
|
-
|
|
1575
|
-
|
|
2242
|
+
const alreadyHandled = event.defaultPrevented;
|
|
2243
|
+
this.handleEditorKey(event);
|
|
2244
|
+
if (!alreadyHandled && event.defaultPrevented) event.stopPropagation();
|
|
2245
|
+
}
|
|
2246
|
+
handleEditorKey(event) {
|
|
2247
|
+
const target = event.target;
|
|
2248
|
+
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) {
|
|
1576
2249
|
return;
|
|
1577
2250
|
}
|
|
1578
|
-
if (event.
|
|
2251
|
+
if (event.isComposing) return;
|
|
2252
|
+
const key = event.key;
|
|
2253
|
+
const modKey = event.metaKey || event.ctrlKey;
|
|
2254
|
+
if (key === "Escape" && this.shortcutsHelpOpen) {
|
|
1579
2255
|
event.preventDefault();
|
|
1580
|
-
this.
|
|
2256
|
+
this.toggleShortcutsHelp(false);
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
if (target === this.canvasEl && this.handleCanvasKeyDown(event)) {
|
|
2260
|
+
event.preventDefault();
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
if (this.readonly) return;
|
|
2264
|
+
if (key === "Escape") {
|
|
2265
|
+
if (this.activeTool) this.setActiveTool(null);
|
|
2266
|
+
else if (this.reconnectEdgeId) {
|
|
2267
|
+
this.reconnectEdgeId = null;
|
|
2268
|
+
this.render({ inspector: false, json: false });
|
|
2269
|
+
} else if (this.selection) this.select(null);
|
|
2270
|
+
else return;
|
|
2271
|
+
event.preventDefault();
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (modKey && (key === "d" || key === "D")) {
|
|
2275
|
+
if (this.selection?.kind === "node") {
|
|
2276
|
+
event.preventDefault();
|
|
2277
|
+
this.duplicateSelection();
|
|
2278
|
+
}
|
|
1581
2279
|
return;
|
|
1582
2280
|
}
|
|
1583
|
-
const modKey = event.metaKey || event.ctrlKey;
|
|
1584
2281
|
if (modKey && (event.key === "z" || event.key === "Z")) {
|
|
1585
2282
|
event.preventDefault();
|
|
1586
2283
|
if (event.shiftKey) {
|
|
@@ -1619,11 +2316,248 @@ var VdFlowchart = class {
|
|
|
1619
2316
|
if (!this.selection) return;
|
|
1620
2317
|
if (event.key === "Backspace" || event.key === "Delete") {
|
|
1621
2318
|
event.preventDefault();
|
|
2319
|
+
if (this.selection.kind === "node" && !this.hasMultiSelection()) {
|
|
2320
|
+
const removedId = this.selection.id;
|
|
2321
|
+
const parent = this.getParentNode(removedId);
|
|
2322
|
+
const sibling = parent ? this.getChildNodes(parent.id).find((node) => node.id !== removedId) : null;
|
|
2323
|
+
if (this.deleteSelection()) {
|
|
2324
|
+
const next = parent && this.findNode(parent.id) || sibling;
|
|
2325
|
+
if (next) this.select({ kind: "node", id: next.id });
|
|
2326
|
+
}
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
1622
2329
|
this.deleteSelection();
|
|
1623
2330
|
}
|
|
1624
2331
|
}
|
|
2332
|
+
// Keys that only apply while the canvas itself has focus. Returns true when
|
|
2333
|
+
// the key was handled (the caller prevents the default action).
|
|
2334
|
+
handleCanvasKeyDown(event) {
|
|
2335
|
+
const key = event.key;
|
|
2336
|
+
const modKey = event.metaKey || event.ctrlKey;
|
|
2337
|
+
const mindmap = this.keyboardShortcuts === "mindmap";
|
|
2338
|
+
const selectedNode = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
|
|
2339
|
+
const selectedEdge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
|
|
2340
|
+
const movable = !this.readonly && Boolean(selectedNode);
|
|
2341
|
+
const editable = movable && !this.hasMultiSelection();
|
|
2342
|
+
if (key === "?" && !modKey && !event.altKey) {
|
|
2343
|
+
this.toggleShortcutsHelp();
|
|
2344
|
+
return true;
|
|
2345
|
+
}
|
|
2346
|
+
if (modKey && !event.altKey) {
|
|
2347
|
+
if (key === "=" || key === "+") return Boolean(this.zoomIn());
|
|
2348
|
+
if (key === "-" || key === "_") return Boolean(this.zoomOut());
|
|
2349
|
+
if (key === "0") return Boolean(this.zoomTo(1));
|
|
2350
|
+
if (key === "a" || key === "A") {
|
|
2351
|
+
this.selectNodes(this.getVisibleNodes().map((node) => node.id));
|
|
2352
|
+
return true;
|
|
2353
|
+
}
|
|
2354
|
+
if (key === "/" && selectedNode && !this.hasMultiSelection()) {
|
|
2355
|
+
this.toggleCollapsed(selectedNode.id);
|
|
2356
|
+
return true;
|
|
2357
|
+
}
|
|
2358
|
+
return false;
|
|
2359
|
+
}
|
|
2360
|
+
if (event.shiftKey && !event.altKey && (key === "!" || event.code === "Digit1")) {
|
|
2361
|
+
return Boolean(this.fitView());
|
|
2362
|
+
}
|
|
2363
|
+
const direction = ARROW_DIRECTIONS[key];
|
|
2364
|
+
if (direction) {
|
|
2365
|
+
if (event.altKey) {
|
|
2366
|
+
if (movable) {
|
|
2367
|
+
this.nudgeNodes(this.getSelectedNodeIds(), direction, event.shiftKey ? 1 : this.gridSize);
|
|
2368
|
+
}
|
|
2369
|
+
return true;
|
|
2370
|
+
}
|
|
2371
|
+
if (event.shiftKey) {
|
|
2372
|
+
const anchorId = selectedNode?.id ?? (selectedEdge ? this.edgeCycleAnchor : null);
|
|
2373
|
+
return anchorId ? this.selectSideEdge(anchorId, direction) : false;
|
|
2374
|
+
}
|
|
2375
|
+
this.navigateSelection(direction);
|
|
2376
|
+
return true;
|
|
2377
|
+
}
|
|
2378
|
+
const nodes = this.getVisibleNodes();
|
|
2379
|
+
if ((key === "Home" || key === "End") && nodes.length) {
|
|
2380
|
+
this.selectNode(nodes[key === "Home" ? 0 : nodes.length - 1].id);
|
|
2381
|
+
return true;
|
|
2382
|
+
}
|
|
2383
|
+
if (selectedEdge && !this.readonly && !event.altKey) {
|
|
2384
|
+
if (key === "F2" || key === "Enter" || key === " " && mindmap) {
|
|
2385
|
+
return this.startEdgeLabelEdit(selectedEdge.id);
|
|
2386
|
+
}
|
|
2387
|
+
if (mindmap && key.length === 1 && key !== " ") {
|
|
2388
|
+
return this.startEdgeLabelEdit(selectedEdge.id, { initialText: key });
|
|
2389
|
+
}
|
|
2390
|
+
return false;
|
|
2391
|
+
}
|
|
2392
|
+
if (!editable || event.altKey) return false;
|
|
2393
|
+
if (key === "F2" || key === "Enter" && !mindmap || key === " " && mindmap) {
|
|
2394
|
+
this.startTextEdit(selectedNode.id);
|
|
2395
|
+
return true;
|
|
2396
|
+
}
|
|
2397
|
+
if (!mindmap) return false;
|
|
2398
|
+
if (key === "Tab" && !event.shiftKey) {
|
|
2399
|
+
this.insertBranchNode(selectedNode.id, { edit: true });
|
|
2400
|
+
return true;
|
|
2401
|
+
}
|
|
2402
|
+
if (key === "Enter") {
|
|
2403
|
+
this.insertSiblingNode(selectedNode.id, { before: event.shiftKey, edit: true });
|
|
2404
|
+
return true;
|
|
2405
|
+
}
|
|
2406
|
+
if (key.length === 1 && key !== " " && isNodeTextEditable(selectedNode)) {
|
|
2407
|
+
this.startTextEdit(selectedNode.id, { initialText: key });
|
|
2408
|
+
return true;
|
|
2409
|
+
}
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
// Move the selection to the nearest node in `direction`; with nothing (or an
|
|
2413
|
+
// edge) selected, start from the node closest to the centre of the view.
|
|
2414
|
+
navigateSelection(direction) {
|
|
2415
|
+
const nodes = this.getVisibleNodes();
|
|
2416
|
+
if (!nodes.length) return false;
|
|
2417
|
+
const current = this.selection?.kind === "node" ? this.findNode(this.selection.id) : null;
|
|
2418
|
+
const edge = this.selection?.kind === "edge" ? this.findEdge(this.selection.id) : null;
|
|
2419
|
+
const next = current ? findSpatialNeighbor(nodes, current, direction) : edge ? this.getEdgeEndInDirection(edge, direction) : findNearestNode(nodes, this.getViewportCenter());
|
|
2420
|
+
if (!next) return false;
|
|
2421
|
+
this.selectNode(next.id);
|
|
2422
|
+
this.revealNode(next.id);
|
|
2423
|
+
return true;
|
|
2424
|
+
}
|
|
2425
|
+
// The end of `edge` that best matches `direction`, measured from the label
|
|
2426
|
+
// anchor so either end can be reached from a selected connection.
|
|
2427
|
+
getEdgeEndInDirection(edge, direction) {
|
|
2428
|
+
const fromNode = this.findNode(edge.from.nodeId);
|
|
2429
|
+
const toNode = this.findNode(edge.to.nodeId);
|
|
2430
|
+
if (!fromNode || !toNode) return fromNode || toNode;
|
|
2431
|
+
const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
|
|
2432
|
+
const vector = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
|
|
2433
|
+
const score = (node) => (node.x + node.width / 2 - labelX) * vector[0] + (node.y + node.height / 2 - labelY) * vector[1];
|
|
2434
|
+
return score(toNode) >= score(fromNode) ? toNode : fromNode;
|
|
2435
|
+
}
|
|
2436
|
+
// Connections attached to `nodeId` on the `direction` side, outgoing or
|
|
2437
|
+
// incoming, in document order.
|
|
2438
|
+
getSideEdges(nodeId, direction) {
|
|
2439
|
+
const port = DIRECTION_PORTS[direction];
|
|
2440
|
+
return this.documentData.edges.filter(
|
|
2441
|
+
(edge) => edge.from.nodeId === nodeId && edge.from.port === port || edge.to.nodeId === nodeId && edge.to.port === port
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
// Select a connection on one side of a node; repeating cycles through them.
|
|
2445
|
+
selectSideEdge(nodeId, direction) {
|
|
2446
|
+
const edges = this.getSideEdges(nodeId, direction);
|
|
2447
|
+
if (!edges.length) return false;
|
|
2448
|
+
const currentIndex = this.selection?.kind === "edge" ? edges.findIndex((edge) => edge.id === this.selection.id) : -1;
|
|
2449
|
+
const next = edges[(currentIndex + 1) % edges.length];
|
|
2450
|
+
this.select({ kind: "edge", id: next.id });
|
|
2451
|
+
this.edgeCycleAnchor = nodeId;
|
|
2452
|
+
return true;
|
|
2453
|
+
}
|
|
2454
|
+
nudgeNode(nodeId, direction, distance) {
|
|
2455
|
+
const [node] = this.nudgeNodes([nodeId], direction, distance);
|
|
2456
|
+
return node || null;
|
|
2457
|
+
}
|
|
2458
|
+
// Move nodes together; repeated nudges of the same selection coalesce into
|
|
2459
|
+
// one undo step.
|
|
2460
|
+
nudgeNodes(nodeIds, direction, distance) {
|
|
2461
|
+
const step = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[direction];
|
|
2462
|
+
if (this.readonly || !step) return [];
|
|
2463
|
+
const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
|
|
2464
|
+
if (!nodes.length) return [];
|
|
2465
|
+
nodes.forEach((node) => {
|
|
2466
|
+
node.x = formatNumber(node.x + step[0] * distance);
|
|
2467
|
+
node.y = formatNumber(node.y + step[1] * distance);
|
|
2468
|
+
});
|
|
2469
|
+
this.render({ inspector: true, json: true });
|
|
2470
|
+
this.emitChange(
|
|
2471
|
+
"node:nudge",
|
|
2472
|
+
nodes.length > 1 ? { nodeIds: nodes.map((node) => node.id) } : { node: deepClone(nodes[0]) }
|
|
2473
|
+
);
|
|
2474
|
+
return nodes.map((node) => deepClone(node));
|
|
2475
|
+
}
|
|
2476
|
+
zoomTo(scale) {
|
|
2477
|
+
const viewport = this.documentData.viewport;
|
|
2478
|
+
const width = this.canvasEl.clientWidth || 800;
|
|
2479
|
+
const height = this.canvasEl.clientHeight || 560;
|
|
2480
|
+
this.scaleAround(
|
|
2481
|
+
clamp(scale, MIN_SCALE, MAX_SCALE) / viewport.scale,
|
|
2482
|
+
width / 2,
|
|
2483
|
+
height / 2,
|
|
2484
|
+
"viewport:zoom"
|
|
2485
|
+
);
|
|
2486
|
+
return this;
|
|
2487
|
+
}
|
|
2488
|
+
duplicateSelection() {
|
|
2489
|
+
if (this.readonly || this.selection?.kind !== "node") return null;
|
|
2490
|
+
if (this.hasMultiSelection()) return this.pasteNodeGroup(this.captureNodeGroup());
|
|
2491
|
+
const node = this.findNode(this.selection.id);
|
|
2492
|
+
return node ? this.pasteNodeData(node) : null;
|
|
2493
|
+
}
|
|
2494
|
+
// Selected nodes plus the connections that run between them.
|
|
2495
|
+
captureNodeGroup() {
|
|
2496
|
+
const ids = new Set(this.getSelectedNodeIds());
|
|
2497
|
+
return {
|
|
2498
|
+
nodes: deepClone(this.documentData.nodes.filter((node) => ids.has(node.id))),
|
|
2499
|
+
edges: deepClone(
|
|
2500
|
+
this.documentData.edges.filter(
|
|
2501
|
+
(edge) => ids.has(edge.from.nodeId) && ids.has(edge.to.nodeId)
|
|
2502
|
+
)
|
|
2503
|
+
)
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
// Paste a captured group offset by one step, remapping ids so internal
|
|
2507
|
+
// connections follow their copies. One change, one undo step.
|
|
2508
|
+
pasteNodeGroup(group) {
|
|
2509
|
+
if (this.readonly || !group?.nodes?.length) return [];
|
|
2510
|
+
const usedNodeIds = new Set(this.documentData.nodes.map((node) => node.id));
|
|
2511
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
2512
|
+
const nodes = group.nodes.map((data, index) => {
|
|
2513
|
+
const node = normalizeNode(
|
|
2514
|
+
{ ...data, id: void 0, x: data.x + 24, y: data.y + 24 },
|
|
2515
|
+
this.documentData.nodes.length + index,
|
|
2516
|
+
usedNodeIds
|
|
2517
|
+
);
|
|
2518
|
+
idMap.set(data.id, node.id);
|
|
2519
|
+
return node;
|
|
2520
|
+
});
|
|
2521
|
+
this.documentData.nodes.push(...nodes);
|
|
2522
|
+
const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
|
|
2523
|
+
const usedEdgeIds = new Set(this.documentData.edges.map((edge) => edge.id));
|
|
2524
|
+
group.edges.forEach((data, index) => {
|
|
2525
|
+
const edge = normalizeEdge(
|
|
2526
|
+
{
|
|
2527
|
+
...data,
|
|
2528
|
+
id: void 0,
|
|
2529
|
+
from: { ...data.from, nodeId: idMap.get(data.from.nodeId) },
|
|
2530
|
+
to: { ...data.to, nodeId: idMap.get(data.to.nodeId) }
|
|
2531
|
+
},
|
|
2532
|
+
this.documentData.edges.length + index,
|
|
2533
|
+
nodeIds,
|
|
2534
|
+
usedEdgeIds
|
|
2535
|
+
);
|
|
2536
|
+
if (edge) this.documentData.edges.push(edge);
|
|
2537
|
+
});
|
|
2538
|
+
const ids = nodes.map((node) => node.id);
|
|
2539
|
+
this.selectNodes(ids);
|
|
2540
|
+
this.emitChange("nodes:add", { nodeIds: ids });
|
|
2541
|
+
return nodes.map((node) => deepClone(node));
|
|
2542
|
+
}
|
|
2543
|
+
pasteNodeData(data) {
|
|
2544
|
+
const usedIds = new Set(this.documentData.nodes.map((node2) => node2.id));
|
|
2545
|
+
const node = normalizeNode(
|
|
2546
|
+
{ ...data, id: void 0, collapsed: void 0, x: data.x + 24, y: data.y + 24 },
|
|
2547
|
+
this.documentData.nodes.length,
|
|
2548
|
+
usedIds
|
|
2549
|
+
);
|
|
2550
|
+
this.documentData.nodes.push(node);
|
|
2551
|
+
this.select({ kind: "node", id: node.id });
|
|
2552
|
+
this.emitChange("node:add", { node: deepClone(node) });
|
|
2553
|
+
return deepClone(node);
|
|
2554
|
+
}
|
|
1625
2555
|
copySelection() {
|
|
1626
2556
|
if (!this.selection) return false;
|
|
2557
|
+
if (this.hasMultiSelection()) {
|
|
2558
|
+
this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
|
|
2559
|
+
return true;
|
|
2560
|
+
}
|
|
1627
2561
|
if (this.selection.kind === "node") {
|
|
1628
2562
|
const node = this.findNode(this.selection.id);
|
|
1629
2563
|
if (!node) return false;
|
|
@@ -1638,22 +2572,14 @@ var VdFlowchart = class {
|
|
|
1638
2572
|
pasteClipboard() {
|
|
1639
2573
|
if (!this.clipboard || this.readonly) return false;
|
|
1640
2574
|
if (this.clipboard.kind === "node") {
|
|
1641
|
-
|
|
1642
|
-
const node = normalizeNode(
|
|
1643
|
-
{
|
|
1644
|
-
...this.clipboard.data,
|
|
1645
|
-
id: void 0,
|
|
1646
|
-
x: this.clipboard.data.x + 24,
|
|
1647
|
-
y: this.clipboard.data.y + 24
|
|
1648
|
-
},
|
|
1649
|
-
this.documentData.nodes.length,
|
|
1650
|
-
usedIds2
|
|
1651
|
-
);
|
|
1652
|
-
this.documentData.nodes.push(node);
|
|
1653
|
-
this.select({ kind: "node", id: node.id });
|
|
1654
|
-
this.emitChange("node:add", { node: deepClone(node) });
|
|
2575
|
+
this.pasteNodeData(this.clipboard.data);
|
|
1655
2576
|
return true;
|
|
1656
2577
|
}
|
|
2578
|
+
if (this.clipboard.kind === "nodes") {
|
|
2579
|
+
const pasted = this.pasteNodeGroup(this.clipboard.data);
|
|
2580
|
+
this.clipboard = { kind: "nodes", data: this.captureNodeGroup() };
|
|
2581
|
+
return pasted.length > 0;
|
|
2582
|
+
}
|
|
1657
2583
|
const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
|
|
1658
2584
|
const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
|
|
1659
2585
|
const edge = normalizeEdge(
|
|
@@ -1680,16 +2606,16 @@ var VdFlowchart = class {
|
|
|
1680
2606
|
const edgeTarget = event.target.closest("[data-edge-id]");
|
|
1681
2607
|
if (edgeTarget && !event.target.closest("[data-edge-endpoint]")) {
|
|
1682
2608
|
const edgeId = edgeTarget.getAttribute("data-edge-id");
|
|
1683
|
-
if (edgeId && this.
|
|
1684
|
-
this.reconnectEdgeId = edgeId;
|
|
1685
|
-
this.select({ kind: "edge", id: edgeId });
|
|
2609
|
+
if (edgeId && this.startEdgeLabelEdit(edgeId)) {
|
|
1686
2610
|
event.preventDefault();
|
|
1687
2611
|
event.stopPropagation();
|
|
1688
2612
|
return;
|
|
1689
2613
|
}
|
|
1690
2614
|
}
|
|
1691
2615
|
const nodeTarget = event.target.closest("[data-node-id]");
|
|
1692
|
-
if (!nodeTarget || event.target.closest("[data-edge-id]"))
|
|
2616
|
+
if (!nodeTarget || event.target.closest("[data-edge-id]") || event.target.closest("[data-collapse-toggle]")) {
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
1693
2619
|
const nodeId = nodeTarget.getAttribute("data-node-id");
|
|
1694
2620
|
if (this.startTextEdit(nodeId)) {
|
|
1695
2621
|
event.preventDefault();
|
|
@@ -1699,7 +2625,7 @@ var VdFlowchart = class {
|
|
|
1699
2625
|
handleClick(event) {
|
|
1700
2626
|
if (this.readonly || event.detail < 2) return;
|
|
1701
2627
|
const nodeTarget = event.target.closest("[data-node-id]");
|
|
1702
|
-
if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]"))
|
|
2628
|
+
if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]") || event.target.closest("[data-collapse-toggle]"))
|
|
1703
2629
|
return;
|
|
1704
2630
|
if (this.startTextEdit(nodeTarget.getAttribute("data-node-id"))) {
|
|
1705
2631
|
event.preventDefault();
|
|
@@ -1712,6 +2638,13 @@ var VdFlowchart = class {
|
|
|
1712
2638
|
this.stopTextEdit({ commit: true });
|
|
1713
2639
|
}
|
|
1714
2640
|
this.canvasEl.focus();
|
|
2641
|
+
const collapseTarget = event.target.closest("[data-collapse-toggle]");
|
|
2642
|
+
if (collapseTarget) {
|
|
2643
|
+
this.lastNodePointer = null;
|
|
2644
|
+
this.toggleCollapsed(collapseTarget.getAttribute("data-collapse-toggle"));
|
|
2645
|
+
event.preventDefault();
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
1715
2648
|
const nodeTarget = event.target.closest("[data-node-id]");
|
|
1716
2649
|
const portTarget = event.target.closest("[data-port]");
|
|
1717
2650
|
const resizeTarget = event.target.closest("[data-resize-handle]");
|
|
@@ -1747,6 +2680,13 @@ var VdFlowchart = class {
|
|
|
1747
2680
|
event.preventDefault();
|
|
1748
2681
|
return;
|
|
1749
2682
|
}
|
|
2683
|
+
if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && event.shiftKey) {
|
|
2684
|
+
this.reconnectEdgeId = null;
|
|
2685
|
+
this.lastNodePointer = null;
|
|
2686
|
+
this.toggleNodeSelection(nodeTarget.getAttribute("data-node-id"));
|
|
2687
|
+
event.preventDefault();
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
1750
2690
|
if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && !this.readonly) {
|
|
1751
2691
|
if (this.activeTool === "arrow") {
|
|
1752
2692
|
this.select({ kind: "node", id: nodeTarget.getAttribute("data-node-id") });
|
|
@@ -1773,6 +2713,9 @@ var VdFlowchart = class {
|
|
|
1773
2713
|
this.interaction = {
|
|
1774
2714
|
kind: "connect",
|
|
1775
2715
|
pointerId: event.pointerId,
|
|
2716
|
+
startClientX: event.clientX,
|
|
2717
|
+
startClientY: event.clientY,
|
|
2718
|
+
moved: false,
|
|
1776
2719
|
source: { nodeId, port },
|
|
1777
2720
|
target: null,
|
|
1778
2721
|
previousSnap: null,
|
|
@@ -1823,22 +2766,27 @@ var VdFlowchart = class {
|
|
|
1823
2766
|
if (nodeTarget) {
|
|
1824
2767
|
this.reconnectEdgeId = null;
|
|
1825
2768
|
const nodeId = nodeTarget.getAttribute("data-node-id");
|
|
1826
|
-
this.
|
|
2769
|
+
const groupIds = this.hasMultiSelection() && this.isNodeSelected(nodeId) ? this.getSelectedNodeIds() : [nodeId];
|
|
2770
|
+
if (groupIds.length > 1) this.selectNodes(groupIds, { primary: nodeId });
|
|
2771
|
+
else this.select({ kind: "node", id: nodeId });
|
|
1827
2772
|
if (!this.readonly) {
|
|
1828
|
-
|
|
1829
|
-
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
1830
|
-
this.interaction = {
|
|
1831
|
-
kind: "drag-node",
|
|
1832
|
-
pointerId: event.pointerId,
|
|
1833
|
-
nodeId,
|
|
1834
|
-
offsetX: world.x - node.x,
|
|
1835
|
-
offsetY: world.y - node.y,
|
|
1836
|
-
moved: false
|
|
1837
|
-
};
|
|
2773
|
+
this.interaction = this.createDragInteraction(event, nodeId, groupIds);
|
|
1838
2774
|
this.capturePointer(event.pointerId);
|
|
1839
2775
|
}
|
|
1840
2776
|
return;
|
|
1841
2777
|
}
|
|
2778
|
+
if (event.shiftKey) {
|
|
2779
|
+
const startWorld = this.clientToWorld(event.clientX, event.clientY);
|
|
2780
|
+
this.interaction = {
|
|
2781
|
+
kind: "marquee",
|
|
2782
|
+
pointerId: event.pointerId,
|
|
2783
|
+
startWorld,
|
|
2784
|
+
baseIds: this.selection?.kind === "node" ? this.getSelectedNodeIds() : []
|
|
2785
|
+
};
|
|
2786
|
+
this.capturePointer(event.pointerId);
|
|
2787
|
+
event.preventDefault();
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
1842
2790
|
this.select(null);
|
|
1843
2791
|
this.reconnectEdgeId = null;
|
|
1844
2792
|
this.interaction = {
|
|
@@ -1853,20 +2801,123 @@ var VdFlowchart = class {
|
|
|
1853
2801
|
this.capturePointer(event.pointerId);
|
|
1854
2802
|
event.preventDefault();
|
|
1855
2803
|
}
|
|
2804
|
+
createDragInteraction(event, nodeId, nodeIds) {
|
|
2805
|
+
const nodes = nodeIds.map((id) => this.findNode(id)).filter(Boolean);
|
|
2806
|
+
const moving = new Set(nodes.map((node) => node.id));
|
|
2807
|
+
return {
|
|
2808
|
+
kind: "drag-node",
|
|
2809
|
+
pointerId: event.pointerId,
|
|
2810
|
+
nodeId,
|
|
2811
|
+
nodeIds: nodes.map((node) => node.id),
|
|
2812
|
+
startWorld: this.clientToWorld(event.clientX, event.clientY),
|
|
2813
|
+
origins: new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }])),
|
|
2814
|
+
bounds: getBounds(nodes),
|
|
2815
|
+
guideLines: this.snapGuides ? collectGuideLines(this.getVisibleNodes().filter((node) => !moving.has(node.id))) : null,
|
|
2816
|
+
snapDisabled: false,
|
|
2817
|
+
moved: false
|
|
2818
|
+
};
|
|
2819
|
+
}
|
|
2820
|
+
// Translate every dragged node by the pointer delta, snapping the group's
|
|
2821
|
+
// bounds to the nearest alignment guide on each axis. Alt turns snapping
|
|
2822
|
+
// off for the rest of the gesture.
|
|
2823
|
+
moveDraggedNodes(event) {
|
|
2824
|
+
const interaction = this.interaction;
|
|
2825
|
+
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
2826
|
+
let dx = world.x - interaction.startWorld.x;
|
|
2827
|
+
let dy = world.y - interaction.startWorld.y;
|
|
2828
|
+
if (event.altKey) interaction.snapDisabled = true;
|
|
2829
|
+
let guides = [];
|
|
2830
|
+
if (interaction.guideLines && !interaction.snapDisabled) {
|
|
2831
|
+
const box = interaction.bounds;
|
|
2832
|
+
const threshold = GUIDE_SNAP_DISTANCE / this.documentData.viewport.scale;
|
|
2833
|
+
const left = box.left + dx;
|
|
2834
|
+
const top = box.top + dy;
|
|
2835
|
+
const width = box.right - box.left;
|
|
2836
|
+
const height = box.bottom - box.top;
|
|
2837
|
+
const snapX = snapToGuides(
|
|
2838
|
+
interaction.guideLines.x,
|
|
2839
|
+
[left, left + width / 2, left + width],
|
|
2840
|
+
threshold
|
|
2841
|
+
);
|
|
2842
|
+
const snapY = snapToGuides(
|
|
2843
|
+
interaction.guideLines.y,
|
|
2844
|
+
[top, top + height / 2, top + height],
|
|
2845
|
+
threshold
|
|
2846
|
+
);
|
|
2847
|
+
if (snapX) dx += snapX.offset;
|
|
2848
|
+
if (snapY) dy += snapY.offset;
|
|
2849
|
+
const snapped = { left: box.left + dx, top: box.top + dy, width, height };
|
|
2850
|
+
if (snapX) guides = guides.concat(guideSegments("x", snapX.lines, snapped));
|
|
2851
|
+
if (snapY) guides = guides.concat(guideSegments("y", snapY.lines, snapped));
|
|
2852
|
+
}
|
|
2853
|
+
let changed = false;
|
|
2854
|
+
interaction.nodeIds.forEach((id) => {
|
|
2855
|
+
const node = this.findNode(id);
|
|
2856
|
+
const origin = interaction.origins.get(id);
|
|
2857
|
+
if (!node || !origin) return;
|
|
2858
|
+
const nextX = formatNumber(origin.x + dx);
|
|
2859
|
+
const nextY = formatNumber(origin.y + dy);
|
|
2860
|
+
if (nextX === node.x && nextY === node.y) return;
|
|
2861
|
+
node.x = nextX;
|
|
2862
|
+
node.y = nextY;
|
|
2863
|
+
changed = true;
|
|
2864
|
+
this.renderDraggedNode(node);
|
|
2865
|
+
});
|
|
2866
|
+
if (changed) interaction.moved = true;
|
|
2867
|
+
this.renderGuides(guides);
|
|
2868
|
+
}
|
|
2869
|
+
// Live marquee: select every visible node the box touches, added to the
|
|
2870
|
+
// selection the gesture started from.
|
|
2871
|
+
updateMarquee(event) {
|
|
2872
|
+
const interaction = this.interaction;
|
|
2873
|
+
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
2874
|
+
const box = {
|
|
2875
|
+
left: Math.min(interaction.startWorld.x, world.x),
|
|
2876
|
+
top: Math.min(interaction.startWorld.y, world.y),
|
|
2877
|
+
right: Math.max(interaction.startWorld.x, world.x),
|
|
2878
|
+
bottom: Math.max(interaction.startWorld.y, world.y)
|
|
2879
|
+
};
|
|
2880
|
+
clearChildren(this.guidesLayer);
|
|
2881
|
+
this.guidesLayer.appendChild(
|
|
2882
|
+
svgEl("rect", {
|
|
2883
|
+
class: "vd-flowchart-marquee",
|
|
2884
|
+
x: formatNumber(box.left),
|
|
2885
|
+
y: formatNumber(box.top),
|
|
2886
|
+
width: formatNumber(box.right - box.left),
|
|
2887
|
+
height: formatNumber(box.bottom - box.top)
|
|
2888
|
+
})
|
|
2889
|
+
);
|
|
2890
|
+
const hits = this.getVisibleNodes().filter(
|
|
2891
|
+
(node) => node.x <= box.right && node.x + node.width >= box.left && node.y <= box.bottom && node.y + node.height >= box.top
|
|
2892
|
+
).map((node) => node.id);
|
|
2893
|
+
const ids = [.../* @__PURE__ */ new Set([...interaction.baseIds, ...hits])];
|
|
2894
|
+
const key = ids.join(",");
|
|
2895
|
+
if (key === interaction.lastKey) return;
|
|
2896
|
+
interaction.lastKey = key;
|
|
2897
|
+
this.selectNodes(ids, { primary: hits[hits.length - 1] });
|
|
2898
|
+
}
|
|
2899
|
+
renderGuides(guides) {
|
|
2900
|
+
clearChildren(this.guidesLayer);
|
|
2901
|
+
guides.forEach((guide) => {
|
|
2902
|
+
this.guidesLayer.appendChild(
|
|
2903
|
+
svgEl("line", {
|
|
2904
|
+
class: "vd-flowchart-guide",
|
|
2905
|
+
x1: guide.x1,
|
|
2906
|
+
y1: guide.y1,
|
|
2907
|
+
x2: guide.x2,
|
|
2908
|
+
y2: guide.y2
|
|
2909
|
+
})
|
|
2910
|
+
);
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
1856
2913
|
handlePointerMove(event) {
|
|
1857
2914
|
if (!this.interaction || this.interaction.pointerId !== event.pointerId) return;
|
|
1858
2915
|
if (this.interaction.kind === "drag-node") {
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
if (nextX !== node.x || nextY !== node.y) {
|
|
1865
|
-
node.x = nextX;
|
|
1866
|
-
node.y = nextY;
|
|
1867
|
-
this.interaction.moved = true;
|
|
1868
|
-
this.render({ inspector: false, json: false });
|
|
1869
|
-
}
|
|
2916
|
+
this.moveDraggedNodes(event);
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
if (this.interaction.kind === "marquee") {
|
|
2920
|
+
this.updateMarquee(event);
|
|
1870
2921
|
return;
|
|
1871
2922
|
}
|
|
1872
2923
|
if (this.interaction.kind === "resize-node") {
|
|
@@ -1901,6 +2952,14 @@ var VdFlowchart = class {
|
|
|
1901
2952
|
return;
|
|
1902
2953
|
}
|
|
1903
2954
|
if (this.interaction.kind === "connect") {
|
|
2955
|
+
if (!this.interaction.moved) {
|
|
2956
|
+
const travel = Math.hypot(
|
|
2957
|
+
event.clientX - this.interaction.startClientX,
|
|
2958
|
+
event.clientY - this.interaction.startClientY
|
|
2959
|
+
);
|
|
2960
|
+
if (!(travel > PORT_CLICK_SLOP)) return;
|
|
2961
|
+
this.interaction.moved = true;
|
|
2962
|
+
}
|
|
1904
2963
|
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
1905
2964
|
const snapTarget = this.findConnectionTarget(
|
|
1906
2965
|
world,
|
|
@@ -1987,10 +3046,19 @@ var VdFlowchart = class {
|
|
|
1987
3046
|
}
|
|
1988
3047
|
return;
|
|
1989
3048
|
}
|
|
3049
|
+
if (interaction.kind === "marquee") {
|
|
3050
|
+
clearChildren(this.guidesLayer);
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
1990
3053
|
if (interaction.kind === "drag-node") {
|
|
3054
|
+
clearChildren(this.guidesLayer);
|
|
1991
3055
|
if (interaction.moved) {
|
|
1992
3056
|
this.render({ inspector: true, json: true });
|
|
1993
|
-
|
|
3057
|
+
if (interaction.nodeIds.length > 1) {
|
|
3058
|
+
this.emitChange("nodes:move", { nodeIds: [...interaction.nodeIds] });
|
|
3059
|
+
} else {
|
|
3060
|
+
this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
|
|
3061
|
+
}
|
|
1994
3062
|
} else {
|
|
1995
3063
|
this.render({ inspector: false, json: false });
|
|
1996
3064
|
}
|
|
@@ -2006,6 +3074,14 @@ var VdFlowchart = class {
|
|
|
2006
3074
|
return;
|
|
2007
3075
|
}
|
|
2008
3076
|
if (interaction.kind === "connect") {
|
|
3077
|
+
if (!interaction.moved && this.activeTool !== "arrow") {
|
|
3078
|
+
this.render({ inspector: false, json: false });
|
|
3079
|
+
this.insertBranchNode(interaction.source.nodeId, {
|
|
3080
|
+
direction: PORT_DIRECTIONS[interaction.source.port],
|
|
3081
|
+
edit: true
|
|
3082
|
+
});
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
2009
3085
|
const world = this.clientToWorld(event.clientX || 0, event.clientY || 0);
|
|
2010
3086
|
const snapTarget = interaction.target ? { node: this.findNode(interaction.target.nodeId), port: interaction.target.port } : this.findConnectionTarget(
|
|
2011
3087
|
world,
|
|
@@ -2081,7 +3157,7 @@ var VdFlowchart = class {
|
|
|
2081
3157
|
if (this.readonly) return false;
|
|
2082
3158
|
if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") return true;
|
|
2083
3159
|
if (this.activeTool === "arrow") return true;
|
|
2084
|
-
return this.selection?.kind === "node" && this.selection.id === node.id;
|
|
3160
|
+
return this.selection?.kind === "node" && this.selection.id === node.id && !this.hasMultiSelection();
|
|
2085
3161
|
}
|
|
2086
3162
|
getMarkerId(markerType, position, strokeWidth) {
|
|
2087
3163
|
const width = normalizeEdgeStrokeWidth(strokeWidth);
|
|
@@ -2104,7 +3180,7 @@ var VdFlowchart = class {
|
|
|
2104
3180
|
findConnectionTarget(worldPoint, excludeNodeId, previousSnap = null, referencePoint = null) {
|
|
2105
3181
|
const snapPadding = CONNECTION_SNAP_PADDING / this.documentData.viewport.scale;
|
|
2106
3182
|
const hysteresisMargin = CONNECTION_HYSTERESIS / this.documentData.viewport.scale;
|
|
2107
|
-
const best = this.
|
|
3183
|
+
const best = this.getVisibleNodes().reduce((candidate, node) => {
|
|
2108
3184
|
if (node.id === excludeNodeId) return candidate;
|
|
2109
3185
|
const distanceToBounds2 = getDistanceToNodeBounds(node, worldPoint);
|
|
2110
3186
|
if (distanceToBounds2 > snapPadding) return candidate;
|
|
@@ -2213,7 +3289,10 @@ var VdFlowchart = class {
|
|
|
2213
3289
|
updateToolbarLabel() {
|
|
2214
3290
|
this.zoomLabel.textContent = `${Math.round(this.documentData.viewport.scale * 100)}%`;
|
|
2215
3291
|
}
|
|
2216
|
-
|
|
3292
|
+
// Options: `initialText` replaces the label with typed text (type-to-edit);
|
|
3293
|
+
// `inserted` marks a node created by a keyboard/handle insertion so naming it
|
|
3294
|
+
// shares the insertion's undo step.
|
|
3295
|
+
startTextEdit(nodeId, options = {}) {
|
|
2217
3296
|
if (this.readonly) return false;
|
|
2218
3297
|
const node = this.findNode(nodeId);
|
|
2219
3298
|
if (!node || !isNodeTextEditable(node)) return false;
|
|
@@ -2225,9 +3304,10 @@ var VdFlowchart = class {
|
|
|
2225
3304
|
}
|
|
2226
3305
|
this.stopTextEdit({ commit: true });
|
|
2227
3306
|
this.select({ kind: "node", id: node.id });
|
|
3307
|
+
const replacing = typeof options.initialText === "string";
|
|
2228
3308
|
const textarea = createElement("textarea", {
|
|
2229
3309
|
className: `vd-flowchart-text-editor vd-flowchart-text-editor--${node.type}`,
|
|
2230
|
-
value: node.text,
|
|
3310
|
+
value: replacing ? options.initialText : node.text,
|
|
2231
3311
|
rows: 1
|
|
2232
3312
|
});
|
|
2233
3313
|
textarea.setAttribute("data-node-id", node.id);
|
|
@@ -2236,17 +3316,7 @@ var VdFlowchart = class {
|
|
|
2236
3316
|
textarea.addEventListener("input", () => this.positionTextEditor());
|
|
2237
3317
|
textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
2238
3318
|
textarea.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
2239
|
-
textarea.addEventListener("keydown", (event) =>
|
|
2240
|
-
if (event.key === "Escape") {
|
|
2241
|
-
event.preventDefault();
|
|
2242
|
-
this.stopTextEdit({ commit: false });
|
|
2243
|
-
return;
|
|
2244
|
-
}
|
|
2245
|
-
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
2246
|
-
event.preventDefault();
|
|
2247
|
-
this.stopTextEdit({ commit: true });
|
|
2248
|
-
}
|
|
2249
|
-
});
|
|
3319
|
+
textarea.addEventListener("keydown", (event) => this.handleTextEditorKeyDown(event, node));
|
|
2250
3320
|
textarea.addEventListener("blur", () => {
|
|
2251
3321
|
if (this.textEditor?.textarea === textarea) {
|
|
2252
3322
|
this.stopTextEdit({ commit: true });
|
|
@@ -2255,18 +3325,97 @@ var VdFlowchart = class {
|
|
|
2255
3325
|
this.textEditor = {
|
|
2256
3326
|
nodeId: node.id,
|
|
2257
3327
|
previousText: node.text,
|
|
3328
|
+
inserted: Boolean(options.inserted),
|
|
2258
3329
|
textarea
|
|
2259
3330
|
};
|
|
2260
3331
|
this.canvasEl.appendChild(textarea);
|
|
2261
3332
|
this.render({ inspector: true, json: false });
|
|
2262
|
-
|
|
2263
|
-
if (this.textEditor?.textarea
|
|
2264
|
-
|
|
3333
|
+
const focusEditor = () => {
|
|
3334
|
+
if (this.textEditor?.textarea !== textarea) return;
|
|
3335
|
+
textarea.focus();
|
|
3336
|
+
if (replacing) {
|
|
3337
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
3338
|
+
} else {
|
|
2265
3339
|
textarea.select();
|
|
2266
3340
|
}
|
|
3341
|
+
};
|
|
3342
|
+
focusEditor();
|
|
3343
|
+
window.requestAnimationFrame(() => {
|
|
3344
|
+
if (document.activeElement !== textarea) focusEditor();
|
|
3345
|
+
});
|
|
3346
|
+
return true;
|
|
3347
|
+
}
|
|
3348
|
+
// Inline label editor for a connection, placed on the label anchor
|
|
3349
|
+
// (curve midpoint). Labels render on one line, so Enter always saves.
|
|
3350
|
+
startEdgeLabelEdit(edgeId, options = {}) {
|
|
3351
|
+
if (this.readonly) return false;
|
|
3352
|
+
const edge = this.findEdge(sanitizeId(edgeId));
|
|
3353
|
+
if (!edge) return false;
|
|
3354
|
+
this.stopTextEdit({ commit: true });
|
|
3355
|
+
this.select({ kind: "edge", id: edge.id });
|
|
3356
|
+
const replacing = typeof options.initialText === "string";
|
|
3357
|
+
const textarea = createElement("textarea", {
|
|
3358
|
+
className: "vd-flowchart-text-editor vd-flowchart-text-editor--edge",
|
|
3359
|
+
value: replacing ? options.initialText : edge.label,
|
|
3360
|
+
rows: 1
|
|
3361
|
+
});
|
|
3362
|
+
textarea.setAttribute("data-edge-id", edge.id);
|
|
3363
|
+
textarea.setAttribute("aria-label", "Edit connection label");
|
|
3364
|
+
textarea.spellcheck = false;
|
|
3365
|
+
textarea.addEventListener("input", () => this.positionTextEditor());
|
|
3366
|
+
textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
3367
|
+
textarea.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
3368
|
+
textarea.addEventListener("keydown", (event) => {
|
|
3369
|
+
if (event.isComposing) return;
|
|
3370
|
+
if (event.key === "Escape" || event.key === "Enter") {
|
|
3371
|
+
event.preventDefault();
|
|
3372
|
+
this.stopTextEdit({ commit: event.key === "Enter" });
|
|
3373
|
+
this.canvasEl.focus();
|
|
3374
|
+
}
|
|
3375
|
+
});
|
|
3376
|
+
textarea.addEventListener("blur", () => {
|
|
3377
|
+
if (this.textEditor?.textarea === textarea) this.stopTextEdit({ commit: true });
|
|
3378
|
+
});
|
|
3379
|
+
this.textEditor = { nodeId: null, edgeId: edge.id, previousText: edge.label, textarea };
|
|
3380
|
+
this.canvasEl.appendChild(textarea);
|
|
3381
|
+
this.render({ inspector: true, json: false });
|
|
3382
|
+
const focusEditor = () => {
|
|
3383
|
+
if (this.textEditor?.textarea !== textarea) return;
|
|
3384
|
+
textarea.focus();
|
|
3385
|
+
if (replacing) textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
3386
|
+
else textarea.select();
|
|
3387
|
+
};
|
|
3388
|
+
focusEditor();
|
|
3389
|
+
window.requestAnimationFrame(() => {
|
|
3390
|
+
if (document.activeElement !== textarea) focusEditor();
|
|
2267
3391
|
});
|
|
2268
3392
|
return true;
|
|
2269
3393
|
}
|
|
3394
|
+
handleTextEditorKeyDown(event, node) {
|
|
3395
|
+
if (event.isComposing) return;
|
|
3396
|
+
const mindmap = this.keyboardShortcuts === "mindmap";
|
|
3397
|
+
if (event.key === "Escape") {
|
|
3398
|
+
event.preventDefault();
|
|
3399
|
+
this.stopTextEdit({ commit: false });
|
|
3400
|
+
this.canvasEl.focus();
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
if (event.key === "Enter") {
|
|
3404
|
+
const modKey = event.ctrlKey || event.metaKey;
|
|
3405
|
+
const commits = modKey || mindmap && node.type !== "textbox" && !event.shiftKey;
|
|
3406
|
+
if (!commits) return;
|
|
3407
|
+
event.preventDefault();
|
|
3408
|
+
this.stopTextEdit({ commit: true });
|
|
3409
|
+
this.canvasEl.focus();
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
if (event.key === "Tab" && mindmap && !event.shiftKey && !event.altKey && !event.ctrlKey) {
|
|
3413
|
+
event.preventDefault();
|
|
3414
|
+
this.stopTextEdit({ commit: true });
|
|
3415
|
+
this.canvasEl.focus();
|
|
3416
|
+
this.insertBranchNode(node.id, { edit: true });
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
2270
3419
|
stopTextEdit(options = {}) {
|
|
2271
3420
|
if (!this.textEditor) return false;
|
|
2272
3421
|
const commit = options.commit !== false;
|
|
@@ -2274,12 +3423,26 @@ var VdFlowchart = class {
|
|
|
2274
3423
|
const nextText = editor.textarea.value;
|
|
2275
3424
|
this.textEditor = null;
|
|
2276
3425
|
editor.textarea.remove();
|
|
3426
|
+
if (editor.edgeId) {
|
|
3427
|
+
const label = nextText.replace(/\s*\n\s*/g, " ").trim();
|
|
3428
|
+
if (commit && !this.readonly && label !== editor.previousText) {
|
|
3429
|
+
this.updateEdge(editor.edgeId, { label }, { inspector: true, reason: "edge:update" });
|
|
3430
|
+
} else {
|
|
3431
|
+
this.render({ scene: true, inspector: false, json: false });
|
|
3432
|
+
}
|
|
3433
|
+
return true;
|
|
3434
|
+
}
|
|
2277
3435
|
if (commit && !this.readonly && nextText !== editor.previousText) {
|
|
2278
|
-
this.
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
3436
|
+
this.coalesceInsertedText = editor.inserted;
|
|
3437
|
+
try {
|
|
3438
|
+
this.updateNode(
|
|
3439
|
+
editor.nodeId,
|
|
3440
|
+
{ text: nextText },
|
|
3441
|
+
{ inspector: true, reason: "node:update" }
|
|
3442
|
+
);
|
|
3443
|
+
} finally {
|
|
3444
|
+
this.coalesceInsertedText = false;
|
|
3445
|
+
}
|
|
2283
3446
|
} else {
|
|
2284
3447
|
this.render({ scene: true, inspector: false, json: false });
|
|
2285
3448
|
}
|
|
@@ -2287,6 +3450,10 @@ var VdFlowchart = class {
|
|
|
2287
3450
|
}
|
|
2288
3451
|
positionTextEditor() {
|
|
2289
3452
|
if (!this.textEditor) return;
|
|
3453
|
+
if (this.textEditor.edgeId) {
|
|
3454
|
+
this.positionEdgeLabelEditor();
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
2290
3457
|
const node = this.findNode(this.textEditor.nodeId);
|
|
2291
3458
|
if (!node) {
|
|
2292
3459
|
this.stopTextEdit({ commit: false });
|
|
@@ -2310,6 +3477,28 @@ var VdFlowchart = class {
|
|
|
2310
3477
|
textarea.style.lineHeight = `${formatNumber(lineHeight)}px`;
|
|
2311
3478
|
textarea.style.padding = `${formatNumber(verticalPadding)}px ${formatNumber(horizontalPadding)}px`;
|
|
2312
3479
|
}
|
|
3480
|
+
positionEdgeLabelEditor() {
|
|
3481
|
+
const edge = this.findEdge(this.textEditor.edgeId);
|
|
3482
|
+
const fromNode = edge && this.findNode(edge.from.nodeId);
|
|
3483
|
+
const toNode = edge && this.findNode(edge.to.nodeId);
|
|
3484
|
+
if (!edge || !fromNode || !toNode) {
|
|
3485
|
+
this.stopTextEdit({ commit: false });
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
const { labelX, labelY } = buildEdgePath(edge, fromNode, toNode);
|
|
3489
|
+
const viewport = this.documentData.viewport;
|
|
3490
|
+
const textarea = this.textEditor.textarea;
|
|
3491
|
+
const fontSize = Math.max(11, 13 * viewport.scale);
|
|
3492
|
+
const width = clamp(textarea.value.length * fontSize * 0.62 + 28, 96, 280);
|
|
3493
|
+
const height = fontSize * 1.4 + 12;
|
|
3494
|
+
textarea.style.left = `${formatNumber(viewport.x + labelX * viewport.scale - width / 2)}px`;
|
|
3495
|
+
textarea.style.top = `${formatNumber(viewport.y + (labelY - 8) * viewport.scale - height / 2)}px`;
|
|
3496
|
+
textarea.style.width = `${formatNumber(width)}px`;
|
|
3497
|
+
textarea.style.height = `${formatNumber(height)}px`;
|
|
3498
|
+
textarea.style.fontSize = `${formatNumber(fontSize)}px`;
|
|
3499
|
+
textarea.style.lineHeight = `${formatNumber(fontSize * 1.4)}px`;
|
|
3500
|
+
textarea.style.padding = "6px 10px";
|
|
3501
|
+
}
|
|
2313
3502
|
render(options = {}) {
|
|
2314
3503
|
if (this.destroyed) return;
|
|
2315
3504
|
const shouldRenderScene = options.scene !== false;
|
|
@@ -2318,11 +3507,14 @@ var VdFlowchart = class {
|
|
|
2318
3507
|
if (shouldRenderScene) this.renderScene();
|
|
2319
3508
|
if (shouldRenderInspector) this.renderSelectionPanel();
|
|
2320
3509
|
if (shouldRenderJson) this.syncJsonTextarea();
|
|
3510
|
+
if (shouldRenderInspector) this.renderGraphOutline();
|
|
2321
3511
|
this.updateToolbarLabel();
|
|
2322
3512
|
this.updatePaletteState();
|
|
2323
3513
|
this.positionTextEditor();
|
|
3514
|
+
this.scheduleMinimap();
|
|
2324
3515
|
}
|
|
2325
3516
|
renderScene() {
|
|
3517
|
+
this.syncSelectedNodeIds();
|
|
2326
3518
|
const viewport = this.documentData.viewport;
|
|
2327
3519
|
this.world.setAttribute(
|
|
2328
3520
|
"transform",
|
|
@@ -2332,16 +3524,35 @@ var VdFlowchart = class {
|
|
|
2332
3524
|
clearChildren(this.previewLayer);
|
|
2333
3525
|
clearChildren(this.nodesLayer);
|
|
2334
3526
|
clearChildren(this.overlayLayer);
|
|
2335
|
-
const
|
|
3527
|
+
const hidden = this.getHiddenNodeIds();
|
|
3528
|
+
this.sceneHidden = hidden;
|
|
3529
|
+
this.sceneOutgoing = this.getOutgoingMap();
|
|
3530
|
+
const nodeMap = new Map(
|
|
3531
|
+
this.documentData.nodes.filter((node) => !hidden.has(node.id)).map((node) => [node.id, node])
|
|
3532
|
+
);
|
|
3533
|
+
this.sceneNodeMap = nodeMap;
|
|
3534
|
+
this.nodeElements = /* @__PURE__ */ new Map();
|
|
3535
|
+
this.edgeElements = /* @__PURE__ */ new Map();
|
|
3536
|
+
this.incidentEdges = /* @__PURE__ */ new Map();
|
|
2336
3537
|
this.documentData.edges.forEach((edge) => {
|
|
2337
3538
|
const edgeElement = this.renderEdge(edge, nodeMap);
|
|
2338
|
-
if (edgeElement)
|
|
3539
|
+
if (edgeElement) {
|
|
3540
|
+
this.edgesLayer.appendChild(edgeElement);
|
|
3541
|
+
this.edgeElements.set(edge.id, edgeElement);
|
|
3542
|
+
for (const id of /* @__PURE__ */ new Set([edge.from.nodeId, edge.to.nodeId])) {
|
|
3543
|
+
if (!this.incidentEdges.has(id)) this.incidentEdges.set(id, []);
|
|
3544
|
+
this.incidentEdges.get(id).push(edge);
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
2339
3547
|
});
|
|
2340
3548
|
if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") {
|
|
2341
3549
|
this.previewLayer.appendChild(this.renderPreviewEdge(this.interaction));
|
|
2342
3550
|
}
|
|
2343
3551
|
this.documentData.nodes.forEach((node) => {
|
|
2344
|
-
|
|
3552
|
+
if (hidden.has(node.id)) return;
|
|
3553
|
+
const element = this.renderNode(node);
|
|
3554
|
+
this.nodesLayer.appendChild(element);
|
|
3555
|
+
this.nodeElements.set(node.id, element);
|
|
2345
3556
|
});
|
|
2346
3557
|
if (this.reconnectEdgeId && !this.readonly) {
|
|
2347
3558
|
const reconnectEdge = this.findEdge(this.reconnectEdgeId);
|
|
@@ -2357,6 +3568,25 @@ var VdFlowchart = class {
|
|
|
2357
3568
|
}
|
|
2358
3569
|
this.syncConnectingState();
|
|
2359
3570
|
}
|
|
3571
|
+
// Drag frames change one translation and its connected paths. Keep unrelated
|
|
3572
|
+
// SVG elements intact; the release event still refreshes inspector/history.
|
|
3573
|
+
renderDraggedNode(node) {
|
|
3574
|
+
const element = this.nodeElements?.get(node.id);
|
|
3575
|
+
if (!element) {
|
|
3576
|
+
this.render({ inspector: false, json: false });
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
element.setAttribute("transform", `translate(${formatNumber(node.x)} ${formatNumber(node.y)})`);
|
|
3580
|
+
element.classList.add("is-dragging");
|
|
3581
|
+
for (const edge of this.incidentEdges.get(node.id) || []) {
|
|
3582
|
+
const previous = this.edgeElements.get(edge.id);
|
|
3583
|
+
const next = this.renderEdge(edge, this.sceneNodeMap);
|
|
3584
|
+
if (previous && next) {
|
|
3585
|
+
previous.replaceWith(next);
|
|
3586
|
+
this.edgeElements.set(edge.id, next);
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
2360
3590
|
applyEdgeMarkers(pathElement, edge) {
|
|
2361
3591
|
if (edge.startMarker && edge.startMarker !== "none") {
|
|
2362
3592
|
pathElement.setAttribute(
|
|
@@ -2407,7 +3637,7 @@ var VdFlowchart = class {
|
|
|
2407
3637
|
});
|
|
2408
3638
|
group.appendChild(visiblePath);
|
|
2409
3639
|
group.appendChild(hitPath);
|
|
2410
|
-
if (edge.label) {
|
|
3640
|
+
if (edge.label && this.textEditor?.edgeId !== edge.id) {
|
|
2411
3641
|
const label = svgEl("text", {
|
|
2412
3642
|
class: "vd-flowchart-edge-label",
|
|
2413
3643
|
x: edgePath.labelX,
|
|
@@ -2468,8 +3698,9 @@ var VdFlowchart = class {
|
|
|
2468
3698
|
});
|
|
2469
3699
|
}
|
|
2470
3700
|
renderNode(node) {
|
|
2471
|
-
const selected = this.
|
|
2472
|
-
const
|
|
3701
|
+
const selected = this.isNodeSelected(node.id);
|
|
3702
|
+
const soloSelected = selected && !this.hasMultiSelection();
|
|
3703
|
+
const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeIds.includes(node.id);
|
|
2473
3704
|
const resizing = this.interaction?.kind === "resize-node" && this.interaction.nodeId === node.id;
|
|
2474
3705
|
const editing = this.textEditor?.nodeId === node.id;
|
|
2475
3706
|
const group = svgEl("g", {
|
|
@@ -2490,12 +3721,29 @@ var VdFlowchart = class {
|
|
|
2490
3721
|
group.appendChild(hitbox);
|
|
2491
3722
|
group.appendChild(this.renderNodeShape(node));
|
|
2492
3723
|
group.appendChild(this.renderNodeText(node));
|
|
2493
|
-
|
|
3724
|
+
const scale = this.documentData.viewport.scale || 1;
|
|
3725
|
+
if (soloSelected && !this.readonly && SELECTION_BOX_TYPES.has(node.type)) {
|
|
3726
|
+
const outset = SELECTION_OUTSET / scale;
|
|
3727
|
+
group.appendChild(
|
|
3728
|
+
svgEl("rect", {
|
|
3729
|
+
class: "vd-flowchart-selection-box",
|
|
3730
|
+
x: -outset,
|
|
3731
|
+
y: -outset,
|
|
3732
|
+
width: node.width + outset * 2,
|
|
3733
|
+
height: node.height + outset * 2
|
|
3734
|
+
})
|
|
3735
|
+
);
|
|
3736
|
+
}
|
|
3737
|
+
if (soloSelected && !this.readonly && isNodeResizable(node)) {
|
|
2494
3738
|
group.appendChild(this.renderResizeControls(node));
|
|
2495
3739
|
}
|
|
2496
|
-
const
|
|
3740
|
+
const collapseToggle = this.renderCollapseToggle(node, soloSelected);
|
|
3741
|
+
if (this.readonly) {
|
|
3742
|
+
if (collapseToggle) group.appendChild(collapseToggle);
|
|
3743
|
+
return group;
|
|
3744
|
+
}
|
|
2497
3745
|
FLOWCHART_PORTS.forEach((port) => {
|
|
2498
|
-
const position =
|
|
3746
|
+
const position = getPortHandlePosition({ ...node, x: 0, y: 0 }, port, scale);
|
|
2499
3747
|
const portGroup = svgEl("g", {
|
|
2500
3748
|
class: `vd-flowchart-port-group${portsVisible ? " is-visible" : ""}`,
|
|
2501
3749
|
"data-node-id": node.id,
|
|
@@ -2517,15 +3765,68 @@ var VdFlowchart = class {
|
|
|
2517
3765
|
r: CONNECTION_PORT_RADIUS / scale
|
|
2518
3766
|
})
|
|
2519
3767
|
);
|
|
3768
|
+
const arm = (CONNECTION_PORT_RADIUS - 2.5) / scale;
|
|
3769
|
+
portGroup.appendChild(
|
|
3770
|
+
svgEl("path", {
|
|
3771
|
+
class: "vd-flowchart-port-plus",
|
|
3772
|
+
d: `M ${formatNumber(position.x - arm)} ${formatNumber(position.y)} H ${formatNumber(position.x + arm)} M ${formatNumber(position.x)} ${formatNumber(position.y - arm)} V ${formatNumber(position.y + arm)}`
|
|
3773
|
+
})
|
|
3774
|
+
);
|
|
2520
3775
|
group.appendChild(portGroup);
|
|
2521
3776
|
});
|
|
3777
|
+
if (collapseToggle) group.appendChild(collapseToggle);
|
|
3778
|
+
return group;
|
|
3779
|
+
}
|
|
3780
|
+
// "+N" on a collapsed node, or "−" on a single selected node with children,
|
|
3781
|
+
// placed beyond the connection handle on the side its branch leaves from.
|
|
3782
|
+
renderCollapseToggle(node, soloSelected) {
|
|
3783
|
+
const outgoing = this.sceneOutgoing || this.getOutgoingMap();
|
|
3784
|
+
const childIds = (outgoing.get(node.id) || []).filter((id) => id !== node.id);
|
|
3785
|
+
if (!childIds.length) return null;
|
|
3786
|
+
const count = node.collapsed ? this.countCollapsedDescendants(
|
|
3787
|
+
node.id,
|
|
3788
|
+
this.sceneHidden || this.getHiddenNodeIds(),
|
|
3789
|
+
outgoing
|
|
3790
|
+
) : 0;
|
|
3791
|
+
if (!node.collapsed && !(soloSelected && !this.readonly)) return null;
|
|
3792
|
+
const sides = /* @__PURE__ */ new Map();
|
|
3793
|
+
this.documentData.edges.forEach((edge) => {
|
|
3794
|
+
if (edge.from.nodeId !== node.id || edge.to.nodeId === node.id) return;
|
|
3795
|
+
sides.set(edge.from.port, (sides.get(edge.from.port) || 0) + 1);
|
|
3796
|
+
});
|
|
3797
|
+
const port = [...sides].sort((a, b) => b[1] - a[1])[0]?.[0] || DIRECTION_PORTS[this.getGrowthDirection(node)];
|
|
3798
|
+
const scale = this.documentData.viewport.scale || 1;
|
|
3799
|
+
const anchor = getPortPosition({ ...node, x: 0, y: 0 }, port);
|
|
3800
|
+
const normal = getPortNormal(port);
|
|
3801
|
+
const offset = COLLAPSE_TOGGLE_OFFSET / scale;
|
|
3802
|
+
const cx = formatNumber(anchor.x + normal.x * offset);
|
|
3803
|
+
const cy = formatNumber(anchor.y + normal.y * offset);
|
|
3804
|
+
const label = node.collapsed ? `+${count}` : "\u2212";
|
|
3805
|
+
const radius = (node.collapsed && label.length > 2 ? 12 : 9) / scale;
|
|
3806
|
+
const group = svgEl("g", {
|
|
3807
|
+
class: `vd-flowchart-collapse-toggle${node.collapsed ? " is-collapsed" : ""}`,
|
|
3808
|
+
"data-collapse-toggle": node.id
|
|
3809
|
+
});
|
|
3810
|
+
const title = svgEl("title");
|
|
3811
|
+
title.textContent = node.collapsed ? `Expand ${count} hidden nodes` : "Collapse branch";
|
|
3812
|
+
group.appendChild(title);
|
|
3813
|
+
group.appendChild(svgEl("circle", { cx, cy, r: formatNumber(radius) }));
|
|
3814
|
+
const text = svgEl("text", {
|
|
3815
|
+
x: cx,
|
|
3816
|
+
y: cy,
|
|
3817
|
+
"font-size": formatNumber(10 / scale),
|
|
3818
|
+
"text-anchor": "middle",
|
|
3819
|
+
"dominant-baseline": "central"
|
|
3820
|
+
});
|
|
3821
|
+
text.textContent = label;
|
|
3822
|
+
group.appendChild(text);
|
|
2522
3823
|
return group;
|
|
2523
3824
|
}
|
|
2524
3825
|
renderResizeControls(node) {
|
|
2525
3826
|
const scale = this.documentData.viewport.scale || 1;
|
|
2526
3827
|
const zone = 14 / scale;
|
|
2527
3828
|
const cornerZone = 22 / scale;
|
|
2528
|
-
const handleSize =
|
|
3829
|
+
const handleSize = RESIZE_HANDLE_SIZE / scale;
|
|
2529
3830
|
const gap = RESIZE_PORT_GAP / scale;
|
|
2530
3831
|
const group = svgEl("g", { class: "vd-flowchart-resize-controls" });
|
|
2531
3832
|
const zones = {
|
|
@@ -2555,12 +3856,6 @@ var VdFlowchart = class {
|
|
|
2555
3856
|
};
|
|
2556
3857
|
RESIZE_HANDLES.forEach((handle) => {
|
|
2557
3858
|
const zoneRect = zones[handle];
|
|
2558
|
-
const position = getResizeHandlePosition(node, handle);
|
|
2559
|
-
const dotOffset = 8 / scale;
|
|
2560
|
-
if (handle.includes("e")) position.x += dotOffset;
|
|
2561
|
-
if (handle.includes("w")) position.x -= dotOffset;
|
|
2562
|
-
if (handle.includes("n")) position.y -= dotOffset;
|
|
2563
|
-
if (handle.includes("s")) position.y += dotOffset;
|
|
2564
3859
|
const cursor = getResizeCursor(handle);
|
|
2565
3860
|
const zoneSegments = [];
|
|
2566
3861
|
if (handle === "e" || handle === "w") {
|
|
@@ -2595,14 +3890,19 @@ var VdFlowchart = class {
|
|
|
2595
3890
|
});
|
|
2596
3891
|
group.appendChild(hit);
|
|
2597
3892
|
});
|
|
3893
|
+
if (handle.length === 1) return;
|
|
3894
|
+
const position = getResizeHandlePosition(node, handle);
|
|
3895
|
+
const outset = SELECTION_OUTSET / scale;
|
|
3896
|
+
position.x += handle.includes("e") ? outset : -outset;
|
|
3897
|
+
position.y += handle.includes("s") ? outset : -outset;
|
|
2598
3898
|
const dot = svgEl("rect", {
|
|
2599
3899
|
class: "vd-flowchart-resize-handle",
|
|
2600
3900
|
x: position.x - handleSize / 2,
|
|
2601
3901
|
y: position.y - handleSize / 2,
|
|
2602
3902
|
width: handleSize,
|
|
2603
3903
|
height: handleSize,
|
|
2604
|
-
rx: handleSize /
|
|
2605
|
-
ry: handleSize /
|
|
3904
|
+
rx: handleSize / 5,
|
|
3905
|
+
ry: handleSize / 5,
|
|
2606
3906
|
"data-node-id": node.id,
|
|
2607
3907
|
"data-resize-handle": handle,
|
|
2608
3908
|
style: `cursor: ${cursor}`
|
|
@@ -2731,6 +4031,16 @@ var VdFlowchart = class {
|
|
|
2731
4031
|
);
|
|
2732
4032
|
return;
|
|
2733
4033
|
}
|
|
4034
|
+
if (this.hasMultiSelection()) {
|
|
4035
|
+
this.selectionMeta.textContent = `${this.selectedNodeIds.size} nodes selected`;
|
|
4036
|
+
this.selectionFields.appendChild(
|
|
4037
|
+
createElement("p", {
|
|
4038
|
+
className: "vd-flowchart-selection-empty",
|
|
4039
|
+
text: "Drag, nudge with Alt+Arrow, copy, duplicate, or delete them together."
|
|
4040
|
+
})
|
|
4041
|
+
);
|
|
4042
|
+
return;
|
|
4043
|
+
}
|
|
2734
4044
|
if (this.selection.kind === "node") {
|
|
2735
4045
|
const node = this.findNode(this.selection.id);
|
|
2736
4046
|
if (!node) {
|
|
@@ -2845,16 +4155,69 @@ var VdFlowchart = class {
|
|
|
2845
4155
|
}
|
|
2846
4156
|
select(selection) {
|
|
2847
4157
|
const next = selection && selection.id && selection.kind ? { kind: selection.kind, id: selection.id } : null;
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
4158
|
+
this.applySelection(next, next?.kind === "node" ? [next.id] : []);
|
|
4159
|
+
}
|
|
4160
|
+
// Single funnel for selection changes: `selection` is the primary item and
|
|
4161
|
+
// `selectedNodeIds` every selected node (the primary one included).
|
|
4162
|
+
applySelection(primary, nodeIds) {
|
|
4163
|
+
const previousKey = this.getSelectionKey();
|
|
4164
|
+
this.selection = primary;
|
|
4165
|
+
this.selectedNodeIds = new Set(nodeIds);
|
|
4166
|
+
if (primary?.kind === "node") this.selectedNodeIds.add(primary.id);
|
|
4167
|
+
if (previousKey !== this.getSelectionKey()) {
|
|
4168
|
+
this.edgeCycleAnchor = null;
|
|
2852
4169
|
this.render({ scene: true, inspector: true, json: false });
|
|
2853
|
-
this.emit("select", {
|
|
4170
|
+
this.emit("select", {
|
|
4171
|
+
selection: this.getSelectionSnapshot(),
|
|
4172
|
+
nodeIds: this.getSelectedNodeIds()
|
|
4173
|
+
});
|
|
2854
4174
|
return;
|
|
2855
4175
|
}
|
|
2856
4176
|
this.render({ scene: true, inspector: false, json: false });
|
|
2857
4177
|
}
|
|
4178
|
+
getSelectionKey() {
|
|
4179
|
+
const primary = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
|
|
4180
|
+
return `${primary}|${[...this.selectedNodeIds || []].sort().join(",")}`;
|
|
4181
|
+
}
|
|
4182
|
+
// Keep the set valid after mutations that bypass applySelection (undo,
|
|
4183
|
+
// load, removal): drop missing nodes and keep the primary node in the set.
|
|
4184
|
+
syncSelectedNodeIds() {
|
|
4185
|
+
if (this.selection?.kind !== "node") {
|
|
4186
|
+
this.selectedNodeIds = /* @__PURE__ */ new Set();
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
const existing = new Set(this.documentData.nodes.map((node) => node.id));
|
|
4190
|
+
const hidden = this.getHiddenNodeIds();
|
|
4191
|
+
this.selectedNodeIds = new Set(
|
|
4192
|
+
[...this.selectedNodeIds || []].filter((id) => existing.has(id) && !hidden.has(id))
|
|
4193
|
+
);
|
|
4194
|
+
this.selectedNodeIds.add(this.selection.id);
|
|
4195
|
+
}
|
|
4196
|
+
getSelectedNodeIds() {
|
|
4197
|
+
return [...this.selectedNodeIds || []];
|
|
4198
|
+
}
|
|
4199
|
+
isNodeSelected(nodeId) {
|
|
4200
|
+
return Boolean(this.selectedNodeIds?.has(nodeId));
|
|
4201
|
+
}
|
|
4202
|
+
hasMultiSelection() {
|
|
4203
|
+
return (this.selectedNodeIds?.size || 0) > 1;
|
|
4204
|
+
}
|
|
4205
|
+
/** Select several nodes; `primary` (default: the last id) drives single-node UI. */
|
|
4206
|
+
selectNodes(nodeIds, options = {}) {
|
|
4207
|
+
const existing = new Set(this.documentData.nodes.map((node) => node.id));
|
|
4208
|
+
const ids = [...new Set((nodeIds || []).map(sanitizeId))].filter((id) => existing.has(id));
|
|
4209
|
+
const primaryId = ids.includes(options.primary) ? options.primary : ids[ids.length - 1];
|
|
4210
|
+
this.applySelection(primaryId ? { kind: "node", id: primaryId } : null, ids);
|
|
4211
|
+
return this;
|
|
4212
|
+
}
|
|
4213
|
+
toggleNodeSelection(nodeId) {
|
|
4214
|
+
const ids = this.selection?.kind === "node" ? this.getSelectedNodeIds() : [];
|
|
4215
|
+
if (ids.includes(nodeId)) {
|
|
4216
|
+
const rest = ids.filter((id) => id !== nodeId);
|
|
4217
|
+
return this.selectNodes(rest, { primary: rest[rest.length - 1] });
|
|
4218
|
+
}
|
|
4219
|
+
return this.selectNodes([...ids, nodeId], { primary: nodeId });
|
|
4220
|
+
}
|
|
2858
4221
|
selectNode(nodeId) {
|
|
2859
4222
|
this.select({ kind: "node", id: sanitizeId(nodeId) });
|
|
2860
4223
|
return this;
|
|
@@ -2875,6 +4238,69 @@ var VdFlowchart = class {
|
|
|
2875
4238
|
findNode(nodeId) {
|
|
2876
4239
|
return this.documentData.nodes.find((node) => node.id === nodeId) || null;
|
|
2877
4240
|
}
|
|
4241
|
+
getHiddenNodeIds() {
|
|
4242
|
+
return computeHiddenNodeIds(this.documentData.nodes, this.documentData.edges);
|
|
4243
|
+
}
|
|
4244
|
+
getVisibleNodes() {
|
|
4245
|
+
const hidden = this.getHiddenNodeIds();
|
|
4246
|
+
return hidden.size ? this.documentData.nodes.filter((node) => !hidden.has(node.id)) : this.documentData.nodes;
|
|
4247
|
+
}
|
|
4248
|
+
isNodeHidden(nodeId) {
|
|
4249
|
+
return this.getHiddenNodeIds().has(nodeId);
|
|
4250
|
+
}
|
|
4251
|
+
getOutgoingMap() {
|
|
4252
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
4253
|
+
this.documentData.edges.forEach((edge) => {
|
|
4254
|
+
if (!outgoing.has(edge.from.nodeId)) outgoing.set(edge.from.nodeId, []);
|
|
4255
|
+
outgoing.get(edge.from.nodeId).push(edge.to.nodeId);
|
|
4256
|
+
});
|
|
4257
|
+
return outgoing;
|
|
4258
|
+
}
|
|
4259
|
+
// Hidden nodes under one collapsed node (its "+N" count).
|
|
4260
|
+
countCollapsedDescendants(nodeId, hidden = this.getHiddenNodeIds(), outgoing = this.getOutgoingMap()) {
|
|
4261
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4262
|
+
const queue = [nodeId];
|
|
4263
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
4264
|
+
(outgoing.get(queue[index]) || []).forEach((childId) => {
|
|
4265
|
+
if (seen.has(childId) || !hidden.has(childId)) return;
|
|
4266
|
+
seen.add(childId);
|
|
4267
|
+
queue.push(childId);
|
|
4268
|
+
});
|
|
4269
|
+
}
|
|
4270
|
+
return seen.size;
|
|
4271
|
+
}
|
|
4272
|
+
setCollapsed(nodeId, collapsed) {
|
|
4273
|
+
if (this.readonly) return false;
|
|
4274
|
+
const node = this.findNode(sanitizeId(nodeId));
|
|
4275
|
+
if (!node) return false;
|
|
4276
|
+
const next = Boolean(collapsed);
|
|
4277
|
+
if (Boolean(node.collapsed) === next) return false;
|
|
4278
|
+
if (next && !this.getChildNodes(node.id).length) return false;
|
|
4279
|
+
if (next) node.collapsed = true;
|
|
4280
|
+
else delete node.collapsed;
|
|
4281
|
+
if (this.selection?.kind === "node") {
|
|
4282
|
+
const hidden = this.getHiddenNodeIds();
|
|
4283
|
+
const visibleIds = this.getSelectedNodeIds().filter((id) => !hidden.has(id));
|
|
4284
|
+
if (hidden.has(this.selection.id)) {
|
|
4285
|
+
this.select({ kind: "node", id: node.id });
|
|
4286
|
+
} else if (visibleIds.length !== this.selectedNodeIds.size) {
|
|
4287
|
+
this.selectNodes(visibleIds, { primary: this.selection.id });
|
|
4288
|
+
}
|
|
4289
|
+
} else if (this.selection?.kind === "edge") {
|
|
4290
|
+
const edge = this.findEdge(this.selection.id);
|
|
4291
|
+
const hidden = this.getHiddenNodeIds();
|
|
4292
|
+
if (edge && (hidden.has(edge.from.nodeId) || hidden.has(edge.to.nodeId))) {
|
|
4293
|
+
this.select({ kind: "node", id: node.id });
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
this.render({ inspector: true, json: true });
|
|
4297
|
+
this.emitChange("node:collapse", { node: deepClone(node) });
|
|
4298
|
+
return true;
|
|
4299
|
+
}
|
|
4300
|
+
toggleCollapsed(nodeId) {
|
|
4301
|
+
const node = this.findNode(sanitizeId(nodeId));
|
|
4302
|
+
return node ? this.setCollapsed(node.id, !node.collapsed) : false;
|
|
4303
|
+
}
|
|
2878
4304
|
findEdge(edgeId) {
|
|
2879
4305
|
return this.documentData.edges.find((edge) => edge.id === edgeId) || null;
|
|
2880
4306
|
}
|
|
@@ -2885,7 +4311,8 @@ var VdFlowchart = class {
|
|
|
2885
4311
|
}
|
|
2886
4312
|
emitChange(reason, extra = {}) {
|
|
2887
4313
|
if (this.historyEnabled && !this.isApplyingHistory) {
|
|
2888
|
-
this.
|
|
4314
|
+
if (this.historyBatchDepth > 0) this.historyBatchDirty = true;
|
|
4315
|
+
else this.recordHistory(reason);
|
|
2889
4316
|
}
|
|
2890
4317
|
this.syncJsonTextarea();
|
|
2891
4318
|
this.emit("change", {
|
|
@@ -2929,7 +4356,9 @@ var VdFlowchart = class {
|
|
|
2929
4356
|
const snapshot = this.toJSON();
|
|
2930
4357
|
const targetKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
|
|
2931
4358
|
const top = this.history[this.historyIndex];
|
|
2932
|
-
const
|
|
4359
|
+
const atTip = Boolean(top) && this.historyIndex === this.history.length - 1;
|
|
4360
|
+
const namesInsertedNode = this.coalesceInsertedText && top?.reason === "node:insert" && reason === "node:update";
|
|
4361
|
+
const canCoalesce = atTip && top.targetKey === targetKey && (namesInsertedNode || COALESCING_REASONS.has(reason) && top.reason === reason);
|
|
2933
4362
|
if (canCoalesce) {
|
|
2934
4363
|
top.snapshot = snapshot;
|
|
2935
4364
|
return;
|
|
@@ -3045,19 +4474,180 @@ var VdFlowchart = class {
|
|
|
3045
4474
|
const parent = this.findNode(sanitizeId(parentId));
|
|
3046
4475
|
if (!parent) return null;
|
|
3047
4476
|
const { direction = "right", distance, angle, edge: edgeOptions, ...nodeOptions } = options;
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
4477
|
+
return this.batchHistory("node:add-child", () => {
|
|
4478
|
+
const node = this.addNode({
|
|
4479
|
+
...nodeOptions,
|
|
4480
|
+
relativeTo: { node: parent.id, direction, distance, angle }
|
|
4481
|
+
});
|
|
4482
|
+
const edge = this.addEdge({
|
|
4483
|
+
from: parent.id,
|
|
4484
|
+
to: node.id,
|
|
4485
|
+
autoPort: true,
|
|
4486
|
+
endMarker: "arrow",
|
|
4487
|
+
...isPlainObject(edgeOptions) ? edgeOptions : {}
|
|
4488
|
+
});
|
|
4489
|
+
this.select({ kind: "node", id: node.id });
|
|
4490
|
+
return { node, edge };
|
|
3051
4491
|
});
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
4492
|
+
}
|
|
4493
|
+
// Run `fn` with history recording suspended, then record a single entry for
|
|
4494
|
+
// everything it changed (change events still fire for each mutation).
|
|
4495
|
+
batchHistory(reason, fn) {
|
|
4496
|
+
this.historyBatchDepth += 1;
|
|
4497
|
+
let result;
|
|
4498
|
+
try {
|
|
4499
|
+
result = fn();
|
|
4500
|
+
} finally {
|
|
4501
|
+
this.historyBatchDepth -= 1;
|
|
4502
|
+
}
|
|
4503
|
+
if (this.historyBatchDepth === 0 && this.historyBatchDirty) {
|
|
4504
|
+
this.historyBatchDirty = false;
|
|
4505
|
+
if (this.historyEnabled && !this.isApplyingHistory) this.recordHistory(reason);
|
|
4506
|
+
}
|
|
4507
|
+
return result;
|
|
4508
|
+
}
|
|
4509
|
+
// Mind-map structure: a node's parent is the source of its first incoming
|
|
4510
|
+
// edge; its children are the targets of its outgoing edges. The side a
|
|
4511
|
+
// branch is on is the port its edge leaves the parent from, which is what
|
|
4512
|
+
// the user sees (centre geometry is ambiguous for diagonal nodes).
|
|
4513
|
+
getParentEdge(nodeId) {
|
|
4514
|
+
return this.documentData.edges.find(
|
|
4515
|
+
(edge) => edge.to.nodeId === nodeId && edge.from.nodeId !== nodeId
|
|
4516
|
+
) || null;
|
|
4517
|
+
}
|
|
4518
|
+
getParentNode(nodeId) {
|
|
4519
|
+
const edge = this.getParentEdge(nodeId);
|
|
4520
|
+
return edge ? this.findNode(edge.from.nodeId) : null;
|
|
4521
|
+
}
|
|
4522
|
+
getChildNodes(nodeId, direction = null) {
|
|
4523
|
+
const port = direction ? DIRECTION_PORTS[direction] : null;
|
|
4524
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4525
|
+
this.documentData.edges.forEach((edge) => {
|
|
4526
|
+
if (edge.from.nodeId !== nodeId || edge.to.nodeId === nodeId) return;
|
|
4527
|
+
if (!port || edge.from.port === port) ids.add(edge.to.nodeId);
|
|
3058
4528
|
});
|
|
3059
|
-
this.
|
|
3060
|
-
|
|
4529
|
+
return this.documentData.nodes.filter((node) => ids.has(node.id));
|
|
4530
|
+
}
|
|
4531
|
+
// Direction a branch grows from `node`: the side its parent edge leaves
|
|
4532
|
+
// from, or the emptier horizontal side for a root.
|
|
4533
|
+
getGrowthDirection(node) {
|
|
4534
|
+
const parentEdge = this.getParentEdge(node.id);
|
|
4535
|
+
if (parentEdge) return PORT_DIRECTIONS[parentEdge.from.port] || "right";
|
|
4536
|
+
const right = this.getChildNodes(node.id, "right").length;
|
|
4537
|
+
const left = this.getChildNodes(node.id, "left").length;
|
|
4538
|
+
return left < right ? "left" : "right";
|
|
4539
|
+
}
|
|
4540
|
+
// Shape for a new branch node: copy a sibling when there is one, otherwise
|
|
4541
|
+
// the parent's box shape, falling back to a rounded step.
|
|
4542
|
+
getBranchTemplate(parent, sibling) {
|
|
4543
|
+
const source = sibling || (["rounded-rect", "rect", "textbox"].includes(parent.type) ? parent : null) || null;
|
|
4544
|
+
if (source) return { type: source.type, width: source.width, height: source.height };
|
|
4545
|
+
const spec = DEFAULT_NODE_SPECS["rounded-rect"];
|
|
4546
|
+
return { type: "rounded-rect", width: spec.width, height: spec.height };
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* Add a node connected from `parentId` on the `direction` side (default: the
|
|
4550
|
+
* branch's growth direction), stacked after `anchorId` or the last sibling on
|
|
4551
|
+
* that side. The node and its edge are one undo step; the new edge copies a
|
|
4552
|
+
* sibling's (or the parent's incoming) edge style. With `edit: true` the
|
|
4553
|
+
* label editor opens with the text selected.
|
|
4554
|
+
*/
|
|
4555
|
+
insertBranchNode(parentId, options = {}) {
|
|
4556
|
+
if (this.readonly) return null;
|
|
4557
|
+
const parent = this.findNode(sanitizeId(parentId));
|
|
4558
|
+
if (!parent) return null;
|
|
4559
|
+
const direction = DIRECTION_PORTS[options.direction] ? options.direction : this.getGrowthDirection(parent);
|
|
4560
|
+
const siblings = this.getChildNodes(parent.id, direction);
|
|
4561
|
+
const anchor = options.anchorId ? this.findNode(sanitizeId(options.anchorId)) : null;
|
|
4562
|
+
const lastSibling = siblings[siblings.length - 1] || null;
|
|
4563
|
+
const template = this.getBranchTemplate(parent, anchor || lastSibling);
|
|
4564
|
+
const position = placeBranchNode({
|
|
4565
|
+
nodes: this.documentData.nodes,
|
|
4566
|
+
parent,
|
|
4567
|
+
siblings,
|
|
4568
|
+
direction,
|
|
4569
|
+
size: template,
|
|
4570
|
+
anchor,
|
|
4571
|
+
before: Boolean(options.before)
|
|
4572
|
+
});
|
|
4573
|
+
const styleSource = this.documentData.edges.find(
|
|
4574
|
+
(edge) => edge.from.nodeId === parent.id && edge.to.nodeId === (anchor || lastSibling)?.id
|
|
4575
|
+
) || this.documentData.edges.find((edge) => edge.to.nodeId === parent.id);
|
|
4576
|
+
const edgeStyle = styleSource ? {
|
|
4577
|
+
route: styleSource.route,
|
|
4578
|
+
strokeWidth: styleSource.strokeWidth,
|
|
4579
|
+
startMarker: styleSource.startMarker,
|
|
4580
|
+
endMarker: styleSource.endMarker
|
|
4581
|
+
} : { endMarker: "arrow" };
|
|
4582
|
+
const result = this.batchHistory("node:insert", () => {
|
|
4583
|
+
if (parent.collapsed) delete parent.collapsed;
|
|
4584
|
+
const node = this.addNode({ ...template, x: position.x, y: position.y });
|
|
4585
|
+
const edge = this.addEdge({
|
|
4586
|
+
from: { nodeId: parent.id, port: DIRECTION_PORTS[direction] },
|
|
4587
|
+
to: { nodeId: node.id, port: DIRECTION_PORTS[oppositeDirection(direction)] },
|
|
4588
|
+
...edgeStyle
|
|
4589
|
+
});
|
|
4590
|
+
if (edge && anchor) this.placeEdgeBySibling(edge.id, parent.id, anchor.id, options.before);
|
|
4591
|
+
if (this.autoLayout && this.layoutMode !== "grid") this.layout(this.layoutMode);
|
|
4592
|
+
this.select({ kind: "node", id: node.id });
|
|
4593
|
+
return { node, edge };
|
|
4594
|
+
});
|
|
4595
|
+
this.revealNode(result.node.id);
|
|
4596
|
+
if (options.edit) this.startTextEdit(result.node.id, { inserted: true });
|
|
4597
|
+
return result;
|
|
4598
|
+
}
|
|
4599
|
+
// Tree and radial layouts order children by connection order, so a sibling's
|
|
4600
|
+
// connection goes right after (or before) its anchor's instead of last.
|
|
4601
|
+
placeEdgeBySibling(edgeId, parentId, anchorId, before = false) {
|
|
4602
|
+
const edges = this.documentData.edges;
|
|
4603
|
+
const fromIndex = edges.findIndex((edge) => edge.id === edgeId);
|
|
4604
|
+
if (fromIndex === -1) return;
|
|
4605
|
+
const [moved] = edges.splice(fromIndex, 1);
|
|
4606
|
+
const anchorIndex = edges.findIndex(
|
|
4607
|
+
(edge) => edge.from.nodeId === parentId && edge.to.nodeId === anchorId
|
|
4608
|
+
);
|
|
4609
|
+
if (anchorIndex === -1) edges.push(moved);
|
|
4610
|
+
else edges.splice(before ? anchorIndex : anchorIndex + 1, 0, moved);
|
|
4611
|
+
}
|
|
4612
|
+
// Insert a sibling after (or before) `nodeId`; a node without a parent gets a
|
|
4613
|
+
// child instead, which is what Enter on a mind-map root means.
|
|
4614
|
+
insertSiblingNode(nodeId, options = {}) {
|
|
4615
|
+
if (this.readonly) return null;
|
|
4616
|
+
const node = this.findNode(sanitizeId(nodeId));
|
|
4617
|
+
if (!node) return null;
|
|
4618
|
+
const parentEdge = this.getParentEdge(node.id);
|
|
4619
|
+
const parent = parentEdge ? this.findNode(parentEdge.from.nodeId) : null;
|
|
4620
|
+
if (!parent) return this.insertBranchNode(node.id, { edit: options.edit });
|
|
4621
|
+
return this.insertBranchNode(parent.id, {
|
|
4622
|
+
direction: PORT_DIRECTIONS[parentEdge.from.port],
|
|
4623
|
+
anchorId: node.id,
|
|
4624
|
+
before: Boolean(options.before),
|
|
4625
|
+
edit: options.edit
|
|
4626
|
+
});
|
|
4627
|
+
}
|
|
4628
|
+
// Pan just enough to bring a node fully into view (with a small margin).
|
|
4629
|
+
revealNode(nodeId) {
|
|
4630
|
+
const node = this.findNode(nodeId);
|
|
4631
|
+
const width = this.canvasEl.clientWidth;
|
|
4632
|
+
const height = this.canvasEl.clientHeight;
|
|
4633
|
+
if (!node || !width || !height) return;
|
|
4634
|
+
const viewport = this.documentData.viewport;
|
|
4635
|
+
const margin = 32;
|
|
4636
|
+
const left = viewport.x + node.x * viewport.scale;
|
|
4637
|
+
const top = viewport.y + node.y * viewport.scale;
|
|
4638
|
+
const right = left + node.width * viewport.scale;
|
|
4639
|
+
const bottom = top + node.height * viewport.scale;
|
|
4640
|
+
let dx = 0;
|
|
4641
|
+
let dy = 0;
|
|
4642
|
+
if (right > width - margin) dx = width - margin - right;
|
|
4643
|
+
if (left + dx < margin) dx = margin - left;
|
|
4644
|
+
if (bottom > height - margin) dy = height - margin - bottom;
|
|
4645
|
+
if (top + dy < margin) dy = margin - top;
|
|
4646
|
+
if (!dx && !dy) return;
|
|
4647
|
+
viewport.x = formatNumber(viewport.x + dx);
|
|
4648
|
+
viewport.y = formatNumber(viewport.y + dy);
|
|
4649
|
+
this.render({ inspector: false, json: true });
|
|
4650
|
+
this.emitViewportChange("viewport:pan");
|
|
3061
4651
|
}
|
|
3062
4652
|
// Arrange nodes with a built-in layout. Positions are computed by the pure
|
|
3063
4653
|
// computeLayout() module, then applied in place (NOT via load(), which would
|
|
@@ -3305,6 +4895,9 @@ var VdFlowchart = class {
|
|
|
3305
4895
|
}
|
|
3306
4896
|
deleteSelection() {
|
|
3307
4897
|
if (!this.selection || this.readonly) return false;
|
|
4898
|
+
if (this.hasMultiSelection()) {
|
|
4899
|
+
return this.removeNodeIds(new Set(this.getSelectedNodeIds()), "node:remove");
|
|
4900
|
+
}
|
|
3308
4901
|
if (this.selection.kind === "node") return this.removeNode(this.selection.id);
|
|
3309
4902
|
return this.removeEdge(this.selection.id);
|
|
3310
4903
|
}
|
|
@@ -3333,10 +4926,11 @@ var VdFlowchart = class {
|
|
|
3333
4926
|
return this;
|
|
3334
4927
|
}
|
|
3335
4928
|
fitView() {
|
|
3336
|
-
|
|
4929
|
+
const nodes = this.getVisibleNodes();
|
|
4930
|
+
if (!nodes.length) {
|
|
3337
4931
|
return this.resetView();
|
|
3338
4932
|
}
|
|
3339
|
-
const bounds = getBounds(
|
|
4933
|
+
const bounds = getBounds(nodes);
|
|
3340
4934
|
const width = this.canvasEl.clientWidth || 800;
|
|
3341
4935
|
const height = this.canvasEl.clientHeight || 560;
|
|
3342
4936
|
const padding = 80;
|
|
@@ -3371,20 +4965,77 @@ var VdFlowchart = class {
|
|
|
3371
4965
|
this.emitChange("clear");
|
|
3372
4966
|
return this;
|
|
3373
4967
|
}
|
|
4968
|
+
updateOptions(options = {}) {
|
|
4969
|
+
if (this.destroyed) return this;
|
|
4970
|
+
if ("readonly" in options) {
|
|
4971
|
+
if (options.readonly && !this.readonly) {
|
|
4972
|
+
this.stopTextEdit({ commit: true });
|
|
4973
|
+
this.interaction = null;
|
|
4974
|
+
this.activeTool = null;
|
|
4975
|
+
this.reconnectEdgeId = null;
|
|
4976
|
+
}
|
|
4977
|
+
this.readonly = Boolean(options.readonly);
|
|
4978
|
+
this.root.classList.toggle("vd-flowchart-readonly", this.readonly);
|
|
4979
|
+
this.arrangeSelect.disabled = this.readonly;
|
|
4980
|
+
this.clearButton.disabled = this.readonly;
|
|
4981
|
+
this.loadJsonButton.disabled = this.readonly;
|
|
4982
|
+
}
|
|
4983
|
+
if ("gridSize" in options) {
|
|
4984
|
+
this.gridSize = clamp(toFiniteNumber(options.gridSize, DEFAULT_GRID_SIZE), 12, 64);
|
|
4985
|
+
const pattern = this.svg.querySelector("pattern");
|
|
4986
|
+
pattern.setAttribute("width", this.gridSize);
|
|
4987
|
+
pattern.setAttribute("height", this.gridSize);
|
|
4988
|
+
pattern.querySelector("path").setAttribute("d", `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`);
|
|
4989
|
+
}
|
|
4990
|
+
if ("autoFit" in options) this.autoFit = Boolean(options.autoFit);
|
|
4991
|
+
if ("keyboardShortcuts" in options) {
|
|
4992
|
+
this.keyboardShortcuts = normalizeKeyboardShortcuts(options.keyboardShortcuts);
|
|
4993
|
+
}
|
|
4994
|
+
if ("snapGuides" in options) this.snapGuides = options.snapGuides !== false;
|
|
4995
|
+
if ("minimap" in options) this.setMinimapEnabled(options.minimap !== false);
|
|
4996
|
+
if ("autoLayout" in options) this.autoLayout = Boolean(options.autoLayout);
|
|
4997
|
+
this.syncCanvasLabel();
|
|
4998
|
+
if (this.shortcutsHelpOpen) this.renderShortcutsHelp();
|
|
4999
|
+
if ("history" in options && options.history !== false !== this.historyEnabled) {
|
|
5000
|
+
this.historyEnabled = options.history !== false;
|
|
5001
|
+
this.history = [];
|
|
5002
|
+
this.historyIndex = -1;
|
|
5003
|
+
this.seedHistory();
|
|
5004
|
+
}
|
|
5005
|
+
if ("historyLimit" in options) {
|
|
5006
|
+
this.historyLimit = Math.max(1, Math.floor(toFiniteNumber(options.historyLimit, 100)));
|
|
5007
|
+
if (this.history.length > this.historyLimit) {
|
|
5008
|
+
const start = Math.max(0, this.historyIndex - this.historyLimit + 1);
|
|
5009
|
+
this.history = this.history.slice(start, start + this.historyLimit);
|
|
5010
|
+
this.historyIndex -= start;
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
this.render();
|
|
5014
|
+
this.updateHistoryButtons();
|
|
5015
|
+
return this;
|
|
5016
|
+
}
|
|
3374
5017
|
load(data, options = {}) {
|
|
5018
|
+
const documentData = normalizeDocument(data);
|
|
5019
|
+
if (options.silent && JSON.stringify(documentData) === JSON.stringify(this.documentData)) {
|
|
5020
|
+
return this;
|
|
5021
|
+
}
|
|
3375
5022
|
this.stopTextEdit({ commit: false });
|
|
3376
5023
|
this.activeTool = null;
|
|
3377
5024
|
this.reconnectEdgeId = null;
|
|
3378
5025
|
const previousSelection = this.selection;
|
|
3379
|
-
this.documentData =
|
|
5026
|
+
this.documentData = documentData;
|
|
3380
5027
|
this.selection = this.resolvePreservedSelection(previousSelection, options.preserveSelection);
|
|
3381
5028
|
this.render();
|
|
3382
|
-
|
|
5029
|
+
if (options.silent) {
|
|
5030
|
+
if (this.historyEnabled) this.recordHistory("load");
|
|
5031
|
+
} else {
|
|
5032
|
+
this.emitChange("load");
|
|
5033
|
+
}
|
|
3383
5034
|
return this;
|
|
3384
5035
|
}
|
|
3385
5036
|
toJSON() {
|
|
3386
5037
|
return deepClone({
|
|
3387
|
-
version:
|
|
5038
|
+
version: FLOWCHART_DOCUMENT_VERSION,
|
|
3388
5039
|
viewport: this.documentData.viewport,
|
|
3389
5040
|
nodes: this.documentData.nodes,
|
|
3390
5041
|
edges: this.documentData.edges
|
|
@@ -3393,11 +5044,19 @@ var VdFlowchart = class {
|
|
|
3393
5044
|
destroy() {
|
|
3394
5045
|
if (this.destroyed) return;
|
|
3395
5046
|
this.destroyed = true;
|
|
5047
|
+
if (this.minimapFrame && typeof cancelAnimationFrame === "function") {
|
|
5048
|
+
cancelAnimationFrame(this.minimapFrame);
|
|
5049
|
+
}
|
|
5050
|
+
this.minimapFrame = 0;
|
|
3396
5051
|
this.activeTool = null;
|
|
3397
5052
|
this.stopTextEdit({ commit: false });
|
|
3398
5053
|
this.resizeObserver?.disconnect();
|
|
3399
5054
|
this.resizeObserver = null;
|
|
3400
5055
|
this.unbindEvents();
|
|
5056
|
+
this.sceneNodeMap?.clear();
|
|
5057
|
+
this.nodeElements?.clear();
|
|
5058
|
+
this.edgeElements?.clear();
|
|
5059
|
+
this.incidentEdges?.clear();
|
|
3401
5060
|
this.element.innerHTML = "";
|
|
3402
5061
|
this.element.classList.remove("vd-flowchart-host");
|
|
3403
5062
|
}
|
|
@@ -3405,6 +5064,17 @@ var VdFlowchart = class {
|
|
|
3405
5064
|
|
|
3406
5065
|
// src/vue.js
|
|
3407
5066
|
var FORWARDED_EVENTS = ["change", "select", "viewport", "connect", "ready"];
|
|
5067
|
+
var EDITOR_OPTION_KEYS = [
|
|
5068
|
+
"readonly",
|
|
5069
|
+
"gridSize",
|
|
5070
|
+
"autoFit",
|
|
5071
|
+
"history",
|
|
5072
|
+
"historyLimit",
|
|
5073
|
+
"keyboardShortcuts",
|
|
5074
|
+
"snapGuides",
|
|
5075
|
+
"minimap",
|
|
5076
|
+
"autoLayout"
|
|
5077
|
+
];
|
|
3408
5078
|
var VdFlowchart2 = (0, import_vue.defineComponent)({
|
|
3409
5079
|
name: "VdFlowchart",
|
|
3410
5080
|
props: {
|
|
@@ -3421,21 +5091,26 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
|
|
|
3421
5091
|
/** Enable the built-in undo/redo history (default true). */
|
|
3422
5092
|
history: { type: Boolean, default: true },
|
|
3423
5093
|
/** Maximum number of history entries to retain. */
|
|
3424
|
-
historyLimit: { type: Number, default: void 0 }
|
|
5094
|
+
historyLimit: { type: Number, default: void 0 },
|
|
5095
|
+
/** Keyboard model: 'mindmap' (Tab child, Enter sibling) or 'basic'. */
|
|
5096
|
+
keyboardShortcuts: { type: String, default: "mindmap" },
|
|
5097
|
+
/** Snap dragged nodes to other nodes' edges and centres (default true). */
|
|
5098
|
+
snapGuides: { type: Boolean, default: true },
|
|
5099
|
+
/** Show the overview minimap in the canvas corner (default true). */
|
|
5100
|
+
minimap: { type: Boolean, default: true },
|
|
5101
|
+
/** Re-run the current layout after keyboard or handle insertions (default false). */
|
|
5102
|
+
autoLayout: { type: Boolean, default: false }
|
|
3425
5103
|
},
|
|
3426
5104
|
emits: ["change", "select", "viewport", "connect", "ready"],
|
|
3427
5105
|
setup(props, { emit, expose }) {
|
|
3428
5106
|
const el = (0, import_vue.ref)(null);
|
|
3429
5107
|
let instance = null;
|
|
5108
|
+
const editorOptions = () => Object.fromEntries(EDITOR_OPTION_KEYS.map((key) => [key, props[key]]));
|
|
3430
5109
|
const create = () => {
|
|
3431
5110
|
instance = new VdFlowchart({
|
|
3432
5111
|
element: el.value,
|
|
3433
5112
|
data: props.data,
|
|
3434
|
-
|
|
3435
|
-
gridSize: props.gridSize,
|
|
3436
|
-
autoFit: props.autoFit,
|
|
3437
|
-
history: props.history,
|
|
3438
|
-
historyLimit: props.historyLimit
|
|
5113
|
+
...editorOptions()
|
|
3439
5114
|
});
|
|
3440
5115
|
FORWARDED_EVENTS.forEach((name) => {
|
|
3441
5116
|
instance.on(name, (payload) => emit(name, payload));
|
|
@@ -3449,17 +5124,15 @@ var VdFlowchart2 = (0, import_vue.defineComponent)({
|
|
|
3449
5124
|
() => props.data,
|
|
3450
5125
|
(next) => {
|
|
3451
5126
|
if (instance && typeof instance.load === "function") {
|
|
3452
|
-
instance.load(next, { preserveSelection: props.preserveSelection });
|
|
5127
|
+
instance.load(next, { preserveSelection: props.preserveSelection, silent: true });
|
|
3453
5128
|
}
|
|
3454
5129
|
},
|
|
3455
5130
|
{ deep: true }
|
|
3456
5131
|
);
|
|
3457
5132
|
(0, import_vue.watch)(
|
|
3458
|
-
() =>
|
|
5133
|
+
() => EDITOR_OPTION_KEYS.map((key) => props[key]),
|
|
3459
5134
|
() => {
|
|
3460
|
-
if (
|
|
3461
|
-
instance.destroy();
|
|
3462
|
-
create();
|
|
5135
|
+
if (instance) instance.updateOptions(editorOptions());
|
|
3463
5136
|
}
|
|
3464
5137
|
);
|
|
3465
5138
|
(0, import_vue.onBeforeUnmount)(() => {
|