@openpresentation/opf-editor 0.12.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -80
- package/dist/annotations.d.ts +16 -16
- package/dist/annotations.js +71 -79
- package/dist/assets.d.ts +7 -7
- package/dist/assets.js +16 -16
- package/dist/background-options.d.ts +12 -5
- package/dist/background-options.js +44 -19
- package/dist/block-controls.js +7 -7
- package/dist/block-convert.d.ts +7 -7
- package/dist/block-convert.js +22 -21
- package/dist/blocks.d.ts +8 -8
- package/dist/blocks.js +41 -40
- package/dist/canvas-fields.js +35 -12
- package/dist/canvas.d.ts +30 -31
- package/dist/canvas.js +64 -68
- package/dist/catalogs.d.ts +48 -0
- package/dist/catalogs.js +217 -0
- package/dist/chart-data.d.ts +10 -10
- package/dist/chart-data.js +21 -21
- package/dist/chart-options-panel.d.ts +2 -2
- package/dist/chart-options-panel.js +82 -8
- package/dist/chart-options.d.ts +35 -9
- package/dist/chart-options.js +133 -40
- package/dist/checks.js +23 -0
- package/dist/color-authoring.js +1 -1
- package/dist/content-actions.d.ts +17 -17
- package/dist/content-actions.js +74 -73
- package/dist/content-controls.js +21 -21
- package/dist/data-grid.js +7 -8
- package/dist/data.d.ts +4 -4
- package/dist/data.js +5 -5
- package/dist/dataset-refs.d.ts +2 -2
- package/dist/dataset-refs.js +5 -5
- package/dist/design-controls.d.ts +12 -5
- package/dist/design-controls.js +312 -111
- package/dist/design-options.d.ts +20 -18
- package/dist/design-options.js +91 -99
- package/dist/edit-helpers.js +8 -7
- package/dist/exact-source.js +5 -5
- package/dist/export.d.ts +7 -7
- package/dist/export.js +48 -17
- package/dist/find-panel.js +5 -5
- package/dist/find-replace.d.ts +3 -3
- package/dist/find-replace.js +23 -20
- package/dist/font-gate.js +49 -58
- package/dist/galleries.js +50 -101
- package/dist/grid-model.d.ts +25 -27
- package/dist/grid-model.js +92 -102
- package/dist/image-crop.d.ts +4 -4
- package/dist/image-crop.js +28 -29
- package/dist/image-cropper.js +3 -3
- package/dist/image-options.d.ts +45 -0
- package/dist/image-options.js +195 -0
- package/dist/index.d.ts +48 -31
- package/dist/index.js +154 -126
- package/dist/json-editor.js +2 -2
- package/dist/json-menu.js +2 -2
- package/dist/json-options.d.ts +3 -3
- package/dist/json-options.js +44 -54
- package/dist/layout-handles.js +5 -5
- package/dist/layout-placeholders.js +4 -4
- package/dist/layout.d.ts +2 -2
- package/dist/layout.js +8 -7
- package/dist/numbering-panel.js +3 -5
- package/dist/numbering.d.ts +3 -5
- package/dist/numbering.js +14 -26
- package/dist/outline-view.js +24 -24
- package/dist/outline.d.ts +12 -12
- package/dist/outline.js +52 -50
- package/dist/persistence.d.ts +1 -1
- package/dist/persistence.js +19 -18
- package/dist/presentation-ids.js +2 -2
- package/dist/react.js +3 -3
- package/dist/review-panel.d.ts +30 -13
- package/dist/review-panel.js +160 -53
- package/dist/review.d.ts +36 -43
- package/dist/review.js +92 -53
- package/dist/rich-text-input.js +4 -3
- package/dist/rich-text-toolbar.js +5 -3
- package/dist/rich-text.js +5 -2
- package/dist/schema-inspector.d.ts +2 -2
- package/dist/schema-inspector.js +16 -11
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +26 -6
- package/dist/slide-manager.d.ts +2 -2
- package/dist/slide-manager.js +19 -19
- package/dist/slides.d.ts +21 -21
- package/dist/slides.js +104 -101
- package/dist/svelte.js +2 -3
- package/dist/switches.d.ts +37 -22
- package/dist/switches.js +282 -171
- package/dist/table-options.d.ts +8 -8
- package/dist/table-options.js +28 -27
- package/dist/table-structure.d.ts +10 -10
- package/dist/table-structure.js +21 -21
- package/dist/template-panel.d.ts +3 -3
- package/dist/template-panel.js +37 -13
- package/dist/templates.d.ts +22 -11
- package/dist/templates.js +48 -55
- package/dist/transfer.d.ts +15 -4
- package/dist/transfer.js +107 -161
- package/package.json +12 -6
- package/dist/font-defaults.js +0 -25
package/dist/review-panel.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
// Review panel (RR-29): the DOM panel over core's
|
|
2
|
-
// open presentation (
|
|
3
|
-
// the content a finding is about, offers
|
|
4
|
-
// colour to the readable one) and updates as the document changes. The
|
|
5
|
-
//
|
|
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";
|
|
9
|
+
import { mergeCatalogs } from "./catalogs.js";
|
|
6
10
|
import {
|
|
7
11
|
applyReviewFix,
|
|
8
|
-
auditAvailable,
|
|
9
12
|
countFindings,
|
|
10
13
|
currentAltText,
|
|
11
14
|
filterFindings,
|
|
12
15
|
findingTarget,
|
|
16
|
+
CORE_SOURCE,
|
|
17
|
+
groupFindings,
|
|
18
|
+
mergeFindingReports,
|
|
13
19
|
reviewFindings,
|
|
14
20
|
reviewSeverities,
|
|
15
|
-
runAudit,
|
|
16
21
|
setReviewAltText,
|
|
17
22
|
} from "./review.js";
|
|
18
23
|
|
|
19
24
|
const SEVERITY_LABELS = { error: "Error", warning: "Warning", info: "Note" };
|
|
25
|
+
const CATEGORY_LABELS = { format: "Format", references: "References", policy: "Policy", accessibility: "Accessibility", layout: "Layout", content: "Content" };
|
|
26
|
+
const categoryLabel = (category) => CATEGORY_LABELS[category] ?? (category.charAt(0).toUpperCase() + category.slice(1));
|
|
27
|
+
const DEFAULT_DELAY = 300;
|
|
20
28
|
const MINIMUMS = { all: "info", warnings: "warning", errors: "error" };
|
|
21
29
|
let panelCounter = 0;
|
|
22
30
|
|
|
@@ -40,7 +48,10 @@ const STYLE = `
|
|
|
40
48
|
.opf-review-counts{margin:0;color:var(--muted,#656570)}
|
|
41
49
|
.opf-review-filters{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;margin:0 0 8px}
|
|
42
50
|
.opf-review-filters label{display:inline-flex;gap:6px;align-items:center;margin:0;font-weight:400}
|
|
43
|
-
.opf-review-list{
|
|
51
|
+
.opf-review-list{margin:0;padding:0}
|
|
52
|
+
.opf-review-category{margin:0 0 8px}
|
|
53
|
+
.opf-review-category-title{margin:10px 0 2px;font-size:12px;font-weight:600;color:var(--muted,#656570)}
|
|
54
|
+
.opf-review-items{list-style:none;margin:0;padding:0}
|
|
44
55
|
.opf-review-item{border-top:1px solid var(--border,#e8e8ec);padding:8px 0}
|
|
45
56
|
.opf-review-item[aria-current="true"]{background:var(--review-current,rgba(101,89,207,.07))}
|
|
46
57
|
.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 +98,56 @@ function summaryText(counts, scopeLabel) {
|
|
|
87
98
|
|
|
88
99
|
/**
|
|
89
100
|
* Mount the Review panel. Options: `editor` (a session), `getSlideIndex()` (the slide the "This slide only" filter
|
|
90
|
-
* follows), `
|
|
91
|
-
*
|
|
92
|
-
* `onFocusField({finding, fix, target})` (focus a field the panel does not own: a title, text, link or
|
|
93
|
-
* `onStatus(message, {error})`, `onChange({error, warning, info, total, all, unavailable})` (after every redraw, with
|
|
94
|
-
* `
|
|
95
|
-
*
|
|
101
|
+
* follows), `getValidateOptions(presentation)` (core's `ValidateOptions`: `fonts` with the host's `textMeasurement` for font-exact
|
|
102
|
+
* overflow, `severity`, `ignore`, `thresholds`, `contracts`, ...), `onGoTo({finding, target})` (select the content: `target` is
|
|
103
|
+
* {slide, path, pointer, exact}), `onFocusField({finding, fix, target})` (focus a field the panel does not own: a title, text, link or
|
|
104
|
+
* language), `onStatus(message, {error})`, `onChange({error, warning, info, total, all, sources, unavailable})` (after every redraw, with
|
|
105
|
+
* the shown counts), `ignored` (rule ids hidden at the start) and `onIgnoredChange(ids)`.
|
|
106
|
+
*
|
|
107
|
+
* The full `validate(presentation)` runs once the document has been quiet for `delay` milliseconds (default 300) after a session change,
|
|
108
|
+
* and at once on mount and on `refresh()`. `autoRefresh` (default true) turns the re-check after changes off for a host that must wait
|
|
109
|
+
* for its fonts; it then calls `refresh()` itself.
|
|
110
|
+
*
|
|
111
|
+
* `review(presentation, { signal, report, validateOptions })` is an optional hook that returns a `FindingReport` from a hosted reviewer
|
|
112
|
+
* (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
|
|
113
|
+
* `review`. The hook can cost money and take seconds, so it runs when the author presses its button (`reviewLabel`, default "Run
|
|
114
|
+
* review") or, with `autoReview: true`, after every re-check; findings from an earlier run stay listed, marked as possibly out of date,
|
|
115
|
+
* until the next run. A failing hook shows its message and leaves core's findings in place.
|
|
96
116
|
*/
|
|
97
117
|
export function createReviewPanel(container, options) {
|
|
98
|
-
const { editor, getSlideIndex,
|
|
118
|
+
const { editor, getSlideIndex, getValidateOptions, onGoTo, onFocusField, onStatus, onIgnoredChange, onChange, review } = options;
|
|
119
|
+
// Core resolves the deck's references with the session's registered catalogs (and any the host adds): the same list as the preview.
|
|
120
|
+
const validateOptions = () => {
|
|
121
|
+
const host = getValidateOptions?.(editor.presentation) ?? {};
|
|
122
|
+
return { ...host, catalogs: mergeCatalogs(editor.catalogs, host.catalogs) };
|
|
123
|
+
};
|
|
124
|
+
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
125
|
if (!editor || typeof editor.subscribe !== "function") throw new Error("The Review panel needs an editor session created by createEditorSession.");
|
|
100
126
|
const doc = container.ownerDocument;
|
|
101
127
|
ensureStyles(doc);
|
|
102
128
|
const id = `opf-review-${++panelCounter}`;
|
|
103
129
|
const ignored = new Set(options.ignored ?? []);
|
|
104
130
|
let report;
|
|
131
|
+
let coreReport;
|
|
132
|
+
let hookReport;
|
|
133
|
+
let hookStale = false;
|
|
134
|
+
let hookError;
|
|
135
|
+
let hookRunning;
|
|
105
136
|
let findings = [];
|
|
106
137
|
let destroyed = false;
|
|
107
|
-
let
|
|
138
|
+
let timer;
|
|
108
139
|
let currentId;
|
|
109
140
|
let editingAlt;
|
|
110
141
|
let altDraft = "";
|
|
111
142
|
let lastAuditError;
|
|
143
|
+
const delay = Number.isFinite(options.delay) && options.delay >= 0 ? options.delay : DEFAULT_DELAY;
|
|
112
144
|
|
|
113
145
|
const root = h(doc, "section", { class: "opf-review", "data-opf-component": "review-panel", "aria-labelledby": `${id}-title` });
|
|
114
146
|
const counts = h(doc, "p", { class: "opf-review-counts", id: `${id}-counts`, role: "status", "aria-live": "polite" });
|
|
115
147
|
const title = h(doc, "h2", { id: `${id}-title`, text: "Review", tabindex: "-1" });
|
|
116
|
-
const
|
|
148
|
+
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;
|
|
149
|
+
const hookStatus = review ? h(doc, "p", { class: "opf-review-meta", role: "status", "aria-live": "polite" }) : undefined;
|
|
150
|
+
const head = h(doc, "div", { class: "opf-review-head" }, title, counts, hookButton);
|
|
117
151
|
const severitySelect = h(doc, "select", { id: `${id}-severity` },
|
|
118
152
|
h(doc, "option", { value: "all", text: "All findings" }),
|
|
119
153
|
h(doc, "option", { value: "warnings", text: "Errors and warnings" }),
|
|
@@ -122,10 +156,10 @@ export function createReviewPanel(container, options) {
|
|
|
122
156
|
const filters = h(doc, "div", { class: "opf-review-filters" },
|
|
123
157
|
h(doc, "label", { for: `${id}-severity` }, "Show ", severitySelect),
|
|
124
158
|
h(doc, "label", { for: `${id}-slide` }, slideOnly, "This slide only"));
|
|
125
|
-
const list = h(doc, "
|
|
159
|
+
const list = h(doc, "div", { class: "opf-review-list", role: "group", "aria-label": "Review findings" });
|
|
126
160
|
const empty = h(doc, "p", { class: "opf-review-empty" });
|
|
127
161
|
const ignoredBox = h(doc, "details", { class: "opf-review-ignored", hidden: true });
|
|
128
|
-
root.append(head, filters, list, empty, ignoredBox);
|
|
162
|
+
root.append(head, ...(hookStatus ? [hookStatus] : []), filters, list, empty, ignoredBox);
|
|
129
163
|
container.append(root);
|
|
130
164
|
|
|
131
165
|
const say = (message, error = false) => {
|
|
@@ -135,21 +169,64 @@ export function createReviewPanel(container, options) {
|
|
|
135
169
|
function visible() {
|
|
136
170
|
const minimum = MINIMUMS[severitySelect.value] ?? "info";
|
|
137
171
|
const slide = slideOnly.checked ? (getSlideIndex?.() ?? 0) : undefined;
|
|
138
|
-
|
|
172
|
+
const shown = filterFindings(findings, { minimum, slide }).filter((finding) => !ignored.has(finding.ruleId));
|
|
173
|
+
return groupFindings(shown).flatMap((group) => group.findings);
|
|
139
174
|
}
|
|
140
175
|
|
|
141
|
-
|
|
176
|
+
// Core's full check, then the hook's findings merged in by source.
|
|
177
|
+
function validateNow() {
|
|
142
178
|
lastAuditError = undefined;
|
|
143
179
|
try {
|
|
144
|
-
|
|
145
|
-
findings = reviewFindings(report);
|
|
180
|
+
coreReport = validate(editor.presentation, validateOptions());
|
|
146
181
|
} catch (error) {
|
|
147
|
-
|
|
148
|
-
findings = [];
|
|
182
|
+
coreReport = undefined;
|
|
149
183
|
lastAuditError = error;
|
|
150
184
|
}
|
|
185
|
+
merge();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function merge() {
|
|
189
|
+
report = coreReport || hookReport ? mergeFindingReports(coreReport, hookReport) : undefined;
|
|
190
|
+
findings = reviewFindings(report);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hookMessage() {
|
|
194
|
+
if (!review) return "";
|
|
195
|
+
if (hookRunning) return "Running the hosted review…";
|
|
196
|
+
if (hookError) return `The hosted review could not run: ${messageOf(hookError)}`;
|
|
197
|
+
if (hookReport && hookStale) return "Hosted review findings are from before your latest edit.";
|
|
198
|
+
if (hookReport) return "Hosted review is up to date.";
|
|
199
|
+
return "";
|
|
151
200
|
}
|
|
152
201
|
|
|
202
|
+
// 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).
|
|
203
|
+
async function runHook() {
|
|
204
|
+
if (!review || destroyed) return;
|
|
205
|
+
hookRunning?.controller.abort();
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const running = { controller };
|
|
208
|
+
hookRunning = running;
|
|
209
|
+
hookError = undefined;
|
|
210
|
+
if (!coreReport && !lastAuditError) validateNow();
|
|
211
|
+
renderList();
|
|
212
|
+
try {
|
|
213
|
+
const result = await review(editor.presentation, { signal: controller.signal, report: coreReport, validateOptions: validateOptions() });
|
|
214
|
+
if (destroyed || hookRunning !== running) return;
|
|
215
|
+
if (!result || !Array.isArray(result.findings)) throw new TypeError("The review hook must resolve a FindingReport ({ valid, findings, counts }).");
|
|
216
|
+
hookRunning = undefined;
|
|
217
|
+
hookReport = result;
|
|
218
|
+
hookStale = false;
|
|
219
|
+
merge();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (destroyed || hookRunning !== running) return;
|
|
222
|
+
hookRunning = undefined;
|
|
223
|
+
hookError = error;
|
|
224
|
+
}
|
|
225
|
+
renderList();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const itemFor = (findingId) => [...list.querySelectorAll(".opf-review-item")].find((entry) => entry.dataset.findingId === findingId);
|
|
229
|
+
|
|
153
230
|
function focusGoto(findingId) {
|
|
154
231
|
const buttons = [...list.querySelectorAll(".opf-review-goto")];
|
|
155
232
|
(buttons.find((button) => button.dataset.findingId === findingId) ?? buttons[0])?.focus();
|
|
@@ -157,21 +234,21 @@ export function createReviewPanel(container, options) {
|
|
|
157
234
|
|
|
158
235
|
function go(finding) {
|
|
159
236
|
currentId = finding.id;
|
|
160
|
-
const target = findingTarget(editor.
|
|
161
|
-
for (const
|
|
237
|
+
const target = findingTarget(editor.presentation, finding);
|
|
238
|
+
for (const entry of list.querySelectorAll(".opf-review-item")) entry.setAttribute("aria-current", String(entry.dataset.findingId === finding.id));
|
|
162
239
|
onGoTo?.({ finding, target });
|
|
163
240
|
}
|
|
164
241
|
|
|
165
242
|
function altForm(finding, fix) {
|
|
166
243
|
const pointer = fix.focus.path;
|
|
167
244
|
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.
|
|
245
|
+
const input = h(doc, "input", { type: "text", id: inputId, value: altDraft || currentAltText(editor.presentation, pointer), "aria-describedby": `${inputId}-help` });
|
|
169
246
|
const error = h(doc, "span", { class: "opf-review-error", role: "alert" });
|
|
170
247
|
const form = h(doc, "form", { class: "opf-review-alt", novalidate: true },
|
|
171
248
|
h(doc, "label", { for: inputId, text: "Alt text" }), input,
|
|
172
249
|
h(doc, "button", { type: "submit", class: "opf-review-fix", text: "Save alt text" }),
|
|
173
250
|
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
|
|
251
|
+
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
252
|
input.addEventListener("input", () => { altDraft = input.value; });
|
|
176
253
|
form.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.stopPropagation(); closeAlt(finding); } });
|
|
177
254
|
form.addEventListener("submit", (event) => {
|
|
@@ -181,6 +258,7 @@ export function createReviewPanel(container, options) {
|
|
|
181
258
|
editingAlt = undefined;
|
|
182
259
|
altDraft = "";
|
|
183
260
|
say("Alt text saved. Undo restores the previous text.");
|
|
261
|
+
refresh();
|
|
184
262
|
} catch (problem) {
|
|
185
263
|
error.textContent = messageOf(problem);
|
|
186
264
|
say(messageOf(problem), true);
|
|
@@ -203,18 +281,19 @@ export function createReviewPanel(container, options) {
|
|
|
203
281
|
editingAlt = finding.id;
|
|
204
282
|
altDraft = "";
|
|
205
283
|
renderList();
|
|
206
|
-
|
|
284
|
+
itemFor(finding.id)?.querySelector(".opf-review-alt input")?.focus();
|
|
207
285
|
return;
|
|
208
286
|
}
|
|
209
287
|
go(finding);
|
|
210
|
-
onFocusField?.({ finding, fix, target: findingTarget(editor.
|
|
288
|
+
onFocusField?.({ finding, fix, target: findingTarget(editor.presentation, finding) });
|
|
211
289
|
return;
|
|
212
290
|
}
|
|
213
291
|
const index = visible().findIndex((entry) => entry.id === finding.id);
|
|
214
292
|
try {
|
|
215
293
|
applyReviewFix(editor, finding, fix);
|
|
216
|
-
say(`${fix.
|
|
217
|
-
//
|
|
294
|
+
say(`${fix.title}. Undo restores the previous state.`);
|
|
295
|
+
// Check the changed document at once, then put focus on the neighbouring finding.
|
|
296
|
+
refresh();
|
|
218
297
|
const after = visible();
|
|
219
298
|
const next = after[Math.min(Math.max(index, 0), after.length - 1)];
|
|
220
299
|
if (next) focusGoto(next.id);
|
|
@@ -236,17 +315,17 @@ export function createReviewPanel(container, options) {
|
|
|
236
315
|
},
|
|
237
316
|
h(doc, "span", { class: "opf-review-sev", text: SEVERITY_LABELS[finding.severity] }),
|
|
238
317
|
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 }));
|
|
318
|
+
h(doc, "span", { class: "opf-review-meta", text: `${where} · ${finding.ruleId}${finding.source && finding.source !== CORE_SOURCE ? ` · ${finding.source}` : ""}` }));
|
|
319
|
+
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_,
|
|
320
|
+
finding.help ? h(doc, "p", { class: "opf-review-help", text: finding.help }) : undefined);
|
|
242
321
|
const actions = h(doc, "div", { class: "opf-review-actions" });
|
|
243
322
|
for (const fix of finding.fixes ?? []) {
|
|
244
323
|
actions.append(h(doc, "button", {
|
|
245
324
|
type: "button",
|
|
246
325
|
class: `opf-review-fix${fix.safe ? "" : " is-careful"}`,
|
|
247
|
-
"data-fix": fix.id,
|
|
326
|
+
"data-fix": fix.id ?? fix.title,
|
|
248
327
|
title: fix.safe ? undefined : "This changes how the slide looks or what it says; it can be undone.",
|
|
249
|
-
text: fix.
|
|
328
|
+
text: fix.title,
|
|
250
329
|
onclick: () => runFix(finding, fix),
|
|
251
330
|
}));
|
|
252
331
|
}
|
|
@@ -289,22 +368,30 @@ export function createReviewPanel(container, options) {
|
|
|
289
368
|
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
369
|
const shown = visible();
|
|
291
370
|
const slideScope = slideOnly.checked ? ` on slide ${(getSlideIndex?.() ?? 0) + 1}` : "";
|
|
292
|
-
list.replaceChildren(...shown.map(
|
|
371
|
+
list.replaceChildren(...groupFindings(shown).map(({ category, findings: inGroup }) => {
|
|
372
|
+
const heading = h(doc, "h3", { class: "opf-review-category-title", id: `${id}-cat-${category}`, text: `${categoryLabel(category)} (${inGroup.length})` });
|
|
373
|
+
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)));
|
|
374
|
+
}));
|
|
375
|
+
if (hookStatus) {
|
|
376
|
+
hookStatus.textContent = hookMessage();
|
|
377
|
+
hookButton.disabled = Boolean(hookRunning);
|
|
378
|
+
hookButton.textContent = hookReport ? `${options.reviewLabel ?? "Run review"} again` : options.reviewLabel ?? "Run review";
|
|
379
|
+
}
|
|
293
380
|
if (lastAuditError) {
|
|
294
381
|
empty.hidden = false;
|
|
295
|
-
empty.textContent =
|
|
382
|
+
empty.textContent = `The review could not run: ${messageOf(lastAuditError)}`;
|
|
296
383
|
counts.textContent = "Review unavailable.";
|
|
297
384
|
} else {
|
|
298
385
|
const hiddenCount = findings.length - findings.filter((finding) => !ignored.has(finding.ruleId)).length;
|
|
299
386
|
counts.textContent = summaryText(countFindings(shown), slideScope) + (hiddenCount ? ` ${plural(hiddenCount, "finding")} hidden.` : "");
|
|
300
387
|
empty.hidden = shown.length > 0;
|
|
301
|
-
empty.textContent = findings.length && !shown.length ? "Nothing to show with these filters." : "
|
|
388
|
+
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
389
|
}
|
|
303
390
|
renderIgnored();
|
|
304
|
-
onChange?.({ ...countFindings(shown), all: findings.length, unavailable: Boolean(lastAuditError) });
|
|
391
|
+
onChange?.({ ...countFindings(shown), all: findings.length, sources: report?.sources ?? [], unavailable: Boolean(lastAuditError) });
|
|
305
392
|
// Live updates must not take the keyboard away: put focus back on the same control, or the same finding.
|
|
306
393
|
if (hadFocus?.id && !lastAuditError) {
|
|
307
|
-
const li =
|
|
394
|
+
const li = itemFor(hadFocus.id);
|
|
308
395
|
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
396
|
if (target && doc.activeElement !== target) target.focus();
|
|
310
397
|
}
|
|
@@ -312,16 +399,27 @@ export function createReviewPanel(container, options) {
|
|
|
312
399
|
|
|
313
400
|
function refresh() {
|
|
314
401
|
if (destroyed) return;
|
|
315
|
-
|
|
316
|
-
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
timer = undefined;
|
|
404
|
+
validateNow();
|
|
317
405
|
renderList();
|
|
406
|
+
if (options.autoReview && review) void runHook();
|
|
318
407
|
}
|
|
319
408
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
409
|
+
// One full check per burst of changes (typing, an undo group, a paste): when nothing has changed for `delay` milliseconds.
|
|
410
|
+
function schedule() {
|
|
411
|
+
if (destroyed) return;
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
timer = setTimeout(refresh, delay);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 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.
|
|
417
|
+
const stop = editor.subscribe(() => {
|
|
418
|
+
if (destroyed) return;
|
|
419
|
+
if (hookRunning) { hookRunning.controller.abort(); hookRunning = undefined; }
|
|
420
|
+
if (hookReport) hookStale = true;
|
|
421
|
+
if (options.autoRefresh === false) { renderList(); return; }
|
|
422
|
+
schedule();
|
|
325
423
|
});
|
|
326
424
|
|
|
327
425
|
severitySelect.addEventListener("change", renderList);
|
|
@@ -341,19 +439,28 @@ export function createReviewPanel(container, options) {
|
|
|
341
439
|
|
|
342
440
|
return {
|
|
343
441
|
element: root,
|
|
344
|
-
/** The latest findings (core's
|
|
442
|
+
/** The latest findings (core's, and the hosted reviewer's, with `id`, `slide` and `dottedPath`), before filters and hidden checks. */
|
|
345
443
|
get findings() { return findings; },
|
|
444
|
+
/** The merged report: `valid`, `findings`, `counts` and `sources`. */
|
|
346
445
|
get report() { return report; },
|
|
446
|
+
/** Core's `validate` report alone. */
|
|
447
|
+
get validation() { return coreReport; },
|
|
347
448
|
refresh,
|
|
449
|
+
/** Check after `delay` milliseconds without another call: a host that drives its own checks (`autoRefresh: false`) calls this after each redraw. */
|
|
450
|
+
schedule,
|
|
451
|
+
/** Run the hosted reviewer now (the `review` hook); resolves when its findings are listed. */
|
|
452
|
+
review: runHook,
|
|
348
453
|
/** Re-render after the host's slide changed (the "This slide only" filter). */
|
|
349
454
|
update: renderList,
|
|
350
455
|
setIgnored,
|
|
351
456
|
destroy() {
|
|
352
457
|
destroyed = true;
|
|
353
|
-
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
hookRunning?.controller.abort();
|
|
460
|
+
stop();
|
|
354
461
|
root.remove();
|
|
355
462
|
},
|
|
356
463
|
};
|
|
357
464
|
}
|
|
358
465
|
|
|
359
|
-
export {
|
|
466
|
+
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 =
|
|
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
|
|
6
|
-
export interface
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
44
|
-
export type
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
61
|
-
export declare
|
|
62
|
-
/**
|
|
63
|
-
export declare
|
|
64
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
68
|
+
export declare function currentAltText(presentation: unknown, pointer: string): string;
|