@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.
- package/README.md +8 -1
- 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 +189 -91
- package/dist/source-a11y.js +18 -1
- package/dist/source-check.js +90 -0
- package/dist/source-checker.js +130 -0
- package/dist/source-completions.js +232 -22
- package/dist/source-details.js +17 -0
- package/dist/source-diagnostics.js +3 -2
- package/dist/source-editing.js +53 -33
- package/dist/source-fixes-syntax.js +334 -0
- package/dist/source-fixes-text.js +75 -2
- package/dist/source-fixes.js +25 -1
- 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 +53 -17
- package/dist/source-messages.js +21 -205
- 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 +240 -45
- package/dist/source-problems.js +3 -0
- package/dist/source-references.js +274 -0
- package/dist/source-shortcuts.js +106 -0
- package/dist/source-snippets.js +20 -0
- package/dist/source-symbols.js +156 -0
- package/dist/source-undo-groups.js +46 -0
- package/dist/source-worker.d.ts +7 -0
- package/dist/source-worker.js +30 -0
- package/dist/source-yaml.js +94 -9
- package/dist/styles.css +288 -0
- package/package.json +12 -7
package/dist/source-pane.js
CHANGED
|
@@ -1,17 +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 {
|
|
11
|
-
import {
|
|
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
|
-
/**
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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 =
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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(() => (
|
|
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
|
-
|
|
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();
|
package/dist/source-problems.js
CHANGED
|
@@ -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;
|