@openpresentation/opf-editor 0.26.5 → 0.26.7

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,18 +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 { withSyntaxFixes } from "./source-fixes-syntax.js";
9
+ import { checkSyntaxOnly, markdownNotes, markdownNotesOf, readSource as readText } from "./source-check.js";
10
+ import { createSourceChecker } from "./source-checker.js";
11
11
  import { detailsFor } from "./source-details.js";
12
12
  import { schemaAtPath, schemaVariants } from "./schema.js";
13
13
  import { locateText } from "./source-ranges.js";
14
14
  import { scanMarkdown, starterValue } from "@openpresentation/opf/source";
15
15
  import { listCatalogRecords } from "./catalogs.js";
16
+ import { documentSymbols, symbolAt } from "./source-symbols.js";
16
17
 
17
18
  // The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
18
19
  // and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
@@ -35,6 +36,8 @@ const ICONS = {
35
36
  Check: '<path d="M20 6 9 17l-5-5"/>',
36
37
  CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
37
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"/>',
38
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"/>',
39
42
  };
40
43
  function icon(name, size = 14) {
@@ -89,25 +92,6 @@ function fillDetails(body, details) {
89
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" }));
90
93
  }
91
94
 
92
- /** Read the text of one format. `{ deck }` on success; `findings` carry the syntax errors (`syntax: true`) or the OPF findings of the parsed deck. */
93
- function readText(format, text, catalogs) {
94
- if (format === "json") {
95
- const problems = analyzeSyntax(text);
96
- if (problems.length) return { syntax: true, findings: problems.map((problem) => normalizeFinding(problem, "syntax")) };
97
- const deck = JSON.parse(text);
98
- const duplicates = findDuplicateKeys(text);
99
- if (duplicates.length) return { syntax: true, findings: duplicates };
100
- return { deck, findings: [] };
101
- }
102
- const result = format === "yaml" ? (fromYaml(text, { catalogs })) : fromMarkdown(text, { catalogs });
103
- // The reader's own findings: errors stop the document being read; its warnings (a numbered list that became bullets) come with the deck.
104
- const readerFindings = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
105
- const errors = readerFindings.filter((finding) => finding.severity === "error");
106
- const syntax = (list) => withSyntaxFixes(format, text, list).map((finding) => normalizeFinding(finding, "syntax"));
107
- if (errors.length) return { syntax: true, findings: syntax(errors) };
108
- return { deck: result.presentation, findings: result.findings.map((finding) => normalizeFinding(finding, "schema")), notes: syntax(readerFindings) };
109
- }
110
-
111
95
  /**
112
96
  * Mount the source pane in `container`.
113
97
  *
@@ -146,6 +130,11 @@ export function createSourcePane(container, options = {}) {
146
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.
147
131
  const detailsOpen = new Set();
148
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 };
149
138
  let problemRows = [], lastDraft = null, lastSummary = "No problems", summaryTimer = null, syntaxTimer = null, recheckTimer = null;
150
139
  // The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
151
140
  // (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
@@ -196,6 +185,10 @@ export function createSourcePane(container, options = {}) {
196
185
  const canonicalAction = actionButton("Canonicalize with OPF", "Sparkles", "canonicalize");
197
186
  canonicalAction.title = "Canonicalize with OPF: rewrite the text the way OPF writes it (drops comments)";
198
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");
199
192
  const downloadAction = actionButton("Download source", "Download", "download");
200
193
  bar.append(file, actions);
201
194
 
@@ -215,7 +208,12 @@ export function createSourcePane(container, options = {}) {
215
208
  const list = el("ul", "opf-source-findings-list", { id: `${id}-findings` });
216
209
  findingsRegion.append(toggle, list);
217
210
 
218
- 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);
219
217
  container.append(root);
220
218
 
221
219
  // ---- Document <-> text --------------------------------------------------------------------------------------------
@@ -232,14 +230,6 @@ export function createSourcePane(container, options = {}) {
232
230
  const result = toMarkdown(value);
233
231
  return { text: result.markdown, notes: markdownNotes(result.report) };
234
232
  }
235
- function markdownNotes(report) {
236
- 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"));
237
- for (const entry of report.loss) {
238
- const at = entry.indexOf(": ");
239
- 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"));
240
- }
241
- return notes;
242
- }
243
233
 
244
234
  if (typeof options.source === "string") {
245
235
  try { if (same(JSON.parse(options.source), deck())) jsonText = options.source; } catch { /* Use the session's document. */ }
@@ -264,53 +254,180 @@ export function createSourcePane(container, options = {}) {
264
254
  readOnly,
265
255
  completions: options.completions !== false,
266
256
  smartListEnter: options.smartListEnter,
257
+ resolvePreview: options.resolvePreview,
267
258
  catalogs: options.catalogs ?? editor.catalogs,
268
259
  describedBy: `${id}-findings`,
269
260
  onChange: () => schedule(),
270
261
  onFormat: () => formatDocument(),
271
262
  onFocusProblems: () => focusProblems(),
263
+ onSelectionChange: () => scheduleOutline(),
264
+ onShowShortcuts: options.onShowShortcuts,
272
265
  // The pane supplies the problems itself (every finding, with ranges and fixes).
273
266
  syntaxProblems: false,
274
267
  historyFallback: { undo: () => stepReplace("undo"), redo: () => stepReplace("redo") },
275
268
  onError: (error) => options.onError?.(error),
276
269
  });
277
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
+
278
334
  // ---- Applying edits -----------------------------------------------------------------------------------------------
279
335
  function schedule() {
336
+ scheduleOutline(true);
280
337
  closeConfirm(false);
281
338
  clearTimeout(timer);
282
339
  clearTimeout(syntaxTimer);
283
340
  if (delay === 0) { flush(); return; }
284
- timer = setTimeout(flush, delay);
341
+ timer = setTimeout(checker ? flushViaWorker : flush, delay);
285
342
  // Syntax problems come first, 150 ms after the last key: the rest of the checking waits for the apply delay.
286
- if (delay > 150) syntaxTimer = setTimeout(checkSyntax, 150);
343
+ if (delay > 150) syntaxTimer = setTimeout(checker ? checkSyntaxViaWorker : checkSyntax, 150);
287
344
  }
288
345
  /** Show or clear the syntax problems of the text now, without applying it to the document. */
289
346
  function checkSyntax() {
290
347
  syntaxTimer = null;
291
348
  if (destroyed || format !== "json") return;
292
- const problems = analyzeSyntax(control.api.getValue());
293
- if (!problems.length) {
349
+ showSyntax(checkSyntaxOnly(control.api.getValue()));
350
+ }
351
+ function showSyntax(found) {
352
+ if (!found.length) {
294
353
  if (!syntaxFindings.length) return;
295
354
  syntaxFindings = [];
296
355
  renderFindings();
297
356
  return;
298
357
  }
299
- syntaxFindings = problems.map((problem) => normalizeFinding(problem, "syntax"));
358
+ syntaxFindings = found;
300
359
  renderFindings();
301
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
+ // A null answer means the worker dropped the request. The text may equal the applied one: a quick fix and Undo bring the broken text
407
+ // back while `baseline` still holds it, and its syntax problem must show again at once (checkSyntax has no such guard either).
408
+ if (found === null || destroyed || format !== "json" || control.api.getValue() !== text) return;
409
+ showSyntax(found);
410
+ }, () => {
411
+ if (token !== syntaxToken) return;
412
+ syntaxBusy = false;
413
+ showBusy();
414
+ if (!destroyed) checkSyntax();
415
+ });
416
+ }
302
417
  /** Apply any edit still waiting for its delay. */
303
418
  function flush() {
304
419
  clearTimeout(timer);
305
420
  timer = null;
421
+ cancelChecks();
306
422
  if (destroyed || control.api.getValue() === baseline) return;
307
423
  applyEdit();
308
424
  }
309
425
 
310
- function applyEdit() {
311
- const source = control.api.getValue();
426
+ /** Apply the text: `answer` is the worker's `{ read, catalogs }` for it, else the text is read here. */
427
+ function applyEdit(source = control.api.getValue(), answer) {
312
428
  transientNotes = [];
313
- const read = readText(format, source, catalogs());
429
+ const read = answer ? answer.read : readText(format, source, catalogs());
430
+ if (answer) stats.worker++; else stats.inline++;
314
431
  rejectionFindings = [];
315
432
  if (!read.deck) {
316
433
  syntaxFindings = read.findings;
@@ -328,7 +445,8 @@ export function createSourcePane(container, options = {}) {
328
445
  try {
329
446
  // Remember the spelling of the document being replaced so Undo restores it exactly, then keep what was typed.
330
447
  syncJson(before);
331
- editor.applyPatch([{ op: "replace", path: "", value: read.deck }], { source: "source-pane", format });
448
+ // The worker has validated this very deck already: the session takes its report instead of checking again on the main thread.
449
+ 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);
332
450
  } catch (error) {
333
451
  dirty = true;
334
452
  // Every issue of the refusal is its own finding, with its path; a format's own findings stand in when the error names none.
@@ -342,12 +460,13 @@ export function createSourcePane(container, options = {}) {
342
460
  dirty = false;
343
461
  if (format === "json") jsonText = source;
344
462
  else syncJson(deck());
345
- if (format === "md") refreshNotes();
463
+ if (format === "md") refreshNotes(answer?.read.markdownNotes, read.deck);
346
464
  renderFindings();
347
465
  syncAttributes();
348
466
  }
349
- function refreshNotes() {
350
- try { noteFindings = markdownNotes(toMarkdown(deck()).report); } catch { noteFindings = []; }
467
+ /** 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. */
468
+ function refreshNotes(computed, read) {
469
+ noteFindings = computed && same(read, deck()) ? computed : markdownNotesOf(deck());
351
470
  }
352
471
  /** `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. */
353
472
  function syncAttributes() {
@@ -357,6 +476,7 @@ export function createSourcePane(container, options = {}) {
357
476
 
358
477
  /** Show `value` as the text of the current format, as an external change. */
359
478
  function show(value) {
479
+ scheduleOutline(true);
360
480
  closeConfirm(false);
361
481
  baseline = value;
362
482
  shownInvalid = false;
@@ -365,6 +485,7 @@ export function createSourcePane(container, options = {}) {
365
485
  function fromSession(notice) {
366
486
  clearTimeout(timer);
367
487
  timer = null;
488
+ cancelChecks();
368
489
  for (const key of Object.keys(drafts)) delete drafts[key];
369
490
  syntaxFindings = [];
370
491
  rejectionFindings = [];
@@ -660,6 +781,7 @@ export function createSourcePane(container, options = {}) {
660
781
  nameInput.addEventListener("blur", commitRename);
661
782
 
662
783
  function updateBar() {
784
+ scheduleOutline(true);
663
785
  const current = formatOf(format);
664
786
  nameButton.textContent = name;
665
787
  nameButton.setAttribute("aria-label", `Rename ${name}`);
@@ -704,6 +826,18 @@ export function createSourcePane(container, options = {}) {
704
826
  return true;
705
827
  }
706
828
  formatAction.addEventListener("click", () => { formatDocument(); });
829
+ // The "?" button, and the "?" key from anywhere in the pane that is not a place to type, open the list of shortcuts.
830
+ const chord = control.getShortcuts().find((row) => row.id === "help.shortcuts")?.keys[0];
831
+ shortcutsAction.title = `Keyboard shortcuts${chord ? ` (${chord})` : ""}`;
832
+ shortcutsAction.setAttribute("aria-label", shortcutsAction.title);
833
+ shortcutsAction.addEventListener("click", () => { control.showShortcuts(); });
834
+ root.addEventListener("keydown", (event) => {
835
+ if (event.key !== "?" || event.ctrlKey || event.metaKey || event.altKey || event.defaultPrevented) return;
836
+ const target = event.target;
837
+ if (target.closest?.(".cm-editor, input, textarea, select, [contenteditable=true]")) return;
838
+ event.preventDefault();
839
+ control.showShortcuts();
840
+ });
707
841
 
708
842
  // 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.
709
843
  function droppedComments(text) {
@@ -1016,6 +1150,7 @@ export function createSourcePane(container, options = {}) {
1016
1150
  }
1017
1151
 
1018
1152
  updateBar();
1153
+ if (options.outline === "open") setOutlineOpen(true);
1019
1154
  root.toggleAttribute("data-readonly", readOnly);
1020
1155
  nameButton.disabled = readOnly;
1021
1156
  formatAction.disabled = readOnly;
@@ -1075,17 +1210,39 @@ export function createSourcePane(container, options = {}) {
1075
1210
  },
1076
1211
  /** Open the suggestion list at the caret, as Ctrl+Space does. */
1077
1212
  triggerCompletion: () => control.triggerCompletion(),
1213
+ /** Every key binding of the editor for `platform` ("mac", "win" or "linux"; default: the page's): `{ id, title, group, keys, symbols, strokes, when? }`. */
1214
+ getShortcuts: (platform) => control.getShortcuts(platform),
1215
+ /** Open the keyboard shortcuts dialog (the "?" button, F1, Cmd/Ctrl+K Cmd/Ctrl+S); `onShowShortcuts(shortcuts)` returning true replaces it. */
1216
+ showShortcuts: () => control.showShortcuts(),
1217
+ /** Open the symbol picker (Cmd/Ctrl+Shift+O). */
1218
+ showSymbols: () => control.showSymbols(),
1219
+ /** 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). */
1220
+ getOutline: () => documentSymbols(format, control.api.getValue()),
1221
+ /** Show or hide the outline panel (needs the `outline` option); with no argument it toggles. Returns whether the panel is open. */
1222
+ toggleOutline(open) { if (!outlinePanel) return false; setOutlineOpen(open ?? outlinePanel.hidden); return !outlinePanel.hidden; },
1078
1223
  /** The code editor, for hosts that focus or measure it. */
1079
1224
  editor: control,
1225
+ /** 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. */
1226
+ whenIdle() {
1227
+ return new Promise((resolve) => {
1228
+ const poll = () => { if (destroyed || (timer === null && syntaxTimer === null && !checkBusy && !syntaxBusy)) resolve(); else setTimeout(poll, 4); };
1229
+ poll();
1230
+ });
1231
+ },
1232
+ /** How the applied edits were checked: `worker` and `inline` counts, and whether the worker is still usable (`available`). */
1233
+ checkerStats: () => ({ worker: stats.worker, inline: stats.inline, available: Boolean(checker?.available) }),
1080
1234
  destroy() {
1081
1235
  if (destroyed) return;
1082
1236
  flush();
1083
1237
  destroyed = true;
1238
+ checker?.dispose();
1084
1239
  clearTimeout(timer);
1085
1240
  clearTimeout(copyTimer);
1086
1241
  clearTimeout(summaryTimer);
1087
1242
  clearTimeout(syntaxTimer);
1088
1243
  clearTimeout(recheckTimer);
1244
+ clearTimeout(outlineTimer);
1245
+ clearTimeout(outlineMarkTimer);
1089
1246
  unsubscribe();
1090
1247
  document.removeEventListener("pointerdown", outside, true);
1091
1248
  control.destroy();
@@ -6,6 +6,7 @@
6
6
  // so a renamed definition or a reworded description fails the test instead of silently dropping a list. Plain functions, no DOM.
7
7
  import { parse as parseYaml } from "yaml";
8
8
  import { describeField } from "@openpresentation/opf/schema";
9
+ import { listBuiltinVariables } from "@openpresentation/opf";
9
10
 
10
11
  /**
11
12
  * The rules. `definitions`: a field whose own schema is that definition (`$defs` name). `keys` + `phrases`: a string field with one of those property
@@ -65,16 +66,20 @@ export function referenceSiteAt(path, document) {
65
66
  const stringOf = (value) => (typeof value === "string" ? value : undefined);
66
67
  const columnName = (column) => (typeof column === "string" ? column : isObject(column) && typeof column.name === "string" ? column.name : undefined);
67
68
 
69
+ // The top-level parts of a deck the built-in variables read (`{{deck.name}}`, `{{speaker.name}}`, `{{organization.name}}`).
70
+ const BUILTIN_SOURCES = ["name", "description", "author", "speaker", "organization"];
71
+
68
72
  /**
69
- * The parts of a deck the references read, from the plain value of the top level (`assets`, `datasets`, `variables`, `references`, `narrative`):
70
- * `{ assets: [{ id, detail }], datasets: [{ id, title, columns }], variables: [{ id, type }], references: [{ id, text }], narrative }`.
71
- * Anything else the value holds, and anything malformed, is ignored: the text being edited is a draft.
73
+ * The parts of a deck the references read, from the plain value of the top level (`assets`, `datasets`, `variables`, `references`, `narrative`, and
74
+ * `name`, `description`, `author`, `speaker`, `organization` for the built-in variables):
75
+ * `{ assets: [{ id, detail, src, mediaType }], datasets: [{ id, title, columns }], variables: [{ id, type }], references: [{ id, text }], narrative, deck }`
76
+ * where `deck` holds the built-in sources the draft sets. Anything else the value holds, and anything malformed, is ignored: the text being edited is a draft.
72
77
  */
73
78
  export function factsOf(deck) {
74
- const out = { assets: [], datasets: [], variables: [], references: [], narrative: undefined };
79
+ const out = { assets: [], datasets: [], variables: [], references: [], narrative: undefined, deck: {} };
75
80
  if (!isObject(deck)) return out;
76
81
  if (isObject(deck.assets)) {
77
- for (const [id, asset] of Object.entries(deck.assets)) out.assets.push({ id, detail: stringOf(asset) ?? stringOf(asset?.src) ?? stringOf(asset?.title) ?? "" });
82
+ for (const [id, asset] of Object.entries(deck.assets)) out.assets.push({ id, detail: stringOf(asset) ?? stringOf(asset?.src) ?? stringOf(asset?.title) ?? "", src: stringOf(asset) ?? stringOf(asset?.src) ?? "", mediaType: stringOf(asset?.mediaType) ?? "" });
78
83
  }
79
84
  if (isObject(deck.datasets)) {
80
85
  for (const [id, dataset] of Object.entries(deck.datasets)) {
@@ -93,16 +98,19 @@ export function factsOf(deck) {
93
98
  for (const reference of deck.references) if (isObject(reference) && typeof reference.id === "string" && reference.id) out.references.push({ id: reference.id, text: stringOf(reference.text) ?? "" });
94
99
  }
95
100
  out.narrative = stringOf(deck.narrative);
101
+ for (const key of BUILTIN_SOURCES) if (deck[key] !== undefined) out.deck[key] = deck[key];
96
102
  return out;
97
103
  }
98
104
 
105
+ const TOP_LEVEL = ["assets", "datasets", "variables", "references", "narrative", ...BUILTIN_SOURCES];
106
+
99
107
  /** The top-level parts of a JSON draft the references read (`root` is a jsonc-parser tree), as a plain value for `factsOf`. */
100
108
  export function jsonDeckParts(root, getNodeValue) {
101
109
  const deck = {};
102
110
  if (root?.type !== "object") return deck;
103
111
  for (const property of root.children ?? []) {
104
112
  const key = property.children?.[0]?.value, value = property.children?.[1];
105
- if (!value || !["assets", "datasets", "variables", "references", "narrative"].includes(key)) continue;
113
+ if (!value || !TOP_LEVEL.includes(key)) continue;
106
114
  if (key === "datasets" && value.type === "object") {
107
115
  // Only the names and the columns: the rows can be the bulk of a deck.
108
116
  deck.datasets = {};
@@ -118,7 +126,6 @@ export function jsonDeckParts(root, getNodeValue) {
118
126
  return deck;
119
127
  }
120
128
 
121
- const TOP_LEVEL = ["assets", "datasets", "variables", "references", "narrative"];
122
129
 
123
130
  /** The text of the top-level blocks of a YAML text that hold the keys the references read, parsed (tolerant: a broken block gives nothing). */
124
131
  export function yamlDeckParts(text) {
@@ -178,12 +185,12 @@ export function regionNames(record) {
178
185
  return names;
179
186
  }
180
187
 
181
- /** The regions of a layout record in reading order (nested ones included) as `{ name, accepts }`. */
188
+ /** The regions of a layout record in reading order (nested ones included) as `{ name, accepts, role?, min? }`. */
182
189
  export function flatRegions(record) {
183
190
  const out = [];
184
191
  const walk = (regions) => {
185
192
  for (const region of Array.isArray(regions) ? regions : []) {
186
- if (typeof region?.name === "string") out.push({ name: region.name, accepts: Array.isArray(region.accepts) ? region.accepts : [] });
193
+ if (typeof region?.name === "string") out.push({ name: region.name, accepts: Array.isArray(region.accepts) ? region.accepts : [], ...(typeof region.role === "string" ? { role: region.role } : {}), ...(Number.isFinite(region.min) ? { min: region.min } : {}) });
187
194
  if (region?.regions) walk(region.regions);
188
195
  }
189
196
  };
@@ -214,13 +221,53 @@ export function beatsOf(record) {
214
221
  }
215
222
 
216
223
  /**
217
- * The names of the variables a `{{` in text can reference: the document's own, with their type. (The built-in `organization.*`, `speaker.*` and
218
- * `deck.*` variables are not offered: they are not declared in the document.)
224
+ * The names of the variables a `{{` in text can reference that the document declares, with their type. (The built-in ones are `builtinVariables`.)
219
225
  */
220
226
  export function variableNames(facts) {
221
227
  return facts.variables.map((variable) => ({ id: variable.id, type: variable.type }));
222
228
  }
223
229
 
230
+ const asText = (value) => (typeof value === "string" ? value : Array.isArray(value) ? value.filter((entry) => typeof entry === "string").join(", ") : "");
231
+ const FIELD_WORDS = { legalName: "legal name", slideCount: "slide count" };
232
+ const fieldWord = (field) => FIELD_WORDS[field] ?? field;
233
+
234
+ /** What a built-in stands for, in a sentence (the name comes from core; the sentence is built from its parts). */
235
+ function builtinSentence(name) {
236
+ if (name === "speakers") return "The names of every speaker, joined with commas.";
237
+ const parts = name.split(".");
238
+ const field = fieldWord(parts.at(-1));
239
+ if (name === "slide.number") return "The displayed number of this slide, after pagination.";
240
+ if (name === "slide.section") return "The section of this slide.";
241
+ if (name === "deck.slideCount") return "The number of slides in the deck, after pagination.";
242
+ if (parts[0] === "deck") return `The ${field} of the deck.`;
243
+ if (parts[0] === "speaker") return parts.length === 3 ? `The ${field} of the speaker ${parts[1]}.` : `The ${field} of the first speaker.`;
244
+ if (parts[0] === "organization") return parts.length === 3 ? `The ${field} of the organization ${parts[1]}.` : `The ${field} of the primary organization.`;
245
+ return "A built-in variable.";
246
+ }
247
+
248
+ const GROUP_RANK = { deck: 0, speaker: 1, speakers: 1, organization: 2, slide: 3 };
249
+
250
+ /**
251
+ * The built-in variables a `{{` in text can reference (core's `listBuiltinVariables` over the deck parts the draft sets), grouped by prefix: `deck.*`,
252
+ * `speaker.*` and `speakers`, `organization.*`, then the per-slide ones. A logo is an image used only as a whole field, never inside `{{ }}`, so it is
253
+ * left out. Each is `{ name, kind, scope, available, doc, detail }`: `available` is false when the draft has no source for it (it resolves to nothing).
254
+ */
255
+ export function builtinVariables(facts) {
256
+ let list = [];
257
+ try { list = listBuiltinVariables({ ...facts.deck, slides: [] }); } catch { list = []; }
258
+ return list
259
+ .filter((entry) => !(entry.scope === "slide" && entry.kind === "image"))
260
+ .map((entry, index) => ({ entry, index }))
261
+ .sort((a, b) => (GROUP_RANK[a.entry.name.split(".")[0]] ?? 9) - (GROUP_RANK[b.entry.name.split(".")[0]] ?? 9) || a.index - b.index)
262
+ .map(({ entry }) => {
263
+ const perSlide = entry.scope === "slide";
264
+ const shown = asText(entry.value);
265
+ const detail = perSlide ? "built-in · varies per slide" : entry.available ? `built-in${shown ? ` · ${shown.length > 24 ? `${shown.slice(0, 23)}…` : shown}` : ""}` : "built-in · not set";
266
+ const note = perSlide ? "" : entry.available ? "" : ` This document does not set it, so it resolves to nothing.`;
267
+ return { name: entry.name, kind: entry.kind, scope: entry.scope, available: perSlide || entry.available, label: entry.label, detail, doc: `${builtinSentence(entry.name)}${note}` };
268
+ });
269
+ }
270
+
224
271
  /** The slide a path of a deck sits in: `["slides", n]`, or null. */
225
272
  export function slidePath(path) {
226
273
  return path[0] === "slides" && isIndex(path[1]) ? [path[0], Number(path[1])] : null;