@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
package/dist/outline.js CHANGED
@@ -9,10 +9,11 @@
9
9
  // item one list item or bullet; `listPath` and `itemIndex` locate it and `level` is 1 plus its nesting level
10
10
  // other content that is not text, shown by its kind (`label`), never edited here
11
11
  // Text that carries formatting (a run array) is shown flattened and is not editable in the outline, so nothing is flattened away.
12
- import { getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
12
+ import { getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
13
13
  import { checkedDocument, fail } from "./edit-helpers.js";
14
14
  import { visitContentPayloads } from "./presentation-ids.js";
15
15
  import { prepareAddSlide, prepareMoveSlidesBy } from "./slides.js";
16
+ import { checkFormat } from "./checks.js";
16
17
 
17
18
  const clone = (value) => structuredClone(value);
18
19
  const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -46,14 +47,15 @@ function walkPayload(node, path, slideIndex, rows) {
46
47
  * Read the deck as outline rows (see the file header). `rows` follow document order: for each slide its title, subtitle, the
47
48
  * slide's own text and list, then every block and region in order.
48
49
  */
49
- export function readOutline(document) {
50
- if (!document || !Array.isArray(document.slides)) throw fail("invalid-input", "The outline needs an OPF document with a slides array.");
50
+ export function readOutline(presentation) {
51
+ if (!presentation || !Array.isArray(presentation.slides)) throw fail("invalid-input", "The outline needs an OPF document with a slides array.");
51
52
  const rows = [];
52
- document.slides.forEach((slide, slideIndex) => {
53
+ presentation.slides.forEach((slide, slideIndex) => {
53
54
  const base = `slides.${slideIndex}`;
54
- const title = typeof slide?.title === "string" ? slide.title : "";
55
+ // FA-10: a title or subtitle may be TextRun[]: the row shows its plain text and is edited as runs elsewhere (a text row writes a string).
56
+ const title = flatten(slide?.title);
55
57
  rows.push({ key: `slide:${base}`, kind: "slide", slideIndex, level: 0, text: title, path: `${base}.title`, editable: slide?.title === undefined || typeof slide.title === "string", hidden: slide?.hidden === true, section: typeof slide?.section === "string" ? slide.section : undefined, id: slide?.id });
56
- if (typeof slide?.subtitle === "string") rows.push({ key: `subtitle:${base}.subtitle`, kind: "subtitle", slideIndex, level: 1, text: slide.subtitle, path: `${base}.subtitle`, editable: true });
58
+ if (typeof slide?.subtitle === "string" || Array.isArray(slide?.subtitle)) rows.push({ key: `subtitle:${base}.subtitle`, kind: "subtitle", slideIndex, level: 1, text: flatten(slide.subtitle), path: `${base}.subtitle`, editable: typeof slide.subtitle === "string" });
57
59
  if (!isObject(slide)) return;
58
60
  walkPayload(slide, base, slideIndex, rows);
59
61
  visitContentPayloads(slide, base, (payload, path) => walkPayload(payload, path, slideIndex, rows));
@@ -64,23 +66,23 @@ export function readOutline(document) {
64
66
  // --- text ------------------------------------------------------------------------------------------
65
67
 
66
68
  /** Compute editing the text of a row (a title, subtitle, text paragraph or list item); a row that is not plain text throws. */
67
- export function prepareSetOutlineText(document, row, text) {
69
+ export function prepareSetOutlineText(presentation, row, text) {
68
70
  if (!row?.editable) throw fail("outline-row-not-editable", "This text carries formatting or is not text. Edit it on the slide.", { path: row?.path });
69
71
  const value = String(text);
70
- const current = getValueAtPath(document, splitOpfPath(row.path));
71
- if (current === value || (current === undefined && value === "")) return { document: clone(document), patches: [], changed: false, selection: [row.slideIndex] };
72
+ const current = getValueAtPath(presentation, splitOpfPath(row.path));
73
+ if (current === value || (current === undefined && value === "")) return { presentation: clone(presentation), patches: [], changed: false, selection: [row.slideIndex] };
72
74
  const pointer = opfPathToJsonPointer(row.path);
73
75
  const patches = [{ op: current === undefined ? "add" : "replace", path: pointer, value }];
74
- return finish(document, patches, { action: "outline-text", selection: [row.slideIndex] });
76
+ return finish(presentation, patches, { action: "outline-text", selection: [row.slideIndex] });
75
77
  }
76
78
  export function setOutlineText(editor, row, text, meta) {
77
- return apply(editor, prepareSetOutlineText(editor.document, row, text), meta, "outline-text");
79
+ return apply(editor, prepareSetOutlineText(editor.presentation, row, text), meta, "outline-text");
78
80
  }
79
81
 
80
82
  // --- list levels -----------------------------------------------------------------------------------
81
83
 
82
- function listAt(document, row) {
83
- const list = getValueAtPath(document, splitOpfPath(row.listPath));
84
+ function listAt(presentation, row) {
85
+ const list = getValueAtPath(presentation, splitOpfPath(row.listPath));
84
86
  if (!Array.isArray(list) || list[row.itemIndex] === undefined) throw fail("outline-row-not-found", "That list item is no longer there.", { path: row.listPath });
85
87
  return list;
86
88
  }
@@ -104,17 +106,17 @@ function blockEnd(levels, index) {
104
106
  * than the item above, and the first item cannot be nested. Un-nesting an item that is already at the top level is
105
107
  * `prepareOutlinePromote`'s job (it becomes a slide); here it reports `changed: false` with a `reason`.
106
108
  */
107
- export function prepareShiftOutlineItem(document, row, delta) {
108
- const list = listAt(document, row);
109
+ export function prepareShiftOutlineItem(presentation, row, delta) {
110
+ const list = listAt(presentation, row);
109
111
  const levels = list.map((item) => itemParts(item).level);
110
112
  const index = row.itemIndex;
111
- const none = (reason) => ({ document: clone(document), patches: [], changed: false, reason, selection: [row.slideIndex] });
113
+ const none = (reason) => ({ presentation: clone(presentation), patches: [], changed: false, reason, selection: [row.slideIndex] });
112
114
  if (delta > 0 && (index === 0 || levels[index] > levels[index - 1])) return none(index === 0 ? "The first item cannot be nested." : "This item is already one level in from the item above.");
113
115
  if (delta < 0 && levels[index] === 0) return none("This item is already at the top level.");
114
116
  const end = blockEnd(levels, index);
115
117
  const next = list.map((item, at) => (at >= index && at < end ? withLevel(item, Math.max(0, levels[at] + delta)) : item));
116
118
  const patches = [{ op: "replace", path: opfPathToJsonPointer(row.listPath), value: next }];
117
- return finish(document, patches, { action: delta > 0 ? "outline-demote" : "outline-promote", selection: [row.slideIndex], focus: `item:${row.listPath}.${index}` });
119
+ return finish(presentation, patches, { action: delta > 0 ? "outline-demote" : "outline-promote", selection: [row.slideIndex], focus: `item:${row.listPath}.${index}` });
118
120
  }
119
121
 
120
122
  // --- item order ------------------------------------------------------------------------------------
@@ -123,11 +125,11 @@ export function prepareShiftOutlineItem(document, row, delta) {
123
125
  * Compute moving a list item (with the items nested under it) one place up (`delta` -1) or down (1) among the items at its level
124
126
  * and under the same parent. At the first or last place nothing changes (`changed` is false).
125
127
  */
126
- export function prepareMoveOutlineItem(document, row, delta) {
127
- const list = listAt(document, row);
128
+ export function prepareMoveOutlineItem(presentation, row, delta) {
129
+ const list = listAt(presentation, row);
128
130
  const levels = list.map((item) => itemParts(item).level);
129
131
  const index = row.itemIndex, end = blockEnd(levels, index);
130
- const none = { document: clone(document), patches: [], changed: false, selection: [row.slideIndex] };
132
+ const none = { presentation: clone(presentation), patches: [], changed: false, selection: [row.slideIndex] };
131
133
  let order, focusIndex;
132
134
  if (delta < 0) {
133
135
  // The previous sibling starts at the nearest earlier item on the same level, provided no shallower item lies between.
@@ -143,32 +145,32 @@ export function prepareMoveOutlineItem(document, row, delta) {
143
145
  focusIndex = index + (siblingEnd - end);
144
146
  }
145
147
  const patches = [{ op: "replace", path: opfPathToJsonPointer(row.listPath), value: order }];
146
- return finish(document, patches, { action: "outline-move", selection: [row.slideIndex], focus: `item:${row.listPath}.${focusIndex}` });
148
+ return finish(presentation, patches, { action: "outline-move", selection: [row.slideIndex], focus: `item:${row.listPath}.${focusIndex}` });
147
149
  }
148
150
 
149
151
  // --- insert and delete -----------------------------------------------------------------------------
150
152
 
151
153
  /** Compute adding an empty list item right after the item at `row` and everything nested under it, at the same level. */
152
- export function prepareInsertOutlineItem(document, row, text = "") {
153
- const list = listAt(document, row);
154
+ export function prepareInsertOutlineItem(presentation, row, text = "") {
155
+ const list = listAt(presentation, row);
154
156
  const levels = list.map((item) => itemParts(item).level);
155
157
  const end = blockEnd(levels, row.itemIndex);
156
158
  const item = levels[row.itemIndex] > 0 ? { text, level: levels[row.itemIndex] } : text;
157
- return finish(document, [{ op: "add", path: `${opfPathToJsonPointer(row.listPath)}/${end}`, value: item }], { action: "outline-insert", selection: [row.slideIndex], focus: `item:${row.listPath}.${end}` });
159
+ return finish(presentation, [{ op: "add", path: `${opfPathToJsonPointer(row.listPath)}/${end}`, value: item }], { action: "outline-insert", selection: [row.slideIndex], focus: `item:${row.listPath}.${end}` });
158
160
  }
159
161
  /** Compute adding the first bullet under a slide: it goes in the slide's own list, or creates one. */
160
- export function prepareAddOutlineBullet(document, slideIndex, text = "") {
161
- const slide = document.slides?.[slideIndex];
162
+ export function prepareAddOutlineBullet(presentation, slideIndex, text = "") {
163
+ const slide = presentation.slides?.[slideIndex];
162
164
  if (!isObject(slide)) throw fail("slide-index-out-of-range", `Slide ${slideIndex} does not exist.`, { slideIndex });
163
165
  const key = LIST_KEYS.find((candidate) => Array.isArray(slide[candidate]));
164
166
  const base = `/slides/${slideIndex}`;
165
- if (key) return finish(document, [{ op: "add", path: `${base}/${key}/${slide[key].length}`, value: text }], { action: "outline-insert", selection: [slideIndex], focus: `item:slides.${slideIndex}.${key}.${slide[key].length}` });
167
+ if (key) return finish(presentation, [{ op: "add", path: `${base}/${key}/${slide[key].length}`, value: text }], { action: "outline-insert", selection: [slideIndex], focus: `item:slides.${slideIndex}.${key}.${slide[key].length}` });
166
168
  if (Array.isArray(slide.blocks) || slide.type !== undefined) throw fail("outline-row-not-supported", "Add a list to this slide from the Content panel; its content is arranged in blocks.", { slideIndex });
167
- return finish(document, [{ op: "add", path: `${base}/items`, value: [text] }], { action: "outline-insert", selection: [slideIndex], focus: `item:slides.${slideIndex}.items.0` });
169
+ return finish(presentation, [{ op: "add", path: `${base}/items`, value: [text] }], { action: "outline-insert", selection: [slideIndex], focus: `item:slides.${slideIndex}.items.0` });
168
170
  }
169
171
  /** Compute deleting a list item together with the items nested under it. */
170
- export function prepareRemoveOutlineItem(document, row) {
171
- const list = listAt(document, row);
172
+ export function prepareRemoveOutlineItem(presentation, row) {
173
+ const list = listAt(presentation, row);
172
174
  const levels = list.map((item) => itemParts(item).level);
173
175
  const end = blockEnd(levels, row.itemIndex);
174
176
  const patches = [];
@@ -178,7 +180,7 @@ export function prepareRemoveOutlineItem(document, row) {
178
180
  const removeList = survivors === 0 && row.listPath.split(".").length === 3;
179
181
  const finalPatches = removeList ? [{ op: "remove", path: opfPathToJsonPointer(row.listPath) }] : patches;
180
182
  const focus = survivors ? `item:${row.listPath}.${Math.max(0, row.itemIndex - 1)}` : `slide:slides.${row.slideIndex}`;
181
- return finish(document, finalPatches, { action: "outline-remove", selection: [row.slideIndex], focus });
183
+ return finish(presentation, finalPatches, { action: "outline-remove", selection: [row.slideIndex], focus });
182
184
  }
183
185
 
184
186
  // --- promote and demote across slides --------------------------------------------------------------
@@ -187,14 +189,14 @@ export function prepareRemoveOutlineItem(document, row) {
187
189
  * Compute promoting a top-level list item to a slide: the item becomes the title of a new slide right after its own, and the items that
188
190
  * follow it in the same list move to that slide (PowerPoint's outline does the same). Items above it stay.
189
191
  */
190
- export function prepareOutlinePromote(document, row) {
191
- const list = listAt(document, row);
192
+ export function prepareOutlinePromote(presentation, row) {
193
+ const list = listAt(presentation, row);
192
194
  const title = itemParts(list[row.itemIndex]);
193
195
  if (title.level !== 0) throw fail("outline-row-not-supported", "Only a top-level item becomes a slide.", { path: row.path });
194
196
  if (!title.plain) throw fail("outline-row-not-editable", "This item carries formatting. Edit it on the slide.", { path: row.path });
195
197
  const following = list.slice(row.itemIndex + 1);
196
- const added = prepareAddSlide(document, { at: row.slideIndex + 1, title: title.text });
197
- const slide = clone(added.document.slides[row.slideIndex + 1]);
198
+ const added = prepareAddSlide(presentation, { at: row.slideIndex + 1, title: title.text });
199
+ const slide = clone(added.presentation.slides[row.slideIndex + 1]);
198
200
  const key = row.listPath.endsWith(".bullets") ? "bullets" : "items";
199
201
  if (following.length) slide[key] = clone(following);
200
202
  const pointer = opfPathToJsonPointer(row.listPath);
@@ -204,7 +206,7 @@ export function prepareOutlinePromote(document, row) {
204
206
  ? [{ op: "remove", path: pointer }]
205
207
  : Array.from({ length: list.length - row.itemIndex }, (_, offset) => ({ op: "remove", path: `${pointer}/${list.length - 1 - offset}` }));
206
208
  const patches = [...removal, { op: "add", path: `/slides/${row.slideIndex + 1}`, value: slide }];
207
- return finish(document, patches, { action: "outline-promote-slide", selection: [row.slideIndex + 1], focus: `slide:slides.${row.slideIndex + 1}`, newSlideIndex: row.slideIndex + 1 });
209
+ return finish(presentation, patches, { action: "outline-promote-slide", selection: [row.slideIndex + 1], focus: `slide:slides.${row.slideIndex + 1}`, newSlideIndex: row.slideIndex + 1 });
208
210
  }
209
211
 
210
212
  const MERGEABLE = new Set(["id", "title", "text", "items", "bullets", "section", "hidden"]);
@@ -213,8 +215,8 @@ const MERGEABLE = new Set(["id", "title", "text", "items", "bullets", "section",
213
215
  * beneath it. Only a plain slide qualifies: one with a title and at most text or a list (no blocks, regions, notes, design or layout),
214
216
  * after a slide that has a list of its own or no body. Anything else throws `outline-row-not-supported` so nothing is dropped.
215
217
  */
216
- export function prepareOutlineDemoteSlide(document, slideIndex) {
217
- const slides = document.slides;
218
+ export function prepareOutlineDemoteSlide(presentation, slideIndex) {
219
+ const slides = presentation.slides;
218
220
  const slide = slides?.[slideIndex];
219
221
  if (!isObject(slide)) throw fail("slide-index-out-of-range", `Slide ${slideIndex} does not exist.`, { slideIndex });
220
222
  if (slideIndex === 0) throw fail("outline-row-not-supported", "The first slide has no slide before it to join.", { slideIndex });
@@ -231,36 +233,36 @@ export function prepareOutlineDemoteSlide(document, slideIndex) {
231
233
  const added = [slide.title, ...(typeof slide.text === "string" && slide.text ? [{ text: slide.text, level: 1 }] : []), ...nested];
232
234
  const base = `/slides/${slideIndex - 1}`;
233
235
  const patches = [{ op: "remove", path: `/slides/${slideIndex}` }, listKey ? { op: "replace", path: `${base}/${key}`, value: [...previous[key], ...added] } : { op: "add", path: `${base}/${key}`, value: added }];
234
- return finish(document, patches, { action: "outline-demote-slide", selection: [slideIndex - 1], focus: `item:slides.${slideIndex - 1}.${key}.${(previous[key]?.length ?? 0)}` });
236
+ return finish(presentation, patches, { action: "outline-demote-slide", selection: [slideIndex - 1], focus: `item:slides.${slideIndex - 1}.${key}.${(previous[key]?.length ?? 0)}` });
235
237
  }
236
238
 
237
239
  // --- slides ----------------------------------------------------------------------------------------
238
240
 
239
241
  /** Compute moving a slide (the whole slide: its text moves with its title) by `delta` places; see `prepareMoveSlidesBy`. */
240
- export function prepareMoveOutlineSlide(document, slideIndex, delta) {
241
- const prepared = prepareMoveSlidesBy(document, [slideIndex], delta);
242
+ export function prepareMoveOutlineSlide(presentation, slideIndex, delta) {
243
+ const prepared = prepareMoveSlidesBy(presentation, [slideIndex], delta);
242
244
  return prepared.changed ? { ...prepared, focus: `slide:slides.${prepared.selection[0]}` } : prepared;
243
245
  }
244
246
  /** Compute adding a slide right after `slideIndex`, with an empty title for the outline to type into. */
245
- export function prepareInsertOutlineSlide(document, slideIndex) {
246
- return { ...prepareAddSlide(document, { at: slideIndex + 1, title: "" }), action: "outline-insert-slide", focus: `slide:slides.${slideIndex + 1}` };
247
+ export function prepareInsertOutlineSlide(presentation, slideIndex) {
248
+ return { ...prepareAddSlide(presentation, { at: slideIndex + 1, title: "" }), action: "outline-insert-slide", focus: `slide:slides.${slideIndex + 1}` };
247
249
  }
248
250
 
249
251
  // --- apply -----------------------------------------------------------------------------------------
250
252
 
251
- function finish(document, patches, extra) {
252
- if (!patches.length) return { document: clone(document), patches: [], changed: false, ...extra };
253
- const before = validateOpfDocument(document);
254
- return { document: checkedDocument(document, patches, before), patches, changed: true, ...extra };
253
+ function finish(presentation, patches, extra) {
254
+ if (!patches.length) return { presentation: clone(presentation), patches: [], changed: false, ...extra };
255
+ const before = checkFormat(presentation);
256
+ return { presentation: checkedDocument(presentation, patches, before), patches, changed: true, ...extra };
255
257
  }
256
258
  function requireEditor(editor) {
257
259
  if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
258
260
  }
259
261
  function apply(editor, prepared, meta, action) {
260
262
  requireEditor(editor);
261
- const { document, patches, ...summary } = prepared;
262
- void document;
263
- if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
263
+ const { presentation, patches, ...summary } = prepared;
264
+ void presentation;
265
+ if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
264
266
  const change = editor.applyPatch(patches, { ...meta, source: meta?.source ?? "outline", action });
265
267
  return { ...change, ...summary };
266
268
  }
@@ -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
- document: unknown;
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
  }
@@ -14,8 +14,9 @@
14
14
  // shows, and the editor keeps working.
15
15
  import { fail } from "./edit-helpers.js";
16
16
 
17
- const VERSION = 1;
18
- const PREFIX = "opf-editor/v1/";
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.document); // JSON of the document last saved elsewhere; null means "unsaved in a way we cannot compare"
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.document) !== baseline;
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 document = editor.document;
183
- const json = jsonOf(document);
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 document.name === "string" ? document.name : undefined, slideCount: Array.isArray(document.slides) ? document.slides.length : 0, dirty, document, ...(history ? { undo: history.undo, redo: history.redo } : {}) };
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.document) && Array.isArray(record.document.slides);
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), document: record.document });
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 = { document: record.document, undo: record.undo ?? [], redo: record.redo ?? [] };
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({ document: record.document }, { source: "restore" });
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.document) }], { source: "restore" });
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.document) : null;
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.document) }], { source: "restore" }); }
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.document);
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.document);
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.document);
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.document) === jsonOf(editor.document)) return { available: true, offered: false };
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);
@@ -47,9 +47,9 @@ export function slideIds(slide) {
47
47
  return ids;
48
48
  }
49
49
 
50
- export function collectReservedPresentationIds(document) {
50
+ export function collectReservedPresentationIds(presentation) {
51
51
  const ids = [];
52
- for (const slide of document.slides ?? []) ids.push(...slideIds(slide));
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.document, path, "");
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.document, path, "");
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.document });
65
+ const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.presentation });
66
66
 
67
67
  return React.createElement(
68
68
  "select",
@@ -1,44 +1,61 @@
1
+ import type { ValidateOptions } from "@openpresentation/opf";
1
2
  import type { EditorSession } from "./index.js";
2
- import type { ReviewAuditOptions, ReviewFinding, ReviewFix, ReviewReport, ReviewTarget } from "./review.js";
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 `AuditOptions` for this document, for example `{ textMeasurement }` from the host's font registry. */
9
- getAuditOptions?: (document: unknown) => ReviewAuditOptions;
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) and of all findings. */
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-audit after every session change (default true). Set false to call `refresh()` yourself, for example after the host's fonts have loaded. */
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
- /** Use another audit function instead of core's `auditPresentation`. */
24
- audit?: ReviewAuditOptions["audit"];
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
- /** Audit the document again and redraw. */
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
- /** Redraw without auditing, after the host's slide changed. */
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 { auditAvailable, reviewSeverities } from "./review.js";
58
+ export { reviewSeverities } from "./review.js";
42
59
 
43
- /** Mount the Review panel: the audit's findings with go-to, safe quick fixes and live updates as the document changes. */
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;