@openpresentation/opf-editor 0.12.1 → 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.
- package/README.md +71 -60
- package/dist/annotations.d.ts +16 -16
- package/dist/annotations.js +71 -79
- package/dist/assets.d.ts +7 -7
- package/dist/assets.js +15 -15
- package/dist/background-options.d.ts +2 -2
- package/dist/background-options.js +6 -6
- 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 +22 -30
- package/dist/canvas.js +57 -64
- 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 +21 -0
- package/dist/color-authoring.js +1 -1
- package/dist/content-actions.d.ts +15 -15
- package/dist/content-actions.js +71 -70
- package/dist/content-controls.js +12 -12
- 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.js +61 -44
- package/dist/design-options.d.ts +16 -14
- package/dist/design-options.js +86 -74
- package/dist/edit-helpers.js +8 -7
- package/dist/exact-source.js +5 -5
- package/dist/export.d.ts +5 -7
- package/dist/export.js +40 -14
- 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 +36 -52
- 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 +20 -20
- package/dist/image-cropper.js +3 -3
- package/dist/index.d.ts +28 -28
- package/dist/index.js +74 -90
- package/dist/json-options.js +17 -16
- 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 +154 -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 +25 -5
- package/dist/slide-manager.d.ts +2 -2
- package/dist/slide-manager.js +18 -18
- package/dist/slides.d.ts +20 -20
- package/dist/slides.js +99 -97
- package/dist/svelte.js +1 -1
- package/dist/switches.d.ts +10 -10
- package/dist/switches.js +124 -114
- 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 +4 -4
- package/dist/transfer.js +49 -59
- package/package.json +8 -6
- package/dist/font-defaults.js +0 -25
package/dist/persistence.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface RestoreOffer {
|
|
|
22
22
|
dirty: boolean;
|
|
23
23
|
/** Whether the undo history is stored with it. */
|
|
24
24
|
hasHistory: boolean;
|
|
25
|
-
|
|
25
|
+
presentation: unknown;
|
|
26
26
|
/** An older copy that was moved aside when the user kept editing while an earlier offer was open. */
|
|
27
27
|
earlier?: { savedAt: number; name?: string; slideCount?: number };
|
|
28
28
|
}
|
package/dist/persistence.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// shows, and the editor keeps working.
|
|
15
15
|
import { fail } from "./edit-helpers.js";
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
const
|
|
17
|
+
// 2: the stored record holds `presentation` (it held `document` in version 1). A record of another version is never read.
|
|
18
|
+
const VERSION = 2;
|
|
19
|
+
const PREFIX = "opf-editor/v2/";
|
|
19
20
|
export const DEFAULT_DEBOUNCE_MS = 800;
|
|
20
21
|
export const DEFAULT_MAX_WAIT_MS = 5000;
|
|
21
22
|
export const DEFAULT_MAX_HISTORY_ENTRIES = 200;
|
|
@@ -155,7 +156,7 @@ export function createPersistence(editor, options = {}) {
|
|
|
155
156
|
const limits = { entries: options.maxHistoryEntries ?? DEFAULT_MAX_HISTORY_ENTRIES, bytes: options.maxHistoryBytes ?? DEFAULT_MAX_HISTORY_BYTES };
|
|
156
157
|
|
|
157
158
|
let adapter;
|
|
158
|
-
let baseline = jsonOf(editor.
|
|
159
|
+
let baseline = jsonOf(editor.presentation); // JSON of the document last saved elsewhere; null means "unsaved in a way we cannot compare"
|
|
159
160
|
let touched = false; // the document changed (or was saved) since this controller was created: only then is a copy written
|
|
160
161
|
let pending; // the stored record on offer
|
|
161
162
|
let stashed = false; // the offered record has been moved aside (earlierKey) because the user kept editing
|
|
@@ -172,20 +173,20 @@ export function createPersistence(editor, options = {}) {
|
|
|
172
173
|
options.onStatus?.(status);
|
|
173
174
|
};
|
|
174
175
|
function isDirty() {
|
|
175
|
-
return baseline === null || jsonOf(editor.
|
|
176
|
+
return baseline === null || jsonOf(editor.presentation) !== baseline;
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
// --- writing --------------------------------------------------------------------------------------
|
|
179
180
|
|
|
180
181
|
async function writeRecord() {
|
|
181
182
|
if (!adapter || destroyed) return false;
|
|
182
|
-
const
|
|
183
|
-
const json = jsonOf(
|
|
183
|
+
const presentation = editor.presentation;
|
|
184
|
+
const json = jsonOf(presentation);
|
|
184
185
|
const dirty = baseline === null || json !== baseline;
|
|
185
186
|
const history = includeHistory ? trimHistory(editor.exportHistory(), limits) : undefined;
|
|
186
187
|
const signature = `${json}|${dirty}|${history ? history.undo.length + ":" + history.redo.length : ""}`;
|
|
187
188
|
if (signature === lastWritten) return true;
|
|
188
|
-
const record = { version: VERSION, key: options.key, savedAt: now(), name: typeof
|
|
189
|
+
const record = { version: VERSION, key: options.key, savedAt: now(), name: typeof presentation.name === "string" ? presentation.name : undefined, slideCount: Array.isArray(presentation.slides) ? presentation.slides.length : 0, dirty, presentation, ...(history ? { undo: history.undo, redo: history.redo } : {}) };
|
|
189
190
|
setStatus({ state: "saving" });
|
|
190
191
|
try {
|
|
191
192
|
// The first write while an offer is open moves the offered copy aside, so ignoring the prompt never loses it.
|
|
@@ -233,24 +234,24 @@ export function createPersistence(editor, options = {}) {
|
|
|
233
234
|
|
|
234
235
|
// --- offering and restoring -------------------------------------------------------------------------
|
|
235
236
|
|
|
236
|
-
const validRecord = (record) => isRecordObject(record) && record.version === VERSION && isRecordObject(record.
|
|
237
|
-
const offerOf = (record) => ({ key: options.key, savedAt: record.savedAt, name: record.name, slideCount: record.slideCount, dirty: record.dirty !== false, hasHistory: Boolean(record.undo?.length || record.redo?.length),
|
|
237
|
+
const validRecord = (record) => isRecordObject(record) && record.version === VERSION && isRecordObject(record.presentation) && Array.isArray(record.presentation.slides);
|
|
238
|
+
const offerOf = (record) => ({ key: options.key, savedAt: record.savedAt, name: record.name, slideCount: record.slideCount, dirty: record.dirty !== false, hasHistory: Boolean(record.undo?.length || record.redo?.length), presentation: record.presentation });
|
|
238
239
|
|
|
239
240
|
async function applyRecord(record) {
|
|
240
241
|
const pristine = !touched && !editor.canUndo && !editor.canRedo;
|
|
241
|
-
const state = {
|
|
242
|
+
const state = { presentation: record.presentation, undo: record.undo ?? [], redo: record.redo ?? [] };
|
|
242
243
|
if (pristine) {
|
|
243
244
|
try { editor.restoreState(state, { source: "restore" }); }
|
|
244
245
|
catch (error) {
|
|
245
246
|
if (error?.code !== "invalid-history") throw error;
|
|
246
|
-
editor.restoreState({
|
|
247
|
+
editor.restoreState({ presentation: record.presentation }, { source: "restore" });
|
|
247
248
|
}
|
|
248
249
|
} else {
|
|
249
250
|
// The user already worked in this session: bring the copy back as one undoable change instead of replacing their history.
|
|
250
|
-
editor.applyPatch([{ op: "replace", path: "", value: structuredClone(record.
|
|
251
|
+
editor.applyPatch([{ op: "replace", path: "", value: structuredClone(record.presentation) }], { source: "restore" });
|
|
251
252
|
}
|
|
252
253
|
touched = true;
|
|
253
|
-
baseline = record.dirty === false ? jsonOf(editor.
|
|
254
|
+
baseline = record.dirty === false ? jsonOf(editor.presentation) : null;
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
/** Put the offered copy back. Resolves true when it was restored, false when there was nothing on offer or it was refused. */
|
|
@@ -294,7 +295,7 @@ export function createPersistence(editor, options = {}) {
|
|
|
294
295
|
let record;
|
|
295
296
|
try { record = await adapter.get(earlierKey); } catch { return false; }
|
|
296
297
|
if (!validRecord(record)) return false;
|
|
297
|
-
try { editor.applyPatch([{ op: "replace", path: "", value: structuredClone(record.
|
|
298
|
+
try { editor.applyPatch([{ op: "replace", path: "", value: structuredClone(record.presentation) }], { source: "restore" }); }
|
|
298
299
|
catch { return false; }
|
|
299
300
|
pending = undefined;
|
|
300
301
|
touched = true;
|
|
@@ -309,13 +310,13 @@ export function createPersistence(editor, options = {}) {
|
|
|
309
310
|
|
|
310
311
|
/** The host saved the document somewhere of its own (a download, a server): it is no longer "unsaved", and the stored copy says so. */
|
|
311
312
|
function markSaved() {
|
|
312
|
-
baseline = jsonOf(editor.
|
|
313
|
+
baseline = jsonOf(editor.presentation);
|
|
313
314
|
touched = true;
|
|
314
315
|
return flush();
|
|
315
316
|
}
|
|
316
317
|
/** Treat the current document as the starting point (a host that loads a document into the editor): no write, and not dirty, until the next change. */
|
|
317
318
|
function rebase() {
|
|
318
|
-
baseline = jsonOf(editor.
|
|
319
|
+
baseline = jsonOf(editor.presentation);
|
|
319
320
|
touched = false;
|
|
320
321
|
clearTimeout(timer);
|
|
321
322
|
firstDirtyAt = 0;
|
|
@@ -329,7 +330,7 @@ export function createPersistence(editor, options = {}) {
|
|
|
329
330
|
await dropEarlier();
|
|
330
331
|
lastWritten = "";
|
|
331
332
|
touched = false;
|
|
332
|
-
baseline = jsonOf(editor.
|
|
333
|
+
baseline = jsonOf(editor.presentation);
|
|
333
334
|
setStatus({ state: status.available ? "idle" : status.state, message: "" });
|
|
334
335
|
}
|
|
335
336
|
|
|
@@ -371,7 +372,7 @@ export function createPersistence(editor, options = {}) {
|
|
|
371
372
|
setStatus({ state: "error", message: `Saved work could not be read (${String(error?.message ?? error)}).` });
|
|
372
373
|
return { available: true, offered: false };
|
|
373
374
|
}
|
|
374
|
-
if (!validRecord(record) || jsonOf(record.
|
|
375
|
+
if (!validRecord(record) || jsonOf(record.presentation) === jsonOf(editor.presentation)) return { available: true, offered: false };
|
|
375
376
|
try {
|
|
376
377
|
const earlier = await adapter.get(earlierKey);
|
|
377
378
|
hasEarlier = validRecord(earlier);
|
package/dist/presentation-ids.js
CHANGED
|
@@ -47,9 +47,9 @@ export function slideIds(slide) {
|
|
|
47
47
|
return ids;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function collectReservedPresentationIds(
|
|
50
|
+
export function collectReservedPresentationIds(presentation) {
|
|
51
51
|
const ids = [];
|
|
52
|
-
for (const slide of
|
|
52
|
+
for (const slide of presentation.slides ?? []) ids.push(...slideIds(slide));
|
|
53
53
|
return ids.filter(Boolean);
|
|
54
54
|
}
|
|
55
55
|
|
package/dist/react.js
CHANGED
|
@@ -32,7 +32,7 @@ export function createOPFReactComponents(React) {
|
|
|
32
32
|
...props
|
|
33
33
|
}) {
|
|
34
34
|
const snapshot = useEditorSnapshot(editor);
|
|
35
|
-
const value = getValueAtPath(snapshot.
|
|
35
|
+
const value = getValueAtPath(snapshot.presentation, path, "");
|
|
36
36
|
const tag = multiline ? "textarea" : "input";
|
|
37
37
|
return React.createElement(tag, {
|
|
38
38
|
...props,
|
|
@@ -60,9 +60,9 @@ export function createOPFReactComponents(React) {
|
|
|
60
60
|
...props
|
|
61
61
|
}) {
|
|
62
62
|
const snapshot = useEditorSnapshot(editor);
|
|
63
|
-
const value = getValueAtPath(snapshot.
|
|
63
|
+
const value = getValueAtPath(snapshot.presentation, path, "");
|
|
64
64
|
const currentId = value && typeof value === "object" ? value.id : value;
|
|
65
|
-
const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.
|
|
65
|
+
const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.presentation });
|
|
66
66
|
|
|
67
67
|
return React.createElement(
|
|
68
68
|
"select",
|
package/dist/review-panel.d.ts
CHANGED
|
@@ -1,44 +1,61 @@
|
|
|
1
|
+
import type { ValidateOptions } from "@openpresentation/opf";
|
|
1
2
|
import type { EditorSession } from "./index.js";
|
|
2
|
-
import type {
|
|
3
|
+
import type { ReviewFinding, ReviewFix, ReviewHook, ReviewReport, ReviewTarget } from "./review.js";
|
|
3
4
|
|
|
4
5
|
export interface ReviewPanelOptions {
|
|
5
6
|
editor: EditorSession;
|
|
6
7
|
/** The slide the "This slide only" filter follows. Call `update()` when it changes. */
|
|
7
8
|
getSlideIndex?: () => number;
|
|
8
|
-
/** Core's `
|
|
9
|
-
|
|
9
|
+
/** Core's `ValidateOptions` for this document, for example `{ fonts: { textMeasurement } }` from the host's fonts handle (a function of the slide index is accepted too). */
|
|
10
|
+
getValidateOptions?: (presentation: unknown) => ValidateOptions;
|
|
10
11
|
/** Select the content a finding is about. `target.path` is the OPF dotted path (the nearest existing field), `target.slide` the slide or null. */
|
|
11
12
|
onGoTo?: (event: { finding: ReviewFinding; target: ReviewTarget }) => void;
|
|
12
13
|
/** Focus a field the panel does not own (a title, text, link, language or font size) after going to it. Alt text is edited in the panel itself. */
|
|
13
14
|
onFocusField?: (event: { finding: ReviewFinding; fix: ReviewFix; target: ReviewTarget }) => void;
|
|
14
15
|
/** Called with every status or error message the panel shows. */
|
|
15
16
|
onStatus?: (message: string, info: { error: boolean }) => void;
|
|
16
|
-
/** Called after every redraw with the counts of the findings shown (after the filters and hidden checks)
|
|
17
|
-
onChange?: (counts: { error: number; warning: number; info: number; total: number; all: number; unavailable: boolean }) => void;
|
|
17
|
+
/** Called after every redraw with the counts of the findings shown (after the filters and hidden checks), of all findings, and the sources present (`opf`, `pptx.dev/review`, ...). */
|
|
18
|
+
onChange?: (counts: { error: number; warning: number; info: number; total: number; all: number; sources: string[]; unavailable: boolean }) => void;
|
|
18
19
|
/** Rule ids hidden at the start, and a callback when the author hides or restores a check. */
|
|
19
20
|
ignored?: string[];
|
|
20
21
|
onIgnoredChange?: (ids: string[]) => void;
|
|
21
|
-
/** Re-
|
|
22
|
+
/** Re-check after changes (default true). Set false to call `refresh()` yourself, for example after the host's fonts have loaded. */
|
|
22
23
|
autoRefresh?: boolean;
|
|
23
|
-
/**
|
|
24
|
-
|
|
24
|
+
/** Milliseconds the document must be quiet after a change before the full `validate` runs again (default 300). `refresh()` checks at once. */
|
|
25
|
+
delay?: number;
|
|
26
|
+
/**
|
|
27
|
+
* A hosted reviewer: resolves a `FindingReport` whose findings (`source`, for example `pptx.dev/review`) are listed beside core's. It runs when
|
|
28
|
+
* the author presses its button, or after every re-check with `autoReview`. A finding with no `source` is listed under `review`.
|
|
29
|
+
*/
|
|
30
|
+
review?: ReviewHook;
|
|
31
|
+
/** The label of the button that runs the `review` hook (default "Run review"). */
|
|
32
|
+
reviewLabel?: string;
|
|
33
|
+
/** Run the `review` hook after every re-check, not only on the button (default false: a hosted review can be slow or metered). */
|
|
34
|
+
autoReview?: boolean;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export interface ReviewPanel {
|
|
28
38
|
element: HTMLElement;
|
|
29
|
-
/** The latest findings before the panel's filters and hidden checks. */
|
|
39
|
+
/** The latest findings before the panel's filters and hidden checks: core's and the hosted reviewer's. */
|
|
30
40
|
readonly findings: ReviewFinding[];
|
|
41
|
+
/** The merged report (`valid`, `findings`, `counts`, `sources`). */
|
|
31
42
|
readonly report: ReviewReport | undefined;
|
|
32
|
-
/**
|
|
43
|
+
/** Core's `validate` report alone, or undefined when it could not run. */
|
|
44
|
+
readonly validation: import("@openpresentation/opf").ValidationReport | undefined;
|
|
45
|
+
/** Run core's full check again now and redraw. */
|
|
33
46
|
refresh(): void;
|
|
34
|
-
/**
|
|
47
|
+
/** Check after `delay` milliseconds without another call (a burst of calls checks once). With `autoRefresh: false` the host calls this after each redraw. */
|
|
48
|
+
schedule(): void;
|
|
49
|
+
/** Run the `review` hook now; resolves once its findings are listed (or its error is shown). */
|
|
50
|
+
review(): Promise<void>;
|
|
51
|
+
/** Redraw without checking, after the host's slide changed. */
|
|
35
52
|
update(): void;
|
|
36
53
|
/** Hide (true) or show again (false) the findings of one rule in this panel. */
|
|
37
54
|
setIgnored(ruleId: string, hidden: boolean): void;
|
|
38
55
|
destroy(): void;
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
export {
|
|
58
|
+
export { reviewSeverities } from "./review.js";
|
|
42
59
|
|
|
43
|
-
/** Mount the Review panel:
|
|
60
|
+
/** Mount the Review panel: core's findings by category with go-to and quick fixes, a hosted reviewer's beside them, and updates as the document changes. */
|
|
44
61
|
export declare function createReviewPanel(container: HTMLElement, options: ReviewPanelOptions): ReviewPanel;
|
package/dist/review-panel.js
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
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";
|
|
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{
|
|
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), `
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
findings = reviewFindings(report);
|
|
174
|
+
coreReport = validate(editor.presentation, getValidateOptions?.(editor.presentation) ?? {});
|
|
146
175
|
} catch (error) {
|
|
147
|
-
|
|
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.
|
|
161
|
-
for (const
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
217
|
-
//
|
|
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.
|
|
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(
|
|
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 =
|
|
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." : "
|
|
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 =
|
|
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
|
-
|
|
316
|
-
|
|
396
|
+
clearTimeout(timer);
|
|
397
|
+
timer = undefined;
|
|
398
|
+
validateNow();
|
|
317
399
|
renderList();
|
|
400
|
+
if (options.autoReview && review) void runHook();
|
|
318
401
|
}
|
|
319
402
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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
|
-
|
|
452
|
+
clearTimeout(timer);
|
|
453
|
+
hookRunning?.controller.abort();
|
|
454
|
+
stop();
|
|
354
455
|
root.remove();
|
|
355
456
|
},
|
|
356
457
|
};
|
|
357
458
|
}
|
|
358
459
|
|
|
359
|
-
export {
|
|
460
|
+
export { reviewSeverities };
|