@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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// Autosave and restore (RR-22). `createPersistence(editor, { key, ... })` keeps the working document of an editor session, and its undo
|
|
2
|
+
// history when it fits, in the browser's own storage (IndexedDB, with localStorage as a fallback), debounced, and brings it back after a
|
|
3
|
+
// reload. It never sends anything anywhere: there is no network call in this module, and the data stays in this browser profile.
|
|
4
|
+
//
|
|
5
|
+
// - A copy is written only after the document changes, so merely opening the editor never overwrites a stored copy.
|
|
6
|
+
// - On creation it looks for a stored copy that differs from the document the session starts with and offers it (`onRestorePrompt`, or
|
|
7
|
+
// `persistence.pending` for a host that draws its own prompt). Restoring puts the copy back as ONE undoable step; into a session that
|
|
8
|
+
// has not been edited it also restores the undo history.
|
|
9
|
+
// - If the user keeps editing while an offer is open, the stored copy is moved aside (`restoreEarlier`) before the first new write, so an
|
|
10
|
+
// ignored banner never costs anyone their work.
|
|
11
|
+
// - `dirty` means "changed since the last `markSaved()`" (a download, a host save): `beforeunload` warns while it is true. The host calls
|
|
12
|
+
// `markSaved()` after it saved the document somewhere durable of its own.
|
|
13
|
+
// - Where storage is unavailable (private browsing, blocked site data) or full, nothing throws: `status` says so with a sentence the host
|
|
14
|
+
// shows, and the editor keeps working.
|
|
15
|
+
import { fail } from "./edit-helpers.js";
|
|
16
|
+
|
|
17
|
+
const VERSION = 1;
|
|
18
|
+
const PREFIX = "opf-editor/v1/";
|
|
19
|
+
export const DEFAULT_DEBOUNCE_MS = 800;
|
|
20
|
+
export const DEFAULT_MAX_WAIT_MS = 5000;
|
|
21
|
+
export const DEFAULT_MAX_HISTORY_ENTRIES = 200;
|
|
22
|
+
export const DEFAULT_MAX_HISTORY_BYTES = 2_000_000;
|
|
23
|
+
|
|
24
|
+
const jsonOf = (value) => JSON.stringify(value);
|
|
25
|
+
const isRecordObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
26
|
+
const isQuotaError = (error) => Boolean(error) && (error.name === "QuotaExceededError" || error.code === 22 || error.code === 1014 || /quota|storage.*full/i.test(String(error.message ?? "")));
|
|
27
|
+
|
|
28
|
+
// --- storage adapters ----------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** An in-memory adapter (tests, and hosts that hold the data themselves). */
|
|
31
|
+
export function createMemoryStorage() {
|
|
32
|
+
const data = new Map();
|
|
33
|
+
return {
|
|
34
|
+
name: "memory",
|
|
35
|
+
async get(key) { return data.has(key) ? structuredClone(data.get(key)) : undefined; },
|
|
36
|
+
async set(key, value) { data.set(key, structuredClone(value)); },
|
|
37
|
+
async delete(key) { data.delete(key); },
|
|
38
|
+
keys: () => [...data.keys()],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** localStorage as JSON strings (about 5 MB shared by the whole origin: the history is dropped first when it does not fit). */
|
|
42
|
+
export function createLocalStorageStorage(options = {}) {
|
|
43
|
+
const storage = options.storage ?? globalThis.localStorage;
|
|
44
|
+
if (!storage) throw new Error("localStorage is not available.");
|
|
45
|
+
// Touching it can throw (blocked site data); probe now so the caller falls back or reports.
|
|
46
|
+
const probe = `${PREFIX}probe`;
|
|
47
|
+
storage.setItem(probe, "1");
|
|
48
|
+
storage.removeItem(probe);
|
|
49
|
+
return {
|
|
50
|
+
name: "localStorage",
|
|
51
|
+
async get(key) { const text = storage.getItem(key); return text === null ? undefined : JSON.parse(text); },
|
|
52
|
+
async set(key, value) { storage.setItem(key, jsonOf(value)); },
|
|
53
|
+
async delete(key) { storage.removeItem(key); },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** IndexedDB (database `opf-editor`, object store `documents`), the default: it holds large documents and their history. */
|
|
57
|
+
export function createIndexedDbStorage(options = {}) {
|
|
58
|
+
const factory = options.indexedDB ?? globalThis.indexedDB;
|
|
59
|
+
if (!factory) throw new Error("IndexedDB is not available.");
|
|
60
|
+
const databaseName = options.databaseName ?? "opf-editor";
|
|
61
|
+
const storeName = options.storeName ?? "documents";
|
|
62
|
+
let opened;
|
|
63
|
+
const open = () => (opened ??= new Promise((resolve, reject) => {
|
|
64
|
+
let request;
|
|
65
|
+
try { request = factory.open(databaseName, 1); } catch (error) { reject(error); return; }
|
|
66
|
+
request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(storeName)) request.result.createObjectStore(storeName); };
|
|
67
|
+
request.onsuccess = () => {
|
|
68
|
+
const database = request.result;
|
|
69
|
+
database.onversionchange = () => { database.close(); opened = undefined; };
|
|
70
|
+
resolve(database);
|
|
71
|
+
};
|
|
72
|
+
request.onerror = () => reject(request.error ?? new Error("Could not open browser storage."));
|
|
73
|
+
request.onblocked = () => reject(new Error("Browser storage is blocked by another tab."));
|
|
74
|
+
}).catch((error) => { opened = undefined; throw error; }));
|
|
75
|
+
const run = async (mode, action) => {
|
|
76
|
+
const database = await open();
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
let result;
|
|
79
|
+
let transaction;
|
|
80
|
+
try {
|
|
81
|
+
transaction = database.transaction(storeName, mode);
|
|
82
|
+
const request = action(transaction.objectStore(storeName));
|
|
83
|
+
request.onsuccess = () => { result = request.result; };
|
|
84
|
+
} catch (error) { reject(error); return; }
|
|
85
|
+
transaction.oncomplete = () => resolve(result);
|
|
86
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Browser storage failed."));
|
|
87
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Browser storage was aborted."));
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
name: "indexedDB",
|
|
92
|
+
/** Open the database now, so a blocked or private-mode browser reports before the first write. */
|
|
93
|
+
probe: async () => { await open(); },
|
|
94
|
+
get: (key) => run("readonly", (store) => store.get(key)),
|
|
95
|
+
set: (key, value) => run("readwrite", (store) => store.put(value, key)),
|
|
96
|
+
delete: (key) => run("readwrite", (store) => store.delete(key)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve the `storage` option to an adapter, or `{ reason }` when none works. */
|
|
101
|
+
async function resolveStorage(option) {
|
|
102
|
+
if (option === false) return { reason: "Autosave is off." };
|
|
103
|
+
if (isRecordObject(option) && typeof option.get === "function") return { adapter: option };
|
|
104
|
+
const wanted = typeof option === "string" ? option.toLowerCase() : "";
|
|
105
|
+
const order = wanted === "memory" ? ["memory"] : wanted === "localstorage" ? ["localStorage"] : ["indexedDB", "localStorage"];
|
|
106
|
+
let reason = "";
|
|
107
|
+
for (const kind of order) {
|
|
108
|
+
try {
|
|
109
|
+
if (kind === "memory") return { adapter: createMemoryStorage() };
|
|
110
|
+
if (kind === "indexedDB") {
|
|
111
|
+
const adapter = createIndexedDbStorage();
|
|
112
|
+
await adapter.probe();
|
|
113
|
+
return { adapter };
|
|
114
|
+
}
|
|
115
|
+
return { adapter: createLocalStorageStorage() };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
reason = reason || String(error?.message ?? error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { reason: `Browser storage is not available here (${reason || "blocked"}).` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- history -----------------------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** Keep the newest entries of each stack within the entry and byte limits; dropping the oldest keeps every stack replayable. */
|
|
126
|
+
function trimHistory(history, { entries, bytes }) {
|
|
127
|
+
let undo = history.undo.slice(-entries);
|
|
128
|
+
let redo = history.redo.slice(-entries);
|
|
129
|
+
let size = jsonOf({ undo, redo }).length;
|
|
130
|
+
while (size > bytes && (undo.length || redo.length)) {
|
|
131
|
+
if (undo.length >= redo.length) undo = undo.slice(1); else redo = redo.slice(1);
|
|
132
|
+
size = jsonOf({ undo, redo }).length;
|
|
133
|
+
}
|
|
134
|
+
return { undo, redo, dropped: undo.length < history.undo.length || redo.length < history.redo.length };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- the controller ----------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Keep an editor session in browser storage and offer to restore it. See the file header and `persistence.d.ts` for the options.
|
|
141
|
+
* Returns a controller: `ready`, `status`, `dirty`, `pending`, `restore()`, `discard()`, `restoreEarlier()`, `markSaved()`, `rebase()`,
|
|
142
|
+
* `flush()`, `clear()` and `destroy()`.
|
|
143
|
+
*/
|
|
144
|
+
export function createPersistence(editor, options = {}) {
|
|
145
|
+
if (!editor || typeof editor.subscribe !== "function" || typeof editor.exportHistory !== "function" || typeof editor.restoreState !== "function")
|
|
146
|
+
throw fail("invalid-editor", "createPersistence needs an editor session from createEditorSession.");
|
|
147
|
+
if (typeof options.key !== "string" || !options.key) throw fail("missing-persistence-key", "Persistence needs a document key: a stable string that names this document.");
|
|
148
|
+
const storeKey = `${PREFIX}${options.key}`;
|
|
149
|
+
const earlierKey = `${storeKey}#earlier`;
|
|
150
|
+
const win = options.window === undefined ? globalThis.window : options.window;
|
|
151
|
+
const now = options.now ?? (() => Date.now());
|
|
152
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
153
|
+
const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
154
|
+
const includeHistory = options.includeHistory !== false;
|
|
155
|
+
const limits = { entries: options.maxHistoryEntries ?? DEFAULT_MAX_HISTORY_ENTRIES, bytes: options.maxHistoryBytes ?? DEFAULT_MAX_HISTORY_BYTES };
|
|
156
|
+
|
|
157
|
+
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 touched = false; // the document changed (or was saved) since this controller was created: only then is a copy written
|
|
160
|
+
let pending; // the stored record on offer
|
|
161
|
+
let stashed = false; // the offered record has been moved aside (earlierKey) because the user kept editing
|
|
162
|
+
let hasEarlier = false;
|
|
163
|
+
let earlierRecord; // what is in the earlierKey slot, for the offer
|
|
164
|
+
let timer;
|
|
165
|
+
let firstDirtyAt = 0;
|
|
166
|
+
let chain = Promise.resolve();
|
|
167
|
+
let lastWritten = "";
|
|
168
|
+
let destroyed = false;
|
|
169
|
+
let status = { state: "starting", available: false, dirty: false };
|
|
170
|
+
const setStatus = (patch) => {
|
|
171
|
+
status = { ...status, ...patch, dirty: isDirty() };
|
|
172
|
+
options.onStatus?.(status);
|
|
173
|
+
};
|
|
174
|
+
function isDirty() {
|
|
175
|
+
return baseline === null || jsonOf(editor.document) !== baseline;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- writing --------------------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
async function writeRecord() {
|
|
181
|
+
if (!adapter || destroyed) return false;
|
|
182
|
+
const document = editor.document;
|
|
183
|
+
const json = jsonOf(document);
|
|
184
|
+
const dirty = baseline === null || json !== baseline;
|
|
185
|
+
const history = includeHistory ? trimHistory(editor.exportHistory(), limits) : undefined;
|
|
186
|
+
const signature = `${json}|${dirty}|${history ? history.undo.length + ":" + history.redo.length : ""}`;
|
|
187
|
+
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
|
+
setStatus({ state: "saving" });
|
|
190
|
+
try {
|
|
191
|
+
// The first write while an offer is open moves the offered copy aside, so ignoring the prompt never loses it.
|
|
192
|
+
if (pending && !stashed) {
|
|
193
|
+
await adapter.set(earlierKey, pending);
|
|
194
|
+
stashed = true;
|
|
195
|
+
hasEarlier = true;
|
|
196
|
+
earlierRecord = pending;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
await adapter.set(storeKey, record);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (!isQuotaError(error) || !history) throw error;
|
|
202
|
+
// Too big with its history: keep the document itself.
|
|
203
|
+
const { undo, redo, ...bare } = record;
|
|
204
|
+
void undo; void redo;
|
|
205
|
+
await adapter.set(storeKey, bare);
|
|
206
|
+
record.historyDropped = true;
|
|
207
|
+
}
|
|
208
|
+
lastWritten = signature;
|
|
209
|
+
setStatus({ state: "saved", savedAt: record.savedAt, message: record.historyDropped ? "Saved on this device. The undo history was too large to keep." : "", error: undefined });
|
|
210
|
+
return true;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const full = isQuotaError(error);
|
|
213
|
+
setStatus({ state: "error", error: full ? "quota" : "write", message: full ? "Autosave stopped: browser storage is full. Download your work to keep it." : `Autosave failed (${String(error?.message ?? error)}). Download your work to keep it.` });
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function schedule() {
|
|
218
|
+
if (destroyed || !adapter) return;
|
|
219
|
+
const at = now();
|
|
220
|
+
if (!firstDirtyAt) firstDirtyAt = at;
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
const wait = Math.max(0, Math.min(debounceMs, firstDirtyAt + maxWaitMs - at));
|
|
223
|
+
timer = setTimeout(() => { void flush(); }, wait);
|
|
224
|
+
}
|
|
225
|
+
/** Write the document now (cancelling the debounce) and resolve when it is stored. Resolves false when nothing could be written. */
|
|
226
|
+
function flush() {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
firstDirtyAt = 0;
|
|
229
|
+
if (!touched) return chain.then(() => true);
|
|
230
|
+
chain = chain.then(writeRecord, writeRecord);
|
|
231
|
+
return chain;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- offering and restoring -------------------------------------------------------------------------
|
|
235
|
+
|
|
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 });
|
|
238
|
+
|
|
239
|
+
async function applyRecord(record) {
|
|
240
|
+
const pristine = !touched && !editor.canUndo && !editor.canRedo;
|
|
241
|
+
const state = { document: record.document, undo: record.undo ?? [], redo: record.redo ?? [] };
|
|
242
|
+
if (pristine) {
|
|
243
|
+
try { editor.restoreState(state, { source: "restore" }); }
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error?.code !== "invalid-history") throw error;
|
|
246
|
+
editor.restoreState({ document: record.document }, { source: "restore" });
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
// 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
|
+
}
|
|
252
|
+
touched = true;
|
|
253
|
+
baseline = record.dirty === false ? jsonOf(editor.document) : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Put the offered copy back. Resolves true when it was restored, false when there was nothing on offer or it was refused. */
|
|
257
|
+
async function restore() {
|
|
258
|
+
if (!pending) return false;
|
|
259
|
+
const record = pending;
|
|
260
|
+
try {
|
|
261
|
+
await applyRecord(record);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
setStatus({ state: "error", message: `The saved copy could not be restored (${String(error?.message ?? error)}).` });
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
pending = undefined;
|
|
267
|
+
await dropEarlier();
|
|
268
|
+
lastWritten = "";
|
|
269
|
+
setStatus({ state: "saved", savedAt: record.savedAt, message: "Restored.", error: undefined, offer: undefined });
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
async function dropEarlier() {
|
|
273
|
+
stashed = false;
|
|
274
|
+
earlierRecord = undefined;
|
|
275
|
+
if (!hasEarlier || !adapter) return;
|
|
276
|
+
hasEarlier = false;
|
|
277
|
+
try { await adapter.delete(earlierKey); } catch { /* the stale copy stays; the next load ignores it */ }
|
|
278
|
+
}
|
|
279
|
+
/** Decline the offered copy and delete it. */
|
|
280
|
+
async function discard() {
|
|
281
|
+
if (!pending && !hasEarlier) return false;
|
|
282
|
+
pending = undefined;
|
|
283
|
+
try { if (adapter) await adapter.delete(storeKey); } catch { /* reported by the next write */ }
|
|
284
|
+
await dropEarlier();
|
|
285
|
+
// The stored copy is gone; the next change writes the current document.
|
|
286
|
+
lastWritten = "";
|
|
287
|
+
if (touched) schedule();
|
|
288
|
+
setStatus({ state: touched ? status.state : "idle", message: "", offer: undefined });
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
/** Bring back the copy that was moved aside when the user kept editing while an offer was open, as one undoable change. */
|
|
292
|
+
async function restoreEarlier() {
|
|
293
|
+
if (!hasEarlier || !adapter) return false;
|
|
294
|
+
let record;
|
|
295
|
+
try { record = await adapter.get(earlierKey); } catch { return false; }
|
|
296
|
+
if (!validRecord(record)) return false;
|
|
297
|
+
try { editor.applyPatch([{ op: "replace", path: "", value: structuredClone(record.document) }], { source: "restore" }); }
|
|
298
|
+
catch { return false; }
|
|
299
|
+
pending = undefined;
|
|
300
|
+
touched = true;
|
|
301
|
+
baseline = null;
|
|
302
|
+
await dropEarlier();
|
|
303
|
+
lastWritten = "";
|
|
304
|
+
setStatus({ message: "Restored the earlier copy.", offer: undefined });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// --- host hooks ---------------------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/** 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
|
+
function markSaved() {
|
|
312
|
+
baseline = jsonOf(editor.document);
|
|
313
|
+
touched = true;
|
|
314
|
+
return flush();
|
|
315
|
+
}
|
|
316
|
+
/** 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
|
+
function rebase() {
|
|
318
|
+
baseline = jsonOf(editor.document);
|
|
319
|
+
touched = false;
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
firstDirtyAt = 0;
|
|
322
|
+
setStatus({});
|
|
323
|
+
}
|
|
324
|
+
/** Delete the stored copy (and any copy moved aside) and stop treating the document as changed. */
|
|
325
|
+
async function clear() {
|
|
326
|
+
pending = undefined;
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
if (adapter) { try { await adapter.delete(storeKey); } catch { /* nothing to clean up */ } }
|
|
329
|
+
await dropEarlier();
|
|
330
|
+
lastWritten = "";
|
|
331
|
+
touched = false;
|
|
332
|
+
baseline = jsonOf(editor.document);
|
|
333
|
+
setStatus({ state: status.available ? "idle" : status.state, message: "" });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- wiring ---------------------------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
const unsubscribe = editor.subscribe(() => {
|
|
339
|
+
touched = true;
|
|
340
|
+
setStatus({});
|
|
341
|
+
schedule();
|
|
342
|
+
});
|
|
343
|
+
const beforeUnload = (event) => {
|
|
344
|
+
try { options.beforeFlush?.(); } catch { /* an uncommitted draft that cannot commit must not block leaving */ }
|
|
345
|
+
// Best effort: start the final write; a browser finishes an IndexedDB transaction that began before the page went away.
|
|
346
|
+
if (touched) void flush();
|
|
347
|
+
if (options.warnOnUnload === false || !isDirty()) return undefined;
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
event.returnValue = "";
|
|
350
|
+
return "";
|
|
351
|
+
};
|
|
352
|
+
const pageHide = () => { try { options.beforeFlush?.(); } catch { /* see above */ } if (touched) void flush(); };
|
|
353
|
+
const visibility = () => { if (win?.document?.visibilityState === "hidden") pageHide(); };
|
|
354
|
+
win?.addEventListener?.("beforeunload", beforeUnload);
|
|
355
|
+
win?.addEventListener?.("pagehide", pageHide);
|
|
356
|
+
win?.document?.addEventListener?.("visibilitychange", visibility);
|
|
357
|
+
|
|
358
|
+
const ready = (async () => {
|
|
359
|
+
const resolved = await resolveStorage(options.storage);
|
|
360
|
+
adapter = resolved.adapter;
|
|
361
|
+
if (!adapter) {
|
|
362
|
+
setStatus({ state: "unavailable", available: false, message: `${resolved.reason} Your work is kept only while this page is open: download it to keep it.` });
|
|
363
|
+
return { available: false, offered: false, reason: resolved.reason };
|
|
364
|
+
}
|
|
365
|
+
setStatus({ state: "idle", available: true, storage: adapter.name });
|
|
366
|
+
// The host may have changed the document while storage was opening: write that, once the offer (if any) is settled below.
|
|
367
|
+
if (touched) schedule();
|
|
368
|
+
let record;
|
|
369
|
+
try { record = await adapter.get(storeKey); }
|
|
370
|
+
catch (error) {
|
|
371
|
+
setStatus({ state: "error", message: `Saved work could not be read (${String(error?.message ?? error)}).` });
|
|
372
|
+
return { available: true, offered: false };
|
|
373
|
+
}
|
|
374
|
+
if (!validRecord(record) || jsonOf(record.document) === jsonOf(editor.document)) return { available: true, offered: false };
|
|
375
|
+
try {
|
|
376
|
+
const earlier = await adapter.get(earlierKey);
|
|
377
|
+
hasEarlier = validRecord(earlier);
|
|
378
|
+
earlierRecord = hasEarlier ? earlier : undefined;
|
|
379
|
+
} catch { hasEarlier = false; }
|
|
380
|
+
pending = record;
|
|
381
|
+
const offer = { ...offerOf(record), ...(earlierRecord ? { earlier: { savedAt: earlierRecord.savedAt, name: earlierRecord.name, slideCount: earlierRecord.slideCount } } : {}) };
|
|
382
|
+
setStatus({ state: "idle", offer });
|
|
383
|
+
if (options.onRestorePrompt) {
|
|
384
|
+
Promise.resolve(options.onRestorePrompt(offer, { restore, discard })).then((choice) => {
|
|
385
|
+
if (choice === "restore") return restore();
|
|
386
|
+
if (choice === "discard") return discard();
|
|
387
|
+
return undefined;
|
|
388
|
+
}).catch(() => undefined);
|
|
389
|
+
}
|
|
390
|
+
return { available: true, offered: true, offer };
|
|
391
|
+
})();
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
ready,
|
|
395
|
+
get status() { return status; },
|
|
396
|
+
/** True when the document differs from the last `markSaved()` (or from how the session started). */
|
|
397
|
+
get dirty() { return isDirty(); },
|
|
398
|
+
/** The stored copy on offer (what `onRestorePrompt` received), or undefined. */
|
|
399
|
+
get pending() { return pending ? offerOf(pending) : undefined; },
|
|
400
|
+
get pendingEarlier() { return hasEarlier && earlierRecord ? { savedAt: earlierRecord.savedAt, name: earlierRecord.name, slideCount: earlierRecord.slideCount } : undefined; },
|
|
401
|
+
restore,
|
|
402
|
+
discard,
|
|
403
|
+
restoreEarlier,
|
|
404
|
+
markSaved,
|
|
405
|
+
rebase,
|
|
406
|
+
flush,
|
|
407
|
+
clear,
|
|
408
|
+
destroy() {
|
|
409
|
+
destroyed = true;
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
unsubscribe();
|
|
412
|
+
win?.removeEventListener?.("beforeunload", beforeUnload);
|
|
413
|
+
win?.removeEventListener?.("pagehide", pageHide);
|
|
414
|
+
win?.document?.removeEventListener?.("visibilitychange", visibility);
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A sentence for a status, for hosts that show their own indicator ("Saved on this device at 2:03 PM", or why autosave is off). */
|
|
420
|
+
export function describeAutosave(status, options = {}) {
|
|
421
|
+
if (!status) return "";
|
|
422
|
+
if (status.state === "unavailable" || status.state === "error") return status.message ?? "";
|
|
423
|
+
if (status.state === "saving") return "Saving on this device…";
|
|
424
|
+
if (status.state === "saved" && status.savedAt) {
|
|
425
|
+
const time = new Date(status.savedAt).toLocaleTimeString(options.locale, { hour: "numeric", minute: "2-digit" });
|
|
426
|
+
return `${status.message && !/^Restored/.test(status.message) ? `${status.message} ` : ""}Saved on this device at ${time}`;
|
|
427
|
+
}
|
|
428
|
+
return "";
|
|
429
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { ReviewAuditOptions, ReviewFinding, ReviewFix, ReviewReport, ReviewTarget } from "./review.js";
|
|
3
|
+
|
|
4
|
+
export interface ReviewPanelOptions {
|
|
5
|
+
editor: EditorSession;
|
|
6
|
+
/** The slide the "This slide only" filter follows. Call `update()` when it changes. */
|
|
7
|
+
getSlideIndex?: () => number;
|
|
8
|
+
/** Core's `AuditOptions` for this document, for example `{ textMeasurement }` from the host's font registry. */
|
|
9
|
+
getAuditOptions?: (document: unknown) => ReviewAuditOptions;
|
|
10
|
+
/** 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
|
+
onGoTo?: (event: { finding: ReviewFinding; target: ReviewTarget }) => void;
|
|
12
|
+
/** 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
|
+
onFocusField?: (event: { finding: ReviewFinding; fix: ReviewFix; target: ReviewTarget }) => void;
|
|
14
|
+
/** Called with every status or error message the panel shows. */
|
|
15
|
+
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;
|
|
18
|
+
/** Rule ids hidden at the start, and a callback when the author hides or restores a check. */
|
|
19
|
+
ignored?: string[];
|
|
20
|
+
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
|
+
autoRefresh?: boolean;
|
|
23
|
+
/** Use another audit function instead of core's `auditPresentation`. */
|
|
24
|
+
audit?: ReviewAuditOptions["audit"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ReviewPanel {
|
|
28
|
+
element: HTMLElement;
|
|
29
|
+
/** The latest findings before the panel's filters and hidden checks. */
|
|
30
|
+
readonly findings: ReviewFinding[];
|
|
31
|
+
readonly report: ReviewReport | undefined;
|
|
32
|
+
/** Audit the document again and redraw. */
|
|
33
|
+
refresh(): void;
|
|
34
|
+
/** Redraw without auditing, after the host's slide changed. */
|
|
35
|
+
update(): void;
|
|
36
|
+
/** Hide (true) or show again (false) the findings of one rule in this panel. */
|
|
37
|
+
setIgnored(ruleId: string, hidden: boolean): void;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { auditAvailable, reviewSeverities } from "./review.js";
|
|
42
|
+
|
|
43
|
+
/** Mount the Review panel: the audit's findings with go-to, safe quick fixes and live updates as the document changes. */
|
|
44
|
+
export declare function createReviewPanel(container: HTMLElement, options: ReviewPanelOptions): ReviewPanel;
|