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 +15 -5
- package/dist/src/frontend/diagram/image-name-modal.js +48 -0
- package/dist/src/frontend/diagram/image-upload.js +8 -6
- package/dist/src/frontend/diagram/main.js +29 -16
- package/dist/src/frontend/diagram/network.js +51 -17
- package/dist/src/frontend/diagram/selection-overlay.js +28 -6
- package/dist/src/frontend/diagram.html +33 -0
- package/dist/src/frontend/index.html +198 -4
- package/dist/src/routes/images.d.ts.map +1 -1
- package/dist/src/routes/images.js +14 -7
- package/dist/src/routes/images.js.map +1 -1
- package/package.json +1 -1
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
|

|
|
8
8
|

|
|
@@ -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
|
+
[](/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
|
+
[](/diagram?id=d1775399110713)
|
|
19
|
+
|
|
14
20
|
- **Recursive folder scanning** — subdirectories are scanned automatically; subdirectory name becomes the category
|
|
15
|
-
|
|
16
|
-
- **Extra files** —
|
|
17
|
-
|
|
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
|
+
[](/diagram?id=d1775399110713)
|
|
24
|
+
|
|
18
25
|
- **Dark mode** — follows system preference, manually toggleable
|
|
19
26
|
- **Syntax highlighting** — always dark, high-contrast code blocks
|
|
27
|
+
[](/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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
✎ 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
|
+
◇ 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">◇ 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 & 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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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[](/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[](/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,
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
1
|
+
{"version":3,"file":"images.js","sourceRoot":"","sources":["../../../src/routes/images.ts"],"names":[],"mappings":"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"}
|