living-documentation 7.43.0 → 7.45.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.
@@ -2,38 +2,62 @@
2
2
  // Creates the vis.js Network, patches _drawNodes for canonical z-order,
3
3
  // and wires all network-level events.
4
4
 
5
- import { st, markDirty } from './state.js';
6
- import { pushSnapshot } from './history.js';
7
- import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
8
- import { uploadImageFile } from './image-upload.js';
9
- import { promptImageName } from './image-name-modal.js';
10
- import { showToast } from './toast.js';
11
- import { t } from './t.js';
12
- import { visEdgeProps } from './edge-rendering.js';
13
- import { showNodePanel, hideNodePanel } from './node-panel.js';
14
- import { showEdgePanel, hideEdgePanel } from './edge-panel.js';
15
- import { startLabelEdit, startEdgeLabelEdit, commitLabelEdit, hideLabelInput } from './label-editor.js';
16
- import { updateSelectionOverlay, hideSelectionOverlay } from './selection-overlay.js';
17
- import { drawGrid, onDragEnd, snapToGrid } from './grid.js';
18
- import { onDragging, drawAlignmentGuides, clearAlignGuides } from './alignment.js';
19
- import { drawDebugOverlay } from './debug.js';
20
- import { updateZoomDisplay } from './zoom.js';
21
- import { expandSelectionToGroup, drawGroupOutlines } from './groups.js';
22
- import { navigateNodeLink, hideLinkPanel } from './link-panel.js';
23
- import { getNearestPort, getPortPosition, drawPortDots, drawPortEdge, distanceToPortEdge, wrapText } from './ports.js';
24
- import { getLastFreeArrowStyle } from './edge-panel.js';
25
- import { getLastNodeStyle } from './node-panel.js';
26
- import { installUnlockHold } from './unlock-hold.js';
27
- import { CUSTOM_SHAPE_TYPE, customShapeIdFromTool, getCustomShapeDefaultSize, getCustomShapeDefinition } from './custom-shapes.js';
5
+ import { st, markDirty } from "./state.js";
6
+ import { pushSnapshot } from "./history.js";
7
+ import { visNodeProps, SHAPE_DEFAULTS } from "./node-rendering.js";
8
+ import { uploadImageFile } from "./image-upload.js";
9
+ import { promptImageName } from "./image-name-modal.js";
10
+ import { showToast } from "./toast.js";
11
+ import { t } from "./t.js";
12
+ import { visEdgeProps } from "./edge-rendering.js";
13
+ import { showNodePanel, hideNodePanel } from "./node-panel.js";
14
+ import { showEdgePanel, hideEdgePanel } from "./edge-panel.js";
15
+ import {
16
+ startLabelEdit,
17
+ startEdgeLabelEdit,
18
+ commitLabelEdit,
19
+ hideLabelInput,
20
+ } from "./label-editor.js";
21
+ import {
22
+ updateSelectionOverlay,
23
+ hideSelectionOverlay,
24
+ } from "./selection-overlay.js";
25
+ import { drawGrid, onDragEnd, snapToGrid } from "./grid.js";
26
+ import {
27
+ onDragging,
28
+ drawAlignmentGuides,
29
+ clearAlignGuides,
30
+ } from "./alignment.js";
31
+ import { drawDebugOverlay } from "./debug.js";
32
+ import { updateZoomDisplay } from "./zoom.js";
33
+ import { expandSelectionToGroup, drawGroupOutlines } from "./groups.js";
34
+ import { navigateNodeLink, hideLinkPanel } from "./link-panel.js";
35
+ import {
36
+ getNearestPort,
37
+ getPortPosition,
38
+ drawPortDots,
39
+ drawPortEdge,
40
+ distanceToPortEdge,
41
+ wrapText,
42
+ } from "./ports.js";
43
+ import { getLastFreeArrowStyle } from "./edge-panel.js";
44
+ import { getLastNodeStyle } from "./node-panel.js";
45
+ import { installUnlockHold } from "./unlock-hold.js";
46
+ import {
47
+ CUSTOM_SHAPE_TYPE,
48
+ customShapeIdFromTool,
49
+ getCustomShapeDefaultSize,
50
+ getCustomShapeDefinition,
51
+ } from "./custom-shapes.js";
28
52
 
29
53
  // Module-level port-hover state — shared between initNetwork event handlers and
30
54
  // module-level helpers (_onAnchorSnapConnect).
31
55
  let _hoveredPortNodeId = null;
32
- let _hoveredPortKey = null;
56
+ let _hoveredPortKey = null;
33
57
  let _draggingAnchorIds = new Set();
34
58
  // Rehook state: tracks which port is hovered while a port edge is selected.
35
- let _rehookEdgeId = null;
36
- let _rehookHoveredNodeId = null;
59
+ let _rehookEdgeId = null;
60
+ let _rehookHoveredNodeId = null;
37
61
  let _rehookHoveredPortKey = null;
38
62
  let _pointerDownSelection = { nodeIds: [], edgeIds: [] };
39
63
  let _edgeLabelPointerAbort = null;
@@ -43,62 +67,116 @@ let _edgeLabelPointerAbort = null;
43
67
  function isRehookable(edgeData) {
44
68
  if (!edgeData) return false;
45
69
  const fromNode = st.nodes.get(edgeData.from);
46
- const toNode = st.nodes.get(edgeData.to);
47
- return !!(fromNode && fromNode.shapeType !== 'anchor') || !!(toNode && toNode.shapeType !== 'anchor');
70
+ const toNode = st.nodes.get(edgeData.to);
71
+ return (
72
+ !!(fromNode && fromNode.shapeType !== "anchor") ||
73
+ !!(toNode && toNode.shapeType !== "anchor")
74
+ );
48
75
  }
49
76
 
50
77
  export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
51
- const container = document.getElementById('vis-canvas');
78
+ const container = document.getElementById("vis-canvas");
52
79
 
53
- const edgeSmooth = edgesStraight ? { enabled: false } : { type: 'continuous' };
80
+ const edgeSmooth = edgesStraight
81
+ ? { enabled: false }
82
+ : { type: "continuous" };
54
83
 
55
84
  st.nodes = new vis.DataSet(
56
85
  savedNodes.map((n) => {
57
- const shapeType = n.shapeType || n.renderAs || 'box';
86
+ const shapeType = n.shapeType || n.renderAs || "box";
58
87
  return {
59
88
  ...n,
60
89
  shapeType,
61
- ...visNodeProps(shapeType, n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign),
90
+ ...visNodeProps(
91
+ shapeType,
92
+ n.colorKey || "c-gray",
93
+ n.nodeWidth,
94
+ n.nodeHeight,
95
+ n.fontSize,
96
+ n.textAlign,
97
+ n.textValign,
98
+ ),
62
99
  ...(n.locked ? { fixed: { x: true, y: true }, draggable: false } : {}),
63
100
  };
64
- })
101
+ }),
65
102
  );
66
103
  st.edges = new vis.DataSet(
67
104
  savedEdges.map((e) => {
68
105
  const toNode = savedNodes.find((n) => n.id === e.to);
69
- const isAnchor = toNode && toNode.shapeType === 'anchor';
106
+ const isAnchor = toNode && toNode.shapeType === "anchor";
70
107
  const edgeObj = {
71
108
  ...e,
72
- ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
109
+ ...visEdgeProps(e.arrowDir ?? "to", e.dashes ?? false),
73
110
  smooth: isAnchor ? { enabled: false } : edgeSmooth,
74
- ...(e.edgeColor ? { color: { color: e.edgeColor, highlight: '#f97316', hover: '#f97316' } } : {}),
111
+ ...(e.edgeColor
112
+ ? {
113
+ color: {
114
+ color: e.edgeColor,
115
+ highlight: "#f97316",
116
+ hover: "#f97316",
117
+ },
118
+ }
119
+ : {}),
75
120
  ...(e.edgeWidth ? { width: e.edgeWidth } : {}),
76
121
  // Edge labels are always drawn by drawEdgeLabels() in afterDrawing
77
122
  // (gives us positioning + rotation control). Hide vis-network's native
78
123
  // label text entirely so it never appears alongside ours.
79
124
  ...(e.label
80
- ? { font: { size: e.fontSize || 11, align: 'middle', color: 'rgba(0,0,0,0)' } }
125
+ ? {
126
+ font: {
127
+ size: e.fontSize || 11,
128
+ align: "middle",
129
+ color: "rgba(0,0,0,0)",
130
+ },
131
+ }
81
132
  : e.fontSize
82
- ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } }
83
- : {}
84
- ),
133
+ ? { font: { size: e.fontSize, align: "middle", color: "#6b7280" } }
134
+ : {}),
85
135
  };
86
136
  // Port edges: hide vis-network's own rendering (line + arrowhead).
87
137
  // drawPortEdge() handles all visual output; vis-network edge is a
88
138
  // transparent ghost kept only for hit-detection (click selection).
89
139
  if (e.fromPort || e.toPort) {
90
- edgeObj.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
140
+ edgeObj.color = {
141
+ color: "rgba(0,0,0,0)",
142
+ highlight: "rgba(0,0,0,0)",
143
+ hover: "rgba(0,0,0,0)",
144
+ };
91
145
  edgeObj.arrows = { to: { enabled: false }, from: { enabled: false } };
92
146
  }
93
147
  return edgeObj;
94
- })
148
+ }),
95
149
  );
96
150
 
97
151
  const options = {
98
152
  physics: { enabled: false },
99
- interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
100
- nodes: { font: { size: 13, face: 'system-ui,-apple-system,sans-serif' }, borderWidth: 1.5, borderWidthSelected: 2.5, shadow: false, widthConstraint: { minimum: 60 }, heightConstraint: { minimum: 28 } },
101
- edges: { smooth: edgeSmooth, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: 'rgba(0,0,0,0)', strokeColor: 'rgba(0,0,0,0)', background: 'rgba(0,0,0,0)' } },
153
+ interaction: {
154
+ hover: true,
155
+ navigationButtons: false,
156
+ keyboard: false,
157
+ multiselect: true,
158
+ },
159
+ nodes: {
160
+ font: { size: 13, face: "system-ui,-apple-system,sans-serif" },
161
+ borderWidth: 1.5,
162
+ borderWidthSelected: 2.5,
163
+ shadow: false,
164
+ widthConstraint: { minimum: 60 },
165
+ heightConstraint: { minimum: 28 },
166
+ },
167
+ edges: {
168
+ smooth: edgeSmooth,
169
+ color: { color: "#a8a29e", highlight: "#f97316", hover: "#f97316" },
170
+ width: 1.5,
171
+ selectionWidth: 2.5,
172
+ font: {
173
+ size: 11,
174
+ align: "middle",
175
+ color: "rgba(0,0,0,0)",
176
+ strokeColor: "rgba(0,0,0,0)",
177
+ background: "rgba(0,0,0,0)",
178
+ },
179
+ },
102
180
  manipulation: {
103
181
  enabled: false,
104
182
  addEdge(data, callback) {
@@ -107,34 +185,53 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
107
185
  // contains it: that target is acting as a background surface, and the
108
186
  // mouseup handler turns the gesture into a free-arrow endpoint instead.
109
187
  const fromNode = st.nodes.get(data.from) || {};
110
- const toNode = st.nodes.get(data.to) || {};
111
- const targetContainsSource = lowerZTargetContainsSource(data.from, data.to);
112
- if (fromNode.locked || toNode.locked || fromNode.shapeType === 'anchor' || toNode.shapeType === 'anchor' || targetContainsSource) {
188
+ const toNode = st.nodes.get(data.to) || {};
189
+ const targetContainsSource = lowerZTargetContainsSource(
190
+ data.from,
191
+ data.to,
192
+ );
193
+ if (
194
+ fromNode.locked ||
195
+ toNode.locked ||
196
+ fromNode.shapeType === "anchor" ||
197
+ toNode.shapeType === "anchor" ||
198
+ targetContainsSource
199
+ ) {
113
200
  _addEdgeFromPort = null;
114
- setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
201
+ setTimeout(() => {
202
+ if (st.currentTool === "addEdge") st.network.addEdgeMode();
203
+ }, 0);
115
204
  return;
116
205
  }
117
206
  pushSnapshot();
118
- data.id = 'e' + Date.now();
119
- data.arrowDir = 'to';
120
- data.dashes = false;
207
+ data.id = "e" + Date.now();
208
+ data.arrowDir = "to";
209
+ data.dashes = false;
121
210
  // Attach captured port selections — skip for anchor nodes.
122
- const fromIsAnchor = fromNode.shapeType === 'anchor';
123
- const toIsAnchor = toNode.shapeType === 'anchor';
211
+ const fromIsAnchor = fromNode.shapeType === "anchor";
212
+ const toIsAnchor = toNode.shapeType === "anchor";
124
213
  if (!fromIsAnchor) data.fromPort = _addEdgeFromPort || null;
125
- if (!toIsAnchor) data.toPort = _hoveredPortKey || null;
126
- Object.assign(data, visEdgeProps('to', false));
214
+ if (!toIsAnchor) data.toPort = _hoveredPortKey || null;
215
+ Object.assign(data, visEdgeProps("to", false));
127
216
  // Port edges: make vis-network's ghost transparent so only drawPortEdge is visible.
128
217
  if (data.fromPort || data.toPort) {
129
- data.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
218
+ data.color = {
219
+ color: "rgba(0,0,0,0)",
220
+ highlight: "rgba(0,0,0,0)",
221
+ hover: "rgba(0,0,0,0)",
222
+ };
130
223
  data.arrows = { to: { enabled: false }, from: { enabled: false } };
131
224
  }
132
225
  callback(data);
133
226
  markDirty();
134
227
  _addEdgeFromPort = null;
135
228
  setTimeout(() => {
136
- if (st.currentTool === 'addEdge') {
137
- window.dispatchEvent(new CustomEvent('diagram:setTool', { detail: { tool: 'select' } }));
229
+ if (st.currentTool === "addEdge") {
230
+ window.dispatchEvent(
231
+ new CustomEvent("diagram:setTool", {
232
+ detail: { tool: "select" },
233
+ }),
234
+ );
138
235
  }
139
236
  }, 0);
140
237
  },
@@ -142,19 +239,27 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
142
239
  };
143
240
 
144
241
  if (st.network) st.network.destroy();
145
- st.network = new vis.Network(container, { nodes: st.nodes, edges: st.edges }, options);
242
+ st.network = new vis.Network(
243
+ container,
244
+ { nodes: st.nodes, edges: st.edges },
245
+ options,
246
+ );
146
247
 
147
248
  // ── Lock interception ───────────────────────────────────────────────────────
148
249
  // Must be registered BEFORE any other capture-phase mousedown listeners on
149
250
  // the container so it can stopImmediatePropagation for locked targets.
150
251
  installUnlockHold(container);
151
- container.addEventListener('mousedown', (e) => {
152
- if (e.button !== 0) return;
153
- _pointerDownSelection = {
154
- nodeIds: [...(st.selectedNodeIds || [])],
155
- edgeIds: [...(st.selectedEdgeIds || [])],
156
- };
157
- }, { capture: true });
252
+ container.addEventListener(
253
+ "mousedown",
254
+ (e) => {
255
+ if (e.button !== 0) return;
256
+ _pointerDownSelection = {
257
+ nodeIds: [...(st.selectedNodeIds || [])],
258
+ edgeIds: [...(st.selectedEdgeIds || [])],
259
+ };
260
+ },
261
+ { capture: true },
262
+ );
158
263
 
159
264
  // ── Z-order patch ──────────────────────────────────────────────────────────
160
265
  // vis.js renders in 3 passes (normal → selected → hovered), which breaks
@@ -164,10 +269,18 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
164
269
  st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
165
270
  const bodyNodes = this.body.nodes;
166
271
  const bodyEdges = this.body.edges;
167
- const margin = 20;
168
- const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
169
- const bottomRight = this.canvas.DOMtoCanvas({ x: this.canvas.frame.canvas.clientWidth + margin, y: this.canvas.frame.canvas.clientHeight + margin });
170
- const viewableArea = { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x };
272
+ const margin = 20;
273
+ const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
274
+ const bottomRight = this.canvas.DOMtoCanvas({
275
+ x: this.canvas.frame.canvas.clientWidth + margin,
276
+ y: this.canvas.frame.canvas.clientHeight + margin,
277
+ });
278
+ const viewableArea = {
279
+ top: topLeft.y,
280
+ left: topLeft.x,
281
+ bottom: bottomRight.y,
282
+ right: bottomRight.x,
283
+ };
171
284
 
172
285
  // Build a map: canonical index → list of edges whose topmost endpoint is at that index.
173
286
  const orderMap = new Map();
@@ -189,11 +302,11 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
189
302
  // centre origin — otherwise the ghost line is visible from the node's
190
303
  // centre instead of appearing to start at its boundary/port.
191
304
  const fromData = st.nodes.get(edge.fromId);
192
- const toData = st.nodes.get(edge.toId);
193
- const fromIsAnchor = fromData && fromData.shapeType === 'anchor';
194
- const toIsAnchor = toData && toData.shapeType === 'anchor';
305
+ const toData = st.nodes.get(edge.toId);
306
+ const fromIsAnchor = fromData && fromData.shapeType === "anchor";
307
+ const toIsAnchor = toData && toData.shapeType === "anchor";
195
308
  const fromLevel = orderMap.get(edge.fromId);
196
- const toLevel = orderMap.get(edge.toId);
309
+ const toLevel = orderMap.get(edge.toId);
197
310
  let level;
198
311
  if (fromLevel === undefined && toLevel === undefined) {
199
312
  level = 0;
@@ -218,22 +331,24 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
218
331
  for (const [level, edges] of edgesByLevel) {
219
332
  for (const edge of edges) {
220
333
  const fromData = st.nodes.get(edge.fromId);
221
- const toData = st.nodes.get(edge.toId);
222
- if (toData && toData.shapeType === 'anchor') anchorEdgeLevel.set(edge.toId, level);
223
- if (fromData && fromData.shapeType === 'anchor') anchorEdgeLevel.set(edge.fromId, level);
334
+ const toData = st.nodes.get(edge.toId);
335
+ if (toData && toData.shapeType === "anchor")
336
+ anchorEdgeLevel.set(edge.toId, level);
337
+ if (fromData && fromData.shapeType === "anchor")
338
+ anchorEdgeLevel.set(edge.fromId, level);
224
339
  }
225
340
  }
226
341
 
227
342
  // Anchors with no connected edge are drawn at level 0 (bottom).
228
343
  for (const id of st.canonicalOrder) {
229
344
  const n = st.nodes.get(id);
230
- if (!n || n.shapeType !== 'anchor') continue;
345
+ if (!n || n.shapeType !== "anchor") continue;
231
346
  if (!anchorEdgeLevel.has(id)) anchorEdgeLevel.set(id, 0);
232
347
  }
233
348
 
234
349
  for (let i = 0; i < st.canonicalOrder.length; i++) {
235
350
  const id = st.canonicalOrder[i];
236
- const n = st.nodes.get(id);
351
+ const n = st.nodes.get(id);
237
352
 
238
353
  // Draw edges whose level is i, before drawing the node at level i.
239
354
  const edges = edgesByLevel.get(i);
@@ -254,7 +369,10 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
254
369
  if (level !== i) continue;
255
370
  const anchorNode = bodyNodes[anchorId];
256
371
  if (!anchorNode) continue;
257
- if (alwaysShow === true || anchorNode.isBoundingBoxOverlappingWith(viewableArea) === true) {
372
+ if (
373
+ alwaysShow === true ||
374
+ anchorNode.isBoundingBoxOverlappingWith(viewableArea) === true
375
+ ) {
258
376
  anchorNode.draw(ctx);
259
377
  } else {
260
378
  anchorNode.updateBoundingBox(ctx, anchorNode.selected);
@@ -263,7 +381,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
263
381
  }
264
382
 
265
383
  // Skip anchor nodes here — they were drawn right after their edge above.
266
- if (n && n.shapeType === 'anchor') continue;
384
+ if (n && n.shapeType === "anchor") continue;
267
385
 
268
386
  const node = bodyNodes[id];
269
387
  if (!node) continue;
@@ -280,84 +398,117 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
280
398
  // We neutralise _drawEdges (make it a no-op) and instead draw each edge
281
399
  // inside _drawNodes, just before the node whose canonical index equals the
282
400
  // max index of its two endpoints. This guarantees true z-order interleaving.
283
- st.network.renderer._drawEdges = function () { /* no-op — edges drawn in _drawNodes */ };
401
+ st.network.renderer._drawEdges = function () {
402
+ /* no-op — edges drawn in _drawNodes */
403
+ };
284
404
 
285
405
  // Keep canonicalOrder in sync with DataSet add/remove events
286
- st.nodes.on('add', (_, { items }) => {
406
+ st.nodes.on("add", (_, { items }) => {
287
407
  const existing = new Set(st.canonicalOrder);
288
- items.forEach((id) => { if (!existing.has(id)) st.canonicalOrder.push(id); });
408
+ items.forEach((id) => {
409
+ if (!existing.has(id)) st.canonicalOrder.push(id);
410
+ });
289
411
  });
290
- st.nodes.on('remove', (_, { items, oldData }) => {
412
+ st.nodes.on("remove", (_, { items, oldData }) => {
291
413
  const removed = new Set(items);
292
414
  st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
293
415
  // If an anchor was deleted directly, also remove its connected edges.
294
416
  (oldData || []).forEach((n) => {
295
- if (n.shapeType !== 'anchor') return;
296
- const connected = st.edges.get({ filter: (e) => e.from === n.id || e.to === n.id });
417
+ if (n.shapeType !== "anchor") return;
418
+ const connected = st.edges.get({
419
+ filter: (e) => e.from === n.id || e.to === n.id,
420
+ });
297
421
  if (connected.length) st.edges.remove(connected.map((e) => e.id));
298
422
  });
299
423
  });
300
424
 
301
425
  // When an edge is removed, delete any anchor node that has no remaining edges.
302
- st.edges.on('remove', (_, { oldData }) => {
426
+ st.edges.on("remove", (_, { oldData }) => {
303
427
  const anchorsToCheck = new Set();
304
428
  (oldData || []).forEach((edge) => {
305
- const toData = st.nodes.get(edge.to);
429
+ const toData = st.nodes.get(edge.to);
306
430
  const fromData = st.nodes.get(edge.from);
307
- if (toData && toData.shapeType === 'anchor') anchorsToCheck.add(edge.to);
308
- if (fromData && fromData.shapeType === 'anchor') anchorsToCheck.add(edge.from);
431
+ if (toData && toData.shapeType === "anchor") anchorsToCheck.add(edge.to);
432
+ if (fromData && fromData.shapeType === "anchor")
433
+ anchorsToCheck.add(edge.from);
309
434
  });
310
435
  anchorsToCheck.forEach((anchorId) => {
311
- const remaining = st.edges.get({ filter: (e) => e.from === anchorId || e.to === anchorId });
436
+ const remaining = st.edges.get({
437
+ filter: (e) => e.from === anchorId || e.to === anchorId,
438
+ });
312
439
  if (remaining.length === 0) st.nodes.remove(anchorId);
313
440
  });
314
441
  });
315
442
 
316
- st.network.on('click', onClickNode);
317
- st.network.on('doubleClick', onDoubleClick);
318
- st.network.on('dragStart', onDragStart);
319
- st.network.on('selectNode', onSelectNode);
320
- st.network.on('deselectNode', onDeselectAll);
321
- st.network.on('selectEdge', onSelectEdge);
322
- st.network.on('deselectEdge', onDeselectAll);
323
- st.network.on('zoom', updateZoomDisplay);
324
- st.network.on('dragging', onDragging);
325
- st.network.on('dragEnd', (p) => { _draggingAnchorIds.clear(); _hoveredPortNodeId = null; _hoveredPortKey = null; _onAnchorSnapConnect(p); onDragEnd(p); clearAlignGuides(); });
326
- st.network.on('beforeDrawing', drawGrid);
327
- st.network.on('afterDrawing', updateSelectionOverlay);
328
- st.network.on('afterDrawing', drawAlignmentGuides);
329
- st.network.on('afterDrawing', (ctx) => drawGroupOutlines(ctx));
330
- st.network.on('afterDrawing', () => drawDebugOverlay());
331
- st.network.on('afterDrawing', drawEdgeLabels);
332
- st.network.on('afterDrawing', (ctx) => {
333
- if (_hoveredPortNodeId && (st.currentTool === 'addEdge' || _draggingAnchorIds.size > 0)) {
443
+ st.network.on("click", onClickNode);
444
+ st.network.on("doubleClick", onDoubleClick);
445
+ st.network.on("dragStart", onDragStart);
446
+ st.network.on("selectNode", onSelectNode);
447
+ st.network.on("deselectNode", onDeselectAll);
448
+ st.network.on("selectEdge", onSelectEdge);
449
+ st.network.on("deselectEdge", onDeselectAll);
450
+ st.network.on("zoom", updateZoomDisplay);
451
+ st.network.on("dragging", onDragging);
452
+ st.network.on("dragEnd", (p) => {
453
+ _draggingAnchorIds.clear();
454
+ _hoveredPortNodeId = null;
455
+ _hoveredPortKey = null;
456
+ _onAnchorSnapConnect(p);
457
+ onDragEnd(p);
458
+ clearAlignGuides();
459
+ });
460
+ st.network.on("beforeDrawing", drawGrid);
461
+ st.network.on("afterDrawing", updateSelectionOverlay);
462
+ st.network.on("afterDrawing", drawAlignmentGuides);
463
+ st.network.on("afterDrawing", (ctx) => drawGroupOutlines(ctx));
464
+ st.network.on("afterDrawing", () => drawDebugOverlay());
465
+ st.network.on("afterDrawing", drawEdgeLabels);
466
+ st.network.on("afterDrawing", (ctx) => {
467
+ if (
468
+ _hoveredPortNodeId &&
469
+ (st.currentTool === "addEdge" || _draggingAnchorIds.size > 0)
470
+ ) {
334
471
  drawPortDots(ctx, _hoveredPortNodeId, _hoveredPortKey);
335
472
  }
336
473
  // Rehook: show port dots on both endpoints of the selected port edge so
337
474
  // the user can click a different port to reconnect that end of the arrow.
338
- if (_rehookEdgeId && st.currentTool !== 'addEdge' && _draggingAnchorIds.size === 0) {
475
+ if (
476
+ _rehookEdgeId &&
477
+ st.currentTool !== "addEdge" &&
478
+ _draggingAnchorIds.size === 0
479
+ ) {
339
480
  const edgeData = st.edges.get(_rehookEdgeId);
340
481
  if (isRehookable(edgeData)) {
341
482
  const fromNode = st.nodes.get(edgeData.from);
342
- if (fromNode && fromNode.shapeType !== 'anchor') {
343
- drawPortDots(ctx, edgeData.from, _rehookHoveredNodeId === edgeData.from ? _rehookHoveredPortKey : null);
483
+ if (fromNode && fromNode.shapeType !== "anchor") {
484
+ drawPortDots(
485
+ ctx,
486
+ edgeData.from,
487
+ _rehookHoveredNodeId === edgeData.from
488
+ ? _rehookHoveredPortKey
489
+ : null,
490
+ );
344
491
  }
345
492
  const toNode = st.nodes.get(edgeData.to);
346
- if (toNode && toNode.shapeType !== 'anchor') {
347
- drawPortDots(ctx, edgeData.to, _rehookHoveredNodeId === edgeData.to ? _rehookHoveredPortKey : null);
493
+ if (toNode && toNode.shapeType !== "anchor") {
494
+ drawPortDots(
495
+ ctx,
496
+ edgeData.to,
497
+ _rehookHoveredNodeId === edgeData.to ? _rehookHoveredPortKey : null,
498
+ );
348
499
  }
349
500
  }
350
501
  }
351
502
  });
352
503
  // Two-click free-arrow: draw an orange dot at the pending first-click origin.
353
- st.network.on('afterDrawing', (ctx) => {
354
- if (st.currentTool !== 'addEdge' || !st.freeArrowFirstPoint) return;
504
+ st.network.on("afterDrawing", (ctx) => {
505
+ if (st.currentTool !== "addEdge" || !st.freeArrowFirstPoint) return;
355
506
  const { x, y } = st.freeArrowFirstPoint;
356
507
  ctx.beginPath();
357
508
  ctx.arc(x, y, 6, 0, Math.PI * 2);
358
- ctx.fillStyle = '#f97316';
509
+ ctx.fillStyle = "#f97316";
359
510
  ctx.fill();
360
- ctx.strokeStyle = '#ffffff';
511
+ ctx.strokeStyle = "#ffffff";
361
512
  ctx.lineWidth = 2;
362
513
  ctx.stroke();
363
514
  });
@@ -373,13 +524,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
373
524
  // hit-detection — this avoids a race with vis-network's capture-phase handlers
374
525
  // which may run first and alter selection state before our mousedown fires.
375
526
  {
376
- let _lr = null; // active resize: { edgeId, bboxCx, bboxCy, rotation }
377
- let _hoverHandle = null; // { edgeId, bboxCx, bboxCy, rotation } | null
378
- let _ld = null; // active label drag: { edgeId, startMouse, startOffsetX, startOffsetY, dragging }
527
+ let _lr = null; // active resize: { edgeId, bboxCx, bboxCy, rotation }
528
+ let _hoverHandle = null; // { edgeId, bboxCx, bboxCy, rotation } | null
529
+ let _ld = null; // active label drag: { edgeId, startMouse, startOffsetX, startOffsetY, dragging }
379
530
  let _hoverLabelDrag = null; // { edgeId } | null
380
531
 
381
532
  // Draw handles for the selected edge label.
382
- st.network.on('afterDrawing', (ctx) => {
533
+ st.network.on("afterDrawing", (ctx) => {
383
534
  if (!st.selectedEdgeIds || st.selectedEdgeIds.length !== 1) return;
384
535
  const edgeId = st.selectedEdgeIds[0];
385
536
  const e = st.edges.get(edgeId);
@@ -393,9 +544,9 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
393
544
  for (const hx of [-bbox.w / 2, bbox.w / 2]) {
394
545
  ctx.beginPath();
395
546
  ctx.arc(hx, 0, 5, 0, Math.PI * 2);
396
- ctx.fillStyle = '#f97316';
397
- ctx.strokeStyle = '#ffffff';
398
- ctx.lineWidth = 1.5;
547
+ ctx.fillStyle = "#f97316";
548
+ ctx.strokeStyle = "#ffffff";
549
+ ctx.lineWidth = 1.5;
399
550
  ctx.fill();
400
551
  ctx.stroke();
401
552
  }
@@ -405,23 +556,36 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
405
556
  function edgeLabelHitAt(clientX, clientY) {
406
557
  if (!st.network || !st.edgeLabelBBox) return;
407
558
  const rect = container.getBoundingClientRect();
408
- const cp = st.network.DOMtoCanvas({ x: clientX - rect.left, y: clientY - rect.top });
559
+ const cp = st.network.DOMtoCanvas({
560
+ x: clientX - rect.left,
561
+ y: clientY - rect.top,
562
+ });
409
563
  const hr = 8 / st.network.getScale();
410
564
 
411
565
  // ── Resize handle detection ──────────────────────────────────────────────
412
- for (const edgeId of (st.selectedEdgeIds || [])) {
566
+ for (const edgeId of st.selectedEdgeIds || []) {
413
567
  const edge = st.edges && st.edges.get(edgeId);
414
568
  if (!edge || !edge.label) continue;
415
569
  const bbox = st.edgeLabelBBox[edgeId];
416
570
  if (!bbox) continue;
417
571
 
418
- const r = -(bbox.rotation || 0);
419
- const dx = cp.x - bbox.cx, dy = cp.y - bbox.cy;
572
+ const r = -(bbox.rotation || 0);
573
+ const dx = cp.x - bbox.cx,
574
+ dy = cp.y - bbox.cy;
420
575
  const lx = dx * Math.cos(r) - dy * Math.sin(r);
421
576
  const ly = dx * Math.sin(r) + dy * Math.cos(r);
422
577
 
423
- if (Math.hypot(lx - (-bbox.w / 2), ly) < hr || Math.hypot(lx - (bbox.w / 2), ly) < hr) {
424
- return { type: 'handle', edgeId, bboxCx: bbox.cx, bboxCy: bbox.cy, rotation: bbox.rotation || 0 };
578
+ if (
579
+ Math.hypot(lx - -bbox.w / 2, ly) < hr ||
580
+ Math.hypot(lx - bbox.w / 2, ly) < hr
581
+ ) {
582
+ return {
583
+ type: "handle",
584
+ edgeId,
585
+ bboxCx: bbox.cx,
586
+ bboxCy: bbox.cy,
587
+ rotation: bbox.rotation || 0,
588
+ };
425
589
  }
426
590
  }
427
591
 
@@ -432,12 +596,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
432
596
  if (edge && edge.label) {
433
597
  const bbox = st.edgeLabelBBox && st.edgeLabelBBox[edgeId];
434
598
  if (bbox) {
435
- const r = -(bbox.rotation || 0);
436
- const dx = cp.x - bbox.cx, dy = cp.y - bbox.cy;
599
+ const r = -(bbox.rotation || 0);
600
+ const dx = cp.x - bbox.cx,
601
+ dy = cp.y - bbox.cy;
437
602
  const lx = dx * Math.cos(r) - dy * Math.sin(r);
438
603
  const ly = dx * Math.sin(r) + dy * Math.cos(r);
439
604
  if (Math.abs(lx) <= bbox.w / 2 && Math.abs(ly) <= bbox.h / 2) {
440
- return { type: 'label', edgeId };
605
+ return { type: "label", edgeId };
441
606
  }
442
607
  }
443
608
  }
@@ -447,19 +612,30 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
447
612
  }
448
613
 
449
614
  // Track hover during mousemove — computed before any mousedown fires.
450
- container.addEventListener('mousemove', (e) => {
615
+ container.addEventListener("mousemove", (e) => {
451
616
  const hit = edgeLabelHitAt(e.clientX, e.clientY);
452
- const found = hit && hit.type === 'handle'
453
- ? { edgeId: hit.edgeId, bboxCx: hit.bboxCx, bboxCy: hit.bboxCy, rotation: hit.rotation }
454
- : null;
617
+ const found =
618
+ hit && hit.type === "handle"
619
+ ? {
620
+ edgeId: hit.edgeId,
621
+ bboxCx: hit.bboxCx,
622
+ bboxCy: hit.bboxCy,
623
+ rotation: hit.rotation,
624
+ }
625
+ : null;
455
626
  if (Boolean(found) !== Boolean(_hoverHandle)) {
456
- container.style.cursor = found ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
627
+ container.style.cursor = found
628
+ ? "ew-resize"
629
+ : _hoverLabelDrag
630
+ ? "grab"
631
+ : "";
457
632
  }
458
633
  _hoverHandle = found;
459
634
 
460
- const labelFound = !found && hit && hit.type === 'label' ? { edgeId: hit.edgeId } : null;
635
+ const labelFound =
636
+ !found && hit && hit.type === "label" ? { edgeId: hit.edgeId } : null;
461
637
  if (Boolean(labelFound) !== Boolean(_hoverLabelDrag)) {
462
- if (!found) container.style.cursor = labelFound ? 'grab' : '';
638
+ if (!found) container.style.cursor = labelFound ? "grab" : "";
463
639
  }
464
640
  _hoverLabelDrag = labelFound;
465
641
  });
@@ -468,14 +644,19 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
468
644
  if (e.button !== 0) return false;
469
645
  if (!container.contains(e.target)) return false;
470
646
  const hit = edgeLabelHitAt(e.clientX, e.clientY);
471
- if (hit && hit.type === 'handle') {
647
+ if (hit && hit.type === "handle") {
472
648
  e.preventDefault();
473
649
  e.stopImmediatePropagation();
474
- _hoverHandle = { edgeId: hit.edgeId, bboxCx: hit.bboxCx, bboxCy: hit.bboxCy, rotation: hit.rotation };
650
+ _hoverHandle = {
651
+ edgeId: hit.edgeId,
652
+ bboxCx: hit.bboxCx,
653
+ bboxCy: hit.bboxCy,
654
+ rotation: hit.rotation,
655
+ };
475
656
  _lr = { ..._hoverHandle, dragging: false };
476
657
  st.network.setOptions({ interaction: { dragView: false } });
477
658
  return true;
478
- } else if (hit && hit.type === 'label') {
659
+ } else if (hit && hit.type === "label") {
479
660
  const edge = st.edges && st.edges.get(hit.edgeId);
480
661
  if (!edge) return false;
481
662
  e.preventDefault();
@@ -500,69 +681,103 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
500
681
  // Mousedown: start resize (handles take priority) or label drag.
501
682
  // The document-level capture listener runs before vis-network's internal
502
683
  // pointer handlers, so a node underneath the edge label cannot start moving.
503
- document.addEventListener('pointerdown', startEdgeLabelPointerInteraction, {
684
+ document.addEventListener("pointerdown", startEdgeLabelPointerInteraction, {
504
685
  capture: true,
505
686
  signal: _edgeLabelPointerAbort.signal,
506
687
  });
507
- document.addEventListener('mousedown', startEdgeLabelPointerInteraction, {
688
+ document.addEventListener("mousedown", startEdgeLabelPointerInteraction, {
508
689
  capture: true,
509
690
  signal: _edgeLabelPointerAbort.signal,
510
691
  });
511
- container.addEventListener('pointerdown', (e) => {
512
- startEdgeLabelPointerInteraction(e);
513
- }, { capture: true, signal: _edgeLabelPointerAbort.signal });
514
- container.addEventListener('mousedown', (e) => {
515
- startEdgeLabelPointerInteraction(e);
516
- }, { capture: true, signal: _edgeLabelPointerAbort.signal });
517
-
518
- document.addEventListener('dblclick', (e) => {
519
- if (!container.contains(e.target)) return;
520
- const hit = edgeLabelHitAt(e.clientX, e.clientY);
521
- if (!hit || hit.type !== 'label') return;
692
+ container.addEventListener(
693
+ "pointerdown",
694
+ (e) => {
695
+ startEdgeLabelPointerInteraction(e);
696
+ },
697
+ { capture: true, signal: _edgeLabelPointerAbort.signal },
698
+ );
699
+ container.addEventListener(
700
+ "mousedown",
701
+ (e) => {
702
+ startEdgeLabelPointerInteraction(e);
703
+ },
704
+ { capture: true, signal: _edgeLabelPointerAbort.signal },
705
+ );
522
706
 
523
- e.preventDefault();
524
- e.stopImmediatePropagation();
525
- _lr = null;
526
- _ld = null;
527
- st.network.setOptions({ interaction: { dragView: true } });
528
- st.selectedNodeIds = [];
529
- st.selectedEdgeIds = [hit.edgeId];
530
- st.network.setSelection({ nodes: [], edges: [hit.edgeId] });
531
- hideNodePanel();
532
- showEdgePanel();
533
- startEdgeLabelEdit();
534
- }, { capture: true, signal: _edgeLabelPointerAbort.signal });
707
+ document.addEventListener(
708
+ "dblclick",
709
+ (e) => {
710
+ if (!container.contains(e.target)) return;
711
+ const hit = edgeLabelHitAt(e.clientX, e.clientY);
712
+ if (!hit || hit.type !== "label") return;
713
+
714
+ e.preventDefault();
715
+ e.stopImmediatePropagation();
716
+ _lr = null;
717
+ _ld = null;
718
+ st.network.setOptions({ interaction: { dragView: true } });
719
+ st.selectedNodeIds = [];
720
+ st.selectedEdgeIds = [hit.edgeId];
721
+ st.network.setSelection({ nodes: [], edges: [hit.edgeId] });
722
+ hideNodePanel();
723
+ showEdgePanel();
724
+ startEdgeLabelEdit();
725
+ },
726
+ { capture: true, signal: _edgeLabelPointerAbort.signal },
727
+ );
535
728
 
536
729
  // Update width (resize) or offset (label drag) while dragging.
537
730
  function onEdgeLabelPointerMove(e) {
538
731
  if (!st.network) return;
539
732
  if (_lr) {
540
- if (!_lr.dragging) { _lr.dragging = true; pushSnapshot(); }
733
+ if (!_lr.dragging) {
734
+ _lr.dragging = true;
735
+ pushSnapshot();
736
+ }
541
737
  const rect = container.getBoundingClientRect();
542
- const cp = st.network.DOMtoCanvas({ x: e.clientX - rect.left, y: e.clientY - rect.top });
543
- const r = -_lr.rotation;
544
- const dx = cp.x - _lr.bboxCx, dy = cp.y - _lr.bboxCy;
545
- const lx = dx * Math.cos(r) - dy * Math.sin(r);
546
- st.edges.update({ id: _lr.edgeId, edgeLabelWidth: Math.max(40, Math.abs(lx) * 2) });
738
+ const cp = st.network.DOMtoCanvas({
739
+ x: e.clientX - rect.left,
740
+ y: e.clientY - rect.top,
741
+ });
742
+ const r = -_lr.rotation;
743
+ const dx = cp.x - _lr.bboxCx,
744
+ dy = cp.y - _lr.bboxCy;
745
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
746
+ st.edges.update({
747
+ id: _lr.edgeId,
748
+ edgeLabelWidth: Math.max(40, Math.abs(lx) * 2),
749
+ });
547
750
  st.network.redraw();
548
751
  }
549
752
  if (_ld) {
550
753
  if (!_ld.dragging) {
551
- if (Math.hypot(e.clientX - _ld.startMouse.x, e.clientY - _ld.startMouse.y) < 4) return;
754
+ if (
755
+ Math.hypot(
756
+ e.clientX - _ld.startMouse.x,
757
+ e.clientY - _ld.startMouse.y,
758
+ ) < 4
759
+ )
760
+ return;
552
761
  _ld.dragging = true;
553
762
  pushSnapshot();
554
763
  }
555
764
  const scale = st.network.getScale();
556
765
  st.edges.update({
557
766
  id: _ld.edgeId,
558
- edgeLabelOffsetX: _ld.startOffsetX + (e.clientX - _ld.startMouse.x) / scale,
559
- edgeLabelOffsetY: _ld.startOffsetY + (e.clientY - _ld.startMouse.y) / scale,
767
+ edgeLabelOffsetX:
768
+ _ld.startOffsetX + (e.clientX - _ld.startMouse.x) / scale,
769
+ edgeLabelOffsetY:
770
+ _ld.startOffsetY + (e.clientY - _ld.startMouse.y) / scale,
560
771
  });
561
772
  st.network.redraw();
562
773
  }
563
774
  }
564
- document.addEventListener('pointermove', onEdgeLabelPointerMove, { signal: _edgeLabelPointerAbort.signal });
565
- document.addEventListener('mousemove', onEdgeLabelPointerMove, { signal: _edgeLabelPointerAbort.signal });
775
+ document.addEventListener("pointermove", onEdgeLabelPointerMove, {
776
+ signal: _edgeLabelPointerAbort.signal,
777
+ });
778
+ document.addEventListener("mousemove", onEdgeLabelPointerMove, {
779
+ signal: _edgeLabelPointerAbort.signal,
780
+ });
566
781
 
567
782
  // Commit on mouseup.
568
783
  function onEdgeLabelPointerUp() {
@@ -576,10 +791,18 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
576
791
  if (_ld.dragging) markDirty();
577
792
  _ld = null;
578
793
  }
579
- container.style.cursor = _hoverHandle ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
794
+ container.style.cursor = _hoverHandle
795
+ ? "ew-resize"
796
+ : _hoverLabelDrag
797
+ ? "grab"
798
+ : "";
580
799
  }
581
- document.addEventListener('pointerup', onEdgeLabelPointerUp, { signal: _edgeLabelPointerAbort.signal });
582
- document.addEventListener('mouseup', onEdgeLabelPointerUp, { signal: _edgeLabelPointerAbort.signal });
800
+ document.addEventListener("pointerup", onEdgeLabelPointerUp, {
801
+ signal: _edgeLabelPointerAbort.signal,
802
+ });
803
+ document.addEventListener("mouseup", onEdgeLabelPointerUp, {
804
+ signal: _edgeLabelPointerAbort.signal,
805
+ });
583
806
  }
584
807
 
585
808
  // ── Free-arrow body drag ──────────────────────────────────────────────────────
@@ -588,25 +811,43 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
588
811
  // Fix: capture mousedown on the edge body, disable dragView, move anchors manually.
589
812
  {
590
813
  let _fad = null; // free-arrow drag state
591
- container.addEventListener('mousedown', (e) => {
592
- if (e.button !== 0 || !st.network) return;
593
- const domPos = { x: e.offsetX, y: e.offsetY };
594
- // If the click landed on a node (including an anchor endpoint), don't intercept:
595
- // the user wants to move only that endpoint (pivot), not the whole arrow.
596
- if (st.network.getNodeAt(domPos)) return;
597
- const edgeId = st.network.getEdgeAt(domPos);
598
- if (!edgeId) return;
599
- const edge = st.edges.get(edgeId);
600
- if (!edge) return;
601
- const fromN = st.nodes.get(edge.from);
602
- const toN = st.nodes.get(edge.to);
603
- if (!(fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor')) return;
604
- const startPos = st.network.getPositions([edge.from, edge.to]);
605
- _fad = { edgeId, fromId: edge.from, toId: edge.to,
606
- startMouse: { x: e.clientX, y: e.clientY }, startPos, dragging: false };
607
- }, { capture: true });
608
-
609
- document.addEventListener('mousemove', (e) => {
814
+ container.addEventListener(
815
+ "mousedown",
816
+ (e) => {
817
+ if (e.button !== 0 || !st.network) return;
818
+ const domPos = { x: e.offsetX, y: e.offsetY };
819
+ // If the click landed on a node (including an anchor endpoint), don't intercept:
820
+ // the user wants to move only that endpoint (pivot), not the whole arrow.
821
+ if (st.network.getNodeAt(domPos)) return;
822
+ const edgeId = st.network.getEdgeAt(domPos);
823
+ if (!edgeId) return;
824
+ const edge = st.edges.get(edgeId);
825
+ if (!edge) return;
826
+ const fromN = st.nodes.get(edge.from);
827
+ const toN = st.nodes.get(edge.to);
828
+ if (
829
+ !(
830
+ fromN &&
831
+ fromN.shapeType === "anchor" &&
832
+ toN &&
833
+ toN.shapeType === "anchor"
834
+ )
835
+ )
836
+ return;
837
+ const startPos = st.network.getPositions([edge.from, edge.to]);
838
+ _fad = {
839
+ edgeId,
840
+ fromId: edge.from,
841
+ toId: edge.to,
842
+ startMouse: { x: e.clientX, y: e.clientY },
843
+ startPos,
844
+ dragging: false,
845
+ };
846
+ },
847
+ { capture: true },
848
+ );
849
+
850
+ document.addEventListener("mousemove", (e) => {
610
851
  if (!_fad || !st.network) return;
611
852
  const dx = e.clientX - _fad.startMouse.x;
612
853
  const dy = e.clientY - _fad.startMouse.y;
@@ -619,13 +860,19 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
619
860
  const scale = st.network.getScale();
620
861
  const fp = _fad.startPos[_fad.fromId];
621
862
  const tp = _fad.startPos[_fad.toId];
622
- const snap = (x, y) => st.gridEnabled ? snapToGrid(x, y) : { x, y };
623
- if (fp) { const p = snap(fp.x + dx / scale, fp.y + dy / scale); st.network.moveNode(_fad.fromId, p.x, p.y); }
624
- if (tp) { const p = snap(tp.x + dx / scale, tp.y + dy / scale); st.network.moveNode(_fad.toId, p.x, p.y); }
863
+ const snap = (x, y) => (st.gridEnabled ? snapToGrid(x, y) : { x, y });
864
+ if (fp) {
865
+ const p = snap(fp.x + dx / scale, fp.y + dy / scale);
866
+ st.network.moveNode(_fad.fromId, p.x, p.y);
867
+ }
868
+ if (tp) {
869
+ const p = snap(tp.x + dx / scale, tp.y + dy / scale);
870
+ st.network.moveNode(_fad.toId, p.x, p.y);
871
+ }
625
872
  st.network.redraw();
626
873
  });
627
874
 
628
- document.addEventListener('mouseup', () => {
875
+ document.addEventListener("mouseup", () => {
629
876
  if (!_fad) return;
630
877
  if (_fad.dragging) {
631
878
  st.network.setOptions({ interaction: { dragView: true } });
@@ -642,67 +889,90 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
642
889
  // Path B — two successive CLICKS on empty canvas → free-standing anchor→anchor.
643
890
  // Uses vis-network's own 'click' event: Hammer.js fires 'click' only
644
891
  // for taps, NOT for drags, so Path A drags never bleed into Path B.
645
- let _addEdgeFromId = null;
646
- let _addEdgeFromPort = null; // port key on the source node (null = no port)
892
+ let _addEdgeFromId = null;
893
+ let _addEdgeFromPort = null; // port key on the source node (null = no port)
647
894
  // _hoveredPortNodeId, _hoveredPortKey, _draggingAnchorIds are module-level.
648
- const visCanvas = document.getElementById('vis-canvas');
895
+ const visCanvas = document.getElementById("vis-canvas");
649
896
 
650
897
  // Track hovered node + nearest port while in addEdge mode OR while dragging an anchor.
651
- visCanvas.addEventListener('mousemove', (e) => {
652
- const inAddEdge = st.currentTool === 'addEdge';
898
+ visCanvas.addEventListener("mousemove", (e) => {
899
+ const inAddEdge = st.currentTool === "addEdge";
653
900
  const anchorDragging = _draggingAnchorIds.size > 0;
654
901
  if ((!inAddEdge && !anchorDragging) || !st.network) return;
655
902
 
656
903
  const pos = { x: e.offsetX, y: e.offsetY };
657
- const cp = st.network.DOMtoCanvas(pos);
904
+ const cp = st.network.DOMtoCanvas(pos);
658
905
 
659
906
  let newPortNodeId = null;
660
907
 
661
908
  if (inAddEdge) {
662
909
  // addEdge mode: use getNodeAt as before
663
- const nodeId = st.network.getNodeAt(pos) || null;
910
+ const nodeId = st.network.getNodeAt(pos) || null;
664
911
  const nodeData = nodeId && st.nodes.get(nodeId);
665
- const isAnchor = nodeData && nodeData.shapeType === 'anchor';
912
+ const isAnchor = nodeData && nodeData.shapeType === "anchor";
666
913
  const isLocked = nodeData && nodeData.locked;
667
- newPortNodeId = (nodeId && !isAnchor && !isLocked) ? nodeId : null;
914
+ newPortNodeId = nodeId && !isAnchor && !isLocked ? nodeId : null;
668
915
  } else {
669
916
  // Anchor drag: getNodeAt returns the dragged anchor itself — do a canvas-space
670
917
  // bounding-box search for any non-anchor node that contains the cursor.
671
918
  const SNAP_THRESHOLD = 30;
672
- let bestId = null;
919
+ let bestId = null;
673
920
  let bestDist = Infinity;
674
- for (const [candidateId, candidatePos] of Object.entries(st.network.getPositions())) {
921
+ for (const [candidateId, candidatePos] of Object.entries(
922
+ st.network.getPositions(),
923
+ )) {
675
924
  if (_draggingAnchorIds.has(candidateId)) continue;
676
925
  const candidate = st.nodes.get(candidateId);
677
- if (!candidate || candidate.shapeType === 'anchor') continue;
926
+ if (!candidate || candidate.shapeType === "anchor") continue;
678
927
  const bodyNode = st.network.body.nodes[candidateId];
679
928
  if (!bodyNode) continue;
680
- const w = (bodyNode.shape && bodyNode.shape.width) || SHAPE_DEFAULTS[candidate.shapeType]?.width || 120;
681
- const h = (bodyNode.shape && bodyNode.shape.height) || SHAPE_DEFAULTS[candidate.shapeType]?.height || 60;
682
- const inBox = cp.x >= candidatePos.x - w / 2 - SNAP_THRESHOLD &&
683
- cp.x <= candidatePos.x + w / 2 + SNAP_THRESHOLD &&
684
- cp.y >= candidatePos.y - h / 2 - SNAP_THRESHOLD &&
685
- cp.y <= candidatePos.y + h / 2 + SNAP_THRESHOLD;
929
+ const w =
930
+ (bodyNode.shape && bodyNode.shape.width) ||
931
+ SHAPE_DEFAULTS[candidate.shapeType]?.width ||
932
+ 120;
933
+ const h =
934
+ (bodyNode.shape && bodyNode.shape.height) ||
935
+ SHAPE_DEFAULTS[candidate.shapeType]?.height ||
936
+ 60;
937
+ const inBox =
938
+ cp.x >= candidatePos.x - w / 2 - SNAP_THRESHOLD &&
939
+ cp.x <= candidatePos.x + w / 2 + SNAP_THRESHOLD &&
940
+ cp.y >= candidatePos.y - h / 2 - SNAP_THRESHOLD &&
941
+ cp.y <= candidatePos.y + h / 2 + SNAP_THRESHOLD;
686
942
  if (inBox) {
687
943
  const dist = Math.hypot(candidatePos.x - cp.x, candidatePos.y - cp.y);
688
- if (dist < bestDist) { bestDist = dist; bestId = candidateId; }
944
+ if (dist < bestDist) {
945
+ bestDist = dist;
946
+ bestId = candidateId;
947
+ }
689
948
  }
690
949
  }
691
950
  newPortNodeId = bestId;
692
951
  }
693
952
 
694
953
  const newPortKey = newPortNodeId ? getNearestPort(newPortNodeId, cp) : null;
695
- if (newPortNodeId !== _hoveredPortNodeId || newPortKey !== _hoveredPortKey) {
954
+ if (
955
+ newPortNodeId !== _hoveredPortNodeId ||
956
+ newPortKey !== _hoveredPortKey
957
+ ) {
696
958
  _hoveredPortNodeId = newPortNodeId;
697
- _hoveredPortKey = newPortKey;
959
+ _hoveredPortKey = newPortKey;
698
960
  st.network.redraw();
699
961
  }
700
962
  });
701
963
 
702
964
  // Rehook hover: track the nearest port on the from/to nodes of the selected port edge.
703
- visCanvas.addEventListener('mousemove', (e) => {
704
- if (!st.network || st.currentTool === 'addEdge' || _draggingAnchorIds.size > 0) return;
705
- const selectedEdge = st.selectedEdgeIds.length === 1 ? st.edges.get(st.selectedEdgeIds[0]) : null;
965
+ visCanvas.addEventListener("mousemove", (e) => {
966
+ if (
967
+ !st.network ||
968
+ st.currentTool === "addEdge" ||
969
+ _draggingAnchorIds.size > 0
970
+ )
971
+ return;
972
+ const selectedEdge =
973
+ st.selectedEdgeIds.length === 1
974
+ ? st.edges.get(st.selectedEdgeIds[0])
975
+ : null;
706
976
  if (!isRehookable(selectedEdge)) {
707
977
  if (_rehookEdgeId !== null) {
708
978
  _rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
@@ -714,49 +984,58 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
714
984
  _rehookEdgeId = selectedEdge.id;
715
985
  const cp = st.network.DOMtoCanvas({ x: e.offsetX, y: e.offsetY });
716
986
  const REHOOK_THRESHOLD = 15; // world units
717
- let newNodeId = null;
718
- let newPortKey = null;
987
+ let newNodeId = null;
988
+ let newPortKey = null;
719
989
  let closestDist = Infinity;
720
990
 
721
991
  for (const nodeId of [selectedEdge.from, selectedEdge.to]) {
722
992
  const nodeData = st.nodes.get(nodeId);
723
- if (!nodeData || nodeData.shapeType === 'anchor') continue;
993
+ if (!nodeData || nodeData.shapeType === "anchor") continue;
724
994
  const portKey = getNearestPort(nodeId, cp);
725
995
  const portPos = getPortPosition(nodeId, portKey);
726
996
  if (!portPos) continue;
727
997
  const dist = Math.hypot(cp.x - portPos.x, cp.y - portPos.y);
728
998
  if (dist <= REHOOK_THRESHOLD && dist < closestDist) {
729
999
  closestDist = dist;
730
- newNodeId = nodeId;
1000
+ newNodeId = nodeId;
731
1001
  newPortKey = portKey;
732
1002
  }
733
1003
  }
734
1004
 
735
- if (newNodeId !== _rehookHoveredNodeId || newPortKey !== _rehookHoveredPortKey) {
736
- _rehookHoveredNodeId = newNodeId;
1005
+ if (
1006
+ newNodeId !== _rehookHoveredNodeId ||
1007
+ newPortKey !== _rehookHoveredPortKey
1008
+ ) {
1009
+ _rehookHoveredNodeId = newNodeId;
737
1010
  _rehookHoveredPortKey = newPortKey;
738
1011
  st.network.redraw();
739
1012
  }
740
1013
  });
741
1014
 
742
1015
  // Path A – capture source node on mousedown.
743
- visCanvas.addEventListener('mousedown', (e) => {
744
- if (st.currentTool !== 'addEdge') return;
745
- const nodeId = st.network.getNodeAt({ x: e.offsetX, y: e.offsetY }) || null;
1016
+ visCanvas.addEventListener("mousedown", (e) => {
1017
+ if (st.currentTool !== "addEdge") return;
1018
+ const nodeId = st.network.getNodeAt({ x: e.offsetX, y: e.offsetY }) || null;
746
1019
  const nodeData = nodeId && st.nodes.get(nodeId);
747
1020
  // Block starting an edge from a locked node or an anchor (free-arrow endpoint).
748
- if (nodeData && (nodeData.locked || nodeData.shapeType === 'anchor')) { _addEdgeFromId = null; return; }
1021
+ if (nodeData && (nodeData.locked || nodeData.shapeType === "anchor")) {
1022
+ _addEdgeFromId = null;
1023
+ return;
1024
+ }
749
1025
  // Dragging from a real node cancels any pending two-click origin.
750
1026
  if (nodeId) st.freeArrowFirstPoint = null;
751
- _addEdgeFromId = nodeId;
1027
+ _addEdgeFromId = nodeId;
752
1028
  _addEdgeFromPort = _hoveredPortKey;
753
1029
  });
754
1030
 
755
1031
  // Path A – release on empty canvas creates the anchor endpoint.
756
- visCanvas.addEventListener('mouseup', (e) => {
757
- if (st.currentTool !== 'addEdge' || !_addEdgeFromId) { _addEdgeFromId = null; return; }
758
- const pos = { x: e.offsetX, y: e.offsetY };
759
- const targetId = st.network.getNodeAt(pos);
1032
+ visCanvas.addEventListener("mouseup", (e) => {
1033
+ if (st.currentTool !== "addEdge" || !_addEdgeFromId) {
1034
+ _addEdgeFromId = null;
1035
+ return;
1036
+ }
1037
+ const pos = { x: e.offsetX, y: e.offsetY };
1038
+ const targetId = st.network.getNodeAt(pos);
760
1039
  const targetData = targetId && st.nodes.get(targetId);
761
1040
  // Locked targets are non-interactive: treat them exactly like empty canvas
762
1041
  // so a free-arrow endpoint is created at the release point — matches the
@@ -764,26 +1043,40 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
764
1043
  // Also: if the source sits on top of a lower-z target that contains it
765
1044
  // (e.g. a post-it on a background image), don't bind to that background.
766
1045
  const targetLocked = !!(targetData && targetData.locked);
767
- const targetContainsSource = targetId && lowerZTargetContainsSource(_addEdgeFromId, targetId);
1046
+ const targetContainsSource =
1047
+ targetId && lowerZTargetContainsSource(_addEdgeFromId, targetId);
768
1048
  if (!targetId || targetLocked || targetContainsSource) {
769
1049
  pushSnapshot();
770
- const cp = st.network.DOMtoCanvas(pos);
771
- const anchorId = 'a' + Date.now();
1050
+ const cp = st.network.DOMtoCanvas(pos);
1051
+ const anchorId = "a" + Date.now();
772
1052
  st.nodes.add({
773
- id: anchorId, label: '', shapeType: 'anchor', colorKey: 'c-gray',
774
- nodeWidth: 8, nodeHeight: 8, fontSize: null, rotation: 0, labelRotation: 0,
775
- x: cp.x, y: cp.y,
776
- ...visNodeProps('anchor', 'c-gray', 8, 8, null, null, null),
1053
+ id: anchorId,
1054
+ label: "",
1055
+ shapeType: "anchor",
1056
+ colorKey: "c-gray",
1057
+ nodeWidth: 8,
1058
+ nodeHeight: 8,
1059
+ fontSize: null,
1060
+ rotation: 0,
1061
+ labelRotation: 0,
1062
+ x: cp.x,
1063
+ y: cp.y,
1064
+ ...visNodeProps("anchor", "c-gray", 8, 8, null, null, null),
777
1065
  });
778
1066
  st.edges.add({
779
- id: 'e' + Date.now(), from: _addEdgeFromId, to: anchorId,
780
- arrowDir: 'to', dashes: false,
1067
+ id: "e" + Date.now(),
1068
+ from: _addEdgeFromId,
1069
+ to: anchorId,
1070
+ arrowDir: "to",
1071
+ dashes: false,
781
1072
  smooth: { enabled: false },
782
- ...visEdgeProps('to', false),
1073
+ ...visEdgeProps("to", false),
783
1074
  });
784
1075
  markDirty();
785
1076
  st.freeArrowFirstPoint = null;
786
- setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
1077
+ setTimeout(() => {
1078
+ if (st.currentTool === "addEdge") st.network.addEdgeMode();
1079
+ }, 0);
787
1080
  }
788
1081
  _addEdgeFromId = null;
789
1082
  });
@@ -791,8 +1084,8 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
791
1084
  // Path B – two successive clicks on empty canvas → free-standing anchor→anchor arrow.
792
1085
  // vis-network's 'click' event (via Hammer.js) only fires for taps, never for drags,
793
1086
  // so Path A drag gestures cannot accidentally trigger Path B.
794
- st.network.on('click', (params) => {
795
- if (st.currentTool !== 'addEdge') return;
1087
+ st.network.on("click", (params) => {
1088
+ if (st.currentTool !== "addEdge") return;
796
1089
  // Only handle clicks that landed on empty canvas (no node, no edge).
797
1090
  if (params.nodes.length > 0 || params.edges.length > 0) return;
798
1091
  const cp = params.pointer.canvas;
@@ -803,26 +1096,71 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
803
1096
  } else {
804
1097
  // Second click: create the free-standing anchor→anchor arrow.
805
1098
  pushSnapshot();
806
- const t = Date.now();
807
- const fromId = 'a' + t;
808
- const toId = 'a' + (t + 1);
809
- const anchorProps = visNodeProps('anchor', 'c-gray', 8, 8, null, null, null);
1099
+ const t = Date.now();
1100
+ const fromId = "a" + t;
1101
+ const toId = "a" + (t + 1);
1102
+ const anchorProps = visNodeProps(
1103
+ "anchor",
1104
+ "c-gray",
1105
+ 8,
1106
+ 8,
1107
+ null,
1108
+ null,
1109
+ null,
1110
+ );
810
1111
  st.nodes.add([
811
- { id: fromId, label: '', shapeType: 'anchor', colorKey: 'c-gray', nodeWidth: 8, nodeHeight: 8, fontSize: null, rotation: 0, labelRotation: 0, x: st.freeArrowFirstPoint.x, y: st.freeArrowFirstPoint.y, ...anchorProps },
812
- { id: toId, label: '', shapeType: 'anchor', colorKey: 'c-gray', nodeWidth: 8, nodeHeight: 8, fontSize: null, rotation: 0, labelRotation: 0, x: cp.x, y: cp.y, ...anchorProps },
1112
+ {
1113
+ id: fromId,
1114
+ label: "",
1115
+ shapeType: "anchor",
1116
+ colorKey: "c-gray",
1117
+ nodeWidth: 8,
1118
+ nodeHeight: 8,
1119
+ fontSize: null,
1120
+ rotation: 0,
1121
+ labelRotation: 0,
1122
+ x: st.freeArrowFirstPoint.x,
1123
+ y: st.freeArrowFirstPoint.y,
1124
+ ...anchorProps,
1125
+ },
1126
+ {
1127
+ id: toId,
1128
+ label: "",
1129
+ shapeType: "anchor",
1130
+ colorKey: "c-gray",
1131
+ nodeWidth: 8,
1132
+ nodeHeight: 8,
1133
+ fontSize: null,
1134
+ rotation: 0,
1135
+ labelRotation: 0,
1136
+ x: cp.x,
1137
+ y: cp.y,
1138
+ ...anchorProps,
1139
+ },
813
1140
  ]);
814
- const edgeId = 'e' + t;
1141
+ const edgeId = "e" + t;
815
1142
  const lastStyle = getLastFreeArrowStyle();
816
- const arrowDir = lastStyle.arrowDir || 'to';
817
- const dashes = lastStyle.dashes || false;
1143
+ const arrowDir = lastStyle.arrowDir || "to";
1144
+ const dashes = lastStyle.dashes || false;
818
1145
  st.edges.add({
819
- id: edgeId, from: fromId, to: toId,
820
- arrowDir, dashes,
1146
+ id: edgeId,
1147
+ from: fromId,
1148
+ to: toId,
1149
+ arrowDir,
1150
+ dashes,
821
1151
  edgeColor: lastStyle.edgeColor || null,
822
1152
  edgeWidth: lastStyle.edgeWidth || null,
823
1153
  smooth: { enabled: false },
824
1154
  ...visEdgeProps(arrowDir, dashes),
825
- ...(lastStyle.edgeColor ? { color: { color: lastStyle.edgeColor, highlight: '#f97316', hover: '#f97316' } } : {}),
1155
+ ...(lastStyle.edgeColor
1156
+ ? {
1157
+ color: {
1158
+ color: lastStyle.edgeColor,
1159
+ highlight: "#f97316",
1160
+ hover: "#f97316",
1161
+ },
1162
+ }
1163
+ : {}),
826
1164
  ...(lastStyle.edgeWidth ? { width: lastStyle.edgeWidth } : {}),
827
1165
  });
828
1166
  st.freeArrowFirstPoint = null;
@@ -837,18 +1175,18 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
837
1175
  }
838
1176
  });
839
1177
 
840
- document.getElementById('emptyState').classList.add('hidden');
1178
+ document.getElementById("emptyState").classList.add("hidden");
841
1179
  updateZoomDisplay();
842
1180
 
843
1181
  // vis-network initialises shape.width/height to 50 (not undefined), so
844
1182
  // needsRefresh() returns false and resize() never runs on the first render.
845
1183
  // Fix: reset shape.width/height to undefined so needsRefresh() returns true
846
1184
  // and vis-network's own render loop runs resize() with the correct context.
847
- st.network.once('afterDrawing', () => {
1185
+ st.network.once("afterDrawing", () => {
848
1186
  for (const id of st.network.body.nodeIndices) {
849
1187
  const bn = st.network.body.nodes[id];
850
1188
  if (!bn || !bn.shape) continue;
851
- bn.shape.width = undefined;
1189
+ bn.shape.width = undefined;
852
1190
  bn.shape.height = undefined;
853
1191
  }
854
1192
  // Patch CustomShape.distanceToBorder so anchor nodes report 0: arrow tips
@@ -864,7 +1202,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
864
1202
  const orig = proto.distanceToBorder;
865
1203
  proto.distanceToBorder = function (ctx, angle) {
866
1204
  const data = st.nodes && st.nodes.get(this.options && this.options.id);
867
- if (data && data.shapeType === 'anchor') return 0;
1205
+ if (data && data.shapeType === "anchor") return 0;
868
1206
  return orig.call(this, ctx, angle);
869
1207
  };
870
1208
  proto.__ldAnchorDistancePatched = true;
@@ -872,7 +1210,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
872
1210
  }
873
1211
  st.network.redraw();
874
1212
  });
875
- window.dispatchEvent(new CustomEvent('diagram:network-ready'));
1213
+ window.dispatchEvent(new CustomEvent("diagram:network-ready"));
876
1214
  }
877
1215
 
878
1216
  // ── Anchor snap-to-connect ────────────────────────────────────────────────────
@@ -888,10 +1226,12 @@ function _onAnchorSnapConnect(params) {
888
1226
 
889
1227
  for (const anchorId of params.nodes) {
890
1228
  const anchor = st.nodes.get(anchorId);
891
- if (!anchor || anchor.shapeType !== 'anchor') continue;
1229
+ if (!anchor || anchor.shapeType !== "anchor") continue;
892
1230
 
893
1231
  // Find the edge this anchor belongs to (as from or to)
894
- const connectedEdges = st.edges.get().filter(e => e.from === anchorId || e.to === anchorId);
1232
+ const connectedEdges = st.edges
1233
+ .get()
1234
+ .filter((e) => e.from === anchorId || e.to === anchorId);
895
1235
  if (connectedEdges.length === 0) continue;
896
1236
 
897
1237
  // Current position of dragged anchor
@@ -907,10 +1247,12 @@ function _onAnchorSnapConnect(params) {
907
1247
  }
908
1248
  siblingIds.delete(anchorId); // keep sibling (other endpoint) in set to block self-loop
909
1249
 
910
- let bestId = null;
1250
+ let bestId = null;
911
1251
  let bestDist = Infinity;
912
1252
 
913
- for (const [candidateId, candidatePos] of Object.entries(st.network.getPositions())) {
1253
+ for (const [candidateId, candidatePos] of Object.entries(
1254
+ st.network.getPositions(),
1255
+ )) {
914
1256
  if (candidateId === anchorId) continue;
915
1257
  if (siblingIds.has(candidateId)) continue; // would create a self-loop
916
1258
 
@@ -919,26 +1261,33 @@ function _onAnchorSnapConnect(params) {
919
1261
 
920
1262
  const dist = Math.hypot(candidatePos.x - pos.x, candidatePos.y - pos.y);
921
1263
 
922
- if (candidate.shapeType === 'anchor') {
1264
+ if (candidate.shapeType === "anchor") {
923
1265
  // Snap to another anchor if within threshold
924
1266
  if (dist < SNAP_THRESHOLD && dist < bestDist) {
925
- bestId = candidateId;
1267
+ bestId = candidateId;
926
1268
  bestDist = dist;
927
1269
  }
928
1270
  } else {
929
1271
  // Snap to a regular node if anchor falls within its bounding box (padded by threshold)
930
1272
  const bodyNode = st.network.body.nodes[candidateId];
931
1273
  if (!bodyNode) continue;
932
- const w = (bodyNode.shape && bodyNode.shape.width) || SHAPE_DEFAULTS[candidate.shapeType]?.width || 120;
933
- const h = (bodyNode.shape && bodyNode.shape.height) || SHAPE_DEFAULTS[candidate.shapeType]?.height || 60;
1274
+ const w =
1275
+ (bodyNode.shape && bodyNode.shape.width) ||
1276
+ SHAPE_DEFAULTS[candidate.shapeType]?.width ||
1277
+ 120;
1278
+ const h =
1279
+ (bodyNode.shape && bodyNode.shape.height) ||
1280
+ SHAPE_DEFAULTS[candidate.shapeType]?.height ||
1281
+ 60;
934
1282
  const cx = candidatePos.x;
935
1283
  const cy = candidatePos.y;
936
- const inBox = pos.x >= cx - w / 2 - SNAP_THRESHOLD &&
937
- pos.x <= cx + w / 2 + SNAP_THRESHOLD &&
938
- pos.y >= cy - h / 2 - SNAP_THRESHOLD &&
939
- pos.y <= cy + h / 2 + SNAP_THRESHOLD;
1284
+ const inBox =
1285
+ pos.x >= cx - w / 2 - SNAP_THRESHOLD &&
1286
+ pos.x <= cx + w / 2 + SNAP_THRESHOLD &&
1287
+ pos.y >= cy - h / 2 - SNAP_THRESHOLD &&
1288
+ pos.y <= cy + h / 2 + SNAP_THRESHOLD;
940
1289
  if (inBox && dist < bestDist) {
941
- bestId = candidateId;
1290
+ bestId = candidateId;
942
1291
  bestDist = dist;
943
1292
  }
944
1293
  }
@@ -946,17 +1295,21 @@ function _onAnchorSnapConnect(params) {
946
1295
 
947
1296
  if (!bestId) continue;
948
1297
 
949
- if (!snapped) { pushSnapshot(); snapped = true; }
1298
+ if (!snapped) {
1299
+ pushSnapshot();
1300
+ snapped = true;
1301
+ }
950
1302
 
951
1303
  // Determine which port to attach to on the target node (non-anchor targets only).
952
1304
  const targetNode = st.nodes.get(bestId);
953
- const isAnchorTarget = targetNode && targetNode.shapeType === 'anchor';
1305
+ const isAnchorTarget = targetNode && targetNode.shapeType === "anchor";
954
1306
  // Use the hovered port (computed by mousemove) when available; fall back to nearest port.
955
1307
  let portKey = null;
956
1308
  if (!isAnchorTarget) {
957
- portKey = (_hoveredPortNodeId === bestId && _hoveredPortKey)
958
- ? _hoveredPortKey
959
- : getNearestPort(bestId, pos);
1309
+ portKey =
1310
+ _hoveredPortNodeId === bestId && _hoveredPortKey
1311
+ ? _hoveredPortKey
1312
+ : getNearestPort(bestId, pos);
960
1313
  }
961
1314
 
962
1315
  // Reconnect every edge that uses this anchor as an endpoint
@@ -966,7 +1319,11 @@ function _onAnchorSnapConnect(params) {
966
1319
  if (portKey) update.fromPort = portKey;
967
1320
  // Port edges must be transparent ghosts so only drawPortEdge is visible.
968
1321
  if (portKey || e.toPort) {
969
- update.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
1322
+ update.color = {
1323
+ color: "rgba(0,0,0,0)",
1324
+ highlight: "rgba(0,0,0,0)",
1325
+ hover: "rgba(0,0,0,0)",
1326
+ };
970
1327
  update.arrows = { to: { enabled: false }, from: { enabled: false } };
971
1328
  }
972
1329
  st.edges.update(update);
@@ -976,7 +1333,11 @@ function _onAnchorSnapConnect(params) {
976
1333
  if (portKey) update.toPort = portKey;
977
1334
  // Port edges must be transparent ghosts so only drawPortEdge is visible.
978
1335
  if (portKey || e.fromPort) {
979
- update.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
1336
+ update.color = {
1337
+ color: "rgba(0,0,0,0)",
1338
+ highlight: "rgba(0,0,0,0)",
1339
+ hover: "rgba(0,0,0,0)",
1340
+ };
980
1341
  update.arrows = { to: { enabled: false }, from: { enabled: false } };
981
1342
  }
982
1343
  st.edges.update(update);
@@ -996,133 +1357,146 @@ function _onAnchorSnapConnect(params) {
996
1357
  // labels to appear twice at different positions.
997
1358
  function drawEdgeLabels(ctx) {
998
1359
  try {
999
- if (!st.edges || !st.network) return;
1000
-
1001
- const m = ctx.getTransform();
1002
- const dpr = window.devicePixelRatio || 1;
1003
- const canvasEl = ctx.canvas;
1004
- const container = document.getElementById('vis-canvas').parentElement;
1005
- const canvasRect = canvasEl.getBoundingClientRect();
1006
- const containerRect = container.getBoundingClientRect();
1007
- const offsetX = canvasRect.left - containerRect.left;
1008
- const offsetY = canvasRect.top - containerRect.top;
1009
-
1010
- st.edges.get().forEach((e) => {
1011
- // Port edges draw their own labels inside drawPortEdge — skip here.
1012
- if (e.fromPort || e.toPort) return;
1013
-
1014
- // Compute bezier midpoint in layout space for every edge (labeled or not)
1015
- // so the label editor always has an accurate DOM position to open at.
1016
- const bodyEdge = st.network.body.edges[e.id];
1017
- let mx, my;
1018
- if (bodyEdge && bodyEdge.edgeType && typeof bodyEdge.edgeType.getPoint === 'function') {
1019
- const pt = bodyEdge.edgeType.getPoint(0.5);
1020
- mx = pt.x;
1021
- my = pt.y;
1022
- } else {
1023
- const positions = st.network.getPositions([e.from, e.to]);
1024
- const fp = positions[e.from];
1025
- const tp = positions[e.to];
1026
- if (!fp || !tp) return;
1027
- mx = (fp.x + tp.x) / 2;
1028
- my = (fp.y + tp.y) / 2;
1029
- }
1030
-
1031
- const ox = e.edgeLabelOffsetX || 0;
1032
- const oy = e.edgeLabelOffsetY || 0;
1033
- const lx = mx + ox;
1034
- const ly = my + oy;
1360
+ if (!st.edges || !st.network) return;
1361
+
1362
+ const m = ctx.getTransform();
1363
+ const dpr = window.devicePixelRatio || 1;
1364
+ const canvasEl = ctx.canvas;
1365
+ const container = document.getElementById("vis-canvas").parentElement;
1366
+ const canvasRect = canvasEl.getBoundingClientRect();
1367
+ const containerRect = container.getBoundingClientRect();
1368
+ const offsetX = canvasRect.left - containerRect.left;
1369
+ const offsetY = canvasRect.top - containerRect.top;
1370
+
1371
+ st.edges.get().forEach((e) => {
1372
+ // Port edges draw their own labels inside drawPortEdge — skip here.
1373
+ if (e.fromPort || e.toPort) return;
1374
+
1375
+ // Compute bezier midpoint in layout space for every edge (labeled or not)
1376
+ // so the label editor always has an accurate DOM position to open at.
1377
+ const bodyEdge = st.network.body.edges[e.id];
1378
+ let mx, my;
1379
+ if (
1380
+ bodyEdge &&
1381
+ bodyEdge.edgeType &&
1382
+ typeof bodyEdge.edgeType.getPoint === "function"
1383
+ ) {
1384
+ const pt = bodyEdge.edgeType.getPoint(0.5);
1385
+ mx = pt.x;
1386
+ my = pt.y;
1387
+ } else {
1388
+ const positions = st.network.getPositions([e.from, e.to]);
1389
+ const fp = positions[e.from];
1390
+ const tp = positions[e.to];
1391
+ if (!fp || !tp) return;
1392
+ mx = (fp.x + tp.x) / 2;
1393
+ my = (fp.y + tp.y) / 2;
1394
+ }
1035
1395
 
1036
- st.edgeLabelCanvasPos[e.id] = {
1037
- x: (m.a * lx + m.e) / dpr + offsetX,
1038
- y: (m.d * ly + m.f) / dpr + offsetY,
1039
- };
1396
+ const ox = e.edgeLabelOffsetX || 0;
1397
+ const oy = e.edgeLabelOffsetY || 0;
1398
+ const lx = mx + ox;
1399
+ const ly = my + oy;
1040
1400
 
1041
- // Only draw the label text for edges that have one.
1042
- if (!e.label) return;
1401
+ st.edgeLabelCanvasPos[e.id] = {
1402
+ x: (m.a * lx + m.e) / dpr + offsetX,
1403
+ y: (m.d * ly + m.f) / dpr + offsetY,
1404
+ };
1043
1405
 
1044
- const fontSize = e.fontSize || 11;
1045
- const lineHeight = fontSize * 1.5;
1046
- const PAD_X = 6, PAD_Y = 4;
1406
+ // Only draw the label text for edges that have one.
1407
+ if (!e.label) return;
1047
1408
 
1048
- ctx.save();
1049
- ctx.translate(lx, ly);
1050
- if (e.labelRotation && Math.abs(e.labelRotation) > 0.001) {
1051
- ctx.rotate(e.labelRotation);
1052
- }
1053
- ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
1054
-
1055
- const fixedW = e.edgeLabelWidth || null;
1056
- const innerW = fixedW ? fixedW - PAD_X * 2 : null;
1057
- const lines = fixedW ? wrapText(ctx, e.label, innerW) : [e.label];
1058
- const textW = fixedW ? innerW : ctx.measureText(e.label).width;
1059
- const boxW = textW + PAD_X * 2;
1060
- const boxH = lines.length * lineHeight + PAD_Y * 2;
1061
-
1062
- // Always store bbox (needed for resize handles, even when not selected)
1063
- st.edgeLabelBBox[e.id] = {
1064
- cx: lx, cy: ly,
1065
- w: boxW, h: boxH,
1066
- rotation: e.labelRotation || 0,
1067
- };
1409
+ const fontSize = e.fontSize || 11;
1410
+ const lineHeight = fontSize * 1.5;
1411
+ const PAD_X = 6,
1412
+ PAD_Y = 4;
1068
1413
 
1069
- const totalH = lines.length * lineHeight;
1070
- const TEXT_PAD = 3;
1071
- const isDark = document.documentElement.classList.contains('dark');
1072
- const bgFill = isDark ? 'rgba(3,7,18,0.82)' : 'rgba(249,250,251,0.82)';
1414
+ ctx.save();
1415
+ ctx.translate(lx, ly);
1416
+ if (e.labelRotation && Math.abs(e.labelRotation) > 0.001) {
1417
+ ctx.rotate(e.labelRotation);
1418
+ }
1419
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
1420
+
1421
+ const fixedW = e.edgeLabelWidth || null;
1422
+ const innerW = fixedW ? fixedW - PAD_X * 2 : null;
1423
+ const lines = fixedW ? wrapText(ctx, e.label, innerW) : [e.label];
1424
+ const textW = fixedW ? innerW : ctx.measureText(e.label).width;
1425
+ const boxW = textW + PAD_X * 2;
1426
+ const boxH = lines.length * lineHeight + PAD_Y * 2;
1427
+
1428
+ // Always store bbox (needed for resize handles, even when not selected)
1429
+ st.edgeLabelBBox[e.id] = {
1430
+ cx: lx,
1431
+ cy: ly,
1432
+ w: boxW,
1433
+ h: boxH,
1434
+ rotation: e.labelRotation || 0,
1435
+ };
1073
1436
 
1074
- // Opaque background tight around the text — makes the arrow appear to pass behind.
1075
- ctx.save();
1076
- ctx.fillStyle = bgFill;
1077
- ctx.fillRect(-textW / 2 - TEXT_PAD, -totalH / 2 - TEXT_PAD,
1078
- textW + TEXT_PAD * 2, totalH + TEXT_PAD * 2);
1079
- ctx.restore();
1437
+ const totalH = lines.length * lineHeight;
1438
+ const TEXT_PAD = 3;
1439
+ const isDark = document.documentElement.classList.contains("dark");
1440
+ const bgFill = isDark ? "rgba(3,7,18,0.82)" : "rgba(249,250,251,0.82)";
1080
1441
 
1081
- // Dashed border boxonly when the edge is selected
1082
- if (st.selectedEdgeIds && st.selectedEdgeIds.includes(e.id)) {
1442
+ // Opaque background tight around the text makes the arrow appear to pass behind.
1083
1443
  ctx.save();
1084
- ctx.strokeStyle = '#9ca3af';
1085
- ctx.lineWidth = 0.8;
1086
- ctx.setLineDash([3, 3]);
1087
- ctx.strokeRect(-boxW / 2, -boxH / 2, boxW, boxH);
1088
- ctx.setLineDash([]);
1444
+ ctx.fillStyle = bgFill;
1445
+ ctx.fillRect(
1446
+ -textW / 2 - TEXT_PAD,
1447
+ -totalH / 2 - TEXT_PAD,
1448
+ textW + TEXT_PAD * 2,
1449
+ totalH + TEXT_PAD * 2,
1450
+ );
1089
1451
  ctx.restore();
1090
- }
1091
1452
 
1092
- ctx.fillStyle = '#6b7280';
1093
- ctx.textAlign = 'center';
1094
- ctx.textBaseline = 'middle';
1095
- lines.forEach((line, i) => {
1096
- const y = -totalH / 2 + i * lineHeight + lineHeight / 2;
1097
- ctx.fillText(line, 0, y);
1098
- });
1453
+ // Dashed border box — only when the edge is selected
1454
+ if (st.selectedEdgeIds && st.selectedEdgeIds.includes(e.id)) {
1455
+ ctx.save();
1456
+ ctx.strokeStyle = "#9ca3af";
1457
+ ctx.lineWidth = 0.8;
1458
+ ctx.setLineDash([3, 3]);
1459
+ ctx.strokeRect(-boxW / 2, -boxH / 2, boxW, boxH);
1460
+ ctx.setLineDash([]);
1461
+ ctx.restore();
1462
+ }
1099
1463
 
1100
- ctx.restore();
1101
- });
1102
- } catch(err) { console.error('[draw] EXCEPTION:', err); }
1464
+ ctx.fillStyle = "#6b7280";
1465
+ ctx.textAlign = "center";
1466
+ ctx.textBaseline = "middle";
1467
+ lines.forEach((line, i) => {
1468
+ const y = -totalH / 2 + i * lineHeight + lineHeight / 2;
1469
+ ctx.fillText(line, 0, y);
1470
+ });
1471
+
1472
+ ctx.restore();
1473
+ });
1474
+ } catch (err) {
1475
+ console.error("[draw] EXCEPTION:", err);
1476
+ }
1103
1477
  }
1104
1478
 
1105
1479
  // ── Network event handlers ────────────────────────────────────────────────────
1106
1480
 
1107
1481
  // Distance from point (px,py) to segment (ax,ay)-(bx,by) in canvas space.
1108
1482
  function _distToSegment(px, py, ax, ay, bx, by) {
1109
- const dx = bx - ax, dy = by - ay;
1483
+ const dx = bx - ax,
1484
+ dy = by - ay;
1110
1485
  const lenSq = dx * dx + dy * dy;
1111
1486
  if (lenSq === 0) return Math.hypot(px - ax, py - ay);
1112
1487
  const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
1113
1488
  return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1114
1489
  }
1115
1490
 
1116
-
1117
1491
  function nodeContainsCanvasPoint(id, canvasPos) {
1118
- const n = st.nodes.get(id);
1492
+ const n = st.nodes.get(id);
1119
1493
  const bn = st.network && st.network.body.nodes[id];
1120
- if (!n || !bn || n.shapeType === 'anchor') return false;
1494
+ if (!n || !bn || n.shapeType === "anchor") return false;
1121
1495
 
1122
- const shapeType = n.shapeType || 'box';
1123
- const defaults = SHAPE_DEFAULTS[shapeType] || [60, 28];
1496
+ const shapeType = n.shapeType || "box";
1497
+ const defaults = SHAPE_DEFAULTS[shapeType] || [60, 28];
1124
1498
  const W = n.nodeWidth || defaults[0];
1125
- const H = shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
1499
+ const H = shapeType === "circle" ? W : n.nodeHeight || defaults[1];
1126
1500
 
1127
1501
  const dx = canvasPos.x - bn.x;
1128
1502
  const dy = canvasPos.y - bn.y;
@@ -1130,10 +1504,12 @@ function nodeContainsCanvasPoint(id, canvasPos) {
1130
1504
  const lx = rot ? dx * Math.cos(-rot) - dy * Math.sin(-rot) : dx;
1131
1505
  const ly = rot ? dx * Math.sin(-rot) + dy * Math.cos(-rot) : dy;
1132
1506
 
1133
- if (shapeType === 'circle' || shapeType === 'ellipse') {
1507
+ if (shapeType === "circle" || shapeType === "ellipse") {
1134
1508
  const rx = W / 2;
1135
1509
  const ry = H / 2;
1136
- return rx > 0 && ry > 0 && ((lx * lx) / (rx * rx) + (ly * ly) / (ry * ry)) <= 1;
1510
+ return (
1511
+ rx > 0 && ry > 0 && (lx * lx) / (rx * rx) + (ly * ly) / (ry * ry) <= 1
1512
+ );
1137
1513
  }
1138
1514
 
1139
1515
  return Math.abs(lx) <= W / 2 && Math.abs(ly) <= H / 2;
@@ -1161,15 +1537,15 @@ function topmostNodeAt(canvasPos) {
1161
1537
  function edgeDrawLevel(edgeData) {
1162
1538
  if (!edgeData) return -1;
1163
1539
  const fromLevel = st.canonicalOrder.indexOf(edgeData.from);
1164
- const toLevel = st.canonicalOrder.indexOf(edgeData.to);
1540
+ const toLevel = st.canonicalOrder.indexOf(edgeData.to);
1165
1541
  if (fromLevel === -1 && toLevel === -1) return -1;
1166
1542
  if (fromLevel === -1) return toLevel;
1167
1543
  if (toLevel === -1) return fromLevel;
1168
1544
 
1169
1545
  const fromNode = st.nodes.get(edgeData.from);
1170
- const toNode = st.nodes.get(edgeData.to);
1171
- const fromIsAnchor = fromNode && fromNode.shapeType === 'anchor';
1172
- const toIsAnchor = toNode && toNode.shapeType === 'anchor';
1546
+ const toNode = st.nodes.get(edgeData.to);
1547
+ const fromIsAnchor = fromNode && fromNode.shapeType === "anchor";
1548
+ const toIsAnchor = toNode && toNode.shapeType === "anchor";
1173
1549
  if (toIsAnchor && !fromIsAnchor) return fromLevel;
1174
1550
  if (fromIsAnchor && !toIsAnchor) return toLevel;
1175
1551
  return Math.max(fromLevel, toLevel);
@@ -1199,7 +1575,7 @@ function edgeLabelAtCanvasPoint(canvasPos) {
1199
1575
  const bbox = st.edgeLabelBBox[edge.id];
1200
1576
  if (!bbox) continue;
1201
1577
 
1202
- const r = -(bbox.rotation || 0);
1578
+ const r = -(bbox.rotation || 0);
1203
1579
  const dx = canvasPos.x - bbox.cx;
1204
1580
  const dy = canvasPos.y - bbox.cy;
1205
1581
  const lx = dx * Math.cos(r) - dy * Math.sin(r);
@@ -1218,24 +1594,28 @@ function edgeLabelAtCanvasPoint(canvasPos) {
1218
1594
  function isEdgeInteractive(edge) {
1219
1595
  if (!edge) return false;
1220
1596
  const fromN = st.nodes.get(edge.from);
1221
- const toN = st.nodes.get(edge.to);
1222
- const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1597
+ const toN = st.nodes.get(edge.to);
1598
+ const isFreeArrow =
1599
+ fromN && fromN.shapeType === "anchor" && toN && toN.shapeType === "anchor";
1223
1600
  return isFreeArrow ? !(fromN.locked && toN.locked) : !edge.edgeLocked;
1224
1601
  }
1225
1602
 
1226
1603
  function selectableEdgesForNodes(nodeIds) {
1227
1604
  const selectedSet = new Set(nodeIds);
1228
- return st.edges.get().filter((e) => {
1229
- if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1230
- return isEdgeInteractive(e);
1231
- }).map((e) => e.id);
1605
+ return st.edges
1606
+ .get()
1607
+ .filter((e) => {
1608
+ if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1609
+ return isEdgeInteractive(e);
1610
+ })
1611
+ .map((e) => e.id);
1232
1612
  }
1233
1613
 
1234
1614
  function selectNodesFromClick(nodeId, srcEvent) {
1235
1615
  const additive = !!(srcEvent && (srcEvent.metaKey || srcEvent.ctrlKey));
1236
1616
  const clicked = expandSelectionToGroup([nodeId]).filter((id) => {
1237
1617
  const n = st.nodes.get(id);
1238
- return n && !n.locked && n.shapeType !== 'anchor';
1618
+ return n && !n.locked && n.shapeType !== "anchor";
1239
1619
  });
1240
1620
  if (!clicked.length) return;
1241
1621
 
@@ -1249,7 +1629,7 @@ function selectNodesFromClick(nodeId, srcEvent) {
1249
1629
  });
1250
1630
  nodeIds = Array.from(next).filter((id) => {
1251
1631
  const n = st.nodes.get(id);
1252
- return n && !n.locked && n.shapeType !== 'anchor';
1632
+ return n && !n.locked && n.shapeType !== "anchor";
1253
1633
  });
1254
1634
  } else {
1255
1635
  nodeIds = clicked;
@@ -1273,11 +1653,14 @@ function selectNodesFromClick(nodeId, srcEvent) {
1273
1653
 
1274
1654
  function onDoubleClick(params) {
1275
1655
  const srcEvent = params.event && params.event.srcEvent;
1276
- const clientPos = srcEvent ? { x: srcEvent.clientX, y: srcEvent.clientY } : null;
1656
+ const clientPos = srcEvent
1657
+ ? { x: srcEvent.clientX, y: srcEvent.clientY }
1658
+ : null;
1277
1659
  const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1278
1660
  const topNodeId = topmostNodeAt(params.pointer.canvas);
1279
1661
  const topNode = topNodeId && st.nodes.get(topNodeId);
1280
- const canEditTopNode = topNode && !topNode.locked && topNode.shapeType !== 'anchor';
1662
+ const canEditTopNode =
1663
+ topNode && !topNode.locked && topNode.shapeType !== "anchor";
1281
1664
 
1282
1665
  // Direct node double-clicks must edit the node. Dense port-edge diagrams can
1283
1666
  // have many visual edges crossing a node; those should not steal the edit.
@@ -1294,12 +1677,20 @@ function onDoubleClick(params) {
1294
1677
 
1295
1678
  const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
1296
1679
  const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1297
- const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
1680
+ const edgeCandidates = [
1681
+ labelEdgeId,
1682
+ nativeEdgeId,
1683
+ portEdge && portEdge.id,
1684
+ ...params.edges,
1685
+ ]
1298
1686
  .filter((id, index, list) => id && list.indexOf(id) === index)
1299
1687
  .filter((id) => isEdgeInteractive(st.edges.get(id)));
1300
1688
  const edgeId = edgeCandidates.reduce((bestId, id) => {
1301
1689
  if (!bestId) return id;
1302
- return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
1690
+ return edgeDrawLevel(st.edges.get(id)) >=
1691
+ edgeDrawLevel(st.edges.get(bestId))
1692
+ ? id
1693
+ : bestId;
1303
1694
  }, null);
1304
1695
 
1305
1696
  if (edgeId) {
@@ -1309,34 +1700,60 @@ function onDoubleClick(params) {
1309
1700
  hideNodePanel();
1310
1701
  showEdgePanel();
1311
1702
  startEdgeLabelEdit();
1312
- } else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
1703
+ } else if (st.currentTool === "addNode" && st.pendingShape === "image") {
1313
1704
  const canvasPos = params.pointer.canvas;
1314
1705
  pickAndCreateImageNode(canvasPos.x, canvasPos.y);
1315
- } else if (st.currentTool === 'addNode') {
1706
+ } else if (st.currentTool === "addNode") {
1316
1707
  pushSnapshot();
1317
- const id = 'n' + Date.now();
1708
+ const id = "n" + Date.now();
1318
1709
  const customShapeId = customShapeIdFromTool(st.pendingShape);
1319
1710
  const shapeType = customShapeId ? CUSTOM_SHAPE_TYPE : st.pendingShape;
1320
- const customDef = customShapeId ? getCustomShapeDefinition(customShapeId) : null;
1321
- const defaults = customShapeId ? getCustomShapeDefaultSize(customShapeId) : SHAPE_DEFAULTS[shapeType] || [100, 40];
1322
- const fallbackColor = shapeType === 'post-it' ? 'c-amber' : 'c-gray';
1323
- const lastStyle = getLastNodeStyle(shapeType);
1324
- const colorKey = lastStyle.colorKey || fallbackColor;
1325
- const fontSize = lastStyle.fontSize || null;
1326
- const textAlign = lastStyle.textAlign || null;
1711
+ const customDef = customShapeId
1712
+ ? getCustomShapeDefinition(customShapeId)
1713
+ : null;
1714
+ const defaults = customShapeId
1715
+ ? getCustomShapeDefaultSize(customShapeId)
1716
+ : SHAPE_DEFAULTS[shapeType] || [100, 40];
1717
+ const fallbackColor = shapeType === "post-it" ? "c-amber" : "c-gray";
1718
+ const lastStyle = getLastNodeStyle(shapeType);
1719
+ const colorKey = lastStyle.colorKey || fallbackColor;
1720
+ const fontSize = lastStyle.fontSize || null;
1721
+ const textAlign = lastStyle.textAlign || null;
1327
1722
  const textValign = lastStyle.textValign || null;
1328
- const rawPos = params.pointer.canvas;
1329
- const pos = st.gridEnabled ? snapToGrid(rawPos.x, rawPos.y) : rawPos;
1723
+ const rawPos = params.pointer.canvas;
1724
+ const pos = st.gridEnabled ? snapToGrid(rawPos.x, rawPos.y) : rawPos;
1330
1725
  st.nodes.add({
1331
- id, label: shapeType === 'text-free' ? t('diagram.label_input.placeholder') : (customDef ? customDef.name : 'Node'),
1332
- shapeType, customShapeId: customShapeId || null, colorKey,
1333
- nodeWidth: defaults[0], nodeHeight: defaults[1],
1334
- fontSize, textAlign, textValign,
1335
- rotation: 0, labelRotation: 0,
1336
- x: pos.x, y: pos.y,
1337
- ...visNodeProps(shapeType, colorKey, defaults[0], defaults[1], fontSize, textAlign, textValign),
1726
+ id,
1727
+ label:
1728
+ shapeType === "text-free"
1729
+ ? t("diagram.label_input.placeholder")
1730
+ : customDef
1731
+ ? customDef.name
1732
+ : "Node",
1733
+ shapeType,
1734
+ customShapeId: customShapeId || null,
1735
+ colorKey,
1736
+ nodeWidth: defaults[0],
1737
+ nodeHeight: defaults[1],
1738
+ fontSize,
1739
+ textAlign,
1740
+ textValign,
1741
+ rotation: 0,
1742
+ labelRotation: 0,
1743
+ x: pos.x,
1744
+ y: pos.y,
1745
+ ...visNodeProps(
1746
+ shapeType,
1747
+ colorKey,
1748
+ defaults[0],
1749
+ defaults[1],
1750
+ fontSize,
1751
+ textAlign,
1752
+ textValign,
1753
+ ),
1338
1754
  });
1339
1755
  markDirty();
1756
+
1340
1757
  setTimeout(() => {
1341
1758
  st.network.selectNodes([id]);
1342
1759
  st.selectedNodeIds = [id];
@@ -1349,9 +1766,9 @@ function onDoubleClick(params) {
1349
1766
  // ── Image node creation ───────────────────────────────────────────────────────
1350
1767
 
1351
1768
  function pickAndCreateImageNode(canvasX, canvasY) {
1352
- const input = document.createElement('input');
1353
- input.type = 'file';
1354
- input.accept = 'image/*';
1769
+ const input = document.createElement("input");
1770
+ input.type = "file";
1771
+ input.accept = "image/*";
1355
1772
  input.onchange = async () => {
1356
1773
  const file = input.files && input.files[0];
1357
1774
  if (!file) return;
@@ -1361,7 +1778,7 @@ function pickAndCreateImageNode(canvasX, canvasY) {
1361
1778
  const src = await uploadImageFile(file, name);
1362
1779
  createImageNode(src, canvasX, canvasY);
1363
1780
  } catch {
1364
- showToast(t('diagram.toast.image_import_error'), 'error');
1781
+ showToast(t("diagram.toast.image_import_error"), "error");
1365
1782
  }
1366
1783
  };
1367
1784
  input.click();
@@ -1369,32 +1786,47 @@ function pickAndCreateImageNode(canvasX, canvasY) {
1369
1786
 
1370
1787
  export function createImageNode(imageSrc, canvasX, canvasY) {
1371
1788
  if (!st.network) return;
1372
- const id = 'n' + Date.now();
1373
- const captionId = id + 'c';
1789
+ const id = "n" + Date.now();
1790
+ const captionId = id + "c";
1374
1791
 
1375
1792
  const addNode = (nW, nH) => {
1376
1793
  pushSnapshot();
1377
- const filename = imageSrc.split('/').pop() || '';
1378
- const textDefs = SHAPE_DEFAULTS['text-free'];
1379
- const captionH = textDefs[1];
1380
- const GAP = 8;
1794
+ const filename = imageSrc.split("/").pop() || "";
1795
+ const textDefs = SHAPE_DEFAULTS["text-free"];
1796
+ const captionH = textDefs[1];
1797
+ const GAP = 8;
1381
1798
 
1382
- const groupId = 'g' + Date.now();
1799
+ const groupId = "g" + Date.now();
1383
1800
  st.nodes.add({
1384
- id, label: '', imageSrc, groupId,
1385
- shapeType: 'image', colorKey: 'c-gray',
1386
- nodeWidth: nW, nodeHeight: nH,
1387
- fontSize: null, rotation: 0, labelRotation: 0,
1388
- x: canvasX, y: canvasY,
1389
- ...visNodeProps('image', 'c-gray', nW, nH, null, null, null),
1801
+ id,
1802
+ label: "",
1803
+ imageSrc,
1804
+ groupId,
1805
+ shapeType: "image",
1806
+ colorKey: "c-gray",
1807
+ nodeWidth: nW,
1808
+ nodeHeight: nH,
1809
+ fontSize: null,
1810
+ rotation: 0,
1811
+ labelRotation: 0,
1812
+ x: canvasX,
1813
+ y: canvasY,
1814
+ ...visNodeProps("image", "c-gray", nW, nH, null, null, null),
1390
1815
  });
1391
1816
  st.nodes.add({
1392
- id: captionId, label: filename, groupId,
1393
- shapeType: 'text-free', colorKey: 'c-gray',
1394
- nodeWidth: nW, nodeHeight: captionH,
1395
- fontSize: null, rotation: 0, labelRotation: 0,
1396
- x: canvasX, y: canvasY + nH / 2 + GAP + captionH / 2,
1397
- ...visNodeProps('text-free', 'c-gray', nW, captionH, null, null, null),
1817
+ id: captionId,
1818
+ label: filename,
1819
+ groupId,
1820
+ shapeType: "text-free",
1821
+ colorKey: "c-gray",
1822
+ nodeWidth: nW,
1823
+ nodeHeight: captionH,
1824
+ fontSize: null,
1825
+ rotation: 0,
1826
+ labelRotation: 0,
1827
+ x: canvasX,
1828
+ y: canvasY + nH / 2 + GAP + captionH / 2,
1829
+ ...visNodeProps("text-free", "c-gray", nW, captionH, null, null, null),
1398
1830
  });
1399
1831
  markDirty();
1400
1832
  setTimeout(() => {
@@ -1408,12 +1840,16 @@ export function createImageNode(imageSrc, canvasX, canvasY) {
1408
1840
  img.onload = () => {
1409
1841
  const MAX = 300;
1410
1842
  const ratio = img.naturalWidth / img.naturalHeight;
1411
- let nW = img.naturalWidth, nH = img.naturalHeight;
1412
- if (nW > MAX) { nW = MAX; nH = Math.round(MAX / ratio); }
1843
+ let nW = img.naturalWidth,
1844
+ nH = img.naturalHeight;
1845
+ if (nW > MAX) {
1846
+ nW = MAX;
1847
+ nH = Math.round(MAX / ratio);
1848
+ }
1413
1849
  addNode(nW, nH);
1414
1850
  };
1415
1851
  img.onerror = () => {
1416
- const d = SHAPE_DEFAULTS['image'];
1852
+ const d = SHAPE_DEFAULTS["image"];
1417
1853
  addNode(d[0], d[1]);
1418
1854
  };
1419
1855
  img.src = imageSrc;
@@ -1424,17 +1860,20 @@ export function toggleEdgeStraight() {
1424
1860
  if (!st.network) return;
1425
1861
  pushSnapshot();
1426
1862
  st.edgesStraight = !st.edgesStraight;
1427
- const smooth = st.edgesStraight ? { enabled: false } : { type: 'continuous' };
1863
+ const smooth = st.edgesStraight ? { enabled: false } : { type: "continuous" };
1428
1864
  // Update global network option first (overrides per-edge inherited defaults).
1429
1865
  st.network.setOptions({ edges: { smooth } });
1430
1866
  // Then update each edge individually, keeping anchor edges always straight.
1431
1867
  const updates = st.edges.get().map((e) => {
1432
1868
  const toData = st.nodes.get(e.to);
1433
- const s = (toData && toData.shapeType === 'anchor') ? { enabled: false } : smooth;
1869
+ const s =
1870
+ toData && toData.shapeType === "anchor" ? { enabled: false } : smooth;
1434
1871
  return { id: e.id, smooth: s };
1435
1872
  });
1436
1873
  if (updates.length) st.edges.update(updates);
1437
- document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
1874
+ document
1875
+ .getElementById("btnEdgeStraight")
1876
+ .classList.toggle("tool-active", st.edgesStraight);
1438
1877
  markDirty();
1439
1878
  }
1440
1879
 
@@ -1449,15 +1888,24 @@ function onClickNode(params) {
1449
1888
  // endpoint nodes, reconnect that end to the new port without losing selection.
1450
1889
  if (_rehookEdgeId && _rehookHoveredNodeId && _rehookHoveredPortKey) {
1451
1890
  const edgeData = st.edges.get(_rehookEdgeId);
1452
- if (edgeData && (edgeData.from === _rehookHoveredNodeId || edgeData.to === _rehookHoveredNodeId)) {
1891
+ if (
1892
+ edgeData &&
1893
+ (edgeData.from === _rehookHoveredNodeId ||
1894
+ edgeData.to === _rehookHoveredNodeId)
1895
+ ) {
1453
1896
  const wasPortEdge = !!(edgeData.fromPort || edgeData.toPort);
1454
1897
  const update = { id: edgeData.id };
1455
- if (edgeData.from === _rehookHoveredNodeId) update.fromPort = _rehookHoveredPortKey;
1456
- else update.toPort = _rehookHoveredPortKey;
1898
+ if (edgeData.from === _rehookHoveredNodeId)
1899
+ update.fromPort = _rehookHoveredPortKey;
1900
+ else update.toPort = _rehookHoveredPortKey;
1457
1901
  // First port assigned on a native edge: hide vis-network's rendering so
1458
1902
  // drawPortEdge() takes over without double-rendering.
1459
1903
  if (!wasPortEdge) {
1460
- update.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
1904
+ update.color = {
1905
+ color: "rgba(0,0,0,0)",
1906
+ highlight: "rgba(0,0,0,0)",
1907
+ hover: "rgba(0,0,0,0)",
1908
+ };
1461
1909
  update.arrows = { to: { enabled: false }, from: { enabled: false } };
1462
1910
  }
1463
1911
  pushSnapshot();
@@ -1485,7 +1933,12 @@ function onClickNode(params) {
1485
1933
  const top = topmostNodeAt(params.pointer.canvas);
1486
1934
  if (top) {
1487
1935
  const topNode = st.nodes.get(top);
1488
- if (topNode && !topNode.locked && topNode.shapeType !== 'anchor' && !labelEdgeId) {
1936
+ if (
1937
+ topNode &&
1938
+ !topNode.locked &&
1939
+ topNode.shapeType !== "anchor" &&
1940
+ !labelEdgeId
1941
+ ) {
1489
1942
  setTimeout(() => {
1490
1943
  selectNodesFromClick(top, params.event.srcEvent);
1491
1944
  }, 0);
@@ -1493,18 +1946,28 @@ function onClickNode(params) {
1493
1946
  }
1494
1947
  }
1495
1948
 
1496
- const clientPos = { x: params.event.srcEvent.clientX, y: params.event.srcEvent.clientY };
1949
+ const clientPos = {
1950
+ x: params.event.srcEvent.clientX,
1951
+ y: params.event.srcEvent.clientY,
1952
+ };
1497
1953
  const nativeEdgeId = st.network.getEdgeAt(clientPos);
1498
1954
  const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1499
1955
  const edgeId = labelEdgeId || nativeEdgeId || (portEdge && portEdge.id);
1500
1956
  if (edgeId) {
1501
- const edge = st.edges.get(edgeId);
1957
+ const edge = st.edges.get(edgeId);
1502
1958
  const fromN = edge && st.nodes.get(edge.from);
1503
- const toN = edge && st.nodes.get(edge.to);
1504
- const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1959
+ const toN = edge && st.nodes.get(edge.to);
1960
+ const isFreeArrow =
1961
+ fromN &&
1962
+ fromN.shapeType === "anchor" &&
1963
+ toN &&
1964
+ toN.shapeType === "anchor";
1505
1965
  // If the user clicked directly on an anchor node (not the edge body), keep only
1506
1966
  // that single anchor selected so dragging pivots the arrow instead of moving it whole.
1507
- const clickedAnAnchor = params.nodes.some(id => { const n = st.nodes.get(id); return n && n.shapeType === 'anchor'; });
1967
+ const clickedAnAnchor = params.nodes.some((id) => {
1968
+ const n = st.nodes.get(id);
1969
+ return n && n.shapeType === "anchor";
1970
+ });
1508
1971
  if (isFreeArrow && clickedAnAnchor) return;
1509
1972
  setTimeout(() => {
1510
1973
  const sel = isFreeArrow
@@ -1523,9 +1986,9 @@ function onClickNode(params) {
1523
1986
  }
1524
1987
  // No edge at click position — apply z-order correction for the topmost node.
1525
1988
  const fallbackTop = topmostNodeAt(params.pointer.canvas);
1526
- const clickable = params.nodes.filter(id => {
1989
+ const clickable = params.nodes.filter((id) => {
1527
1990
  const n = st.nodes.get(id);
1528
- return n && !n.locked && n.shapeType !== 'anchor';
1991
+ return n && !n.locked && n.shapeType !== "anchor";
1529
1992
  });
1530
1993
  if (!fallbackTop || !clickable.includes(fallbackTop)) {
1531
1994
  const fallbackEdgeId = params.edges.length > 0 ? params.edges[0] : null;
@@ -1549,7 +2012,7 @@ function onClickNode(params) {
1549
2012
  // misses them when they diverge from the centre-to-centre line.
1550
2013
  // Also check the canvas for port-edge proximity when nothing was hit.
1551
2014
  if (params.nodes.length === 0 && params.edges.length === 0) {
1552
- const cp = params.pointer.canvas;
2015
+ const cp = params.pointer.canvas;
1553
2016
 
1554
2017
  // ── Port edge proximity check ──────────────────────────────────────────
1555
2018
  // vis-network's hit detection uses the invisible centre-to-centre ghost,
@@ -1578,7 +2041,7 @@ function onDragStart(params) {
1578
2041
  _draggingAnchorIds.clear();
1579
2042
  for (const id of params.nodes) {
1580
2043
  const n = st.nodes && st.nodes.get(id);
1581
- if (n && n.shapeType === 'anchor') _draggingAnchorIds.add(id);
2044
+ if (n && n.shapeType === "anchor") _draggingAnchorIds.add(id);
1582
2045
  }
1583
2046
  const expanded = expandSelectionToGroup(params.nodes);
1584
2047
  if (expanded.length > params.nodes.length) {
@@ -1592,17 +2055,32 @@ let _addingEdgesToSelection = false;
1592
2055
  function onSelectNode(params) {
1593
2056
  if (_expandingGroup || _addingEdgesToSelection) return;
1594
2057
  // Filter out anchor nodes — they have no formatting panel.
1595
- const nonAnchors = params.nodes.filter((id) => { const n = st.nodes.get(id); return !(n && n.shapeType === 'anchor'); });
2058
+ const nonAnchors = params.nodes.filter((id) => {
2059
+ const n = st.nodes.get(id);
2060
+ return !(n && n.shapeType === "anchor");
2061
+ });
1596
2062
  // Drop locked nodes: they are non-interactive until unlocked via long-press.
1597
- const usable = nonAnchors.filter((id) => { const n = st.nodes.get(id); return n && !n.locked; });
2063
+ const usable = nonAnchors.filter((id) => {
2064
+ const n = st.nodes.get(id);
2065
+ return n && !n.locked;
2066
+ });
1598
2067
  if (usable.length !== nonAnchors.length) {
1599
2068
  _addingEdgesToSelection = true;
1600
- const anchorIds = params.nodes.filter((id) => { const n = st.nodes.get(id); return n && n.shapeType === 'anchor'; });
1601
- st.network.setSelection({ nodes: [...usable, ...anchorIds], edges: st.network.getSelectedEdges() });
2069
+ const anchorIds = params.nodes.filter((id) => {
2070
+ const n = st.nodes.get(id);
2071
+ return n && n.shapeType === "anchor";
2072
+ });
2073
+ st.network.setSelection({
2074
+ nodes: [...usable, ...anchorIds],
2075
+ edges: st.network.getSelectedEdges(),
2076
+ });
1602
2077
  _addingEdgesToSelection = false;
1603
2078
  }
1604
2079
  if (!usable.length) {
1605
- const anchorIds = params.nodes.filter((id) => { const n = st.nodes.get(id); return n && n.shapeType === 'anchor'; });
2080
+ const anchorIds = params.nodes.filter((id) => {
2081
+ const n = st.nodes.get(id);
2082
+ return n && n.shapeType === "anchor";
2083
+ });
1606
2084
  if (anchorIds.length) {
1607
2085
  // Only anchors selected (individual endpoint drag) — keep selection but no panel.
1608
2086
  st.selectedNodeIds = anchorIds;
@@ -1616,7 +2094,10 @@ function onSelectNode(params) {
1616
2094
  hideEdgePanel();
1617
2095
  return;
1618
2096
  }
1619
- const expanded = expandSelectionToGroup(usable).filter((id) => { const n = st.nodes.get(id); return n && !n.locked; });
2097
+ const expanded = expandSelectionToGroup(usable).filter((id) => {
2098
+ const n = st.nodes.get(id);
2099
+ return n && !n.locked;
2100
+ });
1620
2101
  if (expanded.length > usable.length) {
1621
2102
  _expandingGroup = true;
1622
2103
  st.network.selectNodes(expanded);
@@ -1629,18 +2110,28 @@ function onSelectNode(params) {
1629
2110
  // This makes rubber-band select and multi-select automatically include connected edges.
1630
2111
  // Exclude locked edges — they are non-interactive.
1631
2112
  const selectedSet = new Set(st.selectedNodeIds);
1632
- st.selectedEdgeIds = st.edges.get().filter((e) => {
1633
- if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1634
- const fromN = st.nodes.get(e.from);
1635
- const toN = st.nodes.get(e.to);
1636
- const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1637
- return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
1638
- }).map((e) => e.id);
2113
+ st.selectedEdgeIds = st.edges
2114
+ .get()
2115
+ .filter((e) => {
2116
+ if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
2117
+ const fromN = st.nodes.get(e.from);
2118
+ const toN = st.nodes.get(e.to);
2119
+ const isFreeArrow =
2120
+ fromN &&
2121
+ fromN.shapeType === "anchor" &&
2122
+ toN &&
2123
+ toN.shapeType === "anchor";
2124
+ return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
2125
+ })
2126
+ .map((e) => e.id);
1639
2127
  // Tell vis-network to visually highlight the free-arrow edges.
1640
2128
  // Use a guard to prevent the resulting selectNode event from re-entering.
1641
2129
  if (st.selectedEdgeIds.length > 0) {
1642
2130
  _addingEdgesToSelection = true;
1643
- st.network.setSelection({ nodes: st.network.getSelectedNodes(), edges: st.selectedEdgeIds });
2131
+ st.network.setSelection({
2132
+ nodes: st.network.getSelectedNodes(),
2133
+ edges: st.selectedEdgeIds,
2134
+ });
1644
2135
  _addingEdgesToSelection = false;
1645
2136
  }
1646
2137
  hideEdgePanel();
@@ -1655,12 +2146,19 @@ function onSelectEdge(params) {
1655
2146
  const e = st.edges.get(id);
1656
2147
  if (!e) return false;
1657
2148
  const fromN = st.nodes.get(e.from);
1658
- const toN = st.nodes.get(e.to);
1659
- const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
2149
+ const toN = st.nodes.get(e.to);
2150
+ const isFreeArrow =
2151
+ fromN &&
2152
+ fromN.shapeType === "anchor" &&
2153
+ toN &&
2154
+ toN.shapeType === "anchor";
1660
2155
  return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
1661
2156
  });
1662
2157
  if (usable.length !== params.edges.length) {
1663
- st.network.setSelection({ nodes: st.network.getSelectedNodes(), edges: usable });
2158
+ st.network.setSelection({
2159
+ nodes: st.network.getSelectedNodes(),
2160
+ edges: usable,
2161
+ });
1664
2162
  }
1665
2163
  if (!usable.length) {
1666
2164
  st.selectedEdgeIds = [];
@@ -1680,8 +2178,13 @@ function onSelectEdge(params) {
1680
2178
  const e = st.edges.get(edgeId);
1681
2179
  if (!e) continue;
1682
2180
  const fromN = st.nodes.get(e.from);
1683
- const toN = st.nodes.get(e.to);
1684
- if (fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor') {
2181
+ const toN = st.nodes.get(e.to);
2182
+ if (
2183
+ fromN &&
2184
+ fromN.shapeType === "anchor" &&
2185
+ toN &&
2186
+ toN.shapeType === "anchor"
2187
+ ) {
1685
2188
  freeAnchors.push(e.from, e.to);
1686
2189
  }
1687
2190
  }