living-ai-documentation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/LICENSE +661 -0
  2. package/README.fr.md +344 -0
  3. package/README.md +344 -0
  4. package/dist/bin/cli.d.ts +3 -0
  5. package/dist/bin/cli.d.ts.map +1 -0
  6. package/dist/bin/cli.js +262 -0
  7. package/dist/bin/cli.js.map +1 -0
  8. package/dist/src/frontend/accuracy-gauge.js +70 -0
  9. package/dist/src/frontend/admin.html +1532 -0
  10. package/dist/src/frontend/annotations.js +585 -0
  11. package/dist/src/frontend/boot.js +101 -0
  12. package/dist/src/frontend/config.js +29 -0
  13. package/dist/src/frontend/confirm-modal.js +82 -0
  14. package/dist/src/frontend/context.html +1252 -0
  15. package/dist/src/frontend/dark-mode.js +20 -0
  16. package/dist/src/frontend/diagram/alignment.js +161 -0
  17. package/dist/src/frontend/diagram/clipboard.js +187 -0
  18. package/dist/src/frontend/diagram/constants.js +109 -0
  19. package/dist/src/frontend/diagram/custom-shapes.js +104 -0
  20. package/dist/src/frontend/diagram/debug.js +43 -0
  21. package/dist/src/frontend/diagram/drawio-export.js +649 -0
  22. package/dist/src/frontend/diagram/edge-panel.js +293 -0
  23. package/dist/src/frontend/diagram/edge-rendering.js +12 -0
  24. package/dist/src/frontend/diagram/evidence.js +146 -0
  25. package/dist/src/frontend/diagram/grid.js +78 -0
  26. package/dist/src/frontend/diagram/groups.js +102 -0
  27. package/dist/src/frontend/diagram/history.js +157 -0
  28. package/dist/src/frontend/diagram/image-name-modal.js +48 -0
  29. package/dist/src/frontend/diagram/image-upload.js +36 -0
  30. package/dist/src/frontend/diagram/label-editor.js +115 -0
  31. package/dist/src/frontend/diagram/link-panel.js +144 -0
  32. package/dist/src/frontend/diagram/main.js +364 -0
  33. package/dist/src/frontend/diagram/network.js +2214 -0
  34. package/dist/src/frontend/diagram/node-panel.js +389 -0
  35. package/dist/src/frontend/diagram/node-rendering.js +964 -0
  36. package/dist/src/frontend/diagram/persistence.js +168 -0
  37. package/dist/src/frontend/diagram/ports.js +421 -0
  38. package/dist/src/frontend/diagram/selection-overlay.js +387 -0
  39. package/dist/src/frontend/diagram/state.js +43 -0
  40. package/dist/src/frontend/diagram/t.js +3 -0
  41. package/dist/src/frontend/diagram/toast.js +21 -0
  42. package/dist/src/frontend/diagram/unlock-hold.js +206 -0
  43. package/dist/src/frontend/diagram/zoom.js +20 -0
  44. package/dist/src/frontend/diagram-link-modal.js +137 -0
  45. package/dist/src/frontend/diagram.html +1494 -0
  46. package/dist/src/frontend/documents.js +479 -0
  47. package/dist/src/frontend/export.js +338 -0
  48. package/dist/src/frontend/file-attach.js +178 -0
  49. package/dist/src/frontend/files-modal.js +243 -0
  50. package/dist/src/frontend/i18n/en.json +624 -0
  51. package/dist/src/frontend/i18n/fr.json +624 -0
  52. package/dist/src/frontend/i18n.js +32 -0
  53. package/dist/src/frontend/image-paste.js +126 -0
  54. package/dist/src/frontend/index.html +2806 -0
  55. package/dist/src/frontend/local-search.js +476 -0
  56. package/dist/src/frontend/metadata.js +318 -0
  57. package/dist/src/frontend/misc.js +92 -0
  58. package/dist/src/frontend/new-doc-modal.js +285 -0
  59. package/dist/src/frontend/new-folder-modal.js +169 -0
  60. package/dist/src/frontend/search.js +194 -0
  61. package/dist/src/frontend/shape-editor.html +685 -0
  62. package/dist/src/frontend/sidebar-helpers.js +96 -0
  63. package/dist/src/frontend/sidebar-resize.js +98 -0
  64. package/dist/src/frontend/sidebar.js +351 -0
  65. package/dist/src/frontend/snippet-detect.js +25 -0
  66. package/dist/src/frontend/snippet-table.js +85 -0
  67. package/dist/src/frontend/snippet-tree.js +94 -0
  68. package/dist/src/frontend/snippets.js +1146 -0
  69. package/dist/src/frontend/state.js +46 -0
  70. package/dist/src/frontend/utils.js +21 -0
  71. package/dist/src/frontend/validate.js +107 -0
  72. package/dist/src/frontend/vendor/wordcloud2.js +1187 -0
  73. package/dist/src/frontend/wordcloud.js +693 -0
  74. package/dist/src/lib/config.d.ts +26 -0
  75. package/dist/src/lib/config.d.ts.map +1 -0
  76. package/dist/src/lib/config.js +195 -0
  77. package/dist/src/lib/config.js.map +1 -0
  78. package/dist/src/lib/hash.d.ts +2 -0
  79. package/dist/src/lib/hash.d.ts.map +1 -0
  80. package/dist/src/lib/hash.js +18 -0
  81. package/dist/src/lib/hash.js.map +1 -0
  82. package/dist/src/lib/metadata.d.ts +31 -0
  83. package/dist/src/lib/metadata.d.ts.map +1 -0
  84. package/dist/src/lib/metadata.js +128 -0
  85. package/dist/src/lib/metadata.js.map +1 -0
  86. package/dist/src/lib/parser.d.ts +11 -0
  87. package/dist/src/lib/parser.d.ts.map +1 -0
  88. package/dist/src/lib/parser.js +111 -0
  89. package/dist/src/lib/parser.js.map +1 -0
  90. package/dist/src/lib/status.d.ts +9 -0
  91. package/dist/src/lib/status.d.ts.map +1 -0
  92. package/dist/src/lib/status.js +72 -0
  93. package/dist/src/lib/status.js.map +1 -0
  94. package/dist/src/mcp/server.d.ts +3 -0
  95. package/dist/src/mcp/server.d.ts.map +1 -0
  96. package/dist/src/mcp/server.js +2046 -0
  97. package/dist/src/mcp/server.js.map +1 -0
  98. package/dist/src/mcp/tools/diagrams.d.ts +82 -0
  99. package/dist/src/mcp/tools/diagrams.d.ts.map +1 -0
  100. package/dist/src/mcp/tools/diagrams.js +594 -0
  101. package/dist/src/mcp/tools/diagrams.js.map +1 -0
  102. package/dist/src/mcp/tools/documents.d.ts +44 -0
  103. package/dist/src/mcp/tools/documents.d.ts.map +1 -0
  104. package/dist/src/mcp/tools/documents.js +186 -0
  105. package/dist/src/mcp/tools/documents.js.map +1 -0
  106. package/dist/src/mcp/tools/git.d.ts +10 -0
  107. package/dist/src/mcp/tools/git.d.ts.map +1 -0
  108. package/dist/src/mcp/tools/git.js +217 -0
  109. package/dist/src/mcp/tools/git.js.map +1 -0
  110. package/dist/src/mcp/tools/metadata.d.ts +57 -0
  111. package/dist/src/mcp/tools/metadata.d.ts.map +1 -0
  112. package/dist/src/mcp/tools/metadata.js +222 -0
  113. package/dist/src/mcp/tools/metadata.js.map +1 -0
  114. package/dist/src/mcp/tools/source.d.ts +29 -0
  115. package/dist/src/mcp/tools/source.d.ts.map +1 -0
  116. package/dist/src/mcp/tools/source.js +196 -0
  117. package/dist/src/mcp/tools/source.js.map +1 -0
  118. package/dist/src/routes/annotations.d.ts +3 -0
  119. package/dist/src/routes/annotations.d.ts.map +1 -0
  120. package/dist/src/routes/annotations.js +83 -0
  121. package/dist/src/routes/annotations.js.map +1 -0
  122. package/dist/src/routes/browse-source.d.ts +3 -0
  123. package/dist/src/routes/browse-source.d.ts.map +1 -0
  124. package/dist/src/routes/browse-source.js +79 -0
  125. package/dist/src/routes/browse-source.js.map +1 -0
  126. package/dist/src/routes/browse.d.ts +3 -0
  127. package/dist/src/routes/browse.d.ts.map +1 -0
  128. package/dist/src/routes/browse.js +91 -0
  129. package/dist/src/routes/browse.js.map +1 -0
  130. package/dist/src/routes/config.d.ts +3 -0
  131. package/dist/src/routes/config.d.ts.map +1 -0
  132. package/dist/src/routes/config.js +145 -0
  133. package/dist/src/routes/config.js.map +1 -0
  134. package/dist/src/routes/context.d.ts +3 -0
  135. package/dist/src/routes/context.d.ts.map +1 -0
  136. package/dist/src/routes/context.js +287 -0
  137. package/dist/src/routes/context.js.map +1 -0
  138. package/dist/src/routes/diagrams.d.ts +3 -0
  139. package/dist/src/routes/diagrams.d.ts.map +1 -0
  140. package/dist/src/routes/diagrams.js +69 -0
  141. package/dist/src/routes/diagrams.js.map +1 -0
  142. package/dist/src/routes/documents.d.ts +11 -0
  143. package/dist/src/routes/documents.d.ts.map +1 -0
  144. package/dist/src/routes/documents.js +450 -0
  145. package/dist/src/routes/documents.js.map +1 -0
  146. package/dist/src/routes/export.d.ts +3 -0
  147. package/dist/src/routes/export.d.ts.map +1 -0
  148. package/dist/src/routes/export.js +280 -0
  149. package/dist/src/routes/export.js.map +1 -0
  150. package/dist/src/routes/files.d.ts +3 -0
  151. package/dist/src/routes/files.d.ts.map +1 -0
  152. package/dist/src/routes/files.js +180 -0
  153. package/dist/src/routes/files.js.map +1 -0
  154. package/dist/src/routes/images.d.ts +3 -0
  155. package/dist/src/routes/images.d.ts.map +1 -0
  156. package/dist/src/routes/images.js +49 -0
  157. package/dist/src/routes/images.js.map +1 -0
  158. package/dist/src/routes/metadata.d.ts +3 -0
  159. package/dist/src/routes/metadata.d.ts.map +1 -0
  160. package/dist/src/routes/metadata.js +131 -0
  161. package/dist/src/routes/metadata.js.map +1 -0
  162. package/dist/src/routes/shape-libraries.d.ts +3 -0
  163. package/dist/src/routes/shape-libraries.d.ts.map +1 -0
  164. package/dist/src/routes/shape-libraries.js +118 -0
  165. package/dist/src/routes/shape-libraries.js.map +1 -0
  166. package/dist/src/routes/wordcloud.d.ts +3 -0
  167. package/dist/src/routes/wordcloud.d.ts.map +1 -0
  168. package/dist/src/routes/wordcloud.js +95 -0
  169. package/dist/src/routes/wordcloud.js.map +1 -0
  170. package/dist/src/server.d.ts +7 -0
  171. package/dist/src/server.d.ts.map +1 -0
  172. package/dist/src/server.js +93 -0
  173. package/dist/src/server.js.map +1 -0
  174. package/dist/starter-doc/.living-doc.json +52 -0
  175. package/dist/starter-doc/ADRS/2026_01_01_[ADR]_example_architecture_decision.md +59 -0
  176. package/dist/starter-doc/AI/2026_01_01_how_to.md +112 -0
  177. package/dist/starter-doc/AI/PROJECT-INSTRUCTIONS.md +172 -0
  178. package/dist/starter-doc/AI/PROJECT-STACK.md +77 -0
  179. package/dist/starter-doc/AI/PROJECT-USEFUL-COMMANDS.md +80 -0
  180. package/dist/starter-doc/AI/default/AGENTS.md +31 -0
  181. package/dist/starter-doc/AI/default/CLAUDE.md +31 -0
  182. package/dist/starter-doc/AI/default/MEMORY.md +24 -0
  183. package/dist/starter-doc/AI/rules/no-magic-numbers.md +18 -0
  184. package/dist/starter-doc/AI/rules/track-current-work.md +23 -0
  185. package/dist/starter-doc/WORKLOG/current-task.md +57 -0
  186. package/dist/starter-doc-fr/.living-doc.json +52 -0
  187. package/dist/starter-doc-fr/ADRS/2026_01_01_[ADR]_example_architecture_decision.md +59 -0
  188. package/dist/starter-doc-fr/AI/2026_01_01_how_to.md +100 -0
  189. package/dist/starter-doc-fr/AI/PROJECT-INSTRUCTIONS.md +172 -0
  190. package/dist/starter-doc-fr/AI/PROJECT-STACK.md +77 -0
  191. package/dist/starter-doc-fr/AI/PROJECT-USEFUL-COMMANDS.md +80 -0
  192. package/dist/starter-doc-fr/AI/default/AGENTS.md +31 -0
  193. package/dist/starter-doc-fr/AI/default/CLAUDE.md +31 -0
  194. package/dist/starter-doc-fr/AI/default/MEMORY.md +24 -0
  195. package/dist/starter-doc-fr/AI/rules/no-magic-numbers.md +18 -0
  196. package/dist/starter-doc-fr/AI/rules/track-current-work.md +23 -0
  197. package/dist/starter-doc-fr/WORKLOG/current-task.md +57 -0
  198. package/images/living_documentation.jpg +0 -0
  199. package/images/readme-extra-files.png +0 -0
  200. package/images/readme-filename-pattern.png +0 -0
  201. package/images/readme-intelligent-search-demo.jpg +0 -0
  202. package/images/readme-sidebar.png +0 -0
  203. package/package.json +72 -0
@@ -0,0 +1,2214 @@
1
+ // ── Network initialisation & vis.js event handlers ────────────────────────────
2
+ // Creates the vis.js Network, patches _drawNodes for canonical z-order,
3
+ // and wires all network-level events.
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 {
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";
52
+
53
+ // Module-level port-hover state — shared between initNetwork event handlers and
54
+ // module-level helpers (_onAnchorSnapConnect).
55
+ let _hoveredPortNodeId = null;
56
+ let _hoveredPortKey = null;
57
+ let _draggingAnchorIds = new Set();
58
+ // Rehook state: tracks which port is hovered while a port edge is selected.
59
+ let _rehookEdgeId = null;
60
+ let _rehookHoveredNodeId = null;
61
+ let _rehookHoveredPortKey = null;
62
+ let _pointerDownSelection = { nodeIds: [], edgeIds: [] };
63
+ let _edgeLabelPointerAbort = null;
64
+
65
+ // Returns true when an edge can enter rehook mode (at least one non-anchor endpoint).
66
+ // Works for both port edges and native vis-network edges.
67
+ function isRehookable(edgeData) {
68
+ if (!edgeData) return false;
69
+ const fromNode = st.nodes.get(edgeData.from);
70
+ const toNode = st.nodes.get(edgeData.to);
71
+ return (
72
+ !!(fromNode && fromNode.shapeType !== "anchor") ||
73
+ !!(toNode && toNode.shapeType !== "anchor")
74
+ );
75
+ }
76
+
77
+ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
78
+ const container = document.getElementById("vis-canvas");
79
+
80
+ const edgeSmooth = edgesStraight
81
+ ? { enabled: false }
82
+ : { type: "continuous" };
83
+
84
+ st.nodes = new vis.DataSet(
85
+ savedNodes.map((n) => {
86
+ const shapeType = n.shapeType || n.renderAs || "box";
87
+ return {
88
+ ...n,
89
+ shapeType,
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
+ ),
99
+ ...(n.locked ? { fixed: { x: true, y: true }, draggable: false } : {}),
100
+ };
101
+ }),
102
+ );
103
+ st.edges = new vis.DataSet(
104
+ savedEdges.map((e) => {
105
+ const toNode = savedNodes.find((n) => n.id === e.to);
106
+ const isAnchor = toNode && toNode.shapeType === "anchor";
107
+ const edgeObj = {
108
+ ...e,
109
+ ...visEdgeProps(e.arrowDir ?? "to", e.dashes ?? false),
110
+ smooth: isAnchor ? { enabled: false } : edgeSmooth,
111
+ ...(e.edgeColor
112
+ ? {
113
+ color: {
114
+ color: e.edgeColor,
115
+ highlight: "#f97316",
116
+ hover: "#f97316",
117
+ },
118
+ }
119
+ : {}),
120
+ ...(e.edgeWidth ? { width: e.edgeWidth } : {}),
121
+ // Edge labels are always drawn by drawEdgeLabels() in afterDrawing
122
+ // (gives us positioning + rotation control). Hide vis-network's native
123
+ // label text entirely so it never appears alongside ours.
124
+ ...(e.label
125
+ ? {
126
+ font: {
127
+ size: e.fontSize || 11,
128
+ align: "middle",
129
+ color: "rgba(0,0,0,0)",
130
+ },
131
+ }
132
+ : e.fontSize
133
+ ? { font: { size: e.fontSize, align: "middle", color: "#6b7280" } }
134
+ : {}),
135
+ };
136
+ // Port edges: hide vis-network's own rendering (line + arrowhead).
137
+ // drawPortEdge() handles all visual output; vis-network edge is a
138
+ // transparent ghost kept only for hit-detection (click selection).
139
+ if (e.fromPort || e.toPort) {
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
+ };
145
+ edgeObj.arrows = { to: { enabled: false }, from: { enabled: false } };
146
+ }
147
+ return edgeObj;
148
+ }),
149
+ );
150
+
151
+ const options = {
152
+ physics: { enabled: false },
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
+ },
180
+ manipulation: {
181
+ enabled: false,
182
+ addEdge(data, callback) {
183
+ // Block edges from/to locked nodes or anchors (free-arrow endpoints).
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.
187
+ const fromNode = st.nodes.get(data.from) || {};
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
+ ) {
200
+ _addEdgeFromPort = null;
201
+ setTimeout(() => {
202
+ if (st.currentTool === "addEdge") st.network.addEdgeMode();
203
+ }, 0);
204
+ return;
205
+ }
206
+ pushSnapshot();
207
+ data.id = "e" + Date.now();
208
+ data.arrowDir = "to";
209
+ data.dashes = false;
210
+ // Attach captured port selections — skip for anchor nodes.
211
+ const fromIsAnchor = fromNode.shapeType === "anchor";
212
+ const toIsAnchor = toNode.shapeType === "anchor";
213
+ if (!fromIsAnchor) data.fromPort = _addEdgeFromPort || null;
214
+ if (!toIsAnchor) data.toPort = _hoveredPortKey || null;
215
+ Object.assign(data, visEdgeProps("to", false));
216
+ // Port edges: make vis-network's ghost transparent so only drawPortEdge is visible.
217
+ if (data.fromPort || data.toPort) {
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
+ };
223
+ data.arrows = { to: { enabled: false }, from: { enabled: false } };
224
+ }
225
+ callback(data);
226
+ markDirty();
227
+ _addEdgeFromPort = null;
228
+ setTimeout(() => {
229
+ if (st.currentTool === "addEdge") {
230
+ window.dispatchEvent(
231
+ new CustomEvent("diagram:setTool", {
232
+ detail: { tool: "select" },
233
+ }),
234
+ );
235
+ }
236
+ }, 0);
237
+ },
238
+ },
239
+ };
240
+
241
+ if (st.network) st.network.destroy();
242
+ st.network = new vis.Network(
243
+ container,
244
+ { nodes: st.nodes, edges: st.edges },
245
+ options,
246
+ );
247
+
248
+ // ── Lock interception ───────────────────────────────────────────────────────
249
+ // Must be registered BEFORE any other capture-phase mousedown listeners on
250
+ // the container so it can stopImmediatePropagation for locked targets.
251
+ installUnlockHold(container);
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
+ );
263
+
264
+ // ── Z-order patch ──────────────────────────────────────────────────────────
265
+ // vis.js renders in 3 passes (normal → selected → hovered), which breaks
266
+ // user-defined stacking. We replace _drawNodes with a single pass in
267
+ // canonicalOrder so hover/selection never override the user's z-order.
268
+ st.canonicalOrder = [...st.network.body.nodeIndices];
269
+ st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
270
+ const bodyNodes = this.body.nodes;
271
+ const bodyEdges = this.body.edges;
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
+ };
284
+
285
+ // Build a map: canonical index → list of edges whose topmost endpoint is at that index.
286
+ const orderMap = new Map();
287
+ st.canonicalOrder.forEach((id, i) => orderMap.set(id, i));
288
+
289
+ const edgesByLevel = new Map(); // canonicalIndex → edge[]
290
+ for (const edgeId of Object.keys(bodyEdges)) {
291
+ const edge = bodyEdges[edgeId];
292
+ if (!edge.connected) continue;
293
+ // Edges are drawn just before the node at their assigned level, so they
294
+ // appear on top of all nodes below that level.
295
+ // Use Math.max so the edge follows the higher-z endpoint — it stays visible
296
+ // above any intermediate nodes between the two endpoints.
297
+ // Anchor nodes are floating endpoints that must not raise the edge above the
298
+ // real source: for anchor edges, use the non-anchor endpoint's level.
299
+ // Temp edges (e.g. the ghost drawn by vis-network's addEdgeMode) have one
300
+ // endpoint that's not in our DataSet (a temp controlNode). Use the real
301
+ // endpoint's level so the source node still draws on top of the ghost's
302
+ // centre origin — otherwise the ghost line is visible from the node's
303
+ // centre instead of appearing to start at its boundary/port.
304
+ const fromData = st.nodes.get(edge.fromId);
305
+ const toData = st.nodes.get(edge.toId);
306
+ const fromIsAnchor = fromData && fromData.shapeType === "anchor";
307
+ const toIsAnchor = toData && toData.shapeType === "anchor";
308
+ const fromLevel = orderMap.get(edge.fromId);
309
+ const toLevel = orderMap.get(edge.toId);
310
+ let level;
311
+ if (fromLevel === undefined && toLevel === undefined) {
312
+ level = 0;
313
+ } else if (fromLevel === undefined) {
314
+ level = toLevel;
315
+ } else if (toLevel === undefined) {
316
+ level = fromLevel;
317
+ } else if (toIsAnchor && !fromIsAnchor) {
318
+ level = fromLevel;
319
+ } else if (fromIsAnchor && !toIsAnchor) {
320
+ level = toLevel;
321
+ } else {
322
+ level = Math.max(fromLevel, toLevel);
323
+ }
324
+ if (!edgesByLevel.has(level)) edgesByLevel.set(level, []);
325
+ edgesByLevel.get(level).push(edge);
326
+ }
327
+
328
+ // Build a map: anchorId → the edge(s) that connect to it, so we can draw
329
+ // each anchor dot AFTER its edge (giving the "planted arrowhead" effect).
330
+ const anchorEdgeLevel = new Map(); // anchorId → level at which its edge is drawn
331
+ for (const [level, edges] of edgesByLevel) {
332
+ for (const edge of edges) {
333
+ const fromData = st.nodes.get(edge.fromId);
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);
339
+ }
340
+ }
341
+
342
+ // Anchors with no connected edge are drawn at level 0 (bottom).
343
+ for (const id of st.canonicalOrder) {
344
+ const n = st.nodes.get(id);
345
+ if (!n || n.shapeType !== "anchor") continue;
346
+ if (!anchorEdgeLevel.has(id)) anchorEdgeLevel.set(id, 0);
347
+ }
348
+
349
+ for (let i = 0; i < st.canonicalOrder.length; i++) {
350
+ const id = st.canonicalOrder[i];
351
+ const n = st.nodes.get(id);
352
+
353
+ // Draw edges whose level is i, before drawing the node at level i.
354
+ const edges = edgesByLevel.get(i);
355
+ if (edges) {
356
+ edges.forEach((e) => {
357
+ // Always let vis-network draw first.
358
+ // – Non-port edges: fully rendered by vis-network (normal path).
359
+ // – Port edges: transparent ghost (invisible line + arrowhead);
360
+ // drawPortEdge() overlays the actual bezier path on top.
361
+ e.draw(ctx);
362
+ const edgeData = st.edges.get(e.id);
363
+ if (edgeData && (edgeData.fromPort || edgeData.toPort)) {
364
+ drawPortEdge(ctx, edgeData);
365
+ }
366
+ });
367
+ // Draw any anchor whose edge was just drawn, so the dot appears on top.
368
+ for (const [anchorId, level] of anchorEdgeLevel) {
369
+ if (level !== i) continue;
370
+ const anchorNode = bodyNodes[anchorId];
371
+ if (!anchorNode) continue;
372
+ if (
373
+ alwaysShow === true ||
374
+ anchorNode.isBoundingBoxOverlappingWith(viewableArea) === true
375
+ ) {
376
+ anchorNode.draw(ctx);
377
+ } else {
378
+ anchorNode.updateBoundingBox(ctx, anchorNode.selected);
379
+ }
380
+ }
381
+ }
382
+
383
+ // Skip anchor nodes here — they were drawn right after their edge above.
384
+ if (n && n.shapeType === "anchor") continue;
385
+
386
+ const node = bodyNodes[id];
387
+ if (!node) continue;
388
+ // Always draw every node — no viewport culling.
389
+ // Culling would skip draw() for off-screen nodes, leaving shape.width/height
390
+ // at vis-network's default of 50, which breaks getNodeAt() hit-testing.
391
+ node.draw(ctx);
392
+ }
393
+ return { drawExternalLabels() {} };
394
+ };
395
+
396
+ // ── Z-order patch for edges ────────────────────────────────────────────────
397
+ // vis.js draws all edges before all nodes in separate passes.
398
+ // We neutralise _drawEdges (make it a no-op) and instead draw each edge
399
+ // inside _drawNodes, just before the node whose canonical index equals the
400
+ // max index of its two endpoints. This guarantees true z-order interleaving.
401
+ st.network.renderer._drawEdges = function () {
402
+ /* no-op — edges drawn in _drawNodes */
403
+ };
404
+
405
+ // Keep canonicalOrder in sync with DataSet add/remove events
406
+ st.nodes.on("add", (_, { items }) => {
407
+ const existing = new Set(st.canonicalOrder);
408
+ items.forEach((id) => {
409
+ if (!existing.has(id)) st.canonicalOrder.push(id);
410
+ });
411
+ });
412
+ st.nodes.on("remove", (_, { items, oldData }) => {
413
+ const removed = new Set(items);
414
+ st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
415
+ // If an anchor was deleted directly, also remove its connected edges.
416
+ (oldData || []).forEach((n) => {
417
+ if (n.shapeType !== "anchor") return;
418
+ const connected = st.edges.get({
419
+ filter: (e) => e.from === n.id || e.to === n.id,
420
+ });
421
+ if (connected.length) st.edges.remove(connected.map((e) => e.id));
422
+ });
423
+ });
424
+
425
+ // When an edge is removed, delete any anchor node that has no remaining edges.
426
+ st.edges.on("remove", (_, { oldData }) => {
427
+ const anchorsToCheck = new Set();
428
+ (oldData || []).forEach((edge) => {
429
+ const toData = st.nodes.get(edge.to);
430
+ const fromData = st.nodes.get(edge.from);
431
+ if (toData && toData.shapeType === "anchor") anchorsToCheck.add(edge.to);
432
+ if (fromData && fromData.shapeType === "anchor")
433
+ anchorsToCheck.add(edge.from);
434
+ });
435
+ anchorsToCheck.forEach((anchorId) => {
436
+ const remaining = st.edges.get({
437
+ filter: (e) => e.from === anchorId || e.to === anchorId,
438
+ });
439
+ if (remaining.length === 0) st.nodes.remove(anchorId);
440
+ });
441
+ });
442
+
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
+ ) {
471
+ drawPortDots(ctx, _hoveredPortNodeId, _hoveredPortKey);
472
+ }
473
+ // Rehook: show port dots on both endpoints of the selected port edge so
474
+ // the user can click a different port to reconnect that end of the arrow.
475
+ if (
476
+ _rehookEdgeId &&
477
+ st.currentTool !== "addEdge" &&
478
+ _draggingAnchorIds.size === 0
479
+ ) {
480
+ const edgeData = st.edges.get(_rehookEdgeId);
481
+ if (isRehookable(edgeData)) {
482
+ const fromNode = st.nodes.get(edgeData.from);
483
+ if (fromNode && fromNode.shapeType !== "anchor") {
484
+ drawPortDots(
485
+ ctx,
486
+ edgeData.from,
487
+ _rehookHoveredNodeId === edgeData.from
488
+ ? _rehookHoveredPortKey
489
+ : null,
490
+ );
491
+ }
492
+ const toNode = st.nodes.get(edgeData.to);
493
+ if (toNode && toNode.shapeType !== "anchor") {
494
+ drawPortDots(
495
+ ctx,
496
+ edgeData.to,
497
+ _rehookHoveredNodeId === edgeData.to ? _rehookHoveredPortKey : null,
498
+ );
499
+ }
500
+ }
501
+ }
502
+ });
503
+ // Two-click free-arrow: draw an orange dot at the pending first-click origin.
504
+ st.network.on("afterDrawing", (ctx) => {
505
+ if (st.currentTool !== "addEdge" || !st.freeArrowFirstPoint) return;
506
+ const { x, y } = st.freeArrowFirstPoint;
507
+ ctx.beginPath();
508
+ ctx.arc(x, y, 6, 0, Math.PI * 2);
509
+ ctx.fillStyle = "#f97316";
510
+ ctx.fill();
511
+ ctx.strokeStyle = "#ffffff";
512
+ ctx.lineWidth = 2;
513
+ ctx.stroke();
514
+ });
515
+
516
+ // ── Edge label resize ─────────────────────────────────────────────────────────
517
+ // When a single edge is selected and has a label, draw two orange resize handles
518
+ // at the left and right sides of the dashed label box. Dragging either handle
519
+ // resizes the box symmetrically (centre fixed). On release the edge's
520
+ // edgeLabelWidth is committed and the text wraps accordingly.
521
+ //
522
+ // Key design: _hoverHandle is computed during mousemove (before any mousedown).
523
+ // The mousedown handler reads this pre-computed state rather than doing its own
524
+ // hit-detection — this avoids a race with vis-network's capture-phase handlers
525
+ // which may run first and alter selection state before our mousedown fires.
526
+ {
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 }
530
+ let _hoverLabelDrag = null; // { edgeId } | null
531
+
532
+ // Draw handles for the selected edge label.
533
+ st.network.on("afterDrawing", (ctx) => {
534
+ if (!st.selectedEdgeIds || st.selectedEdgeIds.length !== 1) return;
535
+ const edgeId = st.selectedEdgeIds[0];
536
+ const e = st.edges.get(edgeId);
537
+ if (!e || !e.label) return;
538
+ const bbox = st.edgeLabelBBox && st.edgeLabelBBox[edgeId];
539
+ if (!bbox) return;
540
+
541
+ ctx.save();
542
+ ctx.translate(bbox.cx, bbox.cy);
543
+ if (bbox.rotation) ctx.rotate(bbox.rotation);
544
+ for (const hx of [-bbox.w / 2, bbox.w / 2]) {
545
+ ctx.beginPath();
546
+ ctx.arc(hx, 0, 5, 0, Math.PI * 2);
547
+ ctx.fillStyle = "#f97316";
548
+ ctx.strokeStyle = "#ffffff";
549
+ ctx.lineWidth = 1.5;
550
+ ctx.fill();
551
+ ctx.stroke();
552
+ }
553
+ ctx.restore();
554
+ });
555
+
556
+ function edgeLabelHitAt(clientX, clientY) {
557
+ if (!st.network || !st.edgeLabelBBox) return;
558
+ const rect = container.getBoundingClientRect();
559
+ const cp = st.network.DOMtoCanvas({
560
+ x: clientX - rect.left,
561
+ y: clientY - rect.top,
562
+ });
563
+ const hr = 8 / st.network.getScale();
564
+
565
+ // ── Resize handle detection ──────────────────────────────────────────────
566
+ for (const edgeId of st.selectedEdgeIds || []) {
567
+ const edge = st.edges && st.edges.get(edgeId);
568
+ if (!edge || !edge.label) continue;
569
+ const bbox = st.edgeLabelBBox[edgeId];
570
+ if (!bbox) continue;
571
+
572
+ const r = -(bbox.rotation || 0);
573
+ const dx = cp.x - bbox.cx,
574
+ dy = cp.y - bbox.cy;
575
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
576
+ const ly = dx * Math.sin(r) + dy * Math.cos(r);
577
+
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
+ };
589
+ }
590
+ }
591
+
592
+ // ── Label box detection (drag) — only when no handle hovered ───────────
593
+ if (st.selectedEdgeIds && st.selectedEdgeIds.length === 1) {
594
+ const edgeId = st.selectedEdgeIds[0];
595
+ const edge = st.edges && st.edges.get(edgeId);
596
+ if (edge && edge.label) {
597
+ const bbox = st.edgeLabelBBox && st.edgeLabelBBox[edgeId];
598
+ if (bbox) {
599
+ const r = -(bbox.rotation || 0);
600
+ const dx = cp.x - bbox.cx,
601
+ dy = cp.y - bbox.cy;
602
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
603
+ const ly = dx * Math.sin(r) + dy * Math.cos(r);
604
+ if (Math.abs(lx) <= bbox.w / 2 && Math.abs(ly) <= bbox.h / 2) {
605
+ return { type: "label", edgeId };
606
+ }
607
+ }
608
+ }
609
+ }
610
+
611
+ return null;
612
+ }
613
+
614
+ // Track hover during mousemove — computed before any mousedown fires.
615
+ container.addEventListener("mousemove", (e) => {
616
+ const hit = edgeLabelHitAt(e.clientX, e.clientY);
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;
626
+ if (Boolean(found) !== Boolean(_hoverHandle)) {
627
+ container.style.cursor = found
628
+ ? "ew-resize"
629
+ : _hoverLabelDrag
630
+ ? "grab"
631
+ : "";
632
+ }
633
+ _hoverHandle = found;
634
+
635
+ const labelFound =
636
+ !found && hit && hit.type === "label" ? { edgeId: hit.edgeId } : null;
637
+ if (Boolean(labelFound) !== Boolean(_hoverLabelDrag)) {
638
+ if (!found) container.style.cursor = labelFound ? "grab" : "";
639
+ }
640
+ _hoverLabelDrag = labelFound;
641
+ });
642
+
643
+ function startEdgeLabelPointerInteraction(e) {
644
+ if (e.button !== 0) return false;
645
+ if (!container.contains(e.target)) return false;
646
+ const hit = edgeLabelHitAt(e.clientX, e.clientY);
647
+ if (hit && hit.type === "handle") {
648
+ e.preventDefault();
649
+ e.stopImmediatePropagation();
650
+ _hoverHandle = {
651
+ edgeId: hit.edgeId,
652
+ bboxCx: hit.bboxCx,
653
+ bboxCy: hit.bboxCy,
654
+ rotation: hit.rotation,
655
+ };
656
+ _lr = { ..._hoverHandle, dragging: false };
657
+ st.network.setOptions({ interaction: { dragView: false } });
658
+ return true;
659
+ } else if (hit && hit.type === "label") {
660
+ const edge = st.edges && st.edges.get(hit.edgeId);
661
+ if (!edge) return false;
662
+ e.preventDefault();
663
+ e.stopImmediatePropagation();
664
+ _hoverLabelDrag = { edgeId: hit.edgeId };
665
+ _ld = {
666
+ edgeId: hit.edgeId,
667
+ startMouse: { x: e.clientX, y: e.clientY },
668
+ startOffsetX: edge.edgeLabelOffsetX || 0,
669
+ startOffsetY: edge.edgeLabelOffsetY || 0,
670
+ dragging: false,
671
+ };
672
+ st.network.setOptions({ interaction: { dragView: false } });
673
+ return true;
674
+ }
675
+ return false;
676
+ }
677
+
678
+ if (_edgeLabelPointerAbort) _edgeLabelPointerAbort.abort();
679
+ _edgeLabelPointerAbort = new AbortController();
680
+
681
+ // Mousedown: start resize (handles take priority) or label drag.
682
+ // The document-level capture listener runs before vis-network's internal
683
+ // pointer handlers, so a node underneath the edge label cannot start moving.
684
+ document.addEventListener("pointerdown", startEdgeLabelPointerInteraction, {
685
+ capture: true,
686
+ signal: _edgeLabelPointerAbort.signal,
687
+ });
688
+ document.addEventListener("mousedown", startEdgeLabelPointerInteraction, {
689
+ capture: true,
690
+ signal: _edgeLabelPointerAbort.signal,
691
+ });
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
+ );
706
+
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
+ );
728
+
729
+ // Update width (resize) or offset (label drag) while dragging.
730
+ function onEdgeLabelPointerMove(e) {
731
+ if (!st.network) return;
732
+ if (_lr) {
733
+ if (!_lr.dragging) {
734
+ _lr.dragging = true;
735
+ pushSnapshot();
736
+ }
737
+ const rect = container.getBoundingClientRect();
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
+ });
750
+ st.network.redraw();
751
+ }
752
+ if (_ld) {
753
+ if (!_ld.dragging) {
754
+ if (
755
+ Math.hypot(
756
+ e.clientX - _ld.startMouse.x,
757
+ e.clientY - _ld.startMouse.y,
758
+ ) < 4
759
+ )
760
+ return;
761
+ _ld.dragging = true;
762
+ pushSnapshot();
763
+ }
764
+ const scale = st.network.getScale();
765
+ st.edges.update({
766
+ id: _ld.edgeId,
767
+ edgeLabelOffsetX:
768
+ _ld.startOffsetX + (e.clientX - _ld.startMouse.x) / scale,
769
+ edgeLabelOffsetY:
770
+ _ld.startOffsetY + (e.clientY - _ld.startMouse.y) / scale,
771
+ });
772
+ st.network.redraw();
773
+ }
774
+ }
775
+ document.addEventListener("pointermove", onEdgeLabelPointerMove, {
776
+ signal: _edgeLabelPointerAbort.signal,
777
+ });
778
+ document.addEventListener("mousemove", onEdgeLabelPointerMove, {
779
+ signal: _edgeLabelPointerAbort.signal,
780
+ });
781
+
782
+ // Commit on mouseup.
783
+ function onEdgeLabelPointerUp() {
784
+ if (_lr) {
785
+ st.network.setOptions({ interaction: { dragView: true } });
786
+ if (_lr.dragging) markDirty();
787
+ _lr = null;
788
+ }
789
+ if (_ld) {
790
+ st.network.setOptions({ interaction: { dragView: true } });
791
+ if (_ld.dragging) markDirty();
792
+ _ld = null;
793
+ }
794
+ container.style.cursor = _hoverHandle
795
+ ? "ew-resize"
796
+ : _hoverLabelDrag
797
+ ? "grab"
798
+ : "";
799
+ }
800
+ document.addEventListener("pointerup", onEdgeLabelPointerUp, {
801
+ signal: _edgeLabelPointerAbort.signal,
802
+ });
803
+ document.addEventListener("mouseup", onEdgeLabelPointerUp, {
804
+ signal: _edgeLabelPointerAbort.signal,
805
+ });
806
+ }
807
+
808
+ // ── Free-arrow body drag ──────────────────────────────────────────────────────
809
+ // Dragging the body of a free arrow (anchor-anchor edge) should move the whole
810
+ // arrow. vis-network pans the canvas instead (no node at the body position).
811
+ // Fix: capture mousedown on the edge body, disable dragView, move anchors manually.
812
+ {
813
+ let _fad = null; // free-arrow drag state
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) => {
851
+ if (!_fad || !st.network) return;
852
+ const dx = e.clientX - _fad.startMouse.x;
853
+ const dy = e.clientY - _fad.startMouse.y;
854
+ if (!_fad.dragging) {
855
+ if (Math.hypot(dx, dy) < 4) return;
856
+ _fad.dragging = true;
857
+ pushSnapshot();
858
+ st.network.setOptions({ interaction: { dragView: false } });
859
+ }
860
+ const scale = st.network.getScale();
861
+ const fp = _fad.startPos[_fad.fromId];
862
+ const tp = _fad.startPos[_fad.toId];
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
+ }
872
+ st.network.redraw();
873
+ });
874
+
875
+ document.addEventListener("mouseup", () => {
876
+ if (!_fad) return;
877
+ if (_fad.dragging) {
878
+ st.network.setOptions({ interaction: { dragView: true } });
879
+ markDirty();
880
+ }
881
+ _fad = null;
882
+ });
883
+ }
884
+
885
+ // ── Free-floating edge: two interaction paths ─────────────────────────────────
886
+ // Path A — drag from a real node to empty canvas → node→anchor endpoint.
887
+ // Intercepted via raw DOM mousedown/mouseup (vis-network's addEdge
888
+ // callback only fires when releasing ON an existing node).
889
+ // Path B — two successive CLICKS on empty canvas → free-standing anchor→anchor.
890
+ // Uses vis-network's own 'click' event: Hammer.js fires 'click' only
891
+ // for taps, NOT for drags, so Path A drags never bleed into Path B.
892
+ let _addEdgeFromId = null;
893
+ let _addEdgeFromPort = null; // port key on the source node (null = no port)
894
+ // _hoveredPortNodeId, _hoveredPortKey, _draggingAnchorIds are module-level.
895
+ const visCanvas = document.getElementById("vis-canvas");
896
+
897
+ // Track hovered node + nearest port while in addEdge mode OR while dragging an anchor.
898
+ visCanvas.addEventListener("mousemove", (e) => {
899
+ const inAddEdge = st.currentTool === "addEdge";
900
+ const anchorDragging = _draggingAnchorIds.size > 0;
901
+ if ((!inAddEdge && !anchorDragging) || !st.network) return;
902
+
903
+ const pos = { x: e.offsetX, y: e.offsetY };
904
+ const cp = st.network.DOMtoCanvas(pos);
905
+
906
+ let newPortNodeId = null;
907
+
908
+ if (inAddEdge) {
909
+ // addEdge mode: use getNodeAt as before
910
+ const nodeId = st.network.getNodeAt(pos) || null;
911
+ const nodeData = nodeId && st.nodes.get(nodeId);
912
+ const isAnchor = nodeData && nodeData.shapeType === "anchor";
913
+ const isLocked = nodeData && nodeData.locked;
914
+ newPortNodeId = nodeId && !isAnchor && !isLocked ? nodeId : null;
915
+ } else {
916
+ // Anchor drag: getNodeAt returns the dragged anchor itself — do a canvas-space
917
+ // bounding-box search for any non-anchor node that contains the cursor.
918
+ const SNAP_THRESHOLD = 30;
919
+ let bestId = null;
920
+ let bestDist = Infinity;
921
+ for (const [candidateId, candidatePos] of Object.entries(
922
+ st.network.getPositions(),
923
+ )) {
924
+ if (_draggingAnchorIds.has(candidateId)) continue;
925
+ const candidate = st.nodes.get(candidateId);
926
+ if (!candidate || candidate.shapeType === "anchor") continue;
927
+ const bodyNode = st.network.body.nodes[candidateId];
928
+ if (!bodyNode) continue;
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;
942
+ if (inBox) {
943
+ const dist = Math.hypot(candidatePos.x - cp.x, candidatePos.y - cp.y);
944
+ if (dist < bestDist) {
945
+ bestDist = dist;
946
+ bestId = candidateId;
947
+ }
948
+ }
949
+ }
950
+ newPortNodeId = bestId;
951
+ }
952
+
953
+ const newPortKey = newPortNodeId ? getNearestPort(newPortNodeId, cp) : null;
954
+ if (
955
+ newPortNodeId !== _hoveredPortNodeId ||
956
+ newPortKey !== _hoveredPortKey
957
+ ) {
958
+ _hoveredPortNodeId = newPortNodeId;
959
+ _hoveredPortKey = newPortKey;
960
+ st.network.redraw();
961
+ }
962
+ });
963
+
964
+ // Rehook hover: track the nearest port on the from/to nodes of the selected port edge.
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;
976
+ if (!isRehookable(selectedEdge)) {
977
+ if (_rehookEdgeId !== null) {
978
+ _rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
979
+ st.network.redraw();
980
+ }
981
+ return;
982
+ }
983
+
984
+ _rehookEdgeId = selectedEdge.id;
985
+ const cp = st.network.DOMtoCanvas({ x: e.offsetX, y: e.offsetY });
986
+ const REHOOK_THRESHOLD = 15; // world units
987
+ let newNodeId = null;
988
+ let newPortKey = null;
989
+ let closestDist = Infinity;
990
+
991
+ for (const nodeId of [selectedEdge.from, selectedEdge.to]) {
992
+ const nodeData = st.nodes.get(nodeId);
993
+ if (!nodeData || nodeData.shapeType === "anchor") continue;
994
+ const portKey = getNearestPort(nodeId, cp);
995
+ const portPos = getPortPosition(nodeId, portKey);
996
+ if (!portPos) continue;
997
+ const dist = Math.hypot(cp.x - portPos.x, cp.y - portPos.y);
998
+ if (dist <= REHOOK_THRESHOLD && dist < closestDist) {
999
+ closestDist = dist;
1000
+ newNodeId = nodeId;
1001
+ newPortKey = portKey;
1002
+ }
1003
+ }
1004
+
1005
+ if (
1006
+ newNodeId !== _rehookHoveredNodeId ||
1007
+ newPortKey !== _rehookHoveredPortKey
1008
+ ) {
1009
+ _rehookHoveredNodeId = newNodeId;
1010
+ _rehookHoveredPortKey = newPortKey;
1011
+ st.network.redraw();
1012
+ }
1013
+ });
1014
+
1015
+ // Path A – capture source node on mousedown.
1016
+ visCanvas.addEventListener("mousedown", (e) => {
1017
+ if (st.currentTool !== "addEdge") return;
1018
+ const nodeId = st.network.getNodeAt({ x: e.offsetX, y: e.offsetY }) || null;
1019
+ const nodeData = nodeId && st.nodes.get(nodeId);
1020
+ // Block starting an edge from a locked node or an anchor (free-arrow endpoint).
1021
+ if (nodeData && (nodeData.locked || nodeData.shapeType === "anchor")) {
1022
+ _addEdgeFromId = null;
1023
+ return;
1024
+ }
1025
+ // Dragging from a real node cancels any pending two-click origin.
1026
+ if (nodeId) st.freeArrowFirstPoint = null;
1027
+ _addEdgeFromId = nodeId;
1028
+ _addEdgeFromPort = _hoveredPortKey;
1029
+ });
1030
+
1031
+ // Path A – release on empty canvas creates the anchor endpoint.
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);
1039
+ const targetData = targetId && st.nodes.get(targetId);
1040
+ // Locked targets are non-interactive: treat them exactly like empty canvas
1041
+ // so a free-arrow endpoint is created at the release point — matches the
1042
+ // behaviour of free arrows drawn over a locked shape (Path B).
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.
1045
+ const targetLocked = !!(targetData && targetData.locked);
1046
+ const targetContainsSource =
1047
+ targetId && lowerZTargetContainsSource(_addEdgeFromId, targetId);
1048
+ if (!targetId || targetLocked || targetContainsSource) {
1049
+ pushSnapshot();
1050
+ const cp = st.network.DOMtoCanvas(pos);
1051
+ const anchorId = "a" + Date.now();
1052
+ st.nodes.add({
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),
1065
+ });
1066
+ st.edges.add({
1067
+ id: "e" + Date.now(),
1068
+ from: _addEdgeFromId,
1069
+ to: anchorId,
1070
+ arrowDir: "to",
1071
+ dashes: false,
1072
+ smooth: { enabled: false },
1073
+ ...visEdgeProps("to", false),
1074
+ });
1075
+ markDirty();
1076
+ st.freeArrowFirstPoint = null;
1077
+ setTimeout(() => {
1078
+ if (st.currentTool === "addEdge") st.network.addEdgeMode();
1079
+ }, 0);
1080
+ }
1081
+ _addEdgeFromId = null;
1082
+ });
1083
+
1084
+ // Path B – two successive clicks on empty canvas → free-standing anchor→anchor arrow.
1085
+ // vis-network's 'click' event (via Hammer.js) only fires for taps, never for drags,
1086
+ // so Path A drag gestures cannot accidentally trigger Path B.
1087
+ st.network.on("click", (params) => {
1088
+ if (st.currentTool !== "addEdge") return;
1089
+ // Only handle clicks that landed on empty canvas (no node, no edge).
1090
+ if (params.nodes.length > 0 || params.edges.length > 0) return;
1091
+ const cp = params.pointer.canvas;
1092
+ if (!st.freeArrowFirstPoint) {
1093
+ // First click: record the origin and show the orange indicator dot.
1094
+ st.freeArrowFirstPoint = { x: cp.x, y: cp.y };
1095
+ st.network.redraw();
1096
+ } else {
1097
+ // Second click: create the free-standing anchor→anchor arrow.
1098
+ pushSnapshot();
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
+ );
1111
+ st.nodes.add([
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
+ },
1140
+ ]);
1141
+ const edgeId = "e" + t;
1142
+ const lastStyle = getLastFreeArrowStyle();
1143
+ const arrowDir = lastStyle.arrowDir || "to";
1144
+ const dashes = lastStyle.dashes || false;
1145
+ st.edges.add({
1146
+ id: edgeId,
1147
+ from: fromId,
1148
+ to: toId,
1149
+ arrowDir,
1150
+ dashes,
1151
+ edgeColor: lastStyle.edgeColor || null,
1152
+ edgeWidth: lastStyle.edgeWidth || null,
1153
+ smooth: { enabled: false },
1154
+ ...visEdgeProps(arrowDir, dashes),
1155
+ ...(lastStyle.edgeColor
1156
+ ? {
1157
+ color: {
1158
+ color: lastStyle.edgeColor,
1159
+ highlight: "#f97316",
1160
+ hover: "#f97316",
1161
+ },
1162
+ }
1163
+ : {}),
1164
+ ...(lastStyle.edgeWidth ? { width: lastStyle.edgeWidth } : {}),
1165
+ });
1166
+ st.freeArrowFirstPoint = null;
1167
+ markDirty();
1168
+ setTimeout(() => {
1169
+ st.network.setSelection({ nodes: [fromId, toId], edges: [edgeId] });
1170
+ st.selectedNodeIds = [fromId, toId];
1171
+ st.selectedEdgeIds = [edgeId];
1172
+ showEdgePanel();
1173
+ st.network.addEdgeMode();
1174
+ }, 0);
1175
+ }
1176
+ });
1177
+
1178
+ document.getElementById("emptyState").classList.add("hidden");
1179
+ updateZoomDisplay();
1180
+
1181
+ // vis-network initialises shape.width/height to 50 (not undefined), so
1182
+ // needsRefresh() returns false and resize() never runs on the first render.
1183
+ // Fix: reset shape.width/height to undefined so needsRefresh() returns true
1184
+ // and vis-network's own render loop runs resize() with the correct context.
1185
+ st.network.once("afterDrawing", () => {
1186
+ for (const id of st.network.body.nodeIndices) {
1187
+ const bn = st.network.body.nodes[id];
1188
+ if (!bn || !bn.shape) continue;
1189
+ bn.shape.width = undefined;
1190
+ bn.shape.height = undefined;
1191
+ }
1192
+ // Patch CustomShape.distanceToBorder so anchor nodes report 0: arrow tips
1193
+ // land at the anchor centre instead of 8 canvas units away (half the 16×16
1194
+ // hit-box). Without this, short free arrows render with reversed or
1195
+ // overlapping arrowheads because the 8+8 boundary offset exceeds the
1196
+ // segment length, and the visible dot sits far from the arrow tip.
1197
+ for (const id of st.network.body.nodeIndices) {
1198
+ const bn = st.network.body.nodes[id];
1199
+ if (!bn || !bn.shape) continue;
1200
+ const proto = Object.getPrototypeOf(bn.shape);
1201
+ if (proto.__ldAnchorDistancePatched) break;
1202
+ const orig = proto.distanceToBorder;
1203
+ proto.distanceToBorder = function (ctx, angle) {
1204
+ const data = st.nodes && st.nodes.get(this.options && this.options.id);
1205
+ if (data && data.shapeType === "anchor") return 0;
1206
+ return orig.call(this, ctx, angle);
1207
+ };
1208
+ proto.__ldAnchorDistancePatched = true;
1209
+ break;
1210
+ }
1211
+ st.network.redraw();
1212
+ });
1213
+ window.dispatchEvent(new CustomEvent("diagram:network-ready"));
1214
+ }
1215
+
1216
+ // ── Anchor snap-to-connect ────────────────────────────────────────────────────
1217
+ // When a free-arrow anchor endpoint is dropped near/onto another node or anchor,
1218
+ // reconnect the edge to that target and remove the orphaned anchor.
1219
+ function _onAnchorSnapConnect(params) {
1220
+ if (!params.nodes || params.nodes.length === 0) return;
1221
+ if (!st.network || !st.nodes || !st.edges) return;
1222
+
1223
+ const SNAP_THRESHOLD = 30; // canvas units
1224
+
1225
+ let snapped = false;
1226
+
1227
+ for (const anchorId of params.nodes) {
1228
+ const anchor = st.nodes.get(anchorId);
1229
+ if (!anchor || anchor.shapeType !== "anchor") continue;
1230
+
1231
+ // Find the edge this anchor belongs to (as from or to)
1232
+ const connectedEdges = st.edges
1233
+ .get()
1234
+ .filter((e) => e.from === anchorId || e.to === anchorId);
1235
+ if (connectedEdges.length === 0) continue;
1236
+
1237
+ // Current position of dragged anchor
1238
+ const pos = st.network.getPositions([anchorId])[anchorId];
1239
+ if (!pos) continue;
1240
+
1241
+ // Find best snap target: any node except this anchor itself and the other
1242
+ // endpoint of the same edge (avoid self-loop on anchor→anchor)
1243
+ const siblingIds = new Set();
1244
+ for (const e of connectedEdges) {
1245
+ siblingIds.add(e.from);
1246
+ siblingIds.add(e.to);
1247
+ }
1248
+ siblingIds.delete(anchorId); // keep sibling (other endpoint) in set to block self-loop
1249
+
1250
+ let bestId = null;
1251
+ let bestDist = Infinity;
1252
+
1253
+ for (const [candidateId, candidatePos] of Object.entries(
1254
+ st.network.getPositions(),
1255
+ )) {
1256
+ if (candidateId === anchorId) continue;
1257
+ if (siblingIds.has(candidateId)) continue; // would create a self-loop
1258
+
1259
+ const candidate = st.nodes.get(candidateId);
1260
+ if (!candidate) continue;
1261
+
1262
+ const dist = Math.hypot(candidatePos.x - pos.x, candidatePos.y - pos.y);
1263
+
1264
+ if (candidate.shapeType === "anchor") {
1265
+ // Snap to another anchor if within threshold
1266
+ if (dist < SNAP_THRESHOLD && dist < bestDist) {
1267
+ bestId = candidateId;
1268
+ bestDist = dist;
1269
+ }
1270
+ } else {
1271
+ // Snap to a regular node if anchor falls within its bounding box (padded by threshold)
1272
+ const bodyNode = st.network.body.nodes[candidateId];
1273
+ if (!bodyNode) continue;
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;
1282
+ const cx = candidatePos.x;
1283
+ const cy = candidatePos.y;
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;
1289
+ if (inBox && dist < bestDist) {
1290
+ bestId = candidateId;
1291
+ bestDist = dist;
1292
+ }
1293
+ }
1294
+ }
1295
+
1296
+ if (!bestId) continue;
1297
+
1298
+ if (!snapped) {
1299
+ pushSnapshot();
1300
+ snapped = true;
1301
+ }
1302
+
1303
+ // Determine which port to attach to on the target node (non-anchor targets only).
1304
+ const targetNode = st.nodes.get(bestId);
1305
+ const isAnchorTarget = targetNode && targetNode.shapeType === "anchor";
1306
+ // Use the hovered port (computed by mousemove) when available; fall back to nearest port.
1307
+ let portKey = null;
1308
+ if (!isAnchorTarget) {
1309
+ portKey =
1310
+ _hoveredPortNodeId === bestId && _hoveredPortKey
1311
+ ? _hoveredPortKey
1312
+ : getNearestPort(bestId, pos);
1313
+ }
1314
+
1315
+ // Reconnect every edge that uses this anchor as an endpoint
1316
+ for (const e of connectedEdges) {
1317
+ if (e.from === anchorId) {
1318
+ const update = { id: e.id, from: bestId };
1319
+ if (portKey) update.fromPort = portKey;
1320
+ // Port edges must be transparent ghosts so only drawPortEdge is visible.
1321
+ if (portKey || e.toPort) {
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
+ };
1327
+ update.arrows = { to: { enabled: false }, from: { enabled: false } };
1328
+ }
1329
+ st.edges.update(update);
1330
+ }
1331
+ if (e.to === anchorId) {
1332
+ const update = { id: e.id, to: bestId };
1333
+ if (portKey) update.toPort = portKey;
1334
+ // Port edges must be transparent ghosts so only drawPortEdge is visible.
1335
+ if (portKey || e.fromPort) {
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
+ };
1341
+ update.arrows = { to: { enabled: false }, from: { enabled: false } };
1342
+ }
1343
+ st.edges.update(update);
1344
+ }
1345
+ }
1346
+
1347
+ // Remove the orphaned anchor
1348
+ st.nodes.remove(anchorId);
1349
+ markDirty();
1350
+ }
1351
+ }
1352
+
1353
+ // ── Edge label rendering ──────────────────────────────────────────────────────
1354
+ // All edge labels (with or without rotation) are drawn here in afterDrawing.
1355
+ // vis-network's native edge label is always made transparent so only this
1356
+ // renderer is visible — eliminating the dual-rendering issue that caused
1357
+ // labels to appear twice at different positions.
1358
+ function drawEdgeLabels(ctx) {
1359
+ try {
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
+ }
1395
+
1396
+ const ox = e.edgeLabelOffsetX || 0;
1397
+ const oy = e.edgeLabelOffsetY || 0;
1398
+ const lx = mx + ox;
1399
+ const ly = my + oy;
1400
+
1401
+ st.edgeLabelCanvasPos[e.id] = {
1402
+ x: (m.a * lx + m.e) / dpr + offsetX,
1403
+ y: (m.d * ly + m.f) / dpr + offsetY,
1404
+ };
1405
+
1406
+ // Only draw the label text for edges that have one.
1407
+ if (!e.label) return;
1408
+
1409
+ const fontSize = e.fontSize || 11;
1410
+ const lineHeight = fontSize * 1.5;
1411
+ const PAD_X = 6,
1412
+ PAD_Y = 4;
1413
+
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
+ };
1436
+
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)";
1441
+
1442
+ // Opaque background tight around the text — makes the arrow appear to pass behind.
1443
+ ctx.save();
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
+ );
1451
+ ctx.restore();
1452
+
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
+ }
1463
+
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
+ }
1477
+ }
1478
+
1479
+ // ── Network event handlers ────────────────────────────────────────────────────
1480
+
1481
+ // Distance from point (px,py) to segment (ax,ay)-(bx,by) in canvas space.
1482
+ function _distToSegment(px, py, ax, ay, bx, by) {
1483
+ const dx = bx - ax,
1484
+ dy = by - ay;
1485
+ const lenSq = dx * dx + dy * dy;
1486
+ if (lenSq === 0) return Math.hypot(px - ax, py - ay);
1487
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
1488
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1489
+ }
1490
+
1491
+ function nodeContainsCanvasPoint(id, canvasPos) {
1492
+ const n = st.nodes.get(id);
1493
+ const bn = st.network && st.network.body.nodes[id];
1494
+ if (!n || !bn || n.shapeType === "anchor") return false;
1495
+
1496
+ const shapeType = n.shapeType || "box";
1497
+ const defaults = SHAPE_DEFAULTS[shapeType] || [60, 28];
1498
+ const W = n.nodeWidth || defaults[0];
1499
+ const H = shapeType === "circle" ? W : n.nodeHeight || defaults[1];
1500
+
1501
+ const dx = canvasPos.x - bn.x;
1502
+ const dy = canvasPos.y - bn.y;
1503
+ const rot = n.rotation || 0;
1504
+ const lx = rot ? dx * Math.cos(-rot) - dy * Math.sin(-rot) : dx;
1505
+ const ly = rot ? dx * Math.sin(-rot) + dy * Math.cos(-rot) : dy;
1506
+
1507
+ if (shapeType === "circle" || shapeType === "ellipse") {
1508
+ const rx = W / 2;
1509
+ const ry = H / 2;
1510
+ return (
1511
+ rx > 0 && ry > 0 && (lx * lx) / (rx * rx) + (ly * ly) / (ry * ry) <= 1
1512
+ );
1513
+ }
1514
+
1515
+ return Math.abs(lx) <= W / 2 && Math.abs(ly) <= H / 2;
1516
+ }
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
+
1527
+ // Returns the topmost (highest z-order) node containing canvasPos.
1528
+ // Ignores anchor nodes and respects st.canonicalOrder.
1529
+ function topmostNodeAt(canvasPos) {
1530
+ for (let i = st.canonicalOrder.length - 1; i >= 0; i--) {
1531
+ const id = st.canonicalOrder[i];
1532
+ if (nodeContainsCanvasPoint(id, canvasPos)) return id;
1533
+ }
1534
+ return null;
1535
+ }
1536
+
1537
+ function edgeDrawLevel(edgeData) {
1538
+ if (!edgeData) return -1;
1539
+ const fromLevel = st.canonicalOrder.indexOf(edgeData.from);
1540
+ const toLevel = st.canonicalOrder.indexOf(edgeData.to);
1541
+ if (fromLevel === -1 && toLevel === -1) return -1;
1542
+ if (fromLevel === -1) return toLevel;
1543
+ if (toLevel === -1) return fromLevel;
1544
+
1545
+ const fromNode = st.nodes.get(edgeData.from);
1546
+ const toNode = st.nodes.get(edgeData.to);
1547
+ const fromIsAnchor = fromNode && fromNode.shapeType === "anchor";
1548
+ const toIsAnchor = toNode && toNode.shapeType === "anchor";
1549
+ if (toIsAnchor && !fromIsAnchor) return fromLevel;
1550
+ if (fromIsAnchor && !toIsAnchor) return toLevel;
1551
+ return Math.max(fromLevel, toLevel);
1552
+ }
1553
+
1554
+ function nearestPortEdgeAt(canvasPos, threshold = 8) {
1555
+ const portEdges = st.edges.get({ filter: (e) => e.fromPort || e.toPort });
1556
+ let nearest = null;
1557
+ let nearestDist = Infinity;
1558
+ for (const edge of portEdges) {
1559
+ const d = distanceToPortEdge(edge, canvasPos);
1560
+ if (d < nearestDist) {
1561
+ nearestDist = d;
1562
+ nearest = edge;
1563
+ }
1564
+ }
1565
+ return nearest && nearestDist <= threshold ? nearest : null;
1566
+ }
1567
+
1568
+ function edgeLabelAtCanvasPoint(canvasPos) {
1569
+ if (!st.edgeLabelBBox || !st.edges) return null;
1570
+
1571
+ let bestEdgeId = null;
1572
+ let bestLevel = -1;
1573
+ for (const edge of st.edges.get()) {
1574
+ if (!edge || !edge.label) continue;
1575
+ const bbox = st.edgeLabelBBox[edge.id];
1576
+ if (!bbox) continue;
1577
+
1578
+ const r = -(bbox.rotation || 0);
1579
+ const dx = canvasPos.x - bbox.cx;
1580
+ const dy = canvasPos.y - bbox.cy;
1581
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
1582
+ const ly = dx * Math.sin(r) + dy * Math.cos(r);
1583
+ if (Math.abs(lx) > bbox.w / 2 || Math.abs(ly) > bbox.h / 2) continue;
1584
+
1585
+ const level = edgeDrawLevel(edge);
1586
+ if (level >= bestLevel) {
1587
+ bestLevel = level;
1588
+ bestEdgeId = edge.id;
1589
+ }
1590
+ }
1591
+ return bestEdgeId;
1592
+ }
1593
+
1594
+ function isEdgeInteractive(edge) {
1595
+ if (!edge) return false;
1596
+ const fromN = st.nodes.get(edge.from);
1597
+ const toN = st.nodes.get(edge.to);
1598
+ const isFreeArrow =
1599
+ fromN && fromN.shapeType === "anchor" && toN && toN.shapeType === "anchor";
1600
+ return isFreeArrow ? !(fromN.locked && toN.locked) : !edge.edgeLocked;
1601
+ }
1602
+
1603
+ function selectableEdgesForNodes(nodeIds) {
1604
+ const selectedSet = new Set(nodeIds);
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);
1612
+ }
1613
+
1614
+ function selectNodesFromClick(nodeId, srcEvent) {
1615
+ const additive = !!(srcEvent && (srcEvent.metaKey || srcEvent.ctrlKey));
1616
+ const clicked = expandSelectionToGroup([nodeId]).filter((id) => {
1617
+ const n = st.nodes.get(id);
1618
+ return n && !n.locked && n.shapeType !== "anchor";
1619
+ });
1620
+ if (!clicked.length) return;
1621
+
1622
+ let nodeIds;
1623
+ if (additive) {
1624
+ const next = new Set(_pointerDownSelection.nodeIds || []);
1625
+ const allAlreadySelected = clicked.every((id) => next.has(id));
1626
+ clicked.forEach((id) => {
1627
+ if (allAlreadySelected) next.delete(id);
1628
+ else next.add(id);
1629
+ });
1630
+ nodeIds = Array.from(next).filter((id) => {
1631
+ const n = st.nodes.get(id);
1632
+ return n && !n.locked && n.shapeType !== "anchor";
1633
+ });
1634
+ } else {
1635
+ nodeIds = clicked;
1636
+ }
1637
+
1638
+ const edgeIds = selectableEdgesForNodes(nodeIds);
1639
+ _addingEdgesToSelection = true;
1640
+ st.network.setSelection({ nodes: nodeIds, edges: edgeIds });
1641
+ _addingEdgesToSelection = false;
1642
+ st.selectedNodeIds = nodeIds;
1643
+ st.selectedEdgeIds = edgeIds;
1644
+ _rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1645
+
1646
+ hideEdgePanel();
1647
+ if (nodeIds.length) showNodePanel();
1648
+ else {
1649
+ hideNodePanel();
1650
+ hideSelectionOverlay();
1651
+ }
1652
+ }
1653
+
1654
+ function onDoubleClick(params) {
1655
+ const srcEvent = params.event && params.event.srcEvent;
1656
+ const clientPos = srcEvent
1657
+ ? { x: srcEvent.clientX, y: srcEvent.clientY }
1658
+ : null;
1659
+ const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1660
+ const topNodeId = topmostNodeAt(params.pointer.canvas);
1661
+ const topNode = topNodeId && st.nodes.get(topNodeId);
1662
+ const canEditTopNode =
1663
+ topNode && !topNode.locked && topNode.shapeType !== "anchor";
1664
+
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) {
1669
+ st.selectedNodeIds = [topNodeId];
1670
+ st.selectedEdgeIds = [];
1671
+ st.network.setSelection({ nodes: st.selectedNodeIds, edges: [] });
1672
+ showNodePanel();
1673
+ hideEdgePanel();
1674
+ startLabelEdit();
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) {
1697
+ st.selectedNodeIds = [];
1698
+ st.selectedEdgeIds = [edgeId];
1699
+ st.network.setSelection({ nodes: [], edges: [edgeId] });
1700
+ hideNodePanel();
1701
+ showEdgePanel();
1702
+ startEdgeLabelEdit();
1703
+ } else if (st.currentTool === "addNode" && st.pendingShape === "image") {
1704
+ const canvasPos = params.pointer.canvas;
1705
+ pickAndCreateImageNode(canvasPos.x, canvasPos.y);
1706
+ } else if (st.currentTool === "addNode") {
1707
+ pushSnapshot();
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;
1722
+ const textValign = lastStyle.textValign || null;
1723
+ const rawPos = params.pointer.canvas;
1724
+ const pos = st.gridEnabled ? snapToGrid(rawPos.x, rawPos.y) : rawPos;
1725
+ st.nodes.add({
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
+ ),
1754
+ });
1755
+ markDirty();
1756
+
1757
+ setTimeout(() => {
1758
+ st.network.selectNodes([id]);
1759
+ st.selectedNodeIds = [id];
1760
+ showNodePanel();
1761
+ startLabelEdit();
1762
+ }, 50);
1763
+ }
1764
+ }
1765
+
1766
+ // ── Image node creation ───────────────────────────────────────────────────────
1767
+
1768
+ function pickAndCreateImageNode(canvasX, canvasY) {
1769
+ const input = document.createElement("input");
1770
+ input.type = "file";
1771
+ input.accept = "image/*";
1772
+ input.onchange = async () => {
1773
+ const file = input.files && input.files[0];
1774
+ if (!file) return;
1775
+ const name = await promptImageName();
1776
+ if (name === null) return; // user cancelled
1777
+ try {
1778
+ const src = await uploadImageFile(file, name);
1779
+ createImageNode(src, canvasX, canvasY);
1780
+ } catch {
1781
+ showToast(t("diagram.toast.image_import_error"), "error");
1782
+ }
1783
+ };
1784
+ input.click();
1785
+ }
1786
+
1787
+ export function createImageNode(imageSrc, canvasX, canvasY) {
1788
+ if (!st.network) return;
1789
+ const id = "n" + Date.now();
1790
+ const captionId = id + "c";
1791
+
1792
+ const addNode = (nW, nH) => {
1793
+ pushSnapshot();
1794
+ const filename = imageSrc.split("/").pop() || "";
1795
+ const textDefs = SHAPE_DEFAULTS["text-free"];
1796
+ const captionH = textDefs[1];
1797
+ const GAP = 8;
1798
+
1799
+ const groupId = "g" + Date.now();
1800
+ st.nodes.add({
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),
1815
+ });
1816
+ st.nodes.add({
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),
1830
+ });
1831
+ markDirty();
1832
+ setTimeout(() => {
1833
+ st.network.selectNodes([id]);
1834
+ st.selectedNodeIds = [id];
1835
+ showNodePanel();
1836
+ }, 50);
1837
+ };
1838
+
1839
+ const img = new Image();
1840
+ img.onload = () => {
1841
+ const MAX = 300;
1842
+ const ratio = img.naturalWidth / img.naturalHeight;
1843
+ let nW = img.naturalWidth,
1844
+ nH = img.naturalHeight;
1845
+ if (nW > MAX) {
1846
+ nW = MAX;
1847
+ nH = Math.round(MAX / ratio);
1848
+ }
1849
+ addNode(nW, nH);
1850
+ };
1851
+ img.onerror = () => {
1852
+ const d = SHAPE_DEFAULTS["image"];
1853
+ addNode(d[0], d[1]);
1854
+ };
1855
+ img.src = imageSrc;
1856
+ }
1857
+
1858
+ // ── Edge straight / curved toggle ────────────────────────────────────────────
1859
+ export function toggleEdgeStraight() {
1860
+ if (!st.network) return;
1861
+ pushSnapshot();
1862
+ st.edgesStraight = !st.edgesStraight;
1863
+ const smooth = st.edgesStraight ? { enabled: false } : { type: "continuous" };
1864
+ // Update global network option first (overrides per-edge inherited defaults).
1865
+ st.network.setOptions({ edges: { smooth } });
1866
+ // Then update each edge individually, keeping anchor edges always straight.
1867
+ const updates = st.edges.get().map((e) => {
1868
+ const toData = st.nodes.get(e.to);
1869
+ const s =
1870
+ toData && toData.shapeType === "anchor" ? { enabled: false } : smooth;
1871
+ return { id: e.id, smooth: s };
1872
+ });
1873
+ if (updates.length) st.edges.update(updates);
1874
+ document
1875
+ .getElementById("btnEdgeStraight")
1876
+ .classList.toggle("tool-active", st.edgesStraight);
1877
+ markDirty();
1878
+ }
1879
+
1880
+ function onClickNode(params) {
1881
+ if (params.nodes.length === 1 && params.event.srcEvent.shiftKey) {
1882
+ navigateNodeLink(params.nodes[0]);
1883
+ return;
1884
+ }
1885
+
1886
+ // ── Edge rehook ──────────────────────────────────────────────────────────────
1887
+ // When a port edge is selected and the user clicks a port dot on one of its
1888
+ // endpoint nodes, reconnect that end to the new port without losing selection.
1889
+ if (_rehookEdgeId && _rehookHoveredNodeId && _rehookHoveredPortKey) {
1890
+ const edgeData = st.edges.get(_rehookEdgeId);
1891
+ if (
1892
+ edgeData &&
1893
+ (edgeData.from === _rehookHoveredNodeId ||
1894
+ edgeData.to === _rehookHoveredNodeId)
1895
+ ) {
1896
+ const wasPortEdge = !!(edgeData.fromPort || edgeData.toPort);
1897
+ const update = { id: edgeData.id };
1898
+ if (edgeData.from === _rehookHoveredNodeId)
1899
+ update.fromPort = _rehookHoveredPortKey;
1900
+ else update.toPort = _rehookHoveredPortKey;
1901
+ // First port assigned on a native edge: hide vis-network's rendering so
1902
+ // drawPortEdge() takes over without double-rendering.
1903
+ if (!wasPortEdge) {
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
+ };
1909
+ update.arrows = { to: { enabled: false }, from: { enabled: false } };
1910
+ }
1911
+ pushSnapshot();
1912
+ st.edges.update(update);
1913
+ markDirty();
1914
+ const edgeId = edgeData.id;
1915
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1916
+ setTimeout(() => {
1917
+ st.network.setSelection({ nodes: [], edges: [edgeId] });
1918
+ st.selectedEdgeIds = [edgeId];
1919
+ st.selectedNodeIds = [];
1920
+ showEdgePanel();
1921
+ }, 0);
1922
+ return;
1923
+ }
1924
+ }
1925
+
1926
+ // When a node is reported, params.edges is usually empty — vis-network short-circuits
1927
+ // edge detection once a node is found. Fix: call getEdgeAt() directly with CLIENT
1928
+ // coordinates. params.pointer.DOM is already offset-relative to the container, so
1929
+ // passing it to getEdgeAt() causes a double-subtraction of the container rect and
1930
+ // returns null. Passing clientX/Y lets vis-network do its own pixel-perfect detection.
1931
+ if (params.nodes.length > 0) {
1932
+ const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1933
+ const top = topmostNodeAt(params.pointer.canvas);
1934
+ if (top) {
1935
+ const topNode = st.nodes.get(top);
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;
1946
+ }
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);
1956
+ if (edgeId) {
1957
+ const edge = st.edges.get(edgeId);
1958
+ const fromN = edge && st.nodes.get(edge.from);
1959
+ const toN = edge && st.nodes.get(edge.to);
1960
+ const isFreeArrow =
1961
+ fromN &&
1962
+ fromN.shapeType === "anchor" &&
1963
+ toN &&
1964
+ toN.shapeType === "anchor";
1965
+ // If the user clicked directly on an anchor node (not the edge body), keep only
1966
+ // that single anchor selected so dragging pivots the arrow instead of moving it whole.
1967
+ const clickedAnAnchor = params.nodes.some((id) => {
1968
+ const n = st.nodes.get(id);
1969
+ return n && n.shapeType === "anchor";
1970
+ });
1971
+ if (isFreeArrow && clickedAnAnchor) return;
1972
+ setTimeout(() => {
1973
+ const sel = isFreeArrow
1974
+ ? { nodes: [edge.from, edge.to], edges: [edgeId] }
1975
+ : { nodes: [], edges: [edgeId] };
1976
+ st.network.setSelection(sel);
1977
+ st.selectedNodeIds = sel.nodes;
1978
+ st.selectedEdgeIds = [edgeId];
1979
+ const e2 = st.edges.get(edgeId);
1980
+ _rehookEdgeId = isRehookable(e2) ? edgeId : null;
1981
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1982
+ hideNodePanel();
1983
+ showEdgePanel();
1984
+ }, 0);
1985
+ return;
1986
+ }
1987
+ // No edge at click position — apply z-order correction for the topmost node.
1988
+ const fallbackTop = topmostNodeAt(params.pointer.canvas);
1989
+ const clickable = params.nodes.filter((id) => {
1990
+ const n = st.nodes.get(id);
1991
+ return n && !n.locked && n.shapeType !== "anchor";
1992
+ });
1993
+ if (!fallbackTop || !clickable.includes(fallbackTop)) {
1994
+ const fallbackEdgeId = params.edges.length > 0 ? params.edges[0] : null;
1995
+ if (fallbackEdgeId) {
1996
+ setTimeout(() => {
1997
+ st.network.setSelection({ nodes: [], edges: [fallbackEdgeId] });
1998
+ st.selectedNodeIds = [];
1999
+ st.selectedEdgeIds = [fallbackEdgeId];
2000
+ const fe = st.edges.get(fallbackEdgeId);
2001
+ _rehookEdgeId = isRehookable(fe) ? fallbackEdgeId : null;
2002
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
2003
+ hideNodePanel();
2004
+ showEdgePanel();
2005
+ }, 0);
2006
+ return;
2007
+ }
2008
+ }
2009
+ }
2010
+
2011
+ // Port edges have a transparent ghost path so vis-network's hit detection
2012
+ // misses them when they diverge from the centre-to-centre line.
2013
+ // Also check the canvas for port-edge proximity when nothing was hit.
2014
+ if (params.nodes.length === 0 && params.edges.length === 0) {
2015
+ const cp = params.pointer.canvas;
2016
+
2017
+ // ── Port edge proximity check ──────────────────────────────────────────
2018
+ // vis-network's hit detection uses the invisible centre-to-centre ghost,
2019
+ // so port edges that diverge visually from that path are not selectable.
2020
+ const nearest = nearestPortEdgeAt(cp);
2021
+ if (nearest) {
2022
+ st.network.selectEdges([nearest.id]);
2023
+ st.selectedEdgeIds = [nearest.id];
2024
+ st.selectedNodeIds = [];
2025
+ _rehookEdgeId = nearest.id;
2026
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
2027
+ hideNodePanel();
2028
+ showEdgePanel();
2029
+ return;
2030
+ }
2031
+ }
2032
+ // Click landed on empty space (no node, no edge, no rehook) — clear stale rehook state.
2033
+ _rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
2034
+ }
2035
+
2036
+ // Expand group selection at dragStart so vis-network moves all members together.
2037
+ // dragStart fires before the move, unlike selectNode which fires after mouseup.
2038
+ function onDragStart(params) {
2039
+ if (!params.nodes.length) return;
2040
+ // Track dragged anchors so the port-dot overlay activates during the drag.
2041
+ _draggingAnchorIds.clear();
2042
+ for (const id of params.nodes) {
2043
+ const n = st.nodes && st.nodes.get(id);
2044
+ if (n && n.shapeType === "anchor") _draggingAnchorIds.add(id);
2045
+ }
2046
+ const expanded = expandSelectionToGroup(params.nodes);
2047
+ if (expanded.length > params.nodes.length) {
2048
+ st.network.selectNodes(expanded);
2049
+ st.selectedNodeIds = expanded;
2050
+ }
2051
+ }
2052
+
2053
+ let _expandingGroup = false;
2054
+ let _addingEdgesToSelection = false;
2055
+ function onSelectNode(params) {
2056
+ if (_expandingGroup || _addingEdgesToSelection) return;
2057
+ // Filter out anchor nodes — they have no formatting panel.
2058
+ const nonAnchors = params.nodes.filter((id) => {
2059
+ const n = st.nodes.get(id);
2060
+ return !(n && n.shapeType === "anchor");
2061
+ });
2062
+ // Drop locked nodes: they are non-interactive until unlocked via long-press.
2063
+ const usable = nonAnchors.filter((id) => {
2064
+ const n = st.nodes.get(id);
2065
+ return n && !n.locked;
2066
+ });
2067
+ if (usable.length !== nonAnchors.length) {
2068
+ _addingEdgesToSelection = true;
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
+ });
2077
+ _addingEdgesToSelection = false;
2078
+ }
2079
+ if (!usable.length) {
2080
+ const anchorIds = params.nodes.filter((id) => {
2081
+ const n = st.nodes.get(id);
2082
+ return n && n.shapeType === "anchor";
2083
+ });
2084
+ if (anchorIds.length) {
2085
+ // Only anchors selected (individual endpoint drag) — keep selection but no panel.
2086
+ st.selectedNodeIds = anchorIds;
2087
+ st.selectedEdgeIds = [];
2088
+ hideEdgePanel();
2089
+ return;
2090
+ }
2091
+ st.selectedNodeIds = [];
2092
+ st.selectedEdgeIds = [];
2093
+ hideNodePanel();
2094
+ hideEdgePanel();
2095
+ return;
2096
+ }
2097
+ const expanded = expandSelectionToGroup(usable).filter((id) => {
2098
+ const n = st.nodes.get(id);
2099
+ return n && !n.locked;
2100
+ });
2101
+ if (expanded.length > usable.length) {
2102
+ _expandingGroup = true;
2103
+ st.network.selectNodes(expanded);
2104
+ _expandingGroup = false;
2105
+ st.selectedNodeIds = expanded;
2106
+ } else {
2107
+ st.selectedNodeIds = usable;
2108
+ }
2109
+ // Include all edges (regular or free-arrow) whose both endpoints are in the selection.
2110
+ // This makes rubber-band select and multi-select automatically include connected edges.
2111
+ // Exclude locked edges — they are non-interactive.
2112
+ const selectedSet = new Set(st.selectedNodeIds);
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);
2127
+ // Tell vis-network to visually highlight the free-arrow edges.
2128
+ // Use a guard to prevent the resulting selectNode event from re-entering.
2129
+ if (st.selectedEdgeIds.length > 0) {
2130
+ _addingEdgesToSelection = true;
2131
+ st.network.setSelection({
2132
+ nodes: st.network.getSelectedNodes(),
2133
+ edges: st.selectedEdgeIds,
2134
+ });
2135
+ _addingEdgesToSelection = false;
2136
+ }
2137
+ hideEdgePanel();
2138
+ showNodePanel();
2139
+ }
2140
+
2141
+ function onSelectEdge(params) {
2142
+ if (st.selectedNodeIds.length > 0) return; // node takes priority
2143
+
2144
+ // Drop locked edges (edgeLocked or free-arrow with both anchors locked).
2145
+ const usable = params.edges.filter((id) => {
2146
+ const e = st.edges.get(id);
2147
+ if (!e) return false;
2148
+ const fromN = st.nodes.get(e.from);
2149
+ const toN = st.nodes.get(e.to);
2150
+ const isFreeArrow =
2151
+ fromN &&
2152
+ fromN.shapeType === "anchor" &&
2153
+ toN &&
2154
+ toN.shapeType === "anchor";
2155
+ return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
2156
+ });
2157
+ if (usable.length !== params.edges.length) {
2158
+ st.network.setSelection({
2159
+ nodes: st.network.getSelectedNodes(),
2160
+ edges: usable,
2161
+ });
2162
+ }
2163
+ if (!usable.length) {
2164
+ st.selectedEdgeIds = [];
2165
+ hideEdgePanel();
2166
+ return;
2167
+ }
2168
+ st.selectedEdgeIds = usable;
2169
+ // Activate rehook mode for any edge with at least one non-anchor endpoint.
2170
+ const singleEdge = usable.length === 1 ? st.edges.get(usable[0]) : null;
2171
+ _rehookEdgeId = isRehookable(singleEdge) ? singleEdge.id : null;
2172
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
2173
+
2174
+ // For free arrows (anchor→anchor edges), also select both endpoint anchors
2175
+ // so the user can drag the whole arrow as a unit via multi-node drag.
2176
+ const freeAnchors = [];
2177
+ for (const edgeId of usable) {
2178
+ const e = st.edges.get(edgeId);
2179
+ if (!e) continue;
2180
+ const fromN = st.nodes.get(e.from);
2181
+ const toN = st.nodes.get(e.to);
2182
+ if (
2183
+ fromN &&
2184
+ fromN.shapeType === "anchor" &&
2185
+ toN &&
2186
+ toN.shapeType === "anchor"
2187
+ ) {
2188
+ freeAnchors.push(e.from, e.to);
2189
+ }
2190
+ }
2191
+ if (freeAnchors.length) {
2192
+ st.network.setSelection({ nodes: freeAnchors, edges: usable });
2193
+ st.selectedNodeIds = freeAnchors; // kept for drag; no panel (all anchors)
2194
+ } else {
2195
+ st.selectedNodeIds = [];
2196
+ }
2197
+ hideNodePanel();
2198
+ showEdgePanel();
2199
+ }
2200
+
2201
+ function onDeselectAll() {
2202
+ hideLinkPanel();
2203
+ st.selectedNodeIds = [];
2204
+ st.selectedEdgeIds = [];
2205
+ // Rehook state is intentionally NOT cleared here: vis-network fires deselectEdge
2206
+ // before the click event when the user clicks a port dot on a node, so clearing
2207
+ // here would destroy the state that onClickNode needs to process the rehook.
2208
+ // The mousemove handler clears it naturally once the edge is no longer selected.
2209
+ hideNodePanel();
2210
+ hideEdgePanel();
2211
+ commitLabelEdit();
2212
+ hideLabelInput();
2213
+ hideSelectionOverlay();
2214
+ }