living-documentation 7.1.0 → 7.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 +68 -22
- package/dist/src/frontend/admin.html +38 -0
- package/dist/src/frontend/boot.js +7 -1
- package/dist/src/frontend/documents.js +26 -14
- package/dist/src/frontend/file-attach.js +178 -0
- package/dist/src/frontend/i18n/en.json +13 -0
- package/dist/src/frontend/i18n/fr.json +13 -0
- package/dist/src/frontend/index.html +61 -17
- package/dist/src/frontend/snippets.js +132 -3
- package/dist/src/lib/config.d.ts +1 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +11 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/routes/config.d.ts.map +1 -1
- package/dist/src/routes/config.js +14 -0
- package/dist/src/routes/config.js.map +1 -1
- package/dist/src/routes/documents.d.ts +1 -0
- package/dist/src/routes/documents.d.ts.map +1 -1
- package/dist/src/routes/documents.js +20 -1
- package/dist/src/routes/documents.js.map +1 -1
- package/dist/src/routes/files.d.ts +3 -0
- package/dist/src/routes/files.d.ts.map +1 -0
- package/dist/src/routes/files.js +80 -0
- package/dist/src/routes/files.js.map +1 -0
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +3 -0
- package/dist/src/server.js.map +1 -1
- package/dist/starting-doc/.living-doc.json +11 -1
- package/dist/starting-doc/2026_04_21_19_47_[General]_tata.md +6 -0
- package/dist/starting-doc/2026_04_21_19_47_[General]_tutu.md +11 -0
- package/dist/starting-doc/2026_04_21_19_52_[General]_titi.md +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ ExtraFiles (added in the admin section) are always first, always expanded in a `
|
|
|
31
31
|
|
|
32
32
|
- **Inline editing** — edit any document directly in the browser, saves to disk instantly
|
|
33
33
|
- **Image paste** — paste an image from clipboard in the editor; auto-uploaded and inserted as Markdown
|
|
34
|
+
- **File attachments** — drag & drop, paste or pick any non-image file (PDF, archives, office docs…) in the editor; uploaded under `DOCS_FOLDER/files/` and inserted as a paperclip link. Blocked extensions and size limits are configurable from the Admin panel.
|
|
34
35
|
- **Snippet inserter** — click **🧩 Snippets** while editing to insert pre-built Markdown constructs at the cursor position:
|
|
35
36
|
- *Simple snippets*: collapsible block (`<details>`), link, link to another document, anchor link, anchor link in another document, numbered list (3 levels), bullet list (3 levels), code block, blockquote, horizontal separator, image
|
|
36
37
|
- *Complex snippets*: **table editor** (dynamic rows/columns grid, generates aligned Markdown table) and **tree editor** (indentation-based ASCII tree, generates a `text` code block with `├──` / `└──` connectors)
|
|
@@ -211,22 +212,42 @@ Use the Admin panel's **General — Extra Files** section to browse the filesyst
|
|
|
211
212
|
```
|
|
212
213
|
living-documentation/
|
|
213
214
|
├── bin/
|
|
214
|
-
│ └── cli.ts
|
|
215
|
+
│ └── cli.ts CLI entry point (Commander)
|
|
215
216
|
├── src/
|
|
216
|
-
│ ├── server.ts
|
|
217
|
+
│ ├── server.ts Express app (mounts routes + static frontend)
|
|
217
218
|
│ ├── routes/
|
|
218
|
-
│ │ ├── documents.ts
|
|
219
|
-
│ │ ├── config.ts
|
|
220
|
-
│ │ ├── browse.ts
|
|
221
|
-
│ │
|
|
219
|
+
│ │ ├── documents.ts Documents API (list, search, read, write, create, delete)
|
|
220
|
+
│ │ ├── config.ts Config API
|
|
221
|
+
│ │ ├── browse.ts Filesystem browser API (+ mkdir)
|
|
222
|
+
│ │ ├── images.ts Image upload API
|
|
223
|
+
│ │ ├── files.ts File attachment upload API (paperclip)
|
|
224
|
+
│ │ ├── wordcloud.ts Word cloud raw text reader
|
|
225
|
+
│ │ ├── diagrams.ts Diagrams CRUD API (vis-network JSON)
|
|
226
|
+
│ │ ├── annotations.ts Per-document highlight markers API
|
|
227
|
+
│ │ └── export.ts HTML export (PDF, Notion, Confluence zip)
|
|
228
|
+
│ ├── mcp/
|
|
229
|
+
│ │ ├── server.ts Model Context Protocol server (Streamable HTTP)
|
|
230
|
+
│ │ └── tools/
|
|
231
|
+
│ │ ├── documents.ts MCP tools: list/read/create document
|
|
232
|
+
│ │ ├── diagrams.ts MCP tools: list/read/create diagram
|
|
233
|
+
│ │ └── source.ts MCP tools: list/read/search source files
|
|
222
234
|
│ ├── lib/
|
|
223
|
-
│ │ ├── parser.ts
|
|
224
|
-
│ │ └── config.ts
|
|
235
|
+
│ │ ├── parser.ts Filename parser
|
|
236
|
+
│ │ └── config.ts Config management (.living-doc.json)
|
|
225
237
|
│ └── frontend/
|
|
226
|
-
│ ├── index.html
|
|
227
|
-
│
|
|
238
|
+
│ ├── index.html Main viewer shell
|
|
239
|
+
│ ├── admin.html Admin panel
|
|
240
|
+
│ ├── diagram.html Diagram editor shell
|
|
241
|
+
│ ├── i18n.js i18n loader (window.t + data-i18n binding)
|
|
242
|
+
│ ├── i18n/{en,fr}.json Translation catalogs
|
|
243
|
+
│ ├── wordcloud.js Word cloud logic
|
|
244
|
+
│ ├── vendor/ Vendored browser libraries (wordcloud2.js)
|
|
245
|
+
│ ├── *.js Viewer modules (state, sidebar, search, documents, …)
|
|
246
|
+
│ └── diagram/*.js Diagram editor modules (network, panels, history, …)
|
|
228
247
|
├── scripts/
|
|
229
|
-
│ └── copy-assets.
|
|
248
|
+
│ └── copy-assets.ts Build helper (copies frontend + starting-doc to dist/)
|
|
249
|
+
├── starting-doc/ Sample docs shipped with the npm package
|
|
250
|
+
├── documentation/adrs/ Architecture Decision Records for this project
|
|
230
251
|
├── package.json
|
|
231
252
|
└── tsconfig.json
|
|
232
253
|
```
|
|
@@ -235,16 +256,34 @@ living-documentation/
|
|
|
235
256
|
|
|
236
257
|
## API reference
|
|
237
258
|
|
|
238
|
-
| Method
|
|
239
|
-
|
|
|
240
|
-
| `GET`
|
|
241
|
-
| `GET`
|
|
242
|
-
| `
|
|
243
|
-
| `
|
|
244
|
-
| `
|
|
245
|
-
| `
|
|
246
|
-
| `GET`
|
|
247
|
-
| `
|
|
259
|
+
| Method | Endpoint | Description |
|
|
260
|
+
| -------- | ------------------------------ | ------------------------------------------------------------------ |
|
|
261
|
+
| `GET` | `/api/documents` | List all documents with metadata (includes extra files) |
|
|
262
|
+
| `GET` | `/api/documents/:id` | Get document content + rendered HTML |
|
|
263
|
+
| `POST` | `/api/documents` | Create a new document from `{ title, category, folder?, content? }` |
|
|
264
|
+
| `PUT` | `/api/documents/:id` | Save document content to disk |
|
|
265
|
+
| `DELETE` | `/api/documents/:id` | Delete a document |
|
|
266
|
+
| `GET` | `/api/documents/search?q=` | Full-text search |
|
|
267
|
+
| `GET` | `/api/config` | Read config |
|
|
268
|
+
| `PUT` | `/api/config` | Update config (`title`, `theme`, `filenamePattern`, `extraFiles`, `showDiagramDebug`, `sourceRoot`, `blockedFileExtensions`) |
|
|
269
|
+
| `GET` | `/api/browse?path=` | List directories and `.md` files at a given filesystem path |
|
|
270
|
+
| `GET` | `/api/browse/alldirs?path=` | List directories recursively (for the folder picker) |
|
|
271
|
+
| `POST` | `/api/browse/mkdir` | Create a new folder under the docs root |
|
|
272
|
+
| `POST` | `/api/images/upload` | Upload a base64 image; saved to `DOCS_FOLDER/images/` |
|
|
273
|
+
| `POST` | `/api/files/upload` | Upload a base64 file attachment; saved to `DOCS_FOLDER/files/` |
|
|
274
|
+
| `GET` | `/api/diagrams` | List saved diagrams |
|
|
275
|
+
| `GET` | `/api/diagrams/:id` | Read a single diagram (nodes + edges) |
|
|
276
|
+
| `PUT` | `/api/diagrams/:id` | Create or update a diagram |
|
|
277
|
+
| `DELETE` | `/api/diagrams/:id` | Delete a diagram |
|
|
278
|
+
| `GET` | `/api/annotations` | List annotations for all documents |
|
|
279
|
+
| `GET` | `/api/annotations/:docId` | List annotations for one document |
|
|
280
|
+
| `POST` | `/api/annotations/:docId` | Add an annotation |
|
|
281
|
+
| `DELETE` | `/api/annotations/:docId/:id` | Delete one annotation |
|
|
282
|
+
| `POST` | `/api/export/html` | Export a document (or a zip bundle) as HTML — Notion / Confluence modes |
|
|
283
|
+
| `POST` | `/api/export/markdown` | Export documents as a Markdown bundle |
|
|
284
|
+
| `GET` | `/api/wordcloud?path=&ext=` | Recursively concatenate matching files as raw text |
|
|
285
|
+
| `POST` | `/mcp` | Model Context Protocol endpoint (Streamable HTTP) |
|
|
286
|
+
| `GET` | `/mcp` | Summary of available MCP tools and prompts |
|
|
248
287
|
|
|
249
288
|
---
|
|
250
289
|
|
|
@@ -268,11 +307,18 @@ A `GET http://localhost:4321/mcp` returns a JSON summary of available tools for
|
|
|
268
307
|
|
|
269
308
|
| Tool | Description |
|
|
270
309
|
|---|---|
|
|
310
|
+
| `get_server_guide` | Return the server guide (purpose, workflow, diagram conventions, coordinate system) |
|
|
271
311
|
| `list_documents` | List all documents with their id, title, category and folder |
|
|
272
312
|
| `read_document` | Read the raw Markdown content of a document by its id |
|
|
273
313
|
| `create_document` | Create a new Markdown document (filename generated from the configured pattern) |
|
|
274
314
|
| `list_diagrams` | List all saved diagrams with their id and title |
|
|
275
|
-
| `
|
|
315
|
+
| `read_diagram` | Read the nodes and edges of a diagram (same shape as `create_diagram` input) |
|
|
316
|
+
| `create_diagram` | Create or overwrite a diagram from nodes and edges (shapes, colors, labels) |
|
|
317
|
+
| `list_source_files` | List project source files under `sourceRoot` (fallback only) |
|
|
318
|
+
| `read_source_file` | Read a source file under `sourceRoot` (fallback only) |
|
|
319
|
+
| `search_source` | Grep-like text search across files under `sourceRoot` |
|
|
320
|
+
|
|
321
|
+
Prompts (`generate-context-diagram`, `generate-container-diagram`, `generate-uml-diagram`, `update-diagram-from-docs`, `generate-screen-guide`, `flow`, `erd`) are exposed alongside the tools for clients that surface MCP prompts to the user.
|
|
276
322
|
|
|
277
323
|
### Installation — Claude Desktop
|
|
278
324
|
|
|
@@ -316,6 +316,35 @@
|
|
|
316
316
|
</div>
|
|
317
317
|
</div>
|
|
318
318
|
|
|
319
|
+
<!-- ── File Attachments (full width) ── -->
|
|
320
|
+
<div
|
|
321
|
+
class="mt-8 rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-5 space-y-3"
|
|
322
|
+
>
|
|
323
|
+
<div>
|
|
324
|
+
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
325
|
+
<span data-i18n="admin.files.title">File Attachments</span>
|
|
326
|
+
</h3>
|
|
327
|
+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" data-i18n="admin.files.description">
|
|
328
|
+
Extensions that are rejected by the <code>/api/files/upload</code> endpoint. One extension per line (or comma/space separated), without the leading dot.
|
|
329
|
+
</p>
|
|
330
|
+
</div>
|
|
331
|
+
<label
|
|
332
|
+
class="block text-xs font-medium text-gray-500 dark:text-gray-400"
|
|
333
|
+
for="field-blocked-extensions"
|
|
334
|
+
data-i18n="admin.files.blocked_label"
|
|
335
|
+
>Blocked extensions</label>
|
|
336
|
+
<textarea
|
|
337
|
+
id="field-blocked-extensions"
|
|
338
|
+
name="blockedFileExtensions"
|
|
339
|
+
rows="3"
|
|
340
|
+
class="w-full px-3 py-2 font-mono text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
341
|
+
placeholder="exe sh bat cmd com scr ps1 msi"
|
|
342
|
+
></textarea>
|
|
343
|
+
<p class="text-xs text-gray-400 dark:text-gray-500" data-i18n="admin.files.hint">
|
|
344
|
+
Defaults to common executable formats. Max upload size is 19 MB per file.
|
|
345
|
+
</p>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
319
348
|
<!-- ── Diagram Palettes (full width) ── -->
|
|
320
349
|
<div
|
|
321
350
|
class="mt-8 rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 divide-y divide-gray-100 dark:divide-gray-800"
|
|
@@ -497,6 +526,8 @@
|
|
|
497
526
|
cfg.filenamePattern || "";
|
|
498
527
|
document.getElementById("field-debug").checked =
|
|
499
528
|
!!cfg.showDiagramDebug;
|
|
529
|
+
document.getElementById("field-blocked-extensions").value =
|
|
530
|
+
(cfg.blockedFileExtensions || []).join(" ");
|
|
500
531
|
updatePreview(cfg.filenamePattern);
|
|
501
532
|
initExtraFiles(cfg);
|
|
502
533
|
initPalettes(cfg);
|
|
@@ -525,6 +556,12 @@
|
|
|
525
556
|
const sourceRootRaw = document
|
|
526
557
|
.getElementById("field-source-root")
|
|
527
558
|
.value.trim();
|
|
559
|
+
const blockedExtensions = document
|
|
560
|
+
.getElementById("field-blocked-extensions")
|
|
561
|
+
.value
|
|
562
|
+
.split(/[\s,]+/)
|
|
563
|
+
.map((e) => e.trim().replace(/^\.+/, "").toLowerCase())
|
|
564
|
+
.filter((e) => /^[a-z0-9]+$/.test(e));
|
|
528
565
|
const payload = {
|
|
529
566
|
title: document.getElementById("field-title").value.trim(),
|
|
530
567
|
theme: document.getElementById("field-theme").value,
|
|
@@ -534,6 +571,7 @@
|
|
|
534
571
|
diagramNodePalette: [...nodePalette],
|
|
535
572
|
diagramEdgePalette: [...edgePalette],
|
|
536
573
|
sourceRoot: sourceRootRaw === "" ? null : sourceRootRaw,
|
|
574
|
+
blockedFileExtensions: blockedExtensions,
|
|
537
575
|
};
|
|
538
576
|
|
|
539
577
|
try {
|
|
@@ -11,6 +11,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
11
11
|
setupDarkToggle();
|
|
12
12
|
setupSearch();
|
|
13
13
|
wcRestorePrefs();
|
|
14
|
+
if (typeof initFileAttach === "function") initFileAttach();
|
|
14
15
|
await loadConfig();
|
|
15
16
|
await loadDocuments();
|
|
16
17
|
|
|
@@ -86,5 +87,10 @@ document
|
|
|
86
87
|
window.addEventListener("popstate", (e) => {
|
|
87
88
|
const id =
|
|
88
89
|
e.state?.docId || new URLSearchParams(location.search).get("doc");
|
|
89
|
-
|
|
90
|
+
const anchor =
|
|
91
|
+
e.state?.anchor ||
|
|
92
|
+
(location.hash && location.hash.length > 1
|
|
93
|
+
? location.hash.slice(1)
|
|
94
|
+
: null);
|
|
95
|
+
if (id) openDocument(id, true, false, anchor);
|
|
90
96
|
});
|
|
@@ -43,17 +43,23 @@ async function refreshAnnotationCounts() {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
async function openDocument(id, skipHistory = false, fromLink = false) {
|
|
46
|
+
async function openDocument(id, skipHistory = false, fromLink = false, anchor = null) {
|
|
47
47
|
// Track navigation history for breadcrumb trail
|
|
48
48
|
// fromLink===true : forward navigation via in-doc link → push current to stack
|
|
49
|
+
// (unless target is already in the stack → rewind instead of loop)
|
|
49
50
|
// fromLink==="restore" : back navigation via history breadcrumb → stack already trimmed, don't touch
|
|
50
51
|
// fromLink===false : sidebar/direct navigation → reset stack
|
|
51
52
|
if (fromLink === true && currentDocId && currentDocId !== id) {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
const existingIdx = navHistory.findIndex((e) => e.id === id);
|
|
54
|
+
if (existingIdx !== -1) {
|
|
55
|
+
navHistory = navHistory.slice(0, existingIdx);
|
|
56
|
+
} else {
|
|
57
|
+
const prev = allDocs && allDocs.find((d) => d.id === currentDocId);
|
|
58
|
+
navHistory.push({
|
|
59
|
+
id: currentDocId,
|
|
60
|
+
title: prev ? prev.title : currentDocId,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
57
63
|
} else if (!fromLink) {
|
|
58
64
|
navHistory = [];
|
|
59
65
|
}
|
|
@@ -105,7 +111,8 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
|
|
|
105
111
|
if (!skipHistory) {
|
|
106
112
|
const url = new URL(location.href);
|
|
107
113
|
url.searchParams.set("doc", id);
|
|
108
|
-
|
|
114
|
+
url.hash = anchor ? `#${anchor}` : "";
|
|
115
|
+
history.pushState({ docId: id, anchor: anchor || null }, "", url);
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
document.getElementById("welcome").classList.add("hidden");
|
|
@@ -158,14 +165,16 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
|
|
|
158
165
|
hljs.highlightElement(block);
|
|
159
166
|
});
|
|
160
167
|
|
|
161
|
-
// Intercept inter-doc links (?doc=X) to stay in SPA and track origin
|
|
168
|
+
// Intercept inter-doc links (?doc=X[#anchor]) to stay in SPA and track origin
|
|
162
169
|
contentEl.querySelectorAll("a[href]").forEach((a) => {
|
|
163
170
|
const href = a.getAttribute("href");
|
|
164
171
|
const m = href && href.match(/[?&]doc=([^&#]+)/);
|
|
165
172
|
if (!m) return;
|
|
173
|
+
const hashIdx = href.indexOf("#");
|
|
174
|
+
const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
|
|
166
175
|
a.addEventListener("click", (e) => {
|
|
167
176
|
e.preventDefault();
|
|
168
|
-
openDocument(decodeURIComponent(m[1]), false, true);
|
|
177
|
+
openDocument(decodeURIComponent(m[1]), false, true, anchor);
|
|
169
178
|
});
|
|
170
179
|
});
|
|
171
180
|
|
|
@@ -199,10 +208,11 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
|
|
|
199
208
|
|
|
200
209
|
document.title = doc.title;
|
|
201
210
|
|
|
202
|
-
// Scroll to anchor if present
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
211
|
+
// Scroll to anchor if present (explicit param wins over URL hash)
|
|
212
|
+
const targetAnchor =
|
|
213
|
+
anchor || (window.location.hash ? window.location.hash.slice(1) : "");
|
|
214
|
+
if (targetAnchor) {
|
|
215
|
+
scrollToAnchor(targetAnchor);
|
|
206
216
|
} else {
|
|
207
217
|
document.getElementById("content-area").scrollTop = 0;
|
|
208
218
|
}
|
|
@@ -331,9 +341,11 @@ async function saveDocument() {
|
|
|
331
341
|
const href = a.getAttribute("href");
|
|
332
342
|
const m = href && href.match(/[?&]doc=([^&#]+)/);
|
|
333
343
|
if (!m) return;
|
|
344
|
+
const hashIdx = href.indexOf("#");
|
|
345
|
+
const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
|
|
334
346
|
a.addEventListener("click", (e) => {
|
|
335
347
|
e.preventDefault();
|
|
336
|
-
openDocument(decodeURIComponent(m[1]), false, true);
|
|
348
|
+
openDocument(decodeURIComponent(m[1]), false, true, anchor);
|
|
337
349
|
});
|
|
338
350
|
});
|
|
339
351
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ── File attachments ────────────────────────────────────────────────────────
|
|
2
|
+
// Attach arbitrary files (pdf, docx, zip, …) to a document via drag & drop,
|
|
3
|
+
// paste or the Snippets panel. Files are saved under DOCS_FOLDER/files/ and
|
|
4
|
+
// inserted as `[📎 name.ext](./files/<server-name>.<ext>)` in the markdown.
|
|
5
|
+
//
|
|
6
|
+
// Client-side size guard matches server-side MAX_FILE_BYTES (19 MB) so the
|
|
7
|
+
// user gets an immediate toast when Express would otherwise reject the body.
|
|
8
|
+
|
|
9
|
+
const FILE_MAX_BYTES = 19 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function _fileAttachNotify(message, isError) {
|
|
12
|
+
const msgEl = document.getElementById("edit-save-msg");
|
|
13
|
+
if (msgEl) {
|
|
14
|
+
msgEl.textContent = message;
|
|
15
|
+
msgEl.className = isError
|
|
16
|
+
? "text-xs text-red-500 dark:text-red-400"
|
|
17
|
+
: "text-xs text-gray-400";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _fileAttachReadAsBase64(file) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const reader = new FileReader();
|
|
24
|
+
reader.onload = () => resolve(reader.result);
|
|
25
|
+
reader.onerror = reject;
|
|
26
|
+
reader.readAsDataURL(file);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _fileAttachInsertAtCursor(markdown, start, end) {
|
|
31
|
+
const editor = document.getElementById("doc-editor");
|
|
32
|
+
if (!editor) return;
|
|
33
|
+
const s = typeof start === "number" ? start : editor.selectionStart;
|
|
34
|
+
const e = typeof end === "number" ? end : editor.selectionEnd;
|
|
35
|
+
const before = editor.value.slice(0, s);
|
|
36
|
+
const after = editor.value.slice(e);
|
|
37
|
+
editor.value = before + markdown + after;
|
|
38
|
+
editor.selectionStart = editor.selectionEnd = s + markdown.length;
|
|
39
|
+
editor.focus();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function uploadAttachedFile(file, cursorStart, cursorEnd) {
|
|
43
|
+
if (!file) return;
|
|
44
|
+
|
|
45
|
+
if (file.size > FILE_MAX_BYTES) {
|
|
46
|
+
const mb = (file.size / 1024 / 1024).toFixed(1);
|
|
47
|
+
_fileAttachNotify(
|
|
48
|
+
(window.t ? window.t("doc.file_too_large") : "File too large") +
|
|
49
|
+
` (${mb} MB, max 19 MB)`,
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_fileAttachNotify(
|
|
56
|
+
window.t ? window.t("doc.uploading_file") : "Uploading file…",
|
|
57
|
+
false,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const base64 = await _fileAttachReadAsBase64(file);
|
|
62
|
+
const res = await fetch("/api/files/upload", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ data: base64, name: file.name }),
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
let msg = "";
|
|
69
|
+
try {
|
|
70
|
+
const body = await res.json();
|
|
71
|
+
msg = body.error || "";
|
|
72
|
+
} catch {
|
|
73
|
+
msg = await res.text();
|
|
74
|
+
}
|
|
75
|
+
throw new Error(msg || `HTTP ${res.status}`);
|
|
76
|
+
}
|
|
77
|
+
const { url, originalName } = await res.json();
|
|
78
|
+
const label = originalName || file.name;
|
|
79
|
+
const markdown = `[📎 ${label}](${url})`;
|
|
80
|
+
_fileAttachInsertAtCursor(markdown, cursorStart, cursorEnd);
|
|
81
|
+
_fileAttachNotify("", false);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
_fileAttachNotify(
|
|
84
|
+
(window.t ? window.t("doc.file_upload_failed") : "File upload failed: ") +
|
|
85
|
+
(err.message || String(err)),
|
|
86
|
+
true,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _fileAttachHandleDrop(e) {
|
|
92
|
+
const editor = document.getElementById("doc-editor");
|
|
93
|
+
if (!editor || editor.classList.contains("hidden")) return;
|
|
94
|
+
const files = Array.from(e.dataTransfer?.files ?? []);
|
|
95
|
+
if (files.length === 0) return;
|
|
96
|
+
|
|
97
|
+
// Images keep flowing through image-paste.js → ignore them here.
|
|
98
|
+
const nonImage = files.filter((f) => !f.type.startsWith("image/"));
|
|
99
|
+
if (nonImage.length === 0) return;
|
|
100
|
+
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
e.stopPropagation();
|
|
103
|
+
|
|
104
|
+
// Drop uses the current caret, not the drop coordinates — keeps things simple.
|
|
105
|
+
const start = editor.selectionStart;
|
|
106
|
+
const end = editor.selectionEnd;
|
|
107
|
+
(async () => {
|
|
108
|
+
let offset = 0;
|
|
109
|
+
for (const file of nonImage) {
|
|
110
|
+
await uploadAttachedFile(file, start + offset, end + offset);
|
|
111
|
+
// After insertion the caret is positioned AFTER the inserted markdown;
|
|
112
|
+
// re-read it for the next file so multiple drops append correctly.
|
|
113
|
+
offset = 0;
|
|
114
|
+
}
|
|
115
|
+
})();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _fileAttachHandlePaste(e) {
|
|
119
|
+
const editor = document.getElementById("doc-editor");
|
|
120
|
+
if (!editor) return;
|
|
121
|
+
const files = Array.from(e.clipboardData?.files ?? []);
|
|
122
|
+
if (files.length === 0) return;
|
|
123
|
+
const nonImage = files.filter((f) => !f.type.startsWith("image/"));
|
|
124
|
+
if (nonImage.length === 0) return;
|
|
125
|
+
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
const start = editor.selectionStart;
|
|
128
|
+
const end = editor.selectionEnd;
|
|
129
|
+
(async () => {
|
|
130
|
+
for (const file of nonImage) {
|
|
131
|
+
await uploadAttachedFile(file, start, end);
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _fileAttachHandleDragOver(e) {
|
|
137
|
+
const editor = document.getElementById("doc-editor");
|
|
138
|
+
if (!editor || editor.classList.contains("hidden")) return;
|
|
139
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
e.dataTransfer.dropEffect = "copy";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function initFileAttach() {
|
|
146
|
+
const editor = document.getElementById("doc-editor");
|
|
147
|
+
if (!editor) return;
|
|
148
|
+
editor.addEventListener("drop", _fileAttachHandleDrop);
|
|
149
|
+
editor.addEventListener("dragover", _fileAttachHandleDragOver);
|
|
150
|
+
editor.addEventListener("paste", _fileAttachHandlePaste);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Triggered by the 📎 snippet button — opens the native file picker.
|
|
154
|
+
function openFilePicker() {
|
|
155
|
+
const editor = document.getElementById("doc-editor");
|
|
156
|
+
if (!editor) return;
|
|
157
|
+
const start = editor.selectionStart;
|
|
158
|
+
const end = editor.selectionEnd;
|
|
159
|
+
|
|
160
|
+
let input = document.getElementById("file-attach-picker");
|
|
161
|
+
if (!input) {
|
|
162
|
+
input = document.createElement("input");
|
|
163
|
+
input.type = "file";
|
|
164
|
+
input.id = "file-attach-picker";
|
|
165
|
+
input.style.display = "none";
|
|
166
|
+
document.body.appendChild(input);
|
|
167
|
+
}
|
|
168
|
+
input.value = "";
|
|
169
|
+
input.onchange = async () => {
|
|
170
|
+
const file = input.files && input.files[0];
|
|
171
|
+
if (file) await uploadAttachedFile(file, start, end);
|
|
172
|
+
};
|
|
173
|
+
input.click();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
window.initFileAttach = initFileAttach;
|
|
177
|
+
window.openFilePicker = openFilePicker;
|
|
178
|
+
window.uploadAttachedFile = uploadAttachedFile;
|
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
"doc.save": "Save document",
|
|
59
59
|
"doc.uploading_image": "Uploading image…",
|
|
60
60
|
"doc.image_upload_failed": "Image upload failed: ",
|
|
61
|
+
"doc.uploading_file": "Uploading file…",
|
|
62
|
+
"doc.file_upload_failed": "File upload failed: ",
|
|
63
|
+
"doc.file_too_large": "File too large",
|
|
61
64
|
"doc.saving": "Saving…",
|
|
62
65
|
"doc.failed_to_load": "Failed to load document: ",
|
|
63
66
|
"doc.copied": "✓ Copied!",
|
|
@@ -149,6 +152,8 @@
|
|
|
149
152
|
"snippet.link_anchor_label": "Anchor",
|
|
150
153
|
"snippet.link_anchor_placeholder": "my-heading",
|
|
151
154
|
"snippet.link_anchor_hint": "(without #, e.g. my-heading)",
|
|
155
|
+
"snippet.link_anchor_select_hint": "(pick a heading from the document)",
|
|
156
|
+
"snippet.link_anchor_no_headings": "No headings detected in this document",
|
|
152
157
|
"snippet.link_target_doc_label": "Target document",
|
|
153
158
|
"snippet.code_lang_label": "Language",
|
|
154
159
|
"snippet.code_lang_hint": "(e.g. javascript, python, bash…)",
|
|
@@ -176,6 +181,9 @@
|
|
|
176
181
|
"snippet.saving_btn": "Saving…",
|
|
177
182
|
"snippet.detected_msg": "✓ Detected type: {type}. Fields pre-filled — edit then click Insert to replace selection.",
|
|
178
183
|
"snippet.unknown_type_msg": "⚠ Selected text doesn't match any known snippet type. Choose a type below — the snippet will still replace the selection.",
|
|
184
|
+
"snippet.attachment": "📎 File attachment",
|
|
185
|
+
"snippet.attachment_help": "Click <strong>Insert</strong> to choose a file. It will be uploaded under the <code>files/</code> folder and inserted as a <i class=\"fa-solid fa-paperclip\"></i> link in your document.",
|
|
186
|
+
"snippet.attachment_alt": "Tip: you can also drag & drop a file onto the editor, or paste it from the clipboard.",
|
|
179
187
|
|
|
180
188
|
"modal.img_paste.title": "Paste clipboard image?",
|
|
181
189
|
"modal.img_paste.filename_label": "Filename",
|
|
@@ -275,6 +283,11 @@
|
|
|
275
283
|
"admin.extra_files.added_label": "Added",
|
|
276
284
|
"admin.extra_files.loading": "Loading…",
|
|
277
285
|
|
|
286
|
+
"admin.files.title": "File Attachments",
|
|
287
|
+
"admin.files.description": "Extensions that are rejected by the <code>/api/files/upload</code> endpoint. One extension per line (or comma/space separated), without the leading dot.",
|
|
288
|
+
"admin.files.blocked_label": "Blocked extensions",
|
|
289
|
+
"admin.files.hint": "Defaults to common executable formats. Max upload size is 19 MB per file.",
|
|
290
|
+
|
|
278
291
|
"admin.palette.title": "Diagram Color Palettes",
|
|
279
292
|
"admin.palette.description": "Customize the colors available in the diagram editor. Changes take effect after saving.",
|
|
280
293
|
"admin.palette.shapes_title": "Shape colors",
|
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
"doc.save": "Enregistrer le document",
|
|
59
59
|
"doc.uploading_image": "Téléversement de l'image…",
|
|
60
60
|
"doc.image_upload_failed": "Échec du téléversement : ",
|
|
61
|
+
"doc.uploading_file": "Téléversement du fichier…",
|
|
62
|
+
"doc.file_upload_failed": "Échec du téléversement du fichier : ",
|
|
63
|
+
"doc.file_too_large": "Fichier trop volumineux",
|
|
61
64
|
"doc.saving": "Enregistrement…",
|
|
62
65
|
"doc.failed_to_load": "Impossible de charger le document : ",
|
|
63
66
|
"doc.copied": "✓ Copié !",
|
|
@@ -149,6 +152,8 @@
|
|
|
149
152
|
"snippet.link_anchor_label": "Ancre",
|
|
150
153
|
"snippet.link_anchor_placeholder": "mon-titre",
|
|
151
154
|
"snippet.link_anchor_hint": "(sans #, ex : mon-titre)",
|
|
155
|
+
"snippet.link_anchor_select_hint": "(choisissez un titre du document)",
|
|
156
|
+
"snippet.link_anchor_no_headings": "Aucun titre détecté dans ce document",
|
|
152
157
|
"snippet.link_target_doc_label": "Document cible",
|
|
153
158
|
"snippet.code_lang_label": "Langage",
|
|
154
159
|
"snippet.code_lang_hint": "(ex : javascript, python, bash…)",
|
|
@@ -176,6 +181,9 @@
|
|
|
176
181
|
"snippet.saving_btn": "Enregistrement…",
|
|
177
182
|
"snippet.detected_msg": "✓ Type détecté : {type}. Les champs ont été pré-remplis — modifiez puis cliquez sur Insérer pour remplacer la sélection.",
|
|
178
183
|
"snippet.unknown_type_msg": "⚠ Le texte sélectionné ne correspond à aucun type de snippet reconnu. Choisissez un type ci-dessous — le snippet remplacera quand même la sélection.",
|
|
184
|
+
"snippet.attachment": "📎 Pièce jointe",
|
|
185
|
+
"snippet.attachment_help": "Cliquez sur <strong>Insérer</strong> pour choisir un fichier. Il sera téléversé dans le dossier <code>files/</code> et inséré sous forme de lien <i class=\"fa-solid fa-paperclip\"></i> dans votre document.",
|
|
186
|
+
"snippet.attachment_alt": "Astuce : vous pouvez aussi glisser-déposer un fichier sur l'éditeur ou le coller depuis le presse-papiers.",
|
|
179
187
|
|
|
180
188
|
"modal.img_paste.title": "Coller l'image du presse-papiers ?",
|
|
181
189
|
"modal.img_paste.filename_label": "Nom du fichier",
|
|
@@ -275,6 +283,11 @@
|
|
|
275
283
|
"admin.extra_files.added_label": "Ajouté",
|
|
276
284
|
"admin.extra_files.loading": "Chargement…",
|
|
277
285
|
|
|
286
|
+
"admin.files.title": "Pièces jointes",
|
|
287
|
+
"admin.files.description": "Extensions refusées par le endpoint <code>/api/files/upload</code>. Une extension par ligne (ou séparées par des virgules ou des espaces), sans le point.",
|
|
288
|
+
"admin.files.blocked_label": "Extensions bloquées",
|
|
289
|
+
"admin.files.hint": "Par défaut, les formats exécutables courants. Taille maximale : 19 Mo par fichier.",
|
|
290
|
+
|
|
278
291
|
"admin.palette.title": "Palettes de couleurs des diagrammes",
|
|
279
292
|
"admin.palette.description": "Personnalisez les couleurs disponibles dans l'éditeur de diagrammes. Les modifications prennent effet après enregistrement.",
|
|
280
293
|
"admin.palette.shapes_title": "Couleurs des formes",
|