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