@openpresentation/opf-editor 0.26.5 → 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.
- package/README.md +6 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +9 -2
- package/dist/json-editor.d.ts +20 -0
- package/dist/json-editor.js +186 -89
- package/dist/source-check.js +90 -0
- package/dist/source-checker.js +130 -0
- package/dist/source-completions.js +46 -10
- package/dist/source-diagnostics.js +3 -2
- package/dist/source-editing.js +53 -33
- package/dist/source-fixes-syntax.js +138 -8
- package/dist/source-folding.js +173 -0
- package/dist/source-hints.js +376 -0
- package/dist/source-keymap.js +369 -0
- package/dist/source-markdown.js +42 -1
- package/dist/source-navigation-model.js +426 -0
- package/dist/source-navigation-read.js +269 -0
- package/dist/source-navigation-ui.js +244 -0
- package/dist/source-navigation.js +217 -0
- package/dist/source-outline.js +112 -0
- package/dist/source-pane.d.ts +63 -0
- package/dist/source-pane.js +200 -44
- package/dist/source-references.js +58 -11
- package/dist/source-shortcuts.js +106 -0
- package/dist/source-symbols.js +156 -0
- package/dist/source-worker.d.ts +7 -0
- package/dist/source-worker.js +30 -0
- package/dist/styles.css +262 -0
- package/package.json +11 -7
package/dist/source-pane.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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,179 @@ 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
|
-
|
|
293
|
-
|
|
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 =
|
|
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
|
+
// 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
|
+
}
|
|
302
416
|
/** Apply any edit still waiting for its delay. */
|
|
303
417
|
function flush() {
|
|
304
418
|
clearTimeout(timer);
|
|
305
419
|
timer = null;
|
|
420
|
+
cancelChecks();
|
|
306
421
|
if (destroyed || control.api.getValue() === baseline) return;
|
|
307
422
|
applyEdit();
|
|
308
423
|
}
|
|
309
424
|
|
|
310
|
-
|
|
311
|
-
|
|
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) {
|
|
312
427
|
transientNotes = [];
|
|
313
|
-
const read = readText(format, source, catalogs());
|
|
428
|
+
const read = answer ? answer.read : readText(format, source, catalogs());
|
|
429
|
+
if (answer) stats.worker++; else stats.inline++;
|
|
314
430
|
rejectionFindings = [];
|
|
315
431
|
if (!read.deck) {
|
|
316
432
|
syntaxFindings = read.findings;
|
|
@@ -328,7 +444,8 @@ export function createSourcePane(container, options = {}) {
|
|
|
328
444
|
try {
|
|
329
445
|
// Remember the spelling of the document being replaced so Undo restores it exactly, then keep what was typed.
|
|
330
446
|
syncJson(before);
|
|
331
|
-
|
|
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);
|
|
332
449
|
} catch (error) {
|
|
333
450
|
dirty = true;
|
|
334
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.
|
|
@@ -342,12 +459,13 @@ export function createSourcePane(container, options = {}) {
|
|
|
342
459
|
dirty = false;
|
|
343
460
|
if (format === "json") jsonText = source;
|
|
344
461
|
else syncJson(deck());
|
|
345
|
-
if (format === "md") refreshNotes();
|
|
462
|
+
if (format === "md") refreshNotes(answer?.read.markdownNotes, read.deck);
|
|
346
463
|
renderFindings();
|
|
347
464
|
syncAttributes();
|
|
348
465
|
}
|
|
349
|
-
|
|
350
|
-
|
|
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());
|
|
351
469
|
}
|
|
352
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. */
|
|
353
471
|
function syncAttributes() {
|
|
@@ -357,6 +475,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
357
475
|
|
|
358
476
|
/** Show `value` as the text of the current format, as an external change. */
|
|
359
477
|
function show(value) {
|
|
478
|
+
scheduleOutline(true);
|
|
360
479
|
closeConfirm(false);
|
|
361
480
|
baseline = value;
|
|
362
481
|
shownInvalid = false;
|
|
@@ -365,6 +484,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
365
484
|
function fromSession(notice) {
|
|
366
485
|
clearTimeout(timer);
|
|
367
486
|
timer = null;
|
|
487
|
+
cancelChecks();
|
|
368
488
|
for (const key of Object.keys(drafts)) delete drafts[key];
|
|
369
489
|
syntaxFindings = [];
|
|
370
490
|
rejectionFindings = [];
|
|
@@ -660,6 +780,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
660
780
|
nameInput.addEventListener("blur", commitRename);
|
|
661
781
|
|
|
662
782
|
function updateBar() {
|
|
783
|
+
scheduleOutline(true);
|
|
663
784
|
const current = formatOf(format);
|
|
664
785
|
nameButton.textContent = name;
|
|
665
786
|
nameButton.setAttribute("aria-label", `Rename ${name}`);
|
|
@@ -704,6 +825,18 @@ export function createSourcePane(container, options = {}) {
|
|
|
704
825
|
return true;
|
|
705
826
|
}
|
|
706
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
|
+
});
|
|
707
840
|
|
|
708
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.
|
|
709
842
|
function droppedComments(text) {
|
|
@@ -1016,6 +1149,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
1016
1149
|
}
|
|
1017
1150
|
|
|
1018
1151
|
updateBar();
|
|
1152
|
+
if (options.outline === "open") setOutlineOpen(true);
|
|
1019
1153
|
root.toggleAttribute("data-readonly", readOnly);
|
|
1020
1154
|
nameButton.disabled = readOnly;
|
|
1021
1155
|
formatAction.disabled = readOnly;
|
|
@@ -1075,17 +1209,39 @@ export function createSourcePane(container, options = {}) {
|
|
|
1075
1209
|
},
|
|
1076
1210
|
/** Open the suggestion list at the caret, as Ctrl+Space does. */
|
|
1077
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; },
|
|
1078
1222
|
/** The code editor, for hosts that focus or measure it. */
|
|
1079
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) }),
|
|
1080
1233
|
destroy() {
|
|
1081
1234
|
if (destroyed) return;
|
|
1082
1235
|
flush();
|
|
1083
1236
|
destroyed = true;
|
|
1237
|
+
checker?.dispose();
|
|
1084
1238
|
clearTimeout(timer);
|
|
1085
1239
|
clearTimeout(copyTimer);
|
|
1086
1240
|
clearTimeout(summaryTimer);
|
|
1087
1241
|
clearTimeout(syntaxTimer);
|
|
1088
1242
|
clearTimeout(recheckTimer);
|
|
1243
|
+
clearTimeout(outlineTimer);
|
|
1244
|
+
clearTimeout(outlineMarkTimer);
|
|
1089
1245
|
unsubscribe();
|
|
1090
1246
|
document.removeEventListener("pointerdown", outside, true);
|
|
1091
1247
|
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
|
-
* `
|
|
71
|
-
*
|
|
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 || !
|
|
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
|
|
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;
|