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.
- package/hdoc-content-routes.js +15 -0
- package/hdoc-edit.js +3 -1
- package/hdoc-help.js +1 -1
- package/hdoc-serve-validate.js +516 -0
- package/hdoc-serve.js +811 -0
- package/hdoc-toc.js +7 -2
- package/package.json +2 -1
- package/ui/index.html +5 -2
- package/ui/js/bootstrap.js +3 -1
- package/ui/js/doc.hornbill.js +15 -1
- package/ui/js/hdoc-edit-book.js +2129 -0
- package/ui/js/hdoc-edit-cm.js +29 -0
- package/ui/js/hdoc-edit-inline.js +1649 -0
- package/ui/js/hdoc-edit-spell.js +17 -0
|
@@ -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, "<")
|
|
88
|
+
.replace(/>/g, ">")
|
|
89
|
+
.replace(/"/g, """);
|
|
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 = ``;
|
|
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
|
+
``,
|
|
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
|
+
})();
|