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,260 @@
1
+ // ── Edge panel ────────────────────────────────────────────────────────────────
2
+ // Floating formatting toolbar for selected edges (arrow type, line style, font size).
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { visEdgeProps } from './edge-rendering.js';
6
+ import { pushSnapshot } from './history.js';
7
+
8
+ const DEFAULT_EDGE_COLOR = '#a8a29e';
9
+ const FREE_ARROW_STYLE_KEY = 'ld-free-arrow-style';
10
+
11
+ // Persist the style of the first free arrow (anchor→anchor) in the current
12
+ // selection so the next double-click creation reuses it.
13
+ function persistFreeArrowStyle() {
14
+ const freeId = st.selectedEdgeIds.find((id) => {
15
+ const e = st.edges.get(id);
16
+ if (!e) return false;
17
+ const fromN = st.nodes && st.nodes.get(e.from);
18
+ const toN = st.nodes && st.nodes.get(e.to);
19
+ return fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
20
+ });
21
+ if (!freeId) return;
22
+ const e = st.edges.get(freeId);
23
+ localStorage.setItem(FREE_ARROW_STYLE_KEY, JSON.stringify({
24
+ arrowDir: e.arrowDir || 'to',
25
+ dashes: e.dashes || false,
26
+ edgeColor: e.edgeColor || null,
27
+ edgeWidth: e.edgeWidth || null,
28
+ }));
29
+ }
30
+
31
+ export function getLastFreeArrowStyle() {
32
+ try { return JSON.parse(localStorage.getItem(FREE_ARROW_STYLE_KEY)) || {}; }
33
+ catch { return {}; }
34
+ }
35
+
36
+ export function showEdgePanel() {
37
+ if (!st.selectedEdgeIds.length) return;
38
+ const e = st.edges.get(st.selectedEdgeIds[0]);
39
+ if (!e) return;
40
+
41
+ document.getElementById('btnEdgeLock').classList.remove('tool-active');
42
+ document.getElementById('edgePanelControls').classList.remove('hidden');
43
+
44
+ const dir = e.arrowDir ?? 'to';
45
+ const dashes = e.dashes ?? false;
46
+
47
+ ['edgeBtnNone', 'edgeBtnTo', 'edgeBtnBoth'].forEach((id) =>
48
+ document.getElementById(id).classList.remove('edge-btn-active'));
49
+ document.getElementById({ none: 'edgeBtnNone', to: 'edgeBtnTo', both: 'edgeBtnBoth' }[dir] || 'edgeBtnTo')
50
+ .classList.add('edge-btn-active');
51
+
52
+ ['edgeBtnSolid', 'edgeBtnDashed'].forEach((id) =>
53
+ document.getElementById(id).classList.remove('edge-btn-active'));
54
+ document.getElementById(dashes ? 'edgeBtnDashed' : 'edgeBtnSolid').classList.add('edge-btn-active');
55
+
56
+ // Highlight active color dot.
57
+ const activeColor = (e.edgeColor || DEFAULT_EDGE_COLOR).toLowerCase();
58
+ document.querySelectorAll('#edgePanel [data-edge-color]').forEach((btn) => {
59
+ const isActive = btn.dataset.edgeColor.toLowerCase() === activeColor;
60
+ btn.style.outline = isActive ? '2px solid #f97316' : '';
61
+ btn.style.outlineOffset = isActive ? '2px' : '';
62
+ });
63
+
64
+ // Show/hide the clear-ports button based on whether this edge has ports.
65
+ const hasPorts = !!(e.fromPort || e.toPort);
66
+ document.getElementById('btnEdgeClearPorts').classList.toggle('edge-btn-active', hasPorts);
67
+
68
+ document.getElementById('edgePanel').classList.remove('hidden');
69
+ }
70
+
71
+ export function toggleEdgeLock() {
72
+ if (!st.selectedEdgeIds.length) return;
73
+ pushSnapshot();
74
+ // Locking is a one-way UI action — once locked, the only way back is the
75
+ // long-press on the shape itself (see unlock-hold.js).
76
+ st.selectedEdgeIds.forEach((id) => {
77
+ const e = st.edges.get(id);
78
+ if (!e) return;
79
+ const fromN = st.nodes && st.nodes.get(e.from);
80
+ const toN = st.nodes && st.nodes.get(e.to);
81
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
82
+ if (isFreeArrow) {
83
+ [e.from, e.to].forEach((nodeId) => {
84
+ st.nodes.update({ id: nodeId, locked: true, fixed: { x: true, y: true }, draggable: false });
85
+ const bn = st.network && st.network.body.nodes[nodeId];
86
+ if (bn) bn.refreshNeeded = true;
87
+ });
88
+ } else {
89
+ st.edges.update({ id, edgeLocked: true });
90
+ }
91
+ });
92
+ if (st.network) {
93
+ st.network.unselectAll();
94
+ st.network.redraw();
95
+ }
96
+ st.selectedNodeIds = [];
97
+ st.selectedEdgeIds = [];
98
+ hideEdgePanel();
99
+ markDirty();
100
+ }
101
+
102
+ export function resetEdgeLabelWidth() {
103
+ if (!st.selectedEdgeIds.length) return;
104
+ pushSnapshot();
105
+ st.selectedEdgeIds.forEach((id) => {
106
+ st.edges.update({ id, edgeLabelWidth: null });
107
+ });
108
+ markDirty();
109
+ }
110
+
111
+ export function clearEdgePorts() {
112
+ if (!st.selectedEdgeIds.length) return;
113
+ pushSnapshot();
114
+ st.selectedEdgeIds.forEach((id) => {
115
+ const e = st.edges.get(id);
116
+ if (!e) return;
117
+ const color = e.edgeColor || DEFAULT_EDGE_COLOR;
118
+ // Restore vis-network's default edge rendering, preserving custom color/width.
119
+ st.edges.update({
120
+ id,
121
+ fromPort: null,
122
+ toPort: null,
123
+ color: { color, highlight: '#f97316', hover: '#f97316' },
124
+ width: e.edgeWidth || 1.5,
125
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
126
+ });
127
+ });
128
+ showEdgePanel();
129
+ markDirty();
130
+ }
131
+
132
+ export function setEdgeColor(hex) {
133
+ if (!st.selectedEdgeIds.length) return;
134
+ pushSnapshot();
135
+ st.selectedEdgeIds.forEach((id) => {
136
+ const e = st.edges.get(id);
137
+ if (!e) return;
138
+ const update = { id, edgeColor: hex };
139
+ // For non-port edges, also update vis-network's native color property.
140
+ if (!(e.fromPort || e.toPort)) {
141
+ update.color = { color: hex, highlight: '#f97316', hover: '#f97316' };
142
+ }
143
+ st.edges.update(update);
144
+ });
145
+ persistFreeArrowStyle();
146
+ showEdgePanel();
147
+ markDirty();
148
+ }
149
+
150
+ export function changeEdgeWidth(delta) {
151
+ if (!st.selectedEdgeIds.length) return;
152
+ pushSnapshot();
153
+ st.selectedEdgeIds.forEach((id) => {
154
+ const e = st.edges.get(id);
155
+ if (!e) return;
156
+ const newWidth = Math.max(1, Math.min(8, (e.edgeWidth || 1.5) + delta));
157
+ const update = { id, edgeWidth: newWidth };
158
+ // For non-port edges, also update vis-network's native width property.
159
+ if (!(e.fromPort || e.toPort)) {
160
+ update.width = newWidth;
161
+ }
162
+ st.edges.update(update);
163
+ });
164
+ persistFreeArrowStyle();
165
+ markDirty();
166
+ }
167
+
168
+ export function hideEdgePanel() {
169
+ document.getElementById('edgePanel').classList.add('hidden');
170
+ }
171
+
172
+ export function setEdgeArrow(dir) {
173
+ if (!st.selectedEdgeIds.length) return;
174
+ pushSnapshot();
175
+ st.selectedEdgeIds.forEach((id) => {
176
+ const e = st.edges.get(id);
177
+ if (!e) return;
178
+ const update = { id, arrowDir: dir };
179
+ // For port edges, vis-network arrows stay disabled; drawPortEdge handles them.
180
+ if (!(e.fromPort || e.toPort)) Object.assign(update, visEdgeProps(dir, e.dashes ?? false));
181
+ st.edges.update(update);
182
+ });
183
+ persistFreeArrowStyle();
184
+ showEdgePanel();
185
+ markDirty();
186
+ }
187
+
188
+ export function setEdgeDashes(dashes) {
189
+ if (!st.selectedEdgeIds.length) return;
190
+ pushSnapshot();
191
+ st.selectedEdgeIds.forEach((id) => {
192
+ const e = st.edges.get(id);
193
+ if (!e) return;
194
+ const update = { id, dashes };
195
+ // For port edges, vis-network arrows stay disabled; drawPortEdge handles them.
196
+ if (!(e.fromPort || e.toPort)) Object.assign(update, visEdgeProps(e.arrowDir ?? 'to', dashes));
197
+ st.edges.update(update);
198
+ });
199
+ persistFreeArrowStyle();
200
+ showEdgePanel();
201
+ markDirty();
202
+ }
203
+
204
+ export function changeEdgeFontSize(delta) {
205
+ if (!st.selectedEdgeIds.length) return;
206
+ pushSnapshot();
207
+ st.selectedEdgeIds.forEach((id) => {
208
+ const e = st.edges.get(id);
209
+ if (!e) return;
210
+ const newSize = Math.max(8, Math.min(48, (e.fontSize || 11) + delta));
211
+ // Keep native label transparent — drawEdgeLabels() is the single render path.
212
+ st.edges.update({ id, fontSize: newSize, font: { size: newSize, align: 'middle', color: 'rgba(0,0,0,0)' } });
213
+ });
214
+ markDirty();
215
+ }
216
+
217
+ export function stepEdgeLabelRotation(delta) {
218
+ if (!st.selectedEdgeIds.length) return;
219
+ pushSnapshot();
220
+ st.selectedEdgeIds.forEach((id) => {
221
+ const e = st.edges.get(id);
222
+ if (!e) return;
223
+ const newRotation = (e.labelRotation || 0) + delta;
224
+ // Keep native label transparent — drawEdgeLabels() is the single render path.
225
+ st.edges.update({
226
+ id,
227
+ labelRotation: newRotation,
228
+ font: { size: e.fontSize || 11, align: 'middle', color: 'rgba(0,0,0,0)' },
229
+ });
230
+ });
231
+ markDirty();
232
+ }
233
+
234
+ const LABEL_OFFSET_STEP = 5;
235
+
236
+ export function stepEdgeLabelOffset(dx, dy) {
237
+ if (!st.selectedEdgeIds.length) return;
238
+ pushSnapshot();
239
+ st.selectedEdgeIds.forEach((id) => {
240
+ const e = st.edges.get(id);
241
+ if (!e) return;
242
+ st.edges.update({
243
+ id,
244
+ edgeLabelOffsetX: (e.edgeLabelOffsetX || 0) + dx * LABEL_OFFSET_STEP,
245
+ edgeLabelOffsetY: (e.edgeLabelOffsetY || 0) + dy * LABEL_OFFSET_STEP,
246
+ });
247
+ });
248
+ if (st.network) st.network.redraw();
249
+ markDirty();
250
+ }
251
+
252
+ export function resetEdgeLabelOffset() {
253
+ if (!st.selectedEdgeIds.length) return;
254
+ pushSnapshot();
255
+ st.selectedEdgeIds.forEach((id) => {
256
+ st.edges.update({ id, edgeLabelOffsetX: 0, edgeLabelOffsetY: 0 });
257
+ });
258
+ if (st.network) st.network.redraw();
259
+ markDirty();
260
+ }
@@ -0,0 +1,12 @@
1
+ // ── Edge rendering ────────────────────────────────────────────────────────────
2
+ // Builds vis.js edge property objects (arrows, dashes).
3
+
4
+ export function visEdgeProps(arrowDir, dashes) {
5
+ return {
6
+ arrows: {
7
+ to: { enabled: arrowDir === 'to' || arrowDir === 'both', scaleFactor: 0.7 },
8
+ from: { enabled: arrowDir === 'both', scaleFactor: 0.7 },
9
+ },
10
+ dashes: dashes === true,
11
+ };
12
+ }
@@ -0,0 +1,78 @@
1
+ // ── Grid & snap ───────────────────────────────────────────────────────────────
2
+ // Grid rendering (beforeDrawing), snap-to-grid (dragEnd).
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { GRID_SIZE } from './constants.js';
6
+ import { t } from './t.js';
7
+ import { pushSnapshot } from './history.js';
8
+ import { snapToAlignGuides } from './alignment.js';
9
+
10
+
11
+
12
+ export function applyGridState(enabled) {
13
+ st.gridEnabled = enabled;
14
+ const btn = document.getElementById('btnGrid');
15
+ btn.classList.toggle('tool-active', enabled);
16
+ btn.title = t('diagram.toolbar.grid');
17
+ }
18
+
19
+ export function toggleGrid() {
20
+ applyGridState(!st.gridEnabled);
21
+ markDirty();
22
+ if (st.network) st.network.redraw();
23
+ }
24
+
25
+ // Called on network "beforeDrawing" — draws grid lines in physical pixel space.
26
+ export function drawGrid(ctx) {
27
+ if (!st.gridEnabled || !st.network) return;
28
+ const isDark = document.documentElement.classList.contains('dark');
29
+ const color = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.15)';
30
+ const scale = st.network.getScale();
31
+ const center = st.network.getViewPosition();
32
+ const canvas = ctx.canvas;
33
+ const W = canvas.width, H = canvas.height;
34
+
35
+ // vis.js coordinates (center.x, scale) are in CSS pixels; must multiply by DPR
36
+ // so the grid aligns correctly on Retina/HiDPI displays.
37
+ const dpr = window.devicePixelRatio || 1;
38
+ const step = GRID_SIZE * scale * dpr;
39
+ const offsetX = (((W / 2 - center.x * scale * dpr) % step) + step) % step;
40
+ const offsetY = (((H / 2 - center.y * scale * dpr) % step) + step) % step;
41
+
42
+ ctx.save();
43
+ ctx.setTransform(1, 0, 0, 1, 0, 0); // physical pixel space
44
+ ctx.strokeStyle = color;
45
+ ctx.lineWidth = 1;
46
+ ctx.beginPath();
47
+ for (let x = offsetX; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
48
+ for (let y = offsetY; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
49
+ ctx.stroke();
50
+ ctx.restore();
51
+ }
52
+
53
+ // Snap a canvas position to the nearest grid intersection.
54
+ export function snapToGrid(x, y) {
55
+ return {
56
+ x: Math.round(x / GRID_SIZE) * GRID_SIZE,
57
+ y: Math.round(y / GRID_SIZE) * GRID_SIZE,
58
+ };
59
+ }
60
+
61
+ // Called on network "dragEnd" — alignment snap, then grid snap.
62
+ export function onDragEnd(params) {
63
+ if (!params.nodes || !params.nodes.length) return;
64
+ pushSnapshot();
65
+ snapToAlignGuides(params);
66
+ if (st.gridEnabled) {
67
+ params.nodes.forEach((id) => {
68
+ const bodyNode = st.network.body.nodes[id];
69
+ if (!bodyNode) return;
70
+ const cx = bodyNode.x, cy = bodyNode.y;
71
+ // Snap the center of the shape to grid intersections
72
+ const snappedX = Math.round(cx / GRID_SIZE) * GRID_SIZE;
73
+ const snappedY = Math.round(cy / GRID_SIZE) * GRID_SIZE;
74
+ st.network.moveNode(id, snappedX, snappedY);
75
+ });
76
+ }
77
+ markDirty();
78
+ }
@@ -0,0 +1,102 @@
1
+ // ── Group management ──────────────────────────────────────────────────────────
2
+
3
+ import { st, markDirty } from './state.js';
4
+ import { pushSnapshot } from './history.js';
5
+ import { SHAPE_DEFAULTS } from './node-rendering.js';
6
+
7
+ // ── Create / destroy ──────────────────────────────────────────────────────────
8
+
9
+ export function groupNodes() {
10
+ if (st.selectedNodeIds.length < 2) return;
11
+ pushSnapshot();
12
+ const groupId = 'g' + Date.now();
13
+ st.selectedNodeIds.forEach((id) => st.nodes.update({ id, groupId }));
14
+ markDirty();
15
+ }
16
+
17
+ export function ungroupNodes() {
18
+ if (!st.selectedNodeIds.length) return;
19
+ pushSnapshot();
20
+ // Collect all members of any group touched by the selection
21
+ const groupIds = new Set(
22
+ st.selectedNodeIds.map((id) => { const n = st.nodes.get(id); return n && n.groupId; }).filter(Boolean)
23
+ );
24
+ st.nodes.get().forEach((n) => {
25
+ if (n.groupId && groupIds.has(n.groupId)) st.nodes.update({ id: n.id, groupId: null });
26
+ });
27
+ markDirty();
28
+ }
29
+
30
+ // ── Selection expansion ───────────────────────────────────────────────────────
31
+ // Called from onSelectNode — expands selection to all group members.
32
+
33
+ export function expandSelectionToGroup(nodeIds) {
34
+ const groupIds = new Set();
35
+ nodeIds.forEach((id) => {
36
+ const n = st.nodes.get(id);
37
+ if (n && n.groupId) groupIds.add(n.groupId);
38
+ });
39
+ if (!groupIds.size) return nodeIds;
40
+
41
+ const expanded = new Set(nodeIds);
42
+ st.nodes.get().forEach((n) => {
43
+ if (n.groupId && groupIds.has(n.groupId)) expanded.add(n.id);
44
+ });
45
+ return [...expanded];
46
+ }
47
+
48
+ // ── Group outline (drawn on canvas in afterDrawing) ───────────────────────────
49
+
50
+ function nodeBounds(id) {
51
+ const n = st.nodes.get(id);
52
+ const bodyNode = st.network.body.nodes[id];
53
+ if (!bodyNode) return null;
54
+ const shape = (n && n.shapeType) || 'box';
55
+ const defaults = SHAPE_DEFAULTS[shape] || [100, 40];
56
+ const W = (n && n.nodeWidth) || defaults[0];
57
+ const H = (n && n.nodeHeight) || defaults[1];
58
+ const rot = (n && n.rotation) || 0;
59
+ const cx = bodyNode.x, cy = bodyNode.y;
60
+ if (rot === 0) {
61
+ return { minX: cx - W / 2, minY: cy - H / 2, maxX: cx + W / 2, maxY: cy + H / 2 };
62
+ }
63
+ const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
64
+ const hw = (W * cos + H * sin) / 2;
65
+ const hh = (W * sin + H * cos) / 2;
66
+ return { minX: cx - hw, minY: cy - hh, maxX: cx + hw, maxY: cy + hh };
67
+ }
68
+
69
+ export function drawGroupOutlines(ctx) {
70
+ if (!st.network || !st.selectedNodeIds.length) return;
71
+
72
+ // Find groupIds that have at least one selected member
73
+ const selectedSet = new Set(st.selectedNodeIds);
74
+ const activeGroups = new Set();
75
+ st.selectedNodeIds.forEach((id) => {
76
+ const n = st.nodes.get(id);
77
+ if (n && n.groupId) activeGroups.add(n.groupId);
78
+ });
79
+ if (!activeGroups.size) return;
80
+
81
+ // For each active group, compute bounding box over ALL members
82
+ activeGroups.forEach((groupId) => {
83
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
84
+ st.nodes.get().forEach((n) => {
85
+ if (n.groupId !== groupId) return;
86
+ const b = nodeBounds(n.id);
87
+ if (!b) return;
88
+ minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY);
89
+ maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY);
90
+ });
91
+ if (minX === Infinity) return;
92
+
93
+ const PAD = 14;
94
+ ctx.save();
95
+ ctx.strokeStyle = '#6366f1';
96
+ ctx.lineWidth = 1.5;
97
+ ctx.setLineDash([6, 4]);
98
+ ctx.strokeRect(minX - PAD, minY - PAD, maxX - minX + PAD * 2, maxY - minY + PAD * 2);
99
+ ctx.setLineDash([]);
100
+ ctx.restore();
101
+ });
102
+ }
@@ -0,0 +1,153 @@
1
+ // ── Undo / Redo history ────────────────────────────────────────────────────────
2
+ // Snapshot-based undo/redo. Captures semantic node/edge data (same format as
3
+ // saveDiagram) so vis-network rendering props are reconstructed cleanly on
4
+ // restore. History is scoped to the current diagram — call resetHistory() on
5
+ // every diagram switch so stacks don't bleed across diagrams.
6
+
7
+ import { st, markDirty } from './state.js';
8
+ import { visNodeProps } from './node-rendering.js';
9
+ import { visEdgeProps } from './edge-rendering.js';
10
+
11
+ const MAX_HISTORY = 50;
12
+
13
+ let _undoStack = [];
14
+ let _redoStack = [];
15
+
16
+ // ── State capture ──────────────────────────────────────────────────────────────
17
+
18
+ function captureState() {
19
+ if (!st.nodes || !st.edges || !st.network) return null;
20
+ const positions = st.network.getPositions();
21
+ const nodes = st.nodes.get().map((n) => ({
22
+ id: n.id, label: n.label,
23
+ shapeType: n.shapeType || 'box', colorKey: n.colorKey || 'c-gray',
24
+ nodeWidth: n.nodeWidth || null, nodeHeight: n.nodeHeight || null,
25
+ fontSize: n.fontSize || null, textAlign: n.textAlign || null, textValign: n.textValign || null,
26
+ bgOpacity: n.bgOpacity ?? null,
27
+ rotation: n.rotation || 0, labelRotation: n.labelRotation || 0,
28
+ imageSrc: n.imageSrc || null, groupId: n.groupId || null,
29
+ nodeLink: n.nodeLink || null, locked: n.locked || false,
30
+ x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
31
+ }));
32
+ const edges = st.edges.get().map((e) => ({
33
+ id: e.id, from: e.from, to: e.to,
34
+ label: e.label || '', arrowDir: e.arrowDir || 'to',
35
+ dashes: e.dashes || false, fontSize: e.fontSize || null,
36
+ labelRotation: e.labelRotation || 0,
37
+ fromPort: e.fromPort || null, toPort: e.toPort || null,
38
+ edgeColor: e.edgeColor || null, edgeWidth: e.edgeWidth || null,
39
+ edgeLocked: e.edgeLocked || false,
40
+ }));
41
+ return {
42
+ nodes,
43
+ edges,
44
+ canonicalOrder: [...st.canonicalOrder],
45
+ edgesStraight: st.edgesStraight,
46
+ };
47
+ }
48
+
49
+ // ── Public API ─────────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Call BEFORE any mutating operation to record the current state for undo.
53
+ * Clears the redo stack (new action invalidates any undone future).
54
+ */
55
+ export function pushSnapshot() {
56
+ const snap = captureState();
57
+ if (!snap) return;
58
+ _undoStack.push(snap);
59
+ if (_undoStack.length > MAX_HISTORY) _undoStack.shift();
60
+ _redoStack = [];
61
+ }
62
+
63
+ /** Reset both stacks — call whenever a new diagram is opened. */
64
+ export function resetHistory() {
65
+ _undoStack = [];
66
+ _redoStack = [];
67
+ }
68
+
69
+ /** Undo the last action. No-op if stack is empty. */
70
+ export function undo() {
71
+ if (!_undoStack.length) return;
72
+ const before = _undoStack.pop();
73
+ const current = captureState();
74
+ if (current) _redoStack.push(current);
75
+ _restoreState(before);
76
+ }
77
+
78
+ /** Redo the last undone action. No-op if stack is empty. */
79
+ export function redo() {
80
+ if (!_redoStack.length) return;
81
+ const after = _redoStack.pop();
82
+ const current = captureState();
83
+ if (current) _undoStack.push(current);
84
+ _restoreState(after);
85
+ }
86
+
87
+ // ── State restore ──────────────────────────────────────────────────────────────
88
+
89
+ function _restoreState(snapshot) {
90
+ if (!st.network || !st.nodes || !st.edges || !snapshot) return;
91
+
92
+ const pos = st.network.getViewPosition();
93
+ const scale = st.network.getScale();
94
+
95
+ // Clear edges first: the edge-remove listener detects orphaned anchors and
96
+ // removes them from st.nodes, which avoids cascade issues when clearing nodes.
97
+ st.edges.clear();
98
+ st.nodes.clear();
99
+
100
+ // Restore edge-straight flag + toolbar button
101
+ if (snapshot.edgesStraight !== undefined) {
102
+ st.edgesStraight = snapshot.edgesStraight;
103
+ document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
104
+ }
105
+ const edgeSmooth = st.edgesStraight ? { enabled: false } : { type: 'continuous' };
106
+
107
+ st.nodes.add(snapshot.nodes.map((n) => ({
108
+ ...n,
109
+ ...visNodeProps(
110
+ n.shapeType || 'box', n.colorKey || 'c-gray',
111
+ n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign,
112
+ ),
113
+ ...(n.locked ? { fixed: { x: true, y: true }, draggable: false } : {}),
114
+ })));
115
+
116
+ st.edges.add(snapshot.edges.map((e) => {
117
+ const toNode = snapshot.nodes.find((n) => n.id === e.to);
118
+ const isAnchor = toNode && toNode.shapeType === 'anchor';
119
+ const edgeObj = {
120
+ ...e,
121
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
122
+ smooth: isAnchor ? { enabled: false } : edgeSmooth,
123
+ ...(e.edgeColor ? { color: { color: e.edgeColor, highlight: '#f97316', hover: '#f97316' } } : {}),
124
+ ...(e.edgeWidth ? { width: e.edgeWidth } : {}),
125
+ ...(e.fontSize || e.labelRotation ? {
126
+ font: {
127
+ size: e.fontSize || 11,
128
+ align: 'middle',
129
+ color: (e.labelRotation && Math.abs(e.labelRotation) > 0.001) ? 'rgba(0,0,0,0)' : '#6b7280',
130
+ },
131
+ } : {}),
132
+ };
133
+ if (e.fromPort || e.toPort) {
134
+ edgeObj.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
135
+ edgeObj.arrows = { to: { enabled: false }, from: { enabled: false } };
136
+ }
137
+ return edgeObj;
138
+ }));
139
+
140
+ // Override the canonical order that the add-listener built in insertion order
141
+ st.canonicalOrder = [...snapshot.canonicalOrder];
142
+
143
+ // Force all nodes to re-render (custom shapes cache ctx closures)
144
+ Object.keys(st.network.body.nodes).forEach((id) => {
145
+ const bn = st.network.body.nodes[id];
146
+ if (bn) bn.refreshNeeded = true;
147
+ });
148
+
149
+ // Restore viewport without animation
150
+ st.network.moveTo({ position: pos, scale, animation: false });
151
+ markDirty();
152
+ st.network.redraw();
153
+ }
@@ -0,0 +1,48 @@
1
+ // ── Image filename modal ───────────────────────────────────────────────────────
2
+ // Returns a Promise<string|null>:
3
+ // string (possibly empty) → user confirmed (empty = use auto name)
4
+ // null → user cancelled
5
+
6
+ const IMAGE_NAME_RE = /^[a-z0-9_-]*$/i;
7
+
8
+ export function promptImageName() {
9
+ return new Promise((resolve) => {
10
+ const modal = document.getElementById('imageNameModal');
11
+ const input = document.getElementById('imageNameInput');
12
+ const error = document.getElementById('imageNameError');
13
+ const confirm = document.getElementById('imageNameConfirm');
14
+ const cancel = document.getElementById('imageNameCancel');
15
+
16
+ input.value = '';
17
+ error.classList.add('hidden');
18
+ modal.style.display = 'flex';
19
+ setTimeout(() => input.focus(), 50);
20
+
21
+ function validate() {
22
+ const val = input.value.trim();
23
+ const ok = val === '' || IMAGE_NAME_RE.test(val);
24
+ error.classList.toggle('hidden', ok);
25
+ return ok;
26
+ }
27
+
28
+ function close(name) {
29
+ modal.style.display = 'none';
30
+ confirm.removeEventListener('click', onConfirm);
31
+ cancel.removeEventListener('click', onCancel);
32
+ input.removeEventListener('input', validate);
33
+ input.removeEventListener('keydown', onKey);
34
+ resolve(name);
35
+ }
36
+ function onConfirm() { if (validate()) close(input.value.trim()); }
37
+ function onCancel() { close(null); }
38
+ function onKey(e) {
39
+ if (e.key === 'Enter') { e.preventDefault(); if (validate()) close(input.value.trim()); }
40
+ if (e.key === 'Escape') { e.preventDefault(); close(null); }
41
+ }
42
+
43
+ confirm.addEventListener('click', onConfirm);
44
+ cancel.addEventListener('click', onCancel);
45
+ input.addEventListener('input', validate);
46
+ input.addEventListener('keydown', onKey);
47
+ });
48
+ }
@@ -0,0 +1,36 @@
1
+ // ── Image upload helper ───────────────────────────────────────────────────────
2
+ // Converts a File or Blob to base64 and uploads it via POST /api/images/upload.
3
+ // Returns the absolute URL path usable in an <img> or ctx.drawImage(), e.g. "/images/foo.png".
4
+
5
+ async function toBase64(blob) {
6
+ return new Promise((resolve, reject) => {
7
+ const reader = new FileReader();
8
+ reader.onload = () => resolve(reader.result);
9
+ reader.onerror = reject;
10
+ reader.readAsDataURL(blob);
11
+ });
12
+ }
13
+
14
+ export async function uploadImageFile(file, name = '') {
15
+ const ext = (file.name.split('.').pop() || 'png').toLowerCase();
16
+ const base64 = await toBase64(file);
17
+ return _upload(base64, ext, name);
18
+ }
19
+
20
+ export async function uploadImageBlob(blob, ext = 'png', name = '') {
21
+ const base64 = await toBase64(blob);
22
+ return _upload(base64, ext, name);
23
+ }
24
+
25
+ async function _upload(base64, ext, name = '') {
26
+ const body = { data: base64, ext };
27
+ if (name) body.name = name;
28
+ const res = await fetch('/api/images/upload', {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(body),
32
+ });
33
+ if (!res.ok) throw new Error('Upload failed');
34
+ const { filename } = await res.json();
35
+ return `/images/${filename}`;
36
+ }