living-documentation 7.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 (173) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +329 -0
  3. package/dist/bin/cli.d.ts +3 -0
  4. package/dist/bin/cli.d.ts.map +1 -0
  5. package/dist/bin/cli.js +62 -0
  6. package/dist/bin/cli.js.map +1 -0
  7. package/dist/src/frontend/admin.html +1073 -0
  8. package/dist/src/frontend/annotations.js +546 -0
  9. package/dist/src/frontend/boot.js +90 -0
  10. package/dist/src/frontend/config.js +19 -0
  11. package/dist/src/frontend/dark-mode.js +20 -0
  12. package/dist/src/frontend/diagram/alignment.js +161 -0
  13. package/dist/src/frontend/diagram/clipboard.js +172 -0
  14. package/dist/src/frontend/diagram/constants.js +109 -0
  15. package/dist/src/frontend/diagram/debug.js +43 -0
  16. package/dist/src/frontend/diagram/edge-panel.js +260 -0
  17. package/dist/src/frontend/diagram/edge-rendering.js +12 -0
  18. package/dist/src/frontend/diagram/grid.js +78 -0
  19. package/dist/src/frontend/diagram/groups.js +102 -0
  20. package/dist/src/frontend/diagram/history.js +153 -0
  21. package/dist/src/frontend/diagram/image-name-modal.js +48 -0
  22. package/dist/src/frontend/diagram/image-upload.js +36 -0
  23. package/dist/src/frontend/diagram/label-editor.js +115 -0
  24. package/dist/src/frontend/diagram/link-panel.js +144 -0
  25. package/dist/src/frontend/diagram/main.js +299 -0
  26. package/dist/src/frontend/diagram/network.js +1473 -0
  27. package/dist/src/frontend/diagram/node-panel.js +267 -0
  28. package/dist/src/frontend/diagram/node-rendering.js +773 -0
  29. package/dist/src/frontend/diagram/persistence.js +161 -0
  30. package/dist/src/frontend/diagram/ports.js +386 -0
  31. package/dist/src/frontend/diagram/selection-overlay.js +336 -0
  32. package/dist/src/frontend/diagram/state.js +39 -0
  33. package/dist/src/frontend/diagram/t.js +3 -0
  34. package/dist/src/frontend/diagram/toast.js +21 -0
  35. package/dist/src/frontend/diagram/unlock-hold.js +182 -0
  36. package/dist/src/frontend/diagram/zoom.js +20 -0
  37. package/dist/src/frontend/diagram-link-modal.js +137 -0
  38. package/dist/src/frontend/diagram.html +1279 -0
  39. package/dist/src/frontend/documents.js +373 -0
  40. package/dist/src/frontend/export.js +338 -0
  41. package/dist/src/frontend/i18n/en.json +406 -0
  42. package/dist/src/frontend/i18n/fr.json +406 -0
  43. package/dist/src/frontend/i18n.js +32 -0
  44. package/dist/src/frontend/image-paste.js +101 -0
  45. package/dist/src/frontend/index.html +2314 -0
  46. package/dist/src/frontend/misc.js +25 -0
  47. package/dist/src/frontend/new-doc-modal.js +260 -0
  48. package/dist/src/frontend/new-folder-modal.js +174 -0
  49. package/dist/src/frontend/search.js +157 -0
  50. package/dist/src/frontend/sidebar-helpers.js +58 -0
  51. package/dist/src/frontend/sidebar.js +182 -0
  52. package/dist/src/frontend/snippet-detect.js +25 -0
  53. package/dist/src/frontend/snippet-table.js +85 -0
  54. package/dist/src/frontend/snippet-tree.js +94 -0
  55. package/dist/src/frontend/snippets.js +534 -0
  56. package/dist/src/frontend/state.js +28 -0
  57. package/dist/src/frontend/utils.js +21 -0
  58. package/dist/src/frontend/vendor/wordcloud2.js +1187 -0
  59. package/dist/src/frontend/wordcloud.js +693 -0
  60. package/dist/src/lib/config.d.ts +17 -0
  61. package/dist/src/lib/config.d.ts.map +1 -0
  62. package/dist/src/lib/config.js +79 -0
  63. package/dist/src/lib/config.js.map +1 -0
  64. package/dist/src/lib/parser.d.ts +11 -0
  65. package/dist/src/lib/parser.d.ts.map +1 -0
  66. package/dist/src/lib/parser.js +111 -0
  67. package/dist/src/lib/parser.js.map +1 -0
  68. package/dist/src/mcp/server.d.ts +3 -0
  69. package/dist/src/mcp/server.d.ts.map +1 -0
  70. package/dist/src/mcp/server.js +986 -0
  71. package/dist/src/mcp/server.js.map +1 -0
  72. package/dist/src/mcp/tools/diagrams.d.ts +44 -0
  73. package/dist/src/mcp/tools/diagrams.d.ts.map +1 -0
  74. package/dist/src/mcp/tools/diagrams.js +245 -0
  75. package/dist/src/mcp/tools/diagrams.js.map +1 -0
  76. package/dist/src/mcp/tools/documents.d.ts +26 -0
  77. package/dist/src/mcp/tools/documents.d.ts.map +1 -0
  78. package/dist/src/mcp/tools/documents.js +127 -0
  79. package/dist/src/mcp/tools/documents.js.map +1 -0
  80. package/dist/src/mcp/tools/source.d.ts +29 -0
  81. package/dist/src/mcp/tools/source.d.ts.map +1 -0
  82. package/dist/src/mcp/tools/source.js +200 -0
  83. package/dist/src/mcp/tools/source.js.map +1 -0
  84. package/dist/src/routes/annotations.d.ts +3 -0
  85. package/dist/src/routes/annotations.d.ts.map +1 -0
  86. package/dist/src/routes/annotations.js +83 -0
  87. package/dist/src/routes/annotations.js.map +1 -0
  88. package/dist/src/routes/browse.d.ts +3 -0
  89. package/dist/src/routes/browse.d.ts.map +1 -0
  90. package/dist/src/routes/browse.js +75 -0
  91. package/dist/src/routes/browse.js.map +1 -0
  92. package/dist/src/routes/config.d.ts +3 -0
  93. package/dist/src/routes/config.d.ts.map +1 -0
  94. package/dist/src/routes/config.js +97 -0
  95. package/dist/src/routes/config.js.map +1 -0
  96. package/dist/src/routes/diagrams.d.ts +3 -0
  97. package/dist/src/routes/diagrams.d.ts.map +1 -0
  98. package/dist/src/routes/diagrams.js +69 -0
  99. package/dist/src/routes/diagrams.js.map +1 -0
  100. package/dist/src/routes/documents.d.ts +8 -0
  101. package/dist/src/routes/documents.d.ts.map +1 -0
  102. package/dist/src/routes/documents.js +332 -0
  103. package/dist/src/routes/documents.js.map +1 -0
  104. package/dist/src/routes/export.d.ts +3 -0
  105. package/dist/src/routes/export.d.ts.map +1 -0
  106. package/dist/src/routes/export.js +277 -0
  107. package/dist/src/routes/export.js.map +1 -0
  108. package/dist/src/routes/images.d.ts +3 -0
  109. package/dist/src/routes/images.d.ts.map +1 -0
  110. package/dist/src/routes/images.js +49 -0
  111. package/dist/src/routes/images.js.map +1 -0
  112. package/dist/src/routes/wordcloud.d.ts +3 -0
  113. package/dist/src/routes/wordcloud.d.ts.map +1 -0
  114. package/dist/src/routes/wordcloud.js +95 -0
  115. package/dist/src/routes/wordcloud.js.map +1 -0
  116. package/dist/src/server.d.ts +7 -0
  117. package/dist/src/server.d.ts.map +1 -0
  118. package/dist/src/server.js +76 -0
  119. package/dist/src/server.js.map +1 -0
  120. package/dist/starting-doc/.annotations.json +3 -0
  121. package/dist/starting-doc/.diagrams.json +1884 -0
  122. package/dist/starting-doc/.living-doc.json +39 -0
  123. package/dist/starting-doc/1_tutorial/2026_04_11_13_25_[General]_crer_vos_dossiers.md +16 -0
  124. package/dist/starting-doc/1_tutorial/2026_04_11_18_58_[General]_creer_un_document_dans_un_dossier.md +9 -0
  125. package/dist/starting-doc/1_tutorial/2026_04_12_09_00_[General]_editer_et_sauvegarder.md +39 -0
  126. package/dist/starting-doc/1_tutorial/2026_04_12_10_00_[General]_utiliser_les_snippets.md +71 -0
  127. package/dist/starting-doc/2026_04_08_20_52_[General]_welcome.md +17 -0
  128. package/dist/starting-doc/2026_04_11_12_55_[General]_premiers_pas.md +271 -0
  129. package/dist/starting-doc/2_guide/2026_04_08_00_04_[DOCUMENT]_utilisation_des_images_plein_ecran_lien_clickable.md +40 -0
  130. package/dist/starting-doc/2_guide/2026_04_08_23_38_[Configuration]_demarrage_de_living_documentation.md +32 -0
  131. package/dist/starting-doc/2_guide/2026_04_09_09_00_[NAVIGATION]_recherche_plein_texte.md +65 -0
  132. package/dist/starting-doc/2_guide/2026_04_09_10_00_[EXPORT]_exporter_en_pdf.md +43 -0
  133. package/dist/starting-doc/2_guide/2026_04_09_11_00_[Configuration]_configurer_le_panneau_admin.md +55 -0
  134. package/dist/starting-doc/2_guide/2026_04_09_12_00_[Configuration]_extra_files.md +68 -0
  135. package/dist/starting-doc/2_guide/2026_04_09_13_00_[WORDCLOUD]_word_cloud.md +54 -0
  136. package/dist/starting-doc/2_guide/2026_04_09_14_00_[DIAGRAM]_creer_et_lier_un_diagramme.md +77 -0
  137. package/dist/starting-doc/3_concept/2026_04_08_20_58_[DOCUMENTING]_ADRS.md +20 -0
  138. package/dist/starting-doc/3_concept/2026_04_08_22_15_[DOCUMENTING]_living_documentation.md +17 -0
  139. package/dist/starting-doc/3_concept/2026_04_08_22_46_[METHODOLOGY]_diataxis_architecture_du_contenu.md +16 -0
  140. package/dist/starting-doc/4_reference/2026_04_08_23_14_[FUNDAMENTALS]_the_living_documentation_tool.md +41 -0
  141. package/dist/starting-doc/4_reference/2026_04_09_01_00_[REFERENCE]_raccourcis_clavier.md +61 -0
  142. package/dist/starting-doc/4_reference/2026_04_09_02_00_[REFERENCE]_tokens_pattern_nommage.md +75 -0
  143. package/dist/starting-doc/4_reference/2026_04_09_03_00_[REFERENCE]_types_de_snippets.md +68 -0
  144. package/dist/starting-doc/4_reference/2026_04_11_17_31_[FUNDAMENTALS]_architecturer_une_documentation.md +12 -0
  145. package/dist/starting-doc/4_reference/2026_04_12_14_07_[FUNDAMENTALS]_dossiers_et_catgories.md +89 -0
  146. package/dist/starting-doc/images/admin_screenshot.png +0 -0
  147. package/dist/starting-doc/images/ajout-document.png +0 -0
  148. package/dist/starting-doc/images/ajouter-document-categorie.png +0 -0
  149. package/dist/starting-doc/images/ajouter_un_document_dans_un_dossier.png +0 -0
  150. package/dist/starting-doc/images/architecturer_une_documentation_reference.png +0 -0
  151. package/dist/starting-doc/images/cr_er_un_document.png +0 -0
  152. package/dist/starting-doc/images/creation-nouveau-dossier.png +0 -0
  153. package/dist/starting-doc/images/creer-document-context-engineering.png +0 -0
  154. package/dist/starting-doc/images/creer-dossier-only-tutoriel.png +0 -0
  155. package/dist/starting-doc/images/creer-dossier-tutoriel.png +0 -0
  156. package/dist/starting-doc/images/creer-dossiers-done.png +0 -0
  157. package/dist/starting-doc/images/creer-un-document.png +0 -0
  158. package/dist/starting-doc/images/creer-vos-dossiers-tutoriel.png +0 -0
  159. package/dist/starting-doc/images/creer-vos-dossiers.png +0 -0
  160. package/dist/starting-doc/images/decouverte_adrs.png +0 -0
  161. package/dist/starting-doc/images/diataxis.png +0 -0
  162. package/dist/starting-doc/images/diataxis_callout.png +0 -0
  163. package/dist/starting-doc/images/document-cree.png +0 -0
  164. package/dist/starting-doc/images/liens_snippets.png +0 -0
  165. package/dist/starting-doc/images/living_documentation.png +0 -0
  166. package/dist/starting-doc/images/npm_logo.png +0 -0
  167. package/dist/starting-doc/images/popup-creer-document.png +0 -0
  168. package/dist/starting-doc/images/popup-creer-dossier.png +0 -0
  169. package/dist/starting-doc/images/popup-dossier-cree.png +0 -0
  170. package/dist/starting-doc/images/quatre-dossiers-crees.png +0 -0
  171. package/dist/starting-doc/images/screenshot-living-doc.png +0 -0
  172. package/dist/starting-doc/images/the_living_documentation_tool.png +0 -0
  173. package/package.json +49 -0
@@ -0,0 +1,1473 @@
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 { startLabelEdit, startEdgeLabelEdit, commitLabelEdit, hideLabelInput } from './label-editor.js';
16
+ import { updateSelectionOverlay, hideSelectionOverlay } from './selection-overlay.js';
17
+ import { drawGrid, onDragEnd, snapToGrid } from './grid.js';
18
+ import { onDragging, drawAlignmentGuides, clearAlignGuides } from './alignment.js';
19
+ import { drawDebugOverlay } from './debug.js';
20
+ import { updateZoomDisplay } from './zoom.js';
21
+ import { expandSelectionToGroup, drawGroupOutlines } from './groups.js';
22
+ import { navigateNodeLink, hideLinkPanel } from './link-panel.js';
23
+ import { getNearestPort, getPortPosition, drawPortDots, drawPortEdge, distanceToPortEdge, wrapText } from './ports.js';
24
+ import { getLastFreeArrowStyle } from './edge-panel.js';
25
+ import { getLastNodeStyle } from './node-panel.js';
26
+ import { installUnlockHold } from './unlock-hold.js';
27
+
28
+ // Module-level port-hover state — shared between initNetwork event handlers and
29
+ // module-level helpers (_onAnchorSnapConnect).
30
+ let _hoveredPortNodeId = null;
31
+ let _hoveredPortKey = null;
32
+ let _draggingAnchorIds = new Set();
33
+ // Rehook state: tracks which port is hovered while a port edge is selected.
34
+ let _rehookEdgeId = null;
35
+ let _rehookHoveredNodeId = null;
36
+ let _rehookHoveredPortKey = null;
37
+
38
+ // Returns true when an edge can enter rehook mode (at least one non-anchor endpoint).
39
+ // Works for both port edges and native vis-network edges.
40
+ function isRehookable(edgeData) {
41
+ if (!edgeData) return false;
42
+ const fromNode = st.nodes.get(edgeData.from);
43
+ const toNode = st.nodes.get(edgeData.to);
44
+ return !!(fromNode && fromNode.shapeType !== 'anchor') || !!(toNode && toNode.shapeType !== 'anchor');
45
+ }
46
+
47
+ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
48
+ const container = document.getElementById('vis-canvas');
49
+
50
+ const edgeSmooth = edgesStraight ? { enabled: false } : { type: 'continuous' };
51
+
52
+ st.nodes = new vis.DataSet(
53
+ savedNodes.map((n) => ({
54
+ ...n,
55
+ ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign),
56
+ ...(n.locked ? { fixed: { x: true, y: true }, draggable: false } : {}),
57
+ }))
58
+ );
59
+ st.edges = new vis.DataSet(
60
+ savedEdges.map((e) => {
61
+ const toNode = savedNodes.find((n) => n.id === e.to);
62
+ const isAnchor = toNode && toNode.shapeType === 'anchor';
63
+ const edgeObj = {
64
+ ...e,
65
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
66
+ smooth: isAnchor ? { enabled: false } : edgeSmooth,
67
+ ...(e.edgeColor ? { color: { color: e.edgeColor, highlight: '#f97316', hover: '#f97316' } } : {}),
68
+ ...(e.edgeWidth ? { width: e.edgeWidth } : {}),
69
+ // Edge labels are always drawn by drawEdgeLabels() in afterDrawing
70
+ // (gives us positioning + rotation control). Hide vis-network's native
71
+ // label text entirely so it never appears alongside ours.
72
+ ...(e.label
73
+ ? { font: { size: e.fontSize || 11, align: 'middle', color: 'rgba(0,0,0,0)' } }
74
+ : e.fontSize
75
+ ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } }
76
+ : {}
77
+ ),
78
+ };
79
+ // Port edges: hide vis-network's own rendering (line + arrowhead).
80
+ // drawPortEdge() handles all visual output; vis-network edge is a
81
+ // transparent ghost kept only for hit-detection (click selection).
82
+ if (e.fromPort || e.toPort) {
83
+ edgeObj.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
84
+ edgeObj.arrows = { to: { enabled: false }, from: { enabled: false } };
85
+ }
86
+ return edgeObj;
87
+ })
88
+ );
89
+
90
+ const options = {
91
+ physics: { enabled: false },
92
+ interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
93
+ 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 } },
94
+ 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)' } },
95
+ manipulation: {
96
+ enabled: false,
97
+ addEdge(data, callback) {
98
+ // Block edges from/to locked nodes or anchors (free-arrow endpoints).
99
+ // Also block when the target sits below the source in z-order — the
100
+ // mouseup handler turns that case into a free-arrow endpoint instead.
101
+ const fromNode = st.nodes.get(data.from) || {};
102
+ const toNode = st.nodes.get(data.to) || {};
103
+ const fromZ = st.canonicalOrder.indexOf(data.from);
104
+ const toZ = st.canonicalOrder.indexOf(data.to);
105
+ const toBelow = fromZ !== -1 && toZ !== -1 && toZ < fromZ;
106
+ if (fromNode.locked || toNode.locked || fromNode.shapeType === 'anchor' || toNode.shapeType === 'anchor' || toBelow) {
107
+ _addEdgeFromPort = null;
108
+ setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
109
+ return;
110
+ }
111
+ pushSnapshot();
112
+ data.id = 'e' + Date.now();
113
+ data.arrowDir = 'to';
114
+ data.dashes = false;
115
+ // Attach captured port selections — skip for anchor nodes.
116
+ const fromIsAnchor = fromNode.shapeType === 'anchor';
117
+ const toIsAnchor = toNode.shapeType === 'anchor';
118
+ if (!fromIsAnchor) data.fromPort = _addEdgeFromPort || null;
119
+ if (!toIsAnchor) data.toPort = _hoveredPortKey || null;
120
+ Object.assign(data, visEdgeProps('to', false));
121
+ // Port edges: make vis-network's ghost transparent so only drawPortEdge is visible.
122
+ if (data.fromPort || data.toPort) {
123
+ data.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
124
+ data.arrows = { to: { enabled: false }, from: { enabled: false } };
125
+ }
126
+ callback(data);
127
+ markDirty();
128
+ _addEdgeFromPort = null;
129
+ setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
130
+ },
131
+ },
132
+ };
133
+
134
+ if (st.network) st.network.destroy();
135
+ st.network = new vis.Network(container, { nodes: st.nodes, edges: st.edges }, options);
136
+
137
+ // ── Lock interception ───────────────────────────────────────────────────────
138
+ // Must be registered BEFORE any other capture-phase mousedown listeners on
139
+ // the container so it can stopImmediatePropagation for locked targets.
140
+ installUnlockHold(container);
141
+
142
+ // ── Z-order patch ──────────────────────────────────────────────────────────
143
+ // vis.js renders in 3 passes (normal → selected → hovered), which breaks
144
+ // user-defined stacking. We replace _drawNodes with a single pass in
145
+ // canonicalOrder so hover/selection never override the user's z-order.
146
+ st.canonicalOrder = [...st.network.body.nodeIndices];
147
+ st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
148
+ const bodyNodes = this.body.nodes;
149
+ const bodyEdges = this.body.edges;
150
+ const margin = 20;
151
+ const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
152
+ const bottomRight = this.canvas.DOMtoCanvas({ x: this.canvas.frame.canvas.clientWidth + margin, y: this.canvas.frame.canvas.clientHeight + margin });
153
+ const viewableArea = { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x };
154
+
155
+ // Build a map: canonical index → list of edges whose topmost endpoint is at that index.
156
+ const orderMap = new Map();
157
+ st.canonicalOrder.forEach((id, i) => orderMap.set(id, i));
158
+
159
+ const edgesByLevel = new Map(); // canonicalIndex → edge[]
160
+ for (const edgeId of Object.keys(bodyEdges)) {
161
+ const edge = bodyEdges[edgeId];
162
+ if (!edge.connected) continue;
163
+ // Edges are drawn just before the node at their assigned level, so they
164
+ // appear on top of all nodes below that level.
165
+ // Use Math.max so the edge follows the higher-z endpoint — it stays visible
166
+ // above any intermediate nodes between the two endpoints.
167
+ // Anchor nodes are floating endpoints that must not raise the edge above the
168
+ // real source: for anchor edges, use the non-anchor endpoint's level.
169
+ // Temp edges (e.g. the ghost drawn by vis-network's addEdgeMode) have one
170
+ // endpoint that's not in our DataSet (a temp controlNode). Use the real
171
+ // endpoint's level so the source node still draws on top of the ghost's
172
+ // centre origin — otherwise the ghost line is visible from the node's
173
+ // centre instead of appearing to start at its boundary/port.
174
+ const fromData = st.nodes.get(edge.fromId);
175
+ const toData = st.nodes.get(edge.toId);
176
+ const fromIsAnchor = fromData && fromData.shapeType === 'anchor';
177
+ const toIsAnchor = toData && toData.shapeType === 'anchor';
178
+ const fromLevel = orderMap.get(edge.fromId);
179
+ const toLevel = orderMap.get(edge.toId);
180
+ let level;
181
+ if (fromLevel === undefined && toLevel === undefined) {
182
+ level = 0;
183
+ } else if (fromLevel === undefined) {
184
+ level = toLevel;
185
+ } else if (toLevel === undefined) {
186
+ level = fromLevel;
187
+ } else if (toIsAnchor && !fromIsAnchor) {
188
+ level = fromLevel;
189
+ } else if (fromIsAnchor && !toIsAnchor) {
190
+ level = toLevel;
191
+ } else {
192
+ level = Math.min(fromLevel, toLevel);
193
+ }
194
+ if (!edgesByLevel.has(level)) edgesByLevel.set(level, []);
195
+ edgesByLevel.get(level).push(edge);
196
+ }
197
+
198
+ // Build a map: anchorId → the edge(s) that connect to it, so we can draw
199
+ // each anchor dot AFTER its edge (giving the "planted arrowhead" effect).
200
+ const anchorEdgeLevel = new Map(); // anchorId → level at which its edge is drawn
201
+ for (const [level, edges] of edgesByLevel) {
202
+ for (const edge of edges) {
203
+ const fromData = st.nodes.get(edge.fromId);
204
+ const toData = st.nodes.get(edge.toId);
205
+ if (toData && toData.shapeType === 'anchor') anchorEdgeLevel.set(edge.toId, level);
206
+ if (fromData && fromData.shapeType === 'anchor') anchorEdgeLevel.set(edge.fromId, level);
207
+ }
208
+ }
209
+
210
+ // Anchors with no connected edge are drawn at level 0 (bottom).
211
+ for (const id of st.canonicalOrder) {
212
+ const n = st.nodes.get(id);
213
+ if (!n || n.shapeType !== 'anchor') continue;
214
+ if (!anchorEdgeLevel.has(id)) anchorEdgeLevel.set(id, 0);
215
+ }
216
+
217
+ for (let i = 0; i < st.canonicalOrder.length; i++) {
218
+ const id = st.canonicalOrder[i];
219
+ const n = st.nodes.get(id);
220
+
221
+ // Draw edges whose level is i, before drawing the node at level i.
222
+ const edges = edgesByLevel.get(i);
223
+ if (edges) {
224
+ edges.forEach((e) => {
225
+ // Always let vis-network draw first.
226
+ // – Non-port edges: fully rendered by vis-network (normal path).
227
+ // – Port edges: transparent ghost (invisible line + arrowhead);
228
+ // drawPortEdge() overlays the actual bezier path on top.
229
+ e.draw(ctx);
230
+ const edgeData = st.edges.get(e.id);
231
+ if (edgeData && (edgeData.fromPort || edgeData.toPort)) {
232
+ drawPortEdge(ctx, edgeData);
233
+ }
234
+ });
235
+ // Draw any anchor whose edge was just drawn, so the dot appears on top.
236
+ for (const [anchorId, level] of anchorEdgeLevel) {
237
+ if (level !== i) continue;
238
+ const anchorNode = bodyNodes[anchorId];
239
+ if (!anchorNode) continue;
240
+ if (alwaysShow === true || anchorNode.isBoundingBoxOverlappingWith(viewableArea) === true) {
241
+ anchorNode.draw(ctx);
242
+ } else {
243
+ anchorNode.updateBoundingBox(ctx, anchorNode.selected);
244
+ }
245
+ }
246
+ }
247
+
248
+ // Skip anchor nodes here — they were drawn right after their edge above.
249
+ if (n && n.shapeType === 'anchor') continue;
250
+
251
+ const node = bodyNodes[id];
252
+ if (!node) continue;
253
+ // Always draw every node — no viewport culling.
254
+ // Culling would skip draw() for off-screen nodes, leaving shape.width/height
255
+ // at vis-network's default of 50, which breaks getNodeAt() hit-testing.
256
+ node.draw(ctx);
257
+ }
258
+ return { drawExternalLabels() {} };
259
+ };
260
+
261
+ // ── Z-order patch for edges ────────────────────────────────────────────────
262
+ // vis.js draws all edges before all nodes in separate passes.
263
+ // We neutralise _drawEdges (make it a no-op) and instead draw each edge
264
+ // inside _drawNodes, just before the node whose canonical index equals the
265
+ // max index of its two endpoints. This guarantees true z-order interleaving.
266
+ st.network.renderer._drawEdges = function () { /* no-op — edges drawn in _drawNodes */ };
267
+
268
+ // Keep canonicalOrder in sync with DataSet add/remove events
269
+ st.nodes.on('add', (_, { items }) => {
270
+ const existing = new Set(st.canonicalOrder);
271
+ items.forEach((id) => { if (!existing.has(id)) st.canonicalOrder.push(id); });
272
+ });
273
+ st.nodes.on('remove', (_, { items, oldData }) => {
274
+ const removed = new Set(items);
275
+ st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
276
+ // If an anchor was deleted directly, also remove its connected edges.
277
+ (oldData || []).forEach((n) => {
278
+ if (n.shapeType !== 'anchor') return;
279
+ const connected = st.edges.get({ filter: (e) => e.from === n.id || e.to === n.id });
280
+ if (connected.length) st.edges.remove(connected.map((e) => e.id));
281
+ });
282
+ });
283
+
284
+ // When an edge is removed, delete any anchor node that has no remaining edges.
285
+ st.edges.on('remove', (_, { oldData }) => {
286
+ const anchorsToCheck = new Set();
287
+ (oldData || []).forEach((edge) => {
288
+ const toData = st.nodes.get(edge.to);
289
+ const fromData = st.nodes.get(edge.from);
290
+ if (toData && toData.shapeType === 'anchor') anchorsToCheck.add(edge.to);
291
+ if (fromData && fromData.shapeType === 'anchor') anchorsToCheck.add(edge.from);
292
+ });
293
+ anchorsToCheck.forEach((anchorId) => {
294
+ const remaining = st.edges.get({ filter: (e) => e.from === anchorId || e.to === anchorId });
295
+ if (remaining.length === 0) st.nodes.remove(anchorId);
296
+ });
297
+ });
298
+
299
+ st.network.on('click', onClickNode);
300
+ st.network.on('doubleClick', onDoubleClick);
301
+ st.network.on('dragStart', onDragStart);
302
+ st.network.on('selectNode', onSelectNode);
303
+ st.network.on('deselectNode', onDeselectAll);
304
+ st.network.on('selectEdge', onSelectEdge);
305
+ st.network.on('deselectEdge', onDeselectAll);
306
+ st.network.on('zoom', updateZoomDisplay);
307
+ st.network.on('dragging', onDragging);
308
+ st.network.on('dragEnd', (p) => { _draggingAnchorIds.clear(); _hoveredPortNodeId = null; _hoveredPortKey = null; _onAnchorSnapConnect(p); onDragEnd(p); clearAlignGuides(); });
309
+ st.network.on('beforeDrawing', drawGrid);
310
+ st.network.on('afterDrawing', updateSelectionOverlay);
311
+ st.network.on('afterDrawing', drawAlignmentGuides);
312
+ st.network.on('afterDrawing', (ctx) => drawGroupOutlines(ctx));
313
+ st.network.on('afterDrawing', () => drawDebugOverlay());
314
+ st.network.on('afterDrawing', drawEdgeLabels);
315
+ st.network.on('afterDrawing', (ctx) => {
316
+ if (_hoveredPortNodeId && (st.currentTool === 'addEdge' || _draggingAnchorIds.size > 0)) {
317
+ drawPortDots(ctx, _hoveredPortNodeId, _hoveredPortKey);
318
+ }
319
+ // Rehook: show port dots on both endpoints of the selected port edge so
320
+ // the user can click a different port to reconnect that end of the arrow.
321
+ if (_rehookEdgeId && st.currentTool !== 'addEdge' && _draggingAnchorIds.size === 0) {
322
+ const edgeData = st.edges.get(_rehookEdgeId);
323
+ if (isRehookable(edgeData)) {
324
+ const fromNode = st.nodes.get(edgeData.from);
325
+ if (fromNode && fromNode.shapeType !== 'anchor') {
326
+ drawPortDots(ctx, edgeData.from, _rehookHoveredNodeId === edgeData.from ? _rehookHoveredPortKey : null);
327
+ }
328
+ const toNode = st.nodes.get(edgeData.to);
329
+ if (toNode && toNode.shapeType !== 'anchor') {
330
+ drawPortDots(ctx, edgeData.to, _rehookHoveredNodeId === edgeData.to ? _rehookHoveredPortKey : null);
331
+ }
332
+ }
333
+ }
334
+ });
335
+ // Two-click free-arrow: draw an orange dot at the pending first-click origin.
336
+ st.network.on('afterDrawing', (ctx) => {
337
+ if (st.currentTool !== 'addEdge' || !st.freeArrowFirstPoint) return;
338
+ const { x, y } = st.freeArrowFirstPoint;
339
+ ctx.beginPath();
340
+ ctx.arc(x, y, 6, 0, Math.PI * 2);
341
+ ctx.fillStyle = '#f97316';
342
+ ctx.fill();
343
+ ctx.strokeStyle = '#ffffff';
344
+ ctx.lineWidth = 2;
345
+ ctx.stroke();
346
+ });
347
+
348
+ // ── Edge label resize ─────────────────────────────────────────────────────────
349
+ // When a single edge is selected and has a label, draw two orange resize handles
350
+ // at the left and right sides of the dashed label box. Dragging either handle
351
+ // resizes the box symmetrically (centre fixed). On release the edge's
352
+ // edgeLabelWidth is committed and the text wraps accordingly.
353
+ //
354
+ // Key design: _hoverHandle is computed during mousemove (before any mousedown).
355
+ // The mousedown handler reads this pre-computed state rather than doing its own
356
+ // hit-detection — this avoids a race with vis-network's capture-phase handlers
357
+ // which may run first and alter selection state before our mousedown fires.
358
+ {
359
+ let _lr = null; // active resize: { edgeId, bboxCx, bboxCy, rotation }
360
+ let _hoverHandle = null; // { edgeId, bboxCx, bboxCy, rotation } | null
361
+ let _ld = null; // active label drag: { edgeId, startMouse, startOffsetX, startOffsetY, dragging }
362
+ let _hoverLabelDrag = null; // { edgeId } | null
363
+
364
+ // Draw handles for the selected edge label.
365
+ st.network.on('afterDrawing', (ctx) => {
366
+ if (!st.selectedEdgeIds || st.selectedEdgeIds.length !== 1) return;
367
+ const edgeId = st.selectedEdgeIds[0];
368
+ const e = st.edges.get(edgeId);
369
+ if (!e || !e.label) return;
370
+ const bbox = st.edgeLabelBBox && st.edgeLabelBBox[edgeId];
371
+ if (!bbox) return;
372
+
373
+ ctx.save();
374
+ ctx.translate(bbox.cx, bbox.cy);
375
+ if (bbox.rotation) ctx.rotate(bbox.rotation);
376
+ for (const hx of [-bbox.w / 2, bbox.w / 2]) {
377
+ ctx.beginPath();
378
+ ctx.arc(hx, 0, 5, 0, Math.PI * 2);
379
+ ctx.fillStyle = '#f97316';
380
+ ctx.strokeStyle = '#ffffff';
381
+ ctx.lineWidth = 1.5;
382
+ ctx.fill();
383
+ ctx.stroke();
384
+ }
385
+ ctx.restore();
386
+ });
387
+
388
+ // Track hover during mousemove — computed before any mousedown fires.
389
+ container.addEventListener('mousemove', (e) => {
390
+ if (!st.network || !st.edgeLabelBBox) return;
391
+ const cp = st.network.DOMtoCanvas({ x: e.offsetX, y: e.offsetY });
392
+ const hr = 8 / st.network.getScale();
393
+
394
+ // ── Resize handle detection ──────────────────────────────────────────────
395
+ let found = null;
396
+ for (const edgeId of (st.selectedEdgeIds || [])) {
397
+ const edge = st.edges && st.edges.get(edgeId);
398
+ if (!edge || !edge.label) continue;
399
+ const bbox = st.edgeLabelBBox[edgeId];
400
+ if (!bbox) continue;
401
+
402
+ const r = -(bbox.rotation || 0);
403
+ const dx = cp.x - bbox.cx, dy = cp.y - bbox.cy;
404
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
405
+ const ly = dx * Math.sin(r) + dy * Math.cos(r);
406
+
407
+ if (Math.hypot(lx - (-bbox.w / 2), ly) < hr || Math.hypot(lx - (bbox.w / 2), ly) < hr) {
408
+ found = { edgeId, bboxCx: bbox.cx, bboxCy: bbox.cy, rotation: bbox.rotation || 0 };
409
+ break;
410
+ }
411
+ }
412
+ if (Boolean(found) !== Boolean(_hoverHandle)) {
413
+ container.style.cursor = found ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
414
+ }
415
+ _hoverHandle = found;
416
+
417
+ // ── Label box hover detection (drag) — only when no handle hovered ───────
418
+ let labelFound = null;
419
+ if (!found && st.selectedEdgeIds && st.selectedEdgeIds.length === 1) {
420
+ const edgeId = st.selectedEdgeIds[0];
421
+ const edge = st.edges && st.edges.get(edgeId);
422
+ if (edge && edge.label) {
423
+ const bbox = st.edgeLabelBBox && st.edgeLabelBBox[edgeId];
424
+ if (bbox) {
425
+ const r = -(bbox.rotation || 0);
426
+ const dx = cp.x - bbox.cx, dy = cp.y - bbox.cy;
427
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
428
+ const ly = dx * Math.sin(r) + dy * Math.cos(r);
429
+ if (Math.abs(lx) <= bbox.w / 2 && Math.abs(ly) <= bbox.h / 2) {
430
+ labelFound = { edgeId };
431
+ }
432
+ }
433
+ }
434
+ }
435
+ if (Boolean(labelFound) !== Boolean(_hoverLabelDrag)) {
436
+ if (!found) container.style.cursor = labelFound ? 'grab' : '';
437
+ }
438
+ _hoverLabelDrag = labelFound;
439
+ });
440
+
441
+ // Mousedown: start resize (handles take priority) or label drag.
442
+ container.addEventListener('mousedown', (e) => {
443
+ if (e.button !== 0) return;
444
+ if (_hoverHandle) {
445
+ _lr = { ..._hoverHandle, dragging: false };
446
+ st.network.setOptions({ interaction: { dragView: false } });
447
+ } else if (_hoverLabelDrag) {
448
+ const edge = st.edges && st.edges.get(_hoverLabelDrag.edgeId);
449
+ if (!edge) return;
450
+ _ld = {
451
+ edgeId: _hoverLabelDrag.edgeId,
452
+ startMouse: { x: e.clientX, y: e.clientY },
453
+ startOffsetX: edge.edgeLabelOffsetX || 0,
454
+ startOffsetY: edge.edgeLabelOffsetY || 0,
455
+ dragging: false,
456
+ };
457
+ st.network.setOptions({ interaction: { dragView: false } });
458
+ }
459
+ }, { capture: true });
460
+
461
+ // Update width (resize) or offset (label drag) while dragging.
462
+ document.addEventListener('mousemove', (e) => {
463
+ if (!st.network) return;
464
+ if (_lr) {
465
+ if (!_lr.dragging) { _lr.dragging = true; pushSnapshot(); }
466
+ const rect = container.getBoundingClientRect();
467
+ const cp = st.network.DOMtoCanvas({ x: e.clientX - rect.left, y: e.clientY - rect.top });
468
+ const r = -_lr.rotation;
469
+ const dx = cp.x - _lr.bboxCx, dy = cp.y - _lr.bboxCy;
470
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
471
+ st.edges.update({ id: _lr.edgeId, edgeLabelWidth: Math.max(40, Math.abs(lx) * 2) });
472
+ st.network.redraw();
473
+ }
474
+ if (_ld) {
475
+ if (!_ld.dragging) {
476
+ if (Math.hypot(e.clientX - _ld.startMouse.x, e.clientY - _ld.startMouse.y) < 4) return;
477
+ _ld.dragging = true;
478
+ pushSnapshot();
479
+ }
480
+ const scale = st.network.getScale();
481
+ st.edges.update({
482
+ id: _ld.edgeId,
483
+ edgeLabelOffsetX: _ld.startOffsetX + (e.clientX - _ld.startMouse.x) / scale,
484
+ edgeLabelOffsetY: _ld.startOffsetY + (e.clientY - _ld.startMouse.y) / scale,
485
+ });
486
+ st.network.redraw();
487
+ }
488
+ });
489
+
490
+ // Commit on mouseup.
491
+ document.addEventListener('mouseup', () => {
492
+ if (_lr) {
493
+ st.network.setOptions({ interaction: { dragView: true } });
494
+ if (_lr.dragging) markDirty();
495
+ _lr = null;
496
+ }
497
+ if (_ld) {
498
+ st.network.setOptions({ interaction: { dragView: true } });
499
+ if (_ld.dragging) markDirty();
500
+ _ld = null;
501
+ }
502
+ container.style.cursor = _hoverHandle ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
503
+ });
504
+ }
505
+
506
+ // ── Free-arrow body drag ──────────────────────────────────────────────────────
507
+ // Dragging the body of a free arrow (anchor-anchor edge) should move the whole
508
+ // arrow. vis-network pans the canvas instead (no node at the body position).
509
+ // Fix: capture mousedown on the edge body, disable dragView, move anchors manually.
510
+ {
511
+ let _fad = null; // free-arrow drag state
512
+ container.addEventListener('mousedown', (e) => {
513
+ if (e.button !== 0 || !st.network) return;
514
+ const domPos = { x: e.offsetX, y: e.offsetY };
515
+ // If the click landed on a node (including an anchor endpoint), don't intercept:
516
+ // the user wants to move only that endpoint (pivot), not the whole arrow.
517
+ if (st.network.getNodeAt(domPos)) return;
518
+ const edgeId = st.network.getEdgeAt(domPos);
519
+ if (!edgeId) return;
520
+ const edge = st.edges.get(edgeId);
521
+ if (!edge) return;
522
+ const fromN = st.nodes.get(edge.from);
523
+ const toN = st.nodes.get(edge.to);
524
+ if (!(fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor')) return;
525
+ const startPos = st.network.getPositions([edge.from, edge.to]);
526
+ _fad = { edgeId, fromId: edge.from, toId: edge.to,
527
+ startMouse: { x: e.clientX, y: e.clientY }, startPos, dragging: false };
528
+ }, { capture: true });
529
+
530
+ document.addEventListener('mousemove', (e) => {
531
+ if (!_fad || !st.network) return;
532
+ const dx = e.clientX - _fad.startMouse.x;
533
+ const dy = e.clientY - _fad.startMouse.y;
534
+ if (!_fad.dragging) {
535
+ if (Math.hypot(dx, dy) < 4) return;
536
+ _fad.dragging = true;
537
+ pushSnapshot();
538
+ st.network.setOptions({ interaction: { dragView: false } });
539
+ }
540
+ const scale = st.network.getScale();
541
+ const fp = _fad.startPos[_fad.fromId];
542
+ const tp = _fad.startPos[_fad.toId];
543
+ const snap = (x, y) => st.gridEnabled ? snapToGrid(x, y) : { x, y };
544
+ if (fp) { const p = snap(fp.x + dx / scale, fp.y + dy / scale); st.network.moveNode(_fad.fromId, p.x, p.y); }
545
+ if (tp) { const p = snap(tp.x + dx / scale, tp.y + dy / scale); st.network.moveNode(_fad.toId, p.x, p.y); }
546
+ st.network.redraw();
547
+ });
548
+
549
+ document.addEventListener('mouseup', () => {
550
+ if (!_fad) return;
551
+ if (_fad.dragging) {
552
+ st.network.setOptions({ interaction: { dragView: true } });
553
+ markDirty();
554
+ }
555
+ _fad = null;
556
+ });
557
+ }
558
+
559
+ // ── Free-floating edge: two interaction paths ─────────────────────────────────
560
+ // Path A — drag from a real node to empty canvas → node→anchor endpoint.
561
+ // Intercepted via raw DOM mousedown/mouseup (vis-network's addEdge
562
+ // callback only fires when releasing ON an existing node).
563
+ // Path B — two successive CLICKS on empty canvas → free-standing anchor→anchor.
564
+ // Uses vis-network's own 'click' event: Hammer.js fires 'click' only
565
+ // for taps, NOT for drags, so Path A drags never bleed into Path B.
566
+ let _addEdgeFromId = null;
567
+ let _addEdgeFromPort = null; // port key on the source node (null = no port)
568
+ // _hoveredPortNodeId, _hoveredPortKey, _draggingAnchorIds are module-level.
569
+ const visCanvas = document.getElementById('vis-canvas');
570
+
571
+ // Track hovered node + nearest port while in addEdge mode OR while dragging an anchor.
572
+ visCanvas.addEventListener('mousemove', (e) => {
573
+ const inAddEdge = st.currentTool === 'addEdge';
574
+ const anchorDragging = _draggingAnchorIds.size > 0;
575
+ if ((!inAddEdge && !anchorDragging) || !st.network) return;
576
+
577
+ const pos = { x: e.offsetX, y: e.offsetY };
578
+ const cp = st.network.DOMtoCanvas(pos);
579
+
580
+ let newPortNodeId = null;
581
+
582
+ if (inAddEdge) {
583
+ // addEdge mode: use getNodeAt as before
584
+ const nodeId = st.network.getNodeAt(pos) || null;
585
+ const nodeData = nodeId && st.nodes.get(nodeId);
586
+ const isAnchor = nodeData && nodeData.shapeType === 'anchor';
587
+ const isLocked = nodeData && nodeData.locked;
588
+ newPortNodeId = (nodeId && !isAnchor && !isLocked) ? nodeId : null;
589
+ } else {
590
+ // Anchor drag: getNodeAt returns the dragged anchor itself — do a canvas-space
591
+ // bounding-box search for any non-anchor node that contains the cursor.
592
+ const SNAP_THRESHOLD = 30;
593
+ let bestId = null;
594
+ let bestDist = Infinity;
595
+ for (const [candidateId, candidatePos] of Object.entries(st.network.getPositions())) {
596
+ if (_draggingAnchorIds.has(candidateId)) continue;
597
+ const candidate = st.nodes.get(candidateId);
598
+ if (!candidate || candidate.shapeType === 'anchor') continue;
599
+ const bodyNode = st.network.body.nodes[candidateId];
600
+ if (!bodyNode) continue;
601
+ const w = (bodyNode.shape && bodyNode.shape.width) || SHAPE_DEFAULTS[candidate.shapeType]?.width || 120;
602
+ const h = (bodyNode.shape && bodyNode.shape.height) || SHAPE_DEFAULTS[candidate.shapeType]?.height || 60;
603
+ const inBox = cp.x >= candidatePos.x - w / 2 - SNAP_THRESHOLD &&
604
+ cp.x <= candidatePos.x + w / 2 + SNAP_THRESHOLD &&
605
+ cp.y >= candidatePos.y - h / 2 - SNAP_THRESHOLD &&
606
+ cp.y <= candidatePos.y + h / 2 + SNAP_THRESHOLD;
607
+ if (inBox) {
608
+ const dist = Math.hypot(candidatePos.x - cp.x, candidatePos.y - cp.y);
609
+ if (dist < bestDist) { bestDist = dist; bestId = candidateId; }
610
+ }
611
+ }
612
+ newPortNodeId = bestId;
613
+ }
614
+
615
+ const newPortKey = newPortNodeId ? getNearestPort(newPortNodeId, cp) : null;
616
+ if (newPortNodeId !== _hoveredPortNodeId || newPortKey !== _hoveredPortKey) {
617
+ _hoveredPortNodeId = newPortNodeId;
618
+ _hoveredPortKey = newPortKey;
619
+ st.network.redraw();
620
+ }
621
+ });
622
+
623
+ // Rehook hover: track the nearest port on the from/to nodes of the selected port edge.
624
+ visCanvas.addEventListener('mousemove', (e) => {
625
+ if (!st.network || st.currentTool === 'addEdge' || _draggingAnchorIds.size > 0) return;
626
+ const selectedEdge = st.selectedEdgeIds.length === 1 ? st.edges.get(st.selectedEdgeIds[0]) : null;
627
+ if (!isRehookable(selectedEdge)) {
628
+ if (_rehookEdgeId !== null) {
629
+ _rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
630
+ st.network.redraw();
631
+ }
632
+ return;
633
+ }
634
+
635
+ _rehookEdgeId = selectedEdge.id;
636
+ const cp = st.network.DOMtoCanvas({ x: e.offsetX, y: e.offsetY });
637
+ const REHOOK_THRESHOLD = 15; // world units
638
+ let newNodeId = null;
639
+ let newPortKey = null;
640
+ let closestDist = Infinity;
641
+
642
+ for (const nodeId of [selectedEdge.from, selectedEdge.to]) {
643
+ const nodeData = st.nodes.get(nodeId);
644
+ if (!nodeData || nodeData.shapeType === 'anchor') continue;
645
+ const portKey = getNearestPort(nodeId, cp);
646
+ const portPos = getPortPosition(nodeId, portKey);
647
+ if (!portPos) continue;
648
+ const dist = Math.hypot(cp.x - portPos.x, cp.y - portPos.y);
649
+ if (dist <= REHOOK_THRESHOLD && dist < closestDist) {
650
+ closestDist = dist;
651
+ newNodeId = nodeId;
652
+ newPortKey = portKey;
653
+ }
654
+ }
655
+
656
+ if (newNodeId !== _rehookHoveredNodeId || newPortKey !== _rehookHoveredPortKey) {
657
+ _rehookHoveredNodeId = newNodeId;
658
+ _rehookHoveredPortKey = newPortKey;
659
+ st.network.redraw();
660
+ }
661
+ });
662
+
663
+ // Path A – capture source node on mousedown.
664
+ visCanvas.addEventListener('mousedown', (e) => {
665
+ if (st.currentTool !== 'addEdge') return;
666
+ const nodeId = st.network.getNodeAt({ x: e.offsetX, y: e.offsetY }) || null;
667
+ const nodeData = nodeId && st.nodes.get(nodeId);
668
+ // Block starting an edge from a locked node or an anchor (free-arrow endpoint).
669
+ if (nodeData && (nodeData.locked || nodeData.shapeType === 'anchor')) { _addEdgeFromId = null; return; }
670
+ // Dragging from a real node cancels any pending two-click origin.
671
+ if (nodeId) st.freeArrowFirstPoint = null;
672
+ _addEdgeFromId = nodeId;
673
+ _addEdgeFromPort = _hoveredPortKey;
674
+ });
675
+
676
+ // Path A – release on empty canvas creates the anchor endpoint.
677
+ visCanvas.addEventListener('mouseup', (e) => {
678
+ if (st.currentTool !== 'addEdge' || !_addEdgeFromId) { _addEdgeFromId = null; return; }
679
+ const pos = { x: e.offsetX, y: e.offsetY };
680
+ const targetId = st.network.getNodeAt(pos);
681
+ const targetData = targetId && st.nodes.get(targetId);
682
+ // Locked targets are non-interactive: treat them exactly like empty canvas
683
+ // so a free-arrow endpoint is created at the release point — matches the
684
+ // behaviour of free arrows drawn over a locked shape (Path B).
685
+ // Also: if the target sits BELOW the source in z-order (e.g. a post-it on
686
+ // top of a background image), don't bind to it — the user is aiming at a
687
+ // point on top of the source's layer, not at the underlying shape.
688
+ const targetLocked = !!(targetData && targetData.locked);
689
+ const sourceZ = st.canonicalOrder.indexOf(_addEdgeFromId);
690
+ const targetZ = targetId ? st.canonicalOrder.indexOf(targetId) : -1;
691
+ const targetBelow = targetId && sourceZ !== -1 && targetZ !== -1 && targetZ < sourceZ;
692
+ if (!targetId || targetLocked || targetBelow) {
693
+ pushSnapshot();
694
+ const cp = st.network.DOMtoCanvas(pos);
695
+ const anchorId = 'a' + Date.now();
696
+ st.nodes.add({
697
+ id: anchorId, label: '', shapeType: 'anchor', colorKey: 'c-gray',
698
+ nodeWidth: 8, nodeHeight: 8, fontSize: null, rotation: 0, labelRotation: 0,
699
+ x: cp.x, y: cp.y,
700
+ ...visNodeProps('anchor', 'c-gray', 8, 8, null, null, null),
701
+ });
702
+ st.edges.add({
703
+ id: 'e' + Date.now(), from: _addEdgeFromId, to: anchorId,
704
+ arrowDir: 'to', dashes: false,
705
+ smooth: { enabled: false },
706
+ ...visEdgeProps('to', false),
707
+ });
708
+ markDirty();
709
+ st.freeArrowFirstPoint = null;
710
+ setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
711
+ }
712
+ _addEdgeFromId = null;
713
+ });
714
+
715
+ // Path B – two successive clicks on empty canvas → free-standing anchor→anchor arrow.
716
+ // vis-network's 'click' event (via Hammer.js) only fires for taps, never for drags,
717
+ // so Path A drag gestures cannot accidentally trigger Path B.
718
+ st.network.on('click', (params) => {
719
+ if (st.currentTool !== 'addEdge') return;
720
+ // Only handle clicks that landed on empty canvas (no node, no edge).
721
+ if (params.nodes.length > 0 || params.edges.length > 0) return;
722
+ const cp = params.pointer.canvas;
723
+ if (!st.freeArrowFirstPoint) {
724
+ // First click: record the origin and show the orange indicator dot.
725
+ st.freeArrowFirstPoint = { x: cp.x, y: cp.y };
726
+ st.network.redraw();
727
+ } else {
728
+ // Second click: create the free-standing anchor→anchor arrow.
729
+ pushSnapshot();
730
+ const t = Date.now();
731
+ const fromId = 'a' + t;
732
+ const toId = 'a' + (t + 1);
733
+ const anchorProps = visNodeProps('anchor', 'c-gray', 8, 8, null, null, null);
734
+ st.nodes.add([
735
+ { 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 },
736
+ { 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 },
737
+ ]);
738
+ const edgeId = 'e' + t;
739
+ const lastStyle = getLastFreeArrowStyle();
740
+ const arrowDir = lastStyle.arrowDir || 'to';
741
+ const dashes = lastStyle.dashes || false;
742
+ st.edges.add({
743
+ id: edgeId, from: fromId, to: toId,
744
+ arrowDir, dashes,
745
+ edgeColor: lastStyle.edgeColor || null,
746
+ edgeWidth: lastStyle.edgeWidth || null,
747
+ smooth: { enabled: false },
748
+ ...visEdgeProps(arrowDir, dashes),
749
+ ...(lastStyle.edgeColor ? { color: { color: lastStyle.edgeColor, highlight: '#f97316', hover: '#f97316' } } : {}),
750
+ ...(lastStyle.edgeWidth ? { width: lastStyle.edgeWidth } : {}),
751
+ });
752
+ st.freeArrowFirstPoint = null;
753
+ markDirty();
754
+ setTimeout(() => {
755
+ st.network.setSelection({ nodes: [fromId, toId], edges: [edgeId] });
756
+ st.selectedNodeIds = [fromId, toId];
757
+ st.selectedEdgeIds = [edgeId];
758
+ showEdgePanel();
759
+ st.network.addEdgeMode();
760
+ }, 0);
761
+ }
762
+ });
763
+
764
+ document.getElementById('emptyState').classList.add('hidden');
765
+ updateZoomDisplay();
766
+
767
+ // vis-network initialises shape.width/height to 50 (not undefined), so
768
+ // needsRefresh() returns false and resize() never runs on the first render.
769
+ // Fix: reset shape.width/height to undefined so needsRefresh() returns true
770
+ // and vis-network's own render loop runs resize() with the correct context.
771
+ st.network.once('afterDrawing', () => {
772
+ for (const id of st.network.body.nodeIndices) {
773
+ const bn = st.network.body.nodes[id];
774
+ if (!bn || !bn.shape) continue;
775
+ bn.shape.width = undefined;
776
+ bn.shape.height = undefined;
777
+ }
778
+ // Patch CustomShape.distanceToBorder so anchor nodes report 0: arrow tips
779
+ // land at the anchor centre instead of 8 canvas units away (half the 16×16
780
+ // hit-box). Without this, short free arrows render with reversed or
781
+ // overlapping arrowheads because the 8+8 boundary offset exceeds the
782
+ // segment length, and the visible dot sits far from the arrow tip.
783
+ for (const id of st.network.body.nodeIndices) {
784
+ const bn = st.network.body.nodes[id];
785
+ if (!bn || !bn.shape) continue;
786
+ const proto = Object.getPrototypeOf(bn.shape);
787
+ if (proto.__ldAnchorDistancePatched) break;
788
+ const orig = proto.distanceToBorder;
789
+ proto.distanceToBorder = function (ctx, angle) {
790
+ const data = st.nodes && st.nodes.get(this.options && this.options.id);
791
+ if (data && data.shapeType === 'anchor') return 0;
792
+ return orig.call(this, ctx, angle);
793
+ };
794
+ proto.__ldAnchorDistancePatched = true;
795
+ break;
796
+ }
797
+ st.network.redraw();
798
+ });
799
+ }
800
+
801
+ // ── Anchor snap-to-connect ────────────────────────────────────────────────────
802
+ // When a free-arrow anchor endpoint is dropped near/onto another node or anchor,
803
+ // reconnect the edge to that target and remove the orphaned anchor.
804
+ function _onAnchorSnapConnect(params) {
805
+ if (!params.nodes || params.nodes.length === 0) return;
806
+ if (!st.network || !st.nodes || !st.edges) return;
807
+
808
+ const SNAP_THRESHOLD = 30; // canvas units
809
+
810
+ let snapped = false;
811
+
812
+ for (const anchorId of params.nodes) {
813
+ const anchor = st.nodes.get(anchorId);
814
+ if (!anchor || anchor.shapeType !== 'anchor') continue;
815
+
816
+ // Find the edge this anchor belongs to (as from or to)
817
+ const connectedEdges = st.edges.get().filter(e => e.from === anchorId || e.to === anchorId);
818
+ if (connectedEdges.length === 0) continue;
819
+
820
+ // Current position of dragged anchor
821
+ const pos = st.network.getPositions([anchorId])[anchorId];
822
+ if (!pos) continue;
823
+
824
+ // Find best snap target: any node except this anchor itself and the other
825
+ // endpoint of the same edge (avoid self-loop on anchor→anchor)
826
+ const siblingIds = new Set();
827
+ for (const e of connectedEdges) {
828
+ siblingIds.add(e.from);
829
+ siblingIds.add(e.to);
830
+ }
831
+ siblingIds.delete(anchorId); // keep sibling (other endpoint) in set to block self-loop
832
+
833
+ let bestId = null;
834
+ let bestDist = Infinity;
835
+
836
+ for (const [candidateId, candidatePos] of Object.entries(st.network.getPositions())) {
837
+ if (candidateId === anchorId) continue;
838
+ if (siblingIds.has(candidateId)) continue; // would create a self-loop
839
+
840
+ const candidate = st.nodes.get(candidateId);
841
+ if (!candidate) continue;
842
+
843
+ const dist = Math.hypot(candidatePos.x - pos.x, candidatePos.y - pos.y);
844
+
845
+ if (candidate.shapeType === 'anchor') {
846
+ // Snap to another anchor if within threshold
847
+ if (dist < SNAP_THRESHOLD && dist < bestDist) {
848
+ bestId = candidateId;
849
+ bestDist = dist;
850
+ }
851
+ } else {
852
+ // Snap to a regular node if anchor falls within its bounding box (padded by threshold)
853
+ const bodyNode = st.network.body.nodes[candidateId];
854
+ if (!bodyNode) continue;
855
+ const w = (bodyNode.shape && bodyNode.shape.width) || SHAPE_DEFAULTS[candidate.shapeType]?.width || 120;
856
+ const h = (bodyNode.shape && bodyNode.shape.height) || SHAPE_DEFAULTS[candidate.shapeType]?.height || 60;
857
+ const cx = candidatePos.x;
858
+ const cy = candidatePos.y;
859
+ const inBox = pos.x >= cx - w / 2 - SNAP_THRESHOLD &&
860
+ pos.x <= cx + w / 2 + SNAP_THRESHOLD &&
861
+ pos.y >= cy - h / 2 - SNAP_THRESHOLD &&
862
+ pos.y <= cy + h / 2 + SNAP_THRESHOLD;
863
+ if (inBox && dist < bestDist) {
864
+ bestId = candidateId;
865
+ bestDist = dist;
866
+ }
867
+ }
868
+ }
869
+
870
+ if (!bestId) continue;
871
+
872
+ if (!snapped) { pushSnapshot(); snapped = true; }
873
+
874
+ // Determine which port to attach to on the target node (non-anchor targets only).
875
+ const targetNode = st.nodes.get(bestId);
876
+ const isAnchorTarget = targetNode && targetNode.shapeType === 'anchor';
877
+ // Use the hovered port (computed by mousemove) when available; fall back to nearest port.
878
+ let portKey = null;
879
+ if (!isAnchorTarget) {
880
+ portKey = (_hoveredPortNodeId === bestId && _hoveredPortKey)
881
+ ? _hoveredPortKey
882
+ : getNearestPort(bestId, pos);
883
+ }
884
+
885
+ // Reconnect every edge that uses this anchor as an endpoint
886
+ for (const e of connectedEdges) {
887
+ if (e.from === anchorId) {
888
+ const update = { id: e.id, from: bestId };
889
+ if (portKey) update.fromPort = portKey;
890
+ // Port edges must be transparent ghosts so only drawPortEdge is visible.
891
+ if (portKey || e.toPort) {
892
+ update.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
893
+ update.arrows = { to: { enabled: false }, from: { enabled: false } };
894
+ }
895
+ st.edges.update(update);
896
+ }
897
+ if (e.to === anchorId) {
898
+ const update = { id: e.id, to: bestId };
899
+ if (portKey) update.toPort = portKey;
900
+ // Port edges must be transparent ghosts so only drawPortEdge is visible.
901
+ if (portKey || e.fromPort) {
902
+ update.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
903
+ update.arrows = { to: { enabled: false }, from: { enabled: false } };
904
+ }
905
+ st.edges.update(update);
906
+ }
907
+ }
908
+
909
+ // Remove the orphaned anchor
910
+ st.nodes.remove(anchorId);
911
+ markDirty();
912
+ }
913
+ }
914
+
915
+ // ── Edge label rendering ──────────────────────────────────────────────────────
916
+ // All edge labels (with or without rotation) are drawn here in afterDrawing.
917
+ // vis-network's native edge label is always made transparent so only this
918
+ // renderer is visible — eliminating the dual-rendering issue that caused
919
+ // labels to appear twice at different positions.
920
+ function drawEdgeLabels(ctx) {
921
+ try {
922
+ if (!st.edges || !st.network) return;
923
+
924
+ const m = ctx.getTransform();
925
+ const dpr = window.devicePixelRatio || 1;
926
+ const canvasEl = ctx.canvas;
927
+ const container = document.getElementById('vis-canvas').parentElement;
928
+ const canvasRect = canvasEl.getBoundingClientRect();
929
+ const containerRect = container.getBoundingClientRect();
930
+ const offsetX = canvasRect.left - containerRect.left;
931
+ const offsetY = canvasRect.top - containerRect.top;
932
+
933
+ st.edges.get().forEach((e) => {
934
+ // Port edges draw their own labels inside drawPortEdge — skip here.
935
+ if (e.fromPort || e.toPort) return;
936
+
937
+ // Compute bezier midpoint in layout space for every edge (labeled or not)
938
+ // so the label editor always has an accurate DOM position to open at.
939
+ const bodyEdge = st.network.body.edges[e.id];
940
+ let mx, my;
941
+ if (bodyEdge && bodyEdge.edgeType && typeof bodyEdge.edgeType.getPoint === 'function') {
942
+ const pt = bodyEdge.edgeType.getPoint(0.5);
943
+ mx = pt.x;
944
+ my = pt.y;
945
+ } else {
946
+ const positions = st.network.getPositions([e.from, e.to]);
947
+ const fp = positions[e.from];
948
+ const tp = positions[e.to];
949
+ if (!fp || !tp) return;
950
+ mx = (fp.x + tp.x) / 2;
951
+ my = (fp.y + tp.y) / 2;
952
+ }
953
+
954
+ const ox = e.edgeLabelOffsetX || 0;
955
+ const oy = e.edgeLabelOffsetY || 0;
956
+ const lx = mx + ox;
957
+ const ly = my + oy;
958
+
959
+ st.edgeLabelCanvasPos[e.id] = {
960
+ x: (m.a * lx + m.e) / dpr + offsetX,
961
+ y: (m.d * ly + m.f) / dpr + offsetY,
962
+ };
963
+
964
+ // Only draw the label text for edges that have one.
965
+ if (!e.label) return;
966
+
967
+ const fontSize = e.fontSize || 11;
968
+ const lineHeight = fontSize * 1.5;
969
+ const PAD_X = 6, PAD_Y = 4;
970
+
971
+ ctx.save();
972
+ ctx.translate(lx, ly);
973
+ if (e.labelRotation && Math.abs(e.labelRotation) > 0.001) {
974
+ ctx.rotate(e.labelRotation);
975
+ }
976
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
977
+
978
+ const fixedW = e.edgeLabelWidth || null;
979
+ const innerW = fixedW ? fixedW - PAD_X * 2 : null;
980
+ const lines = fixedW ? wrapText(ctx, e.label, innerW) : [e.label];
981
+ const textW = fixedW ? innerW : ctx.measureText(e.label).width;
982
+ const boxW = textW + PAD_X * 2;
983
+ const boxH = lines.length * lineHeight + PAD_Y * 2;
984
+
985
+ // Always store bbox (needed for resize handles, even when not selected)
986
+ st.edgeLabelBBox[e.id] = {
987
+ cx: lx, cy: ly,
988
+ w: boxW, h: boxH,
989
+ rotation: e.labelRotation || 0,
990
+ };
991
+
992
+ const totalH = lines.length * lineHeight;
993
+ const TEXT_PAD = 3;
994
+ const isDark = document.documentElement.classList.contains('dark');
995
+ const bgFill = isDark ? 'rgba(3,7,18,0.82)' : 'rgba(249,250,251,0.82)';
996
+
997
+ // Opaque background tight around the text — makes the arrow appear to pass behind.
998
+ ctx.save();
999
+ ctx.fillStyle = bgFill;
1000
+ ctx.fillRect(-textW / 2 - TEXT_PAD, -totalH / 2 - TEXT_PAD,
1001
+ textW + TEXT_PAD * 2, totalH + TEXT_PAD * 2);
1002
+ ctx.restore();
1003
+
1004
+ // Dashed border box — only when the edge is selected
1005
+ if (st.selectedEdgeIds && st.selectedEdgeIds.includes(e.id)) {
1006
+ ctx.save();
1007
+ ctx.strokeStyle = '#9ca3af';
1008
+ ctx.lineWidth = 0.8;
1009
+ ctx.setLineDash([3, 3]);
1010
+ ctx.strokeRect(-boxW / 2, -boxH / 2, boxW, boxH);
1011
+ ctx.setLineDash([]);
1012
+ ctx.restore();
1013
+ }
1014
+
1015
+ ctx.fillStyle = '#6b7280';
1016
+ ctx.textAlign = 'center';
1017
+ ctx.textBaseline = 'middle';
1018
+ lines.forEach((line, i) => {
1019
+ const y = -totalH / 2 + i * lineHeight + lineHeight / 2;
1020
+ ctx.fillText(line, 0, y);
1021
+ });
1022
+
1023
+ ctx.restore();
1024
+ });
1025
+ } catch(err) { console.error('[draw] EXCEPTION:', err); }
1026
+ }
1027
+
1028
+ // ── Network event handlers ────────────────────────────────────────────────────
1029
+
1030
+ // Distance from point (px,py) to segment (ax,ay)-(bx,by) in canvas space.
1031
+ function _distToSegment(px, py, ax, ay, bx, by) {
1032
+ const dx = bx - ax, dy = by - ay;
1033
+ const lenSq = dx * dx + dy * dy;
1034
+ if (lenSq === 0) return Math.hypot(px - ax, py - ay);
1035
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
1036
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1037
+ }
1038
+
1039
+
1040
+ // Returns the topmost (highest z-order) node whose bounding box contains canvasPos.
1041
+ // Ignores anchor nodes and respects st.canonicalOrder.
1042
+ function topmostNodeAt(canvasPos) {
1043
+ let topmost = null;
1044
+ let topmostIdx = -1;
1045
+ for (const id of st.canonicalOrder) {
1046
+ const n = st.nodes.get(id);
1047
+ const bn = st.network && st.network.body.nodes[id];
1048
+ if (!n || !bn || n.shapeType === 'anchor') continue;
1049
+ const defaults = SHAPE_DEFAULTS[n.shapeType || 'box'] || [60, 28];
1050
+ const W = n.nodeWidth || defaults[0];
1051
+ const H = n.nodeHeight || defaults[1];
1052
+ const cx = bn.x, cy = bn.y;
1053
+ if (canvasPos.x >= cx - W / 2 && canvasPos.x <= cx + W / 2 &&
1054
+ canvasPos.y >= cy - H / 2 && canvasPos.y <= cy + H / 2) {
1055
+ const idx = st.canonicalOrder.indexOf(id);
1056
+ if (idx > topmostIdx) { topmostIdx = idx; topmost = id; }
1057
+ }
1058
+ }
1059
+ return topmost;
1060
+ }
1061
+
1062
+ function onDoubleClick(params) {
1063
+ // Locked nodes never intercept double-click — filter them out first.
1064
+ const unlockedNodes = params.nodes.filter(id => { const n = st.nodes.get(id); return n && !n.locked; });
1065
+
1066
+ // When nodes and edges both match the click position, honour z-order:
1067
+ // only give the click to a node if it is truly the topmost element there.
1068
+ // Otherwise fall through to edge handling.
1069
+ let effectiveNodes = unlockedNodes;
1070
+ if (unlockedNodes.length > 0 && params.edges.length > 0) {
1071
+ const top = topmostNodeAt(params.pointer.canvas);
1072
+ effectiveNodes = (top && unlockedNodes.includes(top)) ? [top] : [];
1073
+ }
1074
+
1075
+ if (effectiveNodes.length > 0) {
1076
+ st.selectedNodeIds = effectiveNodes;
1077
+ st.network.selectNodes(st.selectedNodeIds);
1078
+ showNodePanel();
1079
+ startLabelEdit();
1080
+ } else if (params.edges.length > 0) {
1081
+ st.selectedEdgeIds = [params.edges[0]];
1082
+ showEdgePanel();
1083
+ startEdgeLabelEdit();
1084
+ } else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
1085
+ const canvasPos = params.pointer.canvas;
1086
+ pickAndCreateImageNode(canvasPos.x, canvasPos.y);
1087
+ } else if (st.currentTool === 'addNode') {
1088
+ pushSnapshot();
1089
+ const id = 'n' + Date.now();
1090
+ const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
1091
+ const fallbackColor = st.pendingShape === 'post-it' ? 'c-amber' : 'c-gray';
1092
+ const lastStyle = getLastNodeStyle(st.pendingShape);
1093
+ const colorKey = lastStyle.colorKey || fallbackColor;
1094
+ const fontSize = lastStyle.fontSize || null;
1095
+ const textAlign = lastStyle.textAlign || null;
1096
+ const textValign = lastStyle.textValign || null;
1097
+ const rawPos = params.pointer.canvas;
1098
+ const pos = st.gridEnabled ? snapToGrid(rawPos.x, rawPos.y) : rawPos;
1099
+ st.nodes.add({
1100
+ id, label: st.pendingShape === 'text-free' ? t('diagram.label_input.placeholder') : 'Node',
1101
+ shapeType: st.pendingShape, colorKey,
1102
+ nodeWidth: defaults[0], nodeHeight: defaults[1],
1103
+ fontSize, textAlign, textValign,
1104
+ rotation: 0, labelRotation: 0,
1105
+ x: pos.x, y: pos.y,
1106
+ ...visNodeProps(st.pendingShape, colorKey, defaults[0], defaults[1], fontSize, textAlign, textValign),
1107
+ });
1108
+ markDirty();
1109
+ setTimeout(() => {
1110
+ st.network.selectNodes([id]);
1111
+ st.selectedNodeIds = [id];
1112
+ showNodePanel();
1113
+ startLabelEdit();
1114
+ }, 50);
1115
+ }
1116
+ }
1117
+
1118
+ // ── Image node creation ───────────────────────────────────────────────────────
1119
+
1120
+ function pickAndCreateImageNode(canvasX, canvasY) {
1121
+ const input = document.createElement('input');
1122
+ input.type = 'file';
1123
+ input.accept = 'image/*';
1124
+ input.onchange = async () => {
1125
+ const file = input.files && input.files[0];
1126
+ if (!file) return;
1127
+ const name = await promptImageName();
1128
+ if (name === null) return; // user cancelled
1129
+ try {
1130
+ const src = await uploadImageFile(file, name);
1131
+ createImageNode(src, canvasX, canvasY);
1132
+ } catch {
1133
+ showToast(t('diagram.toast.image_import_error'), 'error');
1134
+ }
1135
+ };
1136
+ input.click();
1137
+ }
1138
+
1139
+ export function createImageNode(imageSrc, canvasX, canvasY) {
1140
+ if (!st.network) return;
1141
+ const id = 'n' + Date.now();
1142
+ const captionId = id + 'c';
1143
+
1144
+ const addNode = (nW, nH) => {
1145
+ pushSnapshot();
1146
+ const filename = imageSrc.split('/').pop() || '';
1147
+ const textDefs = SHAPE_DEFAULTS['text-free'];
1148
+ const captionH = textDefs[1];
1149
+ const GAP = 8;
1150
+
1151
+ const groupId = 'g' + Date.now();
1152
+ st.nodes.add({
1153
+ id, label: '', imageSrc, groupId,
1154
+ shapeType: 'image', colorKey: 'c-gray',
1155
+ nodeWidth: nW, nodeHeight: nH,
1156
+ fontSize: null, rotation: 0, labelRotation: 0,
1157
+ x: canvasX, y: canvasY,
1158
+ ...visNodeProps('image', 'c-gray', nW, nH, null, null, null),
1159
+ });
1160
+ st.nodes.add({
1161
+ id: captionId, label: filename, groupId,
1162
+ shapeType: 'text-free', colorKey: 'c-gray',
1163
+ nodeWidth: nW, nodeHeight: captionH,
1164
+ fontSize: null, rotation: 0, labelRotation: 0,
1165
+ x: canvasX, y: canvasY + nH / 2 + GAP + captionH / 2,
1166
+ ...visNodeProps('text-free', 'c-gray', nW, captionH, null, null, null),
1167
+ });
1168
+ markDirty();
1169
+ setTimeout(() => {
1170
+ st.network.selectNodes([id]);
1171
+ st.selectedNodeIds = [id];
1172
+ showNodePanel();
1173
+ }, 50);
1174
+ };
1175
+
1176
+ const img = new Image();
1177
+ img.onload = () => {
1178
+ const MAX = 300;
1179
+ const ratio = img.naturalWidth / img.naturalHeight;
1180
+ let nW = img.naturalWidth, nH = img.naturalHeight;
1181
+ if (nW > MAX) { nW = MAX; nH = Math.round(MAX / ratio); }
1182
+ addNode(nW, nH);
1183
+ };
1184
+ img.onerror = () => {
1185
+ const d = SHAPE_DEFAULTS['image'];
1186
+ addNode(d[0], d[1]);
1187
+ };
1188
+ img.src = imageSrc;
1189
+ }
1190
+
1191
+ // ── Edge straight / curved toggle ────────────────────────────────────────────
1192
+ export function toggleEdgeStraight() {
1193
+ if (!st.network) return;
1194
+ pushSnapshot();
1195
+ st.edgesStraight = !st.edgesStraight;
1196
+ const smooth = st.edgesStraight ? { enabled: false } : { type: 'continuous' };
1197
+ // Update global network option first (overrides per-edge inherited defaults).
1198
+ st.network.setOptions({ edges: { smooth } });
1199
+ // Then update each edge individually, keeping anchor edges always straight.
1200
+ const updates = st.edges.get().map((e) => {
1201
+ const toData = st.nodes.get(e.to);
1202
+ const s = (toData && toData.shapeType === 'anchor') ? { enabled: false } : smooth;
1203
+ return { id: e.id, smooth: s };
1204
+ });
1205
+ if (updates.length) st.edges.update(updates);
1206
+ document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
1207
+ markDirty();
1208
+ }
1209
+
1210
+ function onClickNode(params) {
1211
+ if (params.nodes.length === 1 && params.event.srcEvent.shiftKey) {
1212
+ navigateNodeLink(params.nodes[0]);
1213
+ return;
1214
+ }
1215
+
1216
+ // ── Edge rehook ──────────────────────────────────────────────────────────────
1217
+ // When a port edge is selected and the user clicks a port dot on one of its
1218
+ // endpoint nodes, reconnect that end to the new port without losing selection.
1219
+ if (_rehookEdgeId && _rehookHoveredNodeId && _rehookHoveredPortKey) {
1220
+ const edgeData = st.edges.get(_rehookEdgeId);
1221
+ if (edgeData && (edgeData.from === _rehookHoveredNodeId || edgeData.to === _rehookHoveredNodeId)) {
1222
+ const wasPortEdge = !!(edgeData.fromPort || edgeData.toPort);
1223
+ const update = { id: edgeData.id };
1224
+ if (edgeData.from === _rehookHoveredNodeId) update.fromPort = _rehookHoveredPortKey;
1225
+ else update.toPort = _rehookHoveredPortKey;
1226
+ // First port assigned on a native edge: hide vis-network's rendering so
1227
+ // drawPortEdge() takes over without double-rendering.
1228
+ if (!wasPortEdge) {
1229
+ update.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
1230
+ update.arrows = { to: { enabled: false }, from: { enabled: false } };
1231
+ }
1232
+ st.edges.update(update);
1233
+ pushSnapshot();
1234
+ markDirty();
1235
+ const edgeId = edgeData.id;
1236
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1237
+ setTimeout(() => {
1238
+ st.network.setSelection({ nodes: [], edges: [edgeId] });
1239
+ st.selectedEdgeIds = [edgeId];
1240
+ st.selectedNodeIds = [];
1241
+ showEdgePanel();
1242
+ }, 0);
1243
+ return;
1244
+ }
1245
+ }
1246
+
1247
+ // When a node is reported, params.edges is always empty — vis-network short-circuits
1248
+ // edge detection once a node is found. Fix: call getEdgeAt() directly with CLIENT
1249
+ // coordinates. params.pointer.DOM is already offset-relative to the container, so
1250
+ // passing it to getEdgeAt() causes a double-subtraction of the container rect and
1251
+ // returns null. Passing clientX/Y lets vis-network do its own pixel-perfect detection.
1252
+ if (params.nodes.length > 0) {
1253
+ const clientPos = { x: params.event.srcEvent.clientX, y: params.event.srcEvent.clientY };
1254
+ const edgeId = st.network.getEdgeAt(clientPos);
1255
+ if (edgeId) {
1256
+ const edge = st.edges.get(edgeId);
1257
+ const fromN = edge && st.nodes.get(edge.from);
1258
+ const toN = edge && st.nodes.get(edge.to);
1259
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1260
+ // If the user clicked directly on an anchor node (not the edge body), keep only
1261
+ // that single anchor selected so dragging pivots the arrow instead of moving it whole.
1262
+ const clickedAnAnchor = params.nodes.some(id => { const n = st.nodes.get(id); return n && n.shapeType === 'anchor'; });
1263
+ if (isFreeArrow && clickedAnAnchor) return;
1264
+ setTimeout(() => {
1265
+ const sel = isFreeArrow
1266
+ ? { nodes: [edge.from, edge.to], edges: [edgeId] }
1267
+ : { nodes: [], edges: [edgeId] };
1268
+ st.network.setSelection(sel);
1269
+ st.selectedNodeIds = sel.nodes;
1270
+ st.selectedEdgeIds = [edgeId];
1271
+ const e2 = st.edges.get(edgeId);
1272
+ _rehookEdgeId = isRehookable(e2) ? edgeId : null;
1273
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1274
+ hideNodePanel();
1275
+ showEdgePanel();
1276
+ }, 0);
1277
+ return;
1278
+ }
1279
+ // No edge at click position — apply z-order correction for the topmost node.
1280
+ const top = topmostNodeAt(params.pointer.canvas);
1281
+ const clickable = params.nodes.filter(id => {
1282
+ const n = st.nodes.get(id);
1283
+ return n && !n.locked && n.shapeType !== 'anchor';
1284
+ });
1285
+ if (!top || !clickable.includes(top)) {
1286
+ const fallbackEdgeId = params.edges.length > 0 ? params.edges[0] : null;
1287
+ if (fallbackEdgeId) {
1288
+ setTimeout(() => {
1289
+ st.network.setSelection({ nodes: [], edges: [fallbackEdgeId] });
1290
+ st.selectedNodeIds = [];
1291
+ st.selectedEdgeIds = [fallbackEdgeId];
1292
+ const fe = st.edges.get(fallbackEdgeId);
1293
+ _rehookEdgeId = isRehookable(fe) ? fallbackEdgeId : null;
1294
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1295
+ hideNodePanel();
1296
+ showEdgePanel();
1297
+ }, 0);
1298
+ return;
1299
+ }
1300
+ }
1301
+ }
1302
+
1303
+ // Port edges have a transparent ghost path so vis-network's hit detection
1304
+ // misses them when they diverge from the centre-to-centre line.
1305
+ // Also check the canvas for port-edge proximity when nothing was hit.
1306
+ if (params.nodes.length === 0 && params.edges.length === 0) {
1307
+ const cp = params.pointer.canvas;
1308
+ const THRESHOLD = 8;
1309
+
1310
+ // ── Port edge proximity check ──────────────────────────────────────────
1311
+ // vis-network's hit detection uses the invisible centre-to-centre ghost,
1312
+ // so port edges that diverge visually from that path are not selectable.
1313
+ // We scan all port edges and pick the one whose bezier path is closest.
1314
+ const portEdges = st.edges.get({ filter: (e) => e.fromPort || e.toPort });
1315
+ let nearest = null, nearestDist = Infinity;
1316
+ for (const edge of portEdges) {
1317
+ const d = distanceToPortEdge(edge, cp);
1318
+ if (d < nearestDist) { nearestDist = d; nearest = edge; }
1319
+ }
1320
+ if (nearest && nearestDist <= THRESHOLD) {
1321
+ st.network.selectEdges([nearest.id]);
1322
+ st.selectedEdgeIds = [nearest.id];
1323
+ st.selectedNodeIds = [];
1324
+ _rehookEdgeId = nearest.id;
1325
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1326
+ hideNodePanel();
1327
+ showEdgePanel();
1328
+ return;
1329
+ }
1330
+ }
1331
+ // Click landed on empty space (no node, no edge, no rehook) — clear stale rehook state.
1332
+ _rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1333
+ }
1334
+
1335
+ // Expand group selection at dragStart so vis-network moves all members together.
1336
+ // dragStart fires before the move, unlike selectNode which fires after mouseup.
1337
+ function onDragStart(params) {
1338
+ if (!params.nodes.length) return;
1339
+ // Track dragged anchors so the port-dot overlay activates during the drag.
1340
+ _draggingAnchorIds.clear();
1341
+ for (const id of params.nodes) {
1342
+ const n = st.nodes && st.nodes.get(id);
1343
+ if (n && n.shapeType === 'anchor') _draggingAnchorIds.add(id);
1344
+ }
1345
+ const expanded = expandSelectionToGroup(params.nodes);
1346
+ if (expanded.length > params.nodes.length) {
1347
+ st.network.selectNodes(expanded);
1348
+ st.selectedNodeIds = expanded;
1349
+ }
1350
+ }
1351
+
1352
+ let _expandingGroup = false;
1353
+ let _addingEdgesToSelection = false;
1354
+ function onSelectNode(params) {
1355
+ if (_expandingGroup || _addingEdgesToSelection) return;
1356
+ // Filter out anchor nodes — they have no formatting panel.
1357
+ const nonAnchors = params.nodes.filter((id) => { const n = st.nodes.get(id); return !(n && n.shapeType === 'anchor'); });
1358
+ // Drop locked nodes: they are non-interactive until unlocked via long-press.
1359
+ const usable = nonAnchors.filter((id) => { const n = st.nodes.get(id); return n && !n.locked; });
1360
+ if (usable.length !== nonAnchors.length) {
1361
+ _addingEdgesToSelection = true;
1362
+ const anchorIds = params.nodes.filter((id) => { const n = st.nodes.get(id); return n && n.shapeType === 'anchor'; });
1363
+ st.network.setSelection({ nodes: [...usable, ...anchorIds], edges: st.network.getSelectedEdges() });
1364
+ _addingEdgesToSelection = false;
1365
+ }
1366
+ if (!usable.length) {
1367
+ const anchorIds = params.nodes.filter((id) => { const n = st.nodes.get(id); return n && n.shapeType === 'anchor'; });
1368
+ if (anchorIds.length) {
1369
+ // Only anchors selected (individual endpoint drag) — keep selection but no panel.
1370
+ st.selectedNodeIds = anchorIds;
1371
+ st.selectedEdgeIds = [];
1372
+ hideEdgePanel();
1373
+ return;
1374
+ }
1375
+ st.selectedNodeIds = [];
1376
+ st.selectedEdgeIds = [];
1377
+ hideNodePanel();
1378
+ hideEdgePanel();
1379
+ return;
1380
+ }
1381
+ const expanded = expandSelectionToGroup(usable).filter((id) => { const n = st.nodes.get(id); return n && !n.locked; });
1382
+ if (expanded.length > usable.length) {
1383
+ _expandingGroup = true;
1384
+ st.network.selectNodes(expanded);
1385
+ _expandingGroup = false;
1386
+ st.selectedNodeIds = expanded;
1387
+ } else {
1388
+ st.selectedNodeIds = usable;
1389
+ }
1390
+ // Include all edges (regular or free-arrow) whose both endpoints are in the selection.
1391
+ // This makes rubber-band select and multi-select automatically include connected edges.
1392
+ // Exclude locked edges — they are non-interactive.
1393
+ const selectedSet = new Set(st.selectedNodeIds);
1394
+ st.selectedEdgeIds = st.edges.get().filter((e) => {
1395
+ if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1396
+ const fromN = st.nodes.get(e.from);
1397
+ const toN = st.nodes.get(e.to);
1398
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1399
+ return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
1400
+ }).map((e) => e.id);
1401
+ // Tell vis-network to visually highlight the free-arrow edges.
1402
+ // Use a guard to prevent the resulting selectNode event from re-entering.
1403
+ if (st.selectedEdgeIds.length > 0) {
1404
+ _addingEdgesToSelection = true;
1405
+ st.network.setSelection({ nodes: st.network.getSelectedNodes(), edges: st.selectedEdgeIds });
1406
+ _addingEdgesToSelection = false;
1407
+ }
1408
+ hideEdgePanel();
1409
+ showNodePanel();
1410
+ }
1411
+
1412
+ function onSelectEdge(params) {
1413
+ if (st.selectedNodeIds.length > 0) return; // node takes priority
1414
+
1415
+ // Drop locked edges (edgeLocked or free-arrow with both anchors locked).
1416
+ const usable = params.edges.filter((id) => {
1417
+ const e = st.edges.get(id);
1418
+ if (!e) return false;
1419
+ const fromN = st.nodes.get(e.from);
1420
+ const toN = st.nodes.get(e.to);
1421
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1422
+ return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
1423
+ });
1424
+ if (usable.length !== params.edges.length) {
1425
+ st.network.setSelection({ nodes: st.network.getSelectedNodes(), edges: usable });
1426
+ }
1427
+ if (!usable.length) {
1428
+ st.selectedEdgeIds = [];
1429
+ hideEdgePanel();
1430
+ return;
1431
+ }
1432
+ st.selectedEdgeIds = usable;
1433
+ // Activate rehook mode for any edge with at least one non-anchor endpoint.
1434
+ const singleEdge = usable.length === 1 ? st.edges.get(usable[0]) : null;
1435
+ _rehookEdgeId = isRehookable(singleEdge) ? singleEdge.id : null;
1436
+ _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1437
+
1438
+ // For free arrows (anchor→anchor edges), also select both endpoint anchors
1439
+ // so the user can drag the whole arrow as a unit via multi-node drag.
1440
+ const freeAnchors = [];
1441
+ for (const edgeId of usable) {
1442
+ const e = st.edges.get(edgeId);
1443
+ if (!e) continue;
1444
+ const fromN = st.nodes.get(e.from);
1445
+ const toN = st.nodes.get(e.to);
1446
+ if (fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor') {
1447
+ freeAnchors.push(e.from, e.to);
1448
+ }
1449
+ }
1450
+ if (freeAnchors.length) {
1451
+ st.network.setSelection({ nodes: freeAnchors, edges: usable });
1452
+ st.selectedNodeIds = freeAnchors; // kept for drag; no panel (all anchors)
1453
+ } else {
1454
+ st.selectedNodeIds = [];
1455
+ }
1456
+ hideNodePanel();
1457
+ showEdgePanel();
1458
+ }
1459
+
1460
+ function onDeselectAll() {
1461
+ hideLinkPanel();
1462
+ st.selectedNodeIds = [];
1463
+ st.selectedEdgeIds = [];
1464
+ // Rehook state is intentionally NOT cleared here: vis-network fires deselectEdge
1465
+ // before the click event when the user clicks a port dot on a node, so clearing
1466
+ // here would destroy the state that onClickNode needs to process the rehook.
1467
+ // The mousemove handler clears it naturally once the edge is no longer selected.
1468
+ hideNodePanel();
1469
+ hideEdgePanel();
1470
+ commitLabelEdit();
1471
+ hideLabelInput();
1472
+ hideSelectionOverlay();
1473
+ }