@openpresentation/opf-editor 0.10.6 → 0.11.1

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.
Files changed (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
@@ -0,0 +1,359 @@
1
+ // Review panel (RR-29): the DOM panel over core's design and accessibility audit. It lists the findings of the
2
+ // open presentation (contrast, overflow, alt text, reading order, fonts, links and more), lets the author go to
3
+ // the content a finding is about, offers safe quick fixes (type the alt text in place, switch a failing text
4
+ // colour to the readable one) and updates as the document changes. The panel owns no document state: every fix
5
+ // is an undoable edit through the editor session. Importing this module does not need a DOM; mounting does.
6
+ import {
7
+ applyReviewFix,
8
+ auditAvailable,
9
+ countFindings,
10
+ currentAltText,
11
+ filterFindings,
12
+ findingTarget,
13
+ reviewFindings,
14
+ reviewSeverities,
15
+ runAudit,
16
+ setReviewAltText,
17
+ } from "./review.js";
18
+
19
+ const SEVERITY_LABELS = { error: "Error", warning: "Warning", info: "Note" };
20
+ const MINIMUMS = { all: "info", warnings: "warning", errors: "error" };
21
+ let panelCounter = 0;
22
+
23
+ function h(doc, tag, attributes = {}, ...children) {
24
+ const element = doc.createElement(tag);
25
+ for (const [key, value] of Object.entries(attributes)) {
26
+ if (value === undefined || value === false) continue;
27
+ if (key === "class") element.className = value;
28
+ else if (key === "text") element.textContent = value;
29
+ else if (key.startsWith("on")) element.addEventListener(key.slice(2), value);
30
+ else element.setAttribute(key, value === true ? "" : String(value));
31
+ }
32
+ for (const child of children) if (child) element.append(child);
33
+ return element;
34
+ }
35
+
36
+ const STYLE = `
37
+ .opf-review{display:block;font-size:12px}
38
+ .opf-review-head{display:flex;flex-wrap:wrap;gap:4px 12px;align-items:baseline;margin:0 0 8px}
39
+ .opf-review-head h2{margin:0;font-size:13px}
40
+ .opf-review-counts{margin:0;color:var(--muted,#656570)}
41
+ .opf-review-filters{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;margin:0 0 8px}
42
+ .opf-review-filters label{display:inline-flex;gap:6px;align-items:center;margin:0;font-weight:400}
43
+ .opf-review-list{list-style:none;margin:0;padding:0}
44
+ .opf-review-item{border-top:1px solid var(--border,#e8e8ec);padding:8px 0}
45
+ .opf-review-item[aria-current="true"]{background:var(--review-current,rgba(101,89,207,.07))}
46
+ .opf-review-goto{display:block;width:100%;box-sizing:border-box;white-space:normal;overflow-wrap:anywhere;line-height:1.45;text-align:left;background:none;border:0;padding:2px 4px;font:inherit;color:inherit;cursor:pointer;border-radius:4px}
47
+ .opf-review-goto:hover{background:rgba(0,0,0,.04)}
48
+ .opf-review-goto:focus-visible,.opf-review-fix:focus-visible,.opf-review-alt input:focus-visible{outline:2px solid #6559cf;outline-offset:1px}
49
+ .opf-review-sev{display:inline-block;min-width:5.2em;margin-right:6px;padding:0 6px;border:1px solid currentColor;border-radius:9px;font-size:11px;font-weight:600;text-align:center}
50
+ .opf-review-item[data-severity="error"] .opf-review-sev{color:#b3261e}
51
+ .opf-review-item[data-severity="warning"] .opf-review-sev{color:#8a5a00}
52
+ .opf-review-item[data-severity="info"] .opf-review-sev{color:#44556a}
53
+ .opf-review-meta{display:block;margin-top:2px;color:var(--muted,#656570);font-size:11px}
54
+ .opf-review-help{margin:4px 4px 6px;color:var(--muted,#656570)}
55
+ .opf-review-actions{display:flex;flex-wrap:wrap;gap:6px;margin:0 4px}
56
+ .opf-review-fix{font:inherit;padding:3px 8px;border:1px solid var(--border,#d4d4da);border-radius:5px;background:#fff;cursor:pointer}
57
+ .opf-review-fix.is-careful{border-style:dashed}
58
+ .opf-review-fix.quiet{border-color:transparent;background:none;color:var(--muted,#656570);text-decoration:underline}
59
+ .opf-review-alt{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:6px 4px 0}
60
+ .opf-review-alt label{flex:1 0 100%;font-weight:600}
61
+ .opf-review-alt input{flex:1 1 12em;min-width:0;font:inherit;padding:4px 6px}
62
+ .opf-review-empty{margin:8px 0;color:var(--muted,#656570)}
63
+ .opf-review-ignored{margin-top:8px}
64
+ .opf-review-ignored li{display:flex;gap:8px;align-items:center;margin:4px 0}
65
+ .opf-review-error{color:#b3261e}
66
+ `;
67
+
68
+ function ensureStyles(doc) {
69
+ if (doc.querySelector("style[data-opf-review]")) return;
70
+ const style = doc.createElement("style");
71
+ style.setAttribute("data-opf-review", "");
72
+ style.textContent = STYLE;
73
+ (doc.head ?? doc.documentElement).append(style);
74
+ }
75
+
76
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
77
+ const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
78
+
79
+ function summaryText(counts, scopeLabel) {
80
+ if (!counts.total) return `No findings${scopeLabel}.`;
81
+ const parts = [];
82
+ if (counts.error) parts.push(plural(counts.error, "error"));
83
+ if (counts.warning) parts.push(plural(counts.warning, "warning"));
84
+ if (counts.info) parts.push(plural(counts.info, "note"));
85
+ return `${plural(counts.total, "finding")}${scopeLabel}: ${parts.join(", ")}.`;
86
+ }
87
+
88
+ /**
89
+ * Mount the Review panel. Options: `editor` (a session), `getSlideIndex()` (the slide the "This slide only" filter
90
+ * follows), `getAuditOptions(document)` (core's AuditOptions; pass `textMeasurement` from the host's font registry
91
+ * for font-exact overflow), `onGoTo({finding, target})` (select the content: `target` is {slide, path, pointer, exact}),
92
+ * `onFocusField({finding, fix, target})` (focus a field the panel does not own: a title, text, link or language),
93
+ * `onStatus(message, {error})`, `onChange({error, warning, info, total, all, unavailable})` (after every redraw, with the shown counts), `ignored` (rule ids hidden at the start) and `onIgnoredChange(ids)`,
94
+ * `autoRefresh` (default true: re-audit after every session change; a host that must wait for its fonts sets false and
95
+ * calls `refresh()` itself), `audit` (replace core's audit function).
96
+ */
97
+ export function createReviewPanel(container, options) {
98
+ const { editor, getSlideIndex, getAuditOptions, onGoTo, onFocusField, onStatus, onIgnoredChange, onChange } = options;
99
+ if (!editor || typeof editor.subscribe !== "function") throw new Error("The Review panel needs an editor session created by createEditorSession.");
100
+ const doc = container.ownerDocument;
101
+ ensureStyles(doc);
102
+ const id = `opf-review-${++panelCounter}`;
103
+ const ignored = new Set(options.ignored ?? []);
104
+ let report;
105
+ let findings = [];
106
+ let destroyed = false;
107
+ let scheduled = false;
108
+ let currentId;
109
+ let editingAlt;
110
+ let altDraft = "";
111
+ let lastAuditError;
112
+
113
+ const root = h(doc, "section", { class: "opf-review", "data-opf-component": "review-panel", "aria-labelledby": `${id}-title` });
114
+ const counts = h(doc, "p", { class: "opf-review-counts", id: `${id}-counts`, role: "status", "aria-live": "polite" });
115
+ const title = h(doc, "h2", { id: `${id}-title`, text: "Review", tabindex: "-1" });
116
+ const head = h(doc, "div", { class: "opf-review-head" }, title, counts);
117
+ const severitySelect = h(doc, "select", { id: `${id}-severity` },
118
+ h(doc, "option", { value: "all", text: "All findings" }),
119
+ h(doc, "option", { value: "warnings", text: "Errors and warnings" }),
120
+ h(doc, "option", { value: "errors", text: "Errors only" }));
121
+ const slideOnly = h(doc, "input", { type: "checkbox", id: `${id}-slide` });
122
+ const filters = h(doc, "div", { class: "opf-review-filters" },
123
+ h(doc, "label", { for: `${id}-severity` }, "Show ", severitySelect),
124
+ h(doc, "label", { for: `${id}-slide` }, slideOnly, "This slide only"));
125
+ const list = h(doc, "ul", { class: "opf-review-list", "aria-label": "Review findings" });
126
+ const empty = h(doc, "p", { class: "opf-review-empty" });
127
+ const ignoredBox = h(doc, "details", { class: "opf-review-ignored", hidden: true });
128
+ root.append(head, filters, list, empty, ignoredBox);
129
+ container.append(root);
130
+
131
+ const say = (message, error = false) => {
132
+ onStatus?.(message, { error });
133
+ };
134
+
135
+ function visible() {
136
+ const minimum = MINIMUMS[severitySelect.value] ?? "info";
137
+ const slide = slideOnly.checked ? (getSlideIndex?.() ?? 0) : undefined;
138
+ return filterFindings(findings, { minimum, slide }).filter((finding) => !ignored.has(finding.ruleId));
139
+ }
140
+
141
+ function audit() {
142
+ lastAuditError = undefined;
143
+ try {
144
+ report = runAudit(editor.document, { ...(getAuditOptions?.(editor.document) ?? {}), ...(options.audit ? { audit: options.audit } : {}) });
145
+ findings = reviewFindings(report);
146
+ } catch (error) {
147
+ report = undefined;
148
+ findings = [];
149
+ lastAuditError = error;
150
+ }
151
+ }
152
+
153
+ function focusGoto(findingId) {
154
+ const buttons = [...list.querySelectorAll(".opf-review-goto")];
155
+ (buttons.find((button) => button.dataset.findingId === findingId) ?? buttons[0])?.focus();
156
+ }
157
+
158
+ function go(finding) {
159
+ currentId = finding.id;
160
+ const target = findingTarget(editor.document, finding);
161
+ for (const item of list.children) item.setAttribute("aria-current", String(item.dataset.findingId === finding.id));
162
+ onGoTo?.({ finding, target });
163
+ }
164
+
165
+ function altForm(finding, fix) {
166
+ const pointer = fix.focus.path;
167
+ const inputId = `${id}-alt-${finding.id.replace(/[^a-z0-9]+/gi, "-")}`;
168
+ const input = h(doc, "input", { type: "text", id: inputId, value: altDraft || currentAltText(editor.document, pointer), "aria-describedby": `${inputId}-help` });
169
+ const error = h(doc, "span", { class: "opf-review-error", role: "alert" });
170
+ const form = h(doc, "form", { class: "opf-review-alt", novalidate: true },
171
+ h(doc, "label", { for: inputId, text: "Alt text" }), input,
172
+ h(doc, "button", { type: "submit", class: "opf-review-fix", text: "Save alt text" }),
173
+ h(doc, "button", { type: "button", class: "opf-review-fix quiet", text: "Cancel", onclick: () => closeAlt(finding) }),
174
+ h(doc, "span", { id: `${inputId}-help`, class: "opf-review-meta", text: "Describe what the picture shows. Press Escape to cancel." }), error);
175
+ input.addEventListener("input", () => { altDraft = input.value; });
176
+ form.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.stopPropagation(); closeAlt(finding); } });
177
+ form.addEventListener("submit", (event) => {
178
+ event.preventDefault();
179
+ try {
180
+ setReviewAltText(editor, pointer, input.value);
181
+ editingAlt = undefined;
182
+ altDraft = "";
183
+ say("Alt text saved. Undo restores the previous text.");
184
+ } catch (problem) {
185
+ error.textContent = messageOf(problem);
186
+ say(messageOf(problem), true);
187
+ input.focus();
188
+ }
189
+ });
190
+ return form;
191
+ }
192
+
193
+ function closeAlt(finding) {
194
+ editingAlt = undefined;
195
+ altDraft = "";
196
+ renderList();
197
+ focusGoto(finding.id);
198
+ }
199
+
200
+ function runFix(finding, fix) {
201
+ if (fix.kind === "focus") {
202
+ if (fix.focus?.field === "alt") {
203
+ editingAlt = finding.id;
204
+ altDraft = "";
205
+ renderList();
206
+ [...list.children].find((entry) => entry.dataset.findingId === finding.id)?.querySelector(".opf-review-alt input")?.focus();
207
+ return;
208
+ }
209
+ go(finding);
210
+ onFocusField?.({ finding, fix, target: findingTarget(editor.document, finding) });
211
+ return;
212
+ }
213
+ const index = visible().findIndex((entry) => entry.id === finding.id);
214
+ try {
215
+ applyReviewFix(editor, finding, fix);
216
+ say(`${fix.label}. Undo restores the previous state.`);
217
+ // The change re-audits the document (the subscription); put focus on the neighbouring finding.
218
+ const after = visible();
219
+ const next = after[Math.min(Math.max(index, 0), after.length - 1)];
220
+ if (next) focusGoto(next.id);
221
+ else title.focus();
222
+ } catch (error) {
223
+ say(messageOf(error), true);
224
+ if (error?.code === "stale-finding") refresh();
225
+ }
226
+ }
227
+
228
+ function item(finding) {
229
+ const where = finding.slide === null ? "Presentation" : `Slide ${finding.slide + 1}`;
230
+ const go_ = h(doc, "button", {
231
+ type: "button",
232
+ class: "opf-review-goto",
233
+ "data-finding-id": finding.id,
234
+ "aria-label": `${SEVERITY_LABELS[finding.severity]}, ${where}: ${finding.message} Go to it.`,
235
+ onclick: () => go(finding),
236
+ },
237
+ h(doc, "span", { class: "opf-review-sev", text: SEVERITY_LABELS[finding.severity] }),
238
+ h(doc, "span", { class: "opf-review-msg", text: finding.message }),
239
+ h(doc, "span", { class: "opf-review-meta", text: `${where} · ${finding.ruleId}` }));
240
+ const li = h(doc, "li", { class: "opf-review-item", "data-finding-id": finding.id, "data-rule": finding.ruleId, "data-severity": finding.severity, "aria-current": String(finding.id === currentId) }, go_,
241
+ h(doc, "p", { class: "opf-review-help", text: finding.help }));
242
+ const actions = h(doc, "div", { class: "opf-review-actions" });
243
+ for (const fix of finding.fixes ?? []) {
244
+ actions.append(h(doc, "button", {
245
+ type: "button",
246
+ class: `opf-review-fix${fix.safe ? "" : " is-careful"}`,
247
+ "data-fix": fix.id,
248
+ title: fix.safe ? undefined : "This changes how the slide looks or what it says; it can be undone.",
249
+ text: fix.label,
250
+ onclick: () => runFix(finding, fix),
251
+ }));
252
+ }
253
+ actions.append(h(doc, "button", {
254
+ type: "button",
255
+ class: "opf-review-fix quiet",
256
+ "data-action": "ignore-rule",
257
+ text: "Hide this check",
258
+ title: `Stop listing ${finding.ruleId} findings in this panel`,
259
+ onclick: () => setIgnored(finding.ruleId, true),
260
+ }));
261
+ li.append(actions);
262
+ if (editingAlt === finding.id) {
263
+ const fix = (finding.fixes ?? []).find((entry) => entry.kind === "focus" && entry.focus?.field === "alt");
264
+ if (fix) li.append(altForm(finding, fix));
265
+ }
266
+ return li;
267
+ }
268
+
269
+ function setIgnored(ruleId, hidden) {
270
+ if (hidden) ignored.add(ruleId);
271
+ else ignored.delete(ruleId);
272
+ onIgnoredChange?.([...ignored]);
273
+ say(hidden ? `Hiding ${ruleId} findings.` : `Showing ${ruleId} findings again.`);
274
+ renderList();
275
+ }
276
+
277
+ function renderIgnored() {
278
+ ignoredBox.hidden = ignored.size === 0;
279
+ if (!ignored.size) { ignoredBox.replaceChildren(); return; }
280
+ ignoredBox.replaceChildren(
281
+ h(doc, "summary", { text: `Hidden checks (${ignored.size})` }),
282
+ h(doc, "ul", { class: "opf-review-ignored-list", style: "list-style:none;padding:0" }, ...[...ignored].map((ruleId) => h(doc, "li", {}, h(doc, "span", { text: ruleId }),
283
+ h(doc, "button", { type: "button", class: "opf-review-fix quiet", "data-restore": ruleId, text: "Show again", "aria-label": `Show ${ruleId} findings again`, onclick: () => setIgnored(ruleId, false) })))),
284
+ );
285
+ }
286
+
287
+ function renderList() {
288
+ if (destroyed) return;
289
+ const hadFocus = doc.activeElement && list.contains(doc.activeElement) ? { id: doc.activeElement.closest("[data-finding-id]")?.dataset.findingId, fix: doc.activeElement.dataset.fix, tag: doc.activeElement.tagName } : undefined;
290
+ const shown = visible();
291
+ const slideScope = slideOnly.checked ? ` on slide ${(getSlideIndex?.() ?? 0) + 1}` : "";
292
+ list.replaceChildren(...shown.map(item));
293
+ if (lastAuditError) {
294
+ empty.hidden = false;
295
+ empty.textContent = lastAuditError.code === "audit-unavailable" ? lastAuditError.message : `The review could not run: ${messageOf(lastAuditError)}`;
296
+ counts.textContent = "Review unavailable.";
297
+ } else {
298
+ const hiddenCount = findings.length - findings.filter((finding) => !ignored.has(finding.ruleId)).length;
299
+ counts.textContent = summaryText(countFindings(shown), slideScope) + (hiddenCount ? ` ${plural(hiddenCount, "finding")} hidden.` : "");
300
+ empty.hidden = shown.length > 0;
301
+ empty.textContent = findings.length && !shown.length ? "Nothing to show with these filters." : "The audit found nothing to fix. It checks contrast, text fit, alt text, reading order and more; it does not replace looking at the slides.";
302
+ }
303
+ renderIgnored();
304
+ onChange?.({ ...countFindings(shown), all: findings.length, unavailable: Boolean(lastAuditError) });
305
+ // Live updates must not take the keyboard away: put focus back on the same control, or the same finding.
306
+ if (hadFocus?.id && !lastAuditError) {
307
+ const li = [...list.children].find((entry) => entry.dataset.findingId === hadFocus.id);
308
+ const target = (hadFocus.fix ? [...li?.querySelectorAll("[data-fix]") ?? []].find((button) => button.dataset.fix === hadFocus.fix) : hadFocus.tag === "INPUT" ? li?.querySelector("input") : li?.querySelector(".opf-review-goto")) ?? li?.querySelector(".opf-review-goto");
309
+ if (target && doc.activeElement !== target) target.focus();
310
+ }
311
+ }
312
+
313
+ function refresh() {
314
+ if (destroyed) return;
315
+ scheduled = false;
316
+ audit();
317
+ renderList();
318
+ }
319
+
320
+ const unsubscribe = options.autoRefresh === false ? undefined : editor.subscribe(() => {
321
+ if (scheduled || destroyed) return;
322
+ scheduled = true;
323
+ // One re-audit per burst of edits (an undo group, a paste), after the session has settled.
324
+ Promise.resolve().then(refresh);
325
+ });
326
+
327
+ severitySelect.addEventListener("change", renderList);
328
+ slideOnly.addEventListener("change", renderList);
329
+ list.addEventListener("keydown", (event) => {
330
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
331
+ const target = event.target;
332
+ if (!target?.classList?.contains("opf-review-goto")) return;
333
+ const buttons = [...list.querySelectorAll(".opf-review-goto")];
334
+ const at = buttons.indexOf(target);
335
+ const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, at + (event.key === "ArrowDown" ? 1 : -1)));
336
+ event.preventDefault();
337
+ buttons[next]?.focus();
338
+ });
339
+
340
+ refresh();
341
+
342
+ return {
343
+ element: root,
344
+ /** The latest findings (core's diagnostics with `id`, `slide` and `dottedPath`), before filters and hidden checks. */
345
+ get findings() { return findings; },
346
+ get report() { return report; },
347
+ refresh,
348
+ /** Re-render after the host's slide changed (the "This slide only" filter). */
349
+ update: renderList,
350
+ setIgnored,
351
+ destroy() {
352
+ destroyed = true;
353
+ unsubscribe?.();
354
+ root.remove();
355
+ },
356
+ };
357
+ }
358
+
359
+ export { auditAvailable, reviewSeverities };
@@ -0,0 +1,75 @@
1
+ import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
+
3
+ export type ReviewSeverity = "error" | "warning" | "info";
4
+
5
+ /** A suggested repair from core's audit (the structural subset the editor uses). */
6
+ export interface ReviewFix {
7
+ id: string;
8
+ label: string;
9
+ kind: "patch" | "focus";
10
+ /** True when the change cannot alter what the content says. */
11
+ safe: boolean;
12
+ patch?: JsonPatchOperation[];
13
+ focus?: { path: string; field: "alt" | "title" | "text" | "link" | "language" | "fontSize"; value?: unknown };
14
+ }
15
+
16
+ /** One finding of core's audit, with the fields the editor adds. */
17
+ export interface ReviewFinding {
18
+ /** Unique within the report: the rule id and the path, numbered when they repeat. */
19
+ id: string;
20
+ ruleId: string;
21
+ severity: ReviewSeverity;
22
+ /** JSON Pointer of the content the finding is about. */
23
+ path: string;
24
+ /** The same path in OPF dotted form (`slides.0.text.1.color`). */
25
+ dottedPath: string;
26
+ /** Zero-based slide index, or null for a finding about the whole presentation. */
27
+ slide: number | null;
28
+ message: string;
29
+ help: string;
30
+ category?: "accessibility" | "design" | "content";
31
+ measured?: Record<string, number | string | boolean | null>;
32
+ fixes?: ReviewFix[];
33
+ }
34
+
35
+ export interface ReviewReport {
36
+ valid: boolean;
37
+ documentValid: boolean;
38
+ diagnostics: Omit<ReviewFinding, "id" | "dottedPath">[];
39
+ counts: Record<ReviewSeverity, number>;
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ /** Core's `AuditOptions` (severity per rule, `ignore`, `only`, `thresholds`, `textMeasurement`, ...). */
44
+ export type ReviewAuditOptions = Record<string, unknown> & {
45
+ /** Use this audit function instead of core's `auditPresentation`. */
46
+ audit?: (document: unknown, options?: Record<string, unknown>) => ReviewReport;
47
+ };
48
+
49
+ export interface ReviewTarget {
50
+ /** The slide the finding is on, or null for the presentation. */
51
+ slide: number | null;
52
+ /** OPF dotted path of the deepest existing field on the finding's way. */
53
+ path: string;
54
+ pointer: string;
55
+ /** False when the finding is about a missing field and `path` is its parent. */
56
+ exact: boolean;
57
+ }
58
+
59
+ export declare const reviewSeverities: readonly ReviewSeverity[];
60
+ /** True when the installed core ships the audit (`@openpresentation/opf` after 0.11.4). */
61
+ export declare function auditAvailable(): boolean;
62
+ /** Audit a document with core's `auditPresentation`. Throws `audit-unavailable` when core has none. */
63
+ export declare function runAudit(document: unknown, options?: ReviewAuditOptions): ReviewReport;
64
+ export declare function reviewFindings(report: ReviewReport | undefined): ReviewFinding[];
65
+ export declare function countFindings(findings: readonly ReviewFinding[]): { error: number; warning: number; info: number; total: number };
66
+ export declare function filterFindings(findings: readonly ReviewFinding[], options?: { minimum?: ReviewSeverity; slide?: number | null }): ReviewFinding[];
67
+ /** Where "go to" lands for a finding. */
68
+ export declare function findingTarget(document: unknown, finding: Pick<ReviewFinding, "path">): ReviewTarget;
69
+ /** Apply a `patch` fix as one undoable, validated session change; refuses a stale or unsafe patch. */
70
+ export declare function applyReviewFix(editor: EditorSession, finding: Pick<ReviewFinding, "ruleId"> | undefined, fix: ReviewFix, meta?: Record<string, unknown>): EditorChange;
71
+ /** The patch that sets (or, for `""`, marks decorative) the alt text of the picture at `pointer`; null when nothing changes. */
72
+ export declare function altTextPatch(document: unknown, pointer: string, text: string): JsonPatchOperation[] | null;
73
+ export declare function setReviewAltText(editor: EditorSession, pointer: string, text: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
74
+ export declare function markDecorative(editor: EditorSession, pointer: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
75
+ export declare function currentAltText(document: unknown, pointer: string): string;
package/dist/review.js ADDED
@@ -0,0 +1,170 @@
1
+ // Review (RR-29): the headless model behind the Review panel. It runs core's design and accessibility audit
2
+ // (`auditPresentation`) over the session's document, turns a finding into something an editor can act on (the
3
+ // slide and the nearest existing path to select) and applies its suggested fixes through the session, so every
4
+ // fix is a validated, undoable JSON Patch edit like any other. Importing this module needs no DOM.
5
+ // Core is read from the namespace so an older core still loads this module; the audit then throws "audit-unavailable".
6
+ import * as core from "@openpresentation/opf";
7
+ import { OPFEditorError, getValueAtPath, hasValueAtPath, jsonPointerToOpfPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
8
+ import { assetIdOf, prepareAssetAlt } from "./assets.js";
9
+
10
+ /** True when the installed core ships the audit (`@openpresentation/opf` after 0.11.4). */
11
+ export function auditAvailable() {
12
+ return typeof core.auditPresentation === "function";
13
+ }
14
+
15
+ function fail(code, message, details) {
16
+ return new OPFEditorError(code, message, details);
17
+ }
18
+
19
+ const SEVERITIES = ["error", "warning", "info"];
20
+ const RANK = { error: 3, warning: 2, info: 1 };
21
+ export const reviewSeverities = Object.freeze([...SEVERITIES]);
22
+
23
+ /**
24
+ * Audit a document. `options` are core's `AuditOptions` (severity per rule, `ignore`, thresholds, `textMeasurement`, ...);
25
+ * pass `options.audit` to use another audit function. Returns core's report.
26
+ */
27
+ export function runAudit(document, options = {}) {
28
+ const { audit = core.auditPresentation, ...auditOptions } = options;
29
+ if (typeof audit !== "function") {
30
+ throw fail("audit-unavailable", "The review needs a core release that ships the audit (@openpresentation/opf after 0.11.4).");
31
+ }
32
+ return audit(document, auditOptions);
33
+ }
34
+
35
+ /**
36
+ * The review's findings: core's diagnostics with an `id` unique within the report, the slide index (or null for
37
+ * the deck) and `dottedPath`, the OPF path to select. Sorted by slide, then severity (errors first), then rule.
38
+ */
39
+ export function reviewFindings(report) {
40
+ const seen = new Map();
41
+ return (report?.diagnostics ?? []).map((diagnostic) => {
42
+ const base = `${diagnostic.ruleId}|${diagnostic.path}`;
43
+ const count = seen.get(base) ?? 0;
44
+ seen.set(base, count + 1);
45
+ return {
46
+ ...diagnostic,
47
+ id: count ? `${base}#${count}` : base,
48
+ slide: Number.isInteger(diagnostic.slide) ? diagnostic.slide : slideOfPointer(diagnostic.path),
49
+ dottedPath: safeDotted(diagnostic.path),
50
+ };
51
+ });
52
+ }
53
+
54
+ function slideOfPointer(pointer) {
55
+ const match = /^\/slides\/(\d+)(?:\/|$)/.exec(pointer ?? "");
56
+ return match ? Number(match[1]) : null;
57
+ }
58
+ function safeDotted(pointer) {
59
+ try {
60
+ return jsonPointerToOpfPath(pointer ?? "");
61
+ } catch {
62
+ return "";
63
+ }
64
+ }
65
+
66
+ /** Counts by severity and the number of findings. */
67
+ export function countFindings(findings) {
68
+ const counts = { error: 0, warning: 0, info: 0 };
69
+ for (const finding of findings) counts[finding.severity] += 1;
70
+ return { ...counts, total: findings.length };
71
+ }
72
+
73
+ /** Findings at or above `minimum` severity (`info` keeps all), optionally only those of one slide (or the deck, `null`). */
74
+ export function filterFindings(findings, { minimum = "info", slide } = {}) {
75
+ return findings.filter((finding) => RANK[finding.severity] >= RANK[minimum] && (slide === undefined || finding.slide === slide));
76
+ }
77
+
78
+ /**
79
+ * Where "go to" lands for a finding: the slide and the deepest existing path on the finding's way (a finding about
80
+ * a missing field points at the field's parent). `path` is the OPF dotted path a host selects; `pointer` the JSON Pointer.
81
+ */
82
+ export function findingTarget(document, finding) {
83
+ const segments = splitOpfPath(finding.path ?? "");
84
+ let length = segments.length;
85
+ while (length > 0 && !hasValueAtPath(document, segments.slice(0, length))) length -= 1;
86
+ const kept = segments.slice(0, length);
87
+ const slide = kept[0] === "slides" && /^\d+$/.test(kept[1] ?? "") ? Number(kept[1]) : null;
88
+ return { slide, path: kept.join("."), pointer: opfPathToJsonPointer(kept), exact: length === segments.length };
89
+ }
90
+
91
+ const PATCH_OPS = new Set(["add", "replace", "remove"]);
92
+ const MAX_FIX_OPERATIONS = 4;
93
+
94
+ function checkPatch(document, operations) {
95
+ if (!Array.isArray(operations) || !operations.length || operations.length > MAX_FIX_OPERATIONS) {
96
+ throw fail("invalid-fix", `A review fix changes one to ${MAX_FIX_OPERATIONS} fields.`);
97
+ }
98
+ for (const operation of operations) {
99
+ if (!operation || !PATCH_OPS.has(operation.op) || typeof operation.path !== "string" || !operation.path.startsWith("/") || operation.path === "/") {
100
+ throw fail("invalid-fix", "A review fix may add, replace or remove a field below the document root.", { operation });
101
+ }
102
+ // The finding is stale when the field it was about has since changed: refuse instead of guessing.
103
+ if ((operation.op === "replace" || operation.op === "remove") && !hasValueAtPath(document, operation.path)) {
104
+ throw fail("stale-finding", "The slide has changed since this finding was made. The review has been refreshed; apply the fix again if it still shows.", { operation });
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Apply one `patch` fix of a finding as a single undoable session change (validated: a fix that would make the
111
+ * document invalid is rejected). Returns the session's change.
112
+ */
113
+ export function applyReviewFix(editor, finding, fix, meta = {}) {
114
+ if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
115
+ if (!fix || fix.kind !== "patch") throw fail("invalid-fix", "Only patch fixes can be applied; a focus fix asks the author to type something.");
116
+ checkPatch(editor.document, fix.patch);
117
+ return editor.applyPatch(fix.patch, { ...meta, source: meta.source ?? "review", ruleId: finding?.ruleId, fix: fix.id, rejectInvalid: true });
118
+ }
119
+
120
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
121
+
122
+ /** The patch operations that give the asset value at `pointer` alt text (`""` marks it decorative), or `null` when it already has exactly this alt. */
123
+ export function altTextPatch(document, pointer, text) {
124
+ const value = getValueAtPath(document, pointer);
125
+ if (value === undefined) throw fail("stale-finding", "The picture this finding is about no longer exists.", { pointer });
126
+ const alt = String(text ?? "");
127
+ const id = assetIdOf(value);
128
+ const entry = id === undefined ? undefined : getValueAtPath(document, ["assets", id]);
129
+ // A picture that points into the assets registry gets its alt text there, so every use of the asset has it.
130
+ if (id !== undefined && entry !== undefined) {
131
+ // prepareAssetAlt treats "" as "remove the alt"; an empty alt here is the decorative choice and must stay in the document.
132
+ if (alt !== "") return prepareAssetAlt(document, id, alt).patches;
133
+ if (isObject(entry)) {
134
+ if (entry.alt === "") return null;
135
+ return [{ op: Object.hasOwn(entry, "alt") ? "replace" : "add", path: opfPathToJsonPointer(["assets", id, "alt"]), value: "" }];
136
+ }
137
+ return [{ op: "replace", path: opfPathToJsonPointer(["assets", id]), value: { src: entry, alt: "" } }];
138
+ }
139
+ if (typeof value === "string") return [{ op: "replace", path: pointer, value: { src: value, alt } }];
140
+ if (isObject(value)) {
141
+ if (value.alt === alt) return null;
142
+ return [{ op: Object.hasOwn(value, "alt") ? "replace" : "add", path: `${pointer}/alt`, value: alt }];
143
+ }
144
+ throw fail("invalid-alt-target", "Alt text can be set on an image, video or logo.", { pointer });
145
+ }
146
+
147
+ /** Set a picture's alt text (typed by the author) as one undoable change. Empty text is refused: use `markDecorative`. */
148
+ export function setReviewAltText(editor, pointer, text, meta = {}) {
149
+ const alt = String(text ?? "").trim();
150
+ if (!alt) throw fail("empty-alt-text", "Write what the picture shows, or mark it decorative.", { pointer });
151
+ const patch = altTextPatch(editor.document, pointer, alt);
152
+ if (!patch) return { changed: false, document: editor.document };
153
+ return { changed: true, ...editor.applyPatch(patch, { ...meta, source: meta.source ?? "review-alt", pointer, rejectInvalid: true }) };
154
+ }
155
+
156
+ /** Mark a picture decorative (empty alt text), an explicit choice, as one undoable change. */
157
+ export function markDecorative(editor, pointer, meta = {}) {
158
+ const patch = altTextPatch(editor.document, pointer, "");
159
+ if (!patch) return { changed: false, document: editor.document };
160
+ return { changed: true, ...editor.applyPatch(patch, { ...meta, source: meta.source ?? "review-decorative", pointer, rejectInvalid: true }) };
161
+ }
162
+
163
+ /** The alt text a picture has now (own or through the assets registry), for pre-filling the field. */
164
+ export function currentAltText(document, pointer) {
165
+ const value = getValueAtPath(document, pointer);
166
+ if (isObject(value) && typeof value.alt === "string") return value.alt;
167
+ const id = assetIdOf(value);
168
+ const entry = id === undefined ? undefined : getValueAtPath(document, ["assets", id]);
169
+ return isObject(entry) && typeof entry.alt === "string" ? entry.alt : "";
170
+ }
@@ -0,0 +1,44 @@
1
+ import type { EditorSession } from "./index.js";
2
+
3
+ /** The RR-26 split and merge functions (`@openpresentation/opf-editor/content-actions`), passed in to show those actions in the menu. */
4
+ export interface SlideContentActions {
5
+ splitSlideByBlocks(editor: EditorSession, slideIndex: number, options?: { at?: number[]; each?: boolean; repeatHeadings?: boolean }, meta?: Record<string, unknown>): { changed: boolean; reason?: string; range?: { start: number; deleteCount: number }; slideCount?: number };
6
+ mergeSlides(editor: EditorSession, start: number, count?: number, meta?: Record<string, unknown>): { changed: boolean; reason?: string; loss?: string[] };
7
+ }
8
+ export interface SlideManagerOptions {
9
+ editor: EditorSession;
10
+ /** The slide the host is showing. The manager keeps it in the selection. */
11
+ getSlideIndex: () => number;
12
+ /** Called when the user chooses or moves to a slide; the host shows it and calls `render()`. */
13
+ setSlideIndex: (index: number) => void;
14
+ /** The thumbnail of one slide as an HTML string (usually the renderer's SVG). Cache it: it is called for every card on every render. */
15
+ renderThumbnail?: (document: unknown, index: number) => string;
16
+ /** `"navigator"` (a vertical list, the default) or `"sorter"` (a grid with arrow keys in both directions). */
17
+ variant?: "navigator" | "sorter";
18
+ /** An element to fill with the Duplicate, Delete, Hide and More buttons. */
19
+ toolbar?: HTMLElement;
20
+ /** Show Split and Merge in the slide menu. */
21
+ contentActions?: SlideContentActions;
22
+ /** Layout choices for "Add slide with layout" (default: the layouts catalog, via `listSwitchOptions`). */
23
+ layoutOptions?: (document: unknown) => { id: string; label: string; record?: Record<string, any> }[];
24
+ /** Every message the manager announces in its live region, for a visual status line. */
25
+ onStatus?: (message: string) => void;
26
+ onError?: (error: unknown) => void;
27
+ /** False to skip the first draw (a host that draws only after its fonts are loaded calls `render()` itself). */
28
+ autoRender?: boolean;
29
+ }
30
+ export interface SlideManager {
31
+ /** Redraw from the session. Call after every document change and when the current slide changes. */
32
+ render(): void;
33
+ /** The selected slide indices in order (always includes the current slide). */
34
+ getSelection(): number[];
35
+ setSelection(indices: number[]): void;
36
+ focus(index: number): void;
37
+ openLayoutPicker(): void;
38
+ /** Collapse or expand a section (an index from `listSections`): view state only, nothing is written to the document. Returns false for an unknown section. */
39
+ setSectionCollapsed(sectionIndex: number, collapsed?: boolean): boolean;
40
+ /** Run an action on the selection: duplicate, delete, hide, move-up, move-down, move-start, move-end, split, merge, select-all. */
41
+ run(action: "duplicate" | "delete" | "hide" | "move-up" | "move-down" | "move-start" | "move-end" | "split" | "merge" | "select-all"): void;
42
+ destroy(): void;
43
+ }
44
+ export declare function createSlideManager(container: HTMLElement, options: SlideManagerOptions): SlideManager;