hdoc-tools 0.63.0 → 0.65.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.
@@ -0,0 +1,2129 @@
1
+ //-- LOCAL PREVIEW PATCH — hdoc-tools only, NOT part of the live-site viewer.
2
+ //-- Do not copy this file to esp-docs-service htdocs, and keep it (plus its
3
+ //-- loader entry in js/bootstrap.js) when re-syncing ui/ from the live site.
4
+ //--
5
+ //-- Edit Mode suite for `hdoc serve` (PoC). Probes /_edit/mode; when the
6
+ //-- server confirms edit mode (loopback callers only), an "Edit mode" toggle
7
+ //-- button is added to the toolbar (next to the AI button). Toggling it on:
8
+ //-- - opens the inline page editor (js/hdoc-edit-inline.js)
9
+ //-- - swaps the left-hand Vue nav for a managed sidebar with two tabs:
10
+ //-- Contents — the book's navigation tree (hdocbook.json), with the
11
+ //-- same operations as the `hdoc edit` Contents tab:
12
+ //-- rename, add page/section, delete (± file), draft flag,
13
+ //-- node properties (text/link/expand/id), and move via
14
+ //-- drag-drop or menu
15
+ //-- Files — every file on disk under the book (excluding _ folders),
16
+ //-- flagged page/image/other and linked/unlinked; open pages
17
+ //-- in the editor, create pages/folders, delete files
18
+ //-- All writes go to the /_edit/toc and /_edit/files routes served by
19
+ //-- hdoc-serve.js (loopback-gated), backed by the same TocModel that powers
20
+ //-- `hdoc edit`. After every nav change the viewer's own nav data is
21
+ //-- refreshed from disk so leaving Edit Mode shows the updated tree.
22
+ //-- Relies on viewer globals: view.docApp, loadContentUrl(); and on
23
+ //-- window.HdocEditInline exposed by js/hdoc-edit-inline.js.
24
+
25
+ (() => {
26
+ "use strict";
27
+
28
+ let edit_mode = false;
29
+ let tab = "contents"; //-- "contents" | "files"
30
+ let host = null; //-- our sidebar panel element
31
+ let toc_dto = null; //-- { docId, title, tree }
32
+ let files_dto = null; //-- { docId, dirs, files }
33
+ let ctx_menu = null;
34
+ let drag_id = null;
35
+ let files_unlinked_only = false; //-- Files tab: show only unlinked pages
36
+ const nav_problems = new Map(); //-- node id -> [problem strings] (last nav validate)
37
+ let es = null; //-- EventSource for /_edit/events (open while edit mode is on)
38
+ let fs_refresh_timer = null;
39
+ //-- expanded state survives re-renders (not persisted across reloads)
40
+ const expanded = new Set(); //-- branch node ids
41
+ const fexpanded = new Set(); //-- folder rel paths
42
+
43
+ //-- --- utilities --------------------------------------------------------
44
+
45
+ const api = async (method, url, body) => {
46
+ const r = await fetch(url, {
47
+ method,
48
+ headers: body ? { "content-type": "application/json" } : undefined,
49
+ body: body ? JSON.stringify(body) : undefined,
50
+ });
51
+ let json = null;
52
+ try {
53
+ json = await r.json();
54
+ } catch {
55
+ /* non-JSON response */
56
+ }
57
+ return { ok: r.ok, status: r.status, json };
58
+ };
59
+
60
+ const pref = (k) => {
61
+ try {
62
+ return localStorage.getItem(k);
63
+ } catch {
64
+ return null;
65
+ }
66
+ };
67
+ const set_pref = (k, v) => {
68
+ try {
69
+ localStorage.setItem(k, v);
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ };
74
+
75
+ const docid = () =>
76
+ (window.view && view.docApp.book && view.docApp.book.docId) ||
77
+ decodeURIComponent(window.location.pathname).replace(/^\/+/, "").split("/")[0];
78
+
79
+ const logical_path = () =>
80
+ decodeURIComponent(window.location.pathname)
81
+ .replace(/^\/+/, "")
82
+ .replace(/\/+$/, "");
83
+
84
+ const esc_html = (s) =>
85
+ String(s ?? "")
86
+ .replace(/&/g, "&")
87
+ .replace(/</g, "&lt;")
88
+ .replace(/>/g, "&gt;")
89
+ .replace(/"/g, "&quot;");
90
+
91
+ const toast = (msg, is_err) => {
92
+ const t = document.createElement("div");
93
+ t.className = `hb-eb-toast${is_err ? " hb-eb-toast-err" : ""}`;
94
+ t.textContent = msg;
95
+ document.body.appendChild(t);
96
+ setTimeout(() => t.remove(), is_err ? 6000 : 2600);
97
+ };
98
+
99
+ //-- navigate the viewer SPA to a book-relative link (no leading slash)
100
+ const nav_to = (link) => {
101
+ try {
102
+ return Promise.resolve(loadContentUrl(link));
103
+ } catch {
104
+ window.location.href = `/${link}`;
105
+ return Promise.resolve();
106
+ }
107
+ };
108
+
109
+ //-- Open the page editor once the inline patch is ready. On a fresh page
110
+ //-- load window.HdocEditInline appears only after that script's async
111
+ //-- /_edit/mode probe resolves, so a direct call can silently no-op —
112
+ //-- poll briefly instead. Bails out if edit mode is left meanwhile.
113
+ const open_editor_soon = () => {
114
+ let tries = 0;
115
+ const t = setInterval(() => {
116
+ if (!edit_mode || ++tries > 25) {
117
+ clearInterval(t);
118
+ return;
119
+ }
120
+ if (window.HdocEditInline) {
121
+ clearInterval(t);
122
+ window.HdocEditInline.open();
123
+ }
124
+ }, 200);
125
+ };
126
+
127
+ //-- refresh the viewer's own nav model from disk so the Vue nav (shown
128
+ //-- when Edit Mode is off) reflects structural changes
129
+ const refresh_vue_nav = async () => {
130
+ try {
131
+ const r = await fetch(`/_books/${docid()}/hdocbook.json`);
132
+ if (!r.ok) return;
133
+ const book = await r.json();
134
+ if (
135
+ window.view &&
136
+ view.docApp.book &&
137
+ view.docApp.book.docId &&
138
+ book.navigation
139
+ ) {
140
+ view.docApp.book.navigation = book.navigation;
141
+ view.renderNavigation();
142
+ }
143
+ } catch {
144
+ /* nav refresh is best-effort */
145
+ }
146
+ };
147
+
148
+ //-- --- context menu -----------------------------------------------------
149
+
150
+ const close_ctx = () => {
151
+ if (ctx_menu) {
152
+ ctx_menu.remove();
153
+ ctx_menu = null;
154
+ }
155
+ };
156
+
157
+ //-- items: [{ label, icon, danger, sep, onClick }]
158
+ const show_ctx = (x, y, items) => {
159
+ close_ctx();
160
+ ctx_menu = document.createElement("div");
161
+ ctx_menu.className = "hb-eb-ctx";
162
+ for (const it of items) {
163
+ if (it.sep) {
164
+ const s = document.createElement("div");
165
+ s.className = "hb-eb-ctx-sep";
166
+ ctx_menu.appendChild(s);
167
+ continue;
168
+ }
169
+ const b = document.createElement("button");
170
+ b.type = "button";
171
+ b.className = `hb-eb-ctx-item${it.danger ? " hb-eb-ctx-danger" : ""}`;
172
+ b.innerHTML = `<i class="bi ${it.icon || "bi-dot"}" aria-hidden="true"></i> ${esc_html(it.label)}`;
173
+ b.addEventListener("click", (e) => {
174
+ e.stopPropagation();
175
+ close_ctx();
176
+ it.onClick();
177
+ });
178
+ ctx_menu.appendChild(b);
179
+ }
180
+ document.body.appendChild(ctx_menu);
181
+ ctx_menu.style.left = `${Math.min(x, window.innerWidth - ctx_menu.offsetWidth - 8)}px`;
182
+ ctx_menu.style.top = `${Math.min(y, window.innerHeight - ctx_menu.offsetHeight - 8)}px`;
183
+ };
184
+
185
+ //-- --- dialog -----------------------------------------------------------
186
+ //-- Minimal modal: title, fields, OK/Cancel. Resolves to { name: value }
187
+ //-- (checkbox -> boolean) or null on cancel/Escape.
188
+
189
+ const dialog = ({ title, message, fields = [], okText = "OK", danger }) =>
190
+ new Promise((resolve) => {
191
+ const overlay = document.createElement("div");
192
+ overlay.className = "hb-eb-overlay";
193
+ const box = document.createElement("div");
194
+ box.className = "hb-eb-dialog";
195
+ let inner = `<div class="hb-eb-dialog-title">${esc_html(title)}</div>`;
196
+ if (message) {
197
+ inner += `<div class="hb-eb-dialog-msg">${esc_html(message)}</div>`;
198
+ }
199
+ for (const f of fields) {
200
+ if (f.type === "checkbox") {
201
+ inner += `<label class="hb-eb-dialog-check"><input type="checkbox" name="${esc_html(f.name)}" ${f.value ? "checked" : ""}/> ${esc_html(f.label)}</label>`;
202
+ } else if (f.type === "heading") {
203
+ inner += `<div class="hb-eb-dialog-sect">${esc_html(f.label)}</div>`;
204
+ } else if (f.type === "select") {
205
+ const opts = (f.options || [])
206
+ .map(
207
+ (o) =>
208
+ `<option value="${esc_html(o.value)}" ${o.value === (f.value ?? "") ? "selected" : ""}>${esc_html(o.label)}</option>`,
209
+ )
210
+ .join("");
211
+ inner += `<label class="hb-eb-dialog-field"><span>${esc_html(f.label)}</span><select name="${esc_html(f.name)}">${opts}</select></label>`;
212
+ } else {
213
+ const input = `<input type="text" name="${esc_html(f.name)}" value="${esc_html(f.value ?? "")}" placeholder="${esc_html(f.placeholder ?? "")}" spellcheck="false"/>`;
214
+ //-- a field with a browse callback gets a picker button
215
+ inner += `<label class="hb-eb-dialog-field"><span>${esc_html(f.label)}</span>${
216
+ f.browse
217
+ ? `<span class="hb-eb-dialog-browse-wrap">${input}<button type="button" class="hb-eb-btn hb-eb-btn-sm" data-browse="${esc_html(f.name)}" title="Choose an existing page"><i class="bi bi-folder2-open"></i></button></span>`
218
+ : input
219
+ }</label>`;
220
+ }
221
+ if (f.hint) {
222
+ inner += `<div class="hb-eb-dialog-hint">${esc_html(f.hint)}</div>`;
223
+ }
224
+ }
225
+ inner += `<div class="hb-eb-dialog-foot">
226
+ <button type="button" class="hb-eb-btn ${danger ? "hb-eb-btn-danger" : "hb-eb-btn-primary"}" data-act="ok">${esc_html(okText)}</button>
227
+ <button type="button" class="hb-eb-btn" data-act="cancel">Cancel</button>
228
+ </div>`;
229
+ box.innerHTML = inner;
230
+ overlay.appendChild(box);
231
+ document.body.appendChild(overlay);
232
+
233
+ const done = (values) => {
234
+ overlay.remove();
235
+ resolve(values);
236
+ };
237
+ const collect = () => {
238
+ const out = {};
239
+ for (const f of fields) {
240
+ if (f.type === "heading") continue;
241
+ const el = box.querySelector(`[name="${f.name}"]`);
242
+ out[f.name] = f.type === "checkbox" ? el.checked : el.value;
243
+ }
244
+ return out;
245
+ };
246
+ box.querySelector('[data-act="ok"]').addEventListener("click", () =>
247
+ done(collect()),
248
+ );
249
+ for (const bb of box.querySelectorAll("[data-browse]")) {
250
+ const f = fields.find((x) => x.name === bb.dataset.browse);
251
+ bb.addEventListener("click", () =>
252
+ f.browse(box.querySelector(`[name="${f.name}"]`), box),
253
+ );
254
+ }
255
+ box
256
+ .querySelector('[data-act="cancel"]')
257
+ .addEventListener("click", () => done(null));
258
+ overlay.addEventListener("mousedown", (e) => {
259
+ if (e.target === overlay) done(null);
260
+ });
261
+ box.addEventListener("keydown", (e) => {
262
+ if (e.key === "Escape") done(null);
263
+ else if (e.key === "Enter" && e.target.tagName === "INPUT") {
264
+ e.preventDefault();
265
+ done(collect());
266
+ }
267
+ });
268
+ const first = box.querySelector('input[type="text"]');
269
+ if (first) {
270
+ first.focus();
271
+ first.select();
272
+ } else {
273
+ box.querySelector('[data-act="ok"]').focus();
274
+ }
275
+ });
276
+
277
+ //-- --- TOC (Contents) model helpers ------------------------------------
278
+
279
+ //-- id -> { node, parentId, index, siblings } for the current DTO
280
+ const toc_index = () => {
281
+ const map = new Map();
282
+ const walk = (nodes, parentId) => {
283
+ nodes.forEach((n, i) => {
284
+ map.set(n.id, { node: n, parentId, index: i, siblings: nodes });
285
+ if (n.type === "branch") walk(n.children, n.id);
286
+ });
287
+ };
288
+ if (toc_dto) walk(toc_dto.tree, null);
289
+ return map;
290
+ };
291
+
292
+ const load_toc = async () => {
293
+ const r = await api("GET", "/_edit/toc");
294
+ if (!r.ok || !r.json) {
295
+ toast((r.json && r.json.error) || "Failed to load contents", true);
296
+ return;
297
+ }
298
+ toc_dto = r.json;
299
+ render();
300
+ };
301
+
302
+ const load_files = async () => {
303
+ const r = await api("GET", "/_edit/files");
304
+ if (!r.ok || !r.json) {
305
+ toast((r.json && r.json.error) || "Failed to load files", true);
306
+ return;
307
+ }
308
+ files_dto = r.json;
309
+ render();
310
+ };
311
+
312
+ //-- run a TOC mutation; the server answers with the fresh DTO
313
+ const toc_call = async (url, body, method) => {
314
+ const r = await api(method || "POST", url, body);
315
+ if (!r.ok || !r.json) {
316
+ toast((r.json && r.json.error) || "Operation failed", true);
317
+ return false;
318
+ }
319
+ //-- regular ops answer with the DTO itself; DELETE wraps it as
320
+ //-- { tree: <dto>, fileDeleted } — the DTO always carries docId
321
+ toc_dto = r.json.docId ? r.json : r.json.tree;
322
+ render();
323
+ refresh_vue_nav();
324
+ //-- files linked-flags may have changed
325
+ if (files_dto) load_files();
326
+ return r.json;
327
+ };
328
+
329
+ //-- --- validation --------------------------------------------------------
330
+
331
+ //-- results overlay: sections of [{label, cls, items}]
332
+ const show_results = ({ title, sections, raw }) => {
333
+ const overlay = document.createElement("div");
334
+ overlay.className = "hb-eb-overlay";
335
+ const box = document.createElement("div");
336
+ box.className = "hb-eb-dialog hb-eb-results";
337
+ let inner = `<div class="hb-eb-dialog-title">${esc_html(title)}</div><div class="hb-eb-results-body">`;
338
+ let any = false;
339
+ for (const s of sections || []) {
340
+ if (!s.items || !s.items.length) continue;
341
+ any = true;
342
+ inner += `<div class="hb-eb-dialog-sect">${esc_html(s.label)} (${s.items.length})</div><ul class="hb-eb-results-list ${s.cls || ""}">`;
343
+ for (const it of s.items) inner += `<li>${esc_html(it)}</li>`;
344
+ inner += "</ul>";
345
+ }
346
+ if (raw) {
347
+ any = true;
348
+ inner += `<pre class="hb-eb-results-raw">${esc_html(raw)}</pre>`;
349
+ }
350
+ if (!any) {
351
+ inner += '<div class="hb-eb-results-ok"><i class="bi bi-check-circle"></i> No problems found</div>';
352
+ }
353
+ inner += `</div><div class="hb-eb-dialog-foot"><button type="button" class="hb-eb-btn" data-act="close">Close</button></div>`;
354
+ box.innerHTML = inner;
355
+ overlay.appendChild(box);
356
+ document.body.appendChild(overlay);
357
+ const done = () => overlay.remove();
358
+ box.querySelector('[data-act="close"]').addEventListener("click", done);
359
+ overlay.addEventListener("mousedown", (e) => {
360
+ if (e.target === overlay) done();
361
+ });
362
+ box.setAttribute("tabindex", "-1");
363
+ box.addEventListener("keydown", (e) => {
364
+ if (e.key === "Escape") done();
365
+ });
366
+ box.focus();
367
+ };
368
+
369
+ const op_validate_nav = async () => {
370
+ toast("Validating navigation…");
371
+ const r = await api("POST", "/_edit/validate/nav", {});
372
+ if (!r.ok || !r.json) {
373
+ toast((r.json && r.json.error) || "Validation failed", true);
374
+ return;
375
+ }
376
+ nav_problems.clear();
377
+ for (const n of r.json.nodes || []) nav_problems.set(n.id, n.problems);
378
+ render();
379
+ if (!nav_problems.size) {
380
+ toast("Navigation OK — no problems found");
381
+ } else {
382
+ const count = [...nav_problems.values()].reduce(
383
+ (a, p) => a + p.length,
384
+ 0,
385
+ );
386
+ toast(
387
+ `${count} problem${count > 1 ? "s" : ""} in ${nav_problems.size} nav item${nav_problems.size > 1 ? "s" : ""} — see the markers in the tree`,
388
+ true,
389
+ );
390
+ }
391
+ };
392
+
393
+ const op_validate_page = async (link, label) => {
394
+ const clean = String(link).replace(/^\/+/, "");
395
+ toast(`Validating ${label || clean}…`);
396
+ const r = await api("POST", "/_edit/validate/page", {
397
+ path: clean,
398
+ external: true,
399
+ });
400
+ if (!r.ok || !r.json) {
401
+ toast((r.json && r.json.error) || "Validation failed", true);
402
+ return;
403
+ }
404
+ show_results({
405
+ title: `Validation — ${r.json.file || clean}`,
406
+ sections: [
407
+ { label: "Errors", cls: "hb-eb-res-err", items: r.json.errors },
408
+ { label: "Warnings", cls: "hb-eb-res-warn", items: r.json.warnings },
409
+ { label: "Not checked", cls: "hb-eb-res-skip", items: r.json.skipped },
410
+ ],
411
+ });
412
+ };
413
+
414
+ const op_validate_book = async () => {
415
+ const v = await dialog({
416
+ title: "Validate book",
417
+ message:
418
+ "Runs the full `hdoc validate` build pipeline over the whole book. This can take a while on a large book.",
419
+ fields: [
420
+ {
421
+ name: "externalLinks",
422
+ label: "Check external links (slower; skips the validated-links.txt cache misses only)",
423
+ type: "checkbox",
424
+ value: true,
425
+ },
426
+ ],
427
+ okText: "Run",
428
+ });
429
+ if (v === null) return;
430
+ //-- progress overlay (spinner + elapsed clock) while the child runs
431
+ const overlay = document.createElement("div");
432
+ overlay.className = "hb-eb-overlay";
433
+ overlay.innerHTML = `<div class="hb-eb-dialog hb-eb-busy">
434
+ <div class="hb-eb-spinner" aria-hidden="true"></div>
435
+ <div>
436
+ <div class="hb-eb-dialog-title">Validating book…</div>
437
+ <div class="hb-eb-dialog-msg">Running <code>hdoc validate</code> — this may take a few minutes.</div>
438
+ <div class="hb-eb-busy-elapsed" role="status">0:00</div>
439
+ </div>
440
+ </div>`;
441
+ document.body.appendChild(overlay);
442
+ const started = Date.now();
443
+ const elapsed_el = overlay.querySelector(".hb-eb-busy-elapsed");
444
+ const tick = setInterval(() => {
445
+ const s = Math.floor((Date.now() - started) / 1000);
446
+ elapsed_el.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
447
+ }, 1000);
448
+ let r;
449
+ try {
450
+ r = await api("POST", "/_edit/validate/book", {
451
+ externalLinks: !!v.externalLinks,
452
+ });
453
+ } finally {
454
+ clearInterval(tick);
455
+ overlay.remove();
456
+ }
457
+ if (!r.ok || !r.json) {
458
+ toast((r.json && r.json.error) || "Validation run failed", true);
459
+ return;
460
+ }
461
+ show_results({
462
+ title: `Book validation — ${r.json.code === 0 ? "PASSED" : "FAILED"} (exit ${r.json.code})`,
463
+ raw: (r.json.output || "").trim(),
464
+ });
465
+ };
466
+
467
+ //-- --- TOC operations ---------------------------------------------------
468
+
469
+ const op_rename = async (node) => {
470
+ const v = await dialog({
471
+ title: "Rename",
472
+ fields: [{ name: "text", label: "Label", value: node.text }],
473
+ okText: "Rename",
474
+ });
475
+ if (v === null) return;
476
+ toc_call("/_edit/toc/rename", { id: node.id, text: v.text });
477
+ };
478
+
479
+ //-- --- frontmatter (page header) ---------------------------------------
480
+ //-- The properties dialog edits the recognized frontmatter keys the
481
+ //-- viewer/build consume (title, description, layout). Parsing is
482
+ //-- line-based and only ever touches the managed keys, so any other
483
+ //-- frontmatter (status, custom keys) survives byte-for-byte.
484
+
485
+ const FM_KEYS = ["title", "description", "layout"];
486
+ const FM_LAYOUTS = [
487
+ { value: "", label: "(default — article)" },
488
+ { value: "article", label: "article" },
489
+ { value: "article-toc", label: "article-toc" },
490
+ { value: "article-no-toc", label: "article-no-toc" },
491
+ { value: "article-wide", label: "article-wide" },
492
+ ];
493
+
494
+ //-- strip YAML quoting from a scalar for display
495
+ const fm_unquote = (s) => {
496
+ const t = String(s).trim();
497
+ if (t.length > 1 && t[0] === "'" && t.endsWith("'")) {
498
+ return t.slice(1, -1).replace(/''/g, "'");
499
+ }
500
+ if (t.length > 1 && t[0] === '"' && t.endsWith('"')) {
501
+ return t.slice(1, -1).replace(/\\"/g, '"');
502
+ }
503
+ return t;
504
+ };
505
+
506
+ //-- quote a value for YAML when it needs it (front-matter parses real YAML)
507
+ const fm_quote = (s) => {
508
+ const t = String(s).trim();
509
+ if (!t) return "''";
510
+ if (/[:#'"[\]{}|>&*!%@`]|^\s|\s$|^-/.test(t)) {
511
+ return `'${t.replace(/'/g, "''")}'`;
512
+ }
513
+ return t;
514
+ };
515
+
516
+ //-- { values: {key: display value}, present: bool } for the managed keys
517
+ const fm_read = (content) => {
518
+ const values = {};
519
+ for (const k of FM_KEYS) values[k] = "";
520
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
521
+ if (!m) return { values, present: false };
522
+ for (const line of m[1].split(/\r?\n/)) {
523
+ const lm = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
524
+ if (lm && FM_KEYS.includes(lm[1])) values[lm[1]] = fm_unquote(lm[2]);
525
+ }
526
+ return { values, present: true };
527
+ };
528
+
529
+ //-- apply {key: value} to the content's frontmatter block: replace or
530
+ //-- append managed lines, drop emptied ones, keep everything else as-is;
531
+ //-- create the block when absent and something is set
532
+ const fm_patch = (content, patch) => {
533
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
534
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
535
+ if (!m) {
536
+ const lines = FM_KEYS.filter((k) => patch[k]).map(
537
+ (k) => `${k}: ${fm_quote(patch[k])}`,
538
+ );
539
+ if (!lines.length) return content;
540
+ return `---${eol}${lines.join(eol)}${eol}---${eol}${content}`;
541
+ }
542
+ const body = m[1].split(/\r?\n/);
543
+ const done = new Set();
544
+ const out = [];
545
+ for (const line of body) {
546
+ const lm = line.match(/^([A-Za-z][\w-]*)\s*:/);
547
+ const key = lm && FM_KEYS.includes(lm[1]) ? lm[1] : null;
548
+ if (!key) {
549
+ out.push(line);
550
+ continue;
551
+ }
552
+ done.add(key);
553
+ if (patch[key]) out.push(`${key}: ${fm_quote(patch[key])}`);
554
+ //-- empty value → key removed
555
+ }
556
+ for (const k of FM_KEYS) {
557
+ if (!done.has(k) && patch[k]) out.push(`${k}: ${fm_quote(patch[k])}`);
558
+ }
559
+ const block = out.length ? `---${eol}${out.join(eol)}${eol}---` : "";
560
+ const rest = content.slice(m[0].length);
561
+ if (!block) return rest;
562
+ return `${block}${eol}${rest}`;
563
+ };
564
+
565
+ const op_props = async (node) => {
566
+ const is_branch = node.type === "branch";
567
+ const fields = [{ name: "text", label: "Label", value: node.text }];
568
+ if (is_branch) {
569
+ fields.push({
570
+ name: "expand",
571
+ label: "Expanded by default",
572
+ type: "checkbox",
573
+ value: node.expand === true,
574
+ });
575
+ } else {
576
+ fields.push({
577
+ name: "link",
578
+ label: "Link (page path, no extension)",
579
+ value: node.link || "",
580
+ browse: browse_page,
581
+ });
582
+ }
583
+ fields.push({
584
+ name: "newId",
585
+ label: "Node id",
586
+ value: node.id,
587
+ hint: "Letters, numbers, dot, underscore or hyphen. Must be unique.",
588
+ });
589
+
590
+ //-- leaf with a real file: load its frontmatter into the dialog
591
+ let fm = null;
592
+ let fm_etag = "";
593
+ let fm_content = "";
594
+ if (!is_branch && node.link && node.fileExists) {
595
+ const clean = node.link.replace(/^\/+/, "");
596
+ const r = await api(
597
+ "GET",
598
+ `/_edit/source?path=${encodeURIComponent(clean)}`,
599
+ );
600
+ if (r.ok && r.json && typeof r.json.content === "string") {
601
+ fm_content = r.json.content;
602
+ fm_etag = r.json.etag;
603
+ fm = fm_read(fm_content);
604
+ fields.push(
605
+ { type: "heading", label: "Frontmatter" },
606
+ {
607
+ name: "fm_layout",
608
+ label: "Layout",
609
+ type: "select",
610
+ value: fm.values.layout,
611
+ options: FM_LAYOUTS,
612
+ },
613
+ {
614
+ name: "fm_title",
615
+ label: "Page title",
616
+ value: fm.values.title,
617
+ },
618
+ {
619
+ name: "fm_description",
620
+ label: "Description (search/SEO summary)",
621
+ value: fm.values.description,
622
+ },
623
+ );
624
+ }
625
+ }
626
+
627
+ const v = await dialog({ title: "Page properties", fields, okText: "Save" });
628
+ if (v === null) return;
629
+
630
+ //-- nav-side changes
631
+ const body = { id: node.id, text: v.text };
632
+ if (is_branch) body.expand = !!v.expand;
633
+ else body.link = v.link;
634
+ if (v.newId && v.newId !== node.id) body.newId = v.newId;
635
+ await toc_call("/_edit/toc/update", body);
636
+
637
+ //-- frontmatter changes → rewrite the page file (etag-guarded)
638
+ if (fm) {
639
+ const patch = {
640
+ layout: v.fm_layout,
641
+ title: v.fm_title,
642
+ description: v.fm_description,
643
+ };
644
+ const changed =
645
+ patch.layout !== fm.values.layout ||
646
+ patch.title !== fm.values.title ||
647
+ patch.description !== fm.values.description;
648
+ if (changed) {
649
+ const clean = node.link.replace(/^\/+/, "");
650
+ const next = fm_patch(fm_content, patch);
651
+ const r = await api(
652
+ "PUT",
653
+ `/_edit/source?path=${encodeURIComponent(clean)}`,
654
+ { content: next, baseEtag: fm_etag },
655
+ );
656
+ if (r.status === 409) {
657
+ toast(
658
+ "Frontmatter not saved: the file changed while the dialog was open (unsaved editor changes?)",
659
+ true,
660
+ );
661
+ return;
662
+ }
663
+ if (!r.ok) {
664
+ toast((r.json && r.json.error) || "Frontmatter save failed", true);
665
+ return;
666
+ }
667
+ toast("Frontmatter saved");
668
+ //-- re-render if it's the page on screen (layout applies live);
669
+ //-- an open editor still holds the old buffer — its etag check
670
+ //-- protects against overwriting this change blindly
671
+ if (clean === logical_path()) nav_to(clean).then(render);
672
+ }
673
+ }
674
+ };
675
+
676
+ //-- searchable picker over the book's page files (from /_edit/files);
677
+ //-- resolves to the page's LINK form (no .md, no /index) or null
678
+ const pick_page = async () => {
679
+ const r = await api("GET", "/_edit/files");
680
+ if (!r.ok || !r.json) {
681
+ toast((r.json && r.json.error) || "Failed to list pages", true);
682
+ return null;
683
+ }
684
+ const pages = r.json.files.filter((f) => f.kind === "page");
685
+ return new Promise((resolve) => {
686
+ const overlay = document.createElement("div");
687
+ overlay.className = "hb-eb-overlay hb-eb-picker-overlay";
688
+ const box = document.createElement("div");
689
+ box.className = "hb-eb-dialog hb-eb-picker";
690
+ box.innerHTML = `
691
+ <div class="hb-eb-dialog-title">Choose a page</div>
692
+ <label class="hb-eb-dialog-field"><input type="text" name="filter" placeholder="Filter…" spellcheck="false" autocomplete="off"/></label>
693
+ <label class="hb-eb-dialog-check"><input type="checkbox" name="unlinkedOnly"/> Show only unlinked pages</label>
694
+ <div class="hb-eb-picker-list"></div>
695
+ <div class="hb-eb-dialog-foot"><button type="button" class="hb-eb-btn" data-act="cancel">Cancel</button></div>`;
696
+ overlay.appendChild(box);
697
+ document.body.appendChild(overlay);
698
+
699
+ const done = (v) => {
700
+ overlay.remove();
701
+ resolve(v);
702
+ };
703
+ const list = box.querySelector(".hb-eb-picker-list");
704
+ const filter_el = box.querySelector('[name="filter"]');
705
+ const unlinked_el = box.querySelector('[name="unlinkedOnly"]');
706
+ const render_list = () => {
707
+ const q = filter_el.value.trim().toLowerCase();
708
+ list.innerHTML = "";
709
+ for (const p of pages) {
710
+ if (unlinked_el.checked && p.linked) continue;
711
+ if (q && !p.file.toLowerCase().includes(q)) continue;
712
+ const row = document.createElement("div");
713
+ row.className = "hb-eb-row";
714
+ row.innerHTML = `<span class="hb-eb-icon"><i class="bi bi-file-earmark-text"></i></span>`;
715
+ const label = document.createElement("span");
716
+ label.className = "hb-eb-label";
717
+ label.textContent = p.file;
718
+ row.appendChild(label);
719
+ if (!p.linked) {
720
+ const b = document.createElement("span");
721
+ b.className = "hb-eb-badge hb-eb-badge-orphan";
722
+ b.textContent = "unlinked";
723
+ row.appendChild(b);
724
+ }
725
+ row.addEventListener("click", () =>
726
+ done(file_logical(p.file)),
727
+ );
728
+ list.appendChild(row);
729
+ }
730
+ if (!list.children.length) {
731
+ list.innerHTML = '<div class="hb-eb-empty">No matching pages</div>';
732
+ }
733
+ };
734
+ filter_el.addEventListener("input", render_list);
735
+ unlinked_el.addEventListener("change", render_list);
736
+ box
737
+ .querySelector('[data-act="cancel"]')
738
+ .addEventListener("click", () => done(null));
739
+ overlay.addEventListener("mousedown", (e) => {
740
+ if (e.target === overlay) done(null);
741
+ });
742
+ box.addEventListener("keydown", (e) => {
743
+ if (e.key === "Escape") {
744
+ e.stopPropagation();
745
+ done(null);
746
+ }
747
+ });
748
+ render_list();
749
+ filter_el.focus();
750
+ });
751
+ };
752
+
753
+ //-- browse callback shared by the Add page / Properties link fields:
754
+ //-- picks an existing page and fills the input (and an empty Label
755
+ //-- field, title-cased from the file stem)
756
+ const browse_page = async (input, box) => {
757
+ const link = await pick_page();
758
+ if (!link) return;
759
+ input.value = link;
760
+ const text_el = box.querySelector('[name="text"]');
761
+ if (text_el && !text_el.value.trim()) {
762
+ text_el.value = (link.split("/").pop() || "")
763
+ .split("-")
764
+ .filter(Boolean)
765
+ .map((w) => w[0].toUpperCase() + w.slice(1))
766
+ .join(" ");
767
+ }
768
+ input.focus();
769
+ };
770
+
771
+ //-- where: { parentId, index } insertion point
772
+ const op_add_page = async (where) => {
773
+ const v = await dialog({
774
+ title: "Add page",
775
+ fields: [
776
+ { name: "text", label: "Label", value: "" },
777
+ {
778
+ name: "link",
779
+ label: "Page path (no extension)",
780
+ value: "",
781
+ placeholder: `${docid()}/section/my-page`,
782
+ hint: "Kebab-case path inside the book folder. Browse to link an existing page.",
783
+ browse: browse_page,
784
+ },
785
+ {
786
+ name: "createFile",
787
+ label: "Create the markdown file if it does not exist",
788
+ type: "checkbox",
789
+ value: true,
790
+ },
791
+ ],
792
+ okText: "Add",
793
+ });
794
+ if (v === null) return;
795
+ const link = String(v.link || "").replace(/^\/+/, "").replace(/\.md$/i, "");
796
+ if (!link) {
797
+ toast("A page path is required", true);
798
+ return;
799
+ }
800
+ const r = await toc_call("/_edit/toc/create", {
801
+ parentId: where.parentId,
802
+ index: where.index,
803
+ text: v.text,
804
+ link,
805
+ createFile: !!v.createFile,
806
+ });
807
+ //-- expand the parent so the new page is visible
808
+ if (r && where.parentId) {
809
+ expanded.add(where.parentId);
810
+ render();
811
+ }
812
+ };
813
+
814
+ const op_add_section = async (where) => {
815
+ const v = await dialog({
816
+ title: "Add section",
817
+ fields: [{ name: "text", label: "Label", value: "" }],
818
+ okText: "Add",
819
+ });
820
+ if (v === null) return;
821
+ const r = await toc_call("/_edit/toc/add-section", {
822
+ parentId: where.parentId,
823
+ index: where.index,
824
+ text: v.text,
825
+ });
826
+ //-- expand the parent so the new (empty) section is visible
827
+ if (r && where.parentId) {
828
+ expanded.add(where.parentId);
829
+ render();
830
+ }
831
+ };
832
+
833
+ const op_delete = async (node) => {
834
+ const fields = [];
835
+ if (node.type === "leaf" && node.fileExists) {
836
+ fields.push({
837
+ name: "deleteFile",
838
+ label: `Also delete the page file (${node.file})`,
839
+ type: "checkbox",
840
+ value: false,
841
+ });
842
+ }
843
+ const v = await dialog({
844
+ title: "Remove from contents",
845
+ message:
846
+ node.type === "branch"
847
+ ? `Remove section "${node.text}" and everything inside it from the navigation? Page files stay on disk (find them under Files).`
848
+ : `Remove "${node.text}" from the navigation only — its markdown file stays on disk${node.fileExists ? " unless ticked below" : ""}.`,
849
+ fields,
850
+ okText: "Remove",
851
+ danger: true,
852
+ });
853
+ if (v === null) return;
854
+ const r = await toc_call(
855
+ `/_edit/toc/${encodeURIComponent(node.id)}`,
856
+ { deleteFile: !!v.deleteFile },
857
+ "DELETE",
858
+ );
859
+ if (r && r.fileDeleted) toast(`Deleted ${node.file}`);
860
+ };
861
+
862
+ const op_move_step = (node, dir) => {
863
+ const idx = toc_index().get(node.id);
864
+ if (!idx) return;
865
+ const to = idx.index + dir;
866
+ if (to < 0 || to >= idx.siblings.length) return;
867
+ toc_call("/_edit/toc/move", { id: node.id, parentId: idx.parentId, index: to });
868
+ };
869
+
870
+ //-- --- Contents tree rendering -----------------------------------------
871
+
872
+ const node_row = (n, depth, idx_map) => {
873
+ const row = document.createElement("div");
874
+ row.className = "hb-eb-row";
875
+ row.dataset.id = n.id;
876
+ row.draggable = true;
877
+ row.style.paddingLeft = `${8 + depth * 14}px`;
878
+
879
+ const is_branch = n.type === "branch";
880
+ const open = expanded.has(n.id);
881
+ const caret = document.createElement("span");
882
+ caret.className = "hb-eb-caret";
883
+ caret.innerHTML = is_branch
884
+ ? `<i class="bi ${open ? "bi-chevron-down" : "bi-chevron-right"}"></i>`
885
+ : "";
886
+ row.appendChild(caret);
887
+
888
+ const icon = document.createElement("span");
889
+ icon.className = "hb-eb-icon";
890
+ icon.innerHTML = is_branch
891
+ ? '<i class="bi bi-folder2"></i>'
892
+ : '<i class="bi bi-file-earmark-text"></i>';
893
+ row.appendChild(icon);
894
+
895
+ const label = document.createElement("span");
896
+ label.className = "hb-eb-label";
897
+ label.innerHTML = `<span class="hb-eb-num">${esc_html(n.number)}</span> ${esc_html(n.text || "(untitled)")}`;
898
+ row.appendChild(label);
899
+
900
+ if (n.draft) {
901
+ const b = document.createElement("span");
902
+ b.className = "hb-eb-badge hb-eb-badge-draft";
903
+ b.textContent = "draft";
904
+ row.appendChild(b);
905
+ }
906
+ if (n.type === "leaf" && n.link && !n.fileExists) {
907
+ const b = document.createElement("span");
908
+ b.className = "hb-eb-badge hb-eb-badge-missing";
909
+ b.title = `File not found: ${n.file}`;
910
+ b.textContent = "missing";
911
+ row.appendChild(b);
912
+ }
913
+ //-- links may carry a leading slash in hdocbook.json — normalize
914
+ if (
915
+ n.type === "leaf" &&
916
+ n.link &&
917
+ n.link.replace(/^\/+/, "") === logical_path()
918
+ ) {
919
+ row.classList.add("hb-eb-current");
920
+ }
921
+
922
+ //-- validation marker from the last nav validate
923
+ const probs = nav_problems.get(n.id);
924
+ if (probs && probs.length) {
925
+ const b = document.createElement("span");
926
+ b.className = "hb-eb-badge hb-eb-badge-problem";
927
+ b.textContent = String(probs.length);
928
+ b.title = probs.join("\n");
929
+ b.addEventListener("click", (e) => {
930
+ e.stopPropagation();
931
+ show_results({
932
+ title: `Navigation problems — ${n.text || n.id}`,
933
+ sections: [
934
+ { label: "Problems", cls: "hb-eb-res-err", items: probs },
935
+ ],
936
+ });
937
+ });
938
+ row.appendChild(b);
939
+ }
940
+
941
+ const menu = document.createElement("button");
942
+ menu.type = "button";
943
+ menu.className = "hb-eb-menu";
944
+ menu.title = "Actions";
945
+ menu.innerHTML = '<i class="bi bi-three-dots"></i>';
946
+ row.appendChild(menu);
947
+
948
+ //-- interactions
949
+ const toggle = () => {
950
+ if (expanded.has(n.id)) expanded.delete(n.id);
951
+ else expanded.add(n.id);
952
+ render();
953
+ };
954
+ caret.addEventListener("click", (e) => {
955
+ e.stopPropagation();
956
+ if (is_branch) toggle();
957
+ });
958
+ row.addEventListener("click", () => {
959
+ if (is_branch) toggle();
960
+ else if (n.link) {
961
+ //-- reopen the editor even when the path didn't change (the
962
+ //-- nav-hook reopen only fires on an actual navigation)
963
+ nav_to(n.link).then(() => {
964
+ render();
965
+ open_editor_soon();
966
+ });
967
+ }
968
+ });
969
+ const open_menu = (x, y) => {
970
+ const loc = idx_map.get(n.id);
971
+ const items = [];
972
+ if (!is_branch && n.link) {
973
+ items.push({
974
+ label: "Open",
975
+ icon: "bi-box-arrow-in-right",
976
+ onClick: () => nav_to(n.link).then(render),
977
+ });
978
+ }
979
+ if (!is_branch && n.link && n.fileExists) {
980
+ items.push({
981
+ label: "Validate page…",
982
+ icon: "bi-check2-circle",
983
+ onClick: () => op_validate_page(n.link, n.text),
984
+ });
985
+ }
986
+ items.push(
987
+ { label: "Rename…", icon: "bi-input-cursor-text", onClick: () => op_rename(n) },
988
+ { label: "Properties…", icon: "bi-sliders", onClick: () => op_props(n) },
989
+ {
990
+ label: n.draft ? "Clear draft flag" : "Mark as draft",
991
+ icon: "bi-pencil-square",
992
+ onClick: () =>
993
+ toc_call("/_edit/toc/draft", { id: n.id, draft: !n.draft }),
994
+ },
995
+ { sep: true },
996
+ {
997
+ label: is_branch ? "Add page inside…" : "Add page after…",
998
+ icon: "bi-file-earmark-plus",
999
+ onClick: () =>
1000
+ op_add_page(
1001
+ is_branch
1002
+ ? { parentId: n.id, index: undefined }
1003
+ : { parentId: loc.parentId, index: loc.index + 1 },
1004
+ ),
1005
+ },
1006
+ {
1007
+ label: is_branch ? "Add section inside…" : "Add section after…",
1008
+ icon: "bi-folder-plus",
1009
+ onClick: () =>
1010
+ op_add_section(
1011
+ is_branch
1012
+ ? { parentId: n.id, index: undefined }
1013
+ : { parentId: loc.parentId, index: loc.index + 1 },
1014
+ ),
1015
+ },
1016
+ { sep: true },
1017
+ {
1018
+ label: "Move up",
1019
+ icon: "bi-arrow-up",
1020
+ onClick: () => op_move_step(n, -1),
1021
+ },
1022
+ {
1023
+ label: "Move down",
1024
+ icon: "bi-arrow-down",
1025
+ onClick: () => op_move_step(n, +1),
1026
+ },
1027
+ { sep: true },
1028
+ {
1029
+ label: "Remove from contents…",
1030
+ icon: "bi-trash",
1031
+ danger: true,
1032
+ onClick: () => op_delete(n),
1033
+ },
1034
+ );
1035
+ show_ctx(x, y, items);
1036
+ };
1037
+ menu.addEventListener("click", (e) => {
1038
+ e.stopPropagation();
1039
+ const r = menu.getBoundingClientRect();
1040
+ open_menu(r.left, r.bottom + 2);
1041
+ });
1042
+ row.addEventListener("contextmenu", (e) => {
1043
+ if (e.shiftKey) return;
1044
+ e.preventDefault();
1045
+ e.stopPropagation();
1046
+ open_menu(e.clientX, e.clientY);
1047
+ });
1048
+
1049
+ //-- drag & drop move
1050
+ row.addEventListener("dragstart", (e) => {
1051
+ drag_id = n.id;
1052
+ e.dataTransfer.effectAllowed = "move";
1053
+ try {
1054
+ e.dataTransfer.setData("text/plain", n.id);
1055
+ } catch {
1056
+ /* IE-era quirk */
1057
+ }
1058
+ row.classList.add("hb-eb-dragging");
1059
+ });
1060
+ row.addEventListener("dragend", () => {
1061
+ drag_id = null;
1062
+ row.classList.remove("hb-eb-dragging");
1063
+ clear_drop_marks();
1064
+ });
1065
+ row.addEventListener("dragover", (e) => {
1066
+ if (!drag_id || drag_id === n.id) return;
1067
+ e.preventDefault();
1068
+ e.dataTransfer.dropEffect = "move";
1069
+ row.classList.remove(
1070
+ "hb-eb-drop-before",
1071
+ "hb-eb-drop-after",
1072
+ "hb-eb-drop-into",
1073
+ );
1074
+ row.classList.add(`hb-eb-drop-${drop_zone(e, row, is_branch)}`);
1075
+ });
1076
+ row.addEventListener("dragleave", () => {
1077
+ row.classList.remove(
1078
+ "hb-eb-drop-before",
1079
+ "hb-eb-drop-after",
1080
+ "hb-eb-drop-into",
1081
+ );
1082
+ });
1083
+ row.addEventListener("drop", (e) => {
1084
+ e.preventDefault();
1085
+ e.stopPropagation();
1086
+ const zone = drop_zone(e, row, is_branch);
1087
+ clear_drop_marks();
1088
+ do_drop(n, zone, idx_map);
1089
+ });
1090
+
1091
+ return row;
1092
+ };
1093
+
1094
+ const drop_zone = (e, row, is_branch) => {
1095
+ const rect = row.getBoundingClientRect();
1096
+ const y = (e.clientY - rect.top) / rect.height;
1097
+ if (y < 0.3) return "before";
1098
+ if (y > 0.7 || !is_branch) return y > 0.5 ? "after" : is_branch ? "into" : "after";
1099
+ return "into";
1100
+ };
1101
+
1102
+ const clear_drop_marks = () => {
1103
+ if (!host) return;
1104
+ for (const el of host.querySelectorAll(
1105
+ ".hb-eb-drop-before,.hb-eb-drop-after,.hb-eb-drop-into",
1106
+ )) {
1107
+ el.classList.remove(
1108
+ "hb-eb-drop-before",
1109
+ "hb-eb-drop-after",
1110
+ "hb-eb-drop-into",
1111
+ );
1112
+ }
1113
+ };
1114
+
1115
+ const do_drop = (target, zone, idx_map) => {
1116
+ if (!drag_id || drag_id === target.id) return;
1117
+ const src = idx_map.get(drag_id);
1118
+ const tgt = idx_map.get(target.id);
1119
+ if (!src || !tgt) return;
1120
+ //-- guard: the drop target must not sit inside the dragged subtree
1121
+ //-- (server rejects "into own subtree" too — fail fast client-side)
1122
+ const subtree_contains = (n, id) =>
1123
+ n.id === id ||
1124
+ (n.type === "branch" && n.children.some((c) => subtree_contains(c, id)));
1125
+ if (subtree_contains(src.node, target.id)) return;
1126
+ let parentId;
1127
+ let index;
1128
+ if (zone === "into") {
1129
+ parentId = target.id;
1130
+ index = target.children.length;
1131
+ expanded.add(target.id);
1132
+ } else {
1133
+ parentId = tgt.parentId;
1134
+ index = tgt.index + (zone === "after" ? 1 : 0);
1135
+ //-- the server inserts AFTER removal: leaving the same parent from
1136
+ //-- an earlier position shifts everything down one
1137
+ if (src.parentId === tgt.parentId && src.index < index) index--;
1138
+ }
1139
+ toc_call("/_edit/toc/move", { id: drag_id, parentId, index });
1140
+ };
1141
+
1142
+ const render_contents = (tree_el) => {
1143
+ if (!toc_dto) {
1144
+ tree_el.innerHTML = '<div class="hb-eb-empty">Loading…</div>';
1145
+ return;
1146
+ }
1147
+ const idx_map = toc_index();
1148
+ const frag = document.createDocumentFragment();
1149
+ const walk = (nodes, depth) => {
1150
+ for (const n of nodes) {
1151
+ frag.appendChild(node_row(n, depth, idx_map));
1152
+ if (n.type === "branch" && expanded.has(n.id)) {
1153
+ walk(n.children, depth + 1);
1154
+ }
1155
+ }
1156
+ };
1157
+ walk(toc_dto.tree, 0);
1158
+ if (!toc_dto.tree.length) {
1159
+ const d = document.createElement("div");
1160
+ d.className = "hb-eb-empty";
1161
+ d.textContent = "Navigation is empty — add a page or section.";
1162
+ frag.appendChild(d);
1163
+ }
1164
+ tree_el.innerHTML = "";
1165
+ tree_el.appendChild(frag);
1166
+ };
1167
+
1168
+ //-- --- Files tree -------------------------------------------------------
1169
+
1170
+ //-- nested { name, rel, dirs: [], files: [] } from the flat DTO
1171
+ const files_tree = (file_list) => {
1172
+ const root = { name: files_dto.docId, rel: files_dto.docId, dirs: new Map(), files: [] };
1173
+ const dir_of = (rel) => {
1174
+ //-- rel is docId/a/b — walk/create intermediate dirs
1175
+ const segs = rel.split("/");
1176
+ let cur = root;
1177
+ for (let i = 1; i < segs.length; i++) {
1178
+ const key = segs.slice(0, i + 1).join("/");
1179
+ if (!cur.dirs.has(key)) {
1180
+ cur.dirs.set(key, { name: segs[i], rel: key, dirs: new Map(), files: [] });
1181
+ }
1182
+ cur = cur.dirs.get(key);
1183
+ }
1184
+ return cur;
1185
+ };
1186
+ for (const d of files_dto.dirs) dir_of(d);
1187
+ for (const f of file_list) {
1188
+ const dir = f.file.includes("/")
1189
+ ? f.file.slice(0, f.file.lastIndexOf("/"))
1190
+ : f.file;
1191
+ const parent = dir === files_dto.docId ? root : dir_of(dir);
1192
+ parent.files.push(f);
1193
+ }
1194
+ return root;
1195
+ };
1196
+
1197
+ const file_logical = (rel) =>
1198
+ rel.replace(/\.md$/i, "").replace(/\/index$/i, "");
1199
+
1200
+ const open_page_file = (rel) => {
1201
+ nav_to(file_logical(rel)).then(() => {
1202
+ render();
1203
+ open_editor_soon();
1204
+ });
1205
+ };
1206
+
1207
+ const op_new_file = async (dir_rel) => {
1208
+ const v = await dialog({
1209
+ title: "New page file",
1210
+ fields: [
1211
+ {
1212
+ name: "path",
1213
+ label: "File path",
1214
+ value: `${dir_rel}/`,
1215
+ hint: "Kebab-case, ends with .md — e.g. section/my-page.md",
1216
+ },
1217
+ ],
1218
+ okText: "Create",
1219
+ });
1220
+ if (v === null) return;
1221
+ let p = String(v.path || "").trim();
1222
+ if (p && !/\.md$/i.test(p)) p += ".md";
1223
+ const stem = p
1224
+ .replace(/\.md$/i, "")
1225
+ .split("/")
1226
+ .pop();
1227
+ const title = (stem || "New Page")
1228
+ .split("-")
1229
+ .filter(Boolean)
1230
+ .map((w) => w[0].toUpperCase() + w.slice(1))
1231
+ .join(" ");
1232
+ const r = await api("POST", "/_edit/files", {
1233
+ path: p,
1234
+ content: `# ${title}\n`,
1235
+ });
1236
+ if (!r.ok || !r.json) {
1237
+ toast((r.json && r.json.error) || "Create failed", true);
1238
+ return;
1239
+ }
1240
+ files_dto = { docId: r.json.docId, dirs: r.json.dirs, files: r.json.files };
1241
+ render();
1242
+ toast(`Created ${r.json.created}`);
1243
+ };
1244
+
1245
+ const op_new_folder = async (dir_rel) => {
1246
+ const v = await dialog({
1247
+ title: "New folder",
1248
+ fields: [
1249
+ { name: "path", label: "Folder path", value: `${dir_rel}/` },
1250
+ ],
1251
+ okText: "Create",
1252
+ });
1253
+ if (v === null) return;
1254
+ const r = await api("POST", "/_edit/files/folder", { path: v.path });
1255
+ if (!r.ok || !r.json) {
1256
+ toast((r.json && r.json.error) || "Create failed", true);
1257
+ return;
1258
+ }
1259
+ files_dto = { docId: r.json.docId, dirs: r.json.dirs, files: r.json.files };
1260
+ render();
1261
+ };
1262
+
1263
+ //-- --- image uploads ----------------------------------------------------
1264
+ //-- OS-drag images onto a folder row (or its "Upload images here…" menu
1265
+ //-- item) to add them to the book — mirrors the `hdoc edit` Files panel.
1266
+
1267
+ const IMG_RE = /\.(png|jpe?g|gif|svg|webp|bmp|ico|avif)$/i;
1268
+
1269
+ const pick_images = () =>
1270
+ new Promise((resolve) => {
1271
+ const inp = document.createElement("input");
1272
+ inp.type = "file";
1273
+ inp.multiple = true;
1274
+ inp.accept = "image/*";
1275
+ inp.onchange = () => resolve(inp.files);
1276
+ inp.click();
1277
+ });
1278
+
1279
+ const upload_images = async (folder_rel, files) => {
1280
+ const imgs = Array.from(files || []).filter(
1281
+ (f) => (f.type || "").startsWith("image/") || IMG_RE.test(f.name),
1282
+ );
1283
+ if (!imgs.length) {
1284
+ toast("Only image files can be uploaded", true);
1285
+ return;
1286
+ }
1287
+ try {
1288
+ for (const f of imgs) {
1289
+ const r = await fetch(
1290
+ `/_edit/upload?path=${encodeURIComponent(`${folder_rel}/${f.name}`)}`,
1291
+ {
1292
+ method: "PUT",
1293
+ headers: {
1294
+ "content-type": f.type || "application/octet-stream",
1295
+ },
1296
+ body: f,
1297
+ },
1298
+ );
1299
+ const json = await r.json().catch(() => null);
1300
+ if (!r.ok || !json || !json.ok) {
1301
+ throw new Error((json && json.error) || "Upload failed");
1302
+ }
1303
+ }
1304
+ toast(`Uploaded ${imgs.length} image${imgs.length > 1 ? "s" : ""} to ${folder_rel}/`);
1305
+ load_files();
1306
+ } catch (e) {
1307
+ toast(String((e && e.message) || e), true);
1308
+ }
1309
+ };
1310
+
1311
+ const op_delete_file = async (f) => {
1312
+ const v = await dialog({
1313
+ title: "Delete file",
1314
+ message: `Permanently delete ${f.file}?${f.linked ? " It is linked from the contents tree." : ""}`,
1315
+ okText: "Delete",
1316
+ danger: true,
1317
+ });
1318
+ if (v === null) return;
1319
+ const r = await api("DELETE", "/_edit/files", { path: f.file });
1320
+ if (!r.ok || !r.json) {
1321
+ toast((r.json && r.json.error) || "Delete failed", true);
1322
+ return;
1323
+ }
1324
+ files_dto = { docId: r.json.docId, dirs: r.json.dirs, files: r.json.files };
1325
+ render();
1326
+ //-- a linked page's leaf now points at a missing file
1327
+ if (f.linked) load_toc();
1328
+ toast(`Deleted ${f.file}`);
1329
+ };
1330
+
1331
+ //-- in-app image preview (the `hdoc edit` ImagePreview modal): the image,
1332
+ //-- its reference, and "Insert into open page"
1333
+ const show_image_preview = (f) => {
1334
+ const overlay = document.createElement("div");
1335
+ overlay.className = "hb-eb-overlay";
1336
+ const box = document.createElement("div");
1337
+ box.className = "hb-eb-dialog hb-eb-imgprev";
1338
+ const alt = (f.file.split("/").pop() || "").replace(/\.[^.]+$/, "");
1339
+ const reference = `![${alt}](/_books/${f.file})`;
1340
+ const can_insert =
1341
+ !!window.HdocEditInline && window.HdocEditInline.isOpen();
1342
+ box.innerHTML = `
1343
+ <div class="hb-eb-dialog-title">${esc_html(f.file.split("/").pop())}</div>
1344
+ <div class="hb-eb-imgprev-frame"><img src="/_books/${esc_html(f.file)}" alt="${esc_html(alt)}"/></div>
1345
+ <div class="hb-eb-imgprev-ref"><code>${esc_html(reference)}</code></div>
1346
+ <div class="hb-eb-dialog-foot">
1347
+ <button type="button" class="hb-eb-btn hb-eb-btn-primary" data-act="insert" ${can_insert ? "" : "disabled"} title="${can_insert ? "Insert at the cursor in the open page" : "Open a page in the editor first"}">Insert into open page</button>
1348
+ <button type="button" class="hb-eb-btn" data-act="copy">Copy reference</button>
1349
+ <button type="button" class="hb-eb-btn" data-act="close">Close</button>
1350
+ </div>`;
1351
+ overlay.appendChild(box);
1352
+ document.body.appendChild(overlay);
1353
+ const done = () => overlay.remove();
1354
+ box.querySelector('[data-act="insert"]').addEventListener("click", () => {
1355
+ if (window.HdocEditInline && window.HdocEditInline.insert(reference)) {
1356
+ done();
1357
+ } else {
1358
+ toast("Open a page in the editor first", true);
1359
+ }
1360
+ });
1361
+ box.querySelector('[data-act="copy"]').addEventListener("click", () => {
1362
+ try {
1363
+ navigator.clipboard.writeText(reference);
1364
+ toast("Reference copied");
1365
+ } catch {
1366
+ toast("Clipboard unavailable", true);
1367
+ }
1368
+ });
1369
+ box.querySelector('[data-act="close"]').addEventListener("click", done);
1370
+ overlay.addEventListener("mousedown", (e) => {
1371
+ if (e.target === overlay) done();
1372
+ });
1373
+ box.setAttribute("tabindex", "-1");
1374
+ box.addEventListener("keydown", (e) => {
1375
+ if (e.key === "Escape") done();
1376
+ });
1377
+ box.focus();
1378
+ };
1379
+
1380
+ const file_row = (f, depth) => {
1381
+ const row = document.createElement("div");
1382
+ row.className = "hb-eb-row";
1383
+ row.style.paddingLeft = `${8 + depth * 14}px`;
1384
+ const icons = { page: "bi-file-earmark-text", image: "bi-image", other: "bi-file-earmark" };
1385
+ row.innerHTML = `<span class="hb-eb-caret"></span><span class="hb-eb-icon"><i class="bi ${icons[f.kind]}"></i></span>`;
1386
+ const label = document.createElement("span");
1387
+ label.className = "hb-eb-label";
1388
+ label.textContent = f.file.split("/").pop();
1389
+ row.appendChild(label);
1390
+ if (f.kind === "page" && !f.linked) {
1391
+ const b = document.createElement("span");
1392
+ b.className = "hb-eb-badge hb-eb-badge-orphan";
1393
+ b.title = "Not linked from the contents tree";
1394
+ b.textContent = "unlinked";
1395
+ row.appendChild(b);
1396
+ }
1397
+ const menu = document.createElement("button");
1398
+ menu.type = "button";
1399
+ menu.className = "hb-eb-menu";
1400
+ menu.title = "Actions";
1401
+ menu.innerHTML = '<i class="bi bi-three-dots"></i>';
1402
+ row.appendChild(menu);
1403
+
1404
+ //-- images drag into the open editor to insert a reference — same
1405
+ //-- custom dataTransfer type `hdoc edit` uses for its Files tree
1406
+ if (f.kind === "image") {
1407
+ row.draggable = true;
1408
+ row.addEventListener("dragstart", (e) => {
1409
+ e.dataTransfer.setData("application/x-hdoc-image", f.file);
1410
+ e.dataTransfer.effectAllowed = "copy";
1411
+ });
1412
+ }
1413
+
1414
+ row.addEventListener("click", () => {
1415
+ //-- non-page assets are only served under /_books/*
1416
+ if (f.kind === "page") open_page_file(f.file);
1417
+ else if (f.kind === "image") show_image_preview(f);
1418
+ else window.open(`/_books/${f.file}`, "_blank");
1419
+ });
1420
+ const open_menu = (x, y) => {
1421
+ const items = [];
1422
+ if (f.kind === "page") {
1423
+ items.push(
1424
+ {
1425
+ label: "Open in editor",
1426
+ icon: "bi-pencil",
1427
+ onClick: () => open_page_file(f.file),
1428
+ },
1429
+ {
1430
+ label: "Validate page…",
1431
+ icon: "bi-check2-circle",
1432
+ onClick: () =>
1433
+ op_validate_page(file_logical(f.file), f.file),
1434
+ },
1435
+ );
1436
+ } else {
1437
+ if (f.kind === "image") {
1438
+ items.push({
1439
+ label: "Preview",
1440
+ icon: "bi-eye",
1441
+ onClick: () => show_image_preview(f),
1442
+ });
1443
+ }
1444
+ items.push({
1445
+ label: "Open in new tab",
1446
+ icon: "bi-box-arrow-up-right",
1447
+ onClick: () => window.open(`/_books/${f.file}`, "_blank"),
1448
+ });
1449
+ }
1450
+ if (
1451
+ f.kind === "image" &&
1452
+ window.HdocEditInline &&
1453
+ window.HdocEditInline.isOpen()
1454
+ ) {
1455
+ items.push({
1456
+ label: "Insert into open page",
1457
+ icon: "bi-file-earmark-image",
1458
+ onClick: () => {
1459
+ const alt = (f.file.split("/").pop() || "").replace(
1460
+ /\.[^.]+$/,
1461
+ "",
1462
+ );
1463
+ if (
1464
+ !window.HdocEditInline.insert(
1465
+ `![${alt}](/_books/${f.file})`,
1466
+ )
1467
+ ) {
1468
+ toast("Open a page in the editor first", true);
1469
+ }
1470
+ },
1471
+ });
1472
+ }
1473
+ items.push(
1474
+ { sep: true },
1475
+ {
1476
+ label: "Delete…",
1477
+ icon: "bi-trash",
1478
+ danger: true,
1479
+ onClick: () => op_delete_file(f),
1480
+ },
1481
+ );
1482
+ show_ctx(x, y, items);
1483
+ };
1484
+ menu.addEventListener("click", (e) => {
1485
+ e.stopPropagation();
1486
+ const r = menu.getBoundingClientRect();
1487
+ open_menu(r.left, r.bottom + 2);
1488
+ });
1489
+ row.addEventListener("contextmenu", (e) => {
1490
+ if (e.shiftKey) return;
1491
+ e.preventDefault();
1492
+ e.stopPropagation();
1493
+ open_menu(e.clientX, e.clientY);
1494
+ });
1495
+ return row;
1496
+ };
1497
+
1498
+ const folder_row = (dir, depth, forced_open) => {
1499
+ const row = document.createElement("div");
1500
+ row.className = "hb-eb-row";
1501
+ row.style.paddingLeft = `${8 + depth * 14}px`;
1502
+ const open = forced_open || fexpanded.has(dir.rel);
1503
+ row.innerHTML = `<span class="hb-eb-caret"><i class="bi ${open ? "bi-chevron-down" : "bi-chevron-right"}"></i></span><span class="hb-eb-icon"><i class="bi bi-folder2"></i></span>`;
1504
+ const label = document.createElement("span");
1505
+ label.className = "hb-eb-label";
1506
+ label.textContent = dir.name;
1507
+ row.appendChild(label);
1508
+ const menu = document.createElement("button");
1509
+ menu.type = "button";
1510
+ menu.className = "hb-eb-menu";
1511
+ menu.title = "Actions";
1512
+ menu.innerHTML = '<i class="bi bi-three-dots"></i>';
1513
+ row.appendChild(menu);
1514
+
1515
+ row.addEventListener("click", () => {
1516
+ if (fexpanded.has(dir.rel)) fexpanded.delete(dir.rel);
1517
+ else fexpanded.add(dir.rel);
1518
+ render();
1519
+ });
1520
+ menu.addEventListener("click", (e) => {
1521
+ e.stopPropagation();
1522
+ const r = menu.getBoundingClientRect();
1523
+ show_ctx(r.left, r.bottom + 2, [
1524
+ {
1525
+ label: "New page here…",
1526
+ icon: "bi-file-earmark-plus",
1527
+ onClick: () => op_new_file(dir.rel),
1528
+ },
1529
+ {
1530
+ label: "New folder here…",
1531
+ icon: "bi-folder-plus",
1532
+ onClick: () => op_new_folder(dir.rel),
1533
+ },
1534
+ {
1535
+ label: "Upload images here…",
1536
+ icon: "bi-upload",
1537
+ onClick: async () =>
1538
+ upload_images(dir.rel, await pick_images()),
1539
+ },
1540
+ ]);
1541
+ });
1542
+
1543
+ //-- OS-file drop target: drag images from Explorer/Finder onto a
1544
+ //-- folder to upload them there
1545
+ row.addEventListener("dragover", (e) => {
1546
+ const t = e.dataTransfer && e.dataTransfer.types;
1547
+ if (t && t.includes("Files")) {
1548
+ e.preventDefault();
1549
+ e.dataTransfer.dropEffect = "copy";
1550
+ row.classList.add("hb-eb-drop-into");
1551
+ }
1552
+ });
1553
+ row.addEventListener("dragleave", () => {
1554
+ row.classList.remove("hb-eb-drop-into");
1555
+ });
1556
+ row.addEventListener("drop", (e) => {
1557
+ row.classList.remove("hb-eb-drop-into");
1558
+ if (e.dataTransfer && e.dataTransfer.files.length) {
1559
+ e.preventDefault();
1560
+ e.stopPropagation();
1561
+ upload_images(dir.rel, e.dataTransfer.files);
1562
+ }
1563
+ });
1564
+ return row;
1565
+ };
1566
+
1567
+ const render_files = (tree_el) => {
1568
+ if (!files_dto) {
1569
+ tree_el.innerHTML = '<div class="hb-eb-empty">Loading…</div>';
1570
+ load_files();
1571
+ return;
1572
+ }
1573
+ const filtered = files_unlinked_only
1574
+ ? files_dto.files.filter((f) => f.kind === "page" && !f.linked)
1575
+ : files_dto.files;
1576
+ const root = files_tree(filtered);
1577
+ //-- when filtering, hide folders with no matches anywhere below
1578
+ const has_content = (dir) => {
1579
+ if (dir.files.length) return true;
1580
+ for (const [, sub] of dir.dirs) if (has_content(sub)) return true;
1581
+ return false;
1582
+ };
1583
+ const frag = document.createDocumentFragment();
1584
+ const walk = (dir, depth) => {
1585
+ for (const [, sub] of [...dir.dirs].sort((a, b) =>
1586
+ a[0].localeCompare(b[0]),
1587
+ )) {
1588
+ if (files_unlinked_only && !has_content(sub)) continue;
1589
+ frag.appendChild(folder_row(sub, depth, files_unlinked_only));
1590
+ //-- a filtered view is for finding things — expand everything
1591
+ if (files_unlinked_only || fexpanded.has(sub.rel)) {
1592
+ walk(sub, depth + 1);
1593
+ }
1594
+ }
1595
+ for (const f of dir.files) {
1596
+ frag.appendChild(file_row(f, depth));
1597
+ }
1598
+ };
1599
+ walk(root, 0);
1600
+ if (!frag.children.length) {
1601
+ const d = document.createElement("div");
1602
+ d.className = "hb-eb-empty";
1603
+ d.textContent = files_unlinked_only
1604
+ ? "No unlinked pages — every page is in the contents tree."
1605
+ : "No files.";
1606
+ frag.appendChild(d);
1607
+ }
1608
+ tree_el.innerHTML = "";
1609
+ tree_el.appendChild(frag);
1610
+ };
1611
+
1612
+ //-- --- sidebar panel ----------------------------------------------------
1613
+
1614
+ const render = () => {
1615
+ if (!host) return;
1616
+ const tree_el = host.querySelector(".hb-eb-tree");
1617
+ for (const t of host.querySelectorAll(".hb-eb-tab")) {
1618
+ t.classList.toggle("hb-eb-tab-on", t.dataset.tab === tab);
1619
+ }
1620
+ //-- per-tab header actions
1621
+ host.querySelector(".hb-eb-actions-contents").style.display =
1622
+ tab === "contents" ? "" : "none";
1623
+ host.querySelector(".hb-eb-actions-files").style.display =
1624
+ tab === "files" ? "" : "none";
1625
+ if (tab === "contents") render_contents(tree_el);
1626
+ else render_files(tree_el);
1627
+ };
1628
+
1629
+ const build_host = () => {
1630
+ host = document.createElement("div");
1631
+ host.className = "hb-eb-panel";
1632
+ host.innerHTML = `
1633
+ <div class="hb-eb-tabs">
1634
+ <button type="button" class="hb-eb-tab" data-tab="contents">Contents</button>
1635
+ <button type="button" class="hb-eb-tab" data-tab="files">Files</button>
1636
+ </div>
1637
+ <div class="hb-eb-actions hb-eb-actions-contents">
1638
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="add-page" title="Add a page at the top level"><i class="bi bi-file-earmark-plus"></i> Page</button>
1639
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="add-section" title="Add a section at the top level"><i class="bi bi-folder-plus"></i> Section</button>
1640
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="refresh" title="Reload from disk"><i class="bi bi-arrow-clockwise"></i></button>
1641
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="validate-nav" title="Validate the navigation tree (targets, casing, spellings)"><i class="bi bi-check2-circle"></i></button>
1642
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="validate-book" title="Validate the whole book (full hdoc validate)"><i class="bi bi-clipboard2-check"></i></button>
1643
+ </div>
1644
+ <div class="hb-eb-actions hb-eb-actions-files">
1645
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="new-file" title="New page file"><i class="bi bi-file-earmark-plus"></i> Page</button>
1646
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="new-folder" title="New folder"><i class="bi bi-folder-plus"></i> Folder</button>
1647
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="upload" title="Upload images to the book root (drop onto a folder to target it)"><i class="bi bi-upload"></i></button>
1648
+ <button type="button" class="hb-eb-btn hb-eb-btn-sm" data-act="refresh-files" title="Reload from disk"><i class="bi bi-arrow-clockwise"></i></button>
1649
+ <label class="hb-eb-check-sm" title="Show only pages not linked from the contents tree"><input type="checkbox" data-act="unlinked-only"/> Unlinked</label>
1650
+ </div>
1651
+ <div class="hb-eb-tree"></div>
1652
+ <div class="hb-eb-note">Edit mode — changes save to hdocbook.json and page files on disk.</div>`;
1653
+
1654
+ for (const t of host.querySelectorAll(".hb-eb-tab")) {
1655
+ t.addEventListener("click", () => {
1656
+ tab = t.dataset.tab;
1657
+ render();
1658
+ });
1659
+ }
1660
+ host
1661
+ .querySelector('[data-act="add-page"]')
1662
+ .addEventListener("click", () =>
1663
+ op_add_page({ parentId: null, index: undefined }),
1664
+ );
1665
+ host
1666
+ .querySelector('[data-act="add-section"]')
1667
+ .addEventListener("click", () =>
1668
+ op_add_section({ parentId: null, index: undefined }),
1669
+ );
1670
+ host
1671
+ .querySelector('[data-act="refresh"]')
1672
+ .addEventListener("click", load_toc);
1673
+ host
1674
+ .querySelector('[data-act="validate-nav"]')
1675
+ .addEventListener("click", op_validate_nav);
1676
+ host
1677
+ .querySelector('[data-act="validate-book"]')
1678
+ .addEventListener("click", op_validate_book);
1679
+ host
1680
+ .querySelector('[data-act="new-file"]')
1681
+ .addEventListener("click", () => op_new_file(docid()));
1682
+ host
1683
+ .querySelector('[data-act="new-folder"]')
1684
+ .addEventListener("click", () => op_new_folder(docid()));
1685
+ host
1686
+ .querySelector('[data-act="upload"]')
1687
+ .addEventListener("click", async () =>
1688
+ upload_images(docid(), await pick_images()),
1689
+ );
1690
+ host
1691
+ .querySelector('[data-act="refresh-files"]')
1692
+ .addEventListener("click", load_files);
1693
+ host
1694
+ .querySelector('[data-act="unlinked-only"]')
1695
+ .addEventListener("change", (e) => {
1696
+ files_unlinked_only = e.target.checked;
1697
+ render();
1698
+ });
1699
+ };
1700
+
1701
+ const attach_panel = () => {
1702
+ const aside = document.querySelector("aside.DocSidebar");
1703
+ if (!aside) return false;
1704
+ if (!host) build_host();
1705
+ if (host.parentElement !== aside) aside.prepend(host);
1706
+ render();
1707
+ return true;
1708
+ };
1709
+
1710
+ const detach_panel = () => {
1711
+ if (host) host.remove();
1712
+ };
1713
+
1714
+ //-- --- toolbar toggle ---------------------------------------------------
1715
+
1716
+ const update_button = () => {
1717
+ const btn = document.getElementById("hb-eb-toggle");
1718
+ if (!btn) return;
1719
+ const dark = document.documentElement.classList.contains("dark");
1720
+ btn.classList.toggle("btn-light", !dark);
1721
+ btn.classList.toggle("btn-secondary", dark);
1722
+ btn.classList.toggle("hb-eb-toggle-on", edit_mode);
1723
+ btn.title = edit_mode ? "Leave edit mode" : "Edit mode";
1724
+ };
1725
+
1726
+ const ensure_button = () => {
1727
+ if (document.getElementById("hb-eb-toggle")) {
1728
+ update_button();
1729
+ return;
1730
+ }
1731
+ //-- NOT the first .toolbar-right — that's the search-box wrapper
1732
+ //-- (.toolbar-search-bar) and a button prepended there wraps the
1733
+ //-- search input onto a new line. Target the icon-button group and
1734
+ //-- sit immediately before the AI (MCP) button.
1735
+ const bar = document.querySelector(
1736
+ ".toolbar-right:not(.toolbar-search-bar)",
1737
+ );
1738
+ if (!bar) return;
1739
+ const btn = document.createElement("button");
1740
+ btn.type = "button";
1741
+ btn.id = "hb-eb-toggle";
1742
+ btn.className = "me-2 btn btn-sm rounded-circle toolbar-icon-btn";
1743
+ btn.setAttribute("aria-label", "Toggle edit mode");
1744
+ btn.innerHTML = '<i class="bi bi-pencil-square"></i>';
1745
+ btn.addEventListener("click", () => set_edit_mode(!edit_mode));
1746
+ const ai_btn = bar.querySelector(".hb-icon-mcp");
1747
+ if (ai_btn) bar.insertBefore(btn, ai_btn.closest("button"));
1748
+ else bar.prepend(btn);
1749
+ update_button();
1750
+ };
1751
+
1752
+ //-- live sync: the server watches the book folder and pushes external
1753
+ //-- changes (its own writes are hash-suppressed server-side)
1754
+ const start_events = () => {
1755
+ if (es) return;
1756
+ try {
1757
+ es = new EventSource("/_edit/events");
1758
+ } catch {
1759
+ return;
1760
+ }
1761
+ es.onmessage = (ev) => {
1762
+ let msg;
1763
+ try {
1764
+ msg = JSON.parse(ev.data);
1765
+ } catch {
1766
+ return;
1767
+ }
1768
+ if (msg.type === "structure") {
1769
+ //-- hdocbook.json changed externally: fresh tree + Vue nav
1770
+ load_toc();
1771
+ refresh_vue_nav();
1772
+ return;
1773
+ }
1774
+ if (msg.type === "fs") {
1775
+ //-- hand page-level changes to the inline editor (it decides
1776
+ //-- whether the open buffer is affected)
1777
+ window.dispatchEvent(
1778
+ new CustomEvent("hdoc-ext-change", { detail: msg }),
1779
+ );
1780
+ //-- coalesce bursts (git checkout, save-all) into one refresh
1781
+ if (fs_refresh_timer) clearTimeout(fs_refresh_timer);
1782
+ fs_refresh_timer = setTimeout(() => {
1783
+ fs_refresh_timer = null;
1784
+ if (files_dto) load_files();
1785
+ else render();
1786
+ //-- current page changed and no editor holding it → re-render
1787
+ const cur = logical_path();
1788
+ const rel = String(msg.path || "");
1789
+ const cur_md = [`${cur}.md`, `${cur}/index.md`];
1790
+ if (
1791
+ cur_md.includes(rel) &&
1792
+ !(window.HdocEditInline && window.HdocEditInline.isOpen())
1793
+ ) {
1794
+ nav_to(cur).then(render);
1795
+ }
1796
+ }, 400);
1797
+ }
1798
+ };
1799
+ };
1800
+
1801
+ const stop_events = () => {
1802
+ if (es) {
1803
+ try {
1804
+ es.close();
1805
+ } catch {
1806
+ /* ignore */
1807
+ }
1808
+ es = null;
1809
+ }
1810
+ };
1811
+
1812
+ const set_edit_mode = (on) => {
1813
+ edit_mode = on;
1814
+ set_pref("hdoc-edit-book-mode", on ? "1" : "0");
1815
+ document.body.classList.toggle("hb-eb-on", on);
1816
+ update_button();
1817
+ close_ctx();
1818
+ if (on) {
1819
+ attach_panel();
1820
+ load_toc();
1821
+ start_events();
1822
+ open_editor_soon();
1823
+ } else {
1824
+ detach_panel();
1825
+ stop_events();
1826
+ if (window.HdocEditInline) window.HdocEditInline.close();
1827
+ refresh_vue_nav();
1828
+ }
1829
+ };
1830
+
1831
+ //-- --- styles -----------------------------------------------------------
1832
+
1833
+ const inject_styles = () => {
1834
+ const css = `
1835
+ body.hb-eb-on aside.DocSidebar #DocSidebarNav { display: none !important; }
1836
+ .hb-eb-panel {
1837
+ display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
1838
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
1839
+ font-size: 13px; color: #212529; padding: 6px 4px 0 0;
1840
+ }
1841
+ html.dark .hb-eb-panel { color: #dee2e6; }
1842
+ .hb-eb-tabs { display: flex; gap: 4px; padding: 2px 2px 8px; }
1843
+ .hb-eb-tab {
1844
+ flex: 1; border: 1px solid rgba(0,0,0,.15); background: none; color: inherit;
1845
+ border-radius: 6px; padding: 4px 0; cursor: pointer; font-size: 12px; font-weight: 600;
1846
+ }
1847
+ html.dark .hb-eb-tab { border-color: rgba(255,255,255,.18); }
1848
+ .hb-eb-tab-on { background: #ffd777; color: #6b4400; border-color: transparent; }
1849
+ html.dark .hb-eb-tab-on { background: #7a5200; color: #ffe2a0; }
1850
+ .hb-eb-actions { display: flex; gap: 4px; padding: 0 2px 8px; align-items: center; }
1851
+ .hb-eb-check-sm {
1852
+ display: flex; align-items: center; gap: 4px; font-size: 12px;
1853
+ cursor: pointer; margin-left: auto; padding-right: 2px; user-select: none;
1854
+ }
1855
+ .hb-eb-btn {
1856
+ border: 1px solid rgba(0,0,0,.2); background: none; color: inherit;
1857
+ border-radius: 6px; padding: 5px 14px; cursor: pointer; font-size: 13px;
1858
+ }
1859
+ html.dark .hb-eb-btn { border-color: rgba(255,255,255,.25); }
1860
+ .hb-eb-btn-sm { padding: 3px 8px; font-size: 12px; }
1861
+ .hb-eb-btn-primary { background: #ffd777; color: #6b4400; border-color: transparent; font-weight: 600; }
1862
+ html.dark .hb-eb-btn-primary { background: #7a5200; color: #ffe2a0; }
1863
+ .hb-eb-btn-danger { background: #c62828; color: #fff; border-color: transparent; font-weight: 600; }
1864
+ .hb-eb-tree { flex: 1 1 auto; overflow: auto; min-height: 0; padding-bottom: 8px; }
1865
+ .hb-eb-row {
1866
+ display: flex; align-items: center; gap: 4px; padding: 3px 4px;
1867
+ border-radius: 5px; cursor: pointer; user-select: none; position: relative;
1868
+ line-height: 1.35; min-width: 0;
1869
+ }
1870
+ .hb-eb-row:hover { background: rgba(0,0,0,.05); }
1871
+ html.dark .hb-eb-row:hover { background: rgba(255,255,255,.07); }
1872
+ .hb-eb-current { background: rgba(255,190,60,.18); }
1873
+ html.dark .hb-eb-current { background: rgba(255,190,60,.12); }
1874
+ .hb-eb-caret { width: 14px; flex: 0 0 14px; font-size: 11px; opacity: .7; text-align: center; }
1875
+ .hb-eb-icon { flex: 0 0 auto; opacity: .65; font-size: 12px; }
1876
+ .hb-eb-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1877
+ .hb-eb-num { opacity: .5; font-size: 11px; margin-right: 2px; }
1878
+ .hb-eb-badge {
1879
+ flex: 0 0 auto; font-size: 10px; padding: 0 5px; border-radius: 8px;
1880
+ border: 1px solid transparent; line-height: 16px;
1881
+ }
1882
+ .hb-eb-badge-draft { background: rgba(108,117,125,.18); color: #6c757d; }
1883
+ html.dark .hb-eb-badge-draft { color: #adb5bd; }
1884
+ .hb-eb-badge-missing { background: rgba(198,40,40,.12); color: #c62828; }
1885
+ html.dark .hb-eb-badge-missing { color: #ff8a80; }
1886
+ .hb-eb-badge-orphan { background: rgba(13,110,253,.12); color: #0d6efd; }
1887
+ html.dark .hb-eb-badge-orphan { color: #6ea8fe; }
1888
+ .hb-eb-badge-problem {
1889
+ background: #c62828; color: #fff; cursor: pointer; font-weight: 700;
1890
+ min-width: 16px; text-align: center;
1891
+ }
1892
+ html.dark .hb-eb-badge-problem { background: #b23c3c; }
1893
+ .hb-eb-menu {
1894
+ flex: 0 0 auto; border: none; background: none; color: inherit; cursor: pointer;
1895
+ opacity: 0; padding: 0 4px; font-size: 14px; line-height: 1;
1896
+ }
1897
+ .hb-eb-row:hover .hb-eb-menu { opacity: .7; }
1898
+ .hb-eb-menu:hover { opacity: 1 !important; }
1899
+ .hb-eb-dragging { opacity: .45; }
1900
+ .hb-eb-drop-before { box-shadow: inset 0 2px 0 0 #b8860b; }
1901
+ .hb-eb-drop-after { box-shadow: inset 0 -2px 0 0 #b8860b; }
1902
+ .hb-eb-drop-into { outline: 2px solid #b8860b; outline-offset: -2px; }
1903
+ html.dark .hb-eb-drop-before { box-shadow: inset 0 2px 0 0 #ffd777; }
1904
+ html.dark .hb-eb-drop-after { box-shadow: inset 0 -2px 0 0 #ffd777; }
1905
+ html.dark .hb-eb-drop-into { outline-color: #ffd777; }
1906
+ .hb-eb-empty { opacity: .6; padding: 12px 6px; font-size: 12px; }
1907
+ .hb-eb-note {
1908
+ flex: 0 0 auto; font-size: 11px; opacity: .55; padding: 6px 4px 10px;
1909
+ border-top: 1px solid rgba(0,0,0,.08);
1910
+ }
1911
+ html.dark .hb-eb-note { border-top-color: rgba(255,255,255,.08); }
1912
+ #hb-eb-toggle.hb-eb-toggle-on { background: #ffd777 !important; color: #6b4400 !important; }
1913
+ html.dark #hb-eb-toggle.hb-eb-toggle-on { background: #7a5200 !important; color: #ffe2a0 !important; }
1914
+ .hb-eb-ctx {
1915
+ position: fixed; z-index: 3002; padding: 4px; min-width: 190px;
1916
+ background: #ffffff; border: 1px solid rgba(0,0,0,.15); border-radius: 8px;
1917
+ box-shadow: 0 6px 20px rgba(0,0,0,.2);
1918
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
1919
+ }
1920
+ html.dark .hb-eb-ctx { background: #23272b; border-color: rgba(255,255,255,.15); }
1921
+ .hb-eb-ctx-item {
1922
+ display: block; width: 100%; text-align: left; white-space: nowrap;
1923
+ border: none; background: none; color: #212529; cursor: pointer;
1924
+ padding: 6px 12px; border-radius: 6px; font-size: 13px;
1925
+ }
1926
+ html.dark .hb-eb-ctx-item { color: #dee2e6; }
1927
+ .hb-eb-ctx-item:hover { background: rgba(0,0,0,.06); }
1928
+ html.dark .hb-eb-ctx-item:hover { background: rgba(255,255,255,.08); }
1929
+ .hb-eb-ctx-item .bi { color: #b8860b; margin-right: 6px; }
1930
+ html.dark .hb-eb-ctx-item .bi { color: #ffd777; }
1931
+ .hb-eb-ctx-danger, .hb-eb-ctx-danger .bi { color: #c62828 !important; }
1932
+ html.dark .hb-eb-ctx-danger, html.dark .hb-eb-ctx-danger .bi { color: #ff8a80 !important; }
1933
+ .hb-eb-ctx-sep { height: 1px; margin: 4px 8px; background: rgba(0,0,0,.1); }
1934
+ html.dark .hb-eb-ctx-sep { background: rgba(255,255,255,.12); }
1935
+ .hb-eb-overlay {
1936
+ position: fixed; inset: 0; z-index: 3100; background: rgba(0,0,0,.35);
1937
+ display: flex; align-items: flex-start; justify-content: center; padding-top: 14vh;
1938
+ }
1939
+ .hb-eb-dialog {
1940
+ background: #ffffff; color: #212529; border-radius: 10px; padding: 16px 18px;
1941
+ width: min(440px, 92vw); box-shadow: 0 12px 40px rgba(0,0,0,.3);
1942
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px;
1943
+ }
1944
+ html.dark .hb-eb-dialog { background: #23272b; color: #dee2e6; }
1945
+ .hb-eb-dialog-title { font-weight: 700; font-size: 15px; margin-bottom: 10px; }
1946
+ .hb-eb-dialog-msg { margin-bottom: 10px; opacity: .85; }
1947
+ .hb-eb-dialog-field { display: block; margin-bottom: 10px; }
1948
+ .hb-eb-dialog-field span { display: block; font-size: 12px; opacity: .75; margin-bottom: 3px; }
1949
+ .hb-eb-dialog-field input, .hb-eb-dialog-field select {
1950
+ width: 100%; box-sizing: border-box; padding: 6px 8px; border-radius: 6px;
1951
+ border: 1px solid rgba(0,0,0,.25); background: none; color: inherit; font-size: 13px;
1952
+ }
1953
+ html.dark .hb-eb-dialog-field input, html.dark .hb-eb-dialog-field select { border-color: rgba(255,255,255,.25); }
1954
+ html.dark .hb-eb-dialog-field select option { background: #23272b; }
1955
+ .hb-eb-dialog-sect {
1956
+ font-weight: 700; font-size: 12px; text-transform: uppercase; opacity: .6;
1957
+ letter-spacing: .04em; margin: 14px 0 8px;
1958
+ border-top: 1px solid rgba(0,0,0,.1); padding-top: 10px;
1959
+ }
1960
+ html.dark .hb-eb-dialog-sect { border-top-color: rgba(255,255,255,.1); }
1961
+ .hb-eb-dialog-check { display: block; margin: 4px 0 10px; cursor: pointer; }
1962
+ /* input-group: input and browse button joined, bootstrap-style. The extra
1963
+ .hb-eb-dialog-field qualifier outranks the generic field-label span rule
1964
+ above (which sets display:block and would drop the button underneath). */
1965
+ .hb-eb-dialog-field span.hb-eb-dialog-browse-wrap {
1966
+ display: flex; gap: 0; opacity: 1; font-size: 13px; margin-bottom: 0;
1967
+ }
1968
+ .hb-eb-dialog-browse-wrap input { flex: 1 1 auto; border-radius: 6px 0 0 6px; }
1969
+ .hb-eb-dialog-browse-wrap .hb-eb-btn {
1970
+ flex: 0 0 auto; border-radius: 0 6px 6px 0; border-left: none;
1971
+ border-color: rgba(0,0,0,.25); padding: 3px 10px;
1972
+ }
1973
+ html.dark .hb-eb-dialog-browse-wrap .hb-eb-btn { border-color: rgba(255,255,255,.25); }
1974
+ .hb-eb-picker-overlay { z-index: 3150; }
1975
+ .hb-eb-picker-list {
1976
+ max-height: 46vh; overflow: auto; margin-bottom: 8px;
1977
+ border: 1px solid rgba(0,0,0,.12); border-radius: 6px; padding: 4px;
1978
+ }
1979
+ html.dark .hb-eb-picker-list { border-color: rgba(255,255,255,.14); }
1980
+ .hb-eb-imgprev { width: min(720px, 92vw); }
1981
+ .hb-eb-imgprev-frame {
1982
+ display: flex; align-items: center; justify-content: center;
1983
+ max-height: 56vh; overflow: auto; margin-bottom: 10px; padding: 10px;
1984
+ border: 1px solid rgba(0,0,0,.12); border-radius: 6px;
1985
+ background:
1986
+ repeating-conic-gradient(rgba(128,128,128,.12) 0% 25%, transparent 0% 50%)
1987
+ 0 0 / 20px 20px;
1988
+ }
1989
+ html.dark .hb-eb-imgprev-frame { border-color: rgba(255,255,255,.14); }
1990
+ .hb-eb-imgprev-frame img { max-width: 100%; max-height: 52vh; }
1991
+ .hb-eb-imgprev-ref { margin-bottom: 10px; overflow-x: auto; }
1992
+ .hb-eb-imgprev-ref code { font-size: 12px; opacity: .8; white-space: nowrap; }
1993
+ .hb-eb-results { width: min(760px, 94vw); }
1994
+ .hb-eb-results-body { max-height: 62vh; overflow: auto; }
1995
+ .hb-eb-results-list { margin: 0 0 6px; padding-left: 20px; font-size: 12.5px; line-height: 1.5; }
1996
+ .hb-eb-results-list li { margin-bottom: 3px; overflow-wrap: anywhere; }
1997
+ .hb-eb-res-err li { color: #c62828; }
1998
+ html.dark .hb-eb-res-err li { color: #ff8a80; }
1999
+ .hb-eb-res-warn li { color: #b8860b; }
2000
+ html.dark .hb-eb-res-warn li { color: #ffd777; }
2001
+ .hb-eb-res-skip li { opacity: .6; }
2002
+ .hb-eb-results-raw {
2003
+ font-size: 12px; line-height: 1.5; padding: 10px; border-radius: 6px;
2004
+ background: rgba(0,0,0,.05); overflow-x: auto; white-space: pre-wrap;
2005
+ overflow-wrap: anywhere;
2006
+ }
2007
+ html.dark .hb-eb-results-raw { background: rgba(255,255,255,.06); }
2008
+ .hb-eb-results-ok { padding: 14px 4px; font-size: 14px; color: #2e7d32; }
2009
+ html.dark .hb-eb-results-ok { color: #81c784; }
2010
+ .hb-eb-results-ok .bi { margin-right: 6px; }
2011
+ .hb-eb-busy { display: flex; align-items: center; gap: 16px; }
2012
+ .hb-eb-busy .hb-eb-dialog-title { margin-bottom: 4px; }
2013
+ .hb-eb-busy .hb-eb-dialog-msg { margin-bottom: 4px; }
2014
+ .hb-eb-busy-elapsed { font-size: 12px; opacity: .6; font-variant-numeric: tabular-nums; }
2015
+ .hb-eb-spinner {
2016
+ flex: 0 0 auto; width: 34px; height: 34px; border-radius: 50%;
2017
+ border: 4px solid rgba(128,128,128,.25); border-top-color: #b8860b;
2018
+ animation: hb-eb-spin .8s linear infinite;
2019
+ }
2020
+ html.dark .hb-eb-spinner { border-top-color: #ffd777; }
2021
+ @keyframes hb-eb-spin { to { transform: rotate(360deg); } }
2022
+ .hb-eb-dialog-hint { font-size: 11px; opacity: .55; margin: -6px 0 10px; }
2023
+ .hb-eb-dialog-foot { display: flex; gap: 8px; margin-top: 6px; }
2024
+ .hb-eb-toast {
2025
+ position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
2026
+ z-index: 3200; background: #212529; color: #f8f9fa; border-radius: 8px;
2027
+ padding: 8px 16px; font-size: 13px; box-shadow: 0 6px 20px rgba(0,0,0,.3);
2028
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
2029
+ max-width: 80vw;
2030
+ }
2031
+ .hb-eb-toast-err { background: #c62828; color: #fff; }
2032
+ `;
2033
+ const style = document.createElement("style");
2034
+ style.textContent = css;
2035
+ document.head.appendChild(style);
2036
+ };
2037
+
2038
+ //-- --- init -------------------------------------------------------------
2039
+
2040
+ const init = async () => {
2041
+ let enabled = false;
2042
+ try {
2043
+ const r = await fetch("/_edit/mode", {
2044
+ headers: { accept: "application/json" },
2045
+ });
2046
+ const ct = r.headers.get("content-type") || "";
2047
+ if (r.ok && ct.includes("json")) {
2048
+ enabled = (await r.json()).enabled === true;
2049
+ }
2050
+ } catch {
2051
+ /* not hdoc serve / not loopback */
2052
+ }
2053
+ if (!enabled) return;
2054
+
2055
+ inject_styles();
2056
+
2057
+ //-- small API for the inline editor (js/hdoc-edit-inline.js): its
2058
+ //-- "Insert link to page…" menu item reuses the page picker
2059
+ window.HdocEditBook = { pickPage: pick_page };
2060
+
2061
+ document.addEventListener("click", close_ctx);
2062
+ document.addEventListener("keydown", (ev) => {
2063
+ if (ev.key === "Escape") close_ctx();
2064
+ });
2065
+
2066
+ //-- keep the toolbar button present and the panel attached across Vue
2067
+ //-- re-renders; also highlight the current page as the SPA navigates
2068
+ const ensure = () => {
2069
+ ensure_button();
2070
+ if (edit_mode) attach_panel();
2071
+ };
2072
+ const mo = new MutationObserver(() => {
2073
+ //-- cheap presence checks only — bail out fast when nothing to do
2074
+ if (
2075
+ !document.getElementById("hb-eb-toggle") ||
2076
+ (edit_mode && (!host || !host.isConnected))
2077
+ ) {
2078
+ ensure();
2079
+ }
2080
+ });
2081
+ mo.observe(document.body, { childList: true, subtree: true });
2082
+ ensure();
2083
+
2084
+ //-- SPA navigation → refresh the current-page highlight, and while
2085
+ //-- edit mode is on pop the page editor out for the newly opened page
2086
+ //-- (the inline patch's own nav hook closes the previous page's
2087
+ //-- editor first — it registered earlier, so it runs first; the small
2088
+ //-- extra delay keeps the reopen after a dirty-buffer prompt too)
2089
+ let last_nav_path = logical_path();
2090
+ const on_nav = () => {
2091
+ render();
2092
+ const now = logical_path();
2093
+ if (now === last_nav_path) return;
2094
+ last_nav_path = now;
2095
+ const id = docid();
2096
+ if (edit_mode && (now === id || now.startsWith(`${id}/`))) {
2097
+ //-- small delay so the inline patch's own nav hook (which
2098
+ //-- closes the previous page's editor) has run first
2099
+ setTimeout(open_editor_soon, 80);
2100
+ }
2101
+ };
2102
+ window.addEventListener("popstate", () => setTimeout(on_nav, 0));
2103
+ for (const fn of ["pushState", "replaceState"]) {
2104
+ const orig = history[fn].bind(history);
2105
+ history[fn] = (...args) => {
2106
+ const r = orig(...args);
2107
+ setTimeout(on_nav, 0);
2108
+ return r;
2109
+ };
2110
+ }
2111
+
2112
+ //-- restore edit mode from the previous session
2113
+ if (pref("hdoc-edit-book-mode") === "1") {
2114
+ //-- the book UI may not be mounted yet — retry briefly
2115
+ let tries = 0;
2116
+ const arm = setInterval(() => {
2117
+ tries++;
2118
+ if (document.querySelector("aside.DocSidebar")) {
2119
+ clearInterval(arm);
2120
+ set_edit_mode(true);
2121
+ } else if (tries > 50) {
2122
+ clearInterval(arm);
2123
+ }
2124
+ }, 200);
2125
+ }
2126
+ };
2127
+
2128
+ init();
2129
+ })();