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,1649 @@
|
|
|
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
|
+
//-- Inline edit mode for `hdoc serve`. Probes /_edit/mode; when the server
|
|
6
|
+
//-- confirms edit mode (loopback callers only — LAN viewers stay read-only),
|
|
7
|
+
//-- right-clicking page content offers "Edit this
|
|
8
|
+
//-- in the page editor" (Shift+right-click keeps the browser's own menu).
|
|
9
|
+
//-- The action opens a slide-over panel hosting a CodeMirror 6 editor (the
|
|
10
|
+
//-- same stack `hdoc edit` uses: line numbers, fold gutter, markdown
|
|
11
|
+
//-- highlighting — bundled as js/hdoc-edit-cm.js, lazy-loaded only in edit
|
|
12
|
+
//-- mode) with the page's raw markdown:
|
|
13
|
+
//-- - edits live-preview into the page through the server's shared render
|
|
14
|
+
//-- pipeline (POST /_edit/preview), so preview == published output
|
|
15
|
+
//-- - Save writes the file back (PUT /_edit/source) with an etag conflict
|
|
16
|
+
//-- check, then re-renders the page from disk
|
|
17
|
+
//-- - the editor follows the page viewport (heading-anchored scroll sync),
|
|
18
|
+
//-- and right-clicking content jumps the editor to that block
|
|
19
|
+
//-- Relies on viewer globals loaded before this file: contentContainer(),
|
|
20
|
+
//-- postProcessBookContentRender(), loadContentUrl(), view.docApp.
|
|
21
|
+
|
|
22
|
+
(() => {
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
let panel = null;
|
|
26
|
+
let cm = null; //-- CodeMirror EditorView
|
|
27
|
+
let cm_ready = null; //-- promise: bundle loaded
|
|
28
|
+
let ed_disabled = true;
|
|
29
|
+
let status_el = null;
|
|
30
|
+
let save_btn = null;
|
|
31
|
+
let open_path = "";
|
|
32
|
+
let open_file_rel = ""; //-- source-relative path of the open md file
|
|
33
|
+
let etag = "";
|
|
34
|
+
let saved_content = "";
|
|
35
|
+
let preview_timer = null;
|
|
36
|
+
let previewed = false;
|
|
37
|
+
//-- the file's line-ending style: the editor buffer is LF-normalized, so
|
|
38
|
+
//-- Save re-applies the original EOLs or a CRLF file would come back as a
|
|
39
|
+
//-- whole-file line-ending diff.
|
|
40
|
+
let eol = "\n";
|
|
41
|
+
//-- CM compartments so wrap / read-only can be reconfigured live
|
|
42
|
+
let wrap_comp = null;
|
|
43
|
+
let ro_comp = null;
|
|
44
|
+
|
|
45
|
+
const logical_path = () =>
|
|
46
|
+
decodeURIComponent(window.location.pathname)
|
|
47
|
+
.replace(/^\/+/, "")
|
|
48
|
+
.replace(/\/+$/, "");
|
|
49
|
+
|
|
50
|
+
const api = async (method, url, body) => {
|
|
51
|
+
const r = await fetch(url, {
|
|
52
|
+
method,
|
|
53
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
54
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
55
|
+
});
|
|
56
|
+
let json = null;
|
|
57
|
+
try {
|
|
58
|
+
json = await r.json();
|
|
59
|
+
} catch {
|
|
60
|
+
/* non-JSON response */
|
|
61
|
+
}
|
|
62
|
+
return { ok: r.ok, status: r.status, json };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
//-- persisted prefs (word wrap, panel width). Storage can throw (blocked
|
|
66
|
+
//-- site data) — fall back to defaults.
|
|
67
|
+
const pref = (k) => {
|
|
68
|
+
try {
|
|
69
|
+
return localStorage.getItem(k);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const set_pref = (k, v) => {
|
|
75
|
+
try {
|
|
76
|
+
localStorage.setItem(k, v);
|
|
77
|
+
} catch {
|
|
78
|
+
/* ignore */
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
//-- --- editor adapter ---------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const ed_value = () => (cm ? cm.state.doc.toString() : "");
|
|
85
|
+
|
|
86
|
+
//-- suppress the change listener during programmatic loads, or setting the
|
|
87
|
+
//-- buffer would flag "Unsaved changes" and fire a pointless preview
|
|
88
|
+
let ed_loading = false;
|
|
89
|
+
const ed_set_value = (text) => {
|
|
90
|
+
ed_loading = true;
|
|
91
|
+
try {
|
|
92
|
+
cm.dispatch({
|
|
93
|
+
changes: { from: 0, to: cm.state.doc.length, insert: text },
|
|
94
|
+
//-- keep the load OUT of the undo history — otherwise an undo
|
|
95
|
+
//-- with no edits made reverts to the empty initial doc
|
|
96
|
+
annotations: window.HdocCM.Transaction.addToHistory.of(false),
|
|
97
|
+
});
|
|
98
|
+
} finally {
|
|
99
|
+
ed_loading = false;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const ed_set_disabled = (disabled) => {
|
|
104
|
+
ed_disabled = disabled;
|
|
105
|
+
if (!cm) return;
|
|
106
|
+
cm.dispatch({
|
|
107
|
+
effects: ro_comp.reconfigure([
|
|
108
|
+
window.HdocCM.EditorState.readOnly.of(disabled),
|
|
109
|
+
window.HdocCM.EditorView.editable.of(!disabled),
|
|
110
|
+
]),
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const ed_pos_at_line = (line) => {
|
|
115
|
+
const doc = cm.state.doc;
|
|
116
|
+
return doc.line(Math.max(1, Math.min(line + 1, doc.lines))).from;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const set_caret_at_line = (line, col) => {
|
|
120
|
+
const doc = cm.state.doc;
|
|
121
|
+
const l = doc.line(Math.max(1, Math.min(line + 1, doc.lines)));
|
|
122
|
+
cm.dispatch({
|
|
123
|
+
selection: { anchor: l.from + Math.min(col || 0, l.length) },
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
//-- --- images: insert / upload -----------------------------------------
|
|
128
|
+
//-- Image references use the same form `hdoc edit` inserts (and books
|
|
129
|
+
//-- already use): .
|
|
130
|
+
|
|
131
|
+
const IMG_RE = /\.(png|jpe?g|gif|svg|webp|bmp|ico|avif)$/i;
|
|
132
|
+
|
|
133
|
+
const image_md = (rel) => {
|
|
134
|
+
const alt = (rel.split("/").pop() || "").replace(/\.[^.]+$/, "");
|
|
135
|
+
return ``;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
//-- insert text at a position (default: the caret), placing the caret
|
|
139
|
+
//-- after it. Runs the normal change listener, so live preview follows.
|
|
140
|
+
const ed_insert_at = (text, pos) => {
|
|
141
|
+
if (!cm || ed_disabled) return false;
|
|
142
|
+
const at = typeof pos === "number" ? pos : cm.state.selection.main.head;
|
|
143
|
+
cm.dispatch({
|
|
144
|
+
changes: { from: at, insert: text },
|
|
145
|
+
selection: { anchor: at + text.length },
|
|
146
|
+
});
|
|
147
|
+
cm.focus();
|
|
148
|
+
return true;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
//-- Book conventions want kebab-ish lowercase image names
|
|
152
|
+
//-- (^[a-z]+[_\-a-z0-9]+[a-z0-9]$ plus extension) — normalize what we can.
|
|
153
|
+
const sanitize_img_name = (name) => {
|
|
154
|
+
const dot = name.lastIndexOf(".");
|
|
155
|
+
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : "";
|
|
156
|
+
const stem = (dot >= 0 ? name.slice(0, dot) : name)
|
|
157
|
+
.toLowerCase()
|
|
158
|
+
.replace(/\s+/g, "-")
|
|
159
|
+
.replace(/[^a-z0-9._-]/g, "");
|
|
160
|
+
return stem ? stem + ext : "";
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
//-- images uploaded via the editor land in the page's sibling images/
|
|
164
|
+
//-- folder (the convention real books follow: <section>/images/<name>)
|
|
165
|
+
const page_images_dir = () => {
|
|
166
|
+
const segs = open_path.split("/");
|
|
167
|
+
const dir = segs.length > 1 ? segs.slice(0, -1).join("/") : segs[0];
|
|
168
|
+
return `${dir}/images`;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const upload_image = async (file, name) => {
|
|
172
|
+
const rel = `${page_images_dir()}/${name}`;
|
|
173
|
+
const r = await fetch(`/_edit/upload?path=${encodeURIComponent(rel)}`, {
|
|
174
|
+
method: "PUT",
|
|
175
|
+
headers: { "content-type": file.type || "application/octet-stream" },
|
|
176
|
+
body: file,
|
|
177
|
+
});
|
|
178
|
+
let json = null;
|
|
179
|
+
try {
|
|
180
|
+
json = await r.json();
|
|
181
|
+
} catch {
|
|
182
|
+
/* non-JSON response */
|
|
183
|
+
}
|
|
184
|
+
if (!r.ok || !json || !json.ok) {
|
|
185
|
+
throw new Error((json && json.error) || "Upload failed");
|
|
186
|
+
}
|
|
187
|
+
return json.file;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
//-- Upload OS-dropped/pasted image files and insert their references at
|
|
191
|
+
//-- pos. Returns false when nothing in the list is an image.
|
|
192
|
+
const handle_image_files = async (files, pos) => {
|
|
193
|
+
const imgs = Array.from(files).filter(
|
|
194
|
+
(f) => (f.type || "").startsWith("image/") || IMG_RE.test(f.name),
|
|
195
|
+
);
|
|
196
|
+
if (!imgs.length) return false;
|
|
197
|
+
set_status(`Uploading ${imgs.length} image${imgs.length > 1 ? "s" : ""}…`);
|
|
198
|
+
try {
|
|
199
|
+
const parts = [];
|
|
200
|
+
for (const f of imgs) {
|
|
201
|
+
let name = sanitize_img_name(f.name || "");
|
|
202
|
+
//-- clipboard pastes arrive as a generic "image.png" — stamp
|
|
203
|
+
//-- them so repeated pastes never overwrite each other
|
|
204
|
+
if (!name || /^image\.[a-z0-9]+$/.test(name)) {
|
|
205
|
+
const ext = (f.type.split("/")[1] || "png").replace(/[^a-z0-9]/g, "");
|
|
206
|
+
name = `pasted-${new Date()
|
|
207
|
+
.toISOString()
|
|
208
|
+
.replace(/[-:T]/g, "")
|
|
209
|
+
.slice(0, 14)}.${ext || "png"}`;
|
|
210
|
+
}
|
|
211
|
+
parts.push(image_md(await upload_image(f, name)));
|
|
212
|
+
}
|
|
213
|
+
ed_insert_at(parts.join("\n"), pos);
|
|
214
|
+
set_status(
|
|
215
|
+
`Uploaded to ${page_images_dir()}/ — reference${parts.length > 1 ? "s" : ""} inserted`,
|
|
216
|
+
);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
set_status(String((e && e.message) || e), true);
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
//-- CodeMirror DOM handlers: accept image drags from the Files tab
|
|
224
|
+
//-- (custom type, same one `hdoc edit` uses) and OS image files
|
|
225
|
+
//-- (dropped or pasted) — upload the latter, then insert references.
|
|
226
|
+
const build_image_dnd_ext = () => {
|
|
227
|
+
const CM = window.HdocCM;
|
|
228
|
+
return CM.EditorView.domEventHandlers({
|
|
229
|
+
dragover(event) {
|
|
230
|
+
const t = event.dataTransfer && event.dataTransfer.types;
|
|
231
|
+
if (
|
|
232
|
+
t &&
|
|
233
|
+
(t.includes("application/x-hdoc-image") || t.includes("Files"))
|
|
234
|
+
) {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
event.dataTransfer.dropEffect = "copy";
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
drop(event, view) {
|
|
240
|
+
const dt = event.dataTransfer;
|
|
241
|
+
if (!dt) return false;
|
|
242
|
+
const pos =
|
|
243
|
+
view.posAtCoords({ x: event.clientX, y: event.clientY }) ??
|
|
244
|
+
view.state.selection.main.head;
|
|
245
|
+
if (dt.types.includes("application/x-hdoc-image")) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
const rel = dt.getData("application/x-hdoc-image");
|
|
248
|
+
if (rel) ed_insert_at(image_md(rel), pos);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (dt.files && dt.files.length) {
|
|
252
|
+
event.preventDefault();
|
|
253
|
+
handle_image_files(dt.files, pos);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
},
|
|
258
|
+
paste(event, view) {
|
|
259
|
+
const files = event.clipboardData && event.clipboardData.files;
|
|
260
|
+
if (!files || !files.length) return false;
|
|
261
|
+
const has_img = Array.from(files).some(
|
|
262
|
+
(f) => (f.type || "").startsWith("image/") || IMG_RE.test(f.name),
|
|
263
|
+
);
|
|
264
|
+
if (!has_img) return false;
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
handle_image_files(files, view.state.selection.main.head);
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
//-- --- editor context menu: insert snippets ----------------------------
|
|
273
|
+
//-- Right-click inside the CodeMirror panel offers HDocBook building
|
|
274
|
+
//-- blocks: a starter table, the admonition boxes the build supports
|
|
275
|
+
//-- (::: note|tip|info|important|caution|warning) and links (to a book
|
|
276
|
+
//-- page via the Files/Contents picker, or external). Shift+right-click
|
|
277
|
+
//-- keeps the browser menu, same convention as the page-content menu.
|
|
278
|
+
|
|
279
|
+
let ed_ctx = null;
|
|
280
|
+
const close_ed_ctx = () => {
|
|
281
|
+
if (ed_ctx) {
|
|
282
|
+
ed_ctx.remove();
|
|
283
|
+
ed_ctx = null;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
//-- insert a block snippet at the caret, padded onto its own lines, and
|
|
288
|
+
//-- select [sel_from, sel_to) within the snippet for immediate typing
|
|
289
|
+
const insert_snippet_block = (text, sel_from, sel_to) => {
|
|
290
|
+
if (!cm || ed_disabled) return;
|
|
291
|
+
const r = cm.state.selection.main;
|
|
292
|
+
const line = cm.state.doc.lineAt(r.from);
|
|
293
|
+
//-- tables/fences/boxes need a blank line above them to parse: on an
|
|
294
|
+
//-- empty line ensure the previous line is blank too, anywhere else
|
|
295
|
+
//-- start a fresh paragraph
|
|
296
|
+
let prefix = "\n\n";
|
|
297
|
+
if (r.from === line.from && line.length === 0) {
|
|
298
|
+
const prev_blank =
|
|
299
|
+
line.number === 1 ||
|
|
300
|
+
cm.state.doc.line(line.number - 1).length === 0;
|
|
301
|
+
prefix = prev_blank ? "" : "\n";
|
|
302
|
+
}
|
|
303
|
+
const insert = `${prefix}${text}\n`;
|
|
304
|
+
const changes = { from: r.from, to: r.to, insert };
|
|
305
|
+
const base = r.from + prefix.length;
|
|
306
|
+
cm.dispatch({
|
|
307
|
+
changes,
|
|
308
|
+
selection:
|
|
309
|
+
typeof sel_from === "number"
|
|
310
|
+
? { anchor: base + sel_from, head: base + (sel_to ?? sel_from) }
|
|
311
|
+
: { anchor: r.from + insert.length },
|
|
312
|
+
});
|
|
313
|
+
cm.focus();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const insert_table = () => {
|
|
317
|
+
const t = [
|
|
318
|
+
"| Column 1 | Column 2 | Column 3 |",
|
|
319
|
+
"|----------|----------|----------|",
|
|
320
|
+
"| Value | Value | Value |",
|
|
321
|
+
"| Value | Value | Value |",
|
|
322
|
+
].join("\n");
|
|
323
|
+
//-- select the first header cell text
|
|
324
|
+
insert_snippet_block(t, 2, 10);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const insert_box = (kind) => {
|
|
328
|
+
if (!cm || ed_disabled) return;
|
|
329
|
+
const r = cm.state.selection.main;
|
|
330
|
+
const body =
|
|
331
|
+
r.from !== r.to
|
|
332
|
+
? cm.state.doc.sliceString(r.from, r.to)
|
|
333
|
+
: "Your text here.";
|
|
334
|
+
const t = `::: ${kind}\n${body}\n:::`;
|
|
335
|
+
const body_off = `::: ${kind}\n`.length;
|
|
336
|
+
insert_snippet_block(t, body_off, body_off + body.length);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
//-- inline link: wraps the selection as the label, else leaves a
|
|
340
|
+
//-- placeholder label; the part most likely to need editing is selected
|
|
341
|
+
const insert_link_md = (url, select_url) => {
|
|
342
|
+
if (!cm || ed_disabled) return;
|
|
343
|
+
const r = cm.state.selection.main;
|
|
344
|
+
const has_sel = r.from !== r.to;
|
|
345
|
+
const label = has_sel
|
|
346
|
+
? cm.state.doc.sliceString(r.from, r.to)
|
|
347
|
+
: "link text";
|
|
348
|
+
const md = `[${label}](${url})`;
|
|
349
|
+
let sel;
|
|
350
|
+
if (select_url || has_sel) {
|
|
351
|
+
//-- select the url
|
|
352
|
+
sel = {
|
|
353
|
+
anchor: r.from + label.length + 3,
|
|
354
|
+
head: r.from + label.length + 3 + url.length,
|
|
355
|
+
};
|
|
356
|
+
} else {
|
|
357
|
+
//-- select the placeholder label
|
|
358
|
+
sel = { anchor: r.from + 1, head: r.from + 1 + label.length };
|
|
359
|
+
}
|
|
360
|
+
cm.dispatch({ changes: { from: r.from, to: r.to, insert: md }, selection: sel });
|
|
361
|
+
cm.focus();
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const insert_link_page = async () => {
|
|
365
|
+
if (!(window.HdocEditBook && window.HdocEditBook.pickPage)) {
|
|
366
|
+
insert_link_md("/", true);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const link = await window.HdocEditBook.pickPage();
|
|
370
|
+
if (!link) {
|
|
371
|
+
if (cm) cm.focus();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
insert_link_md(`/${String(link).replace(/^\/+/, "")}`, false);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
//-- fenced code block with a language, body selected for typing over
|
|
378
|
+
const insert_code = (lang) => {
|
|
379
|
+
if (!cm || ed_disabled) return;
|
|
380
|
+
const r = cm.state.selection.main;
|
|
381
|
+
const body =
|
|
382
|
+
r.from !== r.to ? cm.state.doc.sliceString(r.from, r.to) : "code";
|
|
383
|
+
const fence = lang === "plain" ? "```" : `\`\`\`${lang}`;
|
|
384
|
+
const t = `${fence}\n${body}\n\`\`\``;
|
|
385
|
+
const body_off = fence.length + 1;
|
|
386
|
+
insert_snippet_block(t, body_off, body_off + body.length);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
//-- AI assist over the selection: streams the edited Markdown from the
|
|
390
|
+
//-- server (/_edit/assist → Anthropic) and replaces the selection when
|
|
391
|
+
//-- complete. The buffer is locked while the request runs so the anchor
|
|
392
|
+
//-- offsets stay valid; a single undo restores the original.
|
|
393
|
+
let ai_busy = false;
|
|
394
|
+
const ai_assist = async (action, instruction) => {
|
|
395
|
+
if (!cm || ed_disabled || ai_busy) return;
|
|
396
|
+
const r = cm.state.selection.main;
|
|
397
|
+
if (r.from === r.to) return;
|
|
398
|
+
const original = cm.state.doc.sliceString(r.from, r.to);
|
|
399
|
+
ai_busy = true;
|
|
400
|
+
ed_set_disabled(true);
|
|
401
|
+
set_status("AI: editing selection…");
|
|
402
|
+
try {
|
|
403
|
+
const resp = await fetch("/_edit/assist", {
|
|
404
|
+
method: "POST",
|
|
405
|
+
headers: { "content-type": "application/json" },
|
|
406
|
+
body: JSON.stringify({ text: original, action, instruction }),
|
|
407
|
+
});
|
|
408
|
+
if (!resp.ok) {
|
|
409
|
+
let msg = `AI request failed (HTTP ${resp.status})`;
|
|
410
|
+
try {
|
|
411
|
+
const j = await resp.json();
|
|
412
|
+
if (j && j.error) msg = j.error;
|
|
413
|
+
} catch {
|
|
414
|
+
/* not JSON */
|
|
415
|
+
}
|
|
416
|
+
set_status(msg, true);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const reader = resp.body.getReader();
|
|
420
|
+
const decoder = new TextDecoder();
|
|
421
|
+
let out = "";
|
|
422
|
+
for (;;) {
|
|
423
|
+
const { done, value } = await reader.read();
|
|
424
|
+
if (done) break;
|
|
425
|
+
out += decoder.decode(value, { stream: true });
|
|
426
|
+
set_status(`AI: editing selection… ${out.length} chars`);
|
|
427
|
+
}
|
|
428
|
+
if (!panel || !cm) return; //-- closed mid-request
|
|
429
|
+
//-- models tend to add a trailing newline the original didn't have
|
|
430
|
+
if (!original.endsWith("\n")) out = out.replace(/\n+$/, "");
|
|
431
|
+
cm.dispatch({
|
|
432
|
+
changes: { from: r.from, to: r.to, insert: out },
|
|
433
|
+
selection: { anchor: r.from, head: r.from + out.length },
|
|
434
|
+
});
|
|
435
|
+
set_status("AI edit applied — Ctrl+Z reverts it.");
|
|
436
|
+
} catch (e) {
|
|
437
|
+
set_status(`AI request failed: ${String((e && e.message) || e)}`, true);
|
|
438
|
+
} finally {
|
|
439
|
+
ai_busy = false;
|
|
440
|
+
if (panel && cm) {
|
|
441
|
+
ed_set_disabled(false);
|
|
442
|
+
cm.focus();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const ai_custom = () => {
|
|
448
|
+
const instruction = window.prompt(
|
|
449
|
+
"What should the AI do with the selected text?",
|
|
450
|
+
"",
|
|
451
|
+
);
|
|
452
|
+
if (instruction === null) {
|
|
453
|
+
if (cm) cm.focus();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
ai_assist("custom", instruction);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const BOX_KINDS = ["note", "tip", "info", "important", "caution", "warning"];
|
|
460
|
+
//-- highlight.js languages seen in books, plus mermaid (client-rendered)
|
|
461
|
+
const CODE_LANGS = [
|
|
462
|
+
"plain",
|
|
463
|
+
"bash",
|
|
464
|
+
"powershell",
|
|
465
|
+
"javascript",
|
|
466
|
+
"typescript",
|
|
467
|
+
"json",
|
|
468
|
+
"xml",
|
|
469
|
+
"html",
|
|
470
|
+
"css",
|
|
471
|
+
"sql",
|
|
472
|
+
"python",
|
|
473
|
+
"csharp",
|
|
474
|
+
"java",
|
|
475
|
+
"go",
|
|
476
|
+
"yaml",
|
|
477
|
+
"mermaid",
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
//-- generic little menu at (x, y); populate(add, sep) fills it
|
|
481
|
+
const show_ed_menu = (x, y, populate) => {
|
|
482
|
+
close_ed_ctx();
|
|
483
|
+
close_ctx_menu();
|
|
484
|
+
ed_ctx = document.createElement("div");
|
|
485
|
+
ed_ctx.className = "hb-edit-ctx";
|
|
486
|
+
const add = (label, icon, fn) => {
|
|
487
|
+
const b = document.createElement("button");
|
|
488
|
+
b.type = "button";
|
|
489
|
+
b.className = "hb-edit-ctx-item";
|
|
490
|
+
b.innerHTML = `<i class="bi ${icon}" aria-hidden="true"></i> ${label}`;
|
|
491
|
+
b.addEventListener("click", (e) => {
|
|
492
|
+
e.stopPropagation();
|
|
493
|
+
close_ed_ctx();
|
|
494
|
+
fn(x, y);
|
|
495
|
+
});
|
|
496
|
+
ed_ctx.appendChild(b);
|
|
497
|
+
};
|
|
498
|
+
const sep = () => {
|
|
499
|
+
const s = document.createElement("div");
|
|
500
|
+
s.className = "hb-edit-ctx-sep";
|
|
501
|
+
ed_ctx.appendChild(s);
|
|
502
|
+
};
|
|
503
|
+
populate(add, sep);
|
|
504
|
+
document.body.appendChild(ed_ctx);
|
|
505
|
+
ed_ctx.style.left = `${Math.min(x, window.innerWidth - ed_ctx.offsetWidth - 8)}px`;
|
|
506
|
+
ed_ctx.style.top = `${Math.min(y, window.innerHeight - ed_ctx.offsetHeight - 8)}px`;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
//-- second stage: language list for the code block
|
|
510
|
+
const show_lang_menu = (x, y) => {
|
|
511
|
+
show_ed_menu(x, y, (add) => {
|
|
512
|
+
for (const lang of CODE_LANGS) {
|
|
513
|
+
add(lang, "bi-code", () => insert_code(lang));
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const on_editor_ctx = (ev) => {
|
|
519
|
+
if (ev.shiftKey || !cm || ed_disabled) return;
|
|
520
|
+
ev.preventDefault();
|
|
521
|
+
ev.stopPropagation();
|
|
522
|
+
//-- move the caret to the click point unless it's inside the selection
|
|
523
|
+
const pos = cm.posAtCoords({ x: ev.clientX, y: ev.clientY });
|
|
524
|
+
const r = cm.state.selection.main;
|
|
525
|
+
if (pos !== null && (pos < r.from || pos > r.to)) {
|
|
526
|
+
cm.dispatch({ selection: { anchor: pos } });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
show_ed_menu(ev.clientX, ev.clientY, (add, sep) => {
|
|
530
|
+
add("Insert table", "bi-table", insert_table);
|
|
531
|
+
add("Insert code block…", "bi-code-square", show_lang_menu);
|
|
532
|
+
sep();
|
|
533
|
+
for (const kind of BOX_KINDS) {
|
|
534
|
+
add(`Insert ${kind} box`, "bi-chat-square-text", () =>
|
|
535
|
+
insert_box(kind),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
sep();
|
|
539
|
+
add("Insert link to page…", "bi-file-earmark-text", insert_link_page);
|
|
540
|
+
add("Insert external link", "bi-link-45deg", () =>
|
|
541
|
+
insert_link_md("https://example.com", true),
|
|
542
|
+
);
|
|
543
|
+
//-- AI actions operate on the selection (re-read: the right-click
|
|
544
|
+
//-- may have just moved the caret and collapsed it)
|
|
545
|
+
const sel = cm.state.selection.main;
|
|
546
|
+
if (sel.from !== sel.to && !ai_busy) {
|
|
547
|
+
sep();
|
|
548
|
+
add("AI: Rewrite selection", "bi-stars", () => ai_assist("rewrite"));
|
|
549
|
+
add("AI: Fix grammar", "bi-stars", () => ai_assist("grammar"));
|
|
550
|
+
add("AI: Make concise", "bi-stars", () => ai_assist("tighten"));
|
|
551
|
+
add("AI: Custom…", "bi-stars", ai_custom);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
//-- --- jump flash ------------------------------------------------------
|
|
557
|
+
//-- Every editor jump (open-sync, right-click) flashes the target line so
|
|
558
|
+
//-- the eye lands on the right spot: a line decoration with a CSS fade
|
|
559
|
+
//-- animation, removed once the fade completes.
|
|
560
|
+
let hl_effect = null;
|
|
561
|
+
let hl_field = null;
|
|
562
|
+
let hl_timer = null;
|
|
563
|
+
|
|
564
|
+
const build_highlight_ext = () => {
|
|
565
|
+
const CM = window.HdocCM;
|
|
566
|
+
hl_effect = CM.StateEffect.define();
|
|
567
|
+
const hl_deco = CM.Decoration.line({ class: "hb-edit-hl-line" });
|
|
568
|
+
hl_field = CM.StateField.define({
|
|
569
|
+
create: () => CM.Decoration.none,
|
|
570
|
+
update(deco, tr) {
|
|
571
|
+
deco = deco.map(tr.changes);
|
|
572
|
+
for (const e of tr.effects) {
|
|
573
|
+
if (e.is(hl_effect)) {
|
|
574
|
+
deco =
|
|
575
|
+
e.value === null
|
|
576
|
+
? CM.Decoration.none
|
|
577
|
+
: CM.Decoration.set([hl_deco.range(e.value)]);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return deco;
|
|
581
|
+
},
|
|
582
|
+
provide: (f) => CM.EditorView.decorations.from(f),
|
|
583
|
+
});
|
|
584
|
+
return hl_field;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const flash_line = (line) => {
|
|
588
|
+
if (!cm) return;
|
|
589
|
+
const doc = cm.state.doc;
|
|
590
|
+
const from = doc.line(Math.max(1, Math.min(line + 1, doc.lines))).from;
|
|
591
|
+
//-- retrigger cleanly if a flash is already running
|
|
592
|
+
if (hl_timer) clearTimeout(hl_timer);
|
|
593
|
+
cm.dispatch({ effects: hl_effect.of(null) });
|
|
594
|
+
cm.dispatch({ effects: hl_effect.of(from) });
|
|
595
|
+
hl_timer = setTimeout(() => {
|
|
596
|
+
hl_timer = null;
|
|
597
|
+
if (cm) cm.dispatch({ effects: hl_effect.of(null) });
|
|
598
|
+
}, 2600);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const scroll_editor_to_line = (line) => {
|
|
602
|
+
cm.dispatch({
|
|
603
|
+
effects: window.HdocCM.EditorView.scrollIntoView(
|
|
604
|
+
ed_pos_at_line(line),
|
|
605
|
+
{ y: "start", yMargin: 40 },
|
|
606
|
+
),
|
|
607
|
+
});
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
//-- --- viewport → editor scroll sync ---------------------------------
|
|
611
|
+
//-- The page and the markdown have no shared coordinate system (variables
|
|
612
|
+
//-- and includes expand, HTML wraps), but headings survive rendering
|
|
613
|
+
//-- almost verbatim, so they make reliable sync anchors: find the nearest
|
|
614
|
+
//-- heading above the viewport top, locate the same heading line in the
|
|
615
|
+
//-- markdown, scroll the editor to it.
|
|
616
|
+
|
|
617
|
+
const norm_text = (s) =>
|
|
618
|
+
String(s || "")
|
|
619
|
+
.toLowerCase()
|
|
620
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
621
|
+
.trim();
|
|
622
|
+
|
|
623
|
+
//-- ATX heading lines in the buffer (fenced code blocks skipped), as
|
|
624
|
+
//-- { line, text } with text normalized for comparison.
|
|
625
|
+
const md_heading_lines = () => {
|
|
626
|
+
const lines = ed_value().split("\n");
|
|
627
|
+
const out = [];
|
|
628
|
+
let in_fence = false;
|
|
629
|
+
for (let i = 0; i < lines.length; i++) {
|
|
630
|
+
if (/^\s*(```|~~~)/.test(lines[i])) {
|
|
631
|
+
in_fence = !in_fence;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (in_fence) continue;
|
|
635
|
+
const m = lines[i].match(/^#{1,6}\s+(.*)$/);
|
|
636
|
+
if (m) {
|
|
637
|
+
out.push({ line: i, text: norm_text(m[1].replace(/\s*#+\s*$/, "")) });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
let last_typed = 0;
|
|
644
|
+
|
|
645
|
+
//-- set_caret: also move the caret to the matched line (used on open, so
|
|
646
|
+
//-- focus lands on the right line instead of line 0).
|
|
647
|
+
const sync_editor_scroll = (set_caret) => {
|
|
648
|
+
if (!panel || !cm || ed_disabled) return;
|
|
649
|
+
//-- never fight the author: no sync within 2s of them typing
|
|
650
|
+
if (!set_caret && Date.now() - last_typed < 2000) return;
|
|
651
|
+
const root = document.querySelector(".injected-document-content");
|
|
652
|
+
if (!root) return;
|
|
653
|
+
const headings = Array.from(
|
|
654
|
+
root.querySelectorAll("h1,h2,h3,h4,h5,h6"),
|
|
655
|
+
);
|
|
656
|
+
//-- nearest heading at/above the viewport top, where "top" extends a
|
|
657
|
+
//-- band into the top third of the viewport: a heading sitting just
|
|
658
|
+
//-- below the toolbar (anchor scroll-margin parks them ~200px down)
|
|
659
|
+
//-- reads as the current section, not the previous one
|
|
660
|
+
const band = Math.max(120, window.innerHeight * 0.33);
|
|
661
|
+
let current = null;
|
|
662
|
+
for (const h of headings) {
|
|
663
|
+
if (h.getBoundingClientRect().top <= band) current = h;
|
|
664
|
+
else break;
|
|
665
|
+
}
|
|
666
|
+
if (!current) {
|
|
667
|
+
//-- above the first heading (page intro) → top of the source
|
|
668
|
+
if (set_caret) set_caret_at_line(0);
|
|
669
|
+
scroll_editor_to_line(0);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const line = md_line_for_heading(current, headings);
|
|
673
|
+
if (line === null) return;
|
|
674
|
+
if (set_caret) {
|
|
675
|
+
set_caret_at_line(line);
|
|
676
|
+
}
|
|
677
|
+
scroll_editor_to_line(line);
|
|
678
|
+
if (set_caret) flash_line(line);
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
//-- markdown line index for a rendered heading element, or null when it
|
|
682
|
+
//-- can't be matched. Same heading text can repeat — match Nth occurrence.
|
|
683
|
+
const md_line_for_heading = (heading_el, headings) => {
|
|
684
|
+
const target = norm_text(heading_el.textContent);
|
|
685
|
+
if (!target) return null;
|
|
686
|
+
let occurrence = 0;
|
|
687
|
+
for (const h of headings) {
|
|
688
|
+
if (h === heading_el) break;
|
|
689
|
+
if (norm_text(h.textContent) === target) occurrence++;
|
|
690
|
+
}
|
|
691
|
+
const candidates = md_heading_lines().filter((h) => h.text === target);
|
|
692
|
+
if (!candidates.length) return null;
|
|
693
|
+
return candidates[Math.min(occurrence, candidates.length - 1)].line;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
let scroll_sync_timer = null;
|
|
697
|
+
const on_page_scroll = (ev) => {
|
|
698
|
+
//-- ignore scrolls originating inside the panel (the editor's own
|
|
699
|
+
//-- scroller included — capture listener sees all)
|
|
700
|
+
if (
|
|
701
|
+
panel &&
|
|
702
|
+
ev.target instanceof Node &&
|
|
703
|
+
panel.contains(ev.target)
|
|
704
|
+
) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (scroll_sync_timer) clearTimeout(scroll_sync_timer);
|
|
708
|
+
scroll_sync_timer = setTimeout(sync_editor_scroll, 150);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
//-- --- right-click → edit at this point --------------------------------
|
|
712
|
+
//-- Right-clicking page content offers "Edit this in the page editor":
|
|
713
|
+
//-- opens the panel (if closed) and places the caret at the markdown line
|
|
714
|
+
//-- for the clicked block. The block is located by anchoring on the
|
|
715
|
+
//-- nearest heading above it, then scoring the section's markdown lines
|
|
716
|
+
//-- by word overlap with the block's text.
|
|
717
|
+
|
|
718
|
+
let ctx_menu = null;
|
|
719
|
+
const close_ctx_menu = () => {
|
|
720
|
+
if (ctx_menu) {
|
|
721
|
+
ctx_menu.remove();
|
|
722
|
+
ctx_menu = null;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
//-- The words immediately BEFORE the caret position under a point in the
|
|
727
|
+
//-- rendered text (up to 3). Used to place the editor cursor at the same
|
|
728
|
+
//-- spot within the matched markdown line, not just at its start.
|
|
729
|
+
const click_words_at_point = (x, y) => {
|
|
730
|
+
let node = null;
|
|
731
|
+
let offset = 0;
|
|
732
|
+
if (document.caretPositionFromPoint) {
|
|
733
|
+
const p = document.caretPositionFromPoint(x, y);
|
|
734
|
+
if (p) {
|
|
735
|
+
node = p.offsetNode;
|
|
736
|
+
offset = p.offset;
|
|
737
|
+
}
|
|
738
|
+
} else if (document.caretRangeFromPoint) {
|
|
739
|
+
//-- deprecated, but the only option on older WebKit
|
|
740
|
+
const r = document.caretRangeFromPoint(x, y);
|
|
741
|
+
if (r) {
|
|
742
|
+
node = r.startContainer;
|
|
743
|
+
offset = r.startOffset;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (!node || node.nodeType !== Node.TEXT_NODE) return null;
|
|
747
|
+
const words = node.textContent
|
|
748
|
+
.slice(0, offset)
|
|
749
|
+
.toLowerCase()
|
|
750
|
+
.split(/[^a-z0-9]+/)
|
|
751
|
+
.filter(Boolean)
|
|
752
|
+
.slice(-3);
|
|
753
|
+
return words.length ? words : null;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
//-- best-matching markdown line for a rendered block within [start, end)
|
|
757
|
+
const find_block_line = (block_el, start, end, lines) => {
|
|
758
|
+
const words = norm_text(block_el.textContent)
|
|
759
|
+
.split(" ")
|
|
760
|
+
.filter(Boolean)
|
|
761
|
+
.slice(0, 12);
|
|
762
|
+
if (!words.length) return start;
|
|
763
|
+
let best = start;
|
|
764
|
+
let best_score = 0;
|
|
765
|
+
for (let i = start; i < end; i++) {
|
|
766
|
+
const ln = norm_text(lines[i]);
|
|
767
|
+
if (!ln) continue;
|
|
768
|
+
let score = 0;
|
|
769
|
+
for (const w of words) {
|
|
770
|
+
if (ln.includes(w)) score++;
|
|
771
|
+
}
|
|
772
|
+
if (score > best_score) {
|
|
773
|
+
best_score = score;
|
|
774
|
+
best = i;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return best;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const edit_at_block = async (block, click_words) => {
|
|
781
|
+
if (!panel) await open_panel();
|
|
782
|
+
if (!panel || !cm || ed_disabled) return;
|
|
783
|
+
const root = document.querySelector(".injected-document-content");
|
|
784
|
+
if (!root) return;
|
|
785
|
+
const headings = Array.from(
|
|
786
|
+
root.querySelectorAll("h1,h2,h3,h4,h5,h6"),
|
|
787
|
+
);
|
|
788
|
+
//-- nearest heading at/above the clicked block, in document order
|
|
789
|
+
let current = null;
|
|
790
|
+
for (const h of headings) {
|
|
791
|
+
if (
|
|
792
|
+
h === block ||
|
|
793
|
+
h.compareDocumentPosition(block) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
794
|
+
) {
|
|
795
|
+
current = h;
|
|
796
|
+
} else break;
|
|
797
|
+
}
|
|
798
|
+
const lines = ed_value().split("\n");
|
|
799
|
+
let start = 0;
|
|
800
|
+
let end = lines.length;
|
|
801
|
+
const md_hs = md_heading_lines();
|
|
802
|
+
if (current) {
|
|
803
|
+
const heading_line = md_line_for_heading(current, headings);
|
|
804
|
+
if (heading_line !== null) {
|
|
805
|
+
start = heading_line;
|
|
806
|
+
const next = md_hs.find((h) => h.line > heading_line);
|
|
807
|
+
if (next) end = next.line;
|
|
808
|
+
}
|
|
809
|
+
} else if (md_hs.length) {
|
|
810
|
+
//-- block sits in the page intro, above the first heading
|
|
811
|
+
end = md_hs[0].line;
|
|
812
|
+
}
|
|
813
|
+
let line =
|
|
814
|
+
current && block === current
|
|
815
|
+
? start
|
|
816
|
+
: find_block_line(block, start, end, lines);
|
|
817
|
+
//-- refine to the exact spot: find the words that preceded the click
|
|
818
|
+
//-- inside the matched line (markdown punctuation like ** or ` can sit
|
|
819
|
+
//-- between them), and put the caret right after them. Longest word
|
|
820
|
+
//-- suffix wins; the block-matched line is tried first, then the rest
|
|
821
|
+
//-- of the section (covers multi-line blocks like fenced code).
|
|
822
|
+
let col = 0;
|
|
823
|
+
if (click_words && click_words.length) {
|
|
824
|
+
const esc = (w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
825
|
+
const order = [line];
|
|
826
|
+
for (let i = start; i < end; i++) {
|
|
827
|
+
if (i !== line) order.push(i);
|
|
828
|
+
}
|
|
829
|
+
outer: for (let n = click_words.length; n >= 1; n--) {
|
|
830
|
+
const re = new RegExp(
|
|
831
|
+
click_words.slice(-n).map(esc).join("[^a-zA-Z0-9]+"),
|
|
832
|
+
"i",
|
|
833
|
+
);
|
|
834
|
+
for (const li of order) {
|
|
835
|
+
const m = re.exec(lines[li]);
|
|
836
|
+
if (m) {
|
|
837
|
+
line = li;
|
|
838
|
+
col = m.index + m[0].length;
|
|
839
|
+
break outer;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
set_caret_at_line(line, col);
|
|
845
|
+
cm.focus();
|
|
846
|
+
scroll_editor_to_line(line);
|
|
847
|
+
flash_line(line);
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const on_context_menu = (ev) => {
|
|
851
|
+
//-- Shift+right-click = the browser's native menu (the same convention
|
|
852
|
+
//-- Firefox implements natively for pages with contextmenu handlers)
|
|
853
|
+
if (ev.shiftKey) return;
|
|
854
|
+
const root = document.querySelector(".injected-document-content");
|
|
855
|
+
if (!root || !root.contains(ev.target)) return;
|
|
856
|
+
ev.preventDefault();
|
|
857
|
+
close_ctx_menu();
|
|
858
|
+
const block =
|
|
859
|
+
ev.target.closest(
|
|
860
|
+
"p,li,td,th,pre,blockquote,h1,h2,h3,h4,h5,h6",
|
|
861
|
+
) || ev.target;
|
|
862
|
+
//-- capture NOW, before the menu is in the DOM under the pointer
|
|
863
|
+
const click_words = click_words_at_point(ev.clientX, ev.clientY);
|
|
864
|
+
ctx_menu = document.createElement("div");
|
|
865
|
+
ctx_menu.className = "hb-edit-ctx";
|
|
866
|
+
const item = document.createElement("button");
|
|
867
|
+
item.type = "button";
|
|
868
|
+
item.className = "hb-edit-ctx-item";
|
|
869
|
+
item.innerHTML =
|
|
870
|
+
'<i class="bi bi-pencil-fill" aria-hidden="true"></i> Edit this in the page editor';
|
|
871
|
+
item.addEventListener("click", (e) => {
|
|
872
|
+
e.stopPropagation();
|
|
873
|
+
close_ctx_menu();
|
|
874
|
+
edit_at_block(block, click_words);
|
|
875
|
+
});
|
|
876
|
+
ctx_menu.appendChild(item);
|
|
877
|
+
document.body.appendChild(ctx_menu);
|
|
878
|
+
ctx_menu.style.left = `${Math.min(ev.clientX, window.innerWidth - ctx_menu.offsetWidth - 8)}px`;
|
|
879
|
+
ctx_menu.style.top = `${Math.min(ev.clientY, window.innerHeight - ctx_menu.offsetHeight - 8)}px`;
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
//-- --- close on SPA navigation ----------------------------------------
|
|
883
|
+
//-- Left-nav clicks swap the page under the panel (pushState) — without
|
|
884
|
+
//-- this the editor stays open holding the PREVIOUS page's source. Clean
|
|
885
|
+
//-- buffer closes silently; a dirty buffer prompts, and if the author
|
|
886
|
+
//-- keeps it open the status bar flags what it still holds.
|
|
887
|
+
const on_nav_change = () => {
|
|
888
|
+
if (!panel || !cm) return;
|
|
889
|
+
const now = logical_path();
|
|
890
|
+
if (now === open_path) return;
|
|
891
|
+
//-- the new page is already rendered — never "restore" the old one
|
|
892
|
+
previewed = false;
|
|
893
|
+
const held = open_path;
|
|
894
|
+
close_panel();
|
|
895
|
+
if (panel) {
|
|
896
|
+
set_status(
|
|
897
|
+
`Page changed — this editor still holds ${held}. Save your changes or close.`,
|
|
898
|
+
true,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const set_status = (msg, is_err) => {
|
|
904
|
+
if (!status_el) return;
|
|
905
|
+
status_el.textContent = msg || "";
|
|
906
|
+
status_el.classList.toggle("hb-edit-status-err", !!is_err);
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
//-- Replacing the page body resets the window scroll to the top — brutal
|
|
910
|
+
//-- when previewing edits deep in long content. Capture the position
|
|
911
|
+
//-- BEFORE the swap; the returned restorer re-applies it immediately,
|
|
912
|
+
//-- again on the next frame (post-process reflow) and once more shortly
|
|
913
|
+
//-- after (late async layout: mermaid rendering, image loads).
|
|
914
|
+
const capture_page_scroll = () => {
|
|
915
|
+
//-- the viewer scrolls the #DocContent container, not the window
|
|
916
|
+
const el =
|
|
917
|
+
document.getElementById("DocContent") ||
|
|
918
|
+
document.scrollingElement ||
|
|
919
|
+
document.documentElement;
|
|
920
|
+
const y = el.scrollTop;
|
|
921
|
+
const x = el.scrollLeft;
|
|
922
|
+
return () => {
|
|
923
|
+
const restore = () => {
|
|
924
|
+
el.scrollTop = y;
|
|
925
|
+
el.scrollLeft = x;
|
|
926
|
+
};
|
|
927
|
+
restore();
|
|
928
|
+
requestAnimationFrame(restore);
|
|
929
|
+
setTimeout(restore, 300);
|
|
930
|
+
};
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
//-- Re-render the page body from a server-rendered fragment, then run the
|
|
934
|
+
//-- viewer's standard post-render pass (TOC, code badges, mermaid, vue
|
|
935
|
+
//-- components) so the preview behaves like a real page load.
|
|
936
|
+
const inject_fragment = (html) => {
|
|
937
|
+
const body = contentContainer();
|
|
938
|
+
if (!body) return;
|
|
939
|
+
const restore_scroll = capture_page_scroll();
|
|
940
|
+
body.innerHTML = html;
|
|
941
|
+
try {
|
|
942
|
+
//-- deep-clone to a PLAIN object: docApp.frontmatterData is a Vue
|
|
943
|
+
//-- reactive proxy, and postProcess postMessage()s it to any parent
|
|
944
|
+
//-- frame - structured clone throws DataCloneError on a proxy.
|
|
945
|
+
let fm = {};
|
|
946
|
+
try {
|
|
947
|
+
fm = JSON.parse(
|
|
948
|
+
JSON.stringify(
|
|
949
|
+
(window.view && view.docApp.frontmatterData) || {},
|
|
950
|
+
),
|
|
951
|
+
);
|
|
952
|
+
} catch {
|
|
953
|
+
/* keep {} */
|
|
954
|
+
}
|
|
955
|
+
postProcessBookContentRender(open_path, fm, `_books/${open_path}`);
|
|
956
|
+
} catch (e) {
|
|
957
|
+
console.warn("[hdoc edit] preview post-process failed", e);
|
|
958
|
+
}
|
|
959
|
+
restore_scroll();
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
//-- Diagnostics come from two producers — the server lint (dialect +
|
|
963
|
+
//-- link problems, riding the preview round trip) and the typo worker
|
|
964
|
+
//-- (nspell, below). Each stores its set; push_diags merges them, letting
|
|
965
|
+
//-- server findings win where ranges overlap (colour = dialect error AND
|
|
966
|
+
//-- an en-US typo — one squiggle is enough).
|
|
967
|
+
let server_diags = [];
|
|
968
|
+
let typo_diags = [];
|
|
969
|
+
|
|
970
|
+
const push_diags = () => {
|
|
971
|
+
if (!cm || !window.HdocCM.setDiagnostics) return;
|
|
972
|
+
const len = cm.state.doc.length;
|
|
973
|
+
const clamp = (list) =>
|
|
974
|
+
list.filter((d) => d.from >= 0 && d.from < len && d.to <= len);
|
|
975
|
+
const server = clamp(server_diags);
|
|
976
|
+
const typos = clamp(typo_diags).filter(
|
|
977
|
+
(t) => !server.some((s) => t.from < s.to && s.from < t.to),
|
|
978
|
+
);
|
|
979
|
+
cm.dispatch(
|
|
980
|
+
window.HdocCM.setDiagnostics(
|
|
981
|
+
cm.state,
|
|
982
|
+
[...server, ...typos].sort((a, b) => a.from - b.from),
|
|
983
|
+
),
|
|
984
|
+
);
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
//-- shared "add to the book dictionary" action: persists into
|
|
988
|
+
//-- hdocbook-project.json (honored by inline lint AND `hdoc validate`),
|
|
989
|
+
//-- teaches the typo worker, and refreshes both producers
|
|
990
|
+
const add_to_dictionary = (word) => {
|
|
991
|
+
api("POST", "/_edit/dictionary", { word }).then((r) => {
|
|
992
|
+
if (!r.ok) {
|
|
993
|
+
set_status(
|
|
994
|
+
(r.json && r.json.error) || "Failed to update the dictionary",
|
|
995
|
+
true,
|
|
996
|
+
);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
dict_words = (r.json && r.json.words) || dict_words;
|
|
1000
|
+
if (spell_worker) spell_worker.postMessage({ type: "add", word });
|
|
1001
|
+
set_status(`Added "${word}" to the book dictionary`);
|
|
1002
|
+
render_preview();
|
|
1003
|
+
schedule_spell(0);
|
|
1004
|
+
});
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
//-- apply server lint findings; dialect findings carry the US spelling
|
|
1008
|
+
//-- as a one-click fix plus the dictionary action.
|
|
1009
|
+
const apply_findings = (findings) => {
|
|
1010
|
+
if (!cm) return;
|
|
1011
|
+
server_diags = (findings || []).map((f) => {
|
|
1012
|
+
const actions = [];
|
|
1013
|
+
if (f.fix) {
|
|
1014
|
+
actions.push({
|
|
1015
|
+
name: `Replace with "${f.fix}"`,
|
|
1016
|
+
apply(view, from, to) {
|
|
1017
|
+
view.dispatch({ changes: { from, to, insert: f.fix } });
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
if (f.word) {
|
|
1022
|
+
actions.push({
|
|
1023
|
+
name: "Add to dictionary",
|
|
1024
|
+
apply() {
|
|
1025
|
+
add_to_dictionary(f.word);
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
return {
|
|
1030
|
+
from: f.from,
|
|
1031
|
+
to: f.to,
|
|
1032
|
+
severity: f.severity === "warning" ? "warning" : "error",
|
|
1033
|
+
message: f.message,
|
|
1034
|
+
actions: actions.length ? actions : undefined,
|
|
1035
|
+
};
|
|
1036
|
+
});
|
|
1037
|
+
push_diags();
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
//-- --- typo spellcheck (nspell in a worker, en-US) ----------------------
|
|
1041
|
+
//-- Same worker logic as `hdoc edit` (editor/spell-standalone.mjs →
|
|
1042
|
+
//-- js/hdoc-edit-spell.js); dictionary fetched from /dict. Typos are
|
|
1043
|
+
//-- warnings (dialect/link findings stay errors) with up to three
|
|
1044
|
+
//-- suggestions and the dictionary action.
|
|
1045
|
+
let spell_worker = null;
|
|
1046
|
+
let spell_timer = null;
|
|
1047
|
+
let spell_seq = 0;
|
|
1048
|
+
let dict_words = [];
|
|
1049
|
+
|
|
1050
|
+
const start_spell = () => {
|
|
1051
|
+
if (spell_worker) return;
|
|
1052
|
+
try {
|
|
1053
|
+
spell_worker = new Worker("js/hdoc-edit-spell.js");
|
|
1054
|
+
} catch {
|
|
1055
|
+
return; //-- worker bundle missing — typo check just stays off
|
|
1056
|
+
}
|
|
1057
|
+
spell_worker.onmessage = (e) => {
|
|
1058
|
+
const msg = e.data || {};
|
|
1059
|
+
if (msg.type !== "result" || !cm) return;
|
|
1060
|
+
if (msg.id !== spell_seq) return; //-- stale run
|
|
1061
|
+
typo_diags = (msg.findings || []).map((f) => {
|
|
1062
|
+
const actions = f.suggestions.slice(0, 3).map((s) => ({
|
|
1063
|
+
name: `"${s}"`,
|
|
1064
|
+
apply(view, from, to) {
|
|
1065
|
+
view.dispatch({ changes: { from, to, insert: s } });
|
|
1066
|
+
},
|
|
1067
|
+
}));
|
|
1068
|
+
actions.push({
|
|
1069
|
+
name: "Add to dictionary",
|
|
1070
|
+
apply() {
|
|
1071
|
+
add_to_dictionary(f.word);
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
1074
|
+
return {
|
|
1075
|
+
from: f.from,
|
|
1076
|
+
to: f.to,
|
|
1077
|
+
severity: "warning",
|
|
1078
|
+
message: `Unknown word: ${f.word}`,
|
|
1079
|
+
actions,
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
push_diags();
|
|
1083
|
+
};
|
|
1084
|
+
spell_worker.onerror = () => {
|
|
1085
|
+
try {
|
|
1086
|
+
spell_worker.terminate();
|
|
1087
|
+
} catch {
|
|
1088
|
+
/* ignore */
|
|
1089
|
+
}
|
|
1090
|
+
spell_worker = null;
|
|
1091
|
+
};
|
|
1092
|
+
//-- the book's custom dictionary seeds the speller
|
|
1093
|
+
api("GET", "/_edit/dictionary").then((r) => {
|
|
1094
|
+
if (r.ok && r.json && Array.isArray(r.json.words)) {
|
|
1095
|
+
dict_words = r.json.words;
|
|
1096
|
+
}
|
|
1097
|
+
schedule_spell(0);
|
|
1098
|
+
});
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
const schedule_spell = (delay = 700) => {
|
|
1102
|
+
if (!spell_worker) return;
|
|
1103
|
+
if (spell_timer) clearTimeout(spell_timer);
|
|
1104
|
+
spell_timer = setTimeout(() => {
|
|
1105
|
+
spell_timer = null;
|
|
1106
|
+
if (!cm || ed_disabled) return;
|
|
1107
|
+
spell_seq++;
|
|
1108
|
+
spell_worker.postMessage({
|
|
1109
|
+
type: "check",
|
|
1110
|
+
id: spell_seq,
|
|
1111
|
+
text: ed_value(),
|
|
1112
|
+
customWords: dict_words,
|
|
1113
|
+
});
|
|
1114
|
+
}, delay);
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
const render_preview = async () => {
|
|
1118
|
+
preview_timer = null;
|
|
1119
|
+
const sent = ed_value();
|
|
1120
|
+
const r = await api("POST", "/_edit/preview", {
|
|
1121
|
+
path: open_path,
|
|
1122
|
+
content: sent,
|
|
1123
|
+
});
|
|
1124
|
+
if (!r.ok || !r.json || typeof r.json.html !== "string") {
|
|
1125
|
+
set_status((r.json && r.json.error) || "Preview failed", true);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
//-- stale response (buffer moved on while the render ran): the next
|
|
1129
|
+
//-- debounced pass is already scheduled — drop this one
|
|
1130
|
+
if (!cm || ed_value() !== sent) return;
|
|
1131
|
+
apply_findings(r.json.findings);
|
|
1132
|
+
//-- a buffer identical to the saved file needs no injection (this is
|
|
1133
|
+
//-- the lint-only pass that runs on open) and must not flag the page
|
|
1134
|
+
//-- as previewing unsaved content
|
|
1135
|
+
if (sent === saved_content) return;
|
|
1136
|
+
previewed = true;
|
|
1137
|
+
inject_fragment(r.json.html);
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
const schedule_preview = () => {
|
|
1141
|
+
last_typed = Date.now();
|
|
1142
|
+
if (preview_timer) clearTimeout(preview_timer);
|
|
1143
|
+
preview_timer = setTimeout(render_preview, 600);
|
|
1144
|
+
set_status(ed_value() === saved_content ? "" : "Unsaved changes");
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const save = async () => {
|
|
1148
|
+
if (!panel || ed_disabled) return;
|
|
1149
|
+
set_status("Saving…");
|
|
1150
|
+
const value = ed_value();
|
|
1151
|
+
const out = eol === "\r\n" ? value.replace(/\r?\n/g, "\r\n") : value;
|
|
1152
|
+
const r = await api(
|
|
1153
|
+
"PUT",
|
|
1154
|
+
`/_edit/source?path=${encodeURIComponent(open_path)}`,
|
|
1155
|
+
{ content: out, baseEtag: etag },
|
|
1156
|
+
);
|
|
1157
|
+
if (r.status === 409) {
|
|
1158
|
+
set_status(
|
|
1159
|
+
"Conflict: this file changed on disk since it was opened. Copy your changes, close the panel and re-open it to pick up the latest version.",
|
|
1160
|
+
true,
|
|
1161
|
+
);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
if (!r.ok || !r.json) {
|
|
1165
|
+
set_status((r.json && r.json.error) || "Save failed", true);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
etag = r.json.etag;
|
|
1169
|
+
saved_content = value;
|
|
1170
|
+
previewed = false;
|
|
1171
|
+
set_status(`Saved ${r.json.file}`);
|
|
1172
|
+
//-- re-render from disk so the page shows exactly what was persisted,
|
|
1173
|
+
//-- holding the reader's scroll position through the reload
|
|
1174
|
+
const restore_scroll = capture_page_scroll();
|
|
1175
|
+
try {
|
|
1176
|
+
await loadContentUrl(open_path);
|
|
1177
|
+
restore_scroll();
|
|
1178
|
+
} catch {
|
|
1179
|
+
window.location.reload();
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
const close_panel = () => {
|
|
1184
|
+
if (!panel) return;
|
|
1185
|
+
if (
|
|
1186
|
+
cm &&
|
|
1187
|
+
!ed_disabled &&
|
|
1188
|
+
ed_value() !== saved_content &&
|
|
1189
|
+
!window.confirm("Discard unsaved changes?")
|
|
1190
|
+
) {
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
if (preview_timer) clearTimeout(preview_timer);
|
|
1194
|
+
preview_timer = null;
|
|
1195
|
+
if (spell_timer) clearTimeout(spell_timer);
|
|
1196
|
+
spell_timer = null;
|
|
1197
|
+
if (scroll_sync_timer) clearTimeout(scroll_sync_timer);
|
|
1198
|
+
scroll_sync_timer = null;
|
|
1199
|
+
if (hl_timer) clearTimeout(hl_timer);
|
|
1200
|
+
hl_timer = null;
|
|
1201
|
+
document.removeEventListener("scroll", on_page_scroll, true);
|
|
1202
|
+
if (cm) {
|
|
1203
|
+
cm.destroy();
|
|
1204
|
+
cm = null;
|
|
1205
|
+
}
|
|
1206
|
+
panel.remove();
|
|
1207
|
+
panel = null;
|
|
1208
|
+
status_el = null;
|
|
1209
|
+
save_btn = null;
|
|
1210
|
+
ed_disabled = true;
|
|
1211
|
+
document.body.classList.remove("hb-edit-open");
|
|
1212
|
+
//-- if the page body is showing an unsaved preview, restore it from
|
|
1213
|
+
//-- disk — holding the reader's scroll position through the reload
|
|
1214
|
+
if (previewed) {
|
|
1215
|
+
previewed = false;
|
|
1216
|
+
const restore_scroll = capture_page_scroll();
|
|
1217
|
+
loadContentUrl(open_path).then(restore_scroll, () => {
|
|
1218
|
+
window.location.reload();
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
const build_panel = () => {
|
|
1224
|
+
panel = document.createElement("aside");
|
|
1225
|
+
panel.className = "hb-edit-panel";
|
|
1226
|
+
panel.innerHTML = `
|
|
1227
|
+
<div class="hb-edit-resizer" title="Drag to resize"></div>
|
|
1228
|
+
<div class="hb-edit-head">
|
|
1229
|
+
<span class="hb-edit-title">Edit page</span>
|
|
1230
|
+
<span>
|
|
1231
|
+
<button type="button" class="hb-edit-wrap" title="Toggle word wrap" aria-label="Toggle word wrap"><i class="bi bi-text-wrap" aria-hidden="true"></i></button>
|
|
1232
|
+
<button type="button" class="hb-edit-x" title="Close (Esc)" aria-label="Close editor">×</button>
|
|
1233
|
+
</span>
|
|
1234
|
+
</div>
|
|
1235
|
+
<div class="hb-edit-cm" aria-label="Page markdown source"></div>
|
|
1236
|
+
<div class="hb-edit-status" role="status"></div>
|
|
1237
|
+
<div class="hb-edit-foot">
|
|
1238
|
+
<button type="button" class="hb-edit-save">Save</button>
|
|
1239
|
+
<button type="button" class="hb-edit-cancel">Close</button>
|
|
1240
|
+
</div>`;
|
|
1241
|
+
document.body.appendChild(panel);
|
|
1242
|
+
document.body.classList.add("hb-edit-open");
|
|
1243
|
+
|
|
1244
|
+
status_el = panel.querySelector(".hb-edit-status");
|
|
1245
|
+
save_btn = panel.querySelector(".hb-edit-save");
|
|
1246
|
+
|
|
1247
|
+
const saved_width = Number.parseInt(pref("hdoc-edit-width"), 10);
|
|
1248
|
+
if (saved_width) {
|
|
1249
|
+
panel.style.width = `${Math.min(saved_width, window.innerWidth * 0.92)}px`;
|
|
1250
|
+
}
|
|
1251
|
+
const resizer = panel.querySelector(".hb-edit-resizer");
|
|
1252
|
+
resizer.addEventListener("pointerdown", (ev) => {
|
|
1253
|
+
ev.preventDefault();
|
|
1254
|
+
resizer.setPointerCapture(ev.pointerId);
|
|
1255
|
+
const on_move = (mv) => {
|
|
1256
|
+
const w = Math.min(
|
|
1257
|
+
Math.max(window.innerWidth - mv.clientX, 360),
|
|
1258
|
+
window.innerWidth * 0.92,
|
|
1259
|
+
);
|
|
1260
|
+
panel.style.width = `${w}px`;
|
|
1261
|
+
};
|
|
1262
|
+
const on_up = () => {
|
|
1263
|
+
resizer.removeEventListener("pointermove", on_move);
|
|
1264
|
+
resizer.removeEventListener("pointerup", on_up);
|
|
1265
|
+
set_pref("hdoc-edit-width", String(Math.round(panel.offsetWidth)));
|
|
1266
|
+
};
|
|
1267
|
+
resizer.addEventListener("pointermove", on_move);
|
|
1268
|
+
resizer.addEventListener("pointerup", on_up);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
save_btn.addEventListener("click", save);
|
|
1272
|
+
panel.querySelector(".hb-edit-cancel").addEventListener("click", close_panel);
|
|
1273
|
+
panel.querySelector(".hb-edit-x").addEventListener("click", close_panel);
|
|
1274
|
+
panel.addEventListener("keydown", (ev) => {
|
|
1275
|
+
if (ev.key === "Escape") {
|
|
1276
|
+
ev.stopPropagation();
|
|
1277
|
+
close_panel();
|
|
1278
|
+
} else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "s") {
|
|
1279
|
+
ev.preventDefault();
|
|
1280
|
+
save();
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
//-- Create the CodeMirror view inside the panel — same stack `hdoc edit`
|
|
1286
|
+
//-- uses (basicSetup: line numbers, fold gutter, history, search;
|
|
1287
|
+
//-- markdown language), plus a theme matching the panel's light/dark look.
|
|
1288
|
+
const build_editor = () => {
|
|
1289
|
+
const CM = window.HdocCM;
|
|
1290
|
+
wrap_comp = new CM.Compartment();
|
|
1291
|
+
ro_comp = new CM.Compartment();
|
|
1292
|
+
|
|
1293
|
+
const dark = document.documentElement.classList.contains("dark");
|
|
1294
|
+
const theme = CM.EditorView.theme(
|
|
1295
|
+
{
|
|
1296
|
+
"&": {
|
|
1297
|
+
height: "100%",
|
|
1298
|
+
fontSize: "13px",
|
|
1299
|
+
backgroundColor: "transparent",
|
|
1300
|
+
color: "inherit",
|
|
1301
|
+
},
|
|
1302
|
+
".cm-scroller": {
|
|
1303
|
+
fontFamily: 'Consolas, "SFMono-Regular", Menlo, monospace',
|
|
1304
|
+
lineHeight: "1.55",
|
|
1305
|
+
overflow: "auto",
|
|
1306
|
+
},
|
|
1307
|
+
".cm-gutters": {
|
|
1308
|
+
backgroundColor: "transparent",
|
|
1309
|
+
borderRight: dark
|
|
1310
|
+
? "1px solid rgba(255,255,255,.08)"
|
|
1311
|
+
: "1px solid rgba(0,0,0,.08)",
|
|
1312
|
+
color: dark ? "#6c757d" : "#adb5bd",
|
|
1313
|
+
},
|
|
1314
|
+
".cm-activeLine": {
|
|
1315
|
+
backgroundColor: dark
|
|
1316
|
+
? "rgba(255,255,255,.04)"
|
|
1317
|
+
: "rgba(0,0,0,.03)",
|
|
1318
|
+
},
|
|
1319
|
+
".cm-activeLineGutter": {
|
|
1320
|
+
backgroundColor: dark
|
|
1321
|
+
? "rgba(255,255,255,.06)"
|
|
1322
|
+
: "rgba(0,0,0,.05)",
|
|
1323
|
+
},
|
|
1324
|
+
"&.cm-focused": { outline: "none" },
|
|
1325
|
+
".cm-cursor": { borderLeftColor: dark ? "#dee2e6" : "#212529" },
|
|
1326
|
+
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
|
1327
|
+
backgroundColor: dark
|
|
1328
|
+
? "rgba(100,150,255,.25)"
|
|
1329
|
+
: "rgba(100,150,255,.22)",
|
|
1330
|
+
},
|
|
1331
|
+
},
|
|
1332
|
+
{ dark },
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
//-- CM's default syntax colors are designed for a light background —
|
|
1336
|
+
//-- markdown link URLs in particular are near-unreadable on dark. Give
|
|
1337
|
+
//-- dark mode its own palette (light keeps the CM defaults).
|
|
1338
|
+
const t = CM.tags;
|
|
1339
|
+
const dark_syntax = CM.syntaxHighlighting(
|
|
1340
|
+
CM.HighlightStyle.define([
|
|
1341
|
+
{ tag: [t.link, t.url], color: "#6ea8fe" },
|
|
1342
|
+
{ tag: t.heading, color: "#ffd777", fontWeight: "bold" },
|
|
1343
|
+
{ tag: t.strong, fontWeight: "bold" },
|
|
1344
|
+
{ tag: t.emphasis, fontStyle: "italic" },
|
|
1345
|
+
{ tag: t.monospace, color: "#98c379" },
|
|
1346
|
+
{ tag: t.quote, color: "#8fbc8f" },
|
|
1347
|
+
{ tag: [t.meta, t.processingInstruction, t.comment], color: "#8a939c" },
|
|
1348
|
+
{ tag: t.strikethrough, textDecoration: "line-through" },
|
|
1349
|
+
]),
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1352
|
+
const wrap_on = pref("hdoc-edit-wrap") !== "0";
|
|
1353
|
+
cm = new CM.EditorView({
|
|
1354
|
+
state: CM.EditorState.create({
|
|
1355
|
+
doc: "",
|
|
1356
|
+
extensions: [
|
|
1357
|
+
CM.basicSetup,
|
|
1358
|
+
CM.markdown(),
|
|
1359
|
+
theme,
|
|
1360
|
+
...(dark ? [dark_syntax] : []),
|
|
1361
|
+
build_highlight_ext(),
|
|
1362
|
+
build_image_dnd_ext(),
|
|
1363
|
+
//-- guarded: an older cached bundle may lack the lint exports
|
|
1364
|
+
...(CM.lintGutter ? [CM.lintGutter()] : []),
|
|
1365
|
+
wrap_comp.of(wrap_on ? CM.EditorView.lineWrapping : []),
|
|
1366
|
+
ro_comp.of([
|
|
1367
|
+
CM.EditorState.readOnly.of(true),
|
|
1368
|
+
CM.EditorView.editable.of(false),
|
|
1369
|
+
]),
|
|
1370
|
+
CM.EditorView.updateListener.of((u) => {
|
|
1371
|
+
if (u.docChanged && !ed_loading) {
|
|
1372
|
+
schedule_preview();
|
|
1373
|
+
schedule_spell();
|
|
1374
|
+
}
|
|
1375
|
+
}),
|
|
1376
|
+
],
|
|
1377
|
+
}),
|
|
1378
|
+
parent: panel.querySelector(".hb-edit-cm"),
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
//-- right-click inside the editor → insert-snippet menu
|
|
1382
|
+
panel
|
|
1383
|
+
.querySelector(".hb-edit-cm")
|
|
1384
|
+
.addEventListener("contextmenu", on_editor_ctx);
|
|
1385
|
+
|
|
1386
|
+
const wrap_btn = panel.querySelector(".hb-edit-wrap");
|
|
1387
|
+
wrap_btn.classList.toggle("hb-edit-wrap-on", wrap_on);
|
|
1388
|
+
wrap_btn.addEventListener("click", () => {
|
|
1389
|
+
const on = !wrap_btn.classList.contains("hb-edit-wrap-on");
|
|
1390
|
+
cm.dispatch({
|
|
1391
|
+
effects: wrap_comp.reconfigure(
|
|
1392
|
+
on ? CM.EditorView.lineWrapping : [],
|
|
1393
|
+
),
|
|
1394
|
+
});
|
|
1395
|
+
wrap_btn.classList.toggle("hb-edit-wrap-on", on);
|
|
1396
|
+
set_pref("hdoc-edit-wrap", on ? "1" : "0");
|
|
1397
|
+
cm.focus();
|
|
1398
|
+
});
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
const open_panel = async () => {
|
|
1402
|
+
if (panel) {
|
|
1403
|
+
close_panel();
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
open_path = logical_path();
|
|
1407
|
+
previewed = false;
|
|
1408
|
+
build_panel();
|
|
1409
|
+
save_btn.disabled = true;
|
|
1410
|
+
set_status("Loading…");
|
|
1411
|
+
try {
|
|
1412
|
+
await cm_ready;
|
|
1413
|
+
} catch {
|
|
1414
|
+
set_status(
|
|
1415
|
+
"Editor failed to load (js/hdoc-edit-cm.js missing — rebuild it from editor/cm-standalone.mjs)",
|
|
1416
|
+
true,
|
|
1417
|
+
);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
if (!panel) return; //-- closed while loading
|
|
1421
|
+
build_editor();
|
|
1422
|
+
const r = await api(
|
|
1423
|
+
"GET",
|
|
1424
|
+
`/_edit/source?path=${encodeURIComponent(open_path)}`,
|
|
1425
|
+
);
|
|
1426
|
+
if (!panel) return; //-- closed while loading
|
|
1427
|
+
if (!r.ok || !r.json) {
|
|
1428
|
+
set_status(
|
|
1429
|
+
(r.json && r.json.error) || "No markdown source found for this page",
|
|
1430
|
+
true,
|
|
1431
|
+
);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
eol = r.json.content.includes("\r\n") ? "\r\n" : "\n";
|
|
1435
|
+
open_file_rel = r.json.file || "";
|
|
1436
|
+
server_diags = [];
|
|
1437
|
+
typo_diags = [];
|
|
1438
|
+
ed_set_disabled(false);
|
|
1439
|
+
//-- LF-normalize for the buffer; Save re-applies the original EOLs
|
|
1440
|
+
ed_set_value(r.json.content.replace(/\r\n/g, "\n"));
|
|
1441
|
+
saved_content = ed_value();
|
|
1442
|
+
etag = r.json.etag;
|
|
1443
|
+
save_btn.disabled = false;
|
|
1444
|
+
set_status(`Editing ${r.json.file}`);
|
|
1445
|
+
//-- open the editor at the content the reader is currently looking at
|
|
1446
|
+
//-- (caret placed on the matched line so focus lands there too), and
|
|
1447
|
+
//-- keep following while the page (not the editor) scrolls
|
|
1448
|
+
sync_editor_scroll(true);
|
|
1449
|
+
cm.focus();
|
|
1450
|
+
document.addEventListener("scroll", on_page_scroll, true);
|
|
1451
|
+
//-- initial lint + typo passes (buffer == disk, nothing is injected)
|
|
1452
|
+
render_preview();
|
|
1453
|
+
start_spell();
|
|
1454
|
+
schedule_spell(0);
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
const inject_styles = () => {
|
|
1458
|
+
const css = `
|
|
1459
|
+
.hb-edit-panel {
|
|
1460
|
+
position: fixed; top: 0; right: 0; bottom: 0; z-index: 3001;
|
|
1461
|
+
width: min(680px, 92vw); display: flex; flex-direction: column;
|
|
1462
|
+
background: #ffffff; color: #212529;
|
|
1463
|
+
border-left: 1px solid rgba(0,0,0,.15);
|
|
1464
|
+
box-shadow: -8px 0 28px rgba(0,0,0,.22);
|
|
1465
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1466
|
+
}
|
|
1467
|
+
html.dark .hb-edit-panel { background: #1b1e21; color: #dee2e6; border-left-color: rgba(255,255,255,.12); }
|
|
1468
|
+
.hb-edit-head {
|
|
1469
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
1470
|
+
padding: 10px 14px; border-bottom: 1px solid rgba(0,0,0,.1); font-weight: 600;
|
|
1471
|
+
}
|
|
1472
|
+
html.dark .hb-edit-head { border-bottom-color: rgba(255,255,255,.1); }
|
|
1473
|
+
.hb-edit-x {
|
|
1474
|
+
border: none; background: none; font-size: 22px; cursor: pointer;
|
|
1475
|
+
color: inherit; padding: 0 4px; line-height: 1;
|
|
1476
|
+
}
|
|
1477
|
+
.hb-edit-cm { flex: 1 1 auto; overflow: hidden; }
|
|
1478
|
+
.hb-edit-cm .cm-editor { height: 100%; }
|
|
1479
|
+
.hb-edit-hl-line { animation: hb-edit-hl-fade 2.5s ease-out forwards; }
|
|
1480
|
+
@keyframes hb-edit-hl-fade {
|
|
1481
|
+
0%, 25% { background-color: rgba(255, 190, 60, .55); }
|
|
1482
|
+
100% { background-color: transparent; }
|
|
1483
|
+
}
|
|
1484
|
+
html.dark .hb-edit-hl-line { animation-name: hb-edit-hl-fade-dark; }
|
|
1485
|
+
@keyframes hb-edit-hl-fade-dark {
|
|
1486
|
+
0%, 25% { background-color: rgba(255, 190, 60, .30); }
|
|
1487
|
+
100% { background-color: transparent; }
|
|
1488
|
+
}
|
|
1489
|
+
.hb-edit-resizer {
|
|
1490
|
+
position: absolute; left: -3px; top: 0; bottom: 0; width: 8px;
|
|
1491
|
+
cursor: ew-resize; touch-action: none; z-index: 1;
|
|
1492
|
+
}
|
|
1493
|
+
.hb-edit-resizer:hover { background: rgba(128,128,128,.25); }
|
|
1494
|
+
.hb-edit-wrap {
|
|
1495
|
+
border: none; background: none; cursor: pointer; color: inherit;
|
|
1496
|
+
font-size: 16px; padding: 0 8px; opacity: .45; vertical-align: 1px;
|
|
1497
|
+
}
|
|
1498
|
+
.hb-edit-wrap.hb-edit-wrap-on { opacity: 1; color: #b8860b; }
|
|
1499
|
+
html.dark .hb-edit-wrap.hb-edit-wrap-on { color: #ffd777; }
|
|
1500
|
+
.hb-edit-status {
|
|
1501
|
+
padding: 6px 14px; font-size: 12px; min-height: 26px; opacity: .85;
|
|
1502
|
+
border-top: 1px solid rgba(0,0,0,.08);
|
|
1503
|
+
}
|
|
1504
|
+
html.dark .hb-edit-status { border-top-color: rgba(255,255,255,.08); }
|
|
1505
|
+
.hb-edit-status-err { color: #c62828; opacity: 1; }
|
|
1506
|
+
html.dark .hb-edit-status-err { color: #ff8a80; }
|
|
1507
|
+
.hb-edit-foot {
|
|
1508
|
+
display: flex; gap: 8px; padding: 10px 14px 14px;
|
|
1509
|
+
}
|
|
1510
|
+
.hb-edit-foot button {
|
|
1511
|
+
padding: 6px 18px; border-radius: 6px; cursor: pointer; font-size: 14px;
|
|
1512
|
+
border: 1px solid rgba(0,0,0,.2); background: none; color: inherit;
|
|
1513
|
+
}
|
|
1514
|
+
html.dark .hb-edit-foot button { border-color: rgba(255,255,255,.25); }
|
|
1515
|
+
.hb-edit-save { background: #ffd777 !important; color: #6b4400 !important; border-color: transparent !important; font-weight: 600; }
|
|
1516
|
+
html.dark .hb-edit-save { background: #7a5200 !important; color: #ffe2a0 !important; }
|
|
1517
|
+
.hb-edit-save:disabled { opacity: .5; cursor: default; }
|
|
1518
|
+
.hb-edit-ctx {
|
|
1519
|
+
position: fixed; z-index: 3002; padding: 4px;
|
|
1520
|
+
background: #ffffff; border: 1px solid rgba(0,0,0,.15); border-radius: 8px;
|
|
1521
|
+
box-shadow: 0 6px 20px rgba(0,0,0,.2);
|
|
1522
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1523
|
+
max-height: 70vh; overflow-y: auto;
|
|
1524
|
+
}
|
|
1525
|
+
.hb-edit-ctx-sep { height: 1px; margin: 4px 8px; background: rgba(0,0,0,.1); }
|
|
1526
|
+
html.dark .hb-edit-ctx-sep { background: rgba(255,255,255,.12); }
|
|
1527
|
+
html.dark .hb-edit-ctx { background: #23272b; border-color: rgba(255,255,255,.15); }
|
|
1528
|
+
.hb-edit-ctx-item {
|
|
1529
|
+
display: block; width: 100%; text-align: left; white-space: nowrap;
|
|
1530
|
+
border: none; background: none; color: #212529; cursor: pointer;
|
|
1531
|
+
padding: 7px 12px; border-radius: 6px; font-size: 13px;
|
|
1532
|
+
}
|
|
1533
|
+
html.dark .hb-edit-ctx-item { color: #dee2e6; }
|
|
1534
|
+
.hb-edit-ctx-item:hover { background: rgba(0,0,0,.06); }
|
|
1535
|
+
html.dark .hb-edit-ctx-item:hover { background: rgba(255,255,255,.08); }
|
|
1536
|
+
.hb-edit-ctx-item .bi { color: #b8860b; margin-right: 6px; }
|
|
1537
|
+
html.dark .hb-edit-ctx-item .bi { color: #ffd777; }
|
|
1538
|
+
`;
|
|
1539
|
+
const style = document.createElement("style");
|
|
1540
|
+
style.textContent = css;
|
|
1541
|
+
document.head.appendChild(style);
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
//-- Lazy-load the CodeMirror bundle — only edit mode pays its weight.
|
|
1545
|
+
const load_cm_bundle = () =>
|
|
1546
|
+
new Promise((resolve, reject) => {
|
|
1547
|
+
if (window.HdocCM) return resolve();
|
|
1548
|
+
const script = document.createElement("script");
|
|
1549
|
+
script.src = "js/hdoc-edit-cm.js";
|
|
1550
|
+
script.onload = () =>
|
|
1551
|
+
window.HdocCM ? resolve() : reject(new Error("bundle bad"));
|
|
1552
|
+
script.onerror = () => reject(new Error("bundle load failed"));
|
|
1553
|
+
document.head.appendChild(script);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
const init = async () => {
|
|
1557
|
+
let enabled = false;
|
|
1558
|
+
try {
|
|
1559
|
+
const r = await fetch("/_edit/mode", {
|
|
1560
|
+
headers: { accept: "application/json" },
|
|
1561
|
+
});
|
|
1562
|
+
const ct = r.headers.get("content-type") || "";
|
|
1563
|
+
if (r.ok && ct.includes("json")) {
|
|
1564
|
+
enabled = (await r.json()).enabled === true;
|
|
1565
|
+
}
|
|
1566
|
+
} catch {
|
|
1567
|
+
/* not in edit mode / not hdoc serve */
|
|
1568
|
+
}
|
|
1569
|
+
if (!enabled) return;
|
|
1570
|
+
//-- tiny API for the Edit Mode suite (js/hdoc-edit-book.js): open the
|
|
1571
|
+
//-- page editor programmatically (Files tab, Edit Mode toggle).
|
|
1572
|
+
//-- open_panel() TOGGLES, so open()/close() gate on panel state.
|
|
1573
|
+
window.HdocEditInline = {
|
|
1574
|
+
open: () => {
|
|
1575
|
+
if (!panel) open_panel();
|
|
1576
|
+
},
|
|
1577
|
+
close: () => {
|
|
1578
|
+
if (panel) close_panel();
|
|
1579
|
+
},
|
|
1580
|
+
isOpen: () => !!panel,
|
|
1581
|
+
//-- insert text at the caret of the open editor (Files tab's
|
|
1582
|
+
//-- "Insert into open page"); false when no editable buffer
|
|
1583
|
+
insert: (text) => ed_insert_at(String(text)),
|
|
1584
|
+
};
|
|
1585
|
+
cm_ready = load_cm_bundle();
|
|
1586
|
+
cm_ready.catch(() => {}); //-- surfaced in open_panel, not unhandled
|
|
1587
|
+
inject_styles();
|
|
1588
|
+
//-- right-click menu on page content (active whether or not the panel
|
|
1589
|
+
//-- is open — it opens the panel on demand)
|
|
1590
|
+
document.addEventListener("contextmenu", on_context_menu);
|
|
1591
|
+
document.addEventListener("click", () => {
|
|
1592
|
+
close_ctx_menu();
|
|
1593
|
+
close_ed_ctx();
|
|
1594
|
+
});
|
|
1595
|
+
document.addEventListener("keydown", (ev) => {
|
|
1596
|
+
if (ev.key === "Escape") {
|
|
1597
|
+
close_ctx_menu();
|
|
1598
|
+
close_ed_ctx();
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
//-- external file changes (pushed over SSE by js/hdoc-edit-book.js
|
|
1602
|
+
//-- while Edit Mode is on): if the OPEN file changed on disk, refresh
|
|
1603
|
+
//-- a clean buffer in place; warn on a dirty one (its etag will 409
|
|
1604
|
+
//-- rather than clobber the external edit)
|
|
1605
|
+
window.addEventListener("hdoc-ext-change", async (ev) => {
|
|
1606
|
+
const d = (ev && ev.detail) || {};
|
|
1607
|
+
if (!panel || !cm || ed_disabled) return;
|
|
1608
|
+
if (!open_file_rel || d.path !== open_file_rel) return;
|
|
1609
|
+
if (!d.exists) {
|
|
1610
|
+
set_status("This file was deleted on disk.", true);
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (ed_value() !== saved_content) {
|
|
1614
|
+
set_status(
|
|
1615
|
+
"This file changed on disk while you have unsaved changes — saving now will report a conflict.",
|
|
1616
|
+
true,
|
|
1617
|
+
);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
const r = await api(
|
|
1621
|
+
"GET",
|
|
1622
|
+
`/_edit/source?path=${encodeURIComponent(open_path)}`,
|
|
1623
|
+
);
|
|
1624
|
+
if (!panel || !r.ok || !r.json) return;
|
|
1625
|
+
eol = r.json.content.includes("\r\n") ? "\r\n" : "\n";
|
|
1626
|
+
ed_set_value(r.json.content.replace(/\r\n/g, "\n"));
|
|
1627
|
+
saved_content = ed_value();
|
|
1628
|
+
etag = r.json.etag;
|
|
1629
|
+
set_status("Reloaded — this file changed on disk.");
|
|
1630
|
+
//-- refresh the rendered page too, holding scroll
|
|
1631
|
+
const restore_scroll = capture_page_scroll();
|
|
1632
|
+
loadContentUrl(open_path).then(restore_scroll, () => {});
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
//-- SPA navigation detection: back/forward plus the history writes the
|
|
1636
|
+
//-- viewer makes on nav-link clicks
|
|
1637
|
+
window.addEventListener("popstate", () => setTimeout(on_nav_change, 0));
|
|
1638
|
+
for (const fn of ["pushState", "replaceState"]) {
|
|
1639
|
+
const orig = history[fn].bind(history);
|
|
1640
|
+
history[fn] = (...args) => {
|
|
1641
|
+
const r = orig(...args);
|
|
1642
|
+
setTimeout(on_nav_change, 0);
|
|
1643
|
+
return r;
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
init();
|
|
1649
|
+
})();
|