@openpresentation/opf-editor 0.26.4 → 0.26.6

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.
@@ -1,17 +1,19 @@
1
- import { fromYaml, scanYamlComments, toYaml } from "@openpresentation/opf/yaml";
2
- import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
1
+ import { scanYamlComments, toYaml } from "@openpresentation/opf/yaml";
2
+ import { toMarkdown } from "@openpresentation/opf/markdown";
3
3
  import { mountJsonCodeEditor } from "./json-editor.js";
4
4
  import { MAX_EXACT_SOURCE_LENGTH, createSourceMemory, updateJsonSource } from "./exact-source.js";
5
5
  import { lineForPath, offsetOfLine } from "./source-lines.js";
6
6
  import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
7
7
  import { formatSource } from "./source-format.js";
8
8
  import { buildProblems, summarize } from "./source-problems.js";
9
- import { analyzeSyntax, findDuplicateKeys } from "./source-syntax.js";
10
- import { createSchemaValue, schemaAtPath, schemaVariants } from "./schema.js";
11
- import { catalogKindAt } from "@openpresentation/opf/schema";
9
+ import { checkSyntaxOnly, markdownNotes, markdownNotesOf, readSource as readText } from "./source-check.js";
10
+ import { createSourceChecker } from "./source-checker.js";
11
+ import { detailsFor } from "./source-details.js";
12
+ import { schemaAtPath, schemaVariants } from "./schema.js";
12
13
  import { locateText } from "./source-ranges.js";
13
- import { scanMarkdown } from "@openpresentation/opf/source";
14
+ import { scanMarkdown, starterValue } from "@openpresentation/opf/source";
14
15
  import { listCatalogRecords } from "./catalogs.js";
16
+ import { documentSymbols, symbolAt } from "./source-symbols.js";
15
17
 
16
18
  // The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
17
19
  // and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
@@ -34,6 +36,8 @@ const ICONS = {
34
36
  Check: '<path d="M20 6 9 17l-5-5"/>',
35
37
  CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
36
38
  Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
39
+ CircleHelp: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
40
+ List: '<path d="M3 6h.01"/><path d="M8 6h13"/><path d="M3 12h.01"/><path d="M8 12h13"/><path d="M3 18h.01"/><path d="M8 18h13"/>',
37
41
  Sparkles: '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>',
38
42
  };
39
43
  function icon(name, size = 14) {
@@ -75,20 +79,17 @@ function same(a, b) {
75
79
  const SEVERITY = { error: 0, warning: 1, info: 2 };
76
80
  const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
77
81
 
78
- /** Read the text of one format. `{ deck }` on success; `findings` carry the syntax errors (`syntax: true`) or the OPF findings of the parsed deck. */
79
- function readText(format, text, catalogs) {
80
- if (format === "json") {
81
- const problems = analyzeSyntax(text);
82
- if (problems.length) return { syntax: true, findings: problems.map((problem) => normalizeFinding(problem, "syntax")) };
83
- const deck = JSON.parse(text);
84
- const duplicates = findDuplicateKeys(text);
85
- if (duplicates.length) return { syntax: true, findings: duplicates };
86
- return { deck, findings: [] };
82
+ /** The rule's explanation as the body of a row's Details: what it checks, why, what to do, an example and the documentation link. */
83
+ function fillDetails(body, details) {
84
+ const line = (label, text) => { const row = el("p", "opf-source-finding-explain-line"); row.append(Object.assign(el("strong"), { textContent: `${label} ` }), document.createTextNode(text)); return row; };
85
+ body.append(line("Checks:", details.summary), line("Why:", details.why), line("Fix:", details.fix));
86
+ if (details.schema) body.append(line("Schema:", details.schema));
87
+ if (details.example) {
88
+ const example = el("div", "opf-source-finding-explain-example");
89
+ example.append(Object.assign(el("code"), { textContent: details.example.problem || "(empty)" }), Object.assign(el("span", "opf-source-finding-explain-arrow"), { textContent: " becomes " }), Object.assign(el("code"), { textContent: details.example.fixed }));
90
+ body.append(example);
87
91
  }
88
- const result = format === "yaml" ? (fromYaml(text, { catalogs })) : fromMarkdown(text, { catalogs });
89
- const syntax = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
90
- if (syntax.length) return { syntax: true, findings: syntax.map((finding) => normalizeFinding(finding, "syntax")) };
91
- return { deck: result.presentation, findings: result.findings.map((finding) => normalizeFinding(finding, "schema")) };
92
+ if (details.docs && /^https?:/.test(details.docs)) body.append(Object.assign(el("a", "opf-source-finding-explain-link", { href: details.docs, target: "_blank", rel: "noopener noreferrer" }), { textContent: "Documentation" }));
92
93
  }
93
94
 
94
95
  /**
@@ -126,6 +127,14 @@ export function createSourcePane(container, options = {}) {
126
127
  // The problems now shown (rows of source-problems.js), the document they were checked as, and the pause timers of the count announcement and the
127
128
  // syntax pass.
128
129
  const rowOf = new WeakMap();
130
+ // The problems whose Details are open (by rule, path and message), so a redraw of the panel keeps them open, and a number per key for the element ids.
131
+ const detailsOpen = new Set();
132
+ const detailsKeys = { ids: new Map(), id(key) { if (!this.ids.has(key)) this.ids.set(key, this.ids.size + 1); return this.ids.get(key); } };
133
+ // The text checks may run in a Web Worker (options.worker): `checker` is null without one. `checkToken` numbers the check the pane still wants (a flush,
134
+ // an external change or a newer edit cancels the one in flight), `checkBusy` and `syntaxBusy` say the newest worker request of each kind is in flight, `stats` what answered the applied edits.
135
+ const checker = options.worker ? createSourceChecker({ worker: options.worker, onError: (error) => options.onError?.(error) }) : null;
136
+ let checkToken = 0, syntaxToken = 0, checkBusy = false, syntaxBusy = false;
137
+ const stats = { worker: 0, inline: 0 };
129
138
  let problemRows = [], lastDraft = null, lastSummary = "No problems", summaryTimer = null, syntaxTimer = null, recheckTimer = null;
130
139
  // The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
131
140
  // (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
@@ -176,6 +185,10 @@ export function createSourcePane(container, options = {}) {
176
185
  const canonicalAction = actionButton("Canonicalize with OPF", "Sparkles", "canonicalize");
177
186
  canonicalAction.title = "Canonicalize with OPF: rewrite the text the way OPF writes it (drops comments)";
178
187
  const copyAction = actionButton("Copy source", "Copy", "copy");
188
+ // The outline panel is opt-in (`outline: true` adds the toggle, `"open"` also shows the panel); the symbol picker (Cmd/Ctrl+Shift+O) is always there.
189
+ const outlineAction = options.outline ? actionButton("Outline", "List", "outline") : null;
190
+ const shortcutsAction = actionButton("Keyboard shortcuts", "CircleHelp", "shortcuts");
191
+ shortcutsAction.setAttribute("aria-haspopup", "dialog");
179
192
  const downloadAction = actionButton("Download source", "Download", "download");
180
193
  bar.append(file, actions);
181
194
 
@@ -195,7 +208,12 @@ export function createSourcePane(container, options = {}) {
195
208
  const list = el("ul", "opf-source-findings-list", { id: `${id}-findings` });
196
209
  findingsRegion.append(toggle, list);
197
210
 
198
- root.append(bar, confirm, code, findingsRegion, status);
211
+ const outlinePanel = options.outline ? el("nav", "opf-source-outline", { "aria-label": "Document outline", hidden: "" }) : null;
212
+ const outlineList = outlinePanel ? el("ul", "opf-source-outline-list") : null;
213
+ outlinePanel?.append(outlineList);
214
+ let body = null;
215
+ if (outlinePanel) { body = el("div", "opf-source-body"); body.append(outlinePanel, code); }
216
+ root.append(bar, confirm, body ?? code, findingsRegion, status);
199
217
  container.append(root);
200
218
 
201
219
  // ---- Document <-> text --------------------------------------------------------------------------------------------
@@ -212,14 +230,6 @@ export function createSourcePane(container, options = {}) {
212
230
  const result = toMarkdown(value);
213
231
  return { text: result.markdown, notes: markdownNotes(result.report) };
214
232
  }
215
- function markdownNotes(report) {
216
- const notes = report.embedded.map((part) => normalizeFinding({ path: part.path, message: `Markdown has no syntax for this, so it is kept as YAML in a fenced block (${part.reason}).`, severity: "warning" }, "format"));
217
- for (const entry of report.loss) {
218
- const at = entry.indexOf(": ");
219
- notes.push(normalizeFinding({ path: at > 0 ? entry.slice(0, at) : "", message: `Left out of the Markdown: ${at > 0 ? entry.slice(at + 2) : entry}.`, severity: "warning" }, "format"));
220
- }
221
- return notes;
222
- }
223
233
 
224
234
  if (typeof options.source === "string") {
225
235
  try { if (same(JSON.parse(options.source), deck())) jsonText = options.source; } catch { /* Use the session's document. */ }
@@ -244,53 +254,179 @@ export function createSourcePane(container, options = {}) {
244
254
  readOnly,
245
255
  completions: options.completions !== false,
246
256
  smartListEnter: options.smartListEnter,
257
+ resolvePreview: options.resolvePreview,
247
258
  catalogs: options.catalogs ?? editor.catalogs,
248
259
  describedBy: `${id}-findings`,
249
260
  onChange: () => schedule(),
250
261
  onFormat: () => formatDocument(),
251
262
  onFocusProblems: () => focusProblems(),
263
+ onSelectionChange: () => scheduleOutline(),
264
+ onShowShortcuts: options.onShowShortcuts,
252
265
  // The pane supplies the problems itself (every finding, with ranges and fixes).
253
266
  syntaxProblems: false,
254
267
  historyFallback: { undo: () => stepReplace("undo"), redo: () => stepReplace("redo") },
255
268
  onError: (error) => options.onError?.(error),
256
269
  });
257
270
 
271
+ // ---- Outline ------------------------------------------------------------------------------------------------------
272
+ let outlineTimer = null, outlineSymbols = [], outlineKey = "";
273
+ const outlineButtons = () => [...(outlineList?.querySelectorAll(".opf-source-outline-item") ?? [])];
274
+ function markOutline() {
275
+ if (!outlineList) return;
276
+ const caret = control.api.getSelection()[0], here = symbolAt(outlineSymbols, caret);
277
+ for (const button of outlineButtons()) {
278
+ const on = button.dataset.id === here?.id;
279
+ if (on) button.setAttribute("aria-current", "true"); else button.removeAttribute("aria-current");
280
+ }
281
+ }
282
+ function renderOutline() {
283
+ outlineTimer = null;
284
+ if (!outlinePanel || destroyed || outlinePanel.hidden) return;
285
+ outlineSymbols = documentSymbols(format, control.api.getValue());
286
+ const key = JSON.stringify(outlineSymbols.map((symbol) => [symbol.id, symbol.label, symbol.detail, symbol.depth, symbol.from, symbol.to]));
287
+ if (key !== outlineKey) {
288
+ outlineKey = key;
289
+ outlineList.replaceChildren(...outlineSymbols.map((symbol) => {
290
+ const item = el("li", "opf-source-outline-row");
291
+ const button = el("button", `opf-source-outline-item is-${symbol.kind}`, { type: "button", "data-id": symbol.id, "data-kind": symbol.kind, tabindex: "-1" });
292
+ button.style.paddingLeft = `${8 + symbol.depth * 12}px`;
293
+ button.append(Object.assign(el("span", "opf-source-outline-label"), { textContent: symbol.label }));
294
+ if (symbol.detail) button.append(Object.assign(el("span", "opf-source-outline-detail"), { textContent: symbol.detail }));
295
+ button.addEventListener("click", () => { control.api.setSelection(symbol.from, symbol.to); control.api.focus(); control.api.say(`${symbol.label}, line ${symbol.line}`); });
296
+ item.append(button);
297
+ return item;
298
+ }));
299
+ if (!outlineSymbols.length) outlineList.append(Object.assign(el("li", "opf-source-outline-empty"), { textContent: "No slides or sections yet." }));
300
+ outlineButtons()[0]?.setAttribute("tabindex", "0");
301
+ }
302
+ markOutline();
303
+ }
304
+ /** Redraw the outline soon (after the text changed) or only follow the caret. */
305
+ let outlineMarkTimer = null;
306
+ function scheduleOutline(textChanged) {
307
+ if (!outlinePanel || outlinePanel.hidden || destroyed) return;
308
+ if (textChanged) { clearTimeout(outlineTimer); outlineTimer = setTimeout(renderOutline, 300); return; }
309
+ clearTimeout(outlineMarkTimer);
310
+ outlineMarkTimer = setTimeout(() => { outlineMarkTimer = null; markOutline(); }, 120);
311
+ }
312
+ function setOutlineOpen(open) {
313
+ if (!outlinePanel) return false;
314
+ outlinePanel.hidden = !open;
315
+ outlineAction?.setAttribute("aria-expanded", String(open));
316
+ root.toggleAttribute("data-outline", open);
317
+ if (open) { outlineKey = ""; renderOutline(); }
318
+ return true;
319
+ }
320
+ outlineAction?.setAttribute("aria-expanded", "false");
321
+ outlineAction?.setAttribute("aria-controls", `${id}-outline`);
322
+ outlinePanel?.setAttribute("id", `${id}-outline`);
323
+ outlineAction?.addEventListener("click", () => { setOutlineOpen(outlinePanel.hidden); if (!outlinePanel.hidden) outlineButtons()[0]?.focus(); });
324
+ outlineList?.addEventListener("keydown", (event) => {
325
+ const buttons = outlineButtons(), at = buttons.indexOf(document.activeElement);
326
+ const move = (to) => { event.preventDefault(); const next = buttons[Math.max(0, Math.min(buttons.length - 1, to))]; buttons.forEach((button) => button.setAttribute("tabindex", "-1")); next?.setAttribute("tabindex", "0"); next?.focus(); };
327
+ if (event.key === "ArrowDown") move(at + 1);
328
+ else if (event.key === "ArrowUp") move(at - 1);
329
+ else if (event.key === "Home") move(0);
330
+ else if (event.key === "End") move(buttons.length - 1);
331
+ else if (event.key === "Escape") { event.preventDefault(); control.api.focus(); }
332
+ });
333
+
258
334
  // ---- Applying edits -----------------------------------------------------------------------------------------------
259
335
  function schedule() {
336
+ scheduleOutline(true);
260
337
  closeConfirm(false);
261
338
  clearTimeout(timer);
262
339
  clearTimeout(syntaxTimer);
263
340
  if (delay === 0) { flush(); return; }
264
- timer = setTimeout(flush, delay);
341
+ timer = setTimeout(checker ? flushViaWorker : flush, delay);
265
342
  // Syntax problems come first, 150 ms after the last key: the rest of the checking waits for the apply delay.
266
- if (delay > 150) syntaxTimer = setTimeout(checkSyntax, 150);
343
+ if (delay > 150) syntaxTimer = setTimeout(checker ? checkSyntaxViaWorker : checkSyntax, 150);
267
344
  }
268
345
  /** Show or clear the syntax problems of the text now, without applying it to the document. */
269
346
  function checkSyntax() {
270
347
  syntaxTimer = null;
271
348
  if (destroyed || format !== "json") return;
272
- const problems = analyzeSyntax(control.api.getValue());
273
- if (!problems.length) {
349
+ showSyntax(checkSyntaxOnly(control.api.getValue()));
350
+ }
351
+ function showSyntax(found) {
352
+ if (!found.length) {
274
353
  if (!syntaxFindings.length) return;
275
354
  syntaxFindings = [];
276
355
  renderFindings();
277
356
  return;
278
357
  }
279
- syntaxFindings = problems.map((problem) => normalizeFinding(problem, "syntax"));
358
+ syntaxFindings = found;
280
359
  renderFindings();
281
360
  }
361
+ // The newest request of each kind is the one the pane waits for; an older reply that arrives later is ignored and must not count.
362
+ const showBusy = () => { if (checkBusy || syntaxBusy) root.dataset.opfChecking = "1"; else root.removeAttribute("data-opf-checking"); };
363
+ /** Drop the worker requests in flight: their replies are ignored (a flush, an external change or the end of the pane). */
364
+ function cancelChecks() {
365
+ checkToken++;
366
+ syntaxToken++;
367
+ if (checkBusy || syntaxBusy) { checkBusy = false; syntaxBusy = false; showBusy(); }
368
+ }
369
+ /** The apply delay is over: check the text in the worker, then apply it as `flush` would (inline at once when the worker is not usable). */
370
+ function flushViaWorker() {
371
+ timer = null;
372
+ if (destroyed || control.api.getValue() === baseline) return;
373
+ if (!checker.available) { flush(); return; }
374
+ const source = control.api.getValue(), fmt = format, used = catalogs(), token = ++checkToken;
375
+ checkBusy = true;
376
+ showBusy();
377
+ checker.check(fmt, source, used).then((result) => {
378
+ if (token !== checkToken) return;
379
+ checkBusy = false;
380
+ showBusy();
381
+ if (destroyed) return;
382
+ // A newer edit has its own timer; a null answer means the worker dropped the request, so the text is checked here.
383
+ if (control.api.getValue() !== source || format !== fmt) return;
384
+ if (result === null) { flush(); return; }
385
+ // The catalogs changed meanwhile: the answer is for the old ones.
386
+ if (catalogs() !== used) { flushViaWorker(); return; }
387
+ applyEdit(source, { read: result, catalogs: used });
388
+ }, () => {
389
+ if (token !== checkToken) return;
390
+ checkBusy = false;
391
+ showBusy();
392
+ if (!destroyed) flush();
393
+ });
394
+ }
395
+ function checkSyntaxViaWorker() {
396
+ syntaxTimer = null;
397
+ if (destroyed || format !== "json") return;
398
+ if (!checker.available) { checkSyntax(); return; }
399
+ const text = control.api.getValue(), token = ++syntaxToken;
400
+ syntaxBusy = true;
401
+ showBusy();
402
+ checker.syntax(text).then((found) => {
403
+ if (token !== syntaxToken) return;
404
+ syntaxBusy = false;
405
+ showBusy();
406
+ // The applied text has its findings already (and they are the final ones).
407
+ if (found === null || destroyed || format !== "json" || control.api.getValue() !== text || text === baseline) return;
408
+ showSyntax(found);
409
+ }, () => {
410
+ if (token !== syntaxToken) return;
411
+ syntaxBusy = false;
412
+ showBusy();
413
+ if (!destroyed) checkSyntax();
414
+ });
415
+ }
282
416
  /** Apply any edit still waiting for its delay. */
283
417
  function flush() {
284
418
  clearTimeout(timer);
285
419
  timer = null;
420
+ cancelChecks();
286
421
  if (destroyed || control.api.getValue() === baseline) return;
287
422
  applyEdit();
288
423
  }
289
424
 
290
- function applyEdit() {
291
- const source = control.api.getValue();
425
+ /** Apply the text: `answer` is the worker's `{ read, catalogs }` for it, else the text is read here. */
426
+ function applyEdit(source = control.api.getValue(), answer) {
292
427
  transientNotes = [];
293
- const read = readText(format, source, catalogs());
428
+ const read = answer ? answer.read : readText(format, source, catalogs());
429
+ if (answer) stats.worker++; else stats.inline++;
294
430
  rejectionFindings = [];
295
431
  if (!read.deck) {
296
432
  syntaxFindings = read.findings;
@@ -299,7 +435,7 @@ export function createSourcePane(container, options = {}) {
299
435
  renderFindings();
300
436
  return;
301
437
  }
302
- syntaxFindings = [];
438
+ syntaxFindings = read.notes ?? [];
303
439
  baseline = source;
304
440
  lastDraft = read.deck;
305
441
  const before = deck();
@@ -308,7 +444,8 @@ export function createSourcePane(container, options = {}) {
308
444
  try {
309
445
  // Remember the spelling of the document being replaced so Undo restores it exactly, then keep what was typed.
310
446
  syncJson(before);
311
- editor.applyPatch([{ op: "replace", path: "", value: read.deck }], { source: "source-pane", format });
447
+ // The worker has validated this very deck already: the session takes its report instead of checking again on the main thread.
448
+ editor.applyPatch([{ op: "replace", path: "", value: read.deck }], { source: "source-pane", format }, answer && read.validation ? { validated: { document: read.deck, validation: read.validation, catalogs: answer.catalogs } } : undefined);
312
449
  } catch (error) {
313
450
  dirty = true;
314
451
  // Every issue of the refusal is its own finding, with its path; a format's own findings stand in when the error names none.
@@ -322,12 +459,13 @@ export function createSourcePane(container, options = {}) {
322
459
  dirty = false;
323
460
  if (format === "json") jsonText = source;
324
461
  else syncJson(deck());
325
- if (format === "md") refreshNotes();
462
+ if (format === "md") refreshNotes(answer?.read.markdownNotes, read.deck);
326
463
  renderFindings();
327
464
  syncAttributes();
328
465
  }
329
- function refreshNotes() {
330
- try { noteFindings = markdownNotes(toMarkdown(deck()).report); } catch { noteFindings = []; }
466
+ /** What the Markdown cannot hold, for the document now. The worker's answer (`computed`) is for `read`, so it stands in when the session kept that document. */
467
+ function refreshNotes(computed, read) {
468
+ noteFindings = computed && same(read, deck()) ? computed : markdownNotesOf(deck());
331
469
  }
332
470
  /** `aria-invalid` follows the findings: set while any finding is an error (syntax, a refused draft, the session's or the host's), cleared with them. */
333
471
  function syncAttributes() {
@@ -337,6 +475,7 @@ export function createSourcePane(container, options = {}) {
337
475
 
338
476
  /** Show `value` as the text of the current format, as an external change. */
339
477
  function show(value) {
478
+ scheduleOutline(true);
340
479
  closeConfirm(false);
341
480
  baseline = value;
342
481
  shownInvalid = false;
@@ -345,6 +484,7 @@ export function createSourcePane(container, options = {}) {
345
484
  function fromSession(notice) {
346
485
  clearTimeout(timer);
347
486
  timer = null;
487
+ cancelChecks();
348
488
  for (const key of Object.keys(drafts)) delete drafts[key];
349
489
  syntaxFindings = [];
350
490
  rejectionFindings = [];
@@ -640,6 +780,7 @@ export function createSourcePane(container, options = {}) {
640
780
  nameInput.addEventListener("blur", commitRename);
641
781
 
642
782
  function updateBar() {
783
+ scheduleOutline(true);
643
784
  const current = formatOf(format);
644
785
  nameButton.textContent = name;
645
786
  nameButton.setAttribute("aria-label", `Rename ${name}`);
@@ -684,6 +825,18 @@ export function createSourcePane(container, options = {}) {
684
825
  return true;
685
826
  }
686
827
  formatAction.addEventListener("click", () => { formatDocument(); });
828
+ // The "?" button, and the "?" key from anywhere in the pane that is not a place to type, open the list of shortcuts.
829
+ const chord = control.getShortcuts().find((row) => row.id === "help.shortcuts")?.keys[0];
830
+ shortcutsAction.title = `Keyboard shortcuts${chord ? ` (${chord})` : ""}`;
831
+ shortcutsAction.setAttribute("aria-label", shortcutsAction.title);
832
+ shortcutsAction.addEventListener("click", () => { control.showShortcuts(); });
833
+ root.addEventListener("keydown", (event) => {
834
+ if (event.key !== "?" || event.ctrlKey || event.metaKey || event.altKey || event.defaultPrevented) return;
835
+ const target = event.target;
836
+ if (target.closest?.(".cm-editor, input, textarea, select, [contenteditable=true]")) return;
837
+ event.preventDefault();
838
+ control.showShortcuts();
839
+ });
687
840
 
688
841
  // Canonicalize with OPF: the text rewritten through core's writer (the old Format). Unlike Format it drops comments, so it asks first and says how many.
689
842
  function droppedComments(text) {
@@ -807,13 +960,13 @@ export function createSourcePane(container, options = {}) {
807
960
  function problemContext() {
808
961
  const draft = lastDraft ?? deck();
809
962
  return {
963
+ document: draft,
964
+ format,
810
965
  unit: control.api.indentUnit(),
811
966
  valueAt: (segments) => segments.reduce((value, key) => (value !== null && typeof value === "object" ? value[key] : undefined), draft),
812
- schemaAt: (segments) => attempt(() => schemaAtPath(draft, segments), undefined),
813
967
  keysOf: (segments) => attempt(() => [...new Set(schemaVariants(schemaAtPath(draft, segments)).flatMap((variant) => Object.keys(variant.properties ?? {})))], []),
814
- catalogKind: (segments) => catalogKindAt(segments),
815
968
  catalogCount: (kind) => attempt(() => listCatalogRecords(draft, kind, { catalogs: catalogs() }).length, 0),
816
- starter: (segments, property) => attempt(() => (property === "slides" ? [{ title: "Slide title" }] : createSchemaValue(schemaAtPath(draft, [...segments, property]))), undefined),
969
+ starter: (segments, property) => attempt(() => starterValue(draft, segments, property), undefined),
817
970
  };
818
971
  }
819
972
  /** Select the field a core "focus" fix names: its path, else the field inside it, else the nearest place there is one. */
@@ -903,13 +1056,32 @@ export function createSourcePane(container, options = {}) {
903
1056
  options.onFindingSelect?.(finding, target);
904
1057
  });
905
1058
  item.append(button);
906
- if (row.fixes.length) {
1059
+ const details = detailsFor(finding);
1060
+ if (row.fixes.length || details) {
907
1061
  const fixes = el("span", "opf-source-finding-fixes");
908
1062
  for (const fix of row.fixes.slice(0, 3)) {
909
1063
  const fixButton = Object.assign(el("button", "opf-source-finding-fix", { type: "button", "aria-label": `${fix.title}: ${row.message}` }), { textContent: fix.title });
910
1064
  fixButton.addEventListener("click", () => applyFix(fix, row));
911
1065
  fixes.append(fixButton);
912
1066
  }
1067
+ if (details) {
1068
+ // The rule's own explanation, behind a disclosure that stays open across the panel's redraws.
1069
+ const key = `${row.rule}|${row.path}|${row.message}`;
1070
+ const body = el("div", "opf-source-finding-explain", { id: `${id}-explain-${detailsKeys.id(key)}`, role: "region", "aria-label": `About ${row.rule}` });
1071
+ const toggleDetails = Object.assign(el("button", "opf-source-finding-details", { type: "button", "aria-expanded": String(detailsOpen.has(key)), "aria-controls": body.id }), { textContent: "Details" });
1072
+ body.hidden = !detailsOpen.has(key);
1073
+ fillDetails(body, details);
1074
+ toggleDetails.addEventListener("click", () => {
1075
+ const open = body.hidden;
1076
+ body.hidden = !open;
1077
+ toggleDetails.setAttribute("aria-expanded", String(open));
1078
+ if (open) detailsOpen.add(key); else detailsOpen.delete(key);
1079
+ if (open) control.api.say(`${details.summary} ${details.fix}`);
1080
+ });
1081
+ fixes.append(toggleDetails);
1082
+ item.append(fixes, body);
1083
+ return item;
1084
+ }
913
1085
  item.append(fixes);
914
1086
  }
915
1087
  return item;
@@ -977,6 +1149,7 @@ export function createSourcePane(container, options = {}) {
977
1149
  }
978
1150
 
979
1151
  updateBar();
1152
+ if (options.outline === "open") setOutlineOpen(true);
980
1153
  root.toggleAttribute("data-readonly", readOnly);
981
1154
  nameButton.disabled = readOnly;
982
1155
  formatAction.disabled = readOnly;
@@ -1036,17 +1209,39 @@ export function createSourcePane(container, options = {}) {
1036
1209
  },
1037
1210
  /** Open the suggestion list at the caret, as Ctrl+Space does. */
1038
1211
  triggerCompletion: () => control.triggerCompletion(),
1212
+ /** Every key binding of the editor for `platform` ("mac", "win" or "linux"; default: the page's): `{ id, title, group, keys, symbols, strokes, when? }`. */
1213
+ getShortcuts: (platform) => control.getShortcuts(platform),
1214
+ /** Open the keyboard shortcuts dialog (the "?" button, F1, Cmd/Ctrl+K Cmd/Ctrl+S); `onShowShortcuts(shortcuts)` returning true replaces it. */
1215
+ showShortcuts: () => control.showShortcuts(),
1216
+ /** Open the symbol picker (Cmd/Ctrl+Shift+O). */
1217
+ showSymbols: () => control.showSymbols(),
1218
+ /** The outline of the text as it is now: `{ id, kind, label, detail, depth, from, to, line }` in document order (slides, sections, assets, datasets, variables, references, embedded catalog records). */
1219
+ getOutline: () => documentSymbols(format, control.api.getValue()),
1220
+ /** Show or hide the outline panel (needs the `outline` option); with no argument it toggles. Returns whether the panel is open. */
1221
+ toggleOutline(open) { if (!outlinePanel) return false; setOutlineOpen(open ?? outlinePanel.hidden); return !outlinePanel.hidden; },
1039
1222
  /** The code editor, for hosts that focus or measure it. */
1040
1223
  editor: control,
1224
+ /** Resolves once no edit is waiting for its delay and no check is running (a worker's included): the text on screen has been read and applied. */
1225
+ whenIdle() {
1226
+ return new Promise((resolve) => {
1227
+ const poll = () => { if (destroyed || (timer === null && syntaxTimer === null && !checkBusy && !syntaxBusy)) resolve(); else setTimeout(poll, 4); };
1228
+ poll();
1229
+ });
1230
+ },
1231
+ /** How the applied edits were checked: `worker` and `inline` counts, and whether the worker is still usable (`available`). */
1232
+ checkerStats: () => ({ worker: stats.worker, inline: stats.inline, available: Boolean(checker?.available) }),
1041
1233
  destroy() {
1042
1234
  if (destroyed) return;
1043
1235
  flush();
1044
1236
  destroyed = true;
1237
+ checker?.dispose();
1045
1238
  clearTimeout(timer);
1046
1239
  clearTimeout(copyTimer);
1047
1240
  clearTimeout(summaryTimer);
1048
1241
  clearTimeout(syntaxTimer);
1049
1242
  clearTimeout(recheckTimer);
1243
+ clearTimeout(outlineTimer);
1244
+ clearTimeout(outlineMarkTimer);
1050
1245
  unsubscribe();
1051
1246
  document.removeEventListener("pointerdown", outside, true);
1052
1247
  control.destroy();
@@ -44,6 +44,9 @@ export function buildProblems({ format, text, findings, context = {}, actions =
44
44
  }
45
45
  if (finding.kind !== "syntax") fixes.push(...(format === "json" ? fixesForFinding(finding, text, context) : textFixesForFinding(format, finding, text, context)));
46
46
  for (const fix of finding.fixes ?? []) { const row = coreFix(fix, actions); if (row) fixes.push(row); }
47
+ // A repair core ships as a patch and the editor also writes as a text edit is offered once, as the text edit (it keeps comments and layout).
48
+ const titles = new Set();
49
+ for (let at = 0; at < fixes.length; at++) { if (titles.has(fixes[at].title)) fixes.splice(at--, 1); else titles.add(fixes[at].title); }
47
50
  const row = { finding, index, severity: finding.severity, message, next, rule: finding.ruleId, path: finding.path ?? "", fixes };
48
51
  if (range) {
49
52
  row.from = range.from;