@openpresentation/opf-editor 0.24.0 → 0.24.2

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.
@@ -0,0 +1,669 @@
1
+ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
2
+ import { fromYaml, scanYamlComments, toYaml } from "@openpresentation/opf/yaml";
3
+ import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
4
+ import { mountJsonCodeEditor } from "./json-editor.js";
5
+ import { createSourceMemory, findDuplicateKey, updateJsonSource } from "./exact-source.js";
6
+ import { lineForPath, lineOfOffset, offsetOfLine } from "./source-lines.js";
7
+ import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
8
+
9
+ // The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
10
+ // and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
11
+ // Markdown are written and read by core's converters. Every edit, in any format, reaches the document through the editor
12
+ // session as a patch, so it is validated and undoable like any other edit. Plain DOM; no network; the host owns storage.
13
+
14
+ const FORMATS = [
15
+ { id: "json", label: "JSON", extension: "json", description: "The canonical OPF document.", mime: "application/json", language: "json" },
16
+ { id: "yaml", label: "YAML", extension: "yaml", description: "Same fields, less punctuation.", mime: "text/yaml", language: "yaml" },
17
+ { id: "md", label: "Markdown", extension: "md", description: "Settings up top, one heading per slide.", mime: "text/markdown", language: "markdown" },
18
+ ];
19
+ const formatOf = (id) => FORMATS.find((format) => format.id === id) ?? FORMATS[0];
20
+
21
+ // Lucide icons (ISC license), as path data.
22
+ const ICONS = {
23
+ Braces: '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>',
24
+ Copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
25
+ Download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
26
+ ChevronDown: '<path d="m6 9 6 6 6-6"/>',
27
+ Check: '<path d="M20 6 9 17l-5-5"/>',
28
+ CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
29
+ Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
30
+ };
31
+ function icon(name, size = 14) {
32
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
33
+ svg.setAttribute("viewBox", "0 0 24 24");
34
+ svg.setAttribute("width", String(size));
35
+ svg.setAttribute("height", String(size));
36
+ svg.setAttribute("fill", "none");
37
+ svg.setAttribute("stroke", "currentColor");
38
+ svg.setAttribute("stroke-width", "2");
39
+ svg.setAttribute("stroke-linecap", "round");
40
+ svg.setAttribute("stroke-linejoin", "round");
41
+ svg.setAttribute("aria-hidden", "true");
42
+ svg.setAttribute("focusable", "false");
43
+ svg.setAttribute("class", `opf-source-icon opf-source-icon-${name.toLowerCase()}`);
44
+ svg.innerHTML = ICONS[name];
45
+ return svg;
46
+ }
47
+
48
+ let counter = 0;
49
+ const el = (tag, className, attributes = {}) => {
50
+ const node = document.createElement(tag);
51
+ if (className) node.className = className;
52
+ for (const [key, value] of Object.entries(attributes)) if (value !== undefined) node.setAttribute(key, value);
53
+ return node;
54
+ };
55
+ const pointer = (segments) => segments.map((part) => `/${String(part).replaceAll("~", "~0").replaceAll("/", "~1")}`).join("");
56
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
57
+ function same(a, b) {
58
+ if (a === b) return true;
59
+ if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((item, index) => same(item, b[index]));
60
+ if (isObject(a)) {
61
+ if (!isObject(b)) return false;
62
+ const keys = Object.keys(a);
63
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && same(a[key], b[key]));
64
+ }
65
+ return false;
66
+ }
67
+ const SEVERITY = { error: 0, warning: 1, info: 2 };
68
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
69
+
70
+ const JSON_ERRORS = { InvalidSymbol: "Invalid symbol", InvalidNumberFormat: "Invalid number", PropertyNameExpected: "Property name expected", ValueExpected: "Value expected", ColonExpected: "Colon expected", CommaExpected: "Comma expected", CloseBraceExpected: "Closing brace expected", CloseBracketExpected: "Closing bracket expected", EndOfFileExpected: "End of file expected", InvalidCommentToken: "Comments are not allowed in JSON", UnexpectedEndOfComment: "Unexpected end of comment", UnexpectedEndOfString: "Unexpected end of string", UnexpectedEndOfNumber: "Unexpected end of number", InvalidUnicode: "Invalid unicode escape", InvalidEscapeCharacter: "Invalid escape character", InvalidCharacter: "Invalid character" };
71
+
72
+ /** Read the text of one format. `{ deck }` on success; `findings` carry the syntax errors (`syntax: true`) or the OPF findings of the parsed deck. */
73
+ function readText(format, text, catalogs) {
74
+ if (format === "json") {
75
+ try {
76
+ const deck = JSON.parse(text);
77
+ if (!isObject(deck)) return { syntax: true, findings: [normalizeFinding({ message: "An OPF document is a JSON object.", line: 1 }, "syntax")] };
78
+ const duplicate = findDuplicateKey(text);
79
+ if (duplicate) {
80
+ const path = pointer([...duplicate.path, duplicate.key]);
81
+ return { syntax: true, findings: [normalizeFinding({ path, message: `Duplicate key "${duplicate.key}". Remove the repeated key.` }, "syntax")] };
82
+ }
83
+ return { deck, findings: [] };
84
+ } catch (error) {
85
+ const errors = [];
86
+ parseJsonc(text, errors, { allowTrailingComma: false, disallowComments: true });
87
+ const first = errors[0];
88
+ const message = first ? `${JSON_ERRORS[printParseErrorCode(first.error)] ?? printParseErrorCode(first.error)}.` : `${error.message}.`;
89
+ return { syntax: true, findings: [normalizeFinding({ message, line: first ? lineOfOffset(text, first.offset) : 1 }, "syntax")] };
90
+ }
91
+ }
92
+ const result = format === "yaml" ? (fromYaml(text, { catalogs })) : fromMarkdown(text, { catalogs });
93
+ const syntax = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
94
+ if (syntax.length) return { syntax: true, findings: syntax.map((finding) => normalizeFinding(finding, "syntax")) };
95
+ return { deck: result.presentation, findings: result.findings.map((finding) => normalizeFinding(finding, "schema")) };
96
+ }
97
+
98
+ /**
99
+ * Mount the source pane in `container`.
100
+ *
101
+ * @param {HTMLElement} container
102
+ * @param {object} options see `source-pane.d.ts`
103
+ */
104
+ export function createSourcePane(container, options = {}) {
105
+ const editor = options.editor;
106
+ if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw new TypeError("createSourcePane needs an editor session (options.editor).");
107
+ const id = `opf-source-${++counter}`;
108
+ const memory = createSourceMemory();
109
+ const delay = Math.max(0, options.applyDelay ?? 250);
110
+ let name = sanitizeName(options.name ?? "deck");
111
+ let format = formatOf(options.format).id;
112
+ let density = options.density === "touch" ? "touch" : "comfortable";
113
+ let readOnly = Boolean(options.readOnly);
114
+ let hostFindings = Array.isArray(options.findings) ? options.findings : null;
115
+ let version = 0;
116
+ let applying = false;
117
+ let destroyed = false;
118
+ let timer = null;
119
+ let jsonText = null;
120
+ let baseline = "";
121
+ let dirty = false;
122
+ const drafts = {};
123
+ let syntaxFindings = [], rejectionFindings = [], noteFindings = [], transientNotes = [];
124
+ let collapsed = false;
125
+ let copyTimer = null;
126
+ let renderedKey = "";
127
+ // Whether the findings include an error, and what the editor's `aria-invalid` currently says (null: not yet set).
128
+ let invalid = false, shownInvalid = null;
129
+ const lineCache = { key: "", values: new Map() };
130
+
131
+ // ---- DOM ----------------------------------------------------------------------------------------------------------
132
+ const root = el("div", "opf-source opf-ui", { "data-density": density });
133
+ const bar = el("div", "opf-source-bar");
134
+ const file = el("div", "opf-source-file");
135
+ const nameButton = el("button", "opf-source-name", { type: "button", title: "Rename" });
136
+ const nameInput = el("input", "opf-source-name-input", { type: "text", "aria-label": "File name", spellcheck: "false", autocomplete: "off", hidden: "" });
137
+ const formatButton = el("button", "opf-source-format", { type: "button", "aria-label": "Source format", "aria-haspopup": "menu", "aria-expanded": "false", "aria-controls": `${id}-menu` });
138
+ const formatLabel = el("span", "opf-source-format-label");
139
+ formatButton.append(formatLabel, icon("ChevronDown", 12));
140
+ const menu = el("div", "opf-source-menu", { role: "menu", id: `${id}-menu`, "aria-label": "Source format", hidden: "" });
141
+ menu.append(Object.assign(el("div", "opf-source-menu-heading", { "aria-hidden": "true" }), { textContent: "Source format" }));
142
+ const menuItems = FORMATS.map((entry) => {
143
+ const item = el("button", "opf-source-menu-item", { type: "button", role: "menuitemradio", "aria-checked": "false", tabindex: "-1", "data-format": entry.id });
144
+ const check = el("span", "opf-source-menu-check");
145
+ check.append(icon("Check", 14));
146
+ const body = el("span", "opf-source-menu-body");
147
+ body.append(Object.assign(el("span", "opf-source-menu-label"), { textContent: entry.label }), Object.assign(el("span", "opf-source-menu-description"), { textContent: entry.description }));
148
+ item.append(check, body, Object.assign(el("span", "opf-source-menu-extension"), { textContent: `.opf.${entry.extension}` }));
149
+ menu.append(item);
150
+ return item;
151
+ });
152
+ file.append(nameButton, nameInput, formatButton, menu);
153
+
154
+ const actions = el("div", "opf-source-actions");
155
+ const actionButton = (label, glyph, key) => {
156
+ const button = el("button", `opf-source-action opf-source-action-${key}`, { type: "button", "aria-label": label, title: label });
157
+ button.append(icon(glyph, 14));
158
+ actions.append(button);
159
+ return button;
160
+ };
161
+ const formatAction = actionButton("Format (fix indentation)", "Braces", "format");
162
+ const copyAction = actionButton("Copy source", "Copy", "copy");
163
+ const downloadAction = actionButton("Download source", "Download", "download");
164
+ bar.append(file, actions);
165
+
166
+ const code = el("div", "opf-source-code");
167
+ const status = el("div", "opf-source-status", { role: "status", "aria-live": "polite" });
168
+
169
+ const findingsRegion = el("section", "opf-source-findings", { "aria-label": "Source findings", hidden: "" });
170
+ const toggle = el("button", "opf-source-findings-toggle", { type: "button", "aria-expanded": "true", "aria-controls": `${id}-findings` });
171
+ const toggleIcon = el("span", "opf-source-findings-icon");
172
+ const toggleText = el("span", "opf-source-findings-count");
173
+ toggle.append(toggleIcon, toggleText, icon("ChevronDown", 14));
174
+ const list = el("ul", "opf-source-findings-list", { id: `${id}-findings` });
175
+ findingsRegion.append(toggle, list);
176
+
177
+ root.append(bar, code, findingsRegion, status);
178
+ container.append(root);
179
+
180
+ // ---- Document <-> text --------------------------------------------------------------------------------------------
181
+ const deck = () => editor.presentation;
182
+ const prettyJson = (value) => JSON.stringify(value, null, 2);
183
+ function syncJson(value) {
184
+ try { jsonText = updateJsonSource(jsonText ?? prettyJson(value), value, memory); }
185
+ catch { jsonText = prettyJson(value); }
186
+ return jsonText;
187
+ }
188
+ function write(target, value) {
189
+ if (target === "json") return { text: syncJson(value), notes: [] };
190
+ if (target === "yaml") return { text: toYaml(value).yaml, notes: [] };
191
+ const result = toMarkdown(value);
192
+ return { text: result.markdown, notes: markdownNotes(result.report) };
193
+ }
194
+ function markdownNotes(report) {
195
+ 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"));
196
+ for (const entry of report.loss) {
197
+ const at = entry.indexOf(": ");
198
+ 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"));
199
+ }
200
+ return notes;
201
+ }
202
+
203
+ if (typeof options.source === "string") {
204
+ try { if (same(JSON.parse(options.source), deck())) jsonText = options.source; } catch { /* Use the session's document. */ }
205
+ }
206
+ syncJson(deck());
207
+ let initial;
208
+ {
209
+ let first;
210
+ try { first = write(format, deck()); }
211
+ catch { format = "json"; first = write("json", deck()); }
212
+ initial = first.text;
213
+ baseline = first.text;
214
+ noteFindings = first.notes;
215
+ }
216
+
217
+ const catalogs = () => editor.catalogs ?? options.catalogs;
218
+ const control = mountJsonCodeEditor(code, {
219
+ code: initial,
220
+ label: options.label ?? "OPF source",
221
+ lineNumbers: true,
222
+ language: formatOf(format).language,
223
+ readOnly,
224
+ completions: options.completions !== false,
225
+ catalogs: options.catalogs ?? editor.catalogs,
226
+ describedBy: `${id}-findings`,
227
+ onChange: () => schedule(),
228
+ onError: (error) => options.onError?.(error),
229
+ });
230
+
231
+ // ---- Applying edits -----------------------------------------------------------------------------------------------
232
+ function schedule() {
233
+ clearTimeout(timer);
234
+ if (delay === 0) { flush(); return; }
235
+ timer = setTimeout(flush, delay);
236
+ }
237
+ /** Apply any edit still waiting for its delay. */
238
+ function flush() {
239
+ clearTimeout(timer);
240
+ timer = null;
241
+ if (destroyed || control.api.getValue() === baseline) return;
242
+ applyEdit();
243
+ }
244
+
245
+ function applyEdit() {
246
+ const source = control.api.getValue();
247
+ transientNotes = [];
248
+ const read = readText(format, source, catalogs());
249
+ rejectionFindings = [];
250
+ if (!read.deck) {
251
+ syntaxFindings = read.findings;
252
+ dirty = true;
253
+ baseline = source;
254
+ renderFindings();
255
+ return;
256
+ }
257
+ syntaxFindings = [];
258
+ baseline = source;
259
+ const before = deck();
260
+ if (!same(read.deck, before)) {
261
+ applying = true;
262
+ try {
263
+ // Remember the spelling of the document being replaced so Undo restores it exactly, then keep what was typed.
264
+ syncJson(before);
265
+ editor.applyPatch([{ op: "replace", path: "", value: read.deck }], { source: "source-pane", format });
266
+ } catch (error) {
267
+ dirty = true;
268
+ // Every issue of the refusal is its own finding, with its path; a format's own findings stand in when the error names none.
269
+ rejectionFindings = error?.issues?.length || !read.findings.length ? findingsOfError(error) : read.findings.map((finding) => ({ ...finding, kind: "rejected" }));
270
+ applying = false;
271
+ renderFindings();
272
+ return;
273
+ } finally { applying = false; }
274
+ version++;
275
+ }
276
+ dirty = false;
277
+ if (format === "json") jsonText = source;
278
+ else syncJson(deck());
279
+ if (format === "md") refreshNotes();
280
+ renderFindings();
281
+ syncAttributes();
282
+ }
283
+ function refreshNotes() {
284
+ try { noteFindings = markdownNotes(toMarkdown(deck()).report); } catch { noteFindings = []; }
285
+ }
286
+ /** `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. */
287
+ function syncAttributes() {
288
+ shownInvalid = invalid;
289
+ control.update(control.api.getValue(), invalid, null, `${id}-findings`);
290
+ }
291
+
292
+ /** Show `value` as the text of the current format, as an external change. */
293
+ function show(value) {
294
+ baseline = value;
295
+ shownInvalid = false;
296
+ control.update(value, false, null, `${id}-findings`);
297
+ }
298
+ function fromSession(notice) {
299
+ clearTimeout(timer);
300
+ timer = null;
301
+ for (const key of Object.keys(drafts)) delete drafts[key];
302
+ syntaxFindings = [];
303
+ rejectionFindings = [];
304
+ transientNotes = [];
305
+ dirty = false;
306
+ let written;
307
+ try { written = write(format, deck()); }
308
+ catch (error) {
309
+ syncJsonSafe();
310
+ rejectionFindings = [normalizeFinding({ message: `The document cannot be written as ${formatOf(format).label}: ${errorText(error)}. The text below is the last version that could be.`, kind: "rejected" })];
311
+ renderFindings();
312
+ return;
313
+ }
314
+ if (format === "yaml" && notice !== false) {
315
+ const before = control.api.getValue(), comments = scanYamlComments(before).count;
316
+ if (comments > 0 && written.text !== before) transientNotes = [normalizeFinding({ message: `${plural(comments, "comment")} in the YAML ${comments === 1 ? "was" : "were"} dropped when the document changed outside the source.`, severity: "warning" }, "format")];
317
+ }
318
+ noteFindings = written.notes;
319
+ if (format !== "json") syncJsonSafe();
320
+ if (written.text !== control.api.getValue()) show(written.text);
321
+ else baseline = written.text;
322
+ renderFindings();
323
+ syncAttributes();
324
+ }
325
+ function syncJsonSafe() { try { syncJson(deck()); } catch { /* Keep the last JSON. */ } }
326
+
327
+ const unsubscribe = editor.subscribe((event) => {
328
+ if (destroyed) return;
329
+ if (event.type === "catalogs") { control.setCatalogs(options.catalogs ?? editor.catalogs); renderFindings(); return; }
330
+ if (applying) return;
331
+ version++;
332
+ fromSession();
333
+ });
334
+
335
+ // ---- Format -------------------------------------------------------------------------------------------------------
336
+ function setFormatInternal(next, notify) {
337
+ const target = formatOf(next);
338
+ if (target.id === format) return true;
339
+ flush();
340
+ const previous = format;
341
+ let written;
342
+ const draft = drafts[target.id];
343
+ if (draft && draft.version === version) written = { text: draft.text, notes: [], draft: true };
344
+ else {
345
+ try { written = write(target.id, deck()); }
346
+ catch (error) {
347
+ rejectionFindings = [normalizeFinding({ message: `The document cannot be written as ${target.label}: ${errorText(error)} Fix the findings first.`, kind: "rejected" })];
348
+ renderFindings();
349
+ return false;
350
+ }
351
+ }
352
+ if (dirty) drafts[previous] = { text: control.api.getValue(), version };
353
+ delete drafts[target.id];
354
+ format = target.id;
355
+ transientNotes = [];
356
+ rejectionFindings = [];
357
+ syntaxFindings = [];
358
+ noteFindings = written.notes;
359
+ dirty = false;
360
+ control.setLanguage(target.language);
361
+ show(written.text);
362
+ if (format === "md") refreshNotes();
363
+ // A draft the document never accepted is checked and applied again, so its findings come back with it.
364
+ if (written.draft) { baseline = null; flush(); }
365
+ updateBar();
366
+ renderFindings();
367
+ syncAttributes();
368
+ if (notify) options.onFormatChange?.(format, previous);
369
+ return true;
370
+ }
371
+
372
+ // ---- Menu ---------------------------------------------------------------------------------------------------------
373
+ const menuOpen = () => !menu.hidden;
374
+ function openMenu(focus = "checked") {
375
+ menu.hidden = false;
376
+ formatButton.setAttribute("aria-expanded", "true");
377
+ const target = focus === "last" ? menuItems[menuItems.length - 1] : menuItems.find((item) => item.getAttribute("aria-checked") === "true") ?? menuItems[0];
378
+ target.focus();
379
+ document.addEventListener("pointerdown", outside, true);
380
+ }
381
+ function closeMenu(returnFocus) {
382
+ if (menu.hidden) return;
383
+ menu.hidden = true;
384
+ formatButton.setAttribute("aria-expanded", "false");
385
+ document.removeEventListener("pointerdown", outside, true);
386
+ if (returnFocus) formatButton.focus();
387
+ }
388
+ function outside(event) { if (!file.contains(event.target)) closeMenu(false); }
389
+ formatButton.addEventListener("click", () => { if (menuOpen()) closeMenu(true); else openMenu(); });
390
+ formatButton.addEventListener("keydown", (event) => {
391
+ if (event.key === "ArrowDown") { event.preventDefault(); if (!menuOpen()) openMenu(); else menuItems[0].focus(); }
392
+ else if (event.key === "ArrowUp") { event.preventDefault(); if (!menuOpen()) openMenu("last"); }
393
+ else if (event.key === "Escape" && menuOpen()) { event.preventDefault(); closeMenu(true); }
394
+ });
395
+ menu.addEventListener("keydown", (event) => {
396
+ const index = menuItems.indexOf(document.activeElement);
397
+ const move = (to) => { event.preventDefault(); menuItems[(to + menuItems.length) % menuItems.length].focus(); };
398
+ if (event.key === "ArrowDown") move(index + 1);
399
+ else if (event.key === "ArrowUp") move(index - 1);
400
+ else if (event.key === "Home") move(0);
401
+ else if (event.key === "End") move(menuItems.length - 1);
402
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeMenu(true); }
403
+ else if (event.key === "Tab") closeMenu(false);
404
+ });
405
+ for (const item of menuItems) {
406
+ item.addEventListener("click", () => {
407
+ const chosen = item.dataset.format;
408
+ closeMenu(true);
409
+ setFormatInternal(chosen, true);
410
+ });
411
+ }
412
+
413
+ // ---- Rename -------------------------------------------------------------------------------------------------------
414
+ function sanitizeName(value) { return String(value).replace(/[/\\\s]/g, "-"); }
415
+ const widthFor = (value) => `${Math.max(3, value.length) + 1.5}ch`;
416
+ function startRename() {
417
+ if (readOnly) return;
418
+ closeMenu(false);
419
+ nameInput.value = name;
420
+ nameInput.style.width = widthFor(name);
421
+ nameButton.hidden = true;
422
+ nameInput.hidden = false;
423
+ nameInput.focus();
424
+ nameInput.select();
425
+ }
426
+ let committing = false;
427
+ function commitRename() {
428
+ if (nameInput.hidden || committing) return;
429
+ committing = true;
430
+ const next = sanitizeName(nameInput.value) || "deck";
431
+ const changed = next !== name;
432
+ const hadFocus = document.activeElement === nameInput;
433
+ name = next;
434
+ nameInput.hidden = true;
435
+ nameButton.hidden = false;
436
+ if (hadFocus) nameButton.focus();
437
+ updateBar();
438
+ committing = false;
439
+ if (changed) options.onRename?.(name);
440
+ }
441
+ nameButton.addEventListener("click", startRename);
442
+ nameInput.addEventListener("input", () => {
443
+ const start = nameInput.selectionStart;
444
+ const clean = sanitizeName(nameInput.value);
445
+ if (clean !== nameInput.value) { nameInput.value = clean; nameInput.setSelectionRange(start, start); }
446
+ nameInput.style.width = widthFor(clean);
447
+ });
448
+ nameInput.addEventListener("keydown", (event) => {
449
+ if (event.key === "Enter" || event.key === "Escape") { event.preventDefault(); event.stopPropagation(); commitRename(); }
450
+ });
451
+ nameInput.addEventListener("blur", commitRename);
452
+
453
+ function updateBar() {
454
+ const current = formatOf(format);
455
+ nameButton.textContent = name;
456
+ nameButton.setAttribute("aria-label", `Rename ${name}`);
457
+ formatLabel.textContent = `.opf.${current.extension}`;
458
+ root.dataset.format = current.id;
459
+ for (const item of menuItems) item.setAttribute("aria-checked", String(item.dataset.format === current.id));
460
+ }
461
+
462
+ // ---- Actions ------------------------------------------------------------------------------------------------------
463
+ const announce = (message) => {
464
+ status.textContent = message;
465
+ clearTimeout(copyTimer);
466
+ copyTimer = setTimeout(() => { status.textContent = ""; copyAction.replaceChildren(icon("Copy", 14)); }, 1500);
467
+ };
468
+ function currentFile() {
469
+ flush();
470
+ const current = formatOf(format);
471
+ return { name, filename: `${name}.opf.${current.extension}`, text: control.api.getValue(), format: current.id, mime: current.mime };
472
+ }
473
+ formatAction.addEventListener("click", () => {
474
+ flush();
475
+ if (readOnly) return;
476
+ if (format === "json") { control.api.format(); control.api.focus(); return; }
477
+ const read = readText(format, control.api.getValue(), catalogs());
478
+ if (!read.deck) { renderFindings(); return; }
479
+ let written;
480
+ try { written = write(format, read.deck); }
481
+ catch (error) {
482
+ rejectionFindings = [normalizeFinding({ message: `Cannot rewrite the ${formatOf(format).label}: ${errorText(error)}`, kind: "rejected" })];
483
+ renderFindings();
484
+ return;
485
+ }
486
+ const before = control.api.getValue();
487
+ if (written.text !== before) {
488
+ const comments = format === "yaml" ? scanYamlComments(before).count : 0;
489
+ control.api.replace(0, before.length, written.text);
490
+ flush();
491
+ transientNotes = comments > 0 ? [normalizeFinding({ message: `${plural(comments, "comment")} in the YAML ${comments === 1 ? "was" : "were"} dropped when the text was rewritten.`, severity: "warning" }, "format")] : [];
492
+ renderedKey = "";
493
+ renderFindings();
494
+ }
495
+ control.api.focus();
496
+ });
497
+ copyAction.addEventListener("click", async () => {
498
+ const info = currentFile();
499
+ try {
500
+ if (options.onCopy) await options.onCopy(info);
501
+ else await navigator.clipboard.writeText(info.text);
502
+ copyAction.replaceChildren(icon("Check", 14));
503
+ announce("Copied");
504
+ } catch (error) {
505
+ announce("Copy failed");
506
+ options.onError?.(error);
507
+ }
508
+ });
509
+ downloadAction.addEventListener("click", async () => {
510
+ const info = currentFile();
511
+ try {
512
+ if (options.onDownload) await options.onDownload(info);
513
+ else {
514
+ const url = URL.createObjectURL(new Blob([info.text], { type: `${info.mime};charset=utf-8` }));
515
+ const link = el("a", "", { href: url, download: info.filename, hidden: "" });
516
+ document.body.append(link);
517
+ link.click();
518
+ link.remove();
519
+ setTimeout(() => URL.revokeObjectURL(url), 0);
520
+ }
521
+ } catch (error) { options.onError?.(error); }
522
+ });
523
+
524
+ // ---- Findings -----------------------------------------------------------------------------------------------------
525
+ function sessionFindings() {
526
+ try { return (editor.validation?.findings ?? []).map((finding) => normalizeFinding(finding, finding.ruleId === "opf/schema" ? "schema" : finding.category)); }
527
+ catch { return []; }
528
+ }
529
+ function collect() {
530
+ let host;
531
+ if (typeof options.getFindings === "function") {
532
+ try { host = (options.getFindings({ editor, format, text: control.api.getValue() }) ?? []).map((finding) => normalizeFinding(finding)); }
533
+ catch (error) { options.onError?.(error); host = []; }
534
+ } else if (hostFindings) host = hostFindings.map((finding) => normalizeFinding(finding));
535
+ else host = sessionFindings();
536
+ // A finding repeated by an earlier group is dropped; repeats inside one group stay, so a refusal lists every issue the validator found.
537
+ const seen = new Set();
538
+ const all = [];
539
+ for (const group of [syntaxFindings, rejectionFindings, transientNotes, noteFindings, host]) {
540
+ const keys = [];
541
+ for (const finding of group) {
542
+ const key = `${finding.severity}|${finding.path}|${finding.message}`;
543
+ if (seen.has(key)) continue;
544
+ keys.push(key);
545
+ all.push(finding);
546
+ }
547
+ for (const key of keys) seen.add(key);
548
+ }
549
+ return all.map((finding, index) => ({ finding, index })).sort((a, b) => SEVERITY[a.finding.severity] - SEVERITY[b.finding.severity] || a.index - b.index).map((entry) => entry.finding);
550
+ }
551
+ function lineOf(finding) {
552
+ const value = control.api.getValue();
553
+ if (lineCache.key !== `${format}|${value}`) { lineCache.key = `${format}|${value}`; lineCache.values.clear(); }
554
+ const explicit = finding.line;
555
+ if (explicit && (finding.kind === "syntax" || finding.path === "")) return explicit;
556
+ const cacheKey = finding.path;
557
+ if (!lineCache.values.has(cacheKey)) lineCache.values.set(cacheKey, finding.path === "" ? null : lineForPath(format, value, finding.path));
558
+ return lineCache.values.get(cacheKey) ?? explicit ?? null;
559
+ }
560
+ function renderFindings() {
561
+ paintFindings();
562
+ if (!destroyed && invalid !== shownInvalid) syncAttributes();
563
+ }
564
+ function paintFindings() {
565
+ if (destroyed) return;
566
+ const all = collect();
567
+ const rows = all.map((finding) => ({ finding, line: lineOf(finding) }));
568
+ invalid = rows.some((row) => row.finding.severity === "error");
569
+ const key = JSON.stringify([format, collapsed, rows.map(({ finding, line }) => [finding.severity, finding.path, finding.message, finding.kind, line])]);
570
+ if (key === renderedKey) return;
571
+ renderedKey = key;
572
+ findingsRegion.hidden = rows.length === 0;
573
+ root.dataset.findings = String(rows.length);
574
+ if (!rows.length) { list.replaceChildren(); return; }
575
+ const errors = rows.filter((row) => row.finding.severity === "error");
576
+ const warnings = rows.filter((row) => row.finding.severity === "warning");
577
+ const infos = rows.length - errors.length - warnings.length;
578
+ const parts = [];
579
+ if (errors.length) parts.push(plural(errors.length, errors.every((row) => row.finding.kind === "schema" || !row.finding.kind) ? "schema error" : "error"));
580
+ if (warnings.length) parts.push(plural(warnings.length, "warning"));
581
+ if (infos) parts.push(plural(infos, "note"));
582
+ toggleText.textContent = parts.join(" · ");
583
+ const tone = errors.length ? "error" : warnings.length ? "warning" : "info";
584
+ findingsRegion.dataset.tone = tone;
585
+ toggleIcon.replaceChildren(icon(tone === "error" ? "CircleX" : "Info", 13));
586
+ toggle.setAttribute("aria-expanded", String(!collapsed));
587
+ list.hidden = collapsed;
588
+ const shown = rows.slice(0, 200);
589
+ list.replaceChildren(...shown.map(({ finding, line }) => {
590
+ const item = el("li", "opf-source-finding-item");
591
+ const button = el("button", "opf-source-finding", { type: "button", "data-severity": finding.severity });
592
+ if (finding.kind) button.dataset.kind = finding.kind;
593
+ const path = finding.path ? el("code", "opf-source-finding-path") : null;
594
+ if (path) path.textContent = finding.path;
595
+ const message = Object.assign(el("span", "opf-source-finding-message"), { textContent: finding.message });
596
+ const where = Object.assign(el("span", "opf-source-finding-line"), { textContent: line ? `Ln ${line}` : "" });
597
+ button.append(...(path ? [path] : []), message, where);
598
+ button.addEventListener("click", () => {
599
+ const target = focusPath(finding.path, finding);
600
+ options.onFindingSelect?.(finding, target);
601
+ });
602
+ item.append(button);
603
+ return item;
604
+ }));
605
+ if (rows.length > shown.length) list.append(Object.assign(el("li", "opf-source-finding-more"), { textContent: `${rows.length - shown.length} more not shown.` }));
606
+ }
607
+ toggle.addEventListener("click", () => { collapsed = !collapsed; renderedKey = ""; renderFindings(); });
608
+
609
+ // ---- Public API ---------------------------------------------------------------------------------------------------
610
+ function focusPath(path, finding) {
611
+ flush();
612
+ const value = control.api.getValue();
613
+ let line = path === "" || path === undefined ? null : lineForPath(format, value, path);
614
+ if (line === null && finding) line = lineOf(finding);
615
+ if (line === null) line = 1;
616
+ control.api.setSelection(offsetOfLine(value, line));
617
+ control.api.focus();
618
+ return line;
619
+ }
620
+
621
+ updateBar();
622
+ root.toggleAttribute("data-readonly", readOnly);
623
+ nameButton.disabled = readOnly;
624
+ formatAction.disabled = readOnly;
625
+ renderFindings();
626
+ syncAttributes();
627
+
628
+ return {
629
+ element: root,
630
+ /** Re-read the document from the session (for example after the host changed it without events). */
631
+ refresh() { version++; fromSession(false); },
632
+ /** Replace the host findings; `setFindings(null)` returns to the session's own validation. */
633
+ setFindings(findings) {
634
+ hostFindings = Array.isArray(findings) ? findings : null;
635
+ renderedKey = "";
636
+ renderFindings();
637
+ },
638
+ /** Switch the source format ("json", "yaml" or "md"). Returns false when the document cannot be written in it. */
639
+ setFormat: (next) => setFormatInternal(next, false),
640
+ getFormat: () => format,
641
+ /** Move the cursor to the line of a JSON Pointer in the current format (best effort for YAML and Markdown). Returns the line. */
642
+ focusPath: (path) => focusPath(path),
643
+ /** The source text exactly as shown, with any edit still waiting applied first. */
644
+ getText() { flush(); return control.api.getValue(); },
645
+ getName: () => name,
646
+ setName(value) { name = sanitizeName(value) || "deck"; updateBar(); },
647
+ setDensity(value) { density = value === "touch" ? "touch" : "comfortable"; root.dataset.density = density; },
648
+ setReadOnly(value) {
649
+ readOnly = Boolean(value);
650
+ root.toggleAttribute("data-readonly", readOnly);
651
+ nameButton.disabled = readOnly;
652
+ formatAction.disabled = readOnly;
653
+ control.setReadOnly(readOnly);
654
+ },
655
+ /** The code editor, for hosts that focus or measure it. */
656
+ editor: control,
657
+ destroy() {
658
+ if (destroyed) return;
659
+ flush();
660
+ destroyed = true;
661
+ clearTimeout(timer);
662
+ clearTimeout(copyTimer);
663
+ unsubscribe();
664
+ document.removeEventListener("pointerdown", outside, true);
665
+ control.destroy();
666
+ root.remove();
667
+ },
668
+ };
669
+ }