@vanduo-oss/vd3-cbun 1.0.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 +50 -0
- package/LICENSE +22 -0
- package/README.md +160 -0
- package/SKILL.md +119 -0
- package/dist/charts/core.d.ts +273 -0
- package/dist/charts/index.cjs +1828 -0
- package/dist/charts/index.cjs.map +7 -0
- package/dist/charts/index.d.ts +65 -0
- package/dist/charts/index.js +1805 -0
- package/dist/charts/index.js.map +7 -0
- package/dist/charts/vd3-charts.css +51 -0
- package/dist/charts/vue.d.ts +86 -0
- package/dist/flowchart/core.d.ts +288 -0
- package/dist/flowchart/index.cjs +3447 -0
- package/dist/flowchart/index.cjs.map +7 -0
- package/dist/flowchart/index.d.ts +54 -0
- package/dist/flowchart/index.js +3424 -0
- package/dist/flowchart/index.js.map +7 -0
- package/dist/flowchart/vd3-flowchart.css +600 -0
- package/dist/flowchart/vue.d.ts +66 -0
- package/dist/hex-grid/core.d.ts +200 -0
- package/dist/hex-grid/hex-math.cjs +162 -0
- package/dist/hex-grid/hex-math.cjs.map +7 -0
- package/dist/hex-grid/hex-math.d.ts +119 -0
- package/dist/hex-grid/hex-math.js +141 -0
- package/dist/hex-grid/hex-math.js.map +7 -0
- package/dist/hex-grid/index.cjs +915 -0
- package/dist/hex-grid/index.cjs.map +7 -0
- package/dist/hex-grid/index.d.ts +15 -0
- package/dist/hex-grid/index.js +894 -0
- package/dist/hex-grid/index.js.map +7 -0
- package/dist/hex-grid/vue.d.ts +14 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +7 -0
- package/dist/meta.json +551 -0
- package/dist/music-player/core.d.ts +88 -0
- package/dist/music-player/index.cjs +1227 -0
- package/dist/music-player/index.cjs.map +7 -0
- package/dist/music-player/index.d.ts +12 -0
- package/dist/music-player/index.js +1204 -0
- package/dist/music-player/index.js.map +7 -0
- package/dist/music-player/vd3-music-player.css +829 -0
- package/dist/music-player/vue.d.ts +32 -0
- package/package.json +105 -0
|
@@ -0,0 +1,3447 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/flowchart/index.js
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
FLOWCHART_EDGE_MARKERS: () => FLOWCHART_EDGE_MARKERS,
|
|
24
|
+
FLOWCHART_EDGE_ROUTES: () => FLOWCHART_EDGE_ROUTES,
|
|
25
|
+
FLOWCHART_NODE_TYPES: () => FLOWCHART_NODE_TYPES,
|
|
26
|
+
FLOWCHART_PORTS: () => FLOWCHART_PORTS,
|
|
27
|
+
LAYOUT_MODES: () => LAYOUT_MODES,
|
|
28
|
+
VD_FLOWCHART_VERSION: () => VD_FLOWCHART_VERSION,
|
|
29
|
+
VdFlowchart: () => VdFlowchart2,
|
|
30
|
+
VdFlowchartCore: () => VdFlowchart,
|
|
31
|
+
computeLayout: () => computeLayout
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(index_exports);
|
|
34
|
+
|
|
35
|
+
// src/flowchart/vue.js
|
|
36
|
+
var import_vue = require("vue");
|
|
37
|
+
|
|
38
|
+
// src/flowchart/layout.js
|
|
39
|
+
var DIRECTIONS = {
|
|
40
|
+
right: { main: "x", sign: 1 },
|
|
41
|
+
left: { main: "x", sign: -1 },
|
|
42
|
+
down: { main: "y", sign: 1 },
|
|
43
|
+
up: { main: "y", sign: -1 }
|
|
44
|
+
};
|
|
45
|
+
function num(value, fallback) {
|
|
46
|
+
const next = Number(value);
|
|
47
|
+
return Number.isFinite(next) ? next : fallback;
|
|
48
|
+
}
|
|
49
|
+
function buildGraph(nodes, edges) {
|
|
50
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
51
|
+
const children = new Map(nodes.map((node) => [node.id, []]));
|
|
52
|
+
const indegree = new Map(nodes.map((node) => [node.id, 0]));
|
|
53
|
+
edges.forEach((edge) => {
|
|
54
|
+
const from = edge?.from?.nodeId;
|
|
55
|
+
const to = edge?.to?.nodeId;
|
|
56
|
+
if (from === to || !byId.has(from) || !byId.has(to)) return;
|
|
57
|
+
children.get(from).push(to);
|
|
58
|
+
indegree.set(to, indegree.get(to) + 1);
|
|
59
|
+
});
|
|
60
|
+
return { byId, children, indegree };
|
|
61
|
+
}
|
|
62
|
+
function pickRoots(nodes, indegree, optRoot) {
|
|
63
|
+
if (optRoot && indegree.has(optRoot)) return [optRoot];
|
|
64
|
+
const roots = nodes.filter((node) => indegree.get(node.id) === 0).map((node) => node.id);
|
|
65
|
+
return roots.length ? roots : nodes.length ? [nodes[0].id] : [];
|
|
66
|
+
}
|
|
67
|
+
function spanningForest(allIds, roots, children) {
|
|
68
|
+
const visited = /* @__PURE__ */ new Set();
|
|
69
|
+
const depth = /* @__PURE__ */ new Map();
|
|
70
|
+
const tree = /* @__PURE__ */ new Map();
|
|
71
|
+
const forestRoots = [];
|
|
72
|
+
const walk = (id, d) => {
|
|
73
|
+
visited.add(id);
|
|
74
|
+
depth.set(id, d);
|
|
75
|
+
const kids = [];
|
|
76
|
+
(children.get(id) || []).forEach((child) => {
|
|
77
|
+
if (!visited.has(child)) {
|
|
78
|
+
kids.push(child);
|
|
79
|
+
walk(child, d + 1);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
tree.set(id, kids);
|
|
83
|
+
};
|
|
84
|
+
roots.forEach((root) => {
|
|
85
|
+
if (!visited.has(root)) {
|
|
86
|
+
forestRoots.push(root);
|
|
87
|
+
walk(root, 0);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
allIds.forEach((id) => {
|
|
91
|
+
if (!visited.has(id)) {
|
|
92
|
+
forestRoots.push(id);
|
|
93
|
+
walk(id, 0);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return { depth, tree, forestRoots };
|
|
97
|
+
}
|
|
98
|
+
function treeCenters(forestRoots, tree, depth, sizeOf, options) {
|
|
99
|
+
const dir = DIRECTIONS[options.direction] || DIRECTIONS.right;
|
|
100
|
+
const levelGap = num(options.levelGap, 220);
|
|
101
|
+
const siblingGap = num(options.siblingGap, 140);
|
|
102
|
+
const crossOf = /* @__PURE__ */ new Map();
|
|
103
|
+
let cursor = 0;
|
|
104
|
+
const place = (id) => {
|
|
105
|
+
const kids = tree.get(id) || [];
|
|
106
|
+
if (!kids.length) {
|
|
107
|
+
crossOf.set(id, cursor);
|
|
108
|
+
cursor += siblingGap;
|
|
109
|
+
return crossOf.get(id);
|
|
110
|
+
}
|
|
111
|
+
const childCenters = kids.map(place);
|
|
112
|
+
const center = (childCenters[0] + childCenters[childCenters.length - 1]) / 2;
|
|
113
|
+
crossOf.set(id, center);
|
|
114
|
+
return center;
|
|
115
|
+
};
|
|
116
|
+
forestRoots.forEach((root) => {
|
|
117
|
+
place(root);
|
|
118
|
+
cursor += siblingGap;
|
|
119
|
+
});
|
|
120
|
+
const centers = /* @__PURE__ */ new Map();
|
|
121
|
+
crossOf.forEach((cross, id) => {
|
|
122
|
+
const main = (depth.get(id) || 0) * levelGap * dir.sign;
|
|
123
|
+
centers.set(
|
|
124
|
+
id,
|
|
125
|
+
dir.main === "x" ? { centerX: main, centerY: cross } : { centerX: cross, centerY: main }
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
return centers;
|
|
129
|
+
}
|
|
130
|
+
function radialCenters(forestRoots, tree, depth, options) {
|
|
131
|
+
const ringGap = num(options.radius, 220);
|
|
132
|
+
const leaves = [];
|
|
133
|
+
const collect = (id) => {
|
|
134
|
+
const kids = tree.get(id) || [];
|
|
135
|
+
if (!kids.length) leaves.push(id);
|
|
136
|
+
else kids.forEach(collect);
|
|
137
|
+
};
|
|
138
|
+
forestRoots.forEach(collect);
|
|
139
|
+
const total = Math.max(1, leaves.length);
|
|
140
|
+
const leafAngle = new Map(leaves.map((id, index) => [id, (index + 0.5) / total * Math.PI * 2]));
|
|
141
|
+
const angleOf = /* @__PURE__ */ new Map();
|
|
142
|
+
const resolveAngle = (id) => {
|
|
143
|
+
const kids = tree.get(id) || [];
|
|
144
|
+
if (!kids.length) {
|
|
145
|
+
angleOf.set(id, leafAngle.get(id));
|
|
146
|
+
return angleOf.get(id);
|
|
147
|
+
}
|
|
148
|
+
const childAngles = kids.map(resolveAngle);
|
|
149
|
+
const mean = childAngles.reduce((sum, value) => sum + value, 0) / childAngles.length;
|
|
150
|
+
angleOf.set(id, mean);
|
|
151
|
+
return mean;
|
|
152
|
+
};
|
|
153
|
+
forestRoots.forEach(resolveAngle);
|
|
154
|
+
const centers = /* @__PURE__ */ new Map();
|
|
155
|
+
angleOf.forEach((angle, id) => {
|
|
156
|
+
const radius = (depth.get(id) || 0) * ringGap;
|
|
157
|
+
centers.set(id, { centerX: Math.cos(angle) * radius, centerY: Math.sin(angle) * radius });
|
|
158
|
+
});
|
|
159
|
+
return centers;
|
|
160
|
+
}
|
|
161
|
+
function gridCenters(nodes, sizeOf, options) {
|
|
162
|
+
const gap = num(options.gap, 48);
|
|
163
|
+
const columns = Math.max(
|
|
164
|
+
1,
|
|
165
|
+
Math.floor(num(options.columns, Math.ceil(Math.sqrt(nodes.length || 1))))
|
|
166
|
+
);
|
|
167
|
+
let cellW = 0;
|
|
168
|
+
let cellH = 0;
|
|
169
|
+
nodes.forEach((node) => {
|
|
170
|
+
const size = sizeOf(node.id);
|
|
171
|
+
cellW = Math.max(cellW, size.width);
|
|
172
|
+
cellH = Math.max(cellH, size.height);
|
|
173
|
+
});
|
|
174
|
+
cellW += gap;
|
|
175
|
+
cellH += gap;
|
|
176
|
+
const sorted = [...nodes].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
177
|
+
const centers = /* @__PURE__ */ new Map();
|
|
178
|
+
sorted.forEach((node, index) => {
|
|
179
|
+
const col = index % columns;
|
|
180
|
+
const row = Math.floor(index / columns);
|
|
181
|
+
centers.set(node.id, { centerX: col * cellW, centerY: row * cellH });
|
|
182
|
+
});
|
|
183
|
+
return centers;
|
|
184
|
+
}
|
|
185
|
+
function computeLayout(documentData, mode = "tree", options = {}) {
|
|
186
|
+
const nodes = Array.isArray(documentData?.nodes) ? documentData.nodes : [];
|
|
187
|
+
const edges = Array.isArray(documentData?.edges) ? documentData.edges : [];
|
|
188
|
+
if (!nodes.length) return /* @__PURE__ */ new Map();
|
|
189
|
+
const { byId, children, indegree } = buildGraph(nodes, edges);
|
|
190
|
+
const sizeOf = (id) => {
|
|
191
|
+
const node = byId.get(id);
|
|
192
|
+
return { width: num(node?.width, 160), height: num(node?.height, 96) };
|
|
193
|
+
};
|
|
194
|
+
const allIds = nodes.map((node) => node.id);
|
|
195
|
+
let centers;
|
|
196
|
+
let anchorId = nodes[0].id;
|
|
197
|
+
if (mode === "grid") {
|
|
198
|
+
centers = gridCenters(nodes, sizeOf, options);
|
|
199
|
+
} else {
|
|
200
|
+
const rootOpt = options.root == null ? null : String(options.root).trim();
|
|
201
|
+
const roots = pickRoots(nodes, indegree, rootOpt);
|
|
202
|
+
anchorId = roots[0] || anchorId;
|
|
203
|
+
const { depth, tree, forestRoots } = spanningForest(allIds, roots, children);
|
|
204
|
+
centers = mode === "radial" ? radialCenters(forestRoots, tree, depth, options) : treeCenters(forestRoots, tree, depth, sizeOf, options);
|
|
205
|
+
}
|
|
206
|
+
const anchorNode = byId.get(anchorId);
|
|
207
|
+
const anchorCenter = centers.get(anchorId) || { centerX: 0, centerY: 0 };
|
|
208
|
+
const dx = anchorNode ? anchorNode.x + anchorNode.width / 2 - anchorCenter.centerX : 0;
|
|
209
|
+
const dy = anchorNode ? anchorNode.y + anchorNode.height / 2 - anchorCenter.centerY : 0;
|
|
210
|
+
const result = /* @__PURE__ */ new Map();
|
|
211
|
+
centers.forEach((center, id) => {
|
|
212
|
+
const size = sizeOf(id);
|
|
213
|
+
result.set(id, {
|
|
214
|
+
x: Math.round((center.centerX + dx - size.width / 2) * 100) / 100,
|
|
215
|
+
y: Math.round((center.centerY + dy - size.height / 2) * 100) / 100
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
var LAYOUT_MODES = ["tree", "radial", "grid"];
|
|
221
|
+
|
|
222
|
+
// src/flowchart/core.js
|
|
223
|
+
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
224
|
+
var DEFAULT_GRID_SIZE = 24;
|
|
225
|
+
var MIN_SCALE = 0.35;
|
|
226
|
+
var MAX_SCALE = 3;
|
|
227
|
+
var MIN_NODE_SIZE = 56;
|
|
228
|
+
var MAX_NODE_SIZE = 420;
|
|
229
|
+
var WORLD_EXTENT = 12e3;
|
|
230
|
+
var RESIZE_HANDLES = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
|
231
|
+
var DEFAULT_EDGE_STROKE_WIDTH = 2.25;
|
|
232
|
+
var MIN_EDGE_STROKE_WIDTH = 1.25;
|
|
233
|
+
var MAX_EDGE_STROKE_WIDTH = 6;
|
|
234
|
+
var CONNECTION_PORT_RADIUS = 6;
|
|
235
|
+
var CONNECTION_PORT_HIT_RADIUS = 14;
|
|
236
|
+
var RECONNECT_ENDPOINT_RADIUS = 7;
|
|
237
|
+
var RECONNECT_ENDPOINT_HIT_RADIUS = 12;
|
|
238
|
+
var EDGE_HIT_STROKE_MIN = 16;
|
|
239
|
+
var CONNECTION_SNAP_PADDING = 36;
|
|
240
|
+
var CONNECTION_HYSTERESIS = 16;
|
|
241
|
+
var CONNECTION_CENTER_LOCK_RADIUS = 18;
|
|
242
|
+
var RESIZE_PORT_GAP = 34;
|
|
243
|
+
var EDGE_STROKE_PRESETS = [
|
|
244
|
+
{ id: "thin", label: "Thin", width: 1.75 },
|
|
245
|
+
{ id: "medium", label: "Medium", width: DEFAULT_EDGE_STROKE_WIDTH },
|
|
246
|
+
{ id: "bold", label: "Bold", width: 3.5 }
|
|
247
|
+
];
|
|
248
|
+
var VD_FLOWCHART_VERSION = "1.2.0";
|
|
249
|
+
var FLOWCHART_NODE_TYPES = [
|
|
250
|
+
"rounded-rect",
|
|
251
|
+
"rect",
|
|
252
|
+
"diamond",
|
|
253
|
+
"circle",
|
|
254
|
+
"textbox",
|
|
255
|
+
"label",
|
|
256
|
+
"junction"
|
|
257
|
+
];
|
|
258
|
+
var FLOWCHART_PORTS = ["top", "right", "bottom", "left"];
|
|
259
|
+
var FLOWCHART_EDGE_MARKERS = ["none", "arrow", "dot"];
|
|
260
|
+
var FLOWCHART_EDGE_ROUTES = ["curve", "straight", "orthogonal"];
|
|
261
|
+
var COALESCING_REASONS = /* @__PURE__ */ new Set(["node:update", "edge:update"]);
|
|
262
|
+
var DEFAULT_EDGE_ROUTE = "curve";
|
|
263
|
+
var ORTHOGONAL_STUB_LENGTH = 32;
|
|
264
|
+
var ORTHOGONAL_CORNER_RADIUS = 12;
|
|
265
|
+
var ORTHOGONAL_CLEARANCE = 16;
|
|
266
|
+
var EDGE_CURVATURE = 0.5;
|
|
267
|
+
var MIN_CURVE_ARM = 22;
|
|
268
|
+
var MAX_CURVE_ARM = 260;
|
|
269
|
+
var FLOWCHART_EDGE_ROUTE_LABELS = {
|
|
270
|
+
curve: "Curve",
|
|
271
|
+
straight: "Straight",
|
|
272
|
+
orthogonal: "Stepped orthogonal"
|
|
273
|
+
};
|
|
274
|
+
var DEFAULT_NODE_SPECS = {
|
|
275
|
+
"rounded-rect": { width: 180, height: 96, text: "Step" },
|
|
276
|
+
rect: { width: 180, height: 96, text: "Process" },
|
|
277
|
+
diamond: { width: 184, height: 120, text: "Decision" },
|
|
278
|
+
circle: { width: 128, height: 128, text: "Start" },
|
|
279
|
+
textbox: { width: 240, height: 144, text: "Notes" },
|
|
280
|
+
label: { width: 180, height: 72, text: "Label" },
|
|
281
|
+
junction: {
|
|
282
|
+
width: 28,
|
|
283
|
+
height: 28,
|
|
284
|
+
text: "",
|
|
285
|
+
minWidth: 28,
|
|
286
|
+
minHeight: 28,
|
|
287
|
+
maxWidth: 28,
|
|
288
|
+
maxHeight: 28,
|
|
289
|
+
resizable: false,
|
|
290
|
+
textEditable: false
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
var FLOWCHART_PALETTE_ITEMS = [
|
|
294
|
+
{ kind: "tool", tool: "arrow", label: "arrow" },
|
|
295
|
+
{ kind: "node", type: "rounded-rect", label: "rounded rect" },
|
|
296
|
+
{ kind: "node", type: "rect", label: "rect" },
|
|
297
|
+
{ kind: "node", type: "diamond", label: "diamond" },
|
|
298
|
+
{ kind: "node", type: "circle", label: "circle" },
|
|
299
|
+
{ kind: "node", type: "junction", label: "junction" },
|
|
300
|
+
{ kind: "node", type: "textbox", label: "textbox" },
|
|
301
|
+
{ kind: "node", type: "label", label: "label" }
|
|
302
|
+
];
|
|
303
|
+
var flowchartId = 0;
|
|
304
|
+
function nextId(prefix) {
|
|
305
|
+
flowchartId += 1;
|
|
306
|
+
return `${prefix}-${flowchartId}`;
|
|
307
|
+
}
|
|
308
|
+
function hasWindow() {
|
|
309
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
310
|
+
}
|
|
311
|
+
function isElement(value) {
|
|
312
|
+
return hasWindow() && value instanceof Element;
|
|
313
|
+
}
|
|
314
|
+
function isPlainObject(value) {
|
|
315
|
+
return Boolean(value) && Object.prototype.toString.call(value) === "[object Object]";
|
|
316
|
+
}
|
|
317
|
+
function clamp(value, min, max) {
|
|
318
|
+
return Math.min(max, Math.max(min, value));
|
|
319
|
+
}
|
|
320
|
+
function toFiniteNumber(value, fallback) {
|
|
321
|
+
const next = Number(value);
|
|
322
|
+
return Number.isFinite(next) ? next : fallback;
|
|
323
|
+
}
|
|
324
|
+
function deepClone(value) {
|
|
325
|
+
return JSON.parse(JSON.stringify(value));
|
|
326
|
+
}
|
|
327
|
+
function formatNumber(value) {
|
|
328
|
+
return Number(value.toFixed(2));
|
|
329
|
+
}
|
|
330
|
+
function sanitizeId(value) {
|
|
331
|
+
if (value == null) return "";
|
|
332
|
+
return String(value).trim();
|
|
333
|
+
}
|
|
334
|
+
function resolveElement(target) {
|
|
335
|
+
if (!hasWindow()) {
|
|
336
|
+
throw new Error("Vanduo Flowchart requires a browser DOM target.");
|
|
337
|
+
}
|
|
338
|
+
if (typeof target === "string") {
|
|
339
|
+
const el = document.querySelector(target);
|
|
340
|
+
if (!el) throw new Error(`Flowchart target not found: ${target}`);
|
|
341
|
+
return el;
|
|
342
|
+
}
|
|
343
|
+
if (isElement(target)) return target;
|
|
344
|
+
throw new Error("Flowchart target must be an Element or selector string.");
|
|
345
|
+
}
|
|
346
|
+
function createElement(tagName, options = {}) {
|
|
347
|
+
const element = document.createElement(tagName);
|
|
348
|
+
if (options.className) element.className = options.className;
|
|
349
|
+
if (options.text != null) element.textContent = String(options.text);
|
|
350
|
+
if (options.type) element.type = options.type;
|
|
351
|
+
if (options.value != null) element.value = String(options.value);
|
|
352
|
+
if (options.placeholder != null) element.placeholder = String(options.placeholder);
|
|
353
|
+
if (options.title != null) element.title = String(options.title);
|
|
354
|
+
if (options.rows != null) element.rows = Number(options.rows);
|
|
355
|
+
if (options.disabled) element.disabled = true;
|
|
356
|
+
if (options.tabIndex != null) element.tabIndex = Number(options.tabIndex);
|
|
357
|
+
return element;
|
|
358
|
+
}
|
|
359
|
+
function svgEl(name, attrs = {}) {
|
|
360
|
+
const element = document.createElementNS(SVG_NS, name);
|
|
361
|
+
Object.entries(attrs).forEach(([key, value]) => {
|
|
362
|
+
if (value != null) {
|
|
363
|
+
element.setAttribute(key, String(value));
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return element;
|
|
367
|
+
}
|
|
368
|
+
function clearChildren(element) {
|
|
369
|
+
while (element.firstChild) {
|
|
370
|
+
element.removeChild(element.firstChild);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function ensureUniqueId(preferred, prefix, usedIds) {
|
|
374
|
+
let candidate = sanitizeId(preferred) || nextId(prefix);
|
|
375
|
+
while (usedIds.has(candidate)) {
|
|
376
|
+
candidate = nextId(prefix);
|
|
377
|
+
}
|
|
378
|
+
usedIds.add(candidate);
|
|
379
|
+
return candidate;
|
|
380
|
+
}
|
|
381
|
+
function normalizeNodeType(type) {
|
|
382
|
+
return FLOWCHART_NODE_TYPES.includes(type) ? type : "rounded-rect";
|
|
383
|
+
}
|
|
384
|
+
function getNodeSpec(type) {
|
|
385
|
+
return DEFAULT_NODE_SPECS[normalizeNodeType(type)];
|
|
386
|
+
}
|
|
387
|
+
function getNodeSizeBounds(type) {
|
|
388
|
+
const spec = getNodeSpec(type);
|
|
389
|
+
return {
|
|
390
|
+
minWidth: spec.minWidth ?? MIN_NODE_SIZE,
|
|
391
|
+
minHeight: spec.minHeight ?? MIN_NODE_SIZE,
|
|
392
|
+
maxWidth: spec.maxWidth ?? MAX_NODE_SIZE,
|
|
393
|
+
maxHeight: spec.maxHeight ?? MAX_NODE_SIZE
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function clampNodeWidth(type, width, fallback) {
|
|
397
|
+
const bounds = getNodeSizeBounds(type);
|
|
398
|
+
return clamp(toFiniteNumber(width, fallback), bounds.minWidth, bounds.maxWidth);
|
|
399
|
+
}
|
|
400
|
+
function clampNodeHeight(type, height, fallback) {
|
|
401
|
+
const bounds = getNodeSizeBounds(type);
|
|
402
|
+
return clamp(toFiniteNumber(height, fallback), bounds.minHeight, bounds.maxHeight);
|
|
403
|
+
}
|
|
404
|
+
function isNodeResizable(nodeOrType) {
|
|
405
|
+
const spec = typeof nodeOrType === "string" ? getNodeSpec(nodeOrType) : getNodeSpec(nodeOrType?.type);
|
|
406
|
+
return spec.resizable !== false;
|
|
407
|
+
}
|
|
408
|
+
function isNodeTextEditable(nodeOrType) {
|
|
409
|
+
const spec = typeof nodeOrType === "string" ? getNodeSpec(nodeOrType) : getNodeSpec(nodeOrType?.type);
|
|
410
|
+
return spec.textEditable !== false;
|
|
411
|
+
}
|
|
412
|
+
function normalizeEdgeStrokeWidth(value) {
|
|
413
|
+
const next = Number(value);
|
|
414
|
+
if (!Number.isFinite(next)) return DEFAULT_EDGE_STROKE_WIDTH;
|
|
415
|
+
return formatNumber(clamp(next, MIN_EDGE_STROKE_WIDTH, MAX_EDGE_STROKE_WIDTH));
|
|
416
|
+
}
|
|
417
|
+
function getStrokePresetId(strokeWidth) {
|
|
418
|
+
const match = EDGE_STROKE_PRESETS.find((preset) => Math.abs(preset.width - strokeWidth) < 0.01);
|
|
419
|
+
return match?.id || "medium";
|
|
420
|
+
}
|
|
421
|
+
function getStrokePresetWidth(presetId) {
|
|
422
|
+
const preset = EDGE_STROKE_PRESETS.find((item) => item.id === presetId);
|
|
423
|
+
return preset?.width ?? DEFAULT_EDGE_STROKE_WIDTH;
|
|
424
|
+
}
|
|
425
|
+
function normalizeViewport(viewport) {
|
|
426
|
+
return {
|
|
427
|
+
x: toFiniteNumber(viewport?.x, 0),
|
|
428
|
+
y: toFiniteNumber(viewport?.y, 0),
|
|
429
|
+
scale: clamp(toFiniteNumber(viewport?.scale, 1), MIN_SCALE, MAX_SCALE)
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function normalizeNode(rawNode, index, usedIds) {
|
|
433
|
+
const type = normalizeNodeType(rawNode?.type);
|
|
434
|
+
const spec = getNodeSpec(type);
|
|
435
|
+
return {
|
|
436
|
+
id: ensureUniqueId(rawNode?.id, "node", usedIds),
|
|
437
|
+
type,
|
|
438
|
+
x: toFiniteNumber(rawNode?.x, index * 28),
|
|
439
|
+
y: toFiniteNumber(rawNode?.y, index * 18),
|
|
440
|
+
width: clampNodeWidth(type, rawNode?.width, spec.width),
|
|
441
|
+
height: clampNodeHeight(type, rawNode?.height, spec.height),
|
|
442
|
+
text: rawNode?.text == null ? spec.text : String(rawNode.text),
|
|
443
|
+
data: isPlainObject(rawNode?.data) ? deepClone(rawNode.data) : {}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function normalizeEndpoint(rawEndpoint, fallbackPort) {
|
|
447
|
+
return {
|
|
448
|
+
nodeId: sanitizeId(rawEndpoint?.nodeId),
|
|
449
|
+
port: FLOWCHART_PORTS.includes(rawEndpoint?.port) ? rawEndpoint.port : fallbackPort
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function normalizeEdgeMarker(value) {
|
|
453
|
+
return FLOWCHART_EDGE_MARKERS.includes(value) ? value : null;
|
|
454
|
+
}
|
|
455
|
+
function normalizeEdgeRoute(value) {
|
|
456
|
+
return FLOWCHART_EDGE_ROUTES.includes(value) ? value : DEFAULT_EDGE_ROUTE;
|
|
457
|
+
}
|
|
458
|
+
function syncEdgeKind(edge) {
|
|
459
|
+
edge.kind = edge.startMarker === "none" && edge.endMarker === "none" ? "line" : "arrow";
|
|
460
|
+
}
|
|
461
|
+
function normalizeEdge(rawEdge, index, nodeIds, usedIds) {
|
|
462
|
+
const from = normalizeEndpoint(rawEdge?.from, "right");
|
|
463
|
+
const to = normalizeEndpoint(rawEdge?.to, "left");
|
|
464
|
+
if (!from.nodeId || !to.nodeId) return null;
|
|
465
|
+
if (!nodeIds.has(from.nodeId) || !nodeIds.has(to.nodeId)) return null;
|
|
466
|
+
if (!FLOWCHART_PORTS.includes(from.port) || !FLOWCHART_PORTS.includes(to.port)) return null;
|
|
467
|
+
const legacyKind = rawEdge?.kind === "line" ? "line" : "arrow";
|
|
468
|
+
const startMarker = normalizeEdgeMarker(rawEdge?.startMarker) ?? (legacyKind === "line" ? "none" : "none");
|
|
469
|
+
const endMarker = normalizeEdgeMarker(rawEdge?.endMarker) ?? (legacyKind === "line" ? "none" : "arrow");
|
|
470
|
+
const edge = {
|
|
471
|
+
id: ensureUniqueId(rawEdge?.id, "edge", usedIds),
|
|
472
|
+
from,
|
|
473
|
+
to,
|
|
474
|
+
kind: legacyKind,
|
|
475
|
+
startMarker,
|
|
476
|
+
endMarker,
|
|
477
|
+
strokeWidth: normalizeEdgeStrokeWidth(rawEdge?.strokeWidth),
|
|
478
|
+
route: normalizeEdgeRoute(rawEdge?.route),
|
|
479
|
+
label: rawEdge?.label == null ? "" : String(rawEdge.label),
|
|
480
|
+
data: isPlainObject(rawEdge?.data) ? deepClone(rawEdge.data) : {}
|
|
481
|
+
};
|
|
482
|
+
syncEdgeKind(edge);
|
|
483
|
+
return edge;
|
|
484
|
+
}
|
|
485
|
+
function normalizeDocument(input) {
|
|
486
|
+
let source = input;
|
|
487
|
+
if (typeof source === "string") {
|
|
488
|
+
try {
|
|
489
|
+
source = JSON.parse(source);
|
|
490
|
+
} catch {
|
|
491
|
+
source = {};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (!isPlainObject(source)) {
|
|
495
|
+
source = {};
|
|
496
|
+
}
|
|
497
|
+
const usedNodeIds = /* @__PURE__ */ new Set();
|
|
498
|
+
const nodes = (Array.isArray(source.nodes) ? source.nodes : []).map(
|
|
499
|
+
(node, index) => normalizeNode(node, index, usedNodeIds)
|
|
500
|
+
);
|
|
501
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
502
|
+
const usedEdgeIds = /* @__PURE__ */ new Set();
|
|
503
|
+
const edges = (Array.isArray(source.edges) ? source.edges : []).map((edge, index) => normalizeEdge(edge, index, nodeIds, usedEdgeIds)).filter(Boolean);
|
|
504
|
+
return {
|
|
505
|
+
version: VD_FLOWCHART_VERSION,
|
|
506
|
+
viewport: normalizeViewport(source.viewport),
|
|
507
|
+
nodes,
|
|
508
|
+
edges
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function splitLongToken(token, maxChars) {
|
|
512
|
+
const result = [];
|
|
513
|
+
let index = 0;
|
|
514
|
+
while (index < token.length) {
|
|
515
|
+
result.push(token.slice(index, index + maxChars));
|
|
516
|
+
index += maxChars;
|
|
517
|
+
}
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
function wrapText(text, maxChars) {
|
|
521
|
+
const safeMaxChars = Math.max(6, maxChars);
|
|
522
|
+
const lines = [];
|
|
523
|
+
String(text || "").split(/\r?\n/).forEach((paragraph) => {
|
|
524
|
+
const trimmed = paragraph.trim();
|
|
525
|
+
if (!trimmed) {
|
|
526
|
+
lines.push("");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
let current = "";
|
|
530
|
+
trimmed.split(/\s+/).forEach((word) => {
|
|
531
|
+
if (word.length > safeMaxChars) {
|
|
532
|
+
if (current) {
|
|
533
|
+
lines.push(current);
|
|
534
|
+
current = "";
|
|
535
|
+
}
|
|
536
|
+
splitLongToken(word, safeMaxChars).forEach((chunk) => lines.push(chunk));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const next = current ? `${current} ${word}` : word;
|
|
540
|
+
if (next.length <= safeMaxChars) {
|
|
541
|
+
current = next;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (current) lines.push(current);
|
|
545
|
+
current = word;
|
|
546
|
+
});
|
|
547
|
+
if (current) lines.push(current);
|
|
548
|
+
});
|
|
549
|
+
if (!lines.length) return [""];
|
|
550
|
+
if (lines.length <= 6) return lines;
|
|
551
|
+
const clipped = lines.slice(0, 6);
|
|
552
|
+
clipped[5] = clipped[5].length > safeMaxChars - 3 ? `${clipped[5].slice(0, safeMaxChars - 3)}...` : `${clipped[5]}...`;
|
|
553
|
+
return clipped;
|
|
554
|
+
}
|
|
555
|
+
function estimateChars(width) {
|
|
556
|
+
return Math.max(8, Math.floor((width - 24) / 7));
|
|
557
|
+
}
|
|
558
|
+
function getPortPosition(node, port) {
|
|
559
|
+
switch (port) {
|
|
560
|
+
case "top":
|
|
561
|
+
return { x: node.x + node.width / 2, y: node.y };
|
|
562
|
+
case "right":
|
|
563
|
+
return { x: node.x + node.width, y: node.y + node.height / 2 };
|
|
564
|
+
case "bottom":
|
|
565
|
+
return { x: node.x + node.width / 2, y: node.y + node.height };
|
|
566
|
+
case "left":
|
|
567
|
+
default:
|
|
568
|
+
return { x: node.x, y: node.y + node.height / 2 };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function getNearestPort(node, point) {
|
|
572
|
+
return FLOWCHART_PORTS.reduce((best, port) => {
|
|
573
|
+
const portPoint = getPortPosition(node, port);
|
|
574
|
+
const distance = Math.hypot(point.x - portPoint.x, point.y - portPoint.y);
|
|
575
|
+
if (!best || distance < best.distance) {
|
|
576
|
+
return { port, point: portPoint, distance };
|
|
577
|
+
}
|
|
578
|
+
return best;
|
|
579
|
+
}, null);
|
|
580
|
+
}
|
|
581
|
+
function getPortNormal(port) {
|
|
582
|
+
switch (port) {
|
|
583
|
+
case "top":
|
|
584
|
+
return { x: 0, y: -1 };
|
|
585
|
+
case "right":
|
|
586
|
+
return { x: 1, y: 0 };
|
|
587
|
+
case "bottom":
|
|
588
|
+
return { x: 0, y: 1 };
|
|
589
|
+
case "left":
|
|
590
|
+
default:
|
|
591
|
+
return { x: -1, y: 0 };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function isPointInsideNode(node, point) {
|
|
595
|
+
return point.x >= node.x && point.x <= node.x + node.width && point.y >= node.y && point.y <= node.y + node.height;
|
|
596
|
+
}
|
|
597
|
+
function getPortByDirection(node, point) {
|
|
598
|
+
const centerX = node.x + node.width / 2;
|
|
599
|
+
const centerY = node.y + node.height / 2;
|
|
600
|
+
const deltaX = point.x - centerX;
|
|
601
|
+
const deltaY = point.y - centerY;
|
|
602
|
+
const port = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX > 0 ? "right" : "left" : deltaY > 0 ? "bottom" : "top";
|
|
603
|
+
const portPoint = getPortPosition(node, port);
|
|
604
|
+
return {
|
|
605
|
+
port,
|
|
606
|
+
point: portPoint,
|
|
607
|
+
distance: Math.hypot(point.x - portPoint.x, point.y - portPoint.y)
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function pickPortForNode(node, point, referencePoint = null) {
|
|
611
|
+
if (isPointInsideNode(node, point)) {
|
|
612
|
+
const centerX = node.x + node.width / 2;
|
|
613
|
+
const centerY = node.y + node.height / 2;
|
|
614
|
+
const distanceFromCenter = Math.hypot(point.x - centerX, point.y - centerY);
|
|
615
|
+
if (referencePoint && distanceFromCenter <= CONNECTION_CENTER_LOCK_RADIUS) {
|
|
616
|
+
return getPortByDirection(node, referencePoint);
|
|
617
|
+
}
|
|
618
|
+
return getPortByDirection(node, point);
|
|
619
|
+
}
|
|
620
|
+
return getNearestPort(node, point);
|
|
621
|
+
}
|
|
622
|
+
function getDistanceToNodeBounds(node, point) {
|
|
623
|
+
const left = node.x;
|
|
624
|
+
const right = node.x + node.width;
|
|
625
|
+
const top = node.y;
|
|
626
|
+
const bottom = node.y + node.height;
|
|
627
|
+
const dx = point.x < left ? left - point.x : point.x > right ? point.x - right : 0;
|
|
628
|
+
const dy = point.y < top ? top - point.y : point.y > bottom ? point.y - bottom : 0;
|
|
629
|
+
return Math.hypot(dx, dy);
|
|
630
|
+
}
|
|
631
|
+
function offsetPoint(point, normal, distance) {
|
|
632
|
+
return {
|
|
633
|
+
x: point.x + normal.x * distance,
|
|
634
|
+
y: point.y + normal.y * distance
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
function isHorizontalPort(port) {
|
|
638
|
+
return port === "left" || port === "right";
|
|
639
|
+
}
|
|
640
|
+
function addDistinctPoint(points, point) {
|
|
641
|
+
const previous = points[points.length - 1];
|
|
642
|
+
if (!previous || previous.x !== point.x || previous.y !== point.y) {
|
|
643
|
+
points.push(point);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function buildPathFromPoints(points) {
|
|
647
|
+
return points.map((point, index) => {
|
|
648
|
+
const command = index === 0 ? "M" : "L";
|
|
649
|
+
return `${command} ${formatNumber(point.x)} ${formatNumber(point.y)}`;
|
|
650
|
+
}).join(" ");
|
|
651
|
+
}
|
|
652
|
+
function buildRoundedPath(points, radius) {
|
|
653
|
+
if (points.length < 3 || radius <= 0) {
|
|
654
|
+
return buildPathFromPoints(points);
|
|
655
|
+
}
|
|
656
|
+
let d = `M ${formatNumber(points[0].x)} ${formatNumber(points[0].y)}`;
|
|
657
|
+
for (let index = 1; index < points.length - 1; index += 1) {
|
|
658
|
+
const prev = points[index - 1];
|
|
659
|
+
const corner = points[index];
|
|
660
|
+
const next = points[index + 1];
|
|
661
|
+
const lenIn = Math.hypot(corner.x - prev.x, corner.y - prev.y);
|
|
662
|
+
const lenOut = Math.hypot(next.x - corner.x, next.y - corner.y);
|
|
663
|
+
const r = Math.min(radius, lenIn / 2, lenOut / 2);
|
|
664
|
+
if (!(r > 0)) {
|
|
665
|
+
d += ` L ${formatNumber(corner.x)} ${formatNumber(corner.y)}`;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const approach = {
|
|
669
|
+
x: corner.x - (corner.x - prev.x) / lenIn * r,
|
|
670
|
+
y: corner.y - (corner.y - prev.y) / lenIn * r
|
|
671
|
+
};
|
|
672
|
+
const departure = {
|
|
673
|
+
x: corner.x + (next.x - corner.x) / lenOut * r,
|
|
674
|
+
y: corner.y + (next.y - corner.y) / lenOut * r
|
|
675
|
+
};
|
|
676
|
+
d += ` L ${formatNumber(approach.x)} ${formatNumber(approach.y)}`;
|
|
677
|
+
d += ` Q ${formatNumber(corner.x)} ${formatNumber(corner.y)} ${formatNumber(departure.x)} ${formatNumber(departure.y)}`;
|
|
678
|
+
}
|
|
679
|
+
const last = points[points.length - 1];
|
|
680
|
+
d += ` L ${formatNumber(last.x)} ${formatNumber(last.y)}`;
|
|
681
|
+
return d;
|
|
682
|
+
}
|
|
683
|
+
function getPolylineLabelPoint(points) {
|
|
684
|
+
let totalLength = 0;
|
|
685
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
686
|
+
totalLength += Math.hypot(
|
|
687
|
+
points[index].x - points[index - 1].x,
|
|
688
|
+
points[index].y - points[index - 1].y
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
if (!totalLength) {
|
|
692
|
+
const first = points[0] || { x: 0, y: 0 };
|
|
693
|
+
return { x: formatNumber(first.x), y: formatNumber(first.y) };
|
|
694
|
+
}
|
|
695
|
+
const halfway = totalLength / 2;
|
|
696
|
+
let covered = 0;
|
|
697
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
698
|
+
const start = points[index - 1];
|
|
699
|
+
const end = points[index];
|
|
700
|
+
const segmentLength = Math.hypot(end.x - start.x, end.y - start.y);
|
|
701
|
+
if (!segmentLength) continue;
|
|
702
|
+
if (covered + segmentLength >= halfway) {
|
|
703
|
+
const ratio = (halfway - covered) / segmentLength;
|
|
704
|
+
return {
|
|
705
|
+
x: formatNumber(start.x + (end.x - start.x) * ratio),
|
|
706
|
+
y: formatNumber(start.y + (end.y - start.y) * ratio)
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
covered += segmentLength;
|
|
710
|
+
}
|
|
711
|
+
const last = points[points.length - 1] || { x: 0, y: 0 };
|
|
712
|
+
return { x: formatNumber(last.x), y: formatNumber(last.y) };
|
|
713
|
+
}
|
|
714
|
+
function curveControlPoint(point, port, target, curvature) {
|
|
715
|
+
const normal = getPortNormal(port);
|
|
716
|
+
const dx = target.x - point.x;
|
|
717
|
+
const dy = target.y - point.y;
|
|
718
|
+
const distance = Math.hypot(dx, dy) || 1;
|
|
719
|
+
const align = (normal.x * dx + normal.y * dy) / distance;
|
|
720
|
+
const ease = 0.55 + 0.45 * align;
|
|
721
|
+
const arm = clamp(distance * curvature * ease, MIN_CURVE_ARM, MAX_CURVE_ARM);
|
|
722
|
+
return { x: point.x + normal.x * arm, y: point.y + normal.y * arm };
|
|
723
|
+
}
|
|
724
|
+
function buildCurvePath(fromPoint, toPoint, fromPort = "right", toPort = "left") {
|
|
725
|
+
const controlA = curveControlPoint(fromPoint, fromPort, toPoint, EDGE_CURVATURE);
|
|
726
|
+
const controlB = curveControlPoint(toPoint, toPort, fromPoint, EDGE_CURVATURE);
|
|
727
|
+
const labelX = 0.125 * fromPoint.x + 0.375 * controlA.x + 0.375 * controlB.x + 0.125 * toPoint.x;
|
|
728
|
+
const labelY = 0.125 * fromPoint.y + 0.375 * controlA.y + 0.375 * controlB.y + 0.125 * toPoint.y;
|
|
729
|
+
return {
|
|
730
|
+
d: `M ${formatNumber(fromPoint.x)} ${formatNumber(fromPoint.y)} C ${formatNumber(controlA.x)} ${formatNumber(controlA.y)} ${formatNumber(controlB.x)} ${formatNumber(controlB.y)} ${formatNumber(toPoint.x)} ${formatNumber(toPoint.y)}`,
|
|
731
|
+
labelX: formatNumber(labelX),
|
|
732
|
+
labelY: formatNumber(labelY)
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
function buildStraightPath(fromPoint, toPoint) {
|
|
736
|
+
return {
|
|
737
|
+
d: buildPathFromPoints([fromPoint, toPoint]),
|
|
738
|
+
labelX: formatNumber((fromPoint.x + toPoint.x) / 2),
|
|
739
|
+
labelY: formatNumber((fromPoint.y + toPoint.y) / 2)
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function getNodeRectBounds(node, pad = 0) {
|
|
743
|
+
if (!node) return null;
|
|
744
|
+
return {
|
|
745
|
+
left: node.x - pad,
|
|
746
|
+
top: node.y - pad,
|
|
747
|
+
right: node.x + node.width + pad,
|
|
748
|
+
bottom: node.y + node.height + pad
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
function segmentIntersectsRect(a, b, rect) {
|
|
752
|
+
if (!rect) return false;
|
|
753
|
+
const minX = Math.min(a.x, b.x);
|
|
754
|
+
const maxX = Math.max(a.x, b.x);
|
|
755
|
+
const minY = Math.min(a.y, b.y);
|
|
756
|
+
const maxY = Math.max(a.y, b.y);
|
|
757
|
+
return minX < rect.right && maxX > rect.left && minY < rect.bottom && maxY > rect.top;
|
|
758
|
+
}
|
|
759
|
+
function pickOrthogonalChannel(from, to, low, high) {
|
|
760
|
+
const costLow = Math.abs(low - from) + Math.abs(low - to);
|
|
761
|
+
const costHigh = Math.abs(high - from) + Math.abs(high - to);
|
|
762
|
+
if (costLow < costHigh) return low;
|
|
763
|
+
if (costHigh < costLow) return high;
|
|
764
|
+
return to >= from ? high : low;
|
|
765
|
+
}
|
|
766
|
+
function getOrthogonalMidpoints(fromStub, toStub, fromPort, toPort) {
|
|
767
|
+
const sourceHorizontal = isHorizontalPort(fromPort);
|
|
768
|
+
const targetHorizontal = isHorizontalPort(toPort);
|
|
769
|
+
if (sourceHorizontal && targetHorizontal) {
|
|
770
|
+
const midX = (fromStub.x + toStub.x) / 2;
|
|
771
|
+
return [
|
|
772
|
+
{ x: midX, y: fromStub.y },
|
|
773
|
+
{ x: midX, y: toStub.y }
|
|
774
|
+
];
|
|
775
|
+
}
|
|
776
|
+
if (!sourceHorizontal && !targetHorizontal) {
|
|
777
|
+
const midY = (fromStub.y + toStub.y) / 2;
|
|
778
|
+
return [
|
|
779
|
+
{ x: fromStub.x, y: midY },
|
|
780
|
+
{ x: toStub.x, y: midY }
|
|
781
|
+
];
|
|
782
|
+
}
|
|
783
|
+
if (sourceHorizontal) {
|
|
784
|
+
return [{ x: toStub.x, y: fromStub.y }];
|
|
785
|
+
}
|
|
786
|
+
return [{ x: fromStub.x, y: toStub.y }];
|
|
787
|
+
}
|
|
788
|
+
function getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect) {
|
|
789
|
+
const fromNormal = getPortNormal(fromPort);
|
|
790
|
+
const toNormal = getPortNormal(toPort);
|
|
791
|
+
const fromStub = offsetPoint(fromPoint, fromNormal, ORTHOGONAL_STUB_LENGTH);
|
|
792
|
+
const toStub = offsetPoint(toPoint, toNormal, ORTHOGONAL_STUB_LENGTH);
|
|
793
|
+
const sourceHorizontal = isHorizontalPort(fromPort);
|
|
794
|
+
const targetHorizontal = isHorizontalPort(toPort);
|
|
795
|
+
const mids = [];
|
|
796
|
+
if (sourceHorizontal && targetHorizontal) {
|
|
797
|
+
const facing = fromNormal.x === -toNormal.x;
|
|
798
|
+
const hasRoom = fromNormal.x > 0 ? fromStub.x <= toStub.x : fromStub.x >= toStub.x;
|
|
799
|
+
if (facing && hasRoom) {
|
|
800
|
+
const midX = (fromStub.x + toStub.x) / 2;
|
|
801
|
+
mids.push({ x: midX, y: fromStub.y }, { x: midX, y: toStub.y });
|
|
802
|
+
} else {
|
|
803
|
+
const top = Math.min(fromRect.top, toRect.top);
|
|
804
|
+
const bottom = Math.max(fromRect.bottom, toRect.bottom);
|
|
805
|
+
const yChannel = pickOrthogonalChannel(fromStub.y, toStub.y, top, bottom);
|
|
806
|
+
mids.push({ x: fromStub.x, y: yChannel }, { x: toStub.x, y: yChannel });
|
|
807
|
+
}
|
|
808
|
+
} else if (!sourceHorizontal && !targetHorizontal) {
|
|
809
|
+
const facing = fromNormal.y === -toNormal.y;
|
|
810
|
+
const hasRoom = fromNormal.y > 0 ? fromStub.y <= toStub.y : fromStub.y >= toStub.y;
|
|
811
|
+
if (facing && hasRoom) {
|
|
812
|
+
const midY = (fromStub.y + toStub.y) / 2;
|
|
813
|
+
mids.push({ x: fromStub.x, y: midY }, { x: toStub.x, y: midY });
|
|
814
|
+
} else {
|
|
815
|
+
const left = Math.min(fromRect.left, toRect.left);
|
|
816
|
+
const right = Math.max(fromRect.right, toRect.right);
|
|
817
|
+
const xChannel = pickOrthogonalChannel(fromStub.x, toStub.x, left, right);
|
|
818
|
+
mids.push({ x: xChannel, y: fromStub.y }, { x: xChannel, y: toStub.y });
|
|
819
|
+
}
|
|
820
|
+
} else {
|
|
821
|
+
const cleanCorner = sourceHorizontal ? { x: toStub.x, y: fromStub.y } : { x: fromStub.x, y: toStub.y };
|
|
822
|
+
const altCorner = sourceHorizontal ? { x: fromStub.x, y: toStub.y } : { x: toStub.x, y: fromStub.y };
|
|
823
|
+
const legsClear = (corner) => !segmentIntersectsRect(fromStub, corner, fromRect) && !segmentIntersectsRect(fromStub, corner, toRect) && !segmentIntersectsRect(corner, toStub, fromRect) && !segmentIntersectsRect(corner, toStub, toRect);
|
|
824
|
+
mids.push(legsClear(cleanCorner) || !legsClear(altCorner) ? cleanCorner : altCorner);
|
|
825
|
+
}
|
|
826
|
+
const points = [];
|
|
827
|
+
[fromPoint, fromStub, ...mids, toStub, toPoint].forEach(
|
|
828
|
+
(point) => addDistinctPoint(points, point)
|
|
829
|
+
);
|
|
830
|
+
return points;
|
|
831
|
+
}
|
|
832
|
+
function buildOrthogonalPath(fromPoint, toPoint, fromPort = "right", toPort = "left", fromRect = null, toRect = null) {
|
|
833
|
+
let points;
|
|
834
|
+
if (fromRect && toRect) {
|
|
835
|
+
points = getOrthogonalPoints(fromPoint, toPoint, fromPort, toPort, fromRect, toRect);
|
|
836
|
+
} else {
|
|
837
|
+
const fromStub = offsetPoint(fromPoint, getPortNormal(fromPort), ORTHOGONAL_STUB_LENGTH);
|
|
838
|
+
const toStub = offsetPoint(toPoint, getPortNormal(toPort), ORTHOGONAL_STUB_LENGTH);
|
|
839
|
+
points = [];
|
|
840
|
+
[
|
|
841
|
+
fromPoint,
|
|
842
|
+
fromStub,
|
|
843
|
+
...getOrthogonalMidpoints(fromStub, toStub, fromPort, toPort),
|
|
844
|
+
toStub,
|
|
845
|
+
toPoint
|
|
846
|
+
].forEach((point) => addDistinctPoint(points, point));
|
|
847
|
+
}
|
|
848
|
+
const label = getPolylineLabelPoint(points);
|
|
849
|
+
return {
|
|
850
|
+
d: buildRoundedPath(points, ORTHOGONAL_CORNER_RADIUS),
|
|
851
|
+
labelX: label.x,
|
|
852
|
+
labelY: label.y
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
function buildEdgePath(edge, fromNode = null, toNode = null) {
|
|
856
|
+
const fromPort = edge?.from?.port || edge?.fromPort || "right";
|
|
857
|
+
const toPort = edge?.to?.port || edge?.toPort || "left";
|
|
858
|
+
const fromPoint = edge?.fromPoint || (fromNode ? getPortPosition(fromNode, fromPort) : null);
|
|
859
|
+
const toPoint = edge?.toPoint || (toNode ? getPortPosition(toNode, toPort) : null);
|
|
860
|
+
const route = normalizeEdgeRoute(edge?.route);
|
|
861
|
+
if (!fromPoint || !toPoint) {
|
|
862
|
+
return { d: "", labelX: 0, labelY: 0 };
|
|
863
|
+
}
|
|
864
|
+
if (route === "straight") {
|
|
865
|
+
return buildStraightPath(fromPoint, toPoint);
|
|
866
|
+
}
|
|
867
|
+
if (route === "orthogonal") {
|
|
868
|
+
const fromRect = !edge?.fromPoint && fromNode ? getNodeRectBounds(fromNode, ORTHOGONAL_CLEARANCE) : null;
|
|
869
|
+
const toRect = !edge?.toPoint && toNode ? getNodeRectBounds(toNode, ORTHOGONAL_CLEARANCE) : null;
|
|
870
|
+
return buildOrthogonalPath(fromPoint, toPoint, fromPort, toPort, fromRect, toRect);
|
|
871
|
+
}
|
|
872
|
+
return buildCurvePath(fromPoint, toPoint, fromPort, toPort);
|
|
873
|
+
}
|
|
874
|
+
function createArrowMarker(id, strokeWidth, reversed = false) {
|
|
875
|
+
const size = formatNumber(Math.max(9, strokeWidth * 4.4));
|
|
876
|
+
const refInset = formatNumber(Math.max(1.5, strokeWidth * 0.85));
|
|
877
|
+
const marker = svgEl("marker", {
|
|
878
|
+
id,
|
|
879
|
+
markerWidth: size,
|
|
880
|
+
markerHeight: size,
|
|
881
|
+
refX: reversed ? refInset : formatNumber(size - refInset),
|
|
882
|
+
refY: formatNumber(size / 2),
|
|
883
|
+
orient: "auto",
|
|
884
|
+
markerUnits: "userSpaceOnUse"
|
|
885
|
+
});
|
|
886
|
+
marker.appendChild(
|
|
887
|
+
svgEl("path", {
|
|
888
|
+
d: reversed ? `M ${formatNumber(size)} 0 L 0 ${formatNumber(size / 2)} L ${formatNumber(size)} ${formatNumber(size)} z` : `M 0 0 L ${formatNumber(size)} ${formatNumber(size / 2)} L 0 ${formatNumber(size)} z`,
|
|
889
|
+
fill: "var(--vd-flowchart-accent)"
|
|
890
|
+
})
|
|
891
|
+
);
|
|
892
|
+
return marker;
|
|
893
|
+
}
|
|
894
|
+
function createDotMarker(id, strokeWidth) {
|
|
895
|
+
const size = formatNumber(Math.max(8, strokeWidth * 3.3));
|
|
896
|
+
const radius = formatNumber(Math.max(2.75, strokeWidth * 1.45));
|
|
897
|
+
const marker = svgEl("marker", {
|
|
898
|
+
id,
|
|
899
|
+
markerWidth: size,
|
|
900
|
+
markerHeight: size,
|
|
901
|
+
refX: formatNumber(size / 2),
|
|
902
|
+
refY: formatNumber(size / 2),
|
|
903
|
+
orient: "auto",
|
|
904
|
+
markerUnits: "userSpaceOnUse"
|
|
905
|
+
});
|
|
906
|
+
marker.appendChild(
|
|
907
|
+
svgEl("circle", {
|
|
908
|
+
cx: formatNumber(size / 2),
|
|
909
|
+
cy: formatNumber(size / 2),
|
|
910
|
+
r: radius,
|
|
911
|
+
fill: "var(--vd-flowchart-accent)"
|
|
912
|
+
})
|
|
913
|
+
);
|
|
914
|
+
return marker;
|
|
915
|
+
}
|
|
916
|
+
function getNodeFontMetrics(node) {
|
|
917
|
+
if (node.type === "label") {
|
|
918
|
+
return { fontSize: 18, lineHeight: 20 };
|
|
919
|
+
}
|
|
920
|
+
if (node.type === "textbox") {
|
|
921
|
+
return { fontSize: 13, lineHeight: 18 };
|
|
922
|
+
}
|
|
923
|
+
return { fontSize: 14, lineHeight: 18 };
|
|
924
|
+
}
|
|
925
|
+
function getResizeHandlePosition(node, handle) {
|
|
926
|
+
const middleX = node.width / 2;
|
|
927
|
+
const middleY = node.height / 2;
|
|
928
|
+
const x = handle.includes("w") ? 0 : handle.includes("e") ? node.width : middleX;
|
|
929
|
+
const y = handle.includes("n") ? 0 : handle.includes("s") ? node.height : middleY;
|
|
930
|
+
return { x, y };
|
|
931
|
+
}
|
|
932
|
+
function getResizeCursor(handle) {
|
|
933
|
+
if (handle === "n" || handle === "s") return "ns-resize";
|
|
934
|
+
if (handle === "e" || handle === "w") return "ew-resize";
|
|
935
|
+
if (handle === "ne" || handle === "sw") return "nesw-resize";
|
|
936
|
+
return "nwse-resize";
|
|
937
|
+
}
|
|
938
|
+
function getBounds(nodes) {
|
|
939
|
+
if (!nodes.length) {
|
|
940
|
+
return { left: 0, top: 0, right: 0, bottom: 0 };
|
|
941
|
+
}
|
|
942
|
+
return nodes.reduce(
|
|
943
|
+
(accumulator, node) => ({
|
|
944
|
+
left: Math.min(accumulator.left, node.x),
|
|
945
|
+
top: Math.min(accumulator.top, node.y),
|
|
946
|
+
right: Math.max(accumulator.right, node.x + node.width),
|
|
947
|
+
bottom: Math.max(accumulator.bottom, node.y + node.height)
|
|
948
|
+
}),
|
|
949
|
+
{
|
|
950
|
+
left: Number.POSITIVE_INFINITY,
|
|
951
|
+
top: Number.POSITIVE_INFINITY,
|
|
952
|
+
right: Number.NEGATIVE_INFINITY,
|
|
953
|
+
bottom: Number.NEGATIVE_INFINITY
|
|
954
|
+
}
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
function createField(labelText, control) {
|
|
958
|
+
const wrapper = createElement("div", { className: "vd-flowchart-field" });
|
|
959
|
+
const label = createElement("label", { text: labelText });
|
|
960
|
+
wrapper.appendChild(label);
|
|
961
|
+
wrapper.appendChild(control);
|
|
962
|
+
return wrapper;
|
|
963
|
+
}
|
|
964
|
+
function createPalettePreview(type) {
|
|
965
|
+
const preview = svgEl("svg", {
|
|
966
|
+
class: `vd-flowchart-palette-preview vd-flowchart-palette-preview--${type}`,
|
|
967
|
+
viewBox: "0 0 72 44",
|
|
968
|
+
"aria-hidden": "true",
|
|
969
|
+
focusable: "false"
|
|
970
|
+
});
|
|
971
|
+
const baseClass = `vd-flowchart-palette-shape vd-flowchart-palette-shape--${type}`;
|
|
972
|
+
if (type === "arrow") {
|
|
973
|
+
preview.appendChild(
|
|
974
|
+
svgEl("path", {
|
|
975
|
+
class: "vd-flowchart-palette-arrow",
|
|
976
|
+
d: "M 12 30 C 28 10 44 10 60 22"
|
|
977
|
+
})
|
|
978
|
+
);
|
|
979
|
+
preview.appendChild(
|
|
980
|
+
svgEl("path", {
|
|
981
|
+
class: "vd-flowchart-palette-arrowhead",
|
|
982
|
+
d: "M 53 16 L 64 22 L 52 27 z"
|
|
983
|
+
})
|
|
984
|
+
);
|
|
985
|
+
return preview;
|
|
986
|
+
}
|
|
987
|
+
if (type === "rounded-rect") {
|
|
988
|
+
preview.appendChild(
|
|
989
|
+
svgEl("rect", {
|
|
990
|
+
class: baseClass,
|
|
991
|
+
x: 10,
|
|
992
|
+
y: 10,
|
|
993
|
+
width: 52,
|
|
994
|
+
height: 24,
|
|
995
|
+
rx: 8,
|
|
996
|
+
ry: 8
|
|
997
|
+
})
|
|
998
|
+
);
|
|
999
|
+
return preview;
|
|
1000
|
+
}
|
|
1001
|
+
if (type === "rect") {
|
|
1002
|
+
preview.appendChild(
|
|
1003
|
+
svgEl("rect", {
|
|
1004
|
+
class: baseClass,
|
|
1005
|
+
x: 10,
|
|
1006
|
+
y: 10,
|
|
1007
|
+
width: 52,
|
|
1008
|
+
height: 24,
|
|
1009
|
+
rx: 1,
|
|
1010
|
+
ry: 1
|
|
1011
|
+
})
|
|
1012
|
+
);
|
|
1013
|
+
return preview;
|
|
1014
|
+
}
|
|
1015
|
+
if (type === "diamond") {
|
|
1016
|
+
preview.appendChild(
|
|
1017
|
+
svgEl("polygon", {
|
|
1018
|
+
class: baseClass,
|
|
1019
|
+
points: "36,6 64,22 36,38 8,22"
|
|
1020
|
+
})
|
|
1021
|
+
);
|
|
1022
|
+
return preview;
|
|
1023
|
+
}
|
|
1024
|
+
if (type === "circle") {
|
|
1025
|
+
preview.appendChild(
|
|
1026
|
+
svgEl("ellipse", {
|
|
1027
|
+
class: baseClass,
|
|
1028
|
+
cx: 36,
|
|
1029
|
+
cy: 22,
|
|
1030
|
+
rx: 18,
|
|
1031
|
+
ry: 18
|
|
1032
|
+
})
|
|
1033
|
+
);
|
|
1034
|
+
return preview;
|
|
1035
|
+
}
|
|
1036
|
+
if (type === "junction") {
|
|
1037
|
+
preview.appendChild(
|
|
1038
|
+
svgEl("path", {
|
|
1039
|
+
class: "vd-flowchart-palette-junction-lines",
|
|
1040
|
+
d: "M 12 22 H 27 M 45 22 H 60 M 36 8 V 14 M 36 30 V 36"
|
|
1041
|
+
})
|
|
1042
|
+
);
|
|
1043
|
+
preview.appendChild(
|
|
1044
|
+
svgEl("circle", {
|
|
1045
|
+
class: baseClass,
|
|
1046
|
+
cx: 36,
|
|
1047
|
+
cy: 22,
|
|
1048
|
+
r: 8
|
|
1049
|
+
})
|
|
1050
|
+
);
|
|
1051
|
+
return preview;
|
|
1052
|
+
}
|
|
1053
|
+
if (type === "textbox") {
|
|
1054
|
+
preview.appendChild(
|
|
1055
|
+
svgEl("rect", {
|
|
1056
|
+
class: baseClass,
|
|
1057
|
+
x: 10,
|
|
1058
|
+
y: 8,
|
|
1059
|
+
width: 52,
|
|
1060
|
+
height: 28,
|
|
1061
|
+
rx: 6,
|
|
1062
|
+
ry: 6
|
|
1063
|
+
})
|
|
1064
|
+
);
|
|
1065
|
+
preview.appendChild(
|
|
1066
|
+
svgEl("path", {
|
|
1067
|
+
class: "vd-flowchart-palette-lines",
|
|
1068
|
+
d: "M 20 18 H 52 M 20 25 H 46"
|
|
1069
|
+
})
|
|
1070
|
+
);
|
|
1071
|
+
return preview;
|
|
1072
|
+
}
|
|
1073
|
+
const label = svgEl("text", {
|
|
1074
|
+
class: "vd-flowchart-palette-label-mark",
|
|
1075
|
+
x: 36,
|
|
1076
|
+
y: 24,
|
|
1077
|
+
"text-anchor": "middle",
|
|
1078
|
+
"dominant-baseline": "middle"
|
|
1079
|
+
});
|
|
1080
|
+
label.textContent = "Aa";
|
|
1081
|
+
preview.appendChild(label);
|
|
1082
|
+
return preview;
|
|
1083
|
+
}
|
|
1084
|
+
var VdFlowchart = class {
|
|
1085
|
+
constructor(options = {}) {
|
|
1086
|
+
this.element = resolveElement(options.element || options.target);
|
|
1087
|
+
this.readonly = Boolean(options.readonly);
|
|
1088
|
+
this.gridSize = clamp(toFiniteNumber(options.gridSize, DEFAULT_GRID_SIZE), 12, 64);
|
|
1089
|
+
this.documentData = normalizeDocument(options.data || {});
|
|
1090
|
+
this.listeners = {};
|
|
1091
|
+
this.selection = null;
|
|
1092
|
+
this.interaction = null;
|
|
1093
|
+
this.activeTool = null;
|
|
1094
|
+
this.paletteSerial = 0;
|
|
1095
|
+
this.destroyed = false;
|
|
1096
|
+
this.lastNodePointer = null;
|
|
1097
|
+
this.reconnectEdgeId = null;
|
|
1098
|
+
this.clipboard = null;
|
|
1099
|
+
this.gridPatternId = nextId("flowchart-grid");
|
|
1100
|
+
this.markerIds = /* @__PURE__ */ new Map();
|
|
1101
|
+
this.textEditor = null;
|
|
1102
|
+
this.historyEnabled = options.history !== false;
|
|
1103
|
+
this.historyLimit = Math.max(1, Math.floor(toFiniteNumber(options.historyLimit, 100)));
|
|
1104
|
+
this.history = [];
|
|
1105
|
+
this.historyIndex = -1;
|
|
1106
|
+
this.isApplyingHistory = false;
|
|
1107
|
+
this.autoFit = Boolean(options.autoFit);
|
|
1108
|
+
this.readyEmitted = false;
|
|
1109
|
+
this.resizeObserver = null;
|
|
1110
|
+
this.handleToolbarClick = this.handleToolbarClick.bind(this);
|
|
1111
|
+
this.handleArrangeChange = this.handleArrangeChange.bind(this);
|
|
1112
|
+
this.handlePaletteClick = this.handlePaletteClick.bind(this);
|
|
1113
|
+
this.handlePointerDown = this.handlePointerDown.bind(this);
|
|
1114
|
+
this.handlePointerMove = this.handlePointerMove.bind(this);
|
|
1115
|
+
this.handlePointerUp = this.handlePointerUp.bind(this);
|
|
1116
|
+
this.handleClick = this.handleClick.bind(this);
|
|
1117
|
+
this.handleDoubleClick = this.handleDoubleClick.bind(this);
|
|
1118
|
+
this.handleWheel = this.handleWheel.bind(this);
|
|
1119
|
+
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
1120
|
+
this.handleSelectionFieldInput = this.handleSelectionFieldInput.bind(this);
|
|
1121
|
+
this.handleSelectionFieldChange = this.handleSelectionFieldChange.bind(this);
|
|
1122
|
+
this.handleJsonActionClick = this.handleJsonActionClick.bind(this);
|
|
1123
|
+
this.handleResize = this.handleResize.bind(this);
|
|
1124
|
+
this.buildShell();
|
|
1125
|
+
this.bindEvents();
|
|
1126
|
+
this.render();
|
|
1127
|
+
this.seedHistory();
|
|
1128
|
+
this.updateHistoryButtons();
|
|
1129
|
+
this.scheduleReady();
|
|
1130
|
+
}
|
|
1131
|
+
// --- Readiness -----------------------------------------------------------
|
|
1132
|
+
// The editor builds synchronously, but fitView() needs real canvas
|
|
1133
|
+
// dimensions, which only exist after the host is laid out. Emit `ready` once
|
|
1134
|
+
// the canvas reports a non-zero size (the old code fell back to an 800x560
|
|
1135
|
+
// guess); consumers can then fitView() without nextTick/setTimeout. The emit
|
|
1136
|
+
// is always deferred at least a microtask so a listener attached right after
|
|
1137
|
+
// construction — e.g. `new VdFlowchart(...).on('ready', ...)` or the Vue
|
|
1138
|
+
// wrapper — is registered before it fires.
|
|
1139
|
+
scheduleReady() {
|
|
1140
|
+
if (this.readyEmitted || this.destroyed || !hasWindow()) return;
|
|
1141
|
+
const tryEmit = () => {
|
|
1142
|
+
if (this.readyEmitted || this.destroyed) return true;
|
|
1143
|
+
const ready = this.canvasEl.clientWidth > 0 && this.canvasEl.clientHeight > 0;
|
|
1144
|
+
if (!ready) return false;
|
|
1145
|
+
this.readyEmitted = true;
|
|
1146
|
+
if (this.autoFit) this.fitView();
|
|
1147
|
+
this.emit("ready", this);
|
|
1148
|
+
return true;
|
|
1149
|
+
};
|
|
1150
|
+
const startObserving = () => {
|
|
1151
|
+
if (this.readyEmitted || this.destroyed || tryEmit()) return;
|
|
1152
|
+
if (typeof ResizeObserver === "function") {
|
|
1153
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
1154
|
+
if (tryEmit()) {
|
|
1155
|
+
this.resizeObserver?.disconnect();
|
|
1156
|
+
this.resizeObserver = null;
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
this.resizeObserver.observe(this.canvasEl);
|
|
1160
|
+
}
|
|
1161
|
+
const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb) => setTimeout(cb, 16);
|
|
1162
|
+
raf(() => tryEmit());
|
|
1163
|
+
};
|
|
1164
|
+
if (typeof queueMicrotask === "function") {
|
|
1165
|
+
queueMicrotask(startObserving);
|
|
1166
|
+
} else {
|
|
1167
|
+
Promise.resolve().then(startObserving);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
buildShell() {
|
|
1171
|
+
this.element.innerHTML = "";
|
|
1172
|
+
this.element.classList.add("vd-flowchart-host");
|
|
1173
|
+
this.root = createElement("div", {
|
|
1174
|
+
className: `vd-flowchart-shell${this.readonly ? " vd-flowchart-readonly" : ""}`
|
|
1175
|
+
});
|
|
1176
|
+
this.toolbar = createElement("div", { className: "vd-flowchart-toolbar" });
|
|
1177
|
+
const toolbarLeft = createElement("div", { className: "vd-flowchart-toolbar-group" });
|
|
1178
|
+
const toolbarRight = createElement("div", { className: "vd-flowchart-toolbar-group" });
|
|
1179
|
+
this.zoomOutButton = createElement("button", { className: "vd-flowchart-btn", text: "-" });
|
|
1180
|
+
this.zoomOutButton.setAttribute("data-flowchart-action", "zoom-out");
|
|
1181
|
+
this.zoomOutButton.setAttribute("type", "button");
|
|
1182
|
+
this.zoomInButton = createElement("button", { className: "vd-flowchart-btn", text: "+" });
|
|
1183
|
+
this.zoomInButton.setAttribute("data-flowchart-action", "zoom-in");
|
|
1184
|
+
this.zoomInButton.setAttribute("type", "button");
|
|
1185
|
+
this.resetViewButton = createElement("button", {
|
|
1186
|
+
className: "vd-flowchart-btn",
|
|
1187
|
+
text: "Reset"
|
|
1188
|
+
});
|
|
1189
|
+
this.resetViewButton.setAttribute("data-flowchart-action", "reset-view");
|
|
1190
|
+
this.resetViewButton.setAttribute("type", "button");
|
|
1191
|
+
this.fitViewButton = createElement("button", { className: "vd-flowchart-btn", text: "Fit" });
|
|
1192
|
+
this.fitViewButton.setAttribute("data-flowchart-action", "fit-view");
|
|
1193
|
+
this.fitViewButton.setAttribute("type", "button");
|
|
1194
|
+
this.undoButton = createElement("button", {
|
|
1195
|
+
className: "vd-flowchart-btn",
|
|
1196
|
+
text: "\u21B6",
|
|
1197
|
+
title: "Undo",
|
|
1198
|
+
disabled: true
|
|
1199
|
+
});
|
|
1200
|
+
this.undoButton.setAttribute("data-flowchart-action", "undo");
|
|
1201
|
+
this.undoButton.setAttribute("type", "button");
|
|
1202
|
+
this.undoButton.setAttribute("aria-label", "Undo");
|
|
1203
|
+
this.redoButton = createElement("button", {
|
|
1204
|
+
className: "vd-flowchart-btn",
|
|
1205
|
+
text: "\u21B7",
|
|
1206
|
+
title: "Redo",
|
|
1207
|
+
disabled: true
|
|
1208
|
+
});
|
|
1209
|
+
this.redoButton.setAttribute("data-flowchart-action", "redo");
|
|
1210
|
+
this.redoButton.setAttribute("type", "button");
|
|
1211
|
+
this.redoButton.setAttribute("aria-label", "Redo");
|
|
1212
|
+
this.arrangeSelect = createElement("select", {
|
|
1213
|
+
className: "vd-flowchart-btn vd-flowchart-arrange",
|
|
1214
|
+
disabled: this.readonly
|
|
1215
|
+
});
|
|
1216
|
+
this.arrangeSelect.setAttribute("aria-label", "Auto-arrange layout");
|
|
1217
|
+
this.arrangeSelect.setAttribute("data-flowchart-arrange", "");
|
|
1218
|
+
[
|
|
1219
|
+
{ value: "", label: "Arrange \u25BE" },
|
|
1220
|
+
{ value: "tree", label: "Tree" },
|
|
1221
|
+
{ value: "radial", label: "Radial" },
|
|
1222
|
+
{ value: "grid", label: "Grid" }
|
|
1223
|
+
].forEach((item, index) => {
|
|
1224
|
+
const option = createElement("option", { value: item.value, text: item.label });
|
|
1225
|
+
option.value = item.value;
|
|
1226
|
+
if (index === 0) option.disabled = true;
|
|
1227
|
+
this.arrangeSelect.appendChild(option);
|
|
1228
|
+
});
|
|
1229
|
+
this.arrangeSelect.selectedIndex = 0;
|
|
1230
|
+
this.clearButton = createElement("button", {
|
|
1231
|
+
className: "vd-flowchart-btn",
|
|
1232
|
+
text: "Clear",
|
|
1233
|
+
disabled: this.readonly
|
|
1234
|
+
});
|
|
1235
|
+
this.clearButton.setAttribute("data-flowchart-action", "clear");
|
|
1236
|
+
this.clearButton.setAttribute("type", "button");
|
|
1237
|
+
this.zoomLabel = createElement("span", {
|
|
1238
|
+
className: "vd-flowchart-toolbar-label",
|
|
1239
|
+
text: "100%"
|
|
1240
|
+
});
|
|
1241
|
+
toolbarLeft.appendChild(this.zoomOutButton);
|
|
1242
|
+
toolbarLeft.appendChild(this.zoomInButton);
|
|
1243
|
+
toolbarLeft.appendChild(this.resetViewButton);
|
|
1244
|
+
toolbarLeft.appendChild(this.fitViewButton);
|
|
1245
|
+
toolbarLeft.appendChild(this.undoButton);
|
|
1246
|
+
toolbarLeft.appendChild(this.redoButton);
|
|
1247
|
+
toolbarLeft.appendChild(this.arrangeSelect);
|
|
1248
|
+
toolbarLeft.appendChild(this.clearButton);
|
|
1249
|
+
toolbarRight.appendChild(this.zoomLabel);
|
|
1250
|
+
this.toolbar.appendChild(toolbarLeft);
|
|
1251
|
+
this.toolbar.appendChild(toolbarRight);
|
|
1252
|
+
this.body = createElement("div", { className: "vd-flowchart-body" });
|
|
1253
|
+
this.palettePanel = createElement("aside", {
|
|
1254
|
+
className: "vd-flowchart-panel vd-flowchart-panel--palette"
|
|
1255
|
+
});
|
|
1256
|
+
this.palettePanel.appendChild(
|
|
1257
|
+
createElement("h4", { className: "vd-flowchart-panel-title", text: "Shapes & tools" })
|
|
1258
|
+
);
|
|
1259
|
+
this.paletteGrid = createElement("div", { className: "vd-flowchart-palette" });
|
|
1260
|
+
FLOWCHART_PALETTE_ITEMS.forEach((item) => {
|
|
1261
|
+
const button = createElement("button", {
|
|
1262
|
+
className: "vd-flowchart-palette-btn"
|
|
1263
|
+
});
|
|
1264
|
+
button.setAttribute("type", "button");
|
|
1265
|
+
if (item.kind === "tool") {
|
|
1266
|
+
button.setAttribute("data-tool", item.tool);
|
|
1267
|
+
button.setAttribute("aria-label", `Use ${item.label}`);
|
|
1268
|
+
} else {
|
|
1269
|
+
button.setAttribute("data-node-type", item.type);
|
|
1270
|
+
button.setAttribute("aria-label", `Add ${item.label}`);
|
|
1271
|
+
}
|
|
1272
|
+
button.appendChild(createPalettePreview(item.tool || item.type));
|
|
1273
|
+
button.appendChild(
|
|
1274
|
+
createElement("span", {
|
|
1275
|
+
className: "vd-flowchart-palette-label",
|
|
1276
|
+
text: item.label
|
|
1277
|
+
})
|
|
1278
|
+
);
|
|
1279
|
+
this.paletteGrid.appendChild(button);
|
|
1280
|
+
});
|
|
1281
|
+
this.palettePanel.appendChild(this.paletteGrid);
|
|
1282
|
+
this.canvasEl = createElement("div", { className: "vd-flowchart-canvas", tabIndex: 0 });
|
|
1283
|
+
this.svg = svgEl("svg", {
|
|
1284
|
+
class: "vd-flowchart-svg",
|
|
1285
|
+
role: "img",
|
|
1286
|
+
"aria-label": "Vanduo Flowchart editor"
|
|
1287
|
+
});
|
|
1288
|
+
const defs = svgEl("defs");
|
|
1289
|
+
this.markerDefs = svgEl("g");
|
|
1290
|
+
defs.appendChild(this.markerDefs);
|
|
1291
|
+
const pattern = svgEl("pattern", {
|
|
1292
|
+
id: this.gridPatternId,
|
|
1293
|
+
width: this.gridSize,
|
|
1294
|
+
height: this.gridSize,
|
|
1295
|
+
patternUnits: "userSpaceOnUse"
|
|
1296
|
+
});
|
|
1297
|
+
pattern.appendChild(
|
|
1298
|
+
svgEl("path", {
|
|
1299
|
+
d: `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`,
|
|
1300
|
+
fill: "none",
|
|
1301
|
+
stroke: "var(--vd-flowchart-border)",
|
|
1302
|
+
"stroke-opacity": 0.55,
|
|
1303
|
+
"stroke-width": 1
|
|
1304
|
+
})
|
|
1305
|
+
);
|
|
1306
|
+
defs.appendChild(pattern);
|
|
1307
|
+
this.svg.appendChild(defs);
|
|
1308
|
+
this.world = svgEl("g", { class: "vd-flowchart-world" });
|
|
1309
|
+
this.gridRect = svgEl("rect", {
|
|
1310
|
+
class: "vd-flowchart-grid",
|
|
1311
|
+
x: -WORLD_EXTENT / 2,
|
|
1312
|
+
y: -WORLD_EXTENT / 2,
|
|
1313
|
+
width: WORLD_EXTENT,
|
|
1314
|
+
height: WORLD_EXTENT,
|
|
1315
|
+
fill: `url(#${this.gridPatternId})`
|
|
1316
|
+
});
|
|
1317
|
+
this.edgesLayer = svgEl("g", { class: "vd-flowchart-edges" });
|
|
1318
|
+
this.previewLayer = svgEl("g", { class: "vd-flowchart-preview" });
|
|
1319
|
+
this.nodesLayer = svgEl("g", { class: "vd-flowchart-nodes" });
|
|
1320
|
+
this.overlayLayer = svgEl("g", { class: "vd-flowchart-overlay" });
|
|
1321
|
+
this.world.appendChild(this.gridRect);
|
|
1322
|
+
this.world.appendChild(this.edgesLayer);
|
|
1323
|
+
this.world.appendChild(this.previewLayer);
|
|
1324
|
+
this.world.appendChild(this.nodesLayer);
|
|
1325
|
+
this.world.appendChild(this.overlayLayer);
|
|
1326
|
+
this.svg.appendChild(this.world);
|
|
1327
|
+
this.canvasEl.appendChild(this.svg);
|
|
1328
|
+
this.inspectorPanel = createElement("aside", {
|
|
1329
|
+
className: "vd-flowchart-panel vd-flowchart-panel--inspector"
|
|
1330
|
+
});
|
|
1331
|
+
this.inspectorPanel.appendChild(
|
|
1332
|
+
createElement("h4", { className: "vd-flowchart-panel-title", text: "Inspector" })
|
|
1333
|
+
);
|
|
1334
|
+
this.selectionMeta = createElement("div", { className: "vd-flowchart-selection-meta" });
|
|
1335
|
+
this.selectionFields = createElement("div", { className: "vd-flowchart-fields" });
|
|
1336
|
+
this.deleteButton = createElement("button", {
|
|
1337
|
+
className: "vd-flowchart-btn vd-flowchart-delete",
|
|
1338
|
+
text: "Delete",
|
|
1339
|
+
disabled: true
|
|
1340
|
+
});
|
|
1341
|
+
this.deleteButton.setAttribute("type", "button");
|
|
1342
|
+
this.inspectorPanel.appendChild(this.selectionMeta);
|
|
1343
|
+
this.inspectorPanel.appendChild(this.selectionFields);
|
|
1344
|
+
this.inspectorPanel.appendChild(this.deleteButton);
|
|
1345
|
+
this.inspectorPanel.appendChild(
|
|
1346
|
+
createElement("h4", { className: "vd-flowchart-panel-title", text: "JSON" })
|
|
1347
|
+
);
|
|
1348
|
+
this.jsonPanel = createElement("div", { className: "vd-flowchart-json" });
|
|
1349
|
+
this.jsonTextarea = createElement("textarea", { rows: 18 });
|
|
1350
|
+
this.jsonActions = createElement("div", { className: "vd-flowchart-json-actions" });
|
|
1351
|
+
this.refreshJsonButton = createElement("button", {
|
|
1352
|
+
className: "vd-flowchart-json-btn",
|
|
1353
|
+
text: "Refresh"
|
|
1354
|
+
});
|
|
1355
|
+
this.refreshJsonButton.setAttribute("type", "button");
|
|
1356
|
+
this.refreshJsonButton.setAttribute("data-json-action", "refresh");
|
|
1357
|
+
this.loadJsonButton = createElement("button", {
|
|
1358
|
+
className: "vd-flowchart-json-btn",
|
|
1359
|
+
text: "Load",
|
|
1360
|
+
disabled: this.readonly
|
|
1361
|
+
});
|
|
1362
|
+
this.loadJsonButton.setAttribute("type", "button");
|
|
1363
|
+
this.loadJsonButton.setAttribute("data-json-action", "load");
|
|
1364
|
+
this.jsonActions.appendChild(this.refreshJsonButton);
|
|
1365
|
+
this.jsonActions.appendChild(this.loadJsonButton);
|
|
1366
|
+
this.jsonPanel.appendChild(this.jsonTextarea);
|
|
1367
|
+
this.jsonPanel.appendChild(this.jsonActions);
|
|
1368
|
+
this.inspectorPanel.appendChild(this.jsonPanel);
|
|
1369
|
+
this.body.appendChild(this.palettePanel);
|
|
1370
|
+
this.body.appendChild(this.canvasEl);
|
|
1371
|
+
this.body.appendChild(this.inspectorPanel);
|
|
1372
|
+
this.root.appendChild(this.toolbar);
|
|
1373
|
+
this.root.appendChild(this.body);
|
|
1374
|
+
this.element.appendChild(this.root);
|
|
1375
|
+
}
|
|
1376
|
+
bindEvents() {
|
|
1377
|
+
this.toolbar.addEventListener("click", this.handleToolbarClick);
|
|
1378
|
+
this.arrangeSelect.addEventListener("change", this.handleArrangeChange);
|
|
1379
|
+
this.paletteGrid.addEventListener("click", this.handlePaletteClick);
|
|
1380
|
+
this.deleteButton.addEventListener("click", () => this.deleteSelection());
|
|
1381
|
+
this.selectionFields.addEventListener("input", this.handleSelectionFieldInput);
|
|
1382
|
+
this.selectionFields.addEventListener("change", this.handleSelectionFieldChange);
|
|
1383
|
+
this.jsonActions.addEventListener("click", this.handleJsonActionClick);
|
|
1384
|
+
this.canvasEl.addEventListener("pointerdown", this.handlePointerDown);
|
|
1385
|
+
this.canvasEl.addEventListener("pointermove", this.handlePointerMove);
|
|
1386
|
+
this.canvasEl.addEventListener("pointerup", this.handlePointerUp);
|
|
1387
|
+
this.canvasEl.addEventListener("pointercancel", this.handlePointerUp);
|
|
1388
|
+
this.canvasEl.addEventListener("click", this.handleClick);
|
|
1389
|
+
this.canvasEl.addEventListener("dblclick", this.handleDoubleClick);
|
|
1390
|
+
this.canvasEl.addEventListener("wheel", this.handleWheel, { passive: false });
|
|
1391
|
+
this.root.addEventListener("keydown", this.handleKeyDown, true);
|
|
1392
|
+
window.addEventListener("pointerup", this.handlePointerUp);
|
|
1393
|
+
window.addEventListener("resize", this.handleResize);
|
|
1394
|
+
}
|
|
1395
|
+
unbindEvents() {
|
|
1396
|
+
this.toolbar.removeEventListener("click", this.handleToolbarClick);
|
|
1397
|
+
this.arrangeSelect.removeEventListener("change", this.handleArrangeChange);
|
|
1398
|
+
this.paletteGrid.removeEventListener("click", this.handlePaletteClick);
|
|
1399
|
+
this.selectionFields.removeEventListener("input", this.handleSelectionFieldInput);
|
|
1400
|
+
this.selectionFields.removeEventListener("change", this.handleSelectionFieldChange);
|
|
1401
|
+
this.jsonActions.removeEventListener("click", this.handleJsonActionClick);
|
|
1402
|
+
this.canvasEl.removeEventListener("pointerdown", this.handlePointerDown);
|
|
1403
|
+
this.canvasEl.removeEventListener("pointermove", this.handlePointerMove);
|
|
1404
|
+
this.canvasEl.removeEventListener("pointerup", this.handlePointerUp);
|
|
1405
|
+
this.canvasEl.removeEventListener("pointercancel", this.handlePointerUp);
|
|
1406
|
+
this.canvasEl.removeEventListener("click", this.handleClick);
|
|
1407
|
+
this.canvasEl.removeEventListener("dblclick", this.handleDoubleClick);
|
|
1408
|
+
this.canvasEl.removeEventListener("wheel", this.handleWheel);
|
|
1409
|
+
this.root.removeEventListener("keydown", this.handleKeyDown, true);
|
|
1410
|
+
window.removeEventListener("pointerup", this.handlePointerUp);
|
|
1411
|
+
window.removeEventListener("resize", this.handleResize);
|
|
1412
|
+
}
|
|
1413
|
+
handleResize() {
|
|
1414
|
+
if (this.destroyed) return;
|
|
1415
|
+
this.render({ inspector: false, json: false });
|
|
1416
|
+
}
|
|
1417
|
+
updatePaletteState() {
|
|
1418
|
+
const arrowArmed = this.activeTool === "arrow";
|
|
1419
|
+
this.paletteGrid.querySelectorAll(".vd-flowchart-palette-btn").forEach((button) => {
|
|
1420
|
+
const tool = button.getAttribute("data-tool");
|
|
1421
|
+
button.classList.toggle("is-active", Boolean(tool) && tool === this.activeTool);
|
|
1422
|
+
});
|
|
1423
|
+
this.canvasEl.classList.toggle("is-arrow-tool", arrowArmed);
|
|
1424
|
+
}
|
|
1425
|
+
setActiveTool(tool) {
|
|
1426
|
+
this.activeTool = tool || null;
|
|
1427
|
+
this.updatePaletteState();
|
|
1428
|
+
this.render({ scene: true, inspector: false, json: false });
|
|
1429
|
+
}
|
|
1430
|
+
handleToolbarClick(event) {
|
|
1431
|
+
const actionButton = event.target.closest("[data-flowchart-action]");
|
|
1432
|
+
if (!actionButton) return;
|
|
1433
|
+
const action = actionButton.getAttribute("data-flowchart-action");
|
|
1434
|
+
if (action === "zoom-in") this.zoomIn();
|
|
1435
|
+
if (action === "zoom-out") this.zoomOut();
|
|
1436
|
+
if (action === "reset-view") this.resetView();
|
|
1437
|
+
if (action === "fit-view") this.fitView();
|
|
1438
|
+
if (action === "undo") this.undo();
|
|
1439
|
+
if (action === "redo") this.redo();
|
|
1440
|
+
if (action === "clear" && !this.readonly) this.clear();
|
|
1441
|
+
}
|
|
1442
|
+
handleArrangeChange(event) {
|
|
1443
|
+
if (this.readonly) return;
|
|
1444
|
+
const mode = event.target.value;
|
|
1445
|
+
if (mode) this.layout(mode);
|
|
1446
|
+
event.target.selectedIndex = 0;
|
|
1447
|
+
}
|
|
1448
|
+
handlePaletteClick(event) {
|
|
1449
|
+
if (this.readonly) return;
|
|
1450
|
+
const toolButton = event.target.closest("[data-tool]");
|
|
1451
|
+
if (toolButton) {
|
|
1452
|
+
const tool = toolButton.getAttribute("data-tool");
|
|
1453
|
+
this.setActiveTool(this.activeTool === tool ? null : tool);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const button = event.target.closest("[data-node-type]");
|
|
1457
|
+
if (!button) return;
|
|
1458
|
+
this.setActiveTool(null);
|
|
1459
|
+
this.addNode({ type: button.getAttribute("data-node-type") });
|
|
1460
|
+
}
|
|
1461
|
+
handleJsonActionClick(event) {
|
|
1462
|
+
const button = event.target.closest("[data-json-action]");
|
|
1463
|
+
if (!button) return;
|
|
1464
|
+
const action = button.getAttribute("data-json-action");
|
|
1465
|
+
if (action === "refresh") {
|
|
1466
|
+
this.syncJsonTextarea(true);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
if (action === "load" && !this.readonly) {
|
|
1470
|
+
this.load(this.jsonTextarea.value);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
handleSelectionFieldInput(event) {
|
|
1474
|
+
const field = event.target.getAttribute("data-field");
|
|
1475
|
+
if (!field) return;
|
|
1476
|
+
if (field === "node-text") {
|
|
1477
|
+
this.updateNode(
|
|
1478
|
+
this.selection?.id,
|
|
1479
|
+
{ text: event.target.value },
|
|
1480
|
+
{ inspector: false, reason: "node:update" }
|
|
1481
|
+
);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (field === "edge-label") {
|
|
1485
|
+
this.updateEdge(
|
|
1486
|
+
this.selection?.id,
|
|
1487
|
+
{ label: event.target.value },
|
|
1488
|
+
{ inspector: false, reason: "edge:update" }
|
|
1489
|
+
);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (field === "node-width" || field === "node-height") {
|
|
1493
|
+
const patch = field === "node-width" ? { width: toFiniteNumber(event.target.value, void 0) } : { height: toFiniteNumber(event.target.value, void 0) };
|
|
1494
|
+
this.updateNode(this.selection?.id, patch, { inspector: false, reason: "node:update" });
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
handleSelectionFieldChange(event) {
|
|
1498
|
+
const field = event.target.getAttribute("data-field");
|
|
1499
|
+
if (!field) return;
|
|
1500
|
+
if (field === "node-type") {
|
|
1501
|
+
this.updateNode(
|
|
1502
|
+
this.selection?.id,
|
|
1503
|
+
{ type: event.target.value },
|
|
1504
|
+
{ inspector: true, reason: "node:update" }
|
|
1505
|
+
);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (field === "edge-start-marker") {
|
|
1509
|
+
this.updateEdge(
|
|
1510
|
+
this.selection?.id,
|
|
1511
|
+
{ startMarker: event.target.value },
|
|
1512
|
+
{ inspector: true, reason: "edge:update" }
|
|
1513
|
+
);
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (field === "edge-end-marker") {
|
|
1517
|
+
this.updateEdge(
|
|
1518
|
+
this.selection?.id,
|
|
1519
|
+
{ endMarker: event.target.value },
|
|
1520
|
+
{ inspector: true, reason: "edge:update" }
|
|
1521
|
+
);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (field === "edge-route") {
|
|
1525
|
+
this.updateEdge(
|
|
1526
|
+
this.selection?.id,
|
|
1527
|
+
{ route: event.target.value },
|
|
1528
|
+
{ inspector: true, reason: "edge:update" }
|
|
1529
|
+
);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (field === "edge-stroke-preset") {
|
|
1533
|
+
this.updateEdge(
|
|
1534
|
+
this.selection?.id,
|
|
1535
|
+
{ strokeWidth: getStrokePresetWidth(event.target.value) },
|
|
1536
|
+
{ inspector: true, reason: "edge:update" }
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
handleKeyDown(event) {
|
|
1541
|
+
if (this.readonly) return;
|
|
1542
|
+
if (event.target && (event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA" || event.target.tagName === "SELECT")) {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
if (event.key === "Escape" && this.activeTool) {
|
|
1546
|
+
event.preventDefault();
|
|
1547
|
+
this.setActiveTool(null);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const modKey = event.metaKey || event.ctrlKey;
|
|
1551
|
+
if (modKey && (event.key === "z" || event.key === "Z")) {
|
|
1552
|
+
event.preventDefault();
|
|
1553
|
+
if (event.shiftKey) {
|
|
1554
|
+
this.redo();
|
|
1555
|
+
} else {
|
|
1556
|
+
this.undo();
|
|
1557
|
+
}
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (modKey && (event.key === "y" || event.key === "Y")) {
|
|
1561
|
+
event.preventDefault();
|
|
1562
|
+
this.redo();
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (modKey && event.key === "c") {
|
|
1566
|
+
if (this.selection) {
|
|
1567
|
+
event.preventDefault();
|
|
1568
|
+
this.copySelection();
|
|
1569
|
+
}
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (modKey && event.key === "v") {
|
|
1573
|
+
if (this.clipboard) {
|
|
1574
|
+
event.preventDefault();
|
|
1575
|
+
this.pasteClipboard();
|
|
1576
|
+
}
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (modKey && event.key === "x") {
|
|
1580
|
+
if (this.selection) {
|
|
1581
|
+
event.preventDefault();
|
|
1582
|
+
this.cutSelection();
|
|
1583
|
+
}
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (!this.selection) return;
|
|
1587
|
+
if (event.key === "Backspace" || event.key === "Delete") {
|
|
1588
|
+
event.preventDefault();
|
|
1589
|
+
this.deleteSelection();
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
copySelection() {
|
|
1593
|
+
if (!this.selection) return false;
|
|
1594
|
+
if (this.selection.kind === "node") {
|
|
1595
|
+
const node = this.findNode(this.selection.id);
|
|
1596
|
+
if (!node) return false;
|
|
1597
|
+
this.clipboard = { kind: "node", data: deepClone(node) };
|
|
1598
|
+
return true;
|
|
1599
|
+
}
|
|
1600
|
+
const edge = this.findEdge(this.selection.id);
|
|
1601
|
+
if (!edge) return false;
|
|
1602
|
+
this.clipboard = { kind: "edge", data: deepClone(edge) };
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
pasteClipboard() {
|
|
1606
|
+
if (!this.clipboard || this.readonly) return false;
|
|
1607
|
+
if (this.clipboard.kind === "node") {
|
|
1608
|
+
const usedIds2 = new Set(this.documentData.nodes.map((node2) => node2.id));
|
|
1609
|
+
const node = normalizeNode(
|
|
1610
|
+
{
|
|
1611
|
+
...this.clipboard.data,
|
|
1612
|
+
id: void 0,
|
|
1613
|
+
x: this.clipboard.data.x + 24,
|
|
1614
|
+
y: this.clipboard.data.y + 24
|
|
1615
|
+
},
|
|
1616
|
+
this.documentData.nodes.length,
|
|
1617
|
+
usedIds2
|
|
1618
|
+
);
|
|
1619
|
+
this.documentData.nodes.push(node);
|
|
1620
|
+
this.select({ kind: "node", id: node.id });
|
|
1621
|
+
this.emitChange("node:add", { node: deepClone(node) });
|
|
1622
|
+
return true;
|
|
1623
|
+
}
|
|
1624
|
+
const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
|
|
1625
|
+
const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
|
|
1626
|
+
const edge = normalizeEdge(
|
|
1627
|
+
{
|
|
1628
|
+
...this.clipboard.data,
|
|
1629
|
+
id: void 0
|
|
1630
|
+
},
|
|
1631
|
+
this.documentData.edges.length,
|
|
1632
|
+
nodeIds,
|
|
1633
|
+
usedIds
|
|
1634
|
+
);
|
|
1635
|
+
if (!edge) return false;
|
|
1636
|
+
this.documentData.edges.push(edge);
|
|
1637
|
+
this.select({ kind: "edge", id: edge.id });
|
|
1638
|
+
this.emitChange("edge:add", { edge: deepClone(edge) });
|
|
1639
|
+
return true;
|
|
1640
|
+
}
|
|
1641
|
+
cutSelection() {
|
|
1642
|
+
if (!this.copySelection()) return false;
|
|
1643
|
+
return this.deleteSelection();
|
|
1644
|
+
}
|
|
1645
|
+
handleDoubleClick(event) {
|
|
1646
|
+
if (this.readonly) return;
|
|
1647
|
+
const edgeTarget = event.target.closest("[data-edge-id]");
|
|
1648
|
+
if (edgeTarget && !event.target.closest("[data-edge-endpoint]")) {
|
|
1649
|
+
const edgeId = edgeTarget.getAttribute("data-edge-id");
|
|
1650
|
+
if (edgeId && this.findEdge(edgeId)) {
|
|
1651
|
+
this.reconnectEdgeId = edgeId;
|
|
1652
|
+
this.select({ kind: "edge", id: edgeId });
|
|
1653
|
+
event.preventDefault();
|
|
1654
|
+
event.stopPropagation();
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
const nodeTarget = event.target.closest("[data-node-id]");
|
|
1659
|
+
if (!nodeTarget || event.target.closest("[data-edge-id]")) return;
|
|
1660
|
+
const nodeId = nodeTarget.getAttribute("data-node-id");
|
|
1661
|
+
if (this.startTextEdit(nodeId)) {
|
|
1662
|
+
event.preventDefault();
|
|
1663
|
+
event.stopPropagation();
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
handleClick(event) {
|
|
1667
|
+
if (this.readonly || event.detail < 2) return;
|
|
1668
|
+
const nodeTarget = event.target.closest("[data-node-id]");
|
|
1669
|
+
if (!nodeTarget || event.target.closest("[data-port]") || event.target.closest("[data-resize-handle]"))
|
|
1670
|
+
return;
|
|
1671
|
+
if (this.startTextEdit(nodeTarget.getAttribute("data-node-id"))) {
|
|
1672
|
+
event.preventDefault();
|
|
1673
|
+
event.stopPropagation();
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
handlePointerDown(event) {
|
|
1677
|
+
if (this.destroyed || event.button != null && event.button !== 0) return;
|
|
1678
|
+
if (this.textEditor && !event.target.closest(".vd-flowchart-text-editor")) {
|
|
1679
|
+
this.stopTextEdit({ commit: true });
|
|
1680
|
+
}
|
|
1681
|
+
this.canvasEl.focus();
|
|
1682
|
+
const nodeTarget = event.target.closest("[data-node-id]");
|
|
1683
|
+
const portTarget = event.target.closest("[data-port]");
|
|
1684
|
+
const resizeTarget = event.target.closest("[data-resize-handle]");
|
|
1685
|
+
const edgeTarget = event.target.closest("[data-edge-id]");
|
|
1686
|
+
const endpointTarget = event.target.closest("[data-edge-endpoint]");
|
|
1687
|
+
if (endpointTarget && !this.readonly) {
|
|
1688
|
+
const edgeId = endpointTarget.getAttribute("data-edge-id");
|
|
1689
|
+
const endpoint = endpointTarget.getAttribute("data-edge-endpoint");
|
|
1690
|
+
const edge = this.findEdge(edgeId);
|
|
1691
|
+
if (!edge || endpoint !== "from" && endpoint !== "to") return;
|
|
1692
|
+
const fromNode = this.findNode(edge.from.nodeId);
|
|
1693
|
+
const toNode = this.findNode(edge.to.nodeId);
|
|
1694
|
+
if (!fromNode || !toNode) return;
|
|
1695
|
+
this.reconnectEdgeId = edgeId;
|
|
1696
|
+
this.select({ kind: "edge", id: edgeId });
|
|
1697
|
+
this.interaction = {
|
|
1698
|
+
kind: "reconnect",
|
|
1699
|
+
pointerId: event.pointerId,
|
|
1700
|
+
edgeId,
|
|
1701
|
+
endpoint,
|
|
1702
|
+
target: null,
|
|
1703
|
+
previousSnap: null,
|
|
1704
|
+
fromPoint: getPortPosition(fromNode, edge.from.port),
|
|
1705
|
+
toPoint: getPortPosition(toNode, edge.to.port),
|
|
1706
|
+
fromPort: edge.from.port,
|
|
1707
|
+
toPort: edge.to.port,
|
|
1708
|
+
route: edge.route,
|
|
1709
|
+
strokeWidth: edge.strokeWidth
|
|
1710
|
+
};
|
|
1711
|
+
this.capturePointer(event.pointerId);
|
|
1712
|
+
this.syncConnectingState();
|
|
1713
|
+
this.render({ inspector: false, json: false });
|
|
1714
|
+
event.preventDefault();
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
if (nodeTarget && !portTarget && !resizeTarget && !edgeTarget && !this.readonly) {
|
|
1718
|
+
if (this.activeTool === "arrow") {
|
|
1719
|
+
this.select({ kind: "node", id: nodeTarget.getAttribute("data-node-id") });
|
|
1720
|
+
event.preventDefault();
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const nodeId = nodeTarget.getAttribute("data-node-id");
|
|
1724
|
+
const now = Date.now();
|
|
1725
|
+
const repeatedNodeClick = this.lastNodePointer && this.lastNodePointer.nodeId === nodeId && now - this.lastNodePointer.time <= 420;
|
|
1726
|
+
this.lastNodePointer = { nodeId, time: now };
|
|
1727
|
+
if ((event.detail >= 2 || repeatedNodeClick) && this.startTextEdit(nodeId)) {
|
|
1728
|
+
event.preventDefault();
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
} else {
|
|
1732
|
+
this.lastNodePointer = null;
|
|
1733
|
+
}
|
|
1734
|
+
if (portTarget && !this.readonly) {
|
|
1735
|
+
const nodeId = portTarget.getAttribute("data-node-id");
|
|
1736
|
+
const port = portTarget.getAttribute("data-port");
|
|
1737
|
+
const node = this.findNode(nodeId);
|
|
1738
|
+
if (!node) return;
|
|
1739
|
+
const sourcePoint = getPortPosition(node, port);
|
|
1740
|
+
this.interaction = {
|
|
1741
|
+
kind: "connect",
|
|
1742
|
+
pointerId: event.pointerId,
|
|
1743
|
+
source: { nodeId, port },
|
|
1744
|
+
target: null,
|
|
1745
|
+
previousSnap: null,
|
|
1746
|
+
fromPoint: sourcePoint,
|
|
1747
|
+
toPoint: sourcePoint,
|
|
1748
|
+
fromPort: port,
|
|
1749
|
+
toPort: "left",
|
|
1750
|
+
route: DEFAULT_EDGE_ROUTE,
|
|
1751
|
+
strokeWidth: DEFAULT_EDGE_STROKE_WIDTH
|
|
1752
|
+
};
|
|
1753
|
+
this.select({ kind: "node", id: nodeId });
|
|
1754
|
+
this.capturePointer(event.pointerId);
|
|
1755
|
+
this.syncConnectingState();
|
|
1756
|
+
this.render({ inspector: false, json: false });
|
|
1757
|
+
event.preventDefault();
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (resizeTarget && !this.readonly) {
|
|
1761
|
+
const nodeId = resizeTarget.getAttribute("data-node-id");
|
|
1762
|
+
const handle = resizeTarget.getAttribute("data-resize-handle");
|
|
1763
|
+
const node = this.findNode(nodeId);
|
|
1764
|
+
if (!node || !RESIZE_HANDLES.includes(handle)) return;
|
|
1765
|
+
this.select({ kind: "node", id: nodeId });
|
|
1766
|
+
this.interaction = {
|
|
1767
|
+
kind: "resize-node",
|
|
1768
|
+
pointerId: event.pointerId,
|
|
1769
|
+
nodeId,
|
|
1770
|
+
handle,
|
|
1771
|
+
startWorld: this.clientToWorld(event.clientX, event.clientY),
|
|
1772
|
+
original: deepClone(node),
|
|
1773
|
+
moved: false
|
|
1774
|
+
};
|
|
1775
|
+
this.capturePointer(event.pointerId);
|
|
1776
|
+
event.preventDefault();
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (edgeTarget) {
|
|
1780
|
+
const edgeId = edgeTarget.getAttribute("data-edge-id");
|
|
1781
|
+
if (!this.readonly) {
|
|
1782
|
+
this.reconnectEdgeId = edgeId;
|
|
1783
|
+
} else if (this.reconnectEdgeId && this.reconnectEdgeId !== edgeId) {
|
|
1784
|
+
this.reconnectEdgeId = null;
|
|
1785
|
+
}
|
|
1786
|
+
this.select({ kind: "edge", id: edgeId });
|
|
1787
|
+
event.preventDefault();
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (nodeTarget) {
|
|
1791
|
+
this.reconnectEdgeId = null;
|
|
1792
|
+
const nodeId = nodeTarget.getAttribute("data-node-id");
|
|
1793
|
+
this.select({ kind: "node", id: nodeId });
|
|
1794
|
+
if (!this.readonly) {
|
|
1795
|
+
const node = this.findNode(nodeId);
|
|
1796
|
+
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
1797
|
+
this.interaction = {
|
|
1798
|
+
kind: "drag-node",
|
|
1799
|
+
pointerId: event.pointerId,
|
|
1800
|
+
nodeId,
|
|
1801
|
+
offsetX: world.x - node.x,
|
|
1802
|
+
offsetY: world.y - node.y,
|
|
1803
|
+
moved: false
|
|
1804
|
+
};
|
|
1805
|
+
this.capturePointer(event.pointerId);
|
|
1806
|
+
}
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
this.select(null);
|
|
1810
|
+
this.reconnectEdgeId = null;
|
|
1811
|
+
this.interaction = {
|
|
1812
|
+
kind: "pan",
|
|
1813
|
+
pointerId: event.pointerId,
|
|
1814
|
+
startClientX: event.clientX,
|
|
1815
|
+
startClientY: event.clientY,
|
|
1816
|
+
startViewportX: this.documentData.viewport.x,
|
|
1817
|
+
startViewportY: this.documentData.viewport.y,
|
|
1818
|
+
moved: false
|
|
1819
|
+
};
|
|
1820
|
+
this.capturePointer(event.pointerId);
|
|
1821
|
+
event.preventDefault();
|
|
1822
|
+
}
|
|
1823
|
+
handlePointerMove(event) {
|
|
1824
|
+
if (!this.interaction || this.interaction.pointerId !== event.pointerId) return;
|
|
1825
|
+
if (this.interaction.kind === "drag-node") {
|
|
1826
|
+
const node = this.findNode(this.interaction.nodeId);
|
|
1827
|
+
if (!node) return;
|
|
1828
|
+
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
1829
|
+
const nextX = formatNumber(world.x - this.interaction.offsetX);
|
|
1830
|
+
const nextY = formatNumber(world.y - this.interaction.offsetY);
|
|
1831
|
+
if (nextX !== node.x || nextY !== node.y) {
|
|
1832
|
+
node.x = nextX;
|
|
1833
|
+
node.y = nextY;
|
|
1834
|
+
this.interaction.moved = true;
|
|
1835
|
+
this.render({ inspector: false, json: false });
|
|
1836
|
+
}
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
if (this.interaction.kind === "resize-node") {
|
|
1840
|
+
const node = this.findNode(this.interaction.nodeId);
|
|
1841
|
+
if (!node) return;
|
|
1842
|
+
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
1843
|
+
const deltaX = world.x - this.interaction.startWorld.x;
|
|
1844
|
+
const deltaY = world.y - this.interaction.startWorld.y;
|
|
1845
|
+
const next = this.getResizedNodeBounds(
|
|
1846
|
+
this.interaction.original,
|
|
1847
|
+
this.interaction.handle,
|
|
1848
|
+
deltaX,
|
|
1849
|
+
deltaY
|
|
1850
|
+
);
|
|
1851
|
+
if (next.x !== node.x || next.y !== node.y || next.width !== node.width || next.height !== node.height) {
|
|
1852
|
+
node.x = next.x;
|
|
1853
|
+
node.y = next.y;
|
|
1854
|
+
node.width = next.width;
|
|
1855
|
+
node.height = next.height;
|
|
1856
|
+
this.interaction.moved = true;
|
|
1857
|
+
this.render({ inspector: false, json: false });
|
|
1858
|
+
}
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (this.interaction.kind === "pan") {
|
|
1862
|
+
const deltaX = event.clientX - this.interaction.startClientX;
|
|
1863
|
+
const deltaY = event.clientY - this.interaction.startClientY;
|
|
1864
|
+
this.documentData.viewport.x = formatNumber(this.interaction.startViewportX + deltaX);
|
|
1865
|
+
this.documentData.viewport.y = formatNumber(this.interaction.startViewportY + deltaY);
|
|
1866
|
+
this.interaction.moved = true;
|
|
1867
|
+
this.render({ inspector: false, json: false });
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (this.interaction.kind === "connect") {
|
|
1871
|
+
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
1872
|
+
const snapTarget = this.findConnectionTarget(
|
|
1873
|
+
world,
|
|
1874
|
+
this.interaction.source.nodeId,
|
|
1875
|
+
this.interaction.previousSnap,
|
|
1876
|
+
this.interaction.fromPoint
|
|
1877
|
+
);
|
|
1878
|
+
if (snapTarget) {
|
|
1879
|
+
this.interaction.target = { nodeId: snapTarget.node.id, port: snapTarget.port };
|
|
1880
|
+
this.interaction.toPoint = snapTarget.point;
|
|
1881
|
+
this.interaction.toPort = snapTarget.port;
|
|
1882
|
+
this.interaction.previousSnap = { nodeId: snapTarget.node.id, port: snapTarget.port };
|
|
1883
|
+
this.render({ inspector: false, json: false });
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
this.interaction.target = null;
|
|
1887
|
+
this.interaction.toPoint = world;
|
|
1888
|
+
this.interaction.toPort = this.interaction.toPort || "left";
|
|
1889
|
+
this.interaction.previousSnap = null;
|
|
1890
|
+
this.render({ inspector: false, json: false });
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (this.interaction.kind === "reconnect") {
|
|
1894
|
+
const edge = this.findEdge(this.interaction.edgeId);
|
|
1895
|
+
if (!edge) return;
|
|
1896
|
+
const world = this.clientToWorld(event.clientX, event.clientY);
|
|
1897
|
+
const snapTarget = this.findConnectionTarget(
|
|
1898
|
+
world,
|
|
1899
|
+
null,
|
|
1900
|
+
this.interaction.previousSnap,
|
|
1901
|
+
this.interaction.endpoint === "from" ? this.interaction.toPoint : this.interaction.fromPoint
|
|
1902
|
+
);
|
|
1903
|
+
if (this.interaction.endpoint === "from") {
|
|
1904
|
+
const toNode = this.findNode(edge.to.nodeId);
|
|
1905
|
+
if (!toNode) return;
|
|
1906
|
+
this.interaction.toPoint = getPortPosition(toNode, edge.to.port);
|
|
1907
|
+
this.interaction.toPort = edge.to.port;
|
|
1908
|
+
if (snapTarget) {
|
|
1909
|
+
this.interaction.target = { nodeId: snapTarget.node.id, port: snapTarget.port };
|
|
1910
|
+
this.interaction.fromPoint = snapTarget.point;
|
|
1911
|
+
this.interaction.fromPort = snapTarget.port;
|
|
1912
|
+
this.interaction.previousSnap = { nodeId: snapTarget.node.id, port: snapTarget.port };
|
|
1913
|
+
} else {
|
|
1914
|
+
this.interaction.target = null;
|
|
1915
|
+
this.interaction.fromPoint = world;
|
|
1916
|
+
this.interaction.fromPort = this.interaction.fromPort || edge.from.port;
|
|
1917
|
+
this.interaction.previousSnap = null;
|
|
1918
|
+
}
|
|
1919
|
+
} else {
|
|
1920
|
+
const fromNode = this.findNode(edge.from.nodeId);
|
|
1921
|
+
if (!fromNode) return;
|
|
1922
|
+
this.interaction.fromPoint = getPortPosition(fromNode, edge.from.port);
|
|
1923
|
+
this.interaction.fromPort = edge.from.port;
|
|
1924
|
+
if (snapTarget) {
|
|
1925
|
+
this.interaction.target = { nodeId: snapTarget.node.id, port: snapTarget.port };
|
|
1926
|
+
this.interaction.toPoint = snapTarget.point;
|
|
1927
|
+
this.interaction.toPort = snapTarget.port;
|
|
1928
|
+
this.interaction.previousSnap = { nodeId: snapTarget.node.id, port: snapTarget.port };
|
|
1929
|
+
} else {
|
|
1930
|
+
this.interaction.target = null;
|
|
1931
|
+
this.interaction.toPoint = world;
|
|
1932
|
+
this.interaction.toPort = this.interaction.toPort || edge.to.port;
|
|
1933
|
+
this.interaction.previousSnap = null;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
this.render({ inspector: false, json: false });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
handlePointerUp(event) {
|
|
1940
|
+
if (!this.interaction || event.pointerId != null && this.interaction.pointerId !== event.pointerId) {
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
const interaction = this.interaction;
|
|
1944
|
+
this.interaction = null;
|
|
1945
|
+
this.releasePointerCapture(interaction.pointerId);
|
|
1946
|
+
this.syncConnectingState();
|
|
1947
|
+
if (interaction.kind === "resize-node") {
|
|
1948
|
+
if (interaction.moved) {
|
|
1949
|
+
const node = this.findNode(interaction.nodeId);
|
|
1950
|
+
this.render({ inspector: true, json: true });
|
|
1951
|
+
this.emitChange("node:resize", { node: deepClone(node) });
|
|
1952
|
+
} else {
|
|
1953
|
+
this.render({ inspector: false, json: false });
|
|
1954
|
+
}
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
if (interaction.kind === "drag-node") {
|
|
1958
|
+
if (interaction.moved) {
|
|
1959
|
+
this.render({ inspector: true, json: true });
|
|
1960
|
+
this.emitChange("node:move", { node: deepClone(this.findNode(interaction.nodeId)) });
|
|
1961
|
+
} else {
|
|
1962
|
+
this.render({ inspector: false, json: false });
|
|
1963
|
+
}
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
if (interaction.kind === "pan") {
|
|
1967
|
+
if (interaction.moved) {
|
|
1968
|
+
this.render({ inspector: false, json: true });
|
|
1969
|
+
this.emitViewportChange("viewport:pan");
|
|
1970
|
+
} else {
|
|
1971
|
+
this.render({ inspector: false, json: false });
|
|
1972
|
+
}
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (interaction.kind === "connect") {
|
|
1976
|
+
const world = this.clientToWorld(event.clientX || 0, event.clientY || 0);
|
|
1977
|
+
const snapTarget = interaction.target ? { node: this.findNode(interaction.target.nodeId), port: interaction.target.port } : this.findConnectionTarget(
|
|
1978
|
+
world,
|
|
1979
|
+
interaction.source.nodeId,
|
|
1980
|
+
interaction.previousSnap,
|
|
1981
|
+
interaction.fromPoint
|
|
1982
|
+
);
|
|
1983
|
+
if (snapTarget && snapTarget.node) {
|
|
1984
|
+
const edge = this.addEdge({
|
|
1985
|
+
from: interaction.source,
|
|
1986
|
+
to: {
|
|
1987
|
+
nodeId: snapTarget.node.id,
|
|
1988
|
+
port: snapTarget.port
|
|
1989
|
+
},
|
|
1990
|
+
strokeWidth: interaction.strokeWidth,
|
|
1991
|
+
endMarker: "arrow"
|
|
1992
|
+
});
|
|
1993
|
+
if (edge) {
|
|
1994
|
+
if (this.activeTool === "arrow") this.setActiveTool(null);
|
|
1995
|
+
this.syncConnectingState();
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
if (this.activeTool === "arrow") this.setActiveTool(null);
|
|
2000
|
+
this.syncConnectingState();
|
|
2001
|
+
this.render({ inspector: false, json: false });
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (interaction.kind === "reconnect") {
|
|
2005
|
+
const edge = this.findEdge(interaction.edgeId);
|
|
2006
|
+
if (edge && interaction.target) {
|
|
2007
|
+
const patch = interaction.endpoint === "from" ? { from: { nodeId: interaction.target.nodeId, port: interaction.target.port } } : { to: { nodeId: interaction.target.nodeId, port: interaction.target.port } };
|
|
2008
|
+
const nextFrom = patch.from || edge.from;
|
|
2009
|
+
const nextTo = patch.to || edge.to;
|
|
2010
|
+
if (!(nextFrom.nodeId === nextTo.nodeId && nextFrom.port === nextTo.port)) {
|
|
2011
|
+
this.updateEdge(interaction.edgeId, patch, { inspector: true, reason: "edge:reconnect" });
|
|
2012
|
+
} else {
|
|
2013
|
+
this.render({ inspector: false, json: false });
|
|
2014
|
+
}
|
|
2015
|
+
} else {
|
|
2016
|
+
this.render({ inspector: false, json: false });
|
|
2017
|
+
}
|
|
2018
|
+
this.reconnectEdgeId = interaction.edgeId;
|
|
2019
|
+
this.syncConnectingState();
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
handleWheel(event) {
|
|
2024
|
+
event.preventDefault();
|
|
2025
|
+
const local = this.clientToLocal(event.clientX, event.clientY);
|
|
2026
|
+
const factor = event.deltaY > 0 ? 1 / 1.12 : 1.12;
|
|
2027
|
+
this.scaleAround(factor, local.x, local.y, "viewport:zoom");
|
|
2028
|
+
}
|
|
2029
|
+
capturePointer(pointerId) {
|
|
2030
|
+
if (typeof this.canvasEl.setPointerCapture !== "function") return;
|
|
2031
|
+
try {
|
|
2032
|
+
this.canvasEl.setPointerCapture(pointerId);
|
|
2033
|
+
} catch {
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
releasePointerCapture(pointerId) {
|
|
2037
|
+
if (typeof this.canvasEl.releasePointerCapture !== "function") return;
|
|
2038
|
+
try {
|
|
2039
|
+
this.canvasEl.releasePointerCapture(pointerId);
|
|
2040
|
+
} catch {
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
syncConnectingState() {
|
|
2044
|
+
const connecting = this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect";
|
|
2045
|
+
this.canvasEl.classList.toggle("is-connecting", connecting);
|
|
2046
|
+
}
|
|
2047
|
+
shouldShowNodePorts(node) {
|
|
2048
|
+
if (this.readonly) return false;
|
|
2049
|
+
if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") return true;
|
|
2050
|
+
if (this.activeTool === "arrow") return true;
|
|
2051
|
+
return this.selection?.kind === "node" && this.selection.id === node.id;
|
|
2052
|
+
}
|
|
2053
|
+
getMarkerId(markerType, position, strokeWidth) {
|
|
2054
|
+
const width = normalizeEdgeStrokeWidth(strokeWidth);
|
|
2055
|
+
const key = `${markerType}:${position}:${width}`;
|
|
2056
|
+
if (this.markerIds.has(key)) {
|
|
2057
|
+
return this.markerIds.get(key);
|
|
2058
|
+
}
|
|
2059
|
+
const markerId = nextId(`flowchart-${markerType}-${position}`);
|
|
2060
|
+
const marker = markerType === "dot" ? createDotMarker(markerId, width) : createArrowMarker(markerId, width, position === "start");
|
|
2061
|
+
this.markerDefs.appendChild(marker);
|
|
2062
|
+
this.markerIds.set(key, markerId);
|
|
2063
|
+
return markerId;
|
|
2064
|
+
}
|
|
2065
|
+
getEdgeStrokeWidth(edge) {
|
|
2066
|
+
return normalizeEdgeStrokeWidth(edge?.strokeWidth);
|
|
2067
|
+
}
|
|
2068
|
+
getEdgeHitStrokeWidth(edge) {
|
|
2069
|
+
return formatNumber(Math.max(EDGE_HIT_STROKE_MIN, this.getEdgeStrokeWidth(edge) + 12));
|
|
2070
|
+
}
|
|
2071
|
+
findConnectionTarget(worldPoint, excludeNodeId, previousSnap = null, referencePoint = null) {
|
|
2072
|
+
const snapPadding = CONNECTION_SNAP_PADDING / this.documentData.viewport.scale;
|
|
2073
|
+
const hysteresisMargin = CONNECTION_HYSTERESIS / this.documentData.viewport.scale;
|
|
2074
|
+
const best = this.documentData.nodes.reduce((candidate, node) => {
|
|
2075
|
+
if (node.id === excludeNodeId) return candidate;
|
|
2076
|
+
const distanceToBounds2 = getDistanceToNodeBounds(node, worldPoint);
|
|
2077
|
+
if (distanceToBounds2 > snapPadding) return candidate;
|
|
2078
|
+
const nearest = pickPortForNode(node, worldPoint, referencePoint);
|
|
2079
|
+
if (!nearest) return candidate;
|
|
2080
|
+
const score = distanceToBounds2 * 1e3 + nearest.distance;
|
|
2081
|
+
if (!candidate || score < candidate.score) {
|
|
2082
|
+
return {
|
|
2083
|
+
node,
|
|
2084
|
+
port: nearest.port,
|
|
2085
|
+
point: nearest.point,
|
|
2086
|
+
score
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
return candidate;
|
|
2090
|
+
}, null);
|
|
2091
|
+
if (!previousSnap || !best) return best;
|
|
2092
|
+
const previousNode = this.findNode(previousSnap.nodeId);
|
|
2093
|
+
if (!previousNode || previousNode.id === excludeNodeId) return best;
|
|
2094
|
+
const distanceToBounds = getDistanceToNodeBounds(previousNode, worldPoint);
|
|
2095
|
+
if (distanceToBounds > snapPadding) return best;
|
|
2096
|
+
if (best.node.id === previousSnap.nodeId && best.port === previousSnap.port) {
|
|
2097
|
+
return best;
|
|
2098
|
+
}
|
|
2099
|
+
const previousPoint = getPortPosition(previousNode, previousSnap.port);
|
|
2100
|
+
const previousScore = distanceToBounds * 1e3 + Math.hypot(worldPoint.x - previousPoint.x, worldPoint.y - previousPoint.y);
|
|
2101
|
+
if (best.score + hysteresisMargin >= previousScore) {
|
|
2102
|
+
return {
|
|
2103
|
+
node: previousNode,
|
|
2104
|
+
port: previousSnap.port,
|
|
2105
|
+
point: previousPoint,
|
|
2106
|
+
score: previousScore
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
return best;
|
|
2110
|
+
}
|
|
2111
|
+
getResizedNodeBounds(original, handle, deltaX, deltaY) {
|
|
2112
|
+
const nextType = original.type;
|
|
2113
|
+
if (!isNodeResizable(nextType)) {
|
|
2114
|
+
return {
|
|
2115
|
+
x: formatNumber(original.x),
|
|
2116
|
+
y: formatNumber(original.y),
|
|
2117
|
+
width: formatNumber(original.width),
|
|
2118
|
+
height: formatNumber(original.height)
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
let x = original.x;
|
|
2122
|
+
let y = original.y;
|
|
2123
|
+
let width = original.width;
|
|
2124
|
+
let height = original.height;
|
|
2125
|
+
if (handle.includes("e")) {
|
|
2126
|
+
width = clampNodeWidth(nextType, original.width + deltaX, original.width);
|
|
2127
|
+
}
|
|
2128
|
+
if (handle.includes("s")) {
|
|
2129
|
+
height = clampNodeHeight(nextType, original.height + deltaY, original.height);
|
|
2130
|
+
}
|
|
2131
|
+
if (handle.includes("w")) {
|
|
2132
|
+
width = clampNodeWidth(nextType, original.width - deltaX, original.width);
|
|
2133
|
+
x = original.x + original.width - width;
|
|
2134
|
+
}
|
|
2135
|
+
if (handle.includes("n")) {
|
|
2136
|
+
height = clampNodeHeight(nextType, original.height - deltaY, original.height);
|
|
2137
|
+
y = original.y + original.height - height;
|
|
2138
|
+
}
|
|
2139
|
+
return {
|
|
2140
|
+
x: formatNumber(x),
|
|
2141
|
+
y: formatNumber(y),
|
|
2142
|
+
width: formatNumber(width),
|
|
2143
|
+
height: formatNumber(height)
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
clientToLocal(clientX, clientY) {
|
|
2147
|
+
const rect = this.canvasEl.getBoundingClientRect();
|
|
2148
|
+
return {
|
|
2149
|
+
x: clientX - rect.left,
|
|
2150
|
+
y: clientY - rect.top
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
localToWorld(localX, localY) {
|
|
2154
|
+
const viewport = this.documentData.viewport;
|
|
2155
|
+
return {
|
|
2156
|
+
x: (localX - viewport.x) / viewport.scale,
|
|
2157
|
+
y: (localY - viewport.y) / viewport.scale
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
clientToWorld(clientX, clientY) {
|
|
2161
|
+
const local = this.clientToLocal(clientX, clientY);
|
|
2162
|
+
return this.localToWorld(local.x, local.y);
|
|
2163
|
+
}
|
|
2164
|
+
getViewportCenter() {
|
|
2165
|
+
const width = this.canvasEl.clientWidth || 800;
|
|
2166
|
+
const height = this.canvasEl.clientHeight || 560;
|
|
2167
|
+
return this.localToWorld(width / 2, height / 2);
|
|
2168
|
+
}
|
|
2169
|
+
scaleAround(factor, localX, localY, reason) {
|
|
2170
|
+
const viewport = this.documentData.viewport;
|
|
2171
|
+
const anchor = this.localToWorld(localX, localY);
|
|
2172
|
+
const nextScale = clamp(formatNumber(viewport.scale * factor), MIN_SCALE, MAX_SCALE);
|
|
2173
|
+
if (nextScale === viewport.scale) return;
|
|
2174
|
+
viewport.scale = nextScale;
|
|
2175
|
+
viewport.x = formatNumber(localX - anchor.x * nextScale);
|
|
2176
|
+
viewport.y = formatNumber(localY - anchor.y * nextScale);
|
|
2177
|
+
this.render({ inspector: false, json: true });
|
|
2178
|
+
this.emitViewportChange(reason);
|
|
2179
|
+
}
|
|
2180
|
+
updateToolbarLabel() {
|
|
2181
|
+
this.zoomLabel.textContent = `${Math.round(this.documentData.viewport.scale * 100)}%`;
|
|
2182
|
+
}
|
|
2183
|
+
startTextEdit(nodeId) {
|
|
2184
|
+
if (this.readonly) return false;
|
|
2185
|
+
const node = this.findNode(nodeId);
|
|
2186
|
+
if (!node || !isNodeTextEditable(node)) return false;
|
|
2187
|
+
if (this.textEditor?.nodeId === node.id) {
|
|
2188
|
+
this.positionTextEditor();
|
|
2189
|
+
this.textEditor.textarea.focus();
|
|
2190
|
+
this.textEditor.textarea.select();
|
|
2191
|
+
return true;
|
|
2192
|
+
}
|
|
2193
|
+
this.stopTextEdit({ commit: true });
|
|
2194
|
+
this.select({ kind: "node", id: node.id });
|
|
2195
|
+
const textarea = createElement("textarea", {
|
|
2196
|
+
className: `vd-flowchart-text-editor vd-flowchart-text-editor--${node.type}`,
|
|
2197
|
+
value: node.text,
|
|
2198
|
+
rows: 1
|
|
2199
|
+
});
|
|
2200
|
+
textarea.setAttribute("data-node-id", node.id);
|
|
2201
|
+
textarea.setAttribute("aria-label", "Edit node text");
|
|
2202
|
+
textarea.spellcheck = false;
|
|
2203
|
+
textarea.addEventListener("input", () => this.positionTextEditor());
|
|
2204
|
+
textarea.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
2205
|
+
textarea.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
2206
|
+
textarea.addEventListener("keydown", (event) => {
|
|
2207
|
+
if (event.key === "Escape") {
|
|
2208
|
+
event.preventDefault();
|
|
2209
|
+
this.stopTextEdit({ commit: false });
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
2213
|
+
event.preventDefault();
|
|
2214
|
+
this.stopTextEdit({ commit: true });
|
|
2215
|
+
}
|
|
2216
|
+
});
|
|
2217
|
+
textarea.addEventListener("blur", () => {
|
|
2218
|
+
if (this.textEditor?.textarea === textarea) {
|
|
2219
|
+
this.stopTextEdit({ commit: true });
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
this.textEditor = {
|
|
2223
|
+
nodeId: node.id,
|
|
2224
|
+
previousText: node.text,
|
|
2225
|
+
textarea
|
|
2226
|
+
};
|
|
2227
|
+
this.canvasEl.appendChild(textarea);
|
|
2228
|
+
this.render({ inspector: true, json: false });
|
|
2229
|
+
window.requestAnimationFrame(() => {
|
|
2230
|
+
if (this.textEditor?.textarea === textarea) {
|
|
2231
|
+
textarea.focus();
|
|
2232
|
+
textarea.select();
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
return true;
|
|
2236
|
+
}
|
|
2237
|
+
stopTextEdit(options = {}) {
|
|
2238
|
+
if (!this.textEditor) return false;
|
|
2239
|
+
const commit = options.commit !== false;
|
|
2240
|
+
const editor = this.textEditor;
|
|
2241
|
+
const nextText = editor.textarea.value;
|
|
2242
|
+
this.textEditor = null;
|
|
2243
|
+
editor.textarea.remove();
|
|
2244
|
+
if (commit && !this.readonly && nextText !== editor.previousText) {
|
|
2245
|
+
this.updateNode(
|
|
2246
|
+
editor.nodeId,
|
|
2247
|
+
{ text: nextText },
|
|
2248
|
+
{ inspector: true, reason: "node:update" }
|
|
2249
|
+
);
|
|
2250
|
+
} else {
|
|
2251
|
+
this.render({ scene: true, inspector: false, json: false });
|
|
2252
|
+
}
|
|
2253
|
+
return true;
|
|
2254
|
+
}
|
|
2255
|
+
positionTextEditor() {
|
|
2256
|
+
if (!this.textEditor) return;
|
|
2257
|
+
const node = this.findNode(this.textEditor.nodeId);
|
|
2258
|
+
if (!node) {
|
|
2259
|
+
this.stopTextEdit({ commit: false });
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
const viewport = this.documentData.viewport;
|
|
2263
|
+
const scale = viewport.scale;
|
|
2264
|
+
const textarea = this.textEditor.textarea;
|
|
2265
|
+
const width = node.width * scale;
|
|
2266
|
+
const height = node.height * scale;
|
|
2267
|
+
const metrics = getNodeFontMetrics(node);
|
|
2268
|
+
const lineHeight = metrics.lineHeight * scale;
|
|
2269
|
+
const lineCount = Math.max(1, textarea.value.split(/\r?\n/).length);
|
|
2270
|
+
const horizontalPadding = (node.type === "textbox" ? 16 : 12) * scale;
|
|
2271
|
+
const verticalPadding = node.type === "textbox" ? 12 * scale : Math.max(4, (height - lineCount * lineHeight) / 2);
|
|
2272
|
+
textarea.style.left = `${formatNumber(viewport.x + node.x * scale)}px`;
|
|
2273
|
+
textarea.style.top = `${formatNumber(viewport.y + node.y * scale)}px`;
|
|
2274
|
+
textarea.style.width = `${formatNumber(width)}px`;
|
|
2275
|
+
textarea.style.height = `${formatNumber(height)}px`;
|
|
2276
|
+
textarea.style.fontSize = `${formatNumber(metrics.fontSize * scale)}px`;
|
|
2277
|
+
textarea.style.lineHeight = `${formatNumber(lineHeight)}px`;
|
|
2278
|
+
textarea.style.padding = `${formatNumber(verticalPadding)}px ${formatNumber(horizontalPadding)}px`;
|
|
2279
|
+
}
|
|
2280
|
+
render(options = {}) {
|
|
2281
|
+
if (this.destroyed) return;
|
|
2282
|
+
const shouldRenderScene = options.scene !== false;
|
|
2283
|
+
const shouldRenderInspector = options.inspector !== false;
|
|
2284
|
+
const shouldRenderJson = options.json !== false;
|
|
2285
|
+
if (shouldRenderScene) this.renderScene();
|
|
2286
|
+
if (shouldRenderInspector) this.renderSelectionPanel();
|
|
2287
|
+
if (shouldRenderJson) this.syncJsonTextarea();
|
|
2288
|
+
this.updateToolbarLabel();
|
|
2289
|
+
this.updatePaletteState();
|
|
2290
|
+
this.positionTextEditor();
|
|
2291
|
+
}
|
|
2292
|
+
renderScene() {
|
|
2293
|
+
const viewport = this.documentData.viewport;
|
|
2294
|
+
this.world.setAttribute(
|
|
2295
|
+
"transform",
|
|
2296
|
+
`matrix(${viewport.scale} 0 0 ${viewport.scale} ${viewport.x} ${viewport.y})`
|
|
2297
|
+
);
|
|
2298
|
+
clearChildren(this.edgesLayer);
|
|
2299
|
+
clearChildren(this.previewLayer);
|
|
2300
|
+
clearChildren(this.nodesLayer);
|
|
2301
|
+
clearChildren(this.overlayLayer);
|
|
2302
|
+
const nodeMap = new Map(this.documentData.nodes.map((node) => [node.id, node]));
|
|
2303
|
+
this.documentData.edges.forEach((edge) => {
|
|
2304
|
+
const edgeElement = this.renderEdge(edge, nodeMap);
|
|
2305
|
+
if (edgeElement) this.edgesLayer.appendChild(edgeElement);
|
|
2306
|
+
});
|
|
2307
|
+
if (this.interaction?.kind === "connect" || this.interaction?.kind === "reconnect") {
|
|
2308
|
+
this.previewLayer.appendChild(this.renderPreviewEdge(this.interaction));
|
|
2309
|
+
}
|
|
2310
|
+
this.documentData.nodes.forEach((node) => {
|
|
2311
|
+
this.nodesLayer.appendChild(this.renderNode(node));
|
|
2312
|
+
});
|
|
2313
|
+
if (this.reconnectEdgeId && !this.readonly) {
|
|
2314
|
+
const reconnectEdge = this.findEdge(this.reconnectEdgeId);
|
|
2315
|
+
if (reconnectEdge) {
|
|
2316
|
+
const fromNode = nodeMap.get(reconnectEdge.from.nodeId);
|
|
2317
|
+
const toNode = nodeMap.get(reconnectEdge.to.nodeId);
|
|
2318
|
+
if (fromNode && toNode) {
|
|
2319
|
+
this.overlayLayer.appendChild(
|
|
2320
|
+
this.renderReconnectEndpoints(reconnectEdge, fromNode, toNode)
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
this.syncConnectingState();
|
|
2326
|
+
}
|
|
2327
|
+
applyEdgeMarkers(pathElement, edge) {
|
|
2328
|
+
if (edge.startMarker && edge.startMarker !== "none") {
|
|
2329
|
+
pathElement.setAttribute(
|
|
2330
|
+
"marker-start",
|
|
2331
|
+
`url(#${this.getMarkerId(edge.startMarker, "start", this.getEdgeStrokeWidth(edge))})`
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
if (edge.endMarker && edge.endMarker !== "none") {
|
|
2335
|
+
pathElement.setAttribute(
|
|
2336
|
+
"marker-end",
|
|
2337
|
+
`url(#${this.getMarkerId(edge.endMarker, "end", this.getEdgeStrokeWidth(edge))})`
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
renderEdge(edge, nodeMap) {
|
|
2342
|
+
const fromNode = nodeMap.get(edge.from.nodeId);
|
|
2343
|
+
const toNode = nodeMap.get(edge.to.nodeId);
|
|
2344
|
+
if (!fromNode || !toNode) return null;
|
|
2345
|
+
const edgePath = buildEdgePath(edge, fromNode, toNode);
|
|
2346
|
+
const selected = this.selection?.kind === "edge" && this.selection.id === edge.id;
|
|
2347
|
+
const inReconnectMode = this.reconnectEdgeId === edge.id;
|
|
2348
|
+
const group = svgEl("g", {
|
|
2349
|
+
class: `vd-flowchart-edge${selected ? " is-selected" : ""}${inReconnectMode ? " is-reconnecting" : ""}`,
|
|
2350
|
+
"data-edge-id": edge.id
|
|
2351
|
+
});
|
|
2352
|
+
const strokeWidth = this.getEdgeStrokeWidth(edge);
|
|
2353
|
+
if (selected) {
|
|
2354
|
+
group.appendChild(
|
|
2355
|
+
svgEl("path", {
|
|
2356
|
+
class: "vd-flowchart-edge-selection",
|
|
2357
|
+
d: edgePath.d,
|
|
2358
|
+
"stroke-width": formatNumber(strokeWidth + 7)
|
|
2359
|
+
})
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
const visiblePath = svgEl("path", {
|
|
2363
|
+
class: "vd-flowchart-edge-path",
|
|
2364
|
+
d: edgePath.d,
|
|
2365
|
+
"stroke-width": strokeWidth,
|
|
2366
|
+
"data-edge-id": edge.id
|
|
2367
|
+
});
|
|
2368
|
+
this.applyEdgeMarkers(visiblePath, edge);
|
|
2369
|
+
const hitPath = svgEl("path", {
|
|
2370
|
+
class: "vd-flowchart-edge-hit",
|
|
2371
|
+
d: edgePath.d,
|
|
2372
|
+
"stroke-width": this.getEdgeHitStrokeWidth(edge),
|
|
2373
|
+
"data-edge-id": edge.id
|
|
2374
|
+
});
|
|
2375
|
+
group.appendChild(visiblePath);
|
|
2376
|
+
group.appendChild(hitPath);
|
|
2377
|
+
if (edge.label) {
|
|
2378
|
+
const label = svgEl("text", {
|
|
2379
|
+
class: "vd-flowchart-edge-label",
|
|
2380
|
+
x: edgePath.labelX,
|
|
2381
|
+
y: edgePath.labelY - 8,
|
|
2382
|
+
"text-anchor": "middle",
|
|
2383
|
+
"data-edge-id": edge.id
|
|
2384
|
+
});
|
|
2385
|
+
label.textContent = edge.label;
|
|
2386
|
+
group.appendChild(label);
|
|
2387
|
+
}
|
|
2388
|
+
return group;
|
|
2389
|
+
}
|
|
2390
|
+
renderReconnectEndpoints(edge, fromNode, toNode) {
|
|
2391
|
+
const scale = this.documentData.viewport.scale || 1;
|
|
2392
|
+
const group = svgEl("g", { class: "vd-flowchart-edge-endpoints" });
|
|
2393
|
+
[
|
|
2394
|
+
{ endpoint: "from", point: getPortPosition(fromNode, edge.from.port) },
|
|
2395
|
+
{ endpoint: "to", point: getPortPosition(toNode, edge.to.port) }
|
|
2396
|
+
].forEach(({ endpoint, point }) => {
|
|
2397
|
+
group.appendChild(
|
|
2398
|
+
svgEl("circle", {
|
|
2399
|
+
class: "vd-flowchart-edge-endpoint-hit",
|
|
2400
|
+
cx: point.x,
|
|
2401
|
+
cy: point.y,
|
|
2402
|
+
r: RECONNECT_ENDPOINT_HIT_RADIUS / scale,
|
|
2403
|
+
"data-edge-id": edge.id,
|
|
2404
|
+
"data-edge-endpoint": endpoint
|
|
2405
|
+
})
|
|
2406
|
+
);
|
|
2407
|
+
group.appendChild(
|
|
2408
|
+
svgEl("circle", {
|
|
2409
|
+
class: "vd-flowchart-edge-endpoint",
|
|
2410
|
+
cx: point.x,
|
|
2411
|
+
cy: point.y,
|
|
2412
|
+
r: RECONNECT_ENDPOINT_RADIUS / scale,
|
|
2413
|
+
"data-edge-id": edge.id,
|
|
2414
|
+
"data-edge-endpoint": endpoint
|
|
2415
|
+
})
|
|
2416
|
+
);
|
|
2417
|
+
});
|
|
2418
|
+
return group;
|
|
2419
|
+
}
|
|
2420
|
+
renderPreviewEdge(interaction) {
|
|
2421
|
+
const fromPort = interaction.fromPort || interaction.source?.port || "right";
|
|
2422
|
+
const toPort = interaction.toPort || interaction.target?.port || "left";
|
|
2423
|
+
const edgePath = buildEdgePath({
|
|
2424
|
+
strokeWidth: interaction.strokeWidth,
|
|
2425
|
+
route: interaction.route,
|
|
2426
|
+
fromPoint: interaction.fromPoint,
|
|
2427
|
+
toPoint: interaction.toPoint,
|
|
2428
|
+
from: { port: fromPort },
|
|
2429
|
+
to: { port: toPort }
|
|
2430
|
+
});
|
|
2431
|
+
return svgEl("path", {
|
|
2432
|
+
class: "vd-flowchart-preview-path",
|
|
2433
|
+
d: edgePath.d,
|
|
2434
|
+
"stroke-width": this.getEdgeStrokeWidth(interaction)
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
renderNode(node) {
|
|
2438
|
+
const selected = this.selection?.kind === "node" && this.selection.id === node.id;
|
|
2439
|
+
const dragging = this.interaction?.kind === "drag-node" && this.interaction.nodeId === node.id;
|
|
2440
|
+
const resizing = this.interaction?.kind === "resize-node" && this.interaction.nodeId === node.id;
|
|
2441
|
+
const editing = this.textEditor?.nodeId === node.id;
|
|
2442
|
+
const group = svgEl("g", {
|
|
2443
|
+
class: `vd-flowchart-node${selected ? " is-selected" : ""}${dragging ? " is-dragging" : ""}${resizing ? " is-resizing" : ""}${editing ? " is-editing" : ""}`,
|
|
2444
|
+
transform: `translate(${formatNumber(node.x)} ${formatNumber(node.y)})`,
|
|
2445
|
+
"data-node-id": node.id
|
|
2446
|
+
});
|
|
2447
|
+
const portsVisible = this.shouldShowNodePorts(node);
|
|
2448
|
+
const hitbox = svgEl("rect", {
|
|
2449
|
+
x: 0,
|
|
2450
|
+
y: 0,
|
|
2451
|
+
width: node.width,
|
|
2452
|
+
height: node.height,
|
|
2453
|
+
rx: node.type === "rounded-rect" || node.type === "textbox" ? 16 : node.type === "junction" ? node.width / 2 : 6,
|
|
2454
|
+
fill: "transparent",
|
|
2455
|
+
"data-node-id": node.id
|
|
2456
|
+
});
|
|
2457
|
+
group.appendChild(hitbox);
|
|
2458
|
+
group.appendChild(this.renderNodeShape(node));
|
|
2459
|
+
group.appendChild(this.renderNodeText(node));
|
|
2460
|
+
if (selected && !this.readonly && isNodeResizable(node)) {
|
|
2461
|
+
group.appendChild(this.renderResizeControls(node));
|
|
2462
|
+
}
|
|
2463
|
+
const scale = this.documentData.viewport.scale || 1;
|
|
2464
|
+
FLOWCHART_PORTS.forEach((port) => {
|
|
2465
|
+
const position = getPortPosition({ ...node, x: 0, y: 0 }, port);
|
|
2466
|
+
const portGroup = svgEl("g", {
|
|
2467
|
+
class: `vd-flowchart-port-group${portsVisible ? " is-visible" : ""}`,
|
|
2468
|
+
"data-node-id": node.id,
|
|
2469
|
+
"data-port": port
|
|
2470
|
+
});
|
|
2471
|
+
portGroup.appendChild(
|
|
2472
|
+
svgEl("circle", {
|
|
2473
|
+
class: "vd-flowchart-port-hit",
|
|
2474
|
+
cx: position.x,
|
|
2475
|
+
cy: position.y,
|
|
2476
|
+
r: CONNECTION_PORT_HIT_RADIUS / scale
|
|
2477
|
+
})
|
|
2478
|
+
);
|
|
2479
|
+
portGroup.appendChild(
|
|
2480
|
+
svgEl("circle", {
|
|
2481
|
+
class: "vd-flowchart-port",
|
|
2482
|
+
cx: position.x,
|
|
2483
|
+
cy: position.y,
|
|
2484
|
+
r: CONNECTION_PORT_RADIUS / scale
|
|
2485
|
+
})
|
|
2486
|
+
);
|
|
2487
|
+
group.appendChild(portGroup);
|
|
2488
|
+
});
|
|
2489
|
+
return group;
|
|
2490
|
+
}
|
|
2491
|
+
renderResizeControls(node) {
|
|
2492
|
+
const scale = this.documentData.viewport.scale || 1;
|
|
2493
|
+
const zone = 14 / scale;
|
|
2494
|
+
const cornerZone = 22 / scale;
|
|
2495
|
+
const handleSize = 9 / scale;
|
|
2496
|
+
const gap = RESIZE_PORT_GAP / scale;
|
|
2497
|
+
const group = svgEl("g", { class: "vd-flowchart-resize-controls" });
|
|
2498
|
+
const zones = {
|
|
2499
|
+
n: { x: 0, y: -zone / 2, width: node.width, height: zone },
|
|
2500
|
+
e: { x: node.width - zone / 2, y: 0, width: zone, height: node.height },
|
|
2501
|
+
s: { x: 0, y: node.height - zone / 2, width: node.width, height: zone },
|
|
2502
|
+
w: { x: -zone / 2, y: 0, width: zone, height: node.height },
|
|
2503
|
+
ne: {
|
|
2504
|
+
x: node.width - cornerZone / 2,
|
|
2505
|
+
y: -cornerZone / 2,
|
|
2506
|
+
width: cornerZone,
|
|
2507
|
+
height: cornerZone
|
|
2508
|
+
},
|
|
2509
|
+
se: {
|
|
2510
|
+
x: node.width - cornerZone / 2,
|
|
2511
|
+
y: node.height - cornerZone / 2,
|
|
2512
|
+
width: cornerZone,
|
|
2513
|
+
height: cornerZone
|
|
2514
|
+
},
|
|
2515
|
+
sw: {
|
|
2516
|
+
x: -cornerZone / 2,
|
|
2517
|
+
y: node.height - cornerZone / 2,
|
|
2518
|
+
width: cornerZone,
|
|
2519
|
+
height: cornerZone
|
|
2520
|
+
},
|
|
2521
|
+
nw: { x: -cornerZone / 2, y: -cornerZone / 2, width: cornerZone, height: cornerZone }
|
|
2522
|
+
};
|
|
2523
|
+
RESIZE_HANDLES.forEach((handle) => {
|
|
2524
|
+
const zoneRect = zones[handle];
|
|
2525
|
+
const position = getResizeHandlePosition(node, handle);
|
|
2526
|
+
const dotOffset = 8 / scale;
|
|
2527
|
+
if (handle.includes("e")) position.x += dotOffset;
|
|
2528
|
+
if (handle.includes("w")) position.x -= dotOffset;
|
|
2529
|
+
if (handle.includes("n")) position.y -= dotOffset;
|
|
2530
|
+
if (handle.includes("s")) position.y += dotOffset;
|
|
2531
|
+
const cursor = getResizeCursor(handle);
|
|
2532
|
+
const zoneSegments = [];
|
|
2533
|
+
if (handle === "e" || handle === "w") {
|
|
2534
|
+
const upperHeight = Math.max(0, node.height / 2 - gap / 2);
|
|
2535
|
+
const lowerY = node.height / 2 + gap / 2;
|
|
2536
|
+
const lowerHeight = Math.max(0, node.height - lowerY);
|
|
2537
|
+
zoneSegments.push(
|
|
2538
|
+
{ x: zoneRect.x, y: zoneRect.y, width: zoneRect.width, height: upperHeight },
|
|
2539
|
+
{ x: zoneRect.x, y: lowerY, width: zoneRect.width, height: lowerHeight }
|
|
2540
|
+
);
|
|
2541
|
+
} else if (handle === "n" || handle === "s") {
|
|
2542
|
+
const leftWidth = Math.max(0, node.width / 2 - gap / 2);
|
|
2543
|
+
const rightX = node.width / 2 + gap / 2;
|
|
2544
|
+
const rightWidth = Math.max(0, node.width - rightX);
|
|
2545
|
+
zoneSegments.push(
|
|
2546
|
+
{ x: zoneRect.x, y: zoneRect.y, width: leftWidth, height: zoneRect.height },
|
|
2547
|
+
{ x: rightX, y: zoneRect.y, width: rightWidth, height: zoneRect.height }
|
|
2548
|
+
);
|
|
2549
|
+
} else {
|
|
2550
|
+
zoneSegments.push(zoneRect);
|
|
2551
|
+
}
|
|
2552
|
+
zoneSegments.filter((segment) => segment.width > 0 && segment.height > 0).forEach((segment) => {
|
|
2553
|
+
const hit = svgEl("rect", {
|
|
2554
|
+
class: "vd-flowchart-resize-zone",
|
|
2555
|
+
x: segment.x,
|
|
2556
|
+
y: segment.y,
|
|
2557
|
+
width: segment.width,
|
|
2558
|
+
height: segment.height,
|
|
2559
|
+
"data-node-id": node.id,
|
|
2560
|
+
"data-resize-handle": handle,
|
|
2561
|
+
style: `cursor: ${cursor}`
|
|
2562
|
+
});
|
|
2563
|
+
group.appendChild(hit);
|
|
2564
|
+
});
|
|
2565
|
+
const dot = svgEl("rect", {
|
|
2566
|
+
class: "vd-flowchart-resize-handle",
|
|
2567
|
+
x: position.x - handleSize / 2,
|
|
2568
|
+
y: position.y - handleSize / 2,
|
|
2569
|
+
width: handleSize,
|
|
2570
|
+
height: handleSize,
|
|
2571
|
+
rx: handleSize / 3,
|
|
2572
|
+
ry: handleSize / 3,
|
|
2573
|
+
"data-node-id": node.id,
|
|
2574
|
+
"data-resize-handle": handle,
|
|
2575
|
+
style: `cursor: ${cursor}`
|
|
2576
|
+
});
|
|
2577
|
+
group.appendChild(dot);
|
|
2578
|
+
});
|
|
2579
|
+
return group;
|
|
2580
|
+
}
|
|
2581
|
+
renderNodeShape(node) {
|
|
2582
|
+
const baseClass = `vd-flowchart-node-shape vd-flowchart-node-shape--${node.type}`;
|
|
2583
|
+
if (node.type === "rounded-rect") {
|
|
2584
|
+
return svgEl("rect", {
|
|
2585
|
+
class: baseClass,
|
|
2586
|
+
x: 0,
|
|
2587
|
+
y: 0,
|
|
2588
|
+
width: node.width,
|
|
2589
|
+
height: node.height,
|
|
2590
|
+
rx: 18,
|
|
2591
|
+
ry: 18
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
if (node.type === "rect") {
|
|
2595
|
+
return svgEl("rect", {
|
|
2596
|
+
class: baseClass,
|
|
2597
|
+
x: 0,
|
|
2598
|
+
y: 0,
|
|
2599
|
+
width: node.width,
|
|
2600
|
+
height: node.height,
|
|
2601
|
+
rx: 2,
|
|
2602
|
+
ry: 2
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
if (node.type === "diamond") {
|
|
2606
|
+
return svgEl("polygon", {
|
|
2607
|
+
class: baseClass,
|
|
2608
|
+
points: `${node.width / 2},0 ${node.width},${node.height / 2} ${node.width / 2},${node.height} 0,${node.height / 2}`
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
if (node.type === "circle") {
|
|
2612
|
+
return svgEl("ellipse", {
|
|
2613
|
+
class: baseClass,
|
|
2614
|
+
cx: node.width / 2,
|
|
2615
|
+
cy: node.height / 2,
|
|
2616
|
+
rx: node.width / 2,
|
|
2617
|
+
ry: node.height / 2
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
if (node.type === "junction") {
|
|
2621
|
+
return svgEl("circle", {
|
|
2622
|
+
class: baseClass,
|
|
2623
|
+
cx: node.width / 2,
|
|
2624
|
+
cy: node.height / 2,
|
|
2625
|
+
r: Math.min(node.width, node.height) / 2
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
if (node.type === "textbox") {
|
|
2629
|
+
return svgEl("rect", {
|
|
2630
|
+
class: baseClass,
|
|
2631
|
+
x: 0,
|
|
2632
|
+
y: 0,
|
|
2633
|
+
width: node.width,
|
|
2634
|
+
height: node.height,
|
|
2635
|
+
rx: 14,
|
|
2636
|
+
ry: 14
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
return svgEl("rect", {
|
|
2640
|
+
class: baseClass,
|
|
2641
|
+
x: 0,
|
|
2642
|
+
y: 0,
|
|
2643
|
+
width: node.width,
|
|
2644
|
+
height: node.height,
|
|
2645
|
+
rx: 0,
|
|
2646
|
+
ry: 0
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
renderNodeText(node) {
|
|
2650
|
+
const textElement = svgEl("text", {
|
|
2651
|
+
class: `vd-flowchart-node-text vd-flowchart-node-text--${node.type}`,
|
|
2652
|
+
"data-node-id": node.id
|
|
2653
|
+
});
|
|
2654
|
+
if (node.type === "junction" || !node.text) {
|
|
2655
|
+
return textElement;
|
|
2656
|
+
}
|
|
2657
|
+
if (node.type === "textbox") {
|
|
2658
|
+
const lines2 = wrapText(node.text, estimateChars(node.width - 32));
|
|
2659
|
+
const { lineHeight: lineHeight2 } = getNodeFontMetrics(node);
|
|
2660
|
+
lines2.forEach((line, index) => {
|
|
2661
|
+
const span = svgEl("tspan", {
|
|
2662
|
+
x: 16,
|
|
2663
|
+
y: 28 + index * lineHeight2
|
|
2664
|
+
});
|
|
2665
|
+
span.textContent = line;
|
|
2666
|
+
textElement.appendChild(span);
|
|
2667
|
+
});
|
|
2668
|
+
return textElement;
|
|
2669
|
+
}
|
|
2670
|
+
const lines = wrapText(node.text, estimateChars(node.width));
|
|
2671
|
+
const { lineHeight } = getNodeFontMetrics(node);
|
|
2672
|
+
const totalHeight = (lines.length - 1) * lineHeight;
|
|
2673
|
+
const startY = node.height / 2 - totalHeight / 2;
|
|
2674
|
+
textElement.setAttribute("text-anchor", "middle");
|
|
2675
|
+
textElement.setAttribute("dominant-baseline", "middle");
|
|
2676
|
+
lines.forEach((line, index) => {
|
|
2677
|
+
const span = svgEl("tspan", {
|
|
2678
|
+
x: node.width / 2,
|
|
2679
|
+
y: startY + index * lineHeight,
|
|
2680
|
+
"text-anchor": "middle",
|
|
2681
|
+
"dominant-baseline": "middle"
|
|
2682
|
+
});
|
|
2683
|
+
span.textContent = line;
|
|
2684
|
+
textElement.appendChild(span);
|
|
2685
|
+
});
|
|
2686
|
+
return textElement;
|
|
2687
|
+
}
|
|
2688
|
+
renderSelectionPanel() {
|
|
2689
|
+
clearChildren(this.selectionFields);
|
|
2690
|
+
this.deleteButton.disabled = this.readonly || !this.selection;
|
|
2691
|
+
if (!this.selection) {
|
|
2692
|
+
this.selectionMeta.textContent = "Nothing selected";
|
|
2693
|
+
this.selectionFields.appendChild(
|
|
2694
|
+
createElement("p", {
|
|
2695
|
+
className: "vd-flowchart-selection-empty",
|
|
2696
|
+
text: "Select a node or edge to edit it."
|
|
2697
|
+
})
|
|
2698
|
+
);
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
if (this.selection.kind === "node") {
|
|
2702
|
+
const node = this.findNode(this.selection.id);
|
|
2703
|
+
if (!node) {
|
|
2704
|
+
this.selection = null;
|
|
2705
|
+
this.renderSelectionPanel();
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
this.selectionMeta.textContent = `Node ${node.id} \xB7 ${node.type} \xB7 ${Math.round(node.x)}, ${Math.round(node.y)}`;
|
|
2709
|
+
const typeSelect = createElement("select");
|
|
2710
|
+
typeSelect.setAttribute("data-field", "node-type");
|
|
2711
|
+
FLOWCHART_NODE_TYPES.forEach((type) => {
|
|
2712
|
+
const option = createElement("option", { value: type, text: type.replace("-", " ") });
|
|
2713
|
+
option.value = type;
|
|
2714
|
+
option.selected = type === node.type;
|
|
2715
|
+
typeSelect.appendChild(option);
|
|
2716
|
+
});
|
|
2717
|
+
const textArea = createElement("textarea", { value: node.text, rows: 5 });
|
|
2718
|
+
textArea.setAttribute("data-field", "node-text");
|
|
2719
|
+
const widthInput = createElement("input", { value: node.width, type: "number" });
|
|
2720
|
+
widthInput.setAttribute("data-field", "node-width");
|
|
2721
|
+
widthInput.setAttribute("min", String(MIN_NODE_SIZE));
|
|
2722
|
+
widthInput.setAttribute("max", String(MAX_NODE_SIZE));
|
|
2723
|
+
const heightInput = createElement("input", { value: node.height, type: "number" });
|
|
2724
|
+
heightInput.setAttribute("data-field", "node-height");
|
|
2725
|
+
heightInput.setAttribute("min", String(MIN_NODE_SIZE));
|
|
2726
|
+
heightInput.setAttribute("max", String(MAX_NODE_SIZE));
|
|
2727
|
+
this.selectionFields.appendChild(createField("Type", typeSelect));
|
|
2728
|
+
if (isNodeTextEditable(node)) {
|
|
2729
|
+
this.selectionFields.appendChild(createField("Text", textArea));
|
|
2730
|
+
}
|
|
2731
|
+
if (isNodeResizable(node)) {
|
|
2732
|
+
const sizeGrid = createElement("div", { className: "vd-flowchart-field-grid" });
|
|
2733
|
+
sizeGrid.appendChild(createField("Width", widthInput));
|
|
2734
|
+
sizeGrid.appendChild(createField("Height", heightInput));
|
|
2735
|
+
this.selectionFields.appendChild(sizeGrid);
|
|
2736
|
+
} else {
|
|
2737
|
+
this.selectionFields.appendChild(
|
|
2738
|
+
createElement("p", {
|
|
2739
|
+
className: "vd-flowchart-selection-empty",
|
|
2740
|
+
text: "Junctions stay fixed-size and do not carry inline text."
|
|
2741
|
+
})
|
|
2742
|
+
);
|
|
2743
|
+
}
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
const edge = this.findEdge(this.selection.id);
|
|
2747
|
+
if (!edge) {
|
|
2748
|
+
this.selection = null;
|
|
2749
|
+
this.renderSelectionPanel();
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
this.selectionMeta.textContent = `Edge ${edge.id} \xB7 ${edge.route} \xB7 ${formatNumber(edge.strokeWidth)}px \xB7 ${edge.startMarker} \u2192 ${edge.endMarker}`;
|
|
2753
|
+
const routeSelect = createElement("select");
|
|
2754
|
+
routeSelect.setAttribute("data-field", "edge-route");
|
|
2755
|
+
FLOWCHART_EDGE_ROUTES.forEach((route) => {
|
|
2756
|
+
const option = createElement("option", {
|
|
2757
|
+
value: route,
|
|
2758
|
+
text: FLOWCHART_EDGE_ROUTE_LABELS[route]
|
|
2759
|
+
});
|
|
2760
|
+
option.value = route;
|
|
2761
|
+
option.selected = route === edge.route;
|
|
2762
|
+
routeSelect.appendChild(option);
|
|
2763
|
+
});
|
|
2764
|
+
const startSelect = createElement("select");
|
|
2765
|
+
startSelect.setAttribute("data-field", "edge-start-marker");
|
|
2766
|
+
FLOWCHART_EDGE_MARKERS.forEach((marker) => {
|
|
2767
|
+
const option = createElement("option", { value: marker, text: marker });
|
|
2768
|
+
option.value = marker;
|
|
2769
|
+
option.selected = marker === edge.startMarker;
|
|
2770
|
+
startSelect.appendChild(option);
|
|
2771
|
+
});
|
|
2772
|
+
const endSelect = createElement("select");
|
|
2773
|
+
endSelect.setAttribute("data-field", "edge-end-marker");
|
|
2774
|
+
FLOWCHART_EDGE_MARKERS.forEach((marker) => {
|
|
2775
|
+
const option = createElement("option", { value: marker, text: marker });
|
|
2776
|
+
option.value = marker;
|
|
2777
|
+
option.selected = marker === edge.endMarker;
|
|
2778
|
+
endSelect.appendChild(option);
|
|
2779
|
+
});
|
|
2780
|
+
const widthSelect = createElement("select");
|
|
2781
|
+
widthSelect.setAttribute("data-field", "edge-stroke-preset");
|
|
2782
|
+
EDGE_STROKE_PRESETS.forEach((preset) => {
|
|
2783
|
+
const option = createElement("option", { value: preset.id, text: preset.label });
|
|
2784
|
+
option.value = preset.id;
|
|
2785
|
+
option.selected = preset.id === getStrokePresetId(edge.strokeWidth);
|
|
2786
|
+
widthSelect.appendChild(option);
|
|
2787
|
+
});
|
|
2788
|
+
const labelInput = createElement("textarea", { value: edge.label, rows: 4 });
|
|
2789
|
+
labelInput.setAttribute("data-field", "edge-label");
|
|
2790
|
+
const markerGrid = createElement("div", { className: "vd-flowchart-field-grid" });
|
|
2791
|
+
markerGrid.appendChild(createField("Route", routeSelect));
|
|
2792
|
+
markerGrid.appendChild(createField("Weight", widthSelect));
|
|
2793
|
+
markerGrid.appendChild(createField("Start", startSelect));
|
|
2794
|
+
markerGrid.appendChild(createField("End", endSelect));
|
|
2795
|
+
this.selectionFields.appendChild(markerGrid);
|
|
2796
|
+
this.selectionFields.appendChild(createField("Label", labelInput));
|
|
2797
|
+
}
|
|
2798
|
+
syncJsonTextarea(force = false) {
|
|
2799
|
+
if (!force && document.activeElement === this.jsonTextarea) {
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
this.jsonTextarea.value = JSON.stringify(this.toJSON(), null, 2);
|
|
2803
|
+
}
|
|
2804
|
+
getSelectionSnapshot() {
|
|
2805
|
+
if (!this.selection) return null;
|
|
2806
|
+
if (this.selection.kind === "node") {
|
|
2807
|
+
const node = this.findNode(this.selection.id);
|
|
2808
|
+
return node ? { kind: "node", id: node.id, node: deepClone(node) } : null;
|
|
2809
|
+
}
|
|
2810
|
+
const edge = this.findEdge(this.selection.id);
|
|
2811
|
+
return edge ? { kind: "edge", id: edge.id, edge: deepClone(edge) } : null;
|
|
2812
|
+
}
|
|
2813
|
+
select(selection) {
|
|
2814
|
+
const next = selection && selection.id && selection.kind ? { kind: selection.kind, id: selection.id } : null;
|
|
2815
|
+
const previousKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
|
|
2816
|
+
const nextKey = next ? `${next.kind}:${next.id}` : "";
|
|
2817
|
+
this.selection = next;
|
|
2818
|
+
if (previousKey !== nextKey) {
|
|
2819
|
+
this.render({ scene: true, inspector: true, json: false });
|
|
2820
|
+
this.emit("select", { selection: this.getSelectionSnapshot() });
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
2823
|
+
this.render({ scene: true, inspector: false, json: false });
|
|
2824
|
+
}
|
|
2825
|
+
selectNode(nodeId) {
|
|
2826
|
+
this.select({ kind: "node", id: sanitizeId(nodeId) });
|
|
2827
|
+
return this;
|
|
2828
|
+
}
|
|
2829
|
+
selectEdge(edgeId) {
|
|
2830
|
+
this.select({ kind: "edge", id: sanitizeId(edgeId) });
|
|
2831
|
+
return this;
|
|
2832
|
+
}
|
|
2833
|
+
deselect() {
|
|
2834
|
+
this.select(null);
|
|
2835
|
+
return this;
|
|
2836
|
+
}
|
|
2837
|
+
resolvePreservedSelection(previousSelection, preserve) {
|
|
2838
|
+
if (!preserve || !previousSelection) return null;
|
|
2839
|
+
const exists = previousSelection.kind === "node" ? Boolean(this.findNode(previousSelection.id)) : Boolean(this.findEdge(previousSelection.id));
|
|
2840
|
+
return exists ? { kind: previousSelection.kind, id: previousSelection.id } : null;
|
|
2841
|
+
}
|
|
2842
|
+
findNode(nodeId) {
|
|
2843
|
+
return this.documentData.nodes.find((node) => node.id === nodeId) || null;
|
|
2844
|
+
}
|
|
2845
|
+
findEdge(edgeId) {
|
|
2846
|
+
return this.documentData.edges.find((edge) => edge.id === edgeId) || null;
|
|
2847
|
+
}
|
|
2848
|
+
emit(eventName, payload) {
|
|
2849
|
+
const listeners = this.listeners[eventName];
|
|
2850
|
+
if (!listeners || !listeners.size) return;
|
|
2851
|
+
listeners.forEach((listener) => listener(payload));
|
|
2852
|
+
}
|
|
2853
|
+
emitChange(reason, extra = {}) {
|
|
2854
|
+
if (this.historyEnabled && !this.isApplyingHistory) {
|
|
2855
|
+
this.recordHistory(reason);
|
|
2856
|
+
}
|
|
2857
|
+
this.syncJsonTextarea();
|
|
2858
|
+
this.emit("change", {
|
|
2859
|
+
reason,
|
|
2860
|
+
document: this.toJSON(),
|
|
2861
|
+
...extra
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
emitViewportChange(reason) {
|
|
2865
|
+
const payload = {
|
|
2866
|
+
reason,
|
|
2867
|
+
viewport: deepClone(this.documentData.viewport),
|
|
2868
|
+
document: this.toJSON()
|
|
2869
|
+
};
|
|
2870
|
+
this.syncJsonTextarea();
|
|
2871
|
+
this.emit("viewport", payload);
|
|
2872
|
+
this.emit("change", payload);
|
|
2873
|
+
}
|
|
2874
|
+
on(eventName, callback) {
|
|
2875
|
+
if (!this.listeners[eventName]) {
|
|
2876
|
+
this.listeners[eventName] = /* @__PURE__ */ new Set();
|
|
2877
|
+
}
|
|
2878
|
+
this.listeners[eventName].add(callback);
|
|
2879
|
+
return this;
|
|
2880
|
+
}
|
|
2881
|
+
off(eventName, callback) {
|
|
2882
|
+
this.listeners[eventName]?.delete(callback);
|
|
2883
|
+
return this;
|
|
2884
|
+
}
|
|
2885
|
+
// --- History (undo / redo) ----------------------------------------------
|
|
2886
|
+
// Every document mutation funnels through emitChange(), so recording there
|
|
2887
|
+
// captures all of them with exactly one entry per committed gesture (live
|
|
2888
|
+
// drag/resize only render()). Viewport pan/zoom go through emitViewportChange
|
|
2889
|
+
// and are intentionally not recorded — undo is for content, not the camera.
|
|
2890
|
+
seedHistory() {
|
|
2891
|
+
if (!this.historyEnabled) return;
|
|
2892
|
+
this.history = [{ reason: "init", targetKey: "", snapshot: this.toJSON() }];
|
|
2893
|
+
this.historyIndex = 0;
|
|
2894
|
+
}
|
|
2895
|
+
recordHistory(reason) {
|
|
2896
|
+
const snapshot = this.toJSON();
|
|
2897
|
+
const targetKey = this.selection ? `${this.selection.kind}:${this.selection.id}` : "";
|
|
2898
|
+
const top = this.history[this.historyIndex];
|
|
2899
|
+
const canCoalesce = Boolean(top) && this.historyIndex === this.history.length - 1 && COALESCING_REASONS.has(reason) && top.reason === reason && top.targetKey === targetKey;
|
|
2900
|
+
if (canCoalesce) {
|
|
2901
|
+
top.snapshot = snapshot;
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
if (this.historyIndex < this.history.length - 1) {
|
|
2905
|
+
this.history.length = this.historyIndex + 1;
|
|
2906
|
+
}
|
|
2907
|
+
this.history.push({ reason, targetKey, snapshot });
|
|
2908
|
+
if (this.history.length > this.historyLimit) {
|
|
2909
|
+
this.history.shift();
|
|
2910
|
+
}
|
|
2911
|
+
this.historyIndex = this.history.length - 1;
|
|
2912
|
+
this.emitHistoryState(reason);
|
|
2913
|
+
}
|
|
2914
|
+
applyHistorySnapshot(snapshot, reason) {
|
|
2915
|
+
this.isApplyingHistory = true;
|
|
2916
|
+
this.stopTextEdit({ commit: false });
|
|
2917
|
+
this.activeTool = null;
|
|
2918
|
+
this.reconnectEdgeId = null;
|
|
2919
|
+
const currentViewport = deepClone(this.documentData.viewport);
|
|
2920
|
+
const previousSelection = this.selection;
|
|
2921
|
+
this.documentData = normalizeDocument(snapshot);
|
|
2922
|
+
this.documentData.viewport = normalizeViewport(currentViewport);
|
|
2923
|
+
this.selection = this.resolvePreservedSelection(previousSelection, true);
|
|
2924
|
+
this.render();
|
|
2925
|
+
this.emitChange(reason);
|
|
2926
|
+
this.isApplyingHistory = false;
|
|
2927
|
+
this.emitHistoryState(reason);
|
|
2928
|
+
}
|
|
2929
|
+
emitHistoryState(reason) {
|
|
2930
|
+
this.updateHistoryButtons();
|
|
2931
|
+
this.emit("history", { reason, canUndo: this.canUndo(), canRedo: this.canRedo() });
|
|
2932
|
+
}
|
|
2933
|
+
canUndo() {
|
|
2934
|
+
return this.historyEnabled && this.historyIndex > 0;
|
|
2935
|
+
}
|
|
2936
|
+
canRedo() {
|
|
2937
|
+
return this.historyEnabled && this.historyIndex < this.history.length - 1;
|
|
2938
|
+
}
|
|
2939
|
+
undo() {
|
|
2940
|
+
if (!this.canUndo()) return this;
|
|
2941
|
+
this.historyIndex -= 1;
|
|
2942
|
+
this.applyHistorySnapshot(this.history[this.historyIndex].snapshot, "undo");
|
|
2943
|
+
return this;
|
|
2944
|
+
}
|
|
2945
|
+
redo() {
|
|
2946
|
+
if (!this.canRedo()) return this;
|
|
2947
|
+
this.historyIndex += 1;
|
|
2948
|
+
this.applyHistorySnapshot(this.history[this.historyIndex].snapshot, "redo");
|
|
2949
|
+
return this;
|
|
2950
|
+
}
|
|
2951
|
+
clearHistory() {
|
|
2952
|
+
this.seedHistory();
|
|
2953
|
+
this.emitHistoryState("history:clear");
|
|
2954
|
+
return this;
|
|
2955
|
+
}
|
|
2956
|
+
updateHistoryButtons() {
|
|
2957
|
+
if (this.undoButton) this.undoButton.disabled = this.readonly || !this.canUndo();
|
|
2958
|
+
if (this.redoButton) this.redoButton.disabled = this.readonly || !this.canRedo();
|
|
2959
|
+
}
|
|
2960
|
+
// Resolve a `relativeTo` anchor into a top-left position for a new node of the
|
|
2961
|
+
// given spec. Accepts a bare node id or `{ node, direction, distance, angle }`;
|
|
2962
|
+
// `direction` (right/down/left/up) is sugar for an angle in screen space
|
|
2963
|
+
// (y-down). Returns null when the anchor node can't be found.
|
|
2964
|
+
resolveRelativePosition(relativeTo, spec) {
|
|
2965
|
+
const ref2 = typeof relativeTo === "string" ? { node: relativeTo } : relativeTo || {};
|
|
2966
|
+
const anchor = this.findNode(sanitizeId(ref2.node ?? ref2.nodeId ?? ref2.id));
|
|
2967
|
+
if (!anchor) return null;
|
|
2968
|
+
const directionAngles = { right: 0, down: 90, left: 180, up: -90 };
|
|
2969
|
+
const angleDeg = ref2.angle != null ? toFiniteNumber(ref2.angle, 0) : directionAngles[ref2.direction] ?? 0;
|
|
2970
|
+
const distance = Math.max(0, toFiniteNumber(ref2.distance, 220));
|
|
2971
|
+
const radians = angleDeg * Math.PI / 180;
|
|
2972
|
+
const centerX = anchor.x + anchor.width / 2 + Math.cos(radians) * distance;
|
|
2973
|
+
const centerY = anchor.y + anchor.height / 2 + Math.sin(radians) * distance;
|
|
2974
|
+
return {
|
|
2975
|
+
x: formatNumber(centerX - spec.width / 2),
|
|
2976
|
+
y: formatNumber(centerY - spec.height / 2)
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
addNode(partialNode = {}) {
|
|
2980
|
+
const type = normalizeNodeType(partialNode.type);
|
|
2981
|
+
const spec = DEFAULT_NODE_SPECS[type];
|
|
2982
|
+
const relative = partialNode.relativeTo != null ? this.resolveRelativePosition(partialNode.relativeTo, spec) : null;
|
|
2983
|
+
const center = this.getViewportCenter();
|
|
2984
|
+
const offset = this.paletteSerial % 6 * 26;
|
|
2985
|
+
this.paletteSerial += 1;
|
|
2986
|
+
const fallbackX = relative ? relative.x : formatNumber(center.x - spec.width / 2 + offset);
|
|
2987
|
+
const fallbackY = relative ? relative.y : formatNumber(center.y - spec.height / 2 + offset);
|
|
2988
|
+
const rest = { ...partialNode };
|
|
2989
|
+
delete rest.relativeTo;
|
|
2990
|
+
const usedIds = new Set(this.documentData.nodes.map((node2) => node2.id));
|
|
2991
|
+
const node = normalizeNode(
|
|
2992
|
+
{
|
|
2993
|
+
...rest,
|
|
2994
|
+
type,
|
|
2995
|
+
x: partialNode.x == null ? fallbackX : partialNode.x,
|
|
2996
|
+
y: partialNode.y == null ? fallbackY : partialNode.y
|
|
2997
|
+
},
|
|
2998
|
+
this.documentData.nodes.length,
|
|
2999
|
+
usedIds
|
|
3000
|
+
);
|
|
3001
|
+
this.documentData.nodes.push(node);
|
|
3002
|
+
this.select({ kind: "node", id: node.id });
|
|
3003
|
+
this.syncJsonTextarea();
|
|
3004
|
+
this.emitChange("node:add", { node: deepClone(node) });
|
|
3005
|
+
return deepClone(node);
|
|
3006
|
+
}
|
|
3007
|
+
// Convenience: place a child node relative to a parent and connect them with
|
|
3008
|
+
// an auto-ported arrow in one call. Returns { node, edge } (edge may be null
|
|
3009
|
+
// if the connection is rejected). The new child is left selected.
|
|
3010
|
+
addChildNode(parentId, options = {}) {
|
|
3011
|
+
if (this.readonly) return null;
|
|
3012
|
+
const parent = this.findNode(sanitizeId(parentId));
|
|
3013
|
+
if (!parent) return null;
|
|
3014
|
+
const { direction = "right", distance, angle, edge: edgeOptions, ...nodeOptions } = options;
|
|
3015
|
+
const node = this.addNode({
|
|
3016
|
+
...nodeOptions,
|
|
3017
|
+
relativeTo: { node: parent.id, direction, distance, angle }
|
|
3018
|
+
});
|
|
3019
|
+
const edge = this.addEdge({
|
|
3020
|
+
from: parent.id,
|
|
3021
|
+
to: node.id,
|
|
3022
|
+
autoPort: true,
|
|
3023
|
+
endMarker: "arrow",
|
|
3024
|
+
...isPlainObject(edgeOptions) ? edgeOptions : {}
|
|
3025
|
+
});
|
|
3026
|
+
this.select({ kind: "node", id: node.id });
|
|
3027
|
+
return { node, edge };
|
|
3028
|
+
}
|
|
3029
|
+
// Arrange nodes with a built-in layout. Positions are computed by the pure
|
|
3030
|
+
// computeLayout() module, then applied in place (NOT via load(), which would
|
|
3031
|
+
// wipe selection/viewport) so the result is one undoable, change-emitting step.
|
|
3032
|
+
layout(mode = "tree", options = {}) {
|
|
3033
|
+
if (this.readonly) return this;
|
|
3034
|
+
const resolvedMode = LAYOUT_MODES.includes(mode) ? mode : "tree";
|
|
3035
|
+
const positions = computeLayout(this.documentData, resolvedMode, options);
|
|
3036
|
+
if (!positions.size) return this;
|
|
3037
|
+
let changed = false;
|
|
3038
|
+
this.documentData.nodes.forEach((node) => {
|
|
3039
|
+
const next = positions.get(node.id);
|
|
3040
|
+
if (!next) return;
|
|
3041
|
+
const x = formatNumber(next.x);
|
|
3042
|
+
const y = formatNumber(next.y);
|
|
3043
|
+
if (x !== node.x || y !== node.y) {
|
|
3044
|
+
node.x = x;
|
|
3045
|
+
node.y = y;
|
|
3046
|
+
changed = true;
|
|
3047
|
+
}
|
|
3048
|
+
});
|
|
3049
|
+
const rerouted = options.reroutePorts === false ? false : this.rerouteEdgePorts();
|
|
3050
|
+
if (!changed && !rerouted) return this;
|
|
3051
|
+
this.render();
|
|
3052
|
+
this.emitChange("layout", { mode: resolvedMode });
|
|
3053
|
+
if (options.fit) this.fitView();
|
|
3054
|
+
return this;
|
|
3055
|
+
}
|
|
3056
|
+
autoArrange(options = {}) {
|
|
3057
|
+
return this.layout("grid", options);
|
|
3058
|
+
}
|
|
3059
|
+
// Re-pick each edge's from/to port from the nodes' current centers, so a
|
|
3060
|
+
// freshly laid-out graph attaches connectors on sensible sides. Returns
|
|
3061
|
+
// whether any port actually changed.
|
|
3062
|
+
rerouteEdgePorts() {
|
|
3063
|
+
let changed = false;
|
|
3064
|
+
this.documentData.edges.forEach((edge) => {
|
|
3065
|
+
const fromNode = this.findNode(edge.from.nodeId);
|
|
3066
|
+
const toNode = this.findNode(edge.to.nodeId);
|
|
3067
|
+
if (!fromNode || !toNode) return;
|
|
3068
|
+
const fromCenter = {
|
|
3069
|
+
x: fromNode.x + fromNode.width / 2,
|
|
3070
|
+
y: fromNode.y + fromNode.height / 2
|
|
3071
|
+
};
|
|
3072
|
+
const toCenter = { x: toNode.x + toNode.width / 2, y: toNode.y + toNode.height / 2 };
|
|
3073
|
+
const nextFrom = getPortByDirection(fromNode, toCenter).port;
|
|
3074
|
+
const nextTo = getPortByDirection(toNode, fromCenter).port;
|
|
3075
|
+
if (nextFrom !== edge.from.port || nextTo !== edge.to.port) {
|
|
3076
|
+
edge.from.port = nextFrom;
|
|
3077
|
+
edge.to.port = nextTo;
|
|
3078
|
+
changed = true;
|
|
3079
|
+
}
|
|
3080
|
+
});
|
|
3081
|
+
return changed;
|
|
3082
|
+
}
|
|
3083
|
+
applyNodePatch(node, patch = {}) {
|
|
3084
|
+
const nextType = normalizeNodeType(patch.type ?? node.type);
|
|
3085
|
+
node.type = nextType;
|
|
3086
|
+
node.x = patch.x == null ? node.x : formatNumber(toFiniteNumber(patch.x, node.x));
|
|
3087
|
+
node.y = patch.y == null ? node.y : formatNumber(toFiniteNumber(patch.y, node.y));
|
|
3088
|
+
node.text = isNodeTextEditable(nextType) ? patch.text == null ? node.text : String(patch.text) : getNodeSpec(nextType).text;
|
|
3089
|
+
node.width = clampNodeWidth(nextType, patch.width, node.width);
|
|
3090
|
+
node.height = clampNodeHeight(nextType, patch.height, node.height);
|
|
3091
|
+
if (isPlainObject(patch.data)) {
|
|
3092
|
+
node.data = deepClone(patch.data);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
updateNode(nodeId, patch = {}, options = {}) {
|
|
3096
|
+
const node = this.findNode(nodeId);
|
|
3097
|
+
if (!node) return null;
|
|
3098
|
+
const previousType = node.type;
|
|
3099
|
+
const previousSpec = DEFAULT_NODE_SPECS[previousType];
|
|
3100
|
+
this.applyNodePatch(node, patch);
|
|
3101
|
+
if (patch.type && previousType !== node.type) {
|
|
3102
|
+
const nextSpec = DEFAULT_NODE_SPECS[node.type];
|
|
3103
|
+
if (patch.width == null && node.width === previousSpec.width) {
|
|
3104
|
+
node.width = nextSpec.width;
|
|
3105
|
+
}
|
|
3106
|
+
if (patch.height == null && node.height === previousSpec.height) {
|
|
3107
|
+
node.height = nextSpec.height;
|
|
3108
|
+
}
|
|
3109
|
+
if (node.text === DEFAULT_NODE_SPECS[previousType].text) {
|
|
3110
|
+
node.text = nextSpec.text;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
this.render({
|
|
3114
|
+
scene: true,
|
|
3115
|
+
inspector: options.inspector !== false,
|
|
3116
|
+
json: true
|
|
3117
|
+
});
|
|
3118
|
+
this.emitChange(options.reason || "node:update", { node: deepClone(node) });
|
|
3119
|
+
return deepClone(node);
|
|
3120
|
+
}
|
|
3121
|
+
// Walk outgoing edges (from.nodeId -> to.nodeId) breadth-first to collect a
|
|
3122
|
+
// node and every descendant reachable through the directed graph. The visited
|
|
3123
|
+
// Set doubles as a cycle guard, so a diamond or loop is safe (each node once).
|
|
3124
|
+
collectDescendants(rootId) {
|
|
3125
|
+
const visited = /* @__PURE__ */ new Set([rootId]);
|
|
3126
|
+
const queue = [rootId];
|
|
3127
|
+
while (queue.length) {
|
|
3128
|
+
const current = queue.shift();
|
|
3129
|
+
this.documentData.edges.forEach((edge) => {
|
|
3130
|
+
if (edge.from.nodeId === current && !visited.has(edge.to.nodeId)) {
|
|
3131
|
+
visited.add(edge.to.nodeId);
|
|
3132
|
+
queue.push(edge.to.nodeId);
|
|
3133
|
+
}
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
3136
|
+
return visited;
|
|
3137
|
+
}
|
|
3138
|
+
removeNode(nodeId, options = {}) {
|
|
3139
|
+
const id = sanitizeId(nodeId);
|
|
3140
|
+
if (!this.findNode(id)) return false;
|
|
3141
|
+
const ids = options.cascade ? this.collectDescendants(id) : /* @__PURE__ */ new Set([id]);
|
|
3142
|
+
return this.removeNodeIds(ids, options.reason || "node:remove", id);
|
|
3143
|
+
}
|
|
3144
|
+
removeNodeIds(ids, reason = "node:remove", primaryId = null) {
|
|
3145
|
+
const idSet = ids instanceof Set ? ids : new Set(ids);
|
|
3146
|
+
const removed = this.documentData.nodes.filter((node) => idSet.has(node.id)).map((node) => node.id);
|
|
3147
|
+
if (!removed.length) return false;
|
|
3148
|
+
if (this.textEditor && idSet.has(this.textEditor.nodeId)) {
|
|
3149
|
+
this.stopTextEdit({ commit: false });
|
|
3150
|
+
}
|
|
3151
|
+
this.documentData.nodes = this.documentData.nodes.filter((node) => !idSet.has(node.id));
|
|
3152
|
+
this.documentData.edges = this.documentData.edges.filter(
|
|
3153
|
+
(edge) => !idSet.has(edge.from.nodeId) && !idSet.has(edge.to.nodeId)
|
|
3154
|
+
);
|
|
3155
|
+
if (this.selection?.kind === "node" && idSet.has(this.selection.id)) {
|
|
3156
|
+
this.selection = null;
|
|
3157
|
+
}
|
|
3158
|
+
this.render();
|
|
3159
|
+
this.emitChange(reason, { nodeId: primaryId ?? removed[0], nodeIds: removed });
|
|
3160
|
+
return true;
|
|
3161
|
+
}
|
|
3162
|
+
updateEdge(edgeId, patch = {}, options = {}) {
|
|
3163
|
+
const edge = this.findEdge(edgeId);
|
|
3164
|
+
if (!edge) return null;
|
|
3165
|
+
const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
|
|
3166
|
+
const nextFrom = patch.from ? normalizeEndpoint(patch.from, edge.from.port) : edge.from;
|
|
3167
|
+
const nextTo = patch.to ? normalizeEndpoint(patch.to, edge.to.port) : edge.to;
|
|
3168
|
+
if (patch.from != null || patch.to != null) {
|
|
3169
|
+
if (!nodeIds.has(nextFrom.nodeId) || !nodeIds.has(nextTo.nodeId)) return null;
|
|
3170
|
+
if (!FLOWCHART_PORTS.includes(nextFrom.port) || !FLOWCHART_PORTS.includes(nextTo.port))
|
|
3171
|
+
return null;
|
|
3172
|
+
if (nextFrom.nodeId === nextTo.nodeId && nextFrom.port === nextTo.port) return null;
|
|
3173
|
+
edge.from = nextFrom;
|
|
3174
|
+
edge.to = nextTo;
|
|
3175
|
+
}
|
|
3176
|
+
if (patch.kind != null) {
|
|
3177
|
+
edge.kind = patch.kind === "line" ? "line" : "arrow";
|
|
3178
|
+
if (patch.startMarker == null && patch.endMarker == null) {
|
|
3179
|
+
if (edge.kind === "line") {
|
|
3180
|
+
edge.startMarker = "none";
|
|
3181
|
+
edge.endMarker = "none";
|
|
3182
|
+
} else if (edge.endMarker === "none" && edge.startMarker === "none") {
|
|
3183
|
+
edge.endMarker = "arrow";
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
if (patch.startMarker != null) {
|
|
3188
|
+
edge.startMarker = normalizeEdgeMarker(patch.startMarker) || "none";
|
|
3189
|
+
}
|
|
3190
|
+
if (patch.endMarker != null) {
|
|
3191
|
+
edge.endMarker = normalizeEdgeMarker(patch.endMarker) || "none";
|
|
3192
|
+
}
|
|
3193
|
+
if (patch.route != null) {
|
|
3194
|
+
edge.route = normalizeEdgeRoute(patch.route);
|
|
3195
|
+
}
|
|
3196
|
+
if (patch.strokeWidth != null) {
|
|
3197
|
+
edge.strokeWidth = normalizeEdgeStrokeWidth(patch.strokeWidth);
|
|
3198
|
+
}
|
|
3199
|
+
if (patch.label != null) {
|
|
3200
|
+
edge.label = String(patch.label);
|
|
3201
|
+
}
|
|
3202
|
+
if (isPlainObject(patch.data)) {
|
|
3203
|
+
edge.data = deepClone(patch.data);
|
|
3204
|
+
}
|
|
3205
|
+
syncEdgeKind(edge);
|
|
3206
|
+
this.render({
|
|
3207
|
+
scene: true,
|
|
3208
|
+
inspector: options.inspector !== false,
|
|
3209
|
+
json: true
|
|
3210
|
+
});
|
|
3211
|
+
this.emitChange(options.reason || "edge:update", { edge: deepClone(edge) });
|
|
3212
|
+
return deepClone(edge);
|
|
3213
|
+
}
|
|
3214
|
+
// Accept `from`/`to` as a bare node id or `{ nodeId, port }`. When `autoPort`
|
|
3215
|
+
// is set, fill any omitted port by aiming each endpoint at the other node's
|
|
3216
|
+
// center via getPortByDirection — the same geometry the live connect tool
|
|
3217
|
+
// uses, so a programmatic edge picks the same side a dragged one would.
|
|
3218
|
+
resolveEdgeEndpoints(partialEdge) {
|
|
3219
|
+
const toEndpoint = (value) => {
|
|
3220
|
+
if (typeof value === "string") return { nodeId: value };
|
|
3221
|
+
if (isPlainObject(value)) return { ...value };
|
|
3222
|
+
return {};
|
|
3223
|
+
};
|
|
3224
|
+
const from = toEndpoint(partialEdge.from);
|
|
3225
|
+
const to = toEndpoint(partialEdge.to);
|
|
3226
|
+
if (partialEdge.autoPort) {
|
|
3227
|
+
const fromNode = this.findNode(sanitizeId(from.nodeId));
|
|
3228
|
+
const toNode = this.findNode(sanitizeId(to.nodeId));
|
|
3229
|
+
if (fromNode && toNode) {
|
|
3230
|
+
const fromCenter = {
|
|
3231
|
+
x: fromNode.x + fromNode.width / 2,
|
|
3232
|
+
y: fromNode.y + fromNode.height / 2
|
|
3233
|
+
};
|
|
3234
|
+
const toCenter = { x: toNode.x + toNode.width / 2, y: toNode.y + toNode.height / 2 };
|
|
3235
|
+
if (!FLOWCHART_PORTS.includes(from.port))
|
|
3236
|
+
from.port = getPortByDirection(fromNode, toCenter).port;
|
|
3237
|
+
if (!FLOWCHART_PORTS.includes(to.port))
|
|
3238
|
+
to.port = getPortByDirection(toNode, fromCenter).port;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
const rest = { ...partialEdge };
|
|
3242
|
+
delete rest.autoPort;
|
|
3243
|
+
return { ...rest, from, to };
|
|
3244
|
+
}
|
|
3245
|
+
addEdge(partialEdge = {}) {
|
|
3246
|
+
if (this.readonly) return null;
|
|
3247
|
+
const input = this.resolveEdgeEndpoints(partialEdge);
|
|
3248
|
+
const nodeIds = new Set(this.documentData.nodes.map((node) => node.id));
|
|
3249
|
+
const usedIds = new Set(this.documentData.edges.map((edge2) => edge2.id));
|
|
3250
|
+
const edge = normalizeEdge(input, this.documentData.edges.length, nodeIds, usedIds);
|
|
3251
|
+
if (!edge) return null;
|
|
3252
|
+
if (edge.from.nodeId === edge.to.nodeId && edge.from.port === edge.to.port) return null;
|
|
3253
|
+
this.documentData.edges.push(edge);
|
|
3254
|
+
this.select({ kind: "edge", id: edge.id });
|
|
3255
|
+
this.syncJsonTextarea();
|
|
3256
|
+
this.emit("connect", { edge: deepClone(edge) });
|
|
3257
|
+
this.emitChange("edge:add", { edge: deepClone(edge) });
|
|
3258
|
+
return deepClone(edge);
|
|
3259
|
+
}
|
|
3260
|
+
removeEdge(edgeId) {
|
|
3261
|
+
const edgeIndex = this.documentData.edges.findIndex((edge) => edge.id === edgeId);
|
|
3262
|
+
if (edgeIndex === -1) return false;
|
|
3263
|
+
this.documentData.edges.splice(edgeIndex, 1);
|
|
3264
|
+
if (this.selection?.kind === "edge" && this.selection.id === edgeId) {
|
|
3265
|
+
this.selection = null;
|
|
3266
|
+
}
|
|
3267
|
+
this.render();
|
|
3268
|
+
this.emitChange("edge:remove", { edgeId });
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
deleteSelection() {
|
|
3272
|
+
if (!this.selection || this.readonly) return false;
|
|
3273
|
+
if (this.selection.kind === "node") return this.removeNode(this.selection.id);
|
|
3274
|
+
return this.removeEdge(this.selection.id);
|
|
3275
|
+
}
|
|
3276
|
+
setViewport(viewport) {
|
|
3277
|
+
this.documentData.viewport = normalizeViewport(viewport);
|
|
3278
|
+
this.render({ inspector: false, json: true });
|
|
3279
|
+
this.emitViewportChange("viewport:set");
|
|
3280
|
+
return this;
|
|
3281
|
+
}
|
|
3282
|
+
zoomIn() {
|
|
3283
|
+
const width = this.canvasEl.clientWidth || 800;
|
|
3284
|
+
const height = this.canvasEl.clientHeight || 560;
|
|
3285
|
+
this.scaleAround(1.12, width / 2, height / 2, "viewport:zoom");
|
|
3286
|
+
return this;
|
|
3287
|
+
}
|
|
3288
|
+
zoomOut() {
|
|
3289
|
+
const width = this.canvasEl.clientWidth || 800;
|
|
3290
|
+
const height = this.canvasEl.clientHeight || 560;
|
|
3291
|
+
this.scaleAround(1 / 1.12, width / 2, height / 2, "viewport:zoom");
|
|
3292
|
+
return this;
|
|
3293
|
+
}
|
|
3294
|
+
resetView() {
|
|
3295
|
+
this.documentData.viewport = normalizeViewport({ x: 0, y: 0, scale: 1 });
|
|
3296
|
+
this.render({ inspector: false, json: true });
|
|
3297
|
+
this.emitViewportChange("viewport:reset");
|
|
3298
|
+
return this;
|
|
3299
|
+
}
|
|
3300
|
+
fitView() {
|
|
3301
|
+
if (!this.documentData.nodes.length) {
|
|
3302
|
+
return this.resetView();
|
|
3303
|
+
}
|
|
3304
|
+
const bounds = getBounds(this.documentData.nodes);
|
|
3305
|
+
const width = this.canvasEl.clientWidth || 800;
|
|
3306
|
+
const height = this.canvasEl.clientHeight || 560;
|
|
3307
|
+
const padding = 80;
|
|
3308
|
+
const contentWidth = Math.max(1, bounds.right - bounds.left);
|
|
3309
|
+
const contentHeight = Math.max(1, bounds.bottom - bounds.top);
|
|
3310
|
+
const scale = clamp(
|
|
3311
|
+
Math.min((width - padding * 2) / contentWidth, (height - padding * 2) / contentHeight),
|
|
3312
|
+
MIN_SCALE,
|
|
3313
|
+
MAX_SCALE
|
|
3314
|
+
);
|
|
3315
|
+
this.documentData.viewport = {
|
|
3316
|
+
x: formatNumber(width / 2 - (bounds.left + bounds.right) / 2 * scale),
|
|
3317
|
+
y: formatNumber(height / 2 - (bounds.top + bounds.bottom) / 2 * scale),
|
|
3318
|
+
scale: formatNumber(scale)
|
|
3319
|
+
};
|
|
3320
|
+
this.render({ inspector: false, json: true });
|
|
3321
|
+
this.emitViewportChange("viewport:fit");
|
|
3322
|
+
return this;
|
|
3323
|
+
}
|
|
3324
|
+
clear() {
|
|
3325
|
+
this.stopTextEdit({ commit: false });
|
|
3326
|
+
this.activeTool = null;
|
|
3327
|
+
this.reconnectEdgeId = null;
|
|
3328
|
+
this.clipboard = null;
|
|
3329
|
+
this.documentData = normalizeDocument({
|
|
3330
|
+
nodes: [],
|
|
3331
|
+
edges: [],
|
|
3332
|
+
viewport: { x: 0, y: 0, scale: 1 }
|
|
3333
|
+
});
|
|
3334
|
+
this.selection = null;
|
|
3335
|
+
this.render();
|
|
3336
|
+
this.emitChange("clear");
|
|
3337
|
+
return this;
|
|
3338
|
+
}
|
|
3339
|
+
load(data, options = {}) {
|
|
3340
|
+
this.stopTextEdit({ commit: false });
|
|
3341
|
+
this.activeTool = null;
|
|
3342
|
+
this.reconnectEdgeId = null;
|
|
3343
|
+
const previousSelection = this.selection;
|
|
3344
|
+
this.documentData = normalizeDocument(data);
|
|
3345
|
+
this.selection = this.resolvePreservedSelection(previousSelection, options.preserveSelection);
|
|
3346
|
+
this.render();
|
|
3347
|
+
this.emitChange("load");
|
|
3348
|
+
return this;
|
|
3349
|
+
}
|
|
3350
|
+
toJSON() {
|
|
3351
|
+
return deepClone({
|
|
3352
|
+
version: VD_FLOWCHART_VERSION,
|
|
3353
|
+
viewport: this.documentData.viewport,
|
|
3354
|
+
nodes: this.documentData.nodes,
|
|
3355
|
+
edges: this.documentData.edges
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
destroy() {
|
|
3359
|
+
if (this.destroyed) return;
|
|
3360
|
+
this.destroyed = true;
|
|
3361
|
+
this.activeTool = null;
|
|
3362
|
+
this.stopTextEdit({ commit: false });
|
|
3363
|
+
this.resizeObserver?.disconnect();
|
|
3364
|
+
this.resizeObserver = null;
|
|
3365
|
+
this.unbindEvents();
|
|
3366
|
+
this.element.innerHTML = "";
|
|
3367
|
+
this.element.classList.remove("vd-flowchart-host");
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
|
|
3371
|
+
// src/flowchart/vue.js
|
|
3372
|
+
var FORWARDED_EVENTS = ["change", "select", "viewport", "connect", "ready"];
|
|
3373
|
+
var VdFlowchart2 = (0, import_vue.defineComponent)({
|
|
3374
|
+
name: "VdFlowchart",
|
|
3375
|
+
props: {
|
|
3376
|
+
/** Flowchart document — `{ nodes, edges }`. */
|
|
3377
|
+
data: { type: Object, default: () => ({}) },
|
|
3378
|
+
/** Render as a non-editable viewer. */
|
|
3379
|
+
readonly: { type: Boolean, default: false },
|
|
3380
|
+
/** Background grid size in px. */
|
|
3381
|
+
gridSize: { type: Number, default: void 0 },
|
|
3382
|
+
/** Keep the current selection across `data`-driven reloads when possible. */
|
|
3383
|
+
preserveSelection: { type: Boolean, default: false },
|
|
3384
|
+
/** Fit the view to content once the editor reports a measurable size. */
|
|
3385
|
+
autoFit: { type: Boolean, default: false },
|
|
3386
|
+
/** Enable the built-in undo/redo history (default true). */
|
|
3387
|
+
history: { type: Boolean, default: true },
|
|
3388
|
+
/** Maximum number of history entries to retain. */
|
|
3389
|
+
historyLimit: { type: Number, default: void 0 }
|
|
3390
|
+
},
|
|
3391
|
+
emits: ["change", "select", "viewport", "connect", "ready"],
|
|
3392
|
+
setup(props, { emit, expose }) {
|
|
3393
|
+
const el = (0, import_vue.ref)(null);
|
|
3394
|
+
let instance = null;
|
|
3395
|
+
const create = () => {
|
|
3396
|
+
instance = new VdFlowchart({
|
|
3397
|
+
element: el.value,
|
|
3398
|
+
data: props.data,
|
|
3399
|
+
readonly: props.readonly,
|
|
3400
|
+
gridSize: props.gridSize,
|
|
3401
|
+
autoFit: props.autoFit,
|
|
3402
|
+
history: props.history,
|
|
3403
|
+
historyLimit: props.historyLimit
|
|
3404
|
+
});
|
|
3405
|
+
FORWARDED_EVENTS.forEach((name) => {
|
|
3406
|
+
instance.on(name, (payload) => emit(name, payload));
|
|
3407
|
+
});
|
|
3408
|
+
};
|
|
3409
|
+
(0, import_vue.onMounted)(() => {
|
|
3410
|
+
if (typeof window === "undefined" || !el.value) return;
|
|
3411
|
+
create();
|
|
3412
|
+
});
|
|
3413
|
+
(0, import_vue.watch)(
|
|
3414
|
+
() => props.data,
|
|
3415
|
+
(next) => {
|
|
3416
|
+
if (instance && typeof instance.load === "function") {
|
|
3417
|
+
instance.load(next, { preserveSelection: props.preserveSelection });
|
|
3418
|
+
}
|
|
3419
|
+
},
|
|
3420
|
+
{ deep: true }
|
|
3421
|
+
);
|
|
3422
|
+
(0, import_vue.watch)(
|
|
3423
|
+
() => [props.readonly, props.gridSize, props.autoFit, props.history, props.historyLimit],
|
|
3424
|
+
() => {
|
|
3425
|
+
if (!instance) return;
|
|
3426
|
+
instance.destroy();
|
|
3427
|
+
create();
|
|
3428
|
+
}
|
|
3429
|
+
);
|
|
3430
|
+
(0, import_vue.onBeforeUnmount)(() => {
|
|
3431
|
+
if (instance) {
|
|
3432
|
+
instance.destroy();
|
|
3433
|
+
instance = null;
|
|
3434
|
+
}
|
|
3435
|
+
});
|
|
3436
|
+
expose({
|
|
3437
|
+
getInstance: () => instance,
|
|
3438
|
+
undo: () => instance?.undo(),
|
|
3439
|
+
redo: () => instance?.redo(),
|
|
3440
|
+
canUndo: () => Boolean(instance?.canUndo()),
|
|
3441
|
+
canRedo: () => Boolean(instance?.canRedo()),
|
|
3442
|
+
layout: (mode, options) => instance?.layout(mode, options)
|
|
3443
|
+
});
|
|
3444
|
+
return () => (0, import_vue.h)("div", { ref: el, class: "vd-flowchart" });
|
|
3445
|
+
}
|
|
3446
|
+
});
|
|
3447
|
+
//# sourceMappingURL=index.cjs.map
|