living-documentation 4.1.0 → 4.3.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A CLI tool that serves a local Markdown documentation viewer in your browser.
4
4
 
5
- No cloud, no database, no build step — just point it at a folder of `.md` files.
5
+ No cloud, no database, no build step — just point it at a folder where you add your project's folder documentation composed of `.md` files (ADR : Architecture Decision Records, generally).
6
6
 
7
7
  ![Node.js](https://img.shields.io/badge/Node.js-18%2B-green)
8
8
  ![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)
@@ -11,20 +11,31 @@ No cloud, no database, no build step — just point it at a folder of `.md` file
11
11
  ## Features
12
12
 
13
13
  - **Sidebar** grouped by category, sorted alphabetically by full filename
14
+ [![README Diagrams](./images/readme-sidebar.png)](/diagram?id=d1775399110713)
15
+
16
+ - **Categories Sections and General section** — Categories are extracted from the fileName pattern of your Markdown documents (that may be Architecture Decision Records ADRs).
17
+ ExtraFiles (added in the admin section) are always first, always expanded in a `GENERAL Section` that holds uncategorized docs and extra files
18
+ [![README Diagrams](./images/readme-filename-pattern.png)](/diagram?id=d1775399110713)
19
+
14
20
  - **Recursive folder scanning** — subdirectories are scanned automatically; subdirectory name becomes the category
15
- - **General section** — always first, always expanded; holds uncategorized docs and extra files
16
- - **Extra files** — include Markdown files from outside the docs folder (e.g. `README.md`, `CLAUDE.md`)
17
- - **Full-text search** — instant filter + server-side content search
21
+
22
+ - **Extra files** — You can add custom ExtraFiles to your documentation that are outside the docs folder (e.g. `README.md`, `CLAUDE.md`) in the `Admin` Page
23
+ [![README Diagrams](./images/readme-extra-files.png)](/diagram?id=d1775399110713)
24
+
18
25
  - **Dark mode** — follows system preference, manually toggleable
19
26
  - **Syntax highlighting** — always dark, high-contrast code blocks
20
- - **Export to PDF** — print-friendly layout via `window.print()`
21
- - **Deep links** — share a direct URL to any document (`?doc=…`)
22
- - **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
27
+ [![README Diagrams](./images/readme-code-blocks.png)](/diagram?id=d1775399110713)
28
+
29
+ - **Full-text search** — instant filter + server-side content search. Returns all the files containing searched occurences, and for each file lists all the occurences, highlight them, and visit them.
30
+ [![README Diagrams](./images/readme-intelligent-search-demo.png)](/diagram?id=d1775399110713)
31
+
23
32
  - **Inline editing** — edit any document directly in the browser, saves to disk instantly
24
33
  - **Image paste** — paste an image from clipboard in the editor; auto-uploaded and inserted as Markdown
34
+ - **Export to PDF** — Export the markdown as a PDF document
35
+ - **Diagram editor** — built-in canvas diagram editor; deep-link to any diagram in the C4 Model Style; Paste images into diagrams; Export PNG From Images; And Many more features ...
36
+
37
+ - **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
25
38
  - **Word Cloud** — visualise the dominant vocabulary of any folder on disk; supports `.md`, `.ts`, `.java`, `.kt`, `.py`, `.go`, `.rs`, `.cs`, `.swift`, `.rb`, `.html`, `.css`, `.yml`, `.json` and more; stop words filtered per language
26
- - **Diagram editor** — built-in canvas diagram editor (vis-network); deep-link to any diagram with `?id=`
27
- - **Zero frontend build** — Tailwind and highlight.js loaded from CDN
28
39
 
29
40
  ---
30
41
 
@@ -222,4 +233,4 @@ The compiled package is self-contained inside `dist/`. Only `dist/` is included
222
233
 
223
234
  ## License
224
235
 
225
- MIT
236
+ MIT
@@ -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
+ }
@@ -11,22 +11,24 @@ async function toBase64(blob) {
11
11
  });
12
12
  }
13
13
 
14
- export async function uploadImageFile(file) {
14
+ export async function uploadImageFile(file, name = '') {
15
15
  const ext = (file.name.split('.').pop() || 'png').toLowerCase();
16
16
  const base64 = await toBase64(file);
17
- return _upload(base64, ext);
17
+ return _upload(base64, ext, name);
18
18
  }
19
19
 
20
- export async function uploadImageBlob(blob, ext = 'png') {
20
+ export async function uploadImageBlob(blob, ext = 'png', name = '') {
21
21
  const base64 = await toBase64(blob);
22
- return _upload(base64, ext);
22
+ return _upload(base64, ext, name);
23
23
  }
24
24
 
25
- async function _upload(base64, ext) {
25
+ async function _upload(base64, ext, name = '') {
26
+ const body = { data: base64, ext };
27
+ if (name) body.name = name;
26
28
  const res = await fetch('/api/images/upload', {
27
29
  method: 'POST',
28
30
  headers: { 'Content-Type': 'application/json' },
29
- body: JSON.stringify({ data: base64, ext }),
31
+ body: JSON.stringify(body),
30
32
  });
31
33
  if (!res.ok) throw new Error('Upload failed');
32
34
  const { filename } = await res.json();
@@ -14,8 +14,9 @@ import { toggleDebug } from './debug.js';
14
14
  import { adjustZoom, resetZoom } from './zoom.js';
15
15
  import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
16
16
  import { copySelected, pasteClipboard, copySelectionAsPng } from './clipboard.js';
17
- import { createImageNode } from './network.js';
17
+ import { createImageNode, toggleEdgeStraight } from './network.js';
18
18
  import { uploadImageBlob } from './image-upload.js';
19
+ import { promptImageName } from './image-name-modal.js';
19
20
  import { showToast } from './toast.js';
20
21
 
21
22
  // ── Tool management ───────────────────────────────────────────────────────────
@@ -90,6 +91,7 @@ document.getElementById('toolArrow').addEventListener('click', () => setTool(
90
91
  document.getElementById('btnDelete').addEventListener('click', deleteSelected);
91
92
  document.getElementById('btnPhysics').addEventListener('click', togglePhysics);
92
93
  document.getElementById('btnGrid').addEventListener('click', toggleGrid);
94
+ document.getElementById('btnEdgeStraight').addEventListener('click', toggleEdgeStraight);
93
95
 
94
96
  document.getElementById('btnZoomOut').addEventListener('click', () => adjustZoom(-0.2));
95
97
  document.getElementById('btnZoomIn').addEventListener('click', () => adjustZoom(0.2));
@@ -158,7 +160,7 @@ document.addEventListener('keydown', (e) => {
158
160
  if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); selectAll(); return; }
159
161
  if ((e.metaKey || e.ctrlKey) && e.key === 'c' && e.shiftKey) { e.preventDefault(); copySelectionAsPng(); return; }
160
162
  if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
161
- if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
163
+ // Cmd+V is handled in the 'paste' event below (needs clipboardData access)
162
164
  if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
163
165
  if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); hideLinkPanel(); setTool('select'); return; }
164
166
  if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
@@ -172,25 +174,37 @@ document.addEventListener('keydown', (e) => {
172
174
  if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
173
175
  });
174
176
 
175
- // ── Paste image from clipboard ────────────────────────────────────────────────
177
+ // ── Paste (image or shapes) ───────────────────────────────────────────────────
176
178
  document.addEventListener('paste', async (e) => {
177
179
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
178
180
  if (!st.network) return;
179
- const items = Array.from(e.clipboardData.items || []);
180
- const imageItem = items.find((it) => it.type.startsWith('image/'));
181
- if (!imageItem) return;
182
181
  e.preventDefault();
183
- const blob = imageItem.getAsFile();
184
- if (!blob) return;
185
- const ext = imageItem.type.split('/')[1] || 'png';
186
- try {
187
- const src = await uploadImageBlob(blob, ext);
188
- // Place at centre of current viewport
189
- const center = st.network.getViewPosition();
190
- createImageNode(src, center.x, center.y);
191
- showToast('Image ajoutée');
192
- } catch {
193
- showToast('Impossible d\'importer l\'image', 'error');
182
+
183
+ const items = Array.from(e.clipboardData?.items || []);
184
+ const imageItem = items.find((it) => it.type.startsWith('image/'));
185
+
186
+ if (imageItem) {
187
+ const blob = imageItem.getAsFile();
188
+ if (!blob) return;
189
+ const ext = imageItem.type.split('/')[1] || 'png';
190
+
191
+ const name = await promptImageName();
192
+ if (name === null) return; // user cancelled
193
+
194
+ try {
195
+ const src = await uploadImageBlob(blob, ext, name);
196
+ const center = st.network.getViewPosition();
197
+ createImageNode(src, center.x, center.y);
198
+ showToast('Image ajoutée');
199
+ // Clear system clipboard so the next Cmd+V pastes shapes, not the image again
200
+ // (our Cmd+C only writes to st.clipboard, not the system clipboard)
201
+ navigator.clipboard.writeText('').catch(() => {});
202
+ } catch {
203
+ showToast('Impossible d\'importer l\'image', 'error');
204
+ }
205
+ } else {
206
+ // No image — paste shapes from internal clipboard
207
+ pasteClipboard();
194
208
  }
195
209
  });
196
210
 
@@ -5,6 +5,7 @@
5
5
  import { st, markDirty } from './state.js';
6
6
  import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
7
7
  import { uploadImageFile } from './image-upload.js';
8
+ import { promptImageName } from './image-name-modal.js';
8
9
  import { showToast } from './toast.js';
9
10
  import { visEdgeProps } from './edge-rendering.js';
10
11
  import { showNodePanel, hideNodePanel } from './node-panel.js';
@@ -17,9 +18,11 @@ import { updateZoomDisplay } from './zoom.js';
17
18
  import { expandSelectionToGroup, drawGroupOutlines } from './groups.js';
18
19
  import { navigateNodeLink, hideLinkPanel } from './link-panel.js';
19
20
 
20
- export function initNetwork(savedNodes, savedEdges) {
21
+ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
21
22
  const container = document.getElementById('vis-canvas');
22
23
 
24
+ const edgeSmooth = edgesStraight ? { enabled: false } : { type: 'continuous' };
25
+
23
26
  st.nodes = new vis.DataSet(
24
27
  savedNodes.map((n) => ({
25
28
  ...n,
@@ -27,11 +30,16 @@ export function initNetwork(savedNodes, savedEdges) {
27
30
  }))
28
31
  );
29
32
  st.edges = new vis.DataSet(
30
- savedEdges.map((e) => ({
31
- ...e,
32
- ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
33
- ...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
34
- }))
33
+ savedEdges.map((e) => {
34
+ const toNode = savedNodes.find((n) => n.id === e.to);
35
+ const isAnchor = toNode && toNode.shapeType === 'anchor';
36
+ return {
37
+ ...e,
38
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
39
+ smooth: isAnchor ? { enabled: false } : edgeSmooth,
40
+ ...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
41
+ };
42
+ })
35
43
  );
36
44
 
37
45
  const options = {
@@ -42,7 +50,7 @@ export function initNetwork(savedNodes, savedEdges) {
42
50
  },
43
51
  interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
44
52
  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 } },
45
- edges: { smooth: { type: 'continuous' }, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: '#6b7280' } },
53
+ edges: { smooth: edgeSmooth, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: '#6b7280' } },
46
54
  manipulation: {
47
55
  enabled: false,
48
56
  addEdge(data, callback) {
@@ -81,16 +89,70 @@ export function initNetwork(savedNodes, savedEdges) {
81
89
  for (const edgeId of Object.keys(bodyEdges)) {
82
90
  const edge = bodyEdges[edgeId];
83
91
  if (!edge.connected) continue;
84
- const level = Math.min(orderMap.get(edge.fromId) ?? 0, orderMap.get(edge.toId) ?? 0);
92
+ // Edges are drawn just before the node at their assigned level, so they
93
+ // appear on top of all nodes below that level.
94
+ // Use Math.max so the edge follows the higher-z endpoint — it stays visible
95
+ // above any intermediate nodes between the two endpoints.
96
+ // Anchor nodes are floating endpoints that must not raise the edge above the
97
+ // real source: for anchor edges, use the non-anchor endpoint's level.
98
+ const fromData = st.nodes.get(edge.fromId);
99
+ const toData = st.nodes.get(edge.toId);
100
+ const fromIsAnchor = fromData && fromData.shapeType === 'anchor';
101
+ const toIsAnchor = toData && toData.shapeType === 'anchor';
102
+ let level;
103
+ if (toIsAnchor && !fromIsAnchor) {
104
+ level = orderMap.get(edge.fromId) ?? 0;
105
+ } else if (fromIsAnchor && !toIsAnchor) {
106
+ level = orderMap.get(edge.toId) ?? 0;
107
+ } else {
108
+ level = Math.min(orderMap.get(edge.fromId) ?? 0, orderMap.get(edge.toId) ?? 0);
109
+ }
85
110
  if (!edgesByLevel.has(level)) edgesByLevel.set(level, []);
86
111
  edgesByLevel.get(level).push(edge);
87
112
  }
88
113
 
114
+ // Build a map: anchorId → the edge(s) that connect to it, so we can draw
115
+ // each anchor dot AFTER its edge (giving the "planted arrowhead" effect).
116
+ const anchorEdgeLevel = new Map(); // anchorId → level at which its edge is drawn
117
+ for (const [level, edges] of edgesByLevel) {
118
+ for (const edge of edges) {
119
+ const fromData = st.nodes.get(edge.fromId);
120
+ const toData = st.nodes.get(edge.toId);
121
+ if (toData && toData.shapeType === 'anchor') anchorEdgeLevel.set(edge.toId, level);
122
+ if (fromData && fromData.shapeType === 'anchor') anchorEdgeLevel.set(edge.fromId, level);
123
+ }
124
+ }
125
+
126
+ // Anchors with no connected edge are drawn at level 0 (bottom).
127
+ for (const id of st.canonicalOrder) {
128
+ const n = st.nodes.get(id);
129
+ if (!n || n.shapeType !== 'anchor') continue;
130
+ if (!anchorEdgeLevel.has(id)) anchorEdgeLevel.set(id, 0);
131
+ }
132
+
89
133
  for (let i = 0; i < st.canonicalOrder.length; i++) {
90
- const id = st.canonicalOrder[i];
91
- // Draw edges whose topmost node is at this level, before drawing the node.
134
+ const id = st.canonicalOrder[i];
135
+ const n = st.nodes.get(id);
136
+
137
+ // Draw edges whose level is i, before drawing the node at level i.
92
138
  const edges = edgesByLevel.get(i);
93
- if (edges) edges.forEach((e) => e.draw(ctx));
139
+ if (edges) {
140
+ edges.forEach((e) => e.draw(ctx));
141
+ // Draw any anchor whose edge was just drawn, so the dot appears on top.
142
+ for (const [anchorId, level] of anchorEdgeLevel) {
143
+ if (level !== i) continue;
144
+ const anchorNode = bodyNodes[anchorId];
145
+ if (!anchorNode) continue;
146
+ if (alwaysShow === true || anchorNode.isBoundingBoxOverlappingWith(viewableArea) === true) {
147
+ anchorNode.draw(ctx);
148
+ } else {
149
+ anchorNode.updateBoundingBox(ctx, anchorNode.selected);
150
+ }
151
+ }
152
+ }
153
+
154
+ // Skip anchor nodes here — they were drawn right after their edge above.
155
+ if (n && n.shapeType === 'anchor') continue;
94
156
 
95
157
  const node = bodyNodes[id];
96
158
  if (!node) continue;
@@ -115,9 +177,30 @@ export function initNetwork(savedNodes, savedEdges) {
115
177
  const existing = new Set(st.canonicalOrder);
116
178
  items.forEach((id) => { if (!existing.has(id)) st.canonicalOrder.push(id); });
117
179
  });
118
- st.nodes.on('remove', (_, { items }) => {
180
+ st.nodes.on('remove', (_, { items, oldData }) => {
119
181
  const removed = new Set(items);
120
182
  st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
183
+ // If an anchor was deleted directly, also remove its connected edges.
184
+ (oldData || []).forEach((n) => {
185
+ if (n.shapeType !== 'anchor') return;
186
+ const connected = st.edges.get({ filter: (e) => e.from === n.id || e.to === n.id });
187
+ if (connected.length) st.edges.remove(connected.map((e) => e.id));
188
+ });
189
+ });
190
+
191
+ // When an edge is removed, delete any anchor node that has no remaining edges.
192
+ st.edges.on('remove', (_, { oldData }) => {
193
+ const anchorsToCheck = new Set();
194
+ (oldData || []).forEach((edge) => {
195
+ const toData = st.nodes.get(edge.to);
196
+ const fromData = st.nodes.get(edge.from);
197
+ if (toData && toData.shapeType === 'anchor') anchorsToCheck.add(edge.to);
198
+ if (fromData && fromData.shapeType === 'anchor') anchorsToCheck.add(edge.from);
199
+ });
200
+ anchorsToCheck.forEach((anchorId) => {
201
+ const remaining = st.edges.get({ filter: (e) => e.from === anchorId || e.to === anchorId });
202
+ if (remaining.length === 0) st.nodes.remove(anchorId);
203
+ });
121
204
  });
122
205
 
123
206
  st.network.on('click', onClickNode);
@@ -134,6 +217,41 @@ export function initNetwork(savedNodes, savedEdges) {
134
217
  st.network.on('afterDrawing', (ctx) => drawGroupOutlines(ctx));
135
218
  st.network.on('afterDrawing', () => drawDebugOverlay());
136
219
 
220
+ // ── Free-floating edge: drop on empty canvas creates an anchor node ──────────
221
+ // vis-network only fires addEdge callback when dropping on an existing node.
222
+ // We intercept mousedown (capture source) + mouseup (detect empty-canvas drop).
223
+ let _addEdgeFromId = null;
224
+ const visCanvas = document.getElementById('vis-canvas');
225
+
226
+ visCanvas.addEventListener('mousedown', (e) => {
227
+ if (st.currentTool !== 'addEdge') return;
228
+ _addEdgeFromId = st.network.getNodeAt({ x: e.offsetX, y: e.offsetY }) || null;
229
+ });
230
+
231
+ visCanvas.addEventListener('mouseup', (e) => {
232
+ if (st.currentTool !== 'addEdge' || !_addEdgeFromId) { _addEdgeFromId = null; return; }
233
+ const pos = { x: e.offsetX, y: e.offsetY };
234
+ if (!st.network.getNodeAt(pos)) {
235
+ const cp = st.network.DOMtoCanvas(pos);
236
+ const anchorId = 'a' + Date.now();
237
+ st.nodes.add({
238
+ id: anchorId, label: '', shapeType: 'anchor', colorKey: 'c-gray',
239
+ nodeWidth: 8, nodeHeight: 8, fontSize: null, rotation: 0, labelRotation: 0,
240
+ x: cp.x, y: cp.y,
241
+ ...visNodeProps('anchor', 'c-gray', 8, 8, null, null, null),
242
+ });
243
+ st.edges.add({
244
+ id: 'e' + Date.now(), from: _addEdgeFromId, to: anchorId,
245
+ arrowDir: 'to', dashes: false,
246
+ smooth: { enabled: false },
247
+ ...visEdgeProps('to', false),
248
+ });
249
+ markDirty();
250
+ setTimeout(() => st.network.addEdgeMode(), 0);
251
+ }
252
+ _addEdgeFromId = null;
253
+ });
254
+
137
255
  document.getElementById('emptyState').classList.add('hidden');
138
256
  updateZoomDisplay();
139
257
  }
@@ -184,8 +302,10 @@ function pickAndCreateImageNode(canvasX, canvasY) {
184
302
  input.onchange = async () => {
185
303
  const file = input.files && input.files[0];
186
304
  if (!file) return;
305
+ const name = await promptImageName();
306
+ if (name === null) return; // user cancelled
187
307
  try {
188
- const src = await uploadImageFile(file);
308
+ const src = await uploadImageFile(file, name);
189
309
  createImageNode(src, canvasX, canvasY);
190
310
  } catch {
191
311
  showToast('Impossible d\'importer l\'image', 'error');
@@ -196,27 +316,96 @@ function pickAndCreateImageNode(canvasX, canvasY) {
196
316
 
197
317
  export function createImageNode(imageSrc, canvasX, canvasY) {
198
318
  if (!st.network) return;
199
- const id = 'n' + Date.now();
200
- const defaults = SHAPE_DEFAULTS['image'];
201
- st.nodes.add({
202
- id, label: '', imageSrc,
203
- shapeType: 'image', colorKey: 'c-gray',
204
- nodeWidth: defaults[0], nodeHeight: defaults[1],
205
- fontSize: null, rotation: 0, labelRotation: 0,
206
- x: canvasX, y: canvasY,
207
- ...visNodeProps('image', 'c-gray', defaults[0], defaults[1], null, null, null),
319
+ const id = 'n' + Date.now();
320
+ const captionId = id + 'c';
321
+
322
+ const addNode = (nW, nH) => {
323
+ const filename = imageSrc.split('/').pop() || '';
324
+ const textDefs = SHAPE_DEFAULTS['text-free'];
325
+ const captionH = textDefs[1];
326
+ const GAP = 8;
327
+
328
+ const groupId = 'g' + Date.now();
329
+ st.nodes.add({
330
+ id, label: '', imageSrc, groupId,
331
+ shapeType: 'image', colorKey: 'c-gray',
332
+ nodeWidth: nW, nodeHeight: nH,
333
+ fontSize: null, rotation: 0, labelRotation: 0,
334
+ x: canvasX, y: canvasY,
335
+ ...visNodeProps('image', 'c-gray', nW, nH, null, null, null),
336
+ });
337
+ st.nodes.add({
338
+ id: captionId, label: filename, groupId,
339
+ shapeType: 'text-free', colorKey: 'c-gray',
340
+ nodeWidth: nW, nodeHeight: captionH,
341
+ fontSize: null, rotation: 0, labelRotation: 0,
342
+ x: canvasX, y: canvasY + nH / 2 + GAP + captionH / 2,
343
+ ...visNodeProps('text-free', 'c-gray', nW, captionH, null, null, null),
344
+ });
345
+ markDirty();
346
+ setTimeout(() => {
347
+ st.network.selectNodes([id]);
348
+ st.selectedNodeIds = [id];
349
+ showNodePanel();
350
+ }, 50);
351
+ };
352
+
353
+ const img = new Image();
354
+ img.onload = () => {
355
+ const MAX = 300;
356
+ const ratio = img.naturalWidth / img.naturalHeight;
357
+ let nW = img.naturalWidth, nH = img.naturalHeight;
358
+ if (nW > MAX) { nW = MAX; nH = Math.round(MAX / ratio); }
359
+ addNode(nW, nH);
360
+ };
361
+ img.onerror = () => {
362
+ const d = SHAPE_DEFAULTS['image'];
363
+ addNode(d[0], d[1]);
364
+ };
365
+ img.src = imageSrc;
366
+ }
367
+
368
+ // ── Edge straight / curved toggle ────────────────────────────────────────────
369
+ export function toggleEdgeStraight() {
370
+ if (!st.network) return;
371
+ st.edgesStraight = !st.edgesStraight;
372
+ const smooth = st.edgesStraight ? { enabled: false } : { type: 'continuous' };
373
+ // Update global network option first (overrides per-edge inherited defaults).
374
+ st.network.setOptions({ edges: { smooth } });
375
+ // Then update each edge individually, keeping anchor edges always straight.
376
+ const updates = st.edges.get().map((e) => {
377
+ const toData = st.nodes.get(e.to);
378
+ const s = (toData && toData.shapeType === 'anchor') ? { enabled: false } : smooth;
379
+ return { id: e.id, smooth: s };
208
380
  });
381
+ if (updates.length) st.edges.update(updates);
382
+ document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
209
383
  markDirty();
210
- setTimeout(() => {
211
- st.network.selectNodes([id]);
212
- st.selectedNodeIds = [id];
213
- showNodePanel();
214
- }, 50);
215
384
  }
216
385
 
217
386
  function onClickNode(params) {
218
387
  if (params.nodes.length === 1 && params.event.srcEvent.shiftKey) {
219
388
  navigateNodeLink(params.nodes[0]);
389
+ return;
390
+ }
391
+ // Anchor nodes have nodeDimensions=0 so vis-network cannot detect clicks on
392
+ // them. Manually check proximity (8px threshold in canvas space).
393
+ if (params.nodes.length === 0 && params.edges.length === 0) {
394
+ const cp = params.pointer.canvas;
395
+ const THRESHOLD = 8;
396
+ const anchors = st.nodes.get({ filter: (n) => n.shapeType === 'anchor' });
397
+ const positions = st.network.getPositions(anchors.map((a) => a.id));
398
+ for (const anchor of anchors) {
399
+ const pos = positions[anchor.id];
400
+ if (!pos) continue;
401
+ const dx = cp.x - pos.x, dy = cp.y - pos.y;
402
+ if (Math.sqrt(dx * dx + dy * dy) <= THRESHOLD) {
403
+ st.network.selectNodes([anchor.id]);
404
+ st.selectedNodeIds = [anchor.id];
405
+ showNodePanel();
406
+ return;
407
+ }
408
+ }
220
409
  }
221
410
  }
222
411