@openpresentation/opf-editor 0.10.6 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
package/dist/outline.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Outline model and edits (RR-21). The outline is the deck as text: one row per slide title, with the slide's text, bullets and
|
|
2
|
+
// subtitle beneath it and a read-only chip for content that is not text (a chart, a table, an image). Every edit is one validated
|
|
3
|
+
// patch and one undo step, like the slide operations in `slides.js`.
|
|
4
|
+
//
|
|
5
|
+
// Rows. `readOutline` returns `{ rows }`. A row is `{ key, kind, slideIndex, level, text, path, editable, ... }`:
|
|
6
|
+
// slide the slide title (`path` is `slides.N.title`; a slide with no title still has a row)
|
|
7
|
+
// subtitle the slide subtitle
|
|
8
|
+
// text a text payload that is a plain string
|
|
9
|
+
// item one list item or bullet; `listPath` and `itemIndex` locate it and `level` is 1 plus its nesting level
|
|
10
|
+
// other content that is not text, shown by its kind (`label`), never edited here
|
|
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";
|
|
13
|
+
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
14
|
+
import { visitContentPayloads } from "./presentation-ids.js";
|
|
15
|
+
import { prepareAddSlide, prepareMoveSlidesBy } from "./slides.js";
|
|
16
|
+
|
|
17
|
+
const clone = (value) => structuredClone(value);
|
|
18
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
const OTHER_LABELS = { image: "Image", video: "Video", chart: "Chart", table: "Table", code: "Code", metric: "Metric", quote: "Quote", timeline: "Timeline" };
|
|
20
|
+
const LIST_KEYS = ["items", "bullets"];
|
|
21
|
+
|
|
22
|
+
const flatten = (value) => (typeof value === "string" ? value : Array.isArray(value) ? value.map((run) => (typeof run === "string" ? run : (run?.text ?? ""))).join("") : "");
|
|
23
|
+
/** The text, level and editability of one list item or bullet. */
|
|
24
|
+
function itemParts(item) {
|
|
25
|
+
if (isObject(item)) return { text: flatten(item.text), level: Number.isInteger(item.level) ? item.level : 0, plain: typeof item.text === "string", object: true };
|
|
26
|
+
return { text: flatten(item), level: 0, plain: typeof item === "string", object: false };
|
|
27
|
+
}
|
|
28
|
+
const itemTextPath = (listPath, index, item) => (isObject(item) ? `${listPath}.${index}.text` : `${listPath}.${index}`);
|
|
29
|
+
|
|
30
|
+
function walkPayload(node, path, slideIndex, rows) {
|
|
31
|
+
for (const key of LIST_KEYS) {
|
|
32
|
+
if (!Array.isArray(node[key])) continue;
|
|
33
|
+
const listPath = `${path}.${key}`;
|
|
34
|
+
node[key].forEach((item, itemIndex) => {
|
|
35
|
+
const parts = itemParts(item);
|
|
36
|
+
rows.push({ key: `item:${listPath}.${itemIndex}`, kind: "item", slideIndex, level: 1 + parts.level, text: parts.text, path: itemTextPath(listPath, itemIndex, item), listPath, itemIndex, editable: parts.plain });
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (node.text !== undefined && (typeof node.text === "string" || Array.isArray(node.text)))
|
|
40
|
+
rows.push({ key: `text:${path}.text`, kind: "text", slideIndex, level: 1, text: flatten(node.text), path: `${path}.text`, editable: typeof node.text === "string" });
|
|
41
|
+
for (const [field, label] of Object.entries(OTHER_LABELS))
|
|
42
|
+
if (node[field] !== undefined) rows.push({ key: `other:${path}.${field}`, kind: "other", slideIndex, level: 1, text: "", path: `${path}.${field}`, label, editable: false });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the deck as outline rows (see the file header). `rows` follow document order: for each slide its title, subtitle, the
|
|
47
|
+
* slide's own text and list, then every block and region in order.
|
|
48
|
+
*/
|
|
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.");
|
|
51
|
+
const rows = [];
|
|
52
|
+
document.slides.forEach((slide, slideIndex) => {
|
|
53
|
+
const base = `slides.${slideIndex}`;
|
|
54
|
+
const title = typeof slide?.title === "string" ? slide.title : "";
|
|
55
|
+
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 });
|
|
57
|
+
if (!isObject(slide)) return;
|
|
58
|
+
walkPayload(slide, base, slideIndex, rows);
|
|
59
|
+
visitContentPayloads(slide, base, (payload, path) => walkPayload(payload, path, slideIndex, rows));
|
|
60
|
+
});
|
|
61
|
+
return { rows };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- text ------------------------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** 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) {
|
|
68
|
+
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
|
+
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 pointer = opfPathToJsonPointer(row.path);
|
|
73
|
+
const patches = [{ op: current === undefined ? "add" : "replace", path: pointer, value }];
|
|
74
|
+
return finish(document, patches, { action: "outline-text", selection: [row.slideIndex] });
|
|
75
|
+
}
|
|
76
|
+
export function setOutlineText(editor, row, text, meta) {
|
|
77
|
+
return apply(editor, prepareSetOutlineText(editor.document, row, text), meta, "outline-text");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- list levels -----------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function listAt(document, row) {
|
|
83
|
+
const list = getValueAtPath(document, splitOpfPath(row.listPath));
|
|
84
|
+
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
|
+
return list;
|
|
86
|
+
}
|
|
87
|
+
/** Write a nesting level on an item, collapsing back to the plain string form at level 0 when nothing else is carried. */
|
|
88
|
+
function withLevel(item, level) {
|
|
89
|
+
if (!isObject(item)) return level > 0 ? { text: item, level } : item;
|
|
90
|
+
const next = { ...item };
|
|
91
|
+
if (level > 0) next.level = level;
|
|
92
|
+
else delete next.level;
|
|
93
|
+
return Object.keys(next).length === 1 && typeof next.text === "string" ? next.text : next;
|
|
94
|
+
}
|
|
95
|
+
/** The index just after the item at `index` and everything nested beneath it. */
|
|
96
|
+
function blockEnd(levels, index) {
|
|
97
|
+
let end = index + 1;
|
|
98
|
+
while (end < levels.length && levels[end] > levels[index]) end += 1;
|
|
99
|
+
return end;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compute nesting a list item in (`delta` 1) or out (`delta` -1). Items nested beneath it move with it. A level is at most one more
|
|
104
|
+
* than the item above, and the first item cannot be nested. Un-nesting an item that is already at the top level is
|
|
105
|
+
* `prepareOutlinePromote`'s job (it becomes a slide); here it reports `changed: false` with a `reason`.
|
|
106
|
+
*/
|
|
107
|
+
export function prepareShiftOutlineItem(document, row, delta) {
|
|
108
|
+
const list = listAt(document, row);
|
|
109
|
+
const levels = list.map((item) => itemParts(item).level);
|
|
110
|
+
const index = row.itemIndex;
|
|
111
|
+
const none = (reason) => ({ document: clone(document), patches: [], changed: false, reason, selection: [row.slideIndex] });
|
|
112
|
+
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
|
+
if (delta < 0 && levels[index] === 0) return none("This item is already at the top level.");
|
|
114
|
+
const end = blockEnd(levels, index);
|
|
115
|
+
const next = list.map((item, at) => (at >= index && at < end ? withLevel(item, Math.max(0, levels[at] + delta)) : item));
|
|
116
|
+
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}` });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- item order ------------------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 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
|
+
* and under the same parent. At the first or last place nothing changes (`changed` is false).
|
|
125
|
+
*/
|
|
126
|
+
export function prepareMoveOutlineItem(document, row, delta) {
|
|
127
|
+
const list = listAt(document, row);
|
|
128
|
+
const levels = list.map((item) => itemParts(item).level);
|
|
129
|
+
const index = row.itemIndex, end = blockEnd(levels, index);
|
|
130
|
+
const none = { document: clone(document), patches: [], changed: false, selection: [row.slideIndex] };
|
|
131
|
+
let order, focusIndex;
|
|
132
|
+
if (delta < 0) {
|
|
133
|
+
// The previous sibling starts at the nearest earlier item on the same level, provided no shallower item lies between.
|
|
134
|
+
let at = index - 1;
|
|
135
|
+
while (at >= 0 && levels[at] > levels[index]) at -= 1;
|
|
136
|
+
if (at < 0 || levels[at] !== levels[index]) return none;
|
|
137
|
+
order = [...list.slice(0, at), ...list.slice(index, end), ...list.slice(at, index), ...list.slice(end)];
|
|
138
|
+
focusIndex = at;
|
|
139
|
+
} else {
|
|
140
|
+
if (end >= list.length || levels[end] !== levels[index]) return none;
|
|
141
|
+
const siblingEnd = blockEnd(levels, end);
|
|
142
|
+
order = [...list.slice(0, index), ...list.slice(end, siblingEnd), ...list.slice(index, end), ...list.slice(siblingEnd)];
|
|
143
|
+
focusIndex = index + (siblingEnd - end);
|
|
144
|
+
}
|
|
145
|
+
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}` });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- insert and delete -----------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** 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
|
+
const levels = list.map((item) => itemParts(item).level);
|
|
155
|
+
const end = blockEnd(levels, row.itemIndex);
|
|
156
|
+
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}` });
|
|
158
|
+
}
|
|
159
|
+
/** 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
|
+
if (!isObject(slide)) throw fail("slide-index-out-of-range", `Slide ${slideIndex} does not exist.`, { slideIndex });
|
|
163
|
+
const key = LIST_KEYS.find((candidate) => Array.isArray(slide[candidate]));
|
|
164
|
+
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}` });
|
|
166
|
+
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` });
|
|
168
|
+
}
|
|
169
|
+
/** 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
|
+
const levels = list.map((item) => itemParts(item).level);
|
|
173
|
+
const end = blockEnd(levels, row.itemIndex);
|
|
174
|
+
const patches = [];
|
|
175
|
+
for (let at = end - 1; at >= row.itemIndex; at -= 1) patches.push({ op: "remove", path: `${opfPathToJsonPointer(row.listPath)}/${at}` });
|
|
176
|
+
const survivors = list.length - (end - row.itemIndex);
|
|
177
|
+
// An empty `items` array is not a useful list: drop it with its last item.
|
|
178
|
+
const removeList = survivors === 0 && row.listPath.split(".").length === 3;
|
|
179
|
+
const finalPatches = removeList ? [{ op: "remove", path: opfPathToJsonPointer(row.listPath) }] : patches;
|
|
180
|
+
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 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- promote and demote across slides --------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 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
|
+
* follow it in the same list move to that slide (PowerPoint's outline does the same). Items above it stay.
|
|
189
|
+
*/
|
|
190
|
+
export function prepareOutlinePromote(document, row) {
|
|
191
|
+
const list = listAt(document, row);
|
|
192
|
+
const title = itemParts(list[row.itemIndex]);
|
|
193
|
+
if (title.level !== 0) throw fail("outline-row-not-supported", "Only a top-level item becomes a slide.", { path: row.path });
|
|
194
|
+
if (!title.plain) throw fail("outline-row-not-editable", "This item carries formatting. Edit it on the slide.", { path: row.path });
|
|
195
|
+
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 key = row.listPath.endsWith(".bullets") ? "bullets" : "items";
|
|
199
|
+
if (following.length) slide[key] = clone(following);
|
|
200
|
+
const pointer = opfPathToJsonPointer(row.listPath);
|
|
201
|
+
const rootList = row.listPath.split(".").length === 3;
|
|
202
|
+
// Take the item and everything after it out of the old list (the whole list goes when nothing is left above the item).
|
|
203
|
+
const removal = row.itemIndex === 0 && rootList
|
|
204
|
+
? [{ op: "remove", path: pointer }]
|
|
205
|
+
: Array.from({ length: list.length - row.itemIndex }, (_, offset) => ({ op: "remove", path: `${pointer}/${list.length - 1 - offset}` }));
|
|
206
|
+
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 });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const MERGEABLE = new Set(["id", "title", "text", "items", "bullets", "section", "hidden"]);
|
|
211
|
+
/**
|
|
212
|
+
* Compute demoting a slide: its title becomes a top-level bullet of the previous slide and its own text and bullets nest one level
|
|
213
|
+
* 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
|
+
* 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
|
+
*/
|
|
216
|
+
export function prepareOutlineDemoteSlide(document, slideIndex) {
|
|
217
|
+
const slides = document.slides;
|
|
218
|
+
const slide = slides?.[slideIndex];
|
|
219
|
+
if (!isObject(slide)) throw fail("slide-index-out-of-range", `Slide ${slideIndex} does not exist.`, { slideIndex });
|
|
220
|
+
if (slideIndex === 0) throw fail("outline-row-not-supported", "The first slide has no slide before it to join.", { slideIndex });
|
|
221
|
+
const extra = Object.keys(slide).filter((key) => !MERGEABLE.has(key));
|
|
222
|
+
if (extra.length) throw fail("outline-row-not-supported", `This slide also has ${extra.join(", ")}, which would be lost. Merge it from the slide menu or move that content first.`, { slideIndex, keys: extra });
|
|
223
|
+
if (typeof slide.title !== "string" || slide.title === "" || (slide.text !== undefined && typeof slide.text !== "string")) throw fail("outline-row-not-supported", "Only a slide with a plain title and plain text can join the slide before it.", { slideIndex });
|
|
224
|
+
const previous = slides[slideIndex - 1];
|
|
225
|
+
const body = ["text", "items", "bullets", "blocks", "image", "video", "chart", "table", "code", "metric", "quote", "timeline"].filter((key) => previous[key] !== undefined);
|
|
226
|
+
const listKey = LIST_KEYS.find((key) => Array.isArray(previous[key]));
|
|
227
|
+
if (body.length && !(listKey && body.length === 1) && !(listKey && body.length === 2 && previous.text !== undefined))
|
|
228
|
+
throw fail("outline-row-not-supported", "The slide before this one has content that is not a list, so this slide cannot become a bullet there.", { slideIndex });
|
|
229
|
+
const key = listKey ?? "items";
|
|
230
|
+
const nested = LIST_KEYS.flatMap((field) => (Array.isArray(slide[field]) ? slide[field] : [])).map((item) => withLevel(item, itemParts(item).level + 1));
|
|
231
|
+
const added = [slide.title, ...(typeof slide.text === "string" && slide.text ? [{ text: slide.text, level: 1 }] : []), ...nested];
|
|
232
|
+
const base = `/slides/${slideIndex - 1}`;
|
|
233
|
+
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)}` });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- slides ----------------------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/** 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
|
+
return prepared.changed ? { ...prepared, focus: `slide:slides.${prepared.selection[0]}` } : prepared;
|
|
243
|
+
}
|
|
244
|
+
/** 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
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- apply -----------------------------------------------------------------------------------------
|
|
250
|
+
|
|
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 };
|
|
255
|
+
}
|
|
256
|
+
function requireEditor(editor) {
|
|
257
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
258
|
+
}
|
|
259
|
+
function apply(editor, prepared, meta, action) {
|
|
260
|
+
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 };
|
|
264
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta?.source ?? "outline", action });
|
|
265
|
+
return { ...change, ...summary };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Apply any prepared outline change as one undoable step. */
|
|
269
|
+
export function applyOutlineChange(editor, prepared, meta) {
|
|
270
|
+
return apply(editor, prepared, meta, prepared.action ?? "outline");
|
|
271
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AutosaveStatus, RestoreChoice, RestoreOffer } from "./persistence.js";
|
|
2
|
+
|
|
3
|
+
export interface PersistenceUiOptions {
|
|
4
|
+
/** The element the restore prompt is drawn in (hidden when there is nothing to offer). */
|
|
5
|
+
banner?: HTMLElement;
|
|
6
|
+
/** An element for the one-line autosave status ("Saved on this device at 2:03 PM", or why autosave is off). */
|
|
7
|
+
indicator?: HTMLElement;
|
|
8
|
+
locale?: string;
|
|
9
|
+
/** Appended to the status sentence in the indicator (for example "Save an OPF file to keep a copy."). */
|
|
10
|
+
suffix?: string;
|
|
11
|
+
/** What the indicator shows until storage reports (and when a status has no sentence); lines are split on a newline. */
|
|
12
|
+
fallback?: string;
|
|
13
|
+
/** Wired to `persistence.restoreEarlier` for the "Restore older copy" button. */
|
|
14
|
+
restoreEarlier?: () => Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
export interface PersistenceUi {
|
|
17
|
+
/** Pass as `onRestorePrompt` of `createPersistence`. */
|
|
18
|
+
prompt(offer: RestoreOffer, actions: { restore(): Promise<boolean>; discard(): Promise<boolean> }): RestoreChoice;
|
|
19
|
+
/** Pass as `onStatus` of `createPersistence`. */
|
|
20
|
+
status(status: AutosaveStatus): void;
|
|
21
|
+
hide(): void;
|
|
22
|
+
destroy(): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function createPersistenceUi(options: PersistenceUiOptions): PersistenceUi;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The restore prompt and the autosave indicator for `createPersistence` (RR-22), as plain DOM a host can mount or ignore. The prompt is a
|
|
2
|
+
// non-modal region with two buttons (nothing steals focus or blocks editing); the indicator is a short line of text, and the problems
|
|
3
|
+
// (storage unavailable, full) are also announced in a polite live region. Importing needs no DOM; mounting does.
|
|
4
|
+
import { describeAutosave } from "./persistence.js";
|
|
5
|
+
|
|
6
|
+
function el(doc, tag, props = {}, ...children) {
|
|
7
|
+
const node = doc.createElement(tag);
|
|
8
|
+
for (const [key, value] of Object.entries(props)) {
|
|
9
|
+
if (value === undefined || value === null || value === false) continue;
|
|
10
|
+
if (key === "class") node.className = value;
|
|
11
|
+
else if (key === "text") node.textContent = value;
|
|
12
|
+
else node.setAttribute(key, value === true ? "" : String(value));
|
|
13
|
+
}
|
|
14
|
+
node.append(...children.filter((child) => child !== null && child !== undefined && child !== false));
|
|
15
|
+
return node;
|
|
16
|
+
}
|
|
17
|
+
const when = (savedAt, locale) => new Date(savedAt).toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
|
|
18
|
+
const slides = (count) => (Number.isInteger(count) ? `${count} slide${count === 1 ? "" : "s"}` : "");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the prompt and the indicator. Options: `banner` (the element the prompt is drawn in; it is hidden when there is nothing to
|
|
22
|
+
* offer), `indicator` (an element for the one-line autosave status) and `locale`. Pass `ui.prompt` as `onRestorePrompt` and `ui.status` as
|
|
23
|
+
* `onStatus` of `createPersistence`.
|
|
24
|
+
*/
|
|
25
|
+
export function createPersistenceUi(options) {
|
|
26
|
+
const { banner, indicator, locale } = options;
|
|
27
|
+
const doc = (banner ?? indicator).ownerDocument;
|
|
28
|
+
const live = el(doc, "div", { class: "sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true" });
|
|
29
|
+
(banner ?? indicator).after(live);
|
|
30
|
+
let announced = "";
|
|
31
|
+
const say = (message) => {
|
|
32
|
+
if (!message || message === announced) return;
|
|
33
|
+
announced = message;
|
|
34
|
+
live.textContent = "";
|
|
35
|
+
globalThis.setTimeout(() => { live.textContent = message; }, 20);
|
|
36
|
+
};
|
|
37
|
+
if (banner) { banner.hidden = true; }
|
|
38
|
+
|
|
39
|
+
function hide() {
|
|
40
|
+
if (banner) { banner.hidden = true; banner.replaceChildren(); }
|
|
41
|
+
}
|
|
42
|
+
const ui = {
|
|
43
|
+
/** `onRestorePrompt`: draw the offer. The buttons call `actions.restore` and `actions.discard`; the promise stays "later" (the buttons decide). */
|
|
44
|
+
prompt(offer, actions) {
|
|
45
|
+
if (!banner) return "later";
|
|
46
|
+
const intro = offer.dirty
|
|
47
|
+
? `You have unsaved work from ${when(offer.savedAt, locale)}${offer.slideCount ? ` (${slides(offer.slideCount)})` : ""}, kept in this browser. Restore it?`
|
|
48
|
+
: `Your last session from ${when(offer.savedAt, locale)}${offer.slideCount ? ` (${slides(offer.slideCount)})` : ""} is kept in this browser. Open it again?`;
|
|
49
|
+
const note = offer.earlier ? el(doc, "p", { class: "restore-note", text: `An older copy from ${when(offer.earlier.savedAt, locale)} is also kept.` }) : null;
|
|
50
|
+
const restore = el(doc, "button", { type: "button", class: "primary restore-yes", text: "Restore" });
|
|
51
|
+
const discard = el(doc, "button", { type: "button", class: "secondary restore-no", text: "Discard copy", title: "Delete the stored copy" });
|
|
52
|
+
const earlier = offer.earlier ? el(doc, "button", { type: "button", class: "secondary restore-earlier", text: "Restore older copy" }) : null;
|
|
53
|
+
restore.addEventListener("click", async () => { restore.disabled = discard.disabled = true; const done = await actions.restore(); if (done) { hide(); say("Restored. Undo goes back to what you had."); } else { restore.disabled = discard.disabled = false; } });
|
|
54
|
+
discard.addEventListener("click", async () => { await actions.discard(); hide(); say("Discarded the stored copy."); });
|
|
55
|
+
earlier?.addEventListener("click", async () => { const done = await options.restoreEarlier?.(); if (done) { hide(); say("Restored the older copy. Undo goes back to what you had."); } });
|
|
56
|
+
banner.replaceChildren(el(doc, "p", { class: "restore-text", id: `${banner.id || "restore"}-text`, text: intro }), note, el(doc, "div", { class: "restore-actions" }, restore, discard, earlier));
|
|
57
|
+
banner.setAttribute("role", "region");
|
|
58
|
+
banner.setAttribute("aria-label", "Restore your work");
|
|
59
|
+
banner.hidden = false;
|
|
60
|
+
say(intro);
|
|
61
|
+
return "later";
|
|
62
|
+
},
|
|
63
|
+
/** `onStatus`: update the indicator and announce problems. */
|
|
64
|
+
status(status) {
|
|
65
|
+
const text = describeAutosave(status, { locale });
|
|
66
|
+
if (indicator) {
|
|
67
|
+
// The indicator keeps its own fallback line until storage reports; a problem keeps the suffix too, so it still says how to keep the work.
|
|
68
|
+
if (text) indicator.textContent = options.suffix ? `${text}${/[.!?]$/.test(text) ? "" : "."} ${options.suffix}` : text;
|
|
69
|
+
else if (options.fallback !== undefined) indicator.replaceChildren(...String(options.fallback).split("\n").flatMap((line, at) => (at ? [doc.createElement("br"), line] : [line])));
|
|
70
|
+
else indicator.textContent = "";
|
|
71
|
+
indicator.dataset.state = status.state;
|
|
72
|
+
if (!options.fallback) indicator.hidden = !text;
|
|
73
|
+
}
|
|
74
|
+
if (status.state === "unavailable" || status.state === "error") say(status.message);
|
|
75
|
+
},
|
|
76
|
+
/** Hide the prompt (for example after the host restored through its own control). */
|
|
77
|
+
hide,
|
|
78
|
+
destroy() { hide(); live.remove(); if (indicator) indicator.textContent = ""; },
|
|
79
|
+
};
|
|
80
|
+
return ui;
|
|
81
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
|
|
3
|
+
/** What a storage adapter holds: plain JSON-able records under string keys. */
|
|
4
|
+
export interface PersistenceStorage {
|
|
5
|
+
name?: string;
|
|
6
|
+
get(key: string): Promise<unknown>;
|
|
7
|
+
set(key: string, value: unknown): Promise<void>;
|
|
8
|
+
delete(key: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createMemoryStorage(): PersistenceStorage & { keys(): string[] };
|
|
11
|
+
export declare function createLocalStorageStorage(options?: { storage?: Storage }): PersistenceStorage;
|
|
12
|
+
export declare function createIndexedDbStorage(options?: { indexedDB?: IDBFactory; databaseName?: string; storeName?: string }): PersistenceStorage & { probe(): Promise<void> };
|
|
13
|
+
|
|
14
|
+
/** A stored copy on offer. */
|
|
15
|
+
export interface RestoreOffer {
|
|
16
|
+
key: string;
|
|
17
|
+
/** When it was stored (ms since the epoch). */
|
|
18
|
+
savedAt: number;
|
|
19
|
+
name?: string;
|
|
20
|
+
slideCount?: number;
|
|
21
|
+
/** True when it held changes that had not been saved elsewhere (`markSaved`); false for a copy saved as a file. */
|
|
22
|
+
dirty: boolean;
|
|
23
|
+
/** Whether the undo history is stored with it. */
|
|
24
|
+
hasHistory: boolean;
|
|
25
|
+
document: unknown;
|
|
26
|
+
/** An older copy that was moved aside when the user kept editing while an earlier offer was open. */
|
|
27
|
+
earlier?: { savedAt: number; name?: string; slideCount?: number };
|
|
28
|
+
}
|
|
29
|
+
export type RestoreChoice = "restore" | "discard" | "later";
|
|
30
|
+
|
|
31
|
+
export type AutosaveState = "starting" | "idle" | "saving" | "saved" | "unavailable" | "error";
|
|
32
|
+
export interface AutosaveStatus {
|
|
33
|
+
state: AutosaveState;
|
|
34
|
+
/** False when no storage works here (private browsing, blocked site data); the editor still works. */
|
|
35
|
+
available: boolean;
|
|
36
|
+
/** Whether the document has changes that were not saved with `markSaved`. */
|
|
37
|
+
dirty: boolean;
|
|
38
|
+
savedAt?: number;
|
|
39
|
+
/** A sentence to show the user for `unavailable` and `error` (and a note on `saved`, such as "history too large to keep"). */
|
|
40
|
+
message?: string;
|
|
41
|
+
error?: "quota" | "write";
|
|
42
|
+
/** The adapter in use: "indexedDB", "localStorage", "memory" or the host adapter's name. */
|
|
43
|
+
storage?: string;
|
|
44
|
+
offer?: RestoreOffer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PersistenceOptions {
|
|
48
|
+
/** A stable string that names this document; two documents with the same key share one stored copy. */
|
|
49
|
+
key: string;
|
|
50
|
+
/** `"indexeddb"` (default, with localStorage as the fallback), `"localstorage"`, `"memory"`, `false` (off) or your own adapter. */
|
|
51
|
+
storage?: "indexeddb" | "localstorage" | "memory" | false | PersistenceStorage;
|
|
52
|
+
/** Milliseconds after the last change before a copy is written (default 800), and the longest a copy may lag behind (default 5000). */
|
|
53
|
+
debounceMs?: number;
|
|
54
|
+
maxWaitMs?: number;
|
|
55
|
+
/** Store the undo and redo history with the document (default true), limited to the newest entries and bytes (defaults 200 and 2,000,000). */
|
|
56
|
+
includeHistory?: boolean;
|
|
57
|
+
maxHistoryEntries?: number;
|
|
58
|
+
maxHistoryBytes?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Called when a stored copy differs from the document the session started with. Return `"restore"`, `"discard"` or `"later"` (or a promise).
|
|
61
|
+
* `actions` restores or discards on demand, for a prompt with buttons. Without a handler the offer waits in `persistence.pending`.
|
|
62
|
+
*/
|
|
63
|
+
onRestorePrompt?: (offer: RestoreOffer, actions: { restore(): Promise<boolean>; discard(): Promise<boolean> }) => RestoreChoice | Promise<RestoreChoice>;
|
|
64
|
+
/** Called on every status change, for an indicator. */
|
|
65
|
+
onStatus?: (status: AutosaveStatus) => void;
|
|
66
|
+
/** Called before every write and before the page unloads, so the host can commit a draft (text being edited on the canvas). */
|
|
67
|
+
beforeFlush?: () => void;
|
|
68
|
+
/** Warn before the page closes while the document is `dirty` (default true). */
|
|
69
|
+
warnOnUnload?: boolean;
|
|
70
|
+
/** For tests and frames: the window that receives `beforeunload`, `pagehide` and `visibilitychange` (default the global window; null for none). */
|
|
71
|
+
window?: Window | null;
|
|
72
|
+
now?: () => number;
|
|
73
|
+
}
|
|
74
|
+
export interface Persistence {
|
|
75
|
+
/** Resolves when storage has been opened and read: `{ available, offered }`. It does not wait for the user's decision on an offer. */
|
|
76
|
+
readonly ready: Promise<{ available: boolean; offered: boolean; offer?: RestoreOffer; reason?: string }>;
|
|
77
|
+
readonly status: AutosaveStatus;
|
|
78
|
+
/** True when the document differs from the last `markSaved()` (or from how the session started). */
|
|
79
|
+
readonly dirty: boolean;
|
|
80
|
+
/** The stored copy on offer, or undefined. */
|
|
81
|
+
readonly pending: RestoreOffer | undefined;
|
|
82
|
+
/** An older copy moved aside while an offer was open. */
|
|
83
|
+
readonly pendingEarlier: { savedAt: number; name?: string; slideCount?: number } | undefined;
|
|
84
|
+
/** Put the offered copy back: one undoable change (and the undo history too, when the session has not been edited). Resolves false when nothing was on offer. */
|
|
85
|
+
restore(): Promise<boolean>;
|
|
86
|
+
/** Decline the offered copy and delete it. */
|
|
87
|
+
discard(): Promise<boolean>;
|
|
88
|
+
restoreEarlier(): Promise<boolean>;
|
|
89
|
+
/** The host saved the document elsewhere (a download, a server): it is no longer `dirty`, and the stored copy records that. */
|
|
90
|
+
markSaved(): Promise<boolean>;
|
|
91
|
+
/** The host loaded a document into the editor: treat it as the starting point (not dirty, nothing written) until the next change. */
|
|
92
|
+
rebase(): void;
|
|
93
|
+
/** Write the document now; resolves when stored (false when it could not be). */
|
|
94
|
+
flush(): Promise<boolean>;
|
|
95
|
+
/** Delete the stored copy and stop treating the document as changed. */
|
|
96
|
+
clear(): Promise<void>;
|
|
97
|
+
destroy(): void;
|
|
98
|
+
}
|
|
99
|
+
export declare const DEFAULT_DEBOUNCE_MS: 800;
|
|
100
|
+
export declare const DEFAULT_MAX_WAIT_MS: 5000;
|
|
101
|
+
export declare const DEFAULT_MAX_HISTORY_ENTRIES: 200;
|
|
102
|
+
export declare const DEFAULT_MAX_HISTORY_BYTES: 2000000;
|
|
103
|
+
export declare function createPersistence(editor: EditorSession, options: PersistenceOptions): Persistence;
|
|
104
|
+
/** A sentence for a status: "Saved on this device at 2:03 PM", or why autosave is off. */
|
|
105
|
+
export declare function describeAutosave(status: AutosaveStatus | undefined, options?: { locale?: string }): string;
|