living-documentation 4.1.0 → 4.2.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,12 +11,22 @@ 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
27
+ [![README Diagrams](./images/readme-code-blocks.png)](/diagram?id=d1775399110713)
28
+
29
+ - **Full-text search** — instant filter + server-side content search
20
30
  - **Export to PDF** — print-friendly layout via `window.print()`
21
31
  - **Deep links** — share a direct URL to any document (`?doc=…`)
22
32
  - **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
@@ -222,4 +232,4 @@ The compiled package is self-contained inside `dist/`. Only `dist/` is included
222
232
 
223
233
  ## License
224
234
 
225
- MIT
235
+ 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();
@@ -16,6 +16,7 @@ import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
16
16
  import { copySelected, pasteClipboard, copySelectionAsPng } from './clipboard.js';
17
17
  import { createImageNode } 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 ───────────────────────────────────────────────────────────
@@ -158,7 +159,7 @@ document.addEventListener('keydown', (e) => {
158
159
  if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); selectAll(); return; }
159
160
  if ((e.metaKey || e.ctrlKey) && e.key === 'c' && e.shiftKey) { e.preventDefault(); copySelectionAsPng(); return; }
160
161
  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; }
162
+ // Cmd+V is handled in the 'paste' event below (needs clipboardData access)
162
163
  if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
163
164
  if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); hideLinkPanel(); setTool('select'); return; }
164
165
  if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
@@ -172,25 +173,37 @@ document.addEventListener('keydown', (e) => {
172
173
  if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
173
174
  });
174
175
 
175
- // ── Paste image from clipboard ────────────────────────────────────────────────
176
+ // ── Paste (image or shapes) ───────────────────────────────────────────────────
176
177
  document.addEventListener('paste', async (e) => {
177
178
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
178
179
  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
180
  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');
181
+
182
+ const items = Array.from(e.clipboardData?.items || []);
183
+ const imageItem = items.find((it) => it.type.startsWith('image/'));
184
+
185
+ if (imageItem) {
186
+ const blob = imageItem.getAsFile();
187
+ if (!blob) return;
188
+ const ext = imageItem.type.split('/')[1] || 'png';
189
+
190
+ const name = await promptImageName();
191
+ if (name === null) return; // user cancelled
192
+
193
+ try {
194
+ const src = await uploadImageBlob(blob, ext, name);
195
+ const center = st.network.getViewPosition();
196
+ createImageNode(src, center.x, center.y);
197
+ showToast('Image ajoutée');
198
+ // Clear system clipboard so the next Cmd+V pastes shapes, not the image again
199
+ // (our Cmd+C only writes to st.clipboard, not the system clipboard)
200
+ navigator.clipboard.writeText('').catch(() => {});
201
+ } catch {
202
+ showToast('Impossible d\'importer l\'image', 'error');
203
+ }
204
+ } else {
205
+ // No image — paste shapes from internal clipboard
206
+ pasteClipboard();
194
207
  }
195
208
  });
196
209
 
@@ -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';
@@ -184,8 +185,10 @@ function pickAndCreateImageNode(canvasX, canvasY) {
184
185
  input.onchange = async () => {
185
186
  const file = input.files && input.files[0];
186
187
  if (!file) return;
188
+ const name = await promptImageName();
189
+ if (name === null) return; // user cancelled
187
190
  try {
188
- const src = await uploadImageFile(file);
191
+ const src = await uploadImageFile(file, name);
189
192
  createImageNode(src, canvasX, canvasY);
190
193
  } catch {
191
194
  showToast('Impossible d\'importer l\'image', 'error');
@@ -196,22 +199,53 @@ function pickAndCreateImageNode(canvasX, canvasY) {
196
199
 
197
200
  export function createImageNode(imageSrc, canvasX, canvasY) {
198
201
  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),
208
- });
209
- markDirty();
210
- setTimeout(() => {
211
- st.network.selectNodes([id]);
212
- st.selectedNodeIds = [id];
213
- showNodePanel();
214
- }, 50);
202
+ const id = 'n' + Date.now();
203
+ const captionId = id + 'c';
204
+
205
+ const addNode = (nW, nH) => {
206
+ const filename = imageSrc.split('/').pop() || '';
207
+ const textDefs = SHAPE_DEFAULTS['text-free'];
208
+ const captionH = textDefs[1];
209
+ const GAP = 8;
210
+
211
+ const groupId = 'g' + Date.now();
212
+ st.nodes.add({
213
+ id, label: '', imageSrc, groupId,
214
+ shapeType: 'image', colorKey: 'c-gray',
215
+ nodeWidth: nW, nodeHeight: nH,
216
+ fontSize: null, rotation: 0, labelRotation: 0,
217
+ x: canvasX, y: canvasY,
218
+ ...visNodeProps('image', 'c-gray', nW, nH, null, null, null),
219
+ });
220
+ st.nodes.add({
221
+ id: captionId, label: filename, groupId,
222
+ shapeType: 'text-free', colorKey: 'c-gray',
223
+ nodeWidth: nW, nodeHeight: captionH,
224
+ fontSize: null, rotation: 0, labelRotation: 0,
225
+ x: canvasX, y: canvasY + nH / 2 + GAP + captionH / 2,
226
+ ...visNodeProps('text-free', 'c-gray', nW, captionH, null, null, null),
227
+ });
228
+ markDirty();
229
+ setTimeout(() => {
230
+ st.network.selectNodes([id]);
231
+ st.selectedNodeIds = [id];
232
+ showNodePanel();
233
+ }, 50);
234
+ };
235
+
236
+ const img = new Image();
237
+ img.onload = () => {
238
+ const MAX = 300;
239
+ const ratio = img.naturalWidth / img.naturalHeight;
240
+ let nW = img.naturalWidth, nH = img.naturalHeight;
241
+ if (nW > MAX) { nW = MAX; nH = Math.round(MAX / ratio); }
242
+ addNode(nW, nH);
243
+ };
244
+ img.onerror = () => {
245
+ const d = SHAPE_DEFAULTS['image'];
246
+ addNode(d[0], d[1]);
247
+ };
248
+ img.src = imageSrc;
215
249
  }
216
250
 
217
251
  function onClickNode(params) {
@@ -74,6 +74,7 @@ function onResizeStart(e, corner) {
74
74
  e.preventDefault();
75
75
  e.stopPropagation();
76
76
 
77
+ const positions = st.network.getPositions(st.selectedNodeIds);
77
78
  const startBBs = st.selectedNodeIds.map((id) => {
78
79
  const n = st.nodes.get(id);
79
80
  const b = nodeBounds(id);
@@ -81,13 +82,22 @@ function onResizeStart(e, corner) {
81
82
  const defaults = SHAPE_DEFAULTS[shape] || [60, 28];
82
83
  const initW = (n && n.nodeWidth) || (b ? Math.round(b.maxX - b.minX) : defaults[0]);
83
84
  const initH = (n && n.nodeHeight) || (b ? Math.round(b.maxY - b.minY) : defaults[1]);
84
- return { id, node: n, initW, initH };
85
+ const pos = positions[id] || { x: 0, y: 0 };
86
+ return { id, node: n, initW, initH, initX: pos.x, initY: pos.y };
85
87
  });
86
88
 
87
- const initBoxW = startBBs.reduce((m, b) => Math.max(m, b.initW), 0);
88
- const initBoxH = startBBs.reduce((m, b) => Math.max(m, b.initH), 0);
89
+ // True bounding box of the whole selection (for scale reference and pivot)
90
+ let bbMinX = Infinity, bbMinY = Infinity, bbMaxX = -Infinity, bbMaxY = -Infinity;
91
+ for (const { id } of startBBs) {
92
+ const b = nodeBounds(id);
93
+ if (!b) continue;
94
+ bbMinX = Math.min(bbMinX, b.minX); bbMinY = Math.min(bbMinY, b.minY);
95
+ bbMaxX = Math.max(bbMaxX, b.maxX); bbMaxY = Math.max(bbMaxY, b.maxY);
96
+ }
97
+ const initBoxW = (bbMaxX - bbMinX) || 1;
98
+ const initBoxH = (bbMaxY - bbMinY) || 1;
89
99
 
90
- st.resizeDrag = { corner, startMouse: { x: e.clientX, y: e.clientY }, startBBs, initBoxW, initBoxH };
100
+ st.resizeDrag = { corner, startMouse: { x: e.clientX, y: e.clientY }, startBBs, initBoxW, initBoxH, bbMinX, bbMinY, bbMaxX, bbMaxY };
91
101
  st.selectedNodeIds.forEach((id) => st.nodes.update({ id, fixed: true }));
92
102
  document.getElementById('vis-canvas').style.pointerEvents = 'none';
93
103
  document.addEventListener('mousemove', onResizeDrag);
@@ -110,22 +120,34 @@ function onResizeDrag(e) {
110
120
  if (c === 'bl') { nW = initW - cdx; nH = initH + cdy; }
111
121
  if (c === 'tr') { nW = initW + cdx; nH = initH - cdy; }
112
122
  if (c === 'tl') { nW = initW - cdx; nH = initH - cdy; }
123
+ if (e.shiftKey && initW > 0 && initH > 0) {
124
+ const scale = Math.max(nW / initW, nH / initH);
125
+ nW = initW * scale;
126
+ nH = initH * scale;
127
+ }
113
128
  nW = Math.max(MIN, Math.round(nW));
114
129
  nH = Math.max(MIN, Math.round(nH));
115
130
  st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
116
131
  updatedIds.push(id);
117
132
  } else {
118
- const { initBoxW, initBoxH } = st.resizeDrag;
133
+ const { initBoxW, initBoxH, bbMinX, bbMinY, bbMaxX, bbMaxY } = st.resizeDrag;
119
134
  let sx = 1, sy = 1;
120
135
  if (c === 'br') { sx = (initBoxW + cdx) / initBoxW; sy = (initBoxH + cdy) / initBoxH; }
121
136
  if (c === 'bl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH + cdy) / initBoxH; }
122
137
  if (c === 'tr') { sx = (initBoxW + cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
123
138
  if (c === 'tl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
124
139
  sx = Math.max(0.1, sx); sy = Math.max(0.1, sy);
125
- for (const { id, node, initW, initH } of st.resizeDrag.startBBs) {
140
+ if (e.shiftKey) { const s = Math.max(sx, sy); sx = s; sy = s; }
141
+
142
+ // Pivot = corner opposite to the drag corner (stays fixed during scale)
143
+ const pivotX = (c === 'br' || c === 'tr') ? bbMinX : bbMaxX;
144
+ const pivotY = (c === 'br' || c === 'bl') ? bbMinY : bbMaxY;
145
+
146
+ for (const { id, node, initW, initH, initX, initY } of st.resizeDrag.startBBs) {
126
147
  const nW = Math.max(MIN, Math.round(initW * sx));
127
148
  const nH = Math.max(MIN, Math.round(initH * sy));
128
149
  st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
150
+ st.network.moveNode(id, pivotX + (initX - pivotX) * sx, pivotY + (initY - pivotY) * sy);
129
151
  updatedIds.push(id);
130
152
  }
131
153
  }
@@ -1124,6 +1124,39 @@
1124
1124
  </div>
1125
1125
 
1126
1126
  <div id="toastContainer"></div>
1127
+
1128
+ <!-- Image name modal -->
1129
+ <div id="imageNameModal" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.5);"
1130
+ class="flex items-center justify-center">
1131
+ <div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-6 w-80 flex flex-col gap-3">
1132
+ <p class="text-sm font-semibold text-gray-700 dark:text-gray-200">Nom du fichier image</p>
1133
+ <p class="text-xs text-gray-400 dark:text-gray-500">
1134
+ Lettres, chiffres, <code class="bg-gray-100 dark:bg-gray-700 px-0.5 rounded">_</code> et
1135
+ <code class="bg-gray-100 dark:bg-gray-700 px-0.5 rounded">-</code> uniquement.
1136
+ Laisser vide pour un nom automatique.
1137
+ </p>
1138
+ <div class="flex items-center gap-1">
1139
+ <input id="imageNameInput" type="text" autocomplete="off" spellcheck="false"
1140
+ class="flex-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700
1141
+ text-gray-900 dark:text-gray-100 text-sm px-2 py-1 outline-none focus:ring-2 focus:ring-blue-400"
1142
+ placeholder="nom_image" />
1143
+ <span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">.png</span>
1144
+ </div>
1145
+ <p id="imageNameError" class="text-xs text-red-500 hidden">Caractères autorisés : lettres, chiffres, _ et -</p>
1146
+ <div class="flex gap-2 justify-end">
1147
+ <button id="imageNameCancel"
1148
+ class="px-3 py-1 text-xs rounded border border-gray-300 dark:border-gray-600
1149
+ text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
1150
+ Annuler
1151
+ </button>
1152
+ <button id="imageNameConfirm"
1153
+ class="px-3 py-1 text-xs rounded bg-blue-500 text-white hover:bg-blue-600">
1154
+ Coller
1155
+ </button>
1156
+ </div>
1157
+ </div>
1158
+ </div>
1159
+
1127
1160
  <script type="module" src="/diagram/main.js"></script>
1128
1161
  </body>
1129
1162
  </html>
@@ -139,10 +139,18 @@
139
139
  border-radius: 2px;
140
140
  padding: 0 2px;
141
141
  }
142
+ mark.match-active {
143
+ background: #f97316;
144
+ color: #fff;
145
+ }
142
146
  .dark mark {
143
147
  background: #713f12;
144
148
  color: #fef9c3;
145
149
  }
150
+ .dark mark.match-active {
151
+ background: #ea580c;
152
+ color: #fff;
153
+ }
146
154
  </style>
147
155
  </head>
148
156
 
@@ -329,6 +337,13 @@
329
337
  >
330
338
  &#9998; Edit
331
339
  </button>
340
+ <button
341
+ onclick="openDiagramLinkModal()"
342
+ title="Link a diagram"
343
+ class="no-print text-sm px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
344
+ >
345
+ &#9671; Diagram
346
+ </button>
332
347
  </div>
333
348
  <!-- Edit mode actions -->
334
349
  <div id="edit-actions" class="hidden flex items-center gap-2">
@@ -379,6 +394,65 @@
379
394
  </div>
380
395
  <!-- end root -->
381
396
 
397
+ <!-- ── Diagram link modal ── -->
398
+ <div id="diag-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
399
+ <div class="bg-white dark:bg-gray-900 rounded-xl shadow-xl w-full max-w-lg mx-4 p-6 space-y-5">
400
+ <h3 class="text-base font-semibold text-gray-900 dark:text-gray-50">&#9671; Link a diagram</h3>
401
+
402
+ <!-- Mode toggle -->
403
+ <div class="flex gap-4 text-sm">
404
+ <label class="flex items-center gap-2 cursor-pointer">
405
+ <input type="radio" name="diag-mode" id="diag-mode-existing" value="existing" checked onchange="diagModeChanged()" />
406
+ <span class="text-gray-700 dark:text-gray-300">Existing diagram</span>
407
+ </label>
408
+ <label class="flex items-center gap-2 cursor-pointer">
409
+ <input type="radio" name="diag-mode" id="diag-mode-new" value="new" onchange="diagModeChanged()" />
410
+ <span class="text-gray-700 dark:text-gray-300">New diagram</span>
411
+ </label>
412
+ </div>
413
+
414
+ <!-- Existing diagram picker -->
415
+ <div id="diag-existing-section" class="space-y-1.5">
416
+ <label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Select diagram</label>
417
+ <select id="diag-select" onchange="diagUpdatePreview()"
418
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500">
419
+ </select>
420
+ </div>
421
+
422
+ <!-- New diagram name -->
423
+ <div id="diag-new-section" class="hidden space-y-1.5">
424
+ <label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Diagram name</label>
425
+ <input id="diag-new-name" type="text" placeholder="My diagram" oninput="diagUpdatePreview()"
426
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" />
427
+ </div>
428
+
429
+ <!-- Image filename -->
430
+ <div class="space-y-1.5">
431
+ <label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Image filename <span class="font-normal text-gray-400">(saved in ./images/)</span></label>
432
+ <input id="diag-img-name" type="text" placeholder="diagram.png" oninput="diagUpdatePreview()"
433
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" />
434
+ </div>
435
+
436
+ <!-- Markdown preview -->
437
+ <div class="space-y-1.5">
438
+ <label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Markdown that will be appended</label>
439
+ <pre id="diag-preview" class="text-xs bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-2 overflow-x-auto text-gray-700 dark:text-gray-300 whitespace-pre-wrap"></pre>
440
+ </div>
441
+
442
+ <!-- Actions -->
443
+ <div class="flex justify-end gap-3 pt-1">
444
+ <button onclick="closeDiagramLinkModal()"
445
+ class="text-sm px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
446
+ Cancel
447
+ </button>
448
+ <button onclick="insertDiagramLink()" id="diag-insert-btn"
449
+ class="text-sm px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">
450
+ Insert &amp; Open Diagram
451
+ </button>
452
+ </div>
453
+ </div>
454
+ </div>
455
+
382
456
  <!-- ── Word Cloud overlay ── -->
383
457
  <div
384
458
  id="wc-overlay"
@@ -920,7 +994,7 @@
920
994
  const list = document.getElementById("search-notice-list");
921
995
  list.innerHTML = matches.map((m, i) =>
922
996
  `<li>
923
- <button onclick="scrollToMatch('${m.id}')"
997
+ <button onclick="scrollToMatch('${m.id}')" data-match-id="${m.id}"
924
998
  class="w-full text-left px-3 py-1.5 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
925
999
  <span class="text-yellow-600 dark:text-yellow-500 font-mono text-xs mr-2">${i + 1}.</span
926
1000
  ><span class="text-xs">${esc(m.snippet)}</span>
@@ -933,9 +1007,23 @@
933
1007
  const mark = document.getElementById(id);
934
1008
  const container = document.getElementById("content-area");
935
1009
  if (!mark || !container) return;
936
- const markTop = mark.getBoundingClientRect().top;
937
- const containerTop = container.getBoundingClientRect().top;
938
- container.scrollTop += markTop - containerTop - container.clientHeight / 3;
1010
+
1011
+ // Orange on the active mark, yellow on the rest
1012
+ document.querySelectorAll("mark.match-active").forEach((m) => m.classList.remove("match-active"));
1013
+ mark.classList.add("match-active");
1014
+
1015
+ // Highlight active list item
1016
+ document.querySelectorAll("#search-notice-list button").forEach((b) => {
1017
+ b.classList.toggle("bg-orange-100", b.dataset.matchId === id);
1018
+ b.classList.toggle("dark:bg-orange-900/30", b.dataset.matchId === id);
1019
+ });
1020
+
1021
+ const stickyHeader = document.querySelector("#doc-view header");
1022
+ const headerHeight = stickyHeader ? stickyHeader.getBoundingClientRect().height : 0;
1023
+ const markTop = mark.getBoundingClientRect().top;
1024
+ const containerTop = container.getBoundingClientRect().top;
1025
+ const targetOffset = headerHeight + (container.clientHeight - headerHeight) / 3;
1026
+ container.scrollTop += markTop - containerTop - targetOffset;
939
1027
  }
940
1028
 
941
1029
  function esc(str) {
@@ -948,6 +1036,112 @@
948
1036
  }
949
1037
 
950
1038
 
1039
+ // ── Diagram link modal ────────────────────────────────────
1040
+ let _diagrams = [];
1041
+
1042
+ async function openDiagramLinkModal() {
1043
+ if (!currentDocId) return;
1044
+ // Fetch diagram list
1045
+ try {
1046
+ _diagrams = await fetch("/api/diagrams").then((r) => r.json());
1047
+ } catch { _diagrams = []; }
1048
+
1049
+ const sel = document.getElementById("diag-select");
1050
+ sel.innerHTML = _diagrams.length
1051
+ ? _diagrams.map((d) => `<option value="${esc(d.id)}">${esc(d.title)}</option>`).join("")
1052
+ : `<option value="" disabled>No diagrams yet</option>`;
1053
+
1054
+ // Pre-fill image name from current doc title
1055
+ const docTitle = document.getElementById("doc-title").textContent.trim();
1056
+ const slug = docTitle.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
1057
+ document.getElementById("diag-img-name").value = slug ? slug + ".png" : "diagram.png";
1058
+ document.getElementById("diag-new-name").value = docTitle ? docTitle + " Diagram" : "";
1059
+
1060
+ // Default mode
1061
+ document.getElementById("diag-mode-existing").checked = true;
1062
+ diagModeChanged();
1063
+
1064
+ document.getElementById("diag-link-modal").classList.remove("hidden");
1065
+ }
1066
+
1067
+ function closeDiagramLinkModal() {
1068
+ document.getElementById("diag-link-modal").classList.add("hidden");
1069
+ }
1070
+
1071
+ function diagModeChanged() {
1072
+ const isNew = document.getElementById("diag-mode-new").checked;
1073
+ document.getElementById("diag-existing-section").classList.toggle("hidden", isNew);
1074
+ document.getElementById("diag-new-section").classList.toggle("hidden", !isNew);
1075
+ diagUpdatePreview();
1076
+ }
1077
+
1078
+ function diagUpdatePreview() {
1079
+ const isNew = document.getElementById("diag-mode-new").checked;
1080
+ const imgName = document.getElementById("diag-img-name").value.trim() || "diagram.png";
1081
+ let diagId, diagLabel;
1082
+ if (isNew) {
1083
+ diagId = "d" + Date.now();
1084
+ diagLabel = document.getElementById("diag-new-name").value.trim() || "Diagram";
1085
+ } else {
1086
+ const sel = document.getElementById("diag-select");
1087
+ diagId = sel.value;
1088
+ diagLabel = sel.options[sel.selectedIndex]?.text || "Diagram";
1089
+ }
1090
+ const md = `\n\n[![${diagLabel}](./images/${imgName})](/diagram?id=${diagId})`;
1091
+ document.getElementById("diag-preview").textContent = md.trim();
1092
+ // store for insertDiagramLink
1093
+ document.getElementById("diag-insert-btn").dataset.diagId = diagId;
1094
+ document.getElementById("diag-insert-btn").dataset.diagLabel = diagLabel;
1095
+ document.getElementById("diag-insert-btn").dataset.imgName = imgName;
1096
+ document.getElementById("diag-insert-btn").dataset.isNew = isNew ? "1" : "0";
1097
+ }
1098
+
1099
+ async function insertDiagramLink() {
1100
+ const btn = document.getElementById("diag-insert-btn");
1101
+ const diagId = btn.dataset.diagId;
1102
+ const diagLabel= btn.dataset.diagLabel;
1103
+ const imgName = btn.dataset.imgName;
1104
+ const isNew = btn.dataset.isNew === "1";
1105
+
1106
+ btn.disabled = true;
1107
+ btn.textContent = "Saving…";
1108
+
1109
+ try {
1110
+ // Create new diagram if needed
1111
+ if (isNew) {
1112
+ await fetch(`/api/diagrams/${diagId}`, {
1113
+ method: "PUT",
1114
+ headers: { "Content-Type": "application/json" },
1115
+ body: JSON.stringify({ title: diagLabel, nodes: [], edges: [] }),
1116
+ });
1117
+ }
1118
+
1119
+ // Append markdown to document and save
1120
+ const md = `\n\n[![${diagLabel}](./images/${imgName})](/diagram?id=${diagId})`;
1121
+ const newContent = currentDocContent + md;
1122
+ const res = await fetch("/api/documents/" + currentDocId, {
1123
+ method: "PUT",
1124
+ headers: { "Content-Type": "application/json" },
1125
+ body: JSON.stringify({ content: newContent }),
1126
+ });
1127
+ if (!res.ok) throw new Error(await res.text());
1128
+ currentDocContent = newContent;
1129
+
1130
+ // Re-render the document so the page is up-to-date when the user navigates back
1131
+ const doc = await fetch("/api/documents/" + currentDocId).then((r) => r.json());
1132
+ const contentEl = document.getElementById("doc-content");
1133
+ contentEl.innerHTML = doc.html;
1134
+ contentEl.querySelectorAll("pre code").forEach((b) => hljs.highlightElement(b));
1135
+
1136
+ closeDiagramLinkModal();
1137
+ window.location.href = `/diagram?id=${diagId}`;
1138
+ } catch (err) {
1139
+ btn.disabled = false;
1140
+ btn.textContent = "Insert & Open Diagram";
1141
+ alert("Error: " + err.message);
1142
+ }
1143
+ }
1144
+
951
1145
  // Browser back/forward
952
1146
  window.addEventListener("popstate", (e) => {
953
1147
  const id =
@@ -1 +1 @@
1
- {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../../src/routes/images.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AAIpD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAuCrD"}
1
+ {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../../src/routes/images.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AAIpD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA6CrD"}
@@ -11,7 +11,7 @@ function imagesRouter(docsPath) {
11
11
  const router = (0, express_1.Router)();
12
12
  // POST /api/images/upload — save a base64-encoded image to DOCS_FOLDER/images/
13
13
  router.post('/upload', (req, res) => {
14
- const { data, ext } = req.body;
14
+ const { data, ext, name } = req.body;
15
15
  if (typeof data !== 'string' || !data) {
16
16
  return res.status(400).json({ error: 'data is required' });
17
17
  }
@@ -22,12 +22,19 @@ function imagesRouter(docsPath) {
22
22
  if (!fs_1.default.existsSync(imagesDir)) {
23
23
  fs_1.default.mkdirSync(imagesDir, { recursive: true });
24
24
  }
25
- const now = new Date();
26
- const pad = (n) => String(n).padStart(2, '0');
27
- const timestamp = `${now.getFullYear()}_${pad(now.getMonth() + 1)}_${pad(now.getDate())}` +
28
- `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
29
- const random = Math.random().toString(36).slice(2, 6);
30
- const filename = `image_${timestamp}_${random}.${safeExt}`;
25
+ let baseName;
26
+ if (typeof name === 'string' && name.trim()) {
27
+ baseName = name.trim().replace(/[^a-z0-9_\-]/gi, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
28
+ }
29
+ else {
30
+ const now = new Date();
31
+ const pad = (n) => String(n).padStart(2, '0');
32
+ const timestamp = `${now.getFullYear()}_${pad(now.getMonth() + 1)}_${pad(now.getDate())}` +
33
+ `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
34
+ const random = Math.random().toString(36).slice(2, 6);
35
+ baseName = `image_${timestamp}_${random}`;
36
+ }
37
+ const filename = `${baseName}.${safeExt}`;
31
38
  const filePath = path_1.default.join(imagesDir, filename);
32
39
  try {
33
40
  fs_1.default.writeFileSync(filePath, Buffer.from(base64, 'base64'));
@@ -1 +1 @@
1
- {"version":3,"file":"images.js","sourceRoot":"","sources":["../../../src/routes/images.ts"],"names":[],"mappings":";;;;;AAIA,oCAuCC;AA3CD,qCAAoD;AACpD,4CAAoB;AACpB,gDAAwB;AAExB,SAAgB,YAAY,CAAC,QAAgB;IAC3C,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;IAExB,+EAA+E;IAC/E,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;QACrD,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC,IAAuC,CAAC;QAElE,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;YACtC,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC,CAAC;QAC7D,CAAC;QAED,MAAM,OAAO,GAAG,CAAC,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,IAAI,KAAK,CAAC;QAE1G,0CAA0C;QAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,4BAA4B,EAAE,EAAE,CAAC,CAAC;QAE9D,MAAM,SAAS,GAAG,cAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAChD,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9B,YAAE,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QACtD,MAAM,SAAS,GACb,GAAG,GAAG,CAAC,WAAW,EAAE,IAAI,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,EAAE;YACvE,IAAI,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,EAAE,CAAC;QAC5E,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACtD,MAAM,QAAQ,GAAG,SAAS,SAAS,IAAI,MAAM,IAAI,OAAO,EAAE,CAAC;QAC3D,MAAM,QAAQ,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAEhD,IAAI,CAAC;YACH,YAAE,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;YAC1D,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;QACzB,CAAC;QAAC,MAAM,CAAC;YACP,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,sBAAsB,EAAE,CAAC,CAAC;QAC1D,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"images.js","sourceRoot":"","sources":["../../../src/routes/images.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "living-documentation",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "A CLI tool that serves a local Markdown documentation viewer",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {