hdoc-tools 0.64.0 → 0.66.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-edit.js +3 -1
- package/hdoc-serve-validate.js +544 -0
- package/hdoc-serve.js +658 -1
- package/hdoc-toc.js +31 -10
- package/package.json +2 -1
- package/ui/index.html +5 -2
- package/ui/js/bootstrap.js +2 -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 +16 -16
- package/ui/js/hdoc-edit-inline.js +676 -4
- package/ui/js/hdoc-edit-spell.js +17 -0
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
let status_el = null;
|
|
30
30
|
let save_btn = null;
|
|
31
31
|
let open_path = "";
|
|
32
|
+
let open_file_rel = ""; //-- source-relative path of the open md file
|
|
32
33
|
let etag = "";
|
|
33
34
|
let saved_content = "";
|
|
34
35
|
let preview_timer = null;
|
|
@@ -123,6 +124,435 @@
|
|
|
123
124
|
});
|
|
124
125
|
};
|
|
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
|
+
|
|
126
556
|
//-- --- jump flash ------------------------------------------------------
|
|
127
557
|
//-- Every editor jump (open-sync, right-click) flashes the target line so
|
|
128
558
|
//-- the eye lands on the right spot: a line decoration with a CSS fade
|
|
@@ -529,16 +959,180 @@
|
|
|
529
959
|
restore_scroll();
|
|
530
960
|
};
|
|
531
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
|
+
|
|
532
1117
|
const render_preview = async () => {
|
|
533
1118
|
preview_timer = null;
|
|
1119
|
+
const sent = ed_value();
|
|
534
1120
|
const r = await api("POST", "/_edit/preview", {
|
|
535
1121
|
path: open_path,
|
|
536
|
-
content:
|
|
1122
|
+
content: sent,
|
|
537
1123
|
});
|
|
538
1124
|
if (!r.ok || !r.json || typeof r.json.html !== "string") {
|
|
539
1125
|
set_status((r.json && r.json.error) || "Preview failed", true);
|
|
540
1126
|
return;
|
|
541
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;
|
|
542
1136
|
previewed = true;
|
|
543
1137
|
inject_fragment(r.json.html);
|
|
544
1138
|
};
|
|
@@ -598,6 +1192,8 @@
|
|
|
598
1192
|
}
|
|
599
1193
|
if (preview_timer) clearTimeout(preview_timer);
|
|
600
1194
|
preview_timer = null;
|
|
1195
|
+
if (spell_timer) clearTimeout(spell_timer);
|
|
1196
|
+
spell_timer = null;
|
|
601
1197
|
if (scroll_sync_timer) clearTimeout(scroll_sync_timer);
|
|
602
1198
|
scroll_sync_timer = null;
|
|
603
1199
|
if (hl_timer) clearTimeout(hl_timer);
|
|
@@ -763,19 +1359,30 @@
|
|
|
763
1359
|
theme,
|
|
764
1360
|
...(dark ? [dark_syntax] : []),
|
|
765
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()] : []),
|
|
766
1365
|
wrap_comp.of(wrap_on ? CM.EditorView.lineWrapping : []),
|
|
767
1366
|
ro_comp.of([
|
|
768
1367
|
CM.EditorState.readOnly.of(true),
|
|
769
1368
|
CM.EditorView.editable.of(false),
|
|
770
1369
|
]),
|
|
771
1370
|
CM.EditorView.updateListener.of((u) => {
|
|
772
|
-
if (u.docChanged && !ed_loading)
|
|
1371
|
+
if (u.docChanged && !ed_loading) {
|
|
1372
|
+
schedule_preview();
|
|
1373
|
+
schedule_spell();
|
|
1374
|
+
}
|
|
773
1375
|
}),
|
|
774
1376
|
],
|
|
775
1377
|
}),
|
|
776
1378
|
parent: panel.querySelector(".hb-edit-cm"),
|
|
777
1379
|
});
|
|
778
1380
|
|
|
1381
|
+
//-- right-click inside the editor → insert-snippet menu
|
|
1382
|
+
panel
|
|
1383
|
+
.querySelector(".hb-edit-cm")
|
|
1384
|
+
.addEventListener("contextmenu", on_editor_ctx);
|
|
1385
|
+
|
|
779
1386
|
const wrap_btn = panel.querySelector(".hb-edit-wrap");
|
|
780
1387
|
wrap_btn.classList.toggle("hb-edit-wrap-on", wrap_on);
|
|
781
1388
|
wrap_btn.addEventListener("click", () => {
|
|
@@ -825,6 +1432,9 @@
|
|
|
825
1432
|
return;
|
|
826
1433
|
}
|
|
827
1434
|
eol = r.json.content.includes("\r\n") ? "\r\n" : "\n";
|
|
1435
|
+
open_file_rel = r.json.file || "";
|
|
1436
|
+
server_diags = [];
|
|
1437
|
+
typo_diags = [];
|
|
828
1438
|
ed_set_disabled(false);
|
|
829
1439
|
//-- LF-normalize for the buffer; Save re-applies the original EOLs
|
|
830
1440
|
ed_set_value(r.json.content.replace(/\r\n/g, "\n"));
|
|
@@ -838,6 +1448,10 @@
|
|
|
838
1448
|
sync_editor_scroll(true);
|
|
839
1449
|
cm.focus();
|
|
840
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);
|
|
841
1455
|
};
|
|
842
1456
|
|
|
843
1457
|
const inject_styles = () => {
|
|
@@ -906,7 +1520,10 @@ html.dark .hb-edit-save { background: #7a5200 !important; color: #ffe2a0 !import
|
|
|
906
1520
|
background: #ffffff; border: 1px solid rgba(0,0,0,.15); border-radius: 8px;
|
|
907
1521
|
box-shadow: 0 6px 20px rgba(0,0,0,.2);
|
|
908
1522
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1523
|
+
max-height: 70vh; overflow-y: auto;
|
|
909
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); }
|
|
910
1527
|
html.dark .hb-edit-ctx { background: #23272b; border-color: rgba(255,255,255,.15); }
|
|
911
1528
|
.hb-edit-ctx-item {
|
|
912
1529
|
display: block; width: 100%; text-align: left; white-space: nowrap;
|
|
@@ -950,16 +1567,71 @@ html.dark .hb-edit-ctx-item .bi { color: #ffd777; }
|
|
|
950
1567
|
/* not in edit mode / not hdoc serve */
|
|
951
1568
|
}
|
|
952
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
|
+
};
|
|
953
1585
|
cm_ready = load_cm_bundle();
|
|
954
1586
|
cm_ready.catch(() => {}); //-- surfaced in open_panel, not unhandled
|
|
955
1587
|
inject_styles();
|
|
956
1588
|
//-- right-click menu on page content (active whether or not the panel
|
|
957
1589
|
//-- is open — it opens the panel on demand)
|
|
958
1590
|
document.addEventListener("contextmenu", on_context_menu);
|
|
959
|
-
document.addEventListener("click",
|
|
1591
|
+
document.addEventListener("click", () => {
|
|
1592
|
+
close_ctx_menu();
|
|
1593
|
+
close_ed_ctx();
|
|
1594
|
+
});
|
|
960
1595
|
document.addEventListener("keydown", (ev) => {
|
|
961
|
-
if (ev.key === "Escape")
|
|
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, () => {});
|
|
962
1633
|
});
|
|
1634
|
+
|
|
963
1635
|
//-- SPA navigation detection: back/forward plus the history writes the
|
|
964
1636
|
//-- viewer makes on nav-link clicks
|
|
965
1637
|
window.addEventListener("popstate", () => setTimeout(on_nav_change, 0));
|