living-documentation 7.42.0 → 7.44.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,37 +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';
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";
27
52
 
28
53
  // Module-level port-hover state — shared between initNetwork event handlers and
29
54
  // module-level helpers (_onAnchorSnapConnect).
30
55
  let _hoveredPortNodeId = null;
31
- let _hoveredPortKey = null;
56
+ let _hoveredPortKey = null;
32
57
  let _draggingAnchorIds = new Set();
33
58
  // Rehook state: tracks which port is hovered while a port edge is selected.
34
- let _rehookEdgeId = null;
35
- let _rehookHoveredNodeId = null;
59
+ let _rehookEdgeId = null;
60
+ let _rehookHoveredNodeId = null;
36
61
  let _rehookHoveredPortKey = null;
37
62
  let _pointerDownSelection = { nodeIds: [], edgeIds: [] };
38
63
  let _edgeLabelPointerAbort = null;
@@ -42,99 +67,171 @@ let _edgeLabelPointerAbort = null;
42
67
  function isRehookable(edgeData) {
43
68
  if (!edgeData) return false;
44
69
  const fromNode = st.nodes.get(edgeData.from);
45
- const toNode = st.nodes.get(edgeData.to);
46
- 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
+ );
47
75
  }
48
76
 
49
77
  export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
50
- const container = document.getElementById('vis-canvas');
78
+ const container = document.getElementById("vis-canvas");
51
79
 
52
- const edgeSmooth = edgesStraight ? { enabled: false } : { type: 'continuous' };
80
+ const edgeSmooth = edgesStraight
81
+ ? { enabled: false }
82
+ : { type: "continuous" };
53
83
 
54
84
  st.nodes = new vis.DataSet(
55
85
  savedNodes.map((n) => {
56
- const shapeType = n.shapeType || n.renderAs || 'box';
86
+ const shapeType = n.shapeType || n.renderAs || "box";
57
87
  return {
58
88
  ...n,
59
89
  shapeType,
60
- ...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
+ ),
61
99
  ...(n.locked ? { fixed: { x: true, y: true }, draggable: false } : {}),
62
100
  };
63
- })
101
+ }),
64
102
  );
65
103
  st.edges = new vis.DataSet(
66
104
  savedEdges.map((e) => {
67
105
  const toNode = savedNodes.find((n) => n.id === e.to);
68
- const isAnchor = toNode && toNode.shapeType === 'anchor';
106
+ const isAnchor = toNode && toNode.shapeType === "anchor";
69
107
  const edgeObj = {
70
108
  ...e,
71
- ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
109
+ ...visEdgeProps(e.arrowDir ?? "to", e.dashes ?? false),
72
110
  smooth: isAnchor ? { enabled: false } : edgeSmooth,
73
- ...(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
+ : {}),
74
120
  ...(e.edgeWidth ? { width: e.edgeWidth } : {}),
75
121
  // Edge labels are always drawn by drawEdgeLabels() in afterDrawing
76
122
  // (gives us positioning + rotation control). Hide vis-network's native
77
123
  // label text entirely so it never appears alongside ours.
78
124
  ...(e.label
79
- ? { 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
+ }
80
132
  : e.fontSize
81
- ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } }
82
- : {}
83
- ),
133
+ ? { font: { size: e.fontSize, align: "middle", color: "#6b7280" } }
134
+ : {}),
84
135
  };
85
136
  // Port edges: hide vis-network's own rendering (line + arrowhead).
86
137
  // drawPortEdge() handles all visual output; vis-network edge is a
87
138
  // transparent ghost kept only for hit-detection (click selection).
88
139
  if (e.fromPort || e.toPort) {
89
- 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
+ };
90
145
  edgeObj.arrows = { to: { enabled: false }, from: { enabled: false } };
91
146
  }
92
147
  return edgeObj;
93
- })
148
+ }),
94
149
  );
95
150
 
96
151
  const options = {
97
152
  physics: { enabled: false },
98
- interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
99
- 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 } },
100
- 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
+ },
101
180
  manipulation: {
102
181
  enabled: false,
103
182
  addEdge(data, callback) {
104
183
  // Block edges from/to locked nodes or anchors (free-arrow endpoints).
105
- // Also block when the target sits below the source in z-order the
106
- // mouseup handler turns that case into a free-arrow endpoint instead.
184
+ // Also block when the source sits on top of a lower-z target that
185
+ // contains it: that target is acting as a background surface, and the
186
+ // mouseup handler turns the gesture into a free-arrow endpoint instead.
107
187
  const fromNode = st.nodes.get(data.from) || {};
108
- const toNode = st.nodes.get(data.to) || {};
109
- const fromZ = st.canonicalOrder.indexOf(data.from);
110
- const toZ = st.canonicalOrder.indexOf(data.to);
111
- const toBelow = fromZ !== -1 && toZ !== -1 && toZ < fromZ;
112
- if (fromNode.locked || toNode.locked || fromNode.shapeType === 'anchor' || toNode.shapeType === 'anchor' || toBelow) {
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,79 +984,99 @@ 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
763
1042
  // behaviour of free arrows drawn over a locked shape (Path B).
764
- // Also: if the target sits BELOW the source in z-order (e.g. a post-it on
765
- // top of a background image), don't bind to it — the user is aiming at a
766
- // point on top of the source's layer, not at the underlying shape.
1043
+ // Also: if the source sits on top of a lower-z target that contains it
1044
+ // (e.g. a post-it on a background image), don't bind to that background.
767
1045
  const targetLocked = !!(targetData && targetData.locked);
768
- const sourceZ = st.canonicalOrder.indexOf(_addEdgeFromId);
769
- const targetZ = targetId ? st.canonicalOrder.indexOf(targetId) : -1;
770
- const targetBelow = targetId && sourceZ !== -1 && targetZ !== -1 && targetZ < sourceZ;
771
- if (!targetId || targetLocked || targetBelow) {
1046
+ const targetContainsSource =
1047
+ targetId && lowerZTargetContainsSource(_addEdgeFromId, targetId);
1048
+ if (!targetId || targetLocked || targetContainsSource) {
772
1049
  pushSnapshot();
773
- const cp = st.network.DOMtoCanvas(pos);
774
- const anchorId = 'a' + Date.now();
1050
+ const cp = st.network.DOMtoCanvas(pos);
1051
+ const anchorId = "a" + Date.now();
775
1052
  st.nodes.add({
776
- id: anchorId, label: '', shapeType: 'anchor', colorKey: 'c-gray',
777
- nodeWidth: 8, nodeHeight: 8, fontSize: null, rotation: 0, labelRotation: 0,
778
- x: cp.x, y: cp.y,
779
- ...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),
780
1065
  });
781
1066
  st.edges.add({
782
- id: 'e' + Date.now(), from: _addEdgeFromId, to: anchorId,
783
- arrowDir: 'to', dashes: false,
1067
+ id: "e" + Date.now(),
1068
+ from: _addEdgeFromId,
1069
+ to: anchorId,
1070
+ arrowDir: "to",
1071
+ dashes: false,
784
1072
  smooth: { enabled: false },
785
- ...visEdgeProps('to', false),
1073
+ ...visEdgeProps("to", false),
786
1074
  });
787
1075
  markDirty();
788
1076
  st.freeArrowFirstPoint = null;
789
- setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
1077
+ setTimeout(() => {
1078
+ if (st.currentTool === "addEdge") st.network.addEdgeMode();
1079
+ }, 0);
790
1080
  }
791
1081
  _addEdgeFromId = null;
792
1082
  });
@@ -794,8 +1084,8 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
794
1084
  // Path B – two successive clicks on empty canvas → free-standing anchor→anchor arrow.
795
1085
  // vis-network's 'click' event (via Hammer.js) only fires for taps, never for drags,
796
1086
  // so Path A drag gestures cannot accidentally trigger Path B.
797
- st.network.on('click', (params) => {
798
- if (st.currentTool !== 'addEdge') return;
1087
+ st.network.on("click", (params) => {
1088
+ if (st.currentTool !== "addEdge") return;
799
1089
  // Only handle clicks that landed on empty canvas (no node, no edge).
800
1090
  if (params.nodes.length > 0 || params.edges.length > 0) return;
801
1091
  const cp = params.pointer.canvas;
@@ -806,26 +1096,71 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
806
1096
  } else {
807
1097
  // Second click: create the free-standing anchor→anchor arrow.
808
1098
  pushSnapshot();
809
- const t = Date.now();
810
- const fromId = 'a' + t;
811
- const toId = 'a' + (t + 1);
812
- 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
+ );
813
1111
  st.nodes.add([
814
- { 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 },
815
- { 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
+ },
816
1140
  ]);
817
- const edgeId = 'e' + t;
1141
+ const edgeId = "e" + t;
818
1142
  const lastStyle = getLastFreeArrowStyle();
819
- const arrowDir = lastStyle.arrowDir || 'to';
820
- const dashes = lastStyle.dashes || false;
1143
+ const arrowDir = lastStyle.arrowDir || "to";
1144
+ const dashes = lastStyle.dashes || false;
821
1145
  st.edges.add({
822
- id: edgeId, from: fromId, to: toId,
823
- arrowDir, dashes,
1146
+ id: edgeId,
1147
+ from: fromId,
1148
+ to: toId,
1149
+ arrowDir,
1150
+ dashes,
824
1151
  edgeColor: lastStyle.edgeColor || null,
825
1152
  edgeWidth: lastStyle.edgeWidth || null,
826
1153
  smooth: { enabled: false },
827
1154
  ...visEdgeProps(arrowDir, dashes),
828
- ...(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
+ : {}),
829
1164
  ...(lastStyle.edgeWidth ? { width: lastStyle.edgeWidth } : {}),
830
1165
  });
831
1166
  st.freeArrowFirstPoint = null;
@@ -840,18 +1175,18 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
840
1175
  }
841
1176
  });
842
1177
 
843
- document.getElementById('emptyState').classList.add('hidden');
1178
+ document.getElementById("emptyState").classList.add("hidden");
844
1179
  updateZoomDisplay();
845
1180
 
846
1181
  // vis-network initialises shape.width/height to 50 (not undefined), so
847
1182
  // needsRefresh() returns false and resize() never runs on the first render.
848
1183
  // Fix: reset shape.width/height to undefined so needsRefresh() returns true
849
1184
  // and vis-network's own render loop runs resize() with the correct context.
850
- st.network.once('afterDrawing', () => {
1185
+ st.network.once("afterDrawing", () => {
851
1186
  for (const id of st.network.body.nodeIndices) {
852
1187
  const bn = st.network.body.nodes[id];
853
1188
  if (!bn || !bn.shape) continue;
854
- bn.shape.width = undefined;
1189
+ bn.shape.width = undefined;
855
1190
  bn.shape.height = undefined;
856
1191
  }
857
1192
  // Patch CustomShape.distanceToBorder so anchor nodes report 0: arrow tips
@@ -867,7 +1202,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
867
1202
  const orig = proto.distanceToBorder;
868
1203
  proto.distanceToBorder = function (ctx, angle) {
869
1204
  const data = st.nodes && st.nodes.get(this.options && this.options.id);
870
- if (data && data.shapeType === 'anchor') return 0;
1205
+ if (data && data.shapeType === "anchor") return 0;
871
1206
  return orig.call(this, ctx, angle);
872
1207
  };
873
1208
  proto.__ldAnchorDistancePatched = true;
@@ -875,7 +1210,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
875
1210
  }
876
1211
  st.network.redraw();
877
1212
  });
878
- window.dispatchEvent(new CustomEvent('diagram:network-ready'));
1213
+ window.dispatchEvent(new CustomEvent("diagram:network-ready"));
879
1214
  }
880
1215
 
881
1216
  // ── Anchor snap-to-connect ────────────────────────────────────────────────────
@@ -891,10 +1226,12 @@ function _onAnchorSnapConnect(params) {
891
1226
 
892
1227
  for (const anchorId of params.nodes) {
893
1228
  const anchor = st.nodes.get(anchorId);
894
- if (!anchor || anchor.shapeType !== 'anchor') continue;
1229
+ if (!anchor || anchor.shapeType !== "anchor") continue;
895
1230
 
896
1231
  // Find the edge this anchor belongs to (as from or to)
897
- 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);
898
1235
  if (connectedEdges.length === 0) continue;
899
1236
 
900
1237
  // Current position of dragged anchor
@@ -910,10 +1247,12 @@ function _onAnchorSnapConnect(params) {
910
1247
  }
911
1248
  siblingIds.delete(anchorId); // keep sibling (other endpoint) in set to block self-loop
912
1249
 
913
- let bestId = null;
1250
+ let bestId = null;
914
1251
  let bestDist = Infinity;
915
1252
 
916
- for (const [candidateId, candidatePos] of Object.entries(st.network.getPositions())) {
1253
+ for (const [candidateId, candidatePos] of Object.entries(
1254
+ st.network.getPositions(),
1255
+ )) {
917
1256
  if (candidateId === anchorId) continue;
918
1257
  if (siblingIds.has(candidateId)) continue; // would create a self-loop
919
1258
 
@@ -922,26 +1261,33 @@ function _onAnchorSnapConnect(params) {
922
1261
 
923
1262
  const dist = Math.hypot(candidatePos.x - pos.x, candidatePos.y - pos.y);
924
1263
 
925
- if (candidate.shapeType === 'anchor') {
1264
+ if (candidate.shapeType === "anchor") {
926
1265
  // Snap to another anchor if within threshold
927
1266
  if (dist < SNAP_THRESHOLD && dist < bestDist) {
928
- bestId = candidateId;
1267
+ bestId = candidateId;
929
1268
  bestDist = dist;
930
1269
  }
931
1270
  } else {
932
1271
  // Snap to a regular node if anchor falls within its bounding box (padded by threshold)
933
1272
  const bodyNode = st.network.body.nodes[candidateId];
934
1273
  if (!bodyNode) continue;
935
- const w = (bodyNode.shape && bodyNode.shape.width) || SHAPE_DEFAULTS[candidate.shapeType]?.width || 120;
936
- 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;
937
1282
  const cx = candidatePos.x;
938
1283
  const cy = candidatePos.y;
939
- const inBox = pos.x >= cx - w / 2 - SNAP_THRESHOLD &&
940
- pos.x <= cx + w / 2 + SNAP_THRESHOLD &&
941
- pos.y >= cy - h / 2 - SNAP_THRESHOLD &&
942
- 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;
943
1289
  if (inBox && dist < bestDist) {
944
- bestId = candidateId;
1290
+ bestId = candidateId;
945
1291
  bestDist = dist;
946
1292
  }
947
1293
  }
@@ -949,17 +1295,21 @@ function _onAnchorSnapConnect(params) {
949
1295
 
950
1296
  if (!bestId) continue;
951
1297
 
952
- if (!snapped) { pushSnapshot(); snapped = true; }
1298
+ if (!snapped) {
1299
+ pushSnapshot();
1300
+ snapped = true;
1301
+ }
953
1302
 
954
1303
  // Determine which port to attach to on the target node (non-anchor targets only).
955
1304
  const targetNode = st.nodes.get(bestId);
956
- const isAnchorTarget = targetNode && targetNode.shapeType === 'anchor';
1305
+ const isAnchorTarget = targetNode && targetNode.shapeType === "anchor";
957
1306
  // Use the hovered port (computed by mousemove) when available; fall back to nearest port.
958
1307
  let portKey = null;
959
1308
  if (!isAnchorTarget) {
960
- portKey = (_hoveredPortNodeId === bestId && _hoveredPortKey)
961
- ? _hoveredPortKey
962
- : getNearestPort(bestId, pos);
1309
+ portKey =
1310
+ _hoveredPortNodeId === bestId && _hoveredPortKey
1311
+ ? _hoveredPortKey
1312
+ : getNearestPort(bestId, pos);
963
1313
  }
964
1314
 
965
1315
  // Reconnect every edge that uses this anchor as an endpoint
@@ -969,7 +1319,11 @@ function _onAnchorSnapConnect(params) {
969
1319
  if (portKey) update.fromPort = portKey;
970
1320
  // Port edges must be transparent ghosts so only drawPortEdge is visible.
971
1321
  if (portKey || e.toPort) {
972
- 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
+ };
973
1327
  update.arrows = { to: { enabled: false }, from: { enabled: false } };
974
1328
  }
975
1329
  st.edges.update(update);
@@ -979,7 +1333,11 @@ function _onAnchorSnapConnect(params) {
979
1333
  if (portKey) update.toPort = portKey;
980
1334
  // Port edges must be transparent ghosts so only drawPortEdge is visible.
981
1335
  if (portKey || e.fromPort) {
982
- 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
+ };
983
1341
  update.arrows = { to: { enabled: false }, from: { enabled: false } };
984
1342
  }
985
1343
  st.edges.update(update);
@@ -999,133 +1357,146 @@ function _onAnchorSnapConnect(params) {
999
1357
  // labels to appear twice at different positions.
1000
1358
  function drawEdgeLabels(ctx) {
1001
1359
  try {
1002
- if (!st.edges || !st.network) return;
1003
-
1004
- const m = ctx.getTransform();
1005
- const dpr = window.devicePixelRatio || 1;
1006
- const canvasEl = ctx.canvas;
1007
- const container = document.getElementById('vis-canvas').parentElement;
1008
- const canvasRect = canvasEl.getBoundingClientRect();
1009
- const containerRect = container.getBoundingClientRect();
1010
- const offsetX = canvasRect.left - containerRect.left;
1011
- const offsetY = canvasRect.top - containerRect.top;
1012
-
1013
- st.edges.get().forEach((e) => {
1014
- // Port edges draw their own labels inside drawPortEdge — skip here.
1015
- if (e.fromPort || e.toPort) return;
1016
-
1017
- // Compute bezier midpoint in layout space for every edge (labeled or not)
1018
- // so the label editor always has an accurate DOM position to open at.
1019
- const bodyEdge = st.network.body.edges[e.id];
1020
- let mx, my;
1021
- if (bodyEdge && bodyEdge.edgeType && typeof bodyEdge.edgeType.getPoint === 'function') {
1022
- const pt = bodyEdge.edgeType.getPoint(0.5);
1023
- mx = pt.x;
1024
- my = pt.y;
1025
- } else {
1026
- const positions = st.network.getPositions([e.from, e.to]);
1027
- const fp = positions[e.from];
1028
- const tp = positions[e.to];
1029
- if (!fp || !tp) return;
1030
- mx = (fp.x + tp.x) / 2;
1031
- my = (fp.y + tp.y) / 2;
1032
- }
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
+ }
1033
1395
 
1034
- const ox = e.edgeLabelOffsetX || 0;
1035
- const oy = e.edgeLabelOffsetY || 0;
1036
- const lx = mx + ox;
1037
- const ly = my + oy;
1396
+ const ox = e.edgeLabelOffsetX || 0;
1397
+ const oy = e.edgeLabelOffsetY || 0;
1398
+ const lx = mx + ox;
1399
+ const ly = my + oy;
1038
1400
 
1039
- st.edgeLabelCanvasPos[e.id] = {
1040
- x: (m.a * lx + m.e) / dpr + offsetX,
1041
- y: (m.d * ly + m.f) / dpr + offsetY,
1042
- };
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
- // Only draw the label text for edges that have one.
1045
- if (!e.label) return;
1406
+ // Only draw the label text for edges that have one.
1407
+ if (!e.label) return;
1046
1408
 
1047
- const fontSize = e.fontSize || 11;
1048
- const lineHeight = fontSize * 1.5;
1049
- const PAD_X = 6, PAD_Y = 4;
1409
+ const fontSize = e.fontSize || 11;
1410
+ const lineHeight = fontSize * 1.5;
1411
+ const PAD_X = 6,
1412
+ PAD_Y = 4;
1050
1413
 
1051
- ctx.save();
1052
- ctx.translate(lx, ly);
1053
- if (e.labelRotation && Math.abs(e.labelRotation) > 0.001) {
1054
- ctx.rotate(e.labelRotation);
1055
- }
1056
- ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
1057
-
1058
- const fixedW = e.edgeLabelWidth || null;
1059
- const innerW = fixedW ? fixedW - PAD_X * 2 : null;
1060
- const lines = fixedW ? wrapText(ctx, e.label, innerW) : [e.label];
1061
- const textW = fixedW ? innerW : ctx.measureText(e.label).width;
1062
- const boxW = textW + PAD_X * 2;
1063
- const boxH = lines.length * lineHeight + PAD_Y * 2;
1064
-
1065
- // Always store bbox (needed for resize handles, even when not selected)
1066
- st.edgeLabelBBox[e.id] = {
1067
- cx: lx, cy: ly,
1068
- w: boxW, h: boxH,
1069
- rotation: e.labelRotation || 0,
1070
- };
1071
-
1072
- const totalH = lines.length * lineHeight;
1073
- const TEXT_PAD = 3;
1074
- const isDark = document.documentElement.classList.contains('dark');
1075
- 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
+ };
1076
1436
 
1077
- // Opaque background tight around the text — makes the arrow appear to pass behind.
1078
- ctx.save();
1079
- ctx.fillStyle = bgFill;
1080
- ctx.fillRect(-textW / 2 - TEXT_PAD, -totalH / 2 - TEXT_PAD,
1081
- textW + TEXT_PAD * 2, totalH + TEXT_PAD * 2);
1082
- 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)";
1083
1441
 
1084
- // Dashed border boxonly when the edge is selected
1085
- if (st.selectedEdgeIds && st.selectedEdgeIds.includes(e.id)) {
1442
+ // Opaque background tight around the text makes the arrow appear to pass behind.
1086
1443
  ctx.save();
1087
- ctx.strokeStyle = '#9ca3af';
1088
- ctx.lineWidth = 0.8;
1089
- ctx.setLineDash([3, 3]);
1090
- ctx.strokeRect(-boxW / 2, -boxH / 2, boxW, boxH);
1091
- 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
+ );
1092
1451
  ctx.restore();
1093
- }
1094
1452
 
1095
- ctx.fillStyle = '#6b7280';
1096
- ctx.textAlign = 'center';
1097
- ctx.textBaseline = 'middle';
1098
- lines.forEach((line, i) => {
1099
- const y = -totalH / 2 + i * lineHeight + lineHeight / 2;
1100
- ctx.fillText(line, 0, y);
1101
- });
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
+ }
1102
1463
 
1103
- ctx.restore();
1104
- });
1105
- } 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
+ }
1106
1477
  }
1107
1478
 
1108
1479
  // ── Network event handlers ────────────────────────────────────────────────────
1109
1480
 
1110
1481
  // Distance from point (px,py) to segment (ax,ay)-(bx,by) in canvas space.
1111
1482
  function _distToSegment(px, py, ax, ay, bx, by) {
1112
- const dx = bx - ax, dy = by - ay;
1483
+ const dx = bx - ax,
1484
+ dy = by - ay;
1113
1485
  const lenSq = dx * dx + dy * dy;
1114
1486
  if (lenSq === 0) return Math.hypot(px - ax, py - ay);
1115
1487
  const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
1116
1488
  return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1117
1489
  }
1118
1490
 
1119
-
1120
1491
  function nodeContainsCanvasPoint(id, canvasPos) {
1121
- const n = st.nodes.get(id);
1492
+ const n = st.nodes.get(id);
1122
1493
  const bn = st.network && st.network.body.nodes[id];
1123
- if (!n || !bn || n.shapeType === 'anchor') return false;
1494
+ if (!n || !bn || n.shapeType === "anchor") return false;
1124
1495
 
1125
- const shapeType = n.shapeType || 'box';
1126
- const defaults = SHAPE_DEFAULTS[shapeType] || [60, 28];
1496
+ const shapeType = n.shapeType || "box";
1497
+ const defaults = SHAPE_DEFAULTS[shapeType] || [60, 28];
1127
1498
  const W = n.nodeWidth || defaults[0];
1128
- const H = shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
1499
+ const H = shapeType === "circle" ? W : n.nodeHeight || defaults[1];
1129
1500
 
1130
1501
  const dx = canvasPos.x - bn.x;
1131
1502
  const dy = canvasPos.y - bn.y;
@@ -1133,15 +1504,26 @@ function nodeContainsCanvasPoint(id, canvasPos) {
1133
1504
  const lx = rot ? dx * Math.cos(-rot) - dy * Math.sin(-rot) : dx;
1134
1505
  const ly = rot ? dx * Math.sin(-rot) + dy * Math.cos(-rot) : dy;
1135
1506
 
1136
- if (shapeType === 'circle' || shapeType === 'ellipse') {
1507
+ if (shapeType === "circle" || shapeType === "ellipse") {
1137
1508
  const rx = W / 2;
1138
1509
  const ry = H / 2;
1139
- 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
+ );
1140
1513
  }
1141
1514
 
1142
1515
  return Math.abs(lx) <= W / 2 && Math.abs(ly) <= H / 2;
1143
1516
  }
1144
1517
 
1518
+ function lowerZTargetContainsSource(sourceId, targetId) {
1519
+ if (!sourceId || !targetId) return false;
1520
+ const sourceZ = st.canonicalOrder.indexOf(sourceId);
1521
+ const targetZ = st.canonicalOrder.indexOf(targetId);
1522
+ if (sourceZ === -1 || targetZ === -1 || targetZ >= sourceZ) return false;
1523
+ const sourcePos = st.network && st.network.getPositions([sourceId])[sourceId];
1524
+ return !!(sourcePos && nodeContainsCanvasPoint(targetId, sourcePos));
1525
+ }
1526
+
1145
1527
  // Returns the topmost (highest z-order) node containing canvasPos.
1146
1528
  // Ignores anchor nodes and respects st.canonicalOrder.
1147
1529
  function topmostNodeAt(canvasPos) {
@@ -1155,15 +1537,15 @@ function topmostNodeAt(canvasPos) {
1155
1537
  function edgeDrawLevel(edgeData) {
1156
1538
  if (!edgeData) return -1;
1157
1539
  const fromLevel = st.canonicalOrder.indexOf(edgeData.from);
1158
- const toLevel = st.canonicalOrder.indexOf(edgeData.to);
1540
+ const toLevel = st.canonicalOrder.indexOf(edgeData.to);
1159
1541
  if (fromLevel === -1 && toLevel === -1) return -1;
1160
1542
  if (fromLevel === -1) return toLevel;
1161
1543
  if (toLevel === -1) return fromLevel;
1162
1544
 
1163
1545
  const fromNode = st.nodes.get(edgeData.from);
1164
- const toNode = st.nodes.get(edgeData.to);
1165
- const fromIsAnchor = fromNode && fromNode.shapeType === 'anchor';
1166
- 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";
1167
1549
  if (toIsAnchor && !fromIsAnchor) return fromLevel;
1168
1550
  if (fromIsAnchor && !toIsAnchor) return toLevel;
1169
1551
  return Math.max(fromLevel, toLevel);
@@ -1193,7 +1575,7 @@ function edgeLabelAtCanvasPoint(canvasPos) {
1193
1575
  const bbox = st.edgeLabelBBox[edge.id];
1194
1576
  if (!bbox) continue;
1195
1577
 
1196
- const r = -(bbox.rotation || 0);
1578
+ const r = -(bbox.rotation || 0);
1197
1579
  const dx = canvasPos.x - bbox.cx;
1198
1580
  const dy = canvasPos.y - bbox.cy;
1199
1581
  const lx = dx * Math.cos(r) - dy * Math.sin(r);
@@ -1212,24 +1594,28 @@ function edgeLabelAtCanvasPoint(canvasPos) {
1212
1594
  function isEdgeInteractive(edge) {
1213
1595
  if (!edge) return false;
1214
1596
  const fromN = st.nodes.get(edge.from);
1215
- const toN = st.nodes.get(edge.to);
1216
- 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";
1217
1600
  return isFreeArrow ? !(fromN.locked && toN.locked) : !edge.edgeLocked;
1218
1601
  }
1219
1602
 
1220
1603
  function selectableEdgesForNodes(nodeIds) {
1221
1604
  const selectedSet = new Set(nodeIds);
1222
- return st.edges.get().filter((e) => {
1223
- if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1224
- return isEdgeInteractive(e);
1225
- }).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);
1226
1612
  }
1227
1613
 
1228
1614
  function selectNodesFromClick(nodeId, srcEvent) {
1229
1615
  const additive = !!(srcEvent && (srcEvent.metaKey || srcEvent.ctrlKey));
1230
1616
  const clicked = expandSelectionToGroup([nodeId]).filter((id) => {
1231
1617
  const n = st.nodes.get(id);
1232
- return n && !n.locked && n.shapeType !== 'anchor';
1618
+ return n && !n.locked && n.shapeType !== "anchor";
1233
1619
  });
1234
1620
  if (!clicked.length) return;
1235
1621
 
@@ -1243,7 +1629,7 @@ function selectNodesFromClick(nodeId, srcEvent) {
1243
1629
  });
1244
1630
  nodeIds = Array.from(next).filter((id) => {
1245
1631
  const n = st.nodes.get(id);
1246
- return n && !n.locked && n.shapeType !== 'anchor';
1632
+ return n && !n.locked && n.shapeType !== "anchor";
1247
1633
  });
1248
1634
  } else {
1249
1635
  nodeIds = clicked;
@@ -1267,62 +1653,107 @@ function selectNodesFromClick(nodeId, srcEvent) {
1267
1653
 
1268
1654
  function onDoubleClick(params) {
1269
1655
  const srcEvent = params.event && params.event.srcEvent;
1270
- const clientPos = srcEvent ? { x: srcEvent.clientX, y: srcEvent.clientY } : null;
1656
+ const clientPos = srcEvent
1657
+ ? { x: srcEvent.clientX, y: srcEvent.clientY }
1658
+ : null;
1271
1659
  const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1272
- const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
1273
- const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1274
- const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
1275
- .filter((id, index, list) => id && list.indexOf(id) === index)
1276
- .filter((id) => isEdgeInteractive(st.edges.get(id)));
1277
- const edgeId = edgeCandidates.reduce((bestId, id) => {
1278
- if (!bestId) return id;
1279
- return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
1280
- }, null);
1281
-
1282
1660
  const topNodeId = topmostNodeAt(params.pointer.canvas);
1283
1661
  const topNode = topNodeId && st.nodes.get(topNodeId);
1284
- const canEditTopNode = topNode && !topNode.locked && topNode.shapeType !== 'anchor';
1285
- const topNodeWins = canEditTopNode && !labelEdgeId && (!edgeId || st.canonicalOrder.indexOf(topNodeId) >= edgeDrawLevel(st.edges.get(edgeId)));
1662
+ const canEditTopNode =
1663
+ topNode && !topNode.locked && topNode.shapeType !== "anchor";
1286
1664
 
1287
- if (topNodeWins) {
1665
+ // Direct node double-clicks must edit the node. Dense port-edge diagrams can
1666
+ // have many visual edges crossing a node; those should not steal the edit.
1667
+ // The only exception is an actual edge-label hit, which is intentional.
1668
+ if (canEditTopNode && !labelEdgeId) {
1288
1669
  st.selectedNodeIds = [topNodeId];
1289
1670
  st.selectedEdgeIds = [];
1290
1671
  st.network.setSelection({ nodes: st.selectedNodeIds, edges: [] });
1291
1672
  showNodePanel();
1292
1673
  hideEdgePanel();
1293
1674
  startLabelEdit();
1294
- } else if (edgeId) {
1675
+ return;
1676
+ }
1677
+
1678
+ const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
1679
+ const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1680
+ const edgeCandidates = [
1681
+ labelEdgeId,
1682
+ nativeEdgeId,
1683
+ portEdge && portEdge.id,
1684
+ ...params.edges,
1685
+ ]
1686
+ .filter((id, index, list) => id && list.indexOf(id) === index)
1687
+ .filter((id) => isEdgeInteractive(st.edges.get(id)));
1688
+ const edgeId = edgeCandidates.reduce((bestId, id) => {
1689
+ if (!bestId) return id;
1690
+ return edgeDrawLevel(st.edges.get(id)) >=
1691
+ edgeDrawLevel(st.edges.get(bestId))
1692
+ ? id
1693
+ : bestId;
1694
+ }, null);
1695
+
1696
+ if (edgeId) {
1295
1697
  st.selectedNodeIds = [];
1296
1698
  st.selectedEdgeIds = [edgeId];
1297
1699
  st.network.setSelection({ nodes: [], edges: [edgeId] });
1298
1700
  hideNodePanel();
1299
1701
  showEdgePanel();
1300
1702
  startEdgeLabelEdit();
1301
- } else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
1703
+ } else if (st.currentTool === "addNode" && st.pendingShape === "image") {
1302
1704
  const canvasPos = params.pointer.canvas;
1303
1705
  pickAndCreateImageNode(canvasPos.x, canvasPos.y);
1304
- } else if (st.currentTool === 'addNode') {
1706
+ } else if (st.currentTool === "addNode") {
1305
1707
  pushSnapshot();
1306
- const id = 'n' + Date.now();
1307
- const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
1308
- const fallbackColor = st.pendingShape === 'post-it' ? 'c-amber' : 'c-gray';
1309
- const lastStyle = getLastNodeStyle(st.pendingShape);
1310
- const colorKey = lastStyle.colorKey || fallbackColor;
1311
- const fontSize = lastStyle.fontSize || null;
1312
- const textAlign = lastStyle.textAlign || null;
1708
+ const id = "n" + Date.now();
1709
+ const customShapeId = customShapeIdFromTool(st.pendingShape);
1710
+ const shapeType = customShapeId ? CUSTOM_SHAPE_TYPE : st.pendingShape;
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;
1313
1722
  const textValign = lastStyle.textValign || null;
1314
- const rawPos = params.pointer.canvas;
1315
- 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;
1316
1725
  st.nodes.add({
1317
- id, label: st.pendingShape === 'text-free' ? t('diagram.label_input.placeholder') : 'Node',
1318
- shapeType: st.pendingShape, colorKey,
1319
- nodeWidth: defaults[0], nodeHeight: defaults[1],
1320
- fontSize, textAlign, textValign,
1321
- rotation: 0, labelRotation: 0,
1322
- x: pos.x, y: pos.y,
1323
- ...visNodeProps(st.pendingShape, 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
+ ),
1324
1754
  });
1325
1755
  markDirty();
1756
+
1326
1757
  setTimeout(() => {
1327
1758
  st.network.selectNodes([id]);
1328
1759
  st.selectedNodeIds = [id];
@@ -1335,9 +1766,9 @@ function onDoubleClick(params) {
1335
1766
  // ── Image node creation ───────────────────────────────────────────────────────
1336
1767
 
1337
1768
  function pickAndCreateImageNode(canvasX, canvasY) {
1338
- const input = document.createElement('input');
1339
- input.type = 'file';
1340
- input.accept = 'image/*';
1769
+ const input = document.createElement("input");
1770
+ input.type = "file";
1771
+ input.accept = "image/*";
1341
1772
  input.onchange = async () => {
1342
1773
  const file = input.files && input.files[0];
1343
1774
  if (!file) return;
@@ -1347,7 +1778,7 @@ function pickAndCreateImageNode(canvasX, canvasY) {
1347
1778
  const src = await uploadImageFile(file, name);
1348
1779
  createImageNode(src, canvasX, canvasY);
1349
1780
  } catch {
1350
- showToast(t('diagram.toast.image_import_error'), 'error');
1781
+ showToast(t("diagram.toast.image_import_error"), "error");
1351
1782
  }
1352
1783
  };
1353
1784
  input.click();
@@ -1355,32 +1786,47 @@ function pickAndCreateImageNode(canvasX, canvasY) {
1355
1786
 
1356
1787
  export function createImageNode(imageSrc, canvasX, canvasY) {
1357
1788
  if (!st.network) return;
1358
- const id = 'n' + Date.now();
1359
- const captionId = id + 'c';
1789
+ const id = "n" + Date.now();
1790
+ const captionId = id + "c";
1360
1791
 
1361
1792
  const addNode = (nW, nH) => {
1362
1793
  pushSnapshot();
1363
- const filename = imageSrc.split('/').pop() || '';
1364
- const textDefs = SHAPE_DEFAULTS['text-free'];
1365
- const captionH = textDefs[1];
1366
- 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;
1367
1798
 
1368
- const groupId = 'g' + Date.now();
1799
+ const groupId = "g" + Date.now();
1369
1800
  st.nodes.add({
1370
- id, label: '', imageSrc, groupId,
1371
- shapeType: 'image', colorKey: 'c-gray',
1372
- nodeWidth: nW, nodeHeight: nH,
1373
- fontSize: null, rotation: 0, labelRotation: 0,
1374
- x: canvasX, y: canvasY,
1375
- ...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),
1376
1815
  });
1377
1816
  st.nodes.add({
1378
- id: captionId, label: filename, groupId,
1379
- shapeType: 'text-free', colorKey: 'c-gray',
1380
- nodeWidth: nW, nodeHeight: captionH,
1381
- fontSize: null, rotation: 0, labelRotation: 0,
1382
- x: canvasX, y: canvasY + nH / 2 + GAP + captionH / 2,
1383
- ...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),
1384
1830
  });
1385
1831
  markDirty();
1386
1832
  setTimeout(() => {
@@ -1394,12 +1840,16 @@ export function createImageNode(imageSrc, canvasX, canvasY) {
1394
1840
  img.onload = () => {
1395
1841
  const MAX = 300;
1396
1842
  const ratio = img.naturalWidth / img.naturalHeight;
1397
- let nW = img.naturalWidth, nH = img.naturalHeight;
1398
- 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
+ }
1399
1849
  addNode(nW, nH);
1400
1850
  };
1401
1851
  img.onerror = () => {
1402
- const d = SHAPE_DEFAULTS['image'];
1852
+ const d = SHAPE_DEFAULTS["image"];
1403
1853
  addNode(d[0], d[1]);
1404
1854
  };
1405
1855
  img.src = imageSrc;
@@ -1410,17 +1860,20 @@ export function toggleEdgeStraight() {
1410
1860
  if (!st.network) return;
1411
1861
  pushSnapshot();
1412
1862
  st.edgesStraight = !st.edgesStraight;
1413
- const smooth = st.edgesStraight ? { enabled: false } : { type: 'continuous' };
1863
+ const smooth = st.edgesStraight ? { enabled: false } : { type: "continuous" };
1414
1864
  // Update global network option first (overrides per-edge inherited defaults).
1415
1865
  st.network.setOptions({ edges: { smooth } });
1416
1866
  // Then update each edge individually, keeping anchor edges always straight.
1417
1867
  const updates = st.edges.get().map((e) => {
1418
1868
  const toData = st.nodes.get(e.to);
1419
- const s = (toData && toData.shapeType === 'anchor') ? { enabled: false } : smooth;
1869
+ const s =
1870
+ toData && toData.shapeType === "anchor" ? { enabled: false } : smooth;
1420
1871
  return { id: e.id, smooth: s };
1421
1872
  });
1422
1873
  if (updates.length) st.edges.update(updates);
1423
- document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
1874
+ document
1875
+ .getElementById("btnEdgeStraight")
1876
+ .classList.toggle("tool-active", st.edgesStraight);
1424
1877
  markDirty();
1425
1878
  }
1426
1879
 
@@ -1435,15 +1888,24 @@ function onClickNode(params) {
1435
1888
  // endpoint nodes, reconnect that end to the new port without losing selection.
1436
1889
  if (_rehookEdgeId && _rehookHoveredNodeId && _rehookHoveredPortKey) {
1437
1890
  const edgeData = st.edges.get(_rehookEdgeId);
1438
- if (edgeData && (edgeData.from === _rehookHoveredNodeId || edgeData.to === _rehookHoveredNodeId)) {
1891
+ if (
1892
+ edgeData &&
1893
+ (edgeData.from === _rehookHoveredNodeId ||
1894
+ edgeData.to === _rehookHoveredNodeId)
1895
+ ) {
1439
1896
  const wasPortEdge = !!(edgeData.fromPort || edgeData.toPort);
1440
1897
  const update = { id: edgeData.id };
1441
- if (edgeData.from === _rehookHoveredNodeId) update.fromPort = _rehookHoveredPortKey;
1442
- else update.toPort = _rehookHoveredPortKey;
1898
+ if (edgeData.from === _rehookHoveredNodeId)
1899
+ update.fromPort = _rehookHoveredPortKey;
1900
+ else update.toPort = _rehookHoveredPortKey;
1443
1901
  // First port assigned on a native edge: hide vis-network's rendering so
1444
1902
  // drawPortEdge() takes over without double-rendering.
1445
1903
  if (!wasPortEdge) {
1446
- 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
+ };
1447
1909
  update.arrows = { to: { enabled: false }, from: { enabled: false } };
1448
1910
  }
1449
1911
  pushSnapshot();
@@ -1467,36 +1929,45 @@ function onClickNode(params) {
1467
1929
  // passing it to getEdgeAt() causes a double-subtraction of the container rect and
1468
1930
  // returns null. Passing clientX/Y lets vis-network do its own pixel-perfect detection.
1469
1931
  if (params.nodes.length > 0) {
1470
- const clientPos = { x: params.event.srcEvent.clientX, y: params.event.srcEvent.clientY };
1471
- const nativeEdgeId = st.network.getEdgeAt(clientPos);
1472
- const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1473
- const edgeId = nativeEdgeId || (portEdge && portEdge.id);
1474
-
1475
- // First honour the app-managed z-order. vis-network may report an edge or a
1476
- // lower node at the click point; compare draw levels so the visibly top
1477
- // element gets the click.
1932
+ const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1478
1933
  const top = topmostNodeAt(params.pointer.canvas);
1479
1934
  if (top) {
1480
1935
  const topNode = st.nodes.get(top);
1481
- const topLevel = st.canonicalOrder.indexOf(top);
1482
- const edgeLevel = edgeId ? edgeDrawLevel(st.edges.get(edgeId)) : -1;
1483
- if (topNode && !topNode.locked && topNode.shapeType !== 'anchor') {
1484
- if (!edgeId || topLevel >= edgeLevel) {
1485
- setTimeout(() => {
1486
- selectNodesFromClick(top, params.event.srcEvent);
1487
- }, 0);
1488
- return;
1489
- }
1936
+ if (
1937
+ topNode &&
1938
+ !topNode.locked &&
1939
+ topNode.shapeType !== "anchor" &&
1940
+ !labelEdgeId
1941
+ ) {
1942
+ setTimeout(() => {
1943
+ selectNodesFromClick(top, params.event.srcEvent);
1944
+ }, 0);
1945
+ return;
1490
1946
  }
1491
1947
  }
1948
+
1949
+ const clientPos = {
1950
+ x: params.event.srcEvent.clientX,
1951
+ y: params.event.srcEvent.clientY,
1952
+ };
1953
+ const nativeEdgeId = st.network.getEdgeAt(clientPos);
1954
+ const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1955
+ const edgeId = labelEdgeId || nativeEdgeId || (portEdge && portEdge.id);
1492
1956
  if (edgeId) {
1493
- const edge = st.edges.get(edgeId);
1957
+ const edge = st.edges.get(edgeId);
1494
1958
  const fromN = edge && st.nodes.get(edge.from);
1495
- const toN = edge && st.nodes.get(edge.to);
1496
- 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";
1497
1965
  // If the user clicked directly on an anchor node (not the edge body), keep only
1498
1966
  // that single anchor selected so dragging pivots the arrow instead of moving it whole.
1499
- 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
+ });
1500
1971
  if (isFreeArrow && clickedAnAnchor) return;
1501
1972
  setTimeout(() => {
1502
1973
  const sel = isFreeArrow
@@ -1515,9 +1986,9 @@ function onClickNode(params) {
1515
1986
  }
1516
1987
  // No edge at click position — apply z-order correction for the topmost node.
1517
1988
  const fallbackTop = topmostNodeAt(params.pointer.canvas);
1518
- const clickable = params.nodes.filter(id => {
1989
+ const clickable = params.nodes.filter((id) => {
1519
1990
  const n = st.nodes.get(id);
1520
- return n && !n.locked && n.shapeType !== 'anchor';
1991
+ return n && !n.locked && n.shapeType !== "anchor";
1521
1992
  });
1522
1993
  if (!fallbackTop || !clickable.includes(fallbackTop)) {
1523
1994
  const fallbackEdgeId = params.edges.length > 0 ? params.edges[0] : null;
@@ -1541,7 +2012,7 @@ function onClickNode(params) {
1541
2012
  // misses them when they diverge from the centre-to-centre line.
1542
2013
  // Also check the canvas for port-edge proximity when nothing was hit.
1543
2014
  if (params.nodes.length === 0 && params.edges.length === 0) {
1544
- const cp = params.pointer.canvas;
2015
+ const cp = params.pointer.canvas;
1545
2016
 
1546
2017
  // ── Port edge proximity check ──────────────────────────────────────────
1547
2018
  // vis-network's hit detection uses the invisible centre-to-centre ghost,
@@ -1570,7 +2041,7 @@ function onDragStart(params) {
1570
2041
  _draggingAnchorIds.clear();
1571
2042
  for (const id of params.nodes) {
1572
2043
  const n = st.nodes && st.nodes.get(id);
1573
- if (n && n.shapeType === 'anchor') _draggingAnchorIds.add(id);
2044
+ if (n && n.shapeType === "anchor") _draggingAnchorIds.add(id);
1574
2045
  }
1575
2046
  const expanded = expandSelectionToGroup(params.nodes);
1576
2047
  if (expanded.length > params.nodes.length) {
@@ -1584,17 +2055,32 @@ let _addingEdgesToSelection = false;
1584
2055
  function onSelectNode(params) {
1585
2056
  if (_expandingGroup || _addingEdgesToSelection) return;
1586
2057
  // Filter out anchor nodes — they have no formatting panel.
1587
- 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
+ });
1588
2062
  // Drop locked nodes: they are non-interactive until unlocked via long-press.
1589
- 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
+ });
1590
2067
  if (usable.length !== nonAnchors.length) {
1591
2068
  _addingEdgesToSelection = true;
1592
- const anchorIds = params.nodes.filter((id) => { const n = st.nodes.get(id); return n && n.shapeType === 'anchor'; });
1593
- 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
+ });
1594
2077
  _addingEdgesToSelection = false;
1595
2078
  }
1596
2079
  if (!usable.length) {
1597
- 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
+ });
1598
2084
  if (anchorIds.length) {
1599
2085
  // Only anchors selected (individual endpoint drag) — keep selection but no panel.
1600
2086
  st.selectedNodeIds = anchorIds;
@@ -1608,7 +2094,10 @@ function onSelectNode(params) {
1608
2094
  hideEdgePanel();
1609
2095
  return;
1610
2096
  }
1611
- 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
+ });
1612
2101
  if (expanded.length > usable.length) {
1613
2102
  _expandingGroup = true;
1614
2103
  st.network.selectNodes(expanded);
@@ -1621,18 +2110,28 @@ function onSelectNode(params) {
1621
2110
  // This makes rubber-band select and multi-select automatically include connected edges.
1622
2111
  // Exclude locked edges — they are non-interactive.
1623
2112
  const selectedSet = new Set(st.selectedNodeIds);
1624
- st.selectedEdgeIds = st.edges.get().filter((e) => {
1625
- if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1626
- const fromN = st.nodes.get(e.from);
1627
- const toN = st.nodes.get(e.to);
1628
- const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1629
- return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
1630
- }).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);
1631
2127
  // Tell vis-network to visually highlight the free-arrow edges.
1632
2128
  // Use a guard to prevent the resulting selectNode event from re-entering.
1633
2129
  if (st.selectedEdgeIds.length > 0) {
1634
2130
  _addingEdgesToSelection = true;
1635
- st.network.setSelection({ nodes: st.network.getSelectedNodes(), edges: st.selectedEdgeIds });
2131
+ st.network.setSelection({
2132
+ nodes: st.network.getSelectedNodes(),
2133
+ edges: st.selectedEdgeIds,
2134
+ });
1636
2135
  _addingEdgesToSelection = false;
1637
2136
  }
1638
2137
  hideEdgePanel();
@@ -1647,12 +2146,19 @@ function onSelectEdge(params) {
1647
2146
  const e = st.edges.get(id);
1648
2147
  if (!e) return false;
1649
2148
  const fromN = st.nodes.get(e.from);
1650
- const toN = st.nodes.get(e.to);
1651
- 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";
1652
2155
  return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
1653
2156
  });
1654
2157
  if (usable.length !== params.edges.length) {
1655
- st.network.setSelection({ nodes: st.network.getSelectedNodes(), edges: usable });
2158
+ st.network.setSelection({
2159
+ nodes: st.network.getSelectedNodes(),
2160
+ edges: usable,
2161
+ });
1656
2162
  }
1657
2163
  if (!usable.length) {
1658
2164
  st.selectedEdgeIds = [];
@@ -1672,8 +2178,13 @@ function onSelectEdge(params) {
1672
2178
  const e = st.edges.get(edgeId);
1673
2179
  if (!e) continue;
1674
2180
  const fromN = st.nodes.get(e.from);
1675
- const toN = st.nodes.get(e.to);
1676
- 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
+ ) {
1677
2188
  freeAnchors.push(e.from, e.to);
1678
2189
  }
1679
2190
  }