@openpresentation/opf-editor 0.11.3 → 0.14.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.
Files changed (95) hide show
  1. package/README.md +82 -58
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +15 -15
  6. package/dist/background-options.d.ts +2 -2
  7. package/dist/background-options.js +6 -6
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +23 -21
  11. package/dist/blocks.d.ts +8 -8
  12. package/dist/blocks.js +41 -40
  13. package/dist/canvas-fields.js +35 -12
  14. package/dist/canvas.d.ts +22 -30
  15. package/dist/canvas.js +57 -64
  16. package/dist/chart-data.d.ts +12 -10
  17. package/dist/chart-data.js +22 -21
  18. package/dist/chart-options-panel.d.ts +2 -2
  19. package/dist/chart-options-panel.js +82 -8
  20. package/dist/chart-options.d.ts +35 -9
  21. package/dist/chart-options.js +133 -40
  22. package/dist/checks.js +21 -0
  23. package/dist/color-authoring.js +1 -1
  24. package/dist/content-actions.d.ts +15 -15
  25. package/dist/content-actions.js +71 -70
  26. package/dist/content-controls.js +12 -12
  27. package/dist/data-grid.js +179 -10
  28. package/dist/data.d.ts +23 -2
  29. package/dist/data.js +40 -1
  30. package/dist/dataset-refs.d.ts +17 -0
  31. package/dist/dataset-refs.js +45 -0
  32. package/dist/design-controls.js +66 -47
  33. package/dist/design-options.d.ts +16 -14
  34. package/dist/design-options.js +86 -74
  35. package/dist/edit-helpers.js +8 -7
  36. package/dist/exact-source.js +5 -5
  37. package/dist/export.d.ts +5 -7
  38. package/dist/export.js +40 -14
  39. package/dist/find-panel.js +5 -5
  40. package/dist/find-replace.d.ts +3 -3
  41. package/dist/find-replace.js +35 -20
  42. package/dist/font-gate.js +49 -58
  43. package/dist/galleries.js +36 -52
  44. package/dist/grid-model.d.ts +99 -24
  45. package/dist/grid-model.js +577 -101
  46. package/dist/image-crop.d.ts +4 -4
  47. package/dist/image-crop.js +20 -20
  48. package/dist/image-cropper.js +3 -3
  49. package/dist/index.d.ts +28 -28
  50. package/dist/index.js +74 -90
  51. package/dist/json-options.js +17 -16
  52. package/dist/layout-handles.js +5 -5
  53. package/dist/layout-placeholders.js +4 -4
  54. package/dist/layout.d.ts +2 -2
  55. package/dist/layout.js +8 -7
  56. package/dist/numbering-panel.js +3 -5
  57. package/dist/numbering.d.ts +3 -5
  58. package/dist/numbering.js +14 -26
  59. package/dist/outline-view.js +24 -24
  60. package/dist/outline.d.ts +12 -12
  61. package/dist/outline.js +52 -50
  62. package/dist/persistence.d.ts +1 -1
  63. package/dist/persistence.js +19 -18
  64. package/dist/presentation-ids.js +2 -2
  65. package/dist/react.js +3 -3
  66. package/dist/review-panel.d.ts +30 -13
  67. package/dist/review-panel.js +154 -53
  68. package/dist/review.d.ts +36 -43
  69. package/dist/review.js +92 -53
  70. package/dist/rich-text-input.js +4 -3
  71. package/dist/rich-text-toolbar.js +5 -3
  72. package/dist/rich-text.js +5 -2
  73. package/dist/schema-inspector.d.ts +2 -2
  74. package/dist/schema-inspector.js +16 -11
  75. package/dist/schema.d.ts +1 -1
  76. package/dist/schema.js +25 -5
  77. package/dist/slide-manager.d.ts +2 -2
  78. package/dist/slide-manager.js +18 -18
  79. package/dist/slides.d.ts +20 -20
  80. package/dist/slides.js +99 -97
  81. package/dist/svelte.js +1 -1
  82. package/dist/switches.d.ts +10 -10
  83. package/dist/switches.js +136 -109
  84. package/dist/table-options.d.ts +8 -8
  85. package/dist/table-options.js +41 -30
  86. package/dist/table-structure.d.ts +10 -10
  87. package/dist/table-structure.js +21 -21
  88. package/dist/template-panel.d.ts +3 -3
  89. package/dist/template-panel.js +37 -13
  90. package/dist/templates.d.ts +22 -11
  91. package/dist/templates.js +48 -55
  92. package/dist/transfer.d.ts +4 -4
  93. package/dist/transfer.js +74 -54
  94. package/package.json +10 -6
  95. package/dist/font-defaults.js +0 -25
@@ -1,22 +1,29 @@
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.
1
+ // Review panel (RR-29, RR-55): the DOM panel over core's `validate` report and, optionally, a hosted reviewer. It lists the findings
2
+ // of the open presentation by category (format, references, policy, accessibility, layout, content, and any category a hosted
3
+ // reviewer adds), lets the author go to the content a finding is about, offers quick fixes (type the alt text in place, switch a
4
+ // failing text colour to the readable one) and updates as the document changes. The full check runs after the document has been
5
+ // quiet for a moment, not on every keystroke (the session itself checks only the `format` category per edit). The panel owns no
6
+ // document state: every fix is an undoable edit through the editor session. Importing this module does not need a DOM;
7
+ // mounting does.
8
+ import { validate } from "@openpresentation/opf";
6
9
  import {
7
10
  applyReviewFix,
8
- auditAvailable,
9
11
  countFindings,
10
12
  currentAltText,
11
13
  filterFindings,
12
14
  findingTarget,
15
+ CORE_SOURCE,
16
+ groupFindings,
17
+ mergeFindingReports,
13
18
  reviewFindings,
14
19
  reviewSeverities,
15
- runAudit,
16
20
  setReviewAltText,
17
21
  } from "./review.js";
18
22
 
19
23
  const SEVERITY_LABELS = { error: "Error", warning: "Warning", info: "Note" };
24
+ const CATEGORY_LABELS = { format: "Format", references: "References", policy: "Policy", accessibility: "Accessibility", layout: "Layout", content: "Content" };
25
+ const categoryLabel = (category) => CATEGORY_LABELS[category] ?? (category.charAt(0).toUpperCase() + category.slice(1));
26
+ const DEFAULT_DELAY = 300;
20
27
  const MINIMUMS = { all: "info", warnings: "warning", errors: "error" };
21
28
  let panelCounter = 0;
22
29
 
@@ -40,7 +47,10 @@ const STYLE = `
40
47
  .opf-review-counts{margin:0;color:var(--muted,#656570)}
41
48
  .opf-review-filters{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;margin:0 0 8px}
42
49
  .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}
50
+ .opf-review-list{margin:0;padding:0}
51
+ .opf-review-category{margin:0 0 8px}
52
+ .opf-review-category-title{margin:10px 0 2px;font-size:12px;font-weight:600;color:var(--muted,#656570)}
53
+ .opf-review-items{list-style:none;margin:0;padding:0}
44
54
  .opf-review-item{border-top:1px solid var(--border,#e8e8ec);padding:8px 0}
45
55
  .opf-review-item[aria-current="true"]{background:var(--review-current,rgba(101,89,207,.07))}
46
56
  .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}
@@ -87,33 +97,51 @@ function summaryText(counts, scopeLabel) {
87
97
 
88
98
  /**
89
99
  * 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).
100
+ * follows), `getValidateOptions(presentation)` (core's `ValidateOptions`: `fonts` with the host's `textMeasurement` for font-exact
101
+ * overflow, `severity`, `ignore`, `thresholds`, `contracts`, ...), `onGoTo({finding, target})` (select the content: `target` is
102
+ * {slide, path, pointer, exact}), `onFocusField({finding, fix, target})` (focus a field the panel does not own: a title, text, link or
103
+ * language), `onStatus(message, {error})`, `onChange({error, warning, info, total, all, sources, unavailable})` (after every redraw, with
104
+ * the shown counts), `ignored` (rule ids hidden at the start) and `onIgnoredChange(ids)`.
105
+ *
106
+ * The full `validate(presentation)` runs once the document has been quiet for `delay` milliseconds (default 300) after a session change,
107
+ * and at once on mount and on `refresh()`. `autoRefresh` (default true) turns the re-check after changes off for a host that must wait
108
+ * for its fonts; it then calls `refresh()` itself.
109
+ *
110
+ * `review(presentation, { signal, report, validateOptions })` is an optional hook that returns a `FindingReport` from a hosted reviewer
111
+ * (pptx.dev's review). Its findings join core's in the same list, kept per `Finding.source`; a finding with no source is listed under
112
+ * `review`. The hook can cost money and take seconds, so it runs when the author presses its button (`reviewLabel`, default "Run
113
+ * review") or, with `autoReview: true`, after every re-check; findings from an earlier run stay listed, marked as possibly out of date,
114
+ * until the next run. A failing hook shows its message and leaves core's findings in place.
96
115
  */
97
116
  export function createReviewPanel(container, options) {
98
- const { editor, getSlideIndex, getAuditOptions, onGoTo, onFocusField, onStatus, onIgnoredChange, onChange } = options;
117
+ const { editor, getSlideIndex, getValidateOptions, onGoTo, onFocusField, onStatus, onIgnoredChange, onChange, review } = options;
118
+ if (review !== undefined && typeof review !== "function") throw new TypeError("The Review panel's review hook must be a function (presentation, options) returning a FindingReport.");
99
119
  if (!editor || typeof editor.subscribe !== "function") throw new Error("The Review panel needs an editor session created by createEditorSession.");
100
120
  const doc = container.ownerDocument;
101
121
  ensureStyles(doc);
102
122
  const id = `opf-review-${++panelCounter}`;
103
123
  const ignored = new Set(options.ignored ?? []);
104
124
  let report;
125
+ let coreReport;
126
+ let hookReport;
127
+ let hookStale = false;
128
+ let hookError;
129
+ let hookRunning;
105
130
  let findings = [];
106
131
  let destroyed = false;
107
- let scheduled = false;
132
+ let timer;
108
133
  let currentId;
109
134
  let editingAlt;
110
135
  let altDraft = "";
111
136
  let lastAuditError;
137
+ const delay = Number.isFinite(options.delay) && options.delay >= 0 ? options.delay : DEFAULT_DELAY;
112
138
 
113
139
  const root = h(doc, "section", { class: "opf-review", "data-opf-component": "review-panel", "aria-labelledby": `${id}-title` });
114
140
  const counts = h(doc, "p", { class: "opf-review-counts", id: `${id}-counts`, role: "status", "aria-live": "polite" });
115
141
  const title = h(doc, "h2", { id: `${id}-title`, text: "Review", tabindex: "-1" });
116
- const head = h(doc, "div", { class: "opf-review-head" }, title, counts);
142
+ const hookButton = review ? h(doc, "button", { type: "button", class: "opf-review-fix", "data-action": "run-review", text: options.reviewLabel ?? "Run review", onclick: () => { void runHook(); } }) : undefined;
143
+ const hookStatus = review ? h(doc, "p", { class: "opf-review-meta", role: "status", "aria-live": "polite" }) : undefined;
144
+ const head = h(doc, "div", { class: "opf-review-head" }, title, counts, hookButton);
117
145
  const severitySelect = h(doc, "select", { id: `${id}-severity` },
118
146
  h(doc, "option", { value: "all", text: "All findings" }),
119
147
  h(doc, "option", { value: "warnings", text: "Errors and warnings" }),
@@ -122,10 +150,10 @@ export function createReviewPanel(container, options) {
122
150
  const filters = h(doc, "div", { class: "opf-review-filters" },
123
151
  h(doc, "label", { for: `${id}-severity` }, "Show ", severitySelect),
124
152
  h(doc, "label", { for: `${id}-slide` }, slideOnly, "This slide only"));
125
- const list = h(doc, "ul", { class: "opf-review-list", "aria-label": "Review findings" });
153
+ const list = h(doc, "div", { class: "opf-review-list", role: "group", "aria-label": "Review findings" });
126
154
  const empty = h(doc, "p", { class: "opf-review-empty" });
127
155
  const ignoredBox = h(doc, "details", { class: "opf-review-ignored", hidden: true });
128
- root.append(head, filters, list, empty, ignoredBox);
156
+ root.append(head, ...(hookStatus ? [hookStatus] : []), filters, list, empty, ignoredBox);
129
157
  container.append(root);
130
158
 
131
159
  const say = (message, error = false) => {
@@ -135,21 +163,64 @@ export function createReviewPanel(container, options) {
135
163
  function visible() {
136
164
  const minimum = MINIMUMS[severitySelect.value] ?? "info";
137
165
  const slide = slideOnly.checked ? (getSlideIndex?.() ?? 0) : undefined;
138
- return filterFindings(findings, { minimum, slide }).filter((finding) => !ignored.has(finding.ruleId));
166
+ const shown = filterFindings(findings, { minimum, slide }).filter((finding) => !ignored.has(finding.ruleId));
167
+ return groupFindings(shown).flatMap((group) => group.findings);
139
168
  }
140
169
 
141
- function audit() {
170
+ // Core's full check, then the hook's findings merged in by source.
171
+ function validateNow() {
142
172
  lastAuditError = undefined;
143
173
  try {
144
- report = runAudit(editor.document, { ...(getAuditOptions?.(editor.document) ?? {}), ...(options.audit ? { audit: options.audit } : {}) });
145
- findings = reviewFindings(report);
174
+ coreReport = validate(editor.presentation, getValidateOptions?.(editor.presentation) ?? {});
146
175
  } catch (error) {
147
- report = undefined;
148
- findings = [];
176
+ coreReport = undefined;
149
177
  lastAuditError = error;
150
178
  }
179
+ merge();
151
180
  }
152
181
 
182
+ function merge() {
183
+ report = coreReport || hookReport ? mergeFindingReports(coreReport, hookReport) : undefined;
184
+ findings = reviewFindings(report);
185
+ }
186
+
187
+ function hookMessage() {
188
+ if (!review) return "";
189
+ if (hookRunning) return "Running the hosted review…";
190
+ if (hookError) return `The hosted review could not run: ${messageOf(hookError)}`;
191
+ if (hookReport && hookStale) return "Hosted review findings are from before your latest edit.";
192
+ if (hookReport) return "Hosted review is up to date.";
193
+ return "";
194
+ }
195
+
196
+ // The hosted reviewer: one run at a time; a document change while it runs drops its result (the next run starts from the new text).
197
+ async function runHook() {
198
+ if (!review || destroyed) return;
199
+ hookRunning?.controller.abort();
200
+ const controller = new AbortController();
201
+ const running = { controller };
202
+ hookRunning = running;
203
+ hookError = undefined;
204
+ if (!coreReport && !lastAuditError) validateNow();
205
+ renderList();
206
+ try {
207
+ const result = await review(editor.presentation, { signal: controller.signal, report: coreReport, validateOptions: getValidateOptions?.(editor.presentation) ?? {} });
208
+ if (destroyed || hookRunning !== running) return;
209
+ if (!result || !Array.isArray(result.findings)) throw new TypeError("The review hook must resolve a FindingReport ({ valid, findings, counts }).");
210
+ hookRunning = undefined;
211
+ hookReport = result;
212
+ hookStale = false;
213
+ merge();
214
+ } catch (error) {
215
+ if (destroyed || hookRunning !== running) return;
216
+ hookRunning = undefined;
217
+ hookError = error;
218
+ }
219
+ renderList();
220
+ }
221
+
222
+ const itemFor = (findingId) => [...list.querySelectorAll(".opf-review-item")].find((entry) => entry.dataset.findingId === findingId);
223
+
153
224
  function focusGoto(findingId) {
154
225
  const buttons = [...list.querySelectorAll(".opf-review-goto")];
155
226
  (buttons.find((button) => button.dataset.findingId === findingId) ?? buttons[0])?.focus();
@@ -157,21 +228,21 @@ export function createReviewPanel(container, options) {
157
228
 
158
229
  function go(finding) {
159
230
  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));
231
+ const target = findingTarget(editor.presentation, finding);
232
+ for (const entry of list.querySelectorAll(".opf-review-item")) entry.setAttribute("aria-current", String(entry.dataset.findingId === finding.id));
162
233
  onGoTo?.({ finding, target });
163
234
  }
164
235
 
165
236
  function altForm(finding, fix) {
166
237
  const pointer = fix.focus.path;
167
238
  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` });
239
+ const input = h(doc, "input", { type: "text", id: inputId, value: altDraft || currentAltText(editor.presentation, pointer), "aria-describedby": `${inputId}-help` });
169
240
  const error = h(doc, "span", { class: "opf-review-error", role: "alert" });
170
241
  const form = h(doc, "form", { class: "opf-review-alt", novalidate: true },
171
242
  h(doc, "label", { for: inputId, text: "Alt text" }), input,
172
243
  h(doc, "button", { type: "submit", class: "opf-review-fix", text: "Save alt text" }),
173
244
  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);
245
+ h(doc, "span", { id: `${inputId}-help`, class: "opf-review-meta", text: `${finding.ruleId === "opf/chart-text-alternative" || String(finding.path ?? "").includes("/chart") ? "Describe what the chart shows: its point and the key numbers." : "Describe what the picture shows."} Press Escape to cancel.` }), error);
175
246
  input.addEventListener("input", () => { altDraft = input.value; });
176
247
  form.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.stopPropagation(); closeAlt(finding); } });
177
248
  form.addEventListener("submit", (event) => {
@@ -181,6 +252,7 @@ export function createReviewPanel(container, options) {
181
252
  editingAlt = undefined;
182
253
  altDraft = "";
183
254
  say("Alt text saved. Undo restores the previous text.");
255
+ refresh();
184
256
  } catch (problem) {
185
257
  error.textContent = messageOf(problem);
186
258
  say(messageOf(problem), true);
@@ -203,18 +275,19 @@ export function createReviewPanel(container, options) {
203
275
  editingAlt = finding.id;
204
276
  altDraft = "";
205
277
  renderList();
206
- [...list.children].find((entry) => entry.dataset.findingId === finding.id)?.querySelector(".opf-review-alt input")?.focus();
278
+ itemFor(finding.id)?.querySelector(".opf-review-alt input")?.focus();
207
279
  return;
208
280
  }
209
281
  go(finding);
210
- onFocusField?.({ finding, fix, target: findingTarget(editor.document, finding) });
282
+ onFocusField?.({ finding, fix, target: findingTarget(editor.presentation, finding) });
211
283
  return;
212
284
  }
213
285
  const index = visible().findIndex((entry) => entry.id === finding.id);
214
286
  try {
215
287
  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.
288
+ say(`${fix.title}. Undo restores the previous state.`);
289
+ // Check the changed document at once, then put focus on the neighbouring finding.
290
+ refresh();
218
291
  const after = visible();
219
292
  const next = after[Math.min(Math.max(index, 0), after.length - 1)];
220
293
  if (next) focusGoto(next.id);
@@ -236,17 +309,17 @@ export function createReviewPanel(container, options) {
236
309
  },
237
310
  h(doc, "span", { class: "opf-review-sev", text: SEVERITY_LABELS[finding.severity] }),
238
311
  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 }));
312
+ h(doc, "span", { class: "opf-review-meta", text: `${where} · ${finding.ruleId}${finding.source && finding.source !== CORE_SOURCE ? ` · ${finding.source}` : ""}` }));
313
+ const li = h(doc, "li", { class: "opf-review-item", "data-finding-id": finding.id, "data-rule": finding.ruleId, "data-source": finding.source ?? CORE_SOURCE, "data-severity": finding.severity, "aria-current": String(finding.id === currentId) }, go_,
314
+ finding.help ? h(doc, "p", { class: "opf-review-help", text: finding.help }) : undefined);
242
315
  const actions = h(doc, "div", { class: "opf-review-actions" });
243
316
  for (const fix of finding.fixes ?? []) {
244
317
  actions.append(h(doc, "button", {
245
318
  type: "button",
246
319
  class: `opf-review-fix${fix.safe ? "" : " is-careful"}`,
247
- "data-fix": fix.id,
320
+ "data-fix": fix.id ?? fix.title,
248
321
  title: fix.safe ? undefined : "This changes how the slide looks or what it says; it can be undone.",
249
- text: fix.label,
322
+ text: fix.title,
250
323
  onclick: () => runFix(finding, fix),
251
324
  }));
252
325
  }
@@ -289,22 +362,30 @@ export function createReviewPanel(container, options) {
289
362
  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
363
  const shown = visible();
291
364
  const slideScope = slideOnly.checked ? ` on slide ${(getSlideIndex?.() ?? 0) + 1}` : "";
292
- list.replaceChildren(...shown.map(item));
365
+ list.replaceChildren(...groupFindings(shown).map(({ category, findings: inGroup }) => {
366
+ const heading = h(doc, "h3", { class: "opf-review-category-title", id: `${id}-cat-${category}`, text: `${categoryLabel(category)} (${inGroup.length})` });
367
+ return h(doc, "section", { class: "opf-review-category", "data-category": category, "aria-labelledby": heading.id }, heading, h(doc, "ul", { class: "opf-review-items" }, ...inGroup.map(item)));
368
+ }));
369
+ if (hookStatus) {
370
+ hookStatus.textContent = hookMessage();
371
+ hookButton.disabled = Boolean(hookRunning);
372
+ hookButton.textContent = hookReport ? `${options.reviewLabel ?? "Run review"} again` : options.reviewLabel ?? "Run review";
373
+ }
293
374
  if (lastAuditError) {
294
375
  empty.hidden = false;
295
- empty.textContent = lastAuditError.code === "audit-unavailable" ? lastAuditError.message : `The review could not run: ${messageOf(lastAuditError)}`;
376
+ empty.textContent = `The review could not run: ${messageOf(lastAuditError)}`;
296
377
  counts.textContent = "Review unavailable.";
297
378
  } else {
298
379
  const hiddenCount = findings.length - findings.filter((finding) => !ignored.has(finding.ruleId)).length;
299
380
  counts.textContent = summaryText(countFindings(shown), slideScope) + (hiddenCount ? ` ${plural(hiddenCount, "finding")} hidden.` : "");
300
381
  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.";
382
+ empty.textContent = findings.length && !shown.length ? "Nothing to show with these filters." : "Nothing to fix. The checks cover the format, references, contrast, text fit, alt text, reading order and more; they do not replace looking at the slides.";
302
383
  }
303
384
  renderIgnored();
304
- onChange?.({ ...countFindings(shown), all: findings.length, unavailable: Boolean(lastAuditError) });
385
+ onChange?.({ ...countFindings(shown), all: findings.length, sources: report?.sources ?? [], unavailable: Boolean(lastAuditError) });
305
386
  // Live updates must not take the keyboard away: put focus back on the same control, or the same finding.
306
387
  if (hadFocus?.id && !lastAuditError) {
307
- const li = [...list.children].find((entry) => entry.dataset.findingId === hadFocus.id);
388
+ const li = itemFor(hadFocus.id);
308
389
  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
390
  if (target && doc.activeElement !== target) target.focus();
310
391
  }
@@ -312,16 +393,27 @@ export function createReviewPanel(container, options) {
312
393
 
313
394
  function refresh() {
314
395
  if (destroyed) return;
315
- scheduled = false;
316
- audit();
396
+ clearTimeout(timer);
397
+ timer = undefined;
398
+ validateNow();
317
399
  renderList();
400
+ if (options.autoReview && review) void runHook();
318
401
  }
319
402
 
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);
403
+ // One full check per burst of changes (typing, an undo group, a paste): when nothing has changed for `delay` milliseconds.
404
+ function schedule() {
405
+ if (destroyed) return;
406
+ clearTimeout(timer);
407
+ timer = setTimeout(refresh, delay);
408
+ }
409
+
410
+ // A change while the hosted review runs drops its result (it was about the old text); one that comes after marks it out of date.
411
+ const stop = editor.subscribe(() => {
412
+ if (destroyed) return;
413
+ if (hookRunning) { hookRunning.controller.abort(); hookRunning = undefined; }
414
+ if (hookReport) hookStale = true;
415
+ if (options.autoRefresh === false) { renderList(); return; }
416
+ schedule();
325
417
  });
326
418
 
327
419
  severitySelect.addEventListener("change", renderList);
@@ -341,19 +433,28 @@ export function createReviewPanel(container, options) {
341
433
 
342
434
  return {
343
435
  element: root,
344
- /** The latest findings (core's diagnostics with `id`, `slide` and `dottedPath`), before filters and hidden checks. */
436
+ /** The latest findings (core's, and the hosted reviewer's, with `id`, `slide` and `dottedPath`), before filters and hidden checks. */
345
437
  get findings() { return findings; },
438
+ /** The merged report: `valid`, `findings`, `counts` and `sources`. */
346
439
  get report() { return report; },
440
+ /** Core's `validate` report alone. */
441
+ get validation() { return coreReport; },
347
442
  refresh,
443
+ /** Check after `delay` milliseconds without another call: a host that drives its own checks (`autoRefresh: false`) calls this after each redraw. */
444
+ schedule,
445
+ /** Run the hosted reviewer now (the `review` hook); resolves when its findings are listed. */
446
+ review: runHook,
348
447
  /** Re-render after the host's slide changed (the "This slide only" filter). */
349
448
  update: renderList,
350
449
  setIgnored,
351
450
  destroy() {
352
451
  destroyed = true;
353
- unsubscribe?.();
452
+ clearTimeout(timer);
453
+ hookRunning?.controller.abort();
454
+ stop();
354
455
  root.remove();
355
456
  },
356
457
  };
357
458
  }
358
459
 
359
- export { auditAvailable, reviewSeverities };
460
+ export { reviewSeverities };
package/dist/review.d.ts CHANGED
@@ -1,50 +1,37 @@
1
+ import type { Finding, FindingFix, FindingReport, FindingSeverity, ValidateOptions, ValidationCategory, ValidationReport } from "@openpresentation/opf";
1
2
  import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
3
 
3
- export type ReviewSeverity = "error" | "warning" | "info";
4
+ export type ReviewSeverity = FindingSeverity;
5
+ /** A suggested repair from core's `validate` or a hosted reviewer: core's `FindingFix` (`title`, `patch` of RFC 6902 operations, or a `focus` field). */
6
+ export type ReviewFix = FindingFix;
4
7
 
5
- /** A suggested repair from core's audit (the structural subset the editor uses). */
6
- export interface ReviewFix {
8
+ /** A finding of core's `validate` or of a hosted reviewer (core's `Finding`), with the fields the editor derives. */
9
+ export interface ReviewFinding extends Omit<Finding, "slide"> {
10
+ /** Unique within the report: the source, the rule id and the path, numbered when they repeat. */
7
11
  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
12
  /** The same path in OPF dotted form (`slides.0.text.1.color`). */
25
13
  dottedPath: string;
26
14
  /** Zero-based slide index, or null for a finding about the whole presentation. */
27
15
  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
16
  }
34
17
 
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;
18
+ /** What the Review panel holds: core's `FindingReport` of every source merged, the editor's findings, and the sources present (`opf`, `pptx.dev/review`, ...). */
19
+ export interface ReviewReport extends FindingReport {
20
+ findings: ReviewFinding[];
21
+ /** The `Finding.source` of every group of findings in the report. `opf` is core's own checks. */
22
+ sources: string[];
41
23
  }
42
24
 
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
- };
25
+ /** The hook that brings a hosted reviewer's findings into the panel (see `createReviewPanel`). */
26
+ export type ReviewHook = (presentation: unknown, options: ReviewHookOptions) => Promise<FindingReport>;
27
+ export interface ReviewHookOptions {
28
+ /** Aborted when the document changes while the review runs, or the panel is destroyed. */
29
+ signal: AbortSignal;
30
+ /** Core's report for this presentation, so a hosted reviewer need not repeat the deterministic checks. */
31
+ report: ValidationReport | undefined;
32
+ /** The options core's `validate` ran with (from the panel's `getValidateOptions`). */
33
+ validateOptions: ValidateOptions;
34
+ }
48
35
 
49
36
  export interface ReviewTarget {
50
37
  /** The slide the finding is on, or null for the presentation. */
@@ -57,19 +44,25 @@ export interface ReviewTarget {
57
44
  }
58
45
 
59
46
  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[];
47
+ /** Core's finding categories in report order: format, references, policy, accessibility, layout, content. */
48
+ export declare const reviewCategories: readonly ValidationCategory[];
49
+ /** The `source` of a finding that names none: core's own checks. */
50
+ export declare const CORE_SOURCE: "opf";
51
+ /** Merge core's report and a hosted reviewer's, keeping findings per `Finding.source` (a report replaces earlier findings of the sources it carries). A hook finding with no source gets `hookSource` (default `review`). */
52
+ export declare function mergeFindingReports(core: FindingReport | undefined, hook?: FindingReport | undefined, options?: { hookSource?: string }): FindingReport & { sources: string[] };
53
+ export declare function reviewFindings(report: Pick<FindingReport, "findings"> | undefined): ReviewFinding[];
65
54
  export declare function countFindings(findings: readonly ReviewFinding[]): { error: number; warning: number; info: number; total: number };
66
55
  export declare function filterFindings(findings: readonly ReviewFinding[], options?: { minimum?: ReviewSeverity; slide?: number | null }): ReviewFinding[];
56
+ /** The deck first, then each slide; errors before warnings before notes; then by rule and path. */
57
+ export declare function sortFindings(findings: readonly ReviewFinding[]): ReviewFinding[];
58
+ /** Findings grouped by category: core's categories in core's order, then any other category in the order it first appears. Empty categories are left out. */
59
+ export declare function groupFindings(findings: readonly ReviewFinding[]): { category: string; findings: ReviewFinding[] }[];
67
60
  /** Where "go to" lands for a finding. */
68
- export declare function findingTarget(document: unknown, finding: Pick<ReviewFinding, "path">): ReviewTarget;
61
+ export declare function findingTarget(presentation: unknown, finding: Pick<ReviewFinding, "path">): ReviewTarget;
69
62
  /** Apply a `patch` fix as one undoable, validated session change; refuses a stale or unsafe patch. */
70
63
  export declare function applyReviewFix(editor: EditorSession, finding: Pick<ReviewFinding, "ruleId"> | undefined, fix: ReviewFix, meta?: Record<string, unknown>): EditorChange;
71
64
  /** 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;
65
+ export declare function altTextPatch(presentation: unknown, pointer: string, text: string): JsonPatchOperation[] | null;
73
66
  export declare function setReviewAltText(editor: EditorSession, pointer: string, text: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
74
67
  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;
68
+ export declare function currentAltText(presentation: unknown, pointer: string): string;