@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.
- package/README.md +82 -58
- 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 +23 -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 +12 -10
- package/dist/chart-data.js +22 -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 +179 -10
- package/dist/data.d.ts +23 -2
- package/dist/data.js +40 -1
- package/dist/dataset-refs.d.ts +17 -0
- package/dist/dataset-refs.js +45 -0
- package/dist/design-controls.js +66 -47
- 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 +35 -20
- package/dist/font-gate.js +49 -58
- package/dist/galleries.js +36 -52
- package/dist/grid-model.d.ts +99 -24
- package/dist/grid-model.js +577 -101
- 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 +136 -109
- package/dist/table-options.d.ts +8 -8
- package/dist/table-options.js +41 -30
- 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 +74 -54
- package/package.json +10 -6
- 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
|
|
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(
|
|
50
|
-
if (!
|
|
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
|
-
|
|
53
|
+
presentation.slides.forEach((slide, slideIndex) => {
|
|
53
54
|
const base = `slides.${slideIndex}`;
|
|
54
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
71
|
-
if (current === value || (current === undefined && value === "")) return {
|
|
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(
|
|
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.
|
|
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(
|
|
83
|
-
const list = getValueAtPath(
|
|
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(
|
|
108
|
-
const list = listAt(
|
|
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) => ({
|
|
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(
|
|
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(
|
|
127
|
-
const list = listAt(
|
|
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 = {
|
|
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(
|
|
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(
|
|
153
|
-
const list = listAt(
|
|
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(
|
|
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(
|
|
161
|
-
const slide =
|
|
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(
|
|
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(
|
|
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(
|
|
171
|
-
const list = listAt(
|
|
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(
|
|
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(
|
|
191
|
-
const list = listAt(
|
|
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(
|
|
197
|
-
const slide = clone(added.
|
|
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(
|
|
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(
|
|
217
|
-
const 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(
|
|
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(
|
|
241
|
-
const prepared = prepareMoveSlidesBy(
|
|
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(
|
|
246
|
-
return { ...prepareAddSlide(
|
|
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(
|
|
252
|
-
if (!patches.length) return {
|
|
253
|
-
const before =
|
|
254
|
-
return {
|
|
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 {
|
|
262
|
-
void
|
|
263
|
-
if (!prepared.changed) return { ...summary,
|
|
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
|
}
|
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;
|