@openpresentation/opf-editor 0.20.2 → 0.20.4

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
4
4
 
5
- ## Using the editor with opf-render 0.16: what to install (RR-63, unreleased)
5
+ ## Install the editor and optional export dependencies (RR-63)
6
6
 
7
7
  `@openpresentation/opf-render` is an optional peer of the editor, and from render 0.16 its heavy pieces are optional peers too, so install only what you use. The editor itself draws with `@openpresentation/opf-render/svg` and loads fonts with `/fonts-browser`, which need nothing else. Add:
8
8
 
@@ -112,11 +112,12 @@ Chart highlight (FA-14): the same module edits `chart.highlight`: `setChartOptio
112
112
 
113
113
  ```js
114
114
  import { createCanvasEditor } from '@openpresentation/opf-editor/canvas';
115
+ import { saveDocument } from '@openpresentation/opf-editor';
115
116
 
116
117
  const canvas = createCanvasEditor(container, {
117
118
  presentation,
118
119
  fonts, // the renderer's fonts handle: const fonts = await loadFonts({ faces, ... })
119
- onCommit: ({ editor }) => saveDocument(editor.presentation),
120
+ onCommit: ({ editor }) => console.log(saveDocument(editor).document),
120
121
  });
121
122
  await canvas.ready;
122
123
  // canvas.destroy() when unmounting.
package/dist/blocks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { splitOpfPath, opfPathToJsonPointer, getValueAtPath, applyJsonPatch } from './index.js';
2
2
  import { firstErrorMessage, checkFormat } from "./checks.js";
3
+ import {collectReservedPresentationIds, remapSlideTreeIds} from "./presentation-ids.js";
3
4
  const prefix=(a,b)=>a.length<=b.length&&a.every((part,i)=>part===b[i]);
4
5
 
5
6
  /** Move one complete block. The insertion index refers to the destination before removal. */
@@ -120,7 +121,9 @@ export function prepareBlockReplace(presentation,path,block) {
120
121
  /** Duplicate the entire block immediately after itself, preserving formatting and asset references. */
121
122
  export function prepareBlockDuplicate(presentation,path) {
122
123
  const {container,index,blocks}=checkedBlock(presentation,path);
123
- return prepareBlockInsert(presentation,opfPathToJsonPointer(container),blocks[index],index+1);
124
+ const copy=structuredClone(blocks[index]);
125
+ remapSlideTreeIds({blocks:[copy]},new Set(collectReservedPresentationIds(presentation)));
126
+ return prepareBlockInsert(presentation,opfPathToJsonPointer(container),copy,index+1);
124
127
  }
125
128
  /** Delete a block and prune empty ancestor groups; a slide itself is never deleted. */
126
129
  export function prepareBlockRemove(presentation,path) {
package/dist/canvas.js CHANGED
@@ -1029,11 +1029,17 @@ export function createCanvasEditor(container, options = {}) {
1029
1029
  try {
1030
1030
  renderFor();
1031
1031
  } catch (error) {
1032
+ disposed = true;
1033
+ stopDrag?.();
1034
+ active?.rich?.destroy();
1035
+ imageCropper?.destroy();
1032
1036
  unsubscribe();
1033
1037
  resize.disconnect();
1038
+ win.visualViewport?.removeEventListener("resize", keepEditVisible);
1034
1039
  richToolbar.destroy();
1035
1040
  layoutHandles.destroy();
1036
1041
  blockControls.destroy();
1042
+ if (frame) win.cancelAnimationFrame(frame);
1037
1043
  root.remove();
1038
1044
  throw error;
1039
1045
  }
package/dist/checks.js CHANGED
@@ -9,7 +9,7 @@ const EDIT_CATEGORIES = Object.freeze(["format", "opf/unresolved-reference", "op
9
9
 
10
10
  /** Core's `ValidationReport` for the `format` category and the catalog reference rules: `valid`, `findings`, `counts`, `schemaValid`. */
11
11
  export function checkFormat(presentation, options = {}) {
12
- return validate(presentation, { only: EDIT_CATEGORIES, ...(options.catalogs?.length ? { catalogs: options.catalogs } : {}) });
12
+ return validate(presentation, { only: EDIT_CATEGORIES, ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}) });
13
13
  }
14
14
 
15
15
  /** The error-severity findings of a report: what makes it invalid. */
package/dist/data-grid.js CHANGED
@@ -914,10 +914,12 @@ export function createDataGrid(container, options = {}) {
914
914
  function copyToClipboard(then) {
915
915
  const { text, rows, columns } = selectionText();
916
916
  const done = () => {
917
+ if (destroyed) return;
917
918
  say(`Copied ${plural(rows, "row")} by ${plural(columns, "column")} as tab-separated text.`);
918
919
  then?.();
919
920
  };
920
921
  const fallback = () => {
922
+ if (destroyed) return;
921
923
  const previous = doc.activeElement;
922
924
  const area = h("textarea", { "aria-hidden": "true", tabindex: -1 });
923
925
  area.style.cssText = "position:fixed;opacity:0;left:-9999px";
@@ -1045,7 +1047,20 @@ export function createDataGrid(container, options = {}) {
1045
1047
  // Browsers differ in firing copy for a focused cell that holds no selection, so the keys do the copy themselves.
1046
1048
  event.preventDefault();
1047
1049
  event.stopPropagation();
1048
- copyToClipboard(lower === "x" ? clearSelection : undefined);
1050
+ const source = editor.snapshot(), path = target.path;
1051
+ const edits = [];
1052
+ if (lower === "x") {
1053
+ const r = rect();
1054
+ for (let u = r.top; u <= r.bottom; u += 1)
1055
+ for (let c = r.left; c <= r.right; c += 1) {
1056
+ const cell = cellAt(u, c);
1057
+ if (cell && !cell.covered) edits.push({ ...addr(u, c), text: "" });
1058
+ }
1059
+ }
1060
+ copyToClipboard(lower === "x" ? () => {
1061
+ if (destroyed || editor.snapshot() !== source || !edits.length) return;
1062
+ attempt(() => setGridCells(editor, path, edits, opts()), `${plural(edits.length, "cell")} cleared.`);
1063
+ } : undefined);
1049
1064
  return;
1050
1065
  }
1051
1066
  }
@@ -45,6 +45,7 @@ import { createContentControls } from "./content-controls.js";
45
45
  import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
46
46
  import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
47
47
  import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, readTableStyle, setTableAlt, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
48
+ import { OPFEditorError } from "./index.js";
48
49
 
49
50
  /** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
50
51
  export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
@@ -117,6 +118,7 @@ export function createDesignControls(container, options = {}) {
117
118
  const state = { scope: options.scope === "slide" ? "slide" : "deck" };
118
119
  const syncs = [];
119
120
  let destroyed = false;
121
+ let uploadEpoch = 0;
120
122
  // After a refused change the field the person typed in is reset too, even though it still has focus.
121
123
  let restoring = false;
122
124
  // The background form is a draft until Apply; edits to it survive unrelated refreshes.
@@ -138,6 +140,10 @@ export function createDesignControls(container, options = {}) {
138
140
  };
139
141
 
140
142
  const root = h("div", { class: "opf-design-controls", "data-opf-component": "design-controls" });
143
+ const invalidateUpload = () => { uploadEpoch++; };
144
+ // Capture before the file input's own listener starts the next upload.
145
+ root.addEventListener("input", invalidateUpload, true);
146
+ root.addEventListener("change", invalidateUpload, true);
141
147
  const statusEl = h("p", { class: "opf-dc-status", role: "status", "aria-live": "polite" });
142
148
  const errorEl = h("p", { class: "opf-dc-error", role: "alert" });
143
149
 
@@ -328,9 +334,10 @@ export function createDesignControls(container, options = {}) {
328
334
 
329
335
  /**
330
336
  * Source text, file upload and alt text for one image. `apply(ref | null)` commits a typed source (null removes it);
331
- * `build(ref, document)` prepares the change an upload makes, after its asset patch, in the same undo step.
337
+ * `captureBuild()` freezes the target and form before reading a file. Its returned
338
+ * builder prepares the change after the asset patch, in the same undo step.
332
339
  */
333
- function imageCluster(name, label, { apply, build, help }) {
340
+ function imageCluster(name, label, { apply, captureBuild, help }) {
334
341
  const source = textField(`${name}-source`, `${label} source`, {
335
342
  list: true,
336
343
  placeholder: "asset:photo or https://…",
@@ -363,6 +370,18 @@ export function createDesignControls(container, options = {}) {
363
370
  const file = input.files?.[0];
364
371
  input.value = "";
365
372
  if (!file) return;
373
+ const epoch = ++uploadEpoch;
374
+ const targetKey = lastKey;
375
+ // A host may register assets before returning asset:id. Only assets are
376
+ // excluded; replacing content or changing any other source is stale.
377
+ const sourceSignature = () => JSON.stringify({ ...editor.presentation, assets: undefined });
378
+ const originalSource = sourceSignature();
379
+ const capturedBuild = captureBuild();
380
+ const build = (ref, presentation) => {
381
+ if (destroyed || uploadEpoch !== epoch || lastKey !== targetKey || sourceSignature() !== originalSource)
382
+ throw new OPFEditorError("stale-image-upload", "The upload target or form changed. Choose the image again.", {});
383
+ return capturedBuild(ref, presentation);
384
+ };
366
385
  statusEl.textContent = `Adding ${file.name}…`;
367
386
  errorEl.textContent = "";
368
387
  try {
@@ -371,6 +390,7 @@ export function createDesignControls(container, options = {}) {
371
390
  say(`${label} set to ${file.name}${change.assetId ? `, added to assets as ${change.assetId}` : ""}.${warnings} Undo restores the previous state.`);
372
391
  options.onChange?.(change);
373
392
  } catch (error) {
393
+ if (destroyed) return;
374
394
  complain(messageOf(error));
375
395
  sync();
376
396
  }
@@ -384,6 +404,7 @@ export function createDesignControls(container, options = {}) {
384
404
  // `key` names what the image is for (variant, zone, slide, scope): alt text typed for one target is not offered to another.
385
405
  set(ref, note = "", key = "") {
386
406
  const targetChanged = (ref ?? "") !== lastRef || key !== lastKey;
407
+ if (targetChanged) invalidateUpload();
387
408
  lastKey = key;
388
409
  lastRef = ref ?? "";
389
410
  source.setList(assetList());
@@ -568,7 +589,10 @@ export function createDesignControls(container, options = {}) {
568
589
  };
569
590
  const bgImage = imageCluster("bg-image", "Background image", {
570
591
  apply: (ref) => (ref === null ? setBackground(editor, null, scoped()) : setBackground(editor, imageSpec(ref), scoped())),
571
- build: (ref, doc) => prepareBackground(doc, imageSpec(ref), scoped()),
592
+ captureBuild: () => {
593
+ const spec = imageSpec(undefined), target = scoped();
594
+ return (ref, doc) => prepareBackground(doc, { ...spec, src: ref }, target);
595
+ },
572
596
  help: "An asset reference, a web address or a data address. Press Enter or choose a file to apply it with the settings here.",
573
597
  });
574
598
  imagePanel.append(bgImage.wrap, fit.wrap, bgAlt.wrap, focusX.wrap, focusY.wrap, bgRecolor.wrap, bgDuoDark.wrap, bgDuoLight.wrap, overlay.wrap);
@@ -727,7 +751,10 @@ export function createDesignControls(container, options = {}) {
727
751
  });
728
752
  const image = imageCluster("hf-image", "Image", {
729
753
  apply: (ref) => setHeaderFooterZone(editor, which(), zone(), { image: ref }, scoped()),
730
- build: (ref, doc) => prepareHeaderFooterZone(doc, which(), zone(), { image: ref }, scoped()),
754
+ captureBuild: () => {
755
+ const kind = which(), place = zone(), target = scoped();
756
+ return (ref, doc) => prepareHeaderFooterZone(doc, kind, place, { image: ref }, target);
757
+ },
731
758
  help: "A picture in this zone, such as a partner mark or badge: an asset reference, a web address or a data address, or an organization logo (Insert logo, below). Press Enter to apply; clear the field to remove it.",
732
759
  });
733
760
  // RR-71: a logo is the zone's image, written as a reference to the organization's logo (var:organization.logo.icon). Which artwork is drawn (light or dark) follows each slide's background.
@@ -838,9 +865,9 @@ export function createDesignControls(container, options = {}) {
838
865
  const { shape, ...rest } = logoTarget();
839
866
  return setOrganizationLogo(editor, shape, ref, rest);
840
867
  },
841
- build: (ref, doc) => {
868
+ captureBuild: () => {
842
869
  const { shape, ...rest } = logoTarget();
843
- return prepareOrganizationLogo(doc, shape, ref, rest);
870
+ return (ref, doc) => prepareOrganizationLogo(doc, shape, ref, rest);
844
871
  },
845
872
  help: "An asset reference, a web address or a data address for the chosen shape and background. Press Enter to apply; clear the field to remove it.",
846
873
  });
@@ -933,7 +960,10 @@ export function createDesignControls(container, options = {}) {
933
960
  });
934
961
  const watermarkSource = imageCluster("watermark", "Watermark", {
935
962
  apply: (ref) => setDesignOption(editor, "watermark", ref === null ? null : { src: ref }, scoped()),
936
- build: (ref, doc) => prepareDesignOption(doc, "watermark", { src: ref }, scoped()),
963
+ captureBuild: () => {
964
+ const target = scoped();
965
+ return (ref, doc) => prepareDesignOption(doc, "watermark", { src: ref }, target);
966
+ },
937
967
  });
938
968
  // FA-13: a watermark is an image or a text stamp; the text field and the image cluster replace each other.
939
969
  const watermarkText = textField("watermark-text", "Watermark text", {
@@ -1459,6 +1489,9 @@ export function createDesignControls(container, options = {}) {
1459
1489
  },
1460
1490
  destroy() {
1461
1491
  destroyed = true;
1492
+ invalidateUpload();
1493
+ root.removeEventListener("input", invalidateUpload, true);
1494
+ root.removeEventListener("change", invalidateUpload, true);
1462
1495
  unsubscribe();
1463
1496
  root.remove();
1464
1497
  },
@@ -226,7 +226,10 @@ export function findMatches(presentation, query, options = {}) {
226
226
  const tokens = item.text.includes("{{") ? [...item.text.matchAll(VARIABLE_TOKEN)].map((token) => [token.index, token.index + token[0].length]) : [];
227
227
  while ((found = compiled.regex.exec(item.text))) {
228
228
  if (found[0] === "") {
229
- compiled.regex.lastIndex += 1;
229
+ // AdvanceStringIndex in Unicode mode must skip a whole surrogate pair:
230
+ // exec() otherwise rewinds an index inside it to the same empty match.
231
+ const at = compiled.regex.lastIndex;
232
+ compiled.regex.lastIndex = at + (compiled.regex.unicode && item.text.codePointAt(at) > 0xFFFF ? 2 : 1);
230
233
  continue;
231
234
  }
232
235
  if (tokens.some(([from, to]) => found.index < to && found.index + found[0].length > from)) continue;
@@ -55,5 +55,5 @@ export interface LoadedImage { image: HTMLImageElement; width: number; height: n
55
55
  export declare function loadImagePixels(src: string, options?: { document?: Document; signal?: AbortSignal }): Promise<LoadedImage>;
56
56
  /** Cut a rectangle out of a loaded picture at its own resolution (JPEG stays JPEG, everything else PNG). Needs a browser canvas. */
57
57
  export declare function cropImagePixels(loaded: LoadedImage, rect: Rect, options?: { mediaType?: string; maxBytes?: number; document?: Document }): Promise<CroppedPixels>;
58
- /** Crop a picture of the document and apply it as one undoable change. */
59
- export declare function applyCrop(editor: EditorSession, path: string, rect: Rect, options?: { loaded?: LoadedImage; maxBytes?: number; meta?: Record<string, unknown> }): Promise<EditorChange & { assetId: string; reference: string; width: number; height: number }>;
58
+ /** Crop a picture as one undoable change; rejects stale document revisions and cancellation before committing. */
59
+ export declare function applyCrop(editor: EditorSession, path: string, rect: Rect, options?: { loaded?: LoadedImage; maxBytes?: number; meta?: Record<string, unknown>; signal?: AbortSignal }): Promise<EditorChange & { assetId: string; reference: string; width: number; height: number }>;
@@ -259,17 +259,30 @@ export async function loadImagePixels(src, { document: doc = globalThis.document
259
259
  const win = doc.defaultView ?? globalThis;
260
260
  const attempt = (anonymous) => new Promise((resolve, reject) => {
261
261
  const image = new win.Image();
262
+ let settled = false;
263
+ const finish = (callback, value) => {
264
+ if (settled) return;
265
+ settled = true;
266
+ image.onload = image.onerror = null;
267
+ signal?.removeEventListener("abort", abort);
268
+ callback(value);
269
+ };
270
+ const abort = () => {
271
+ finish(reject, signal.reason ?? new win.DOMException("Image loading was cancelled.", "AbortError"));
272
+ image.src = "";
273
+ };
262
274
  if (anonymous) image.crossOrigin = "anonymous";
263
275
  image.decoding = "async";
264
- image.onload = () => resolve(image);
265
- image.onerror = () => reject(fail("image-unreadable", "The picture could not be loaded. If it is hosted elsewhere, upload it to the presentation first.", { src: src.slice(0, 80) }));
266
- signal?.addEventListener("abort", () => { image.src = ""; }, { once: true });
267
- image.src = src;
276
+ image.onload = () => finish(resolve, image);
277
+ image.onerror = () => finish(reject, fail("image-unreadable", "The picture could not be loaded. If it is hosted elsewhere, upload it to the presentation first.", { src: src.slice(0, 80) }));
278
+ signal?.addEventListener("abort", abort, { once: true });
279
+ if (signal?.aborted) abort();
280
+ else image.src = src;
268
281
  });
269
282
  // A picture on another site is read with CORS first (so its pixels can be cut); without it the picture still shows and
270
283
  // cropImagePixels reports why it cannot be cut.
271
284
  const hosted = !/^(?:data|blob):/.test(src);
272
- const image = hosted ? await attempt(true).catch(() => attempt(false)) : await attempt(false);
285
+ const image = hosted ? await attempt(true).catch((error) => { if (signal?.aborted) throw error; return attempt(false); }) : await attempt(false);
273
286
  if (!image.naturalWidth || !image.naturalHeight) throw fail("image-unreadable", "The picture has no size.", {});
274
287
  return { image, width: image.naturalWidth, height: image.naturalHeight };
275
288
  }
@@ -324,12 +337,20 @@ export async function cropImagePixels(loaded, rect, { mediaType, maxBytes = DEFA
324
337
  * `{ assetId, reference, width, height }`.
325
338
  */
326
339
  export async function applyCrop(editor, path, rect, options = {}) {
327
- const image = describeImage(editor.presentation, path);
340
+ const source = editor.snapshot();
341
+ const current = () => {
342
+ options.signal?.throwIfAborted();
343
+ if (editor.snapshot() !== source) throw fail("stale-image-crop", "The presentation changed while the picture was being cropped; open the current picture and crop it again.", { path });
344
+ };
345
+ current();
346
+ const image = describeImage(source.presentation, path);
328
347
  if (image.error) throw fail("not-croppable", image.error, { path });
329
- const loaded = options.loaded ?? await loadImagePixels(image.assetSrc);
348
+ const loaded = options.loaded ?? await loadImagePixels(image.assetSrc, { signal: options.signal });
349
+ current();
330
350
  const pixels = await cropImagePixels(loaded, rect, { mediaType: image.mediaType, maxBytes: options.maxBytes });
331
- // The document may have changed while the picture was encoding.
332
- const prepared = prepareCrop(editor.presentation, path, pixels);
351
+ // Never apply old pixels to a newer document or to a cancelled crop session.
352
+ current();
353
+ const prepared = prepareCrop(source.presentation, path, pixels);
333
354
  const change = editor.applyPatch(prepared.patches, { ...options.meta, source: options.meta?.source ?? "image-crop", path, assetId: prepared.assetId });
334
355
  return { ...change, assetId: prepared.assetId, reference: prepared.reference, width: pixels.width, height: pixels.height };
335
356
  }
@@ -143,6 +143,8 @@ export function createImageCropper(root, overlay, options) {
143
143
  const opener = doc.activeElement;
144
144
  const s = (session = {
145
145
  path,
146
+ source: editor.snapshot(),
147
+ controller: new win.AbortController(),
146
148
  opener,
147
149
  image,
148
150
  loaded: null,
@@ -210,6 +212,7 @@ export function createImageCropper(root, overlay, options) {
210
212
  const close = (result) => {
211
213
  if (session !== s) return;
212
214
  session = null;
215
+ s.controller.abort();
213
216
  resizeObserver.disconnect();
214
217
  layer.remove();
215
218
  if (s.scrollLock !== null) doc.documentElement.style.overflow = s.scrollLock;
@@ -447,7 +450,8 @@ export function createImageCropper(root, overlay, options) {
447
450
  applyButton.disabled = true;
448
451
  applyButton.textContent = "Applying…";
449
452
  try {
450
- const changed = await applyCrop(editor, path, s.rect, { loaded: s.loaded, meta: { tool: s.tool } });
453
+ if (editor.snapshot() !== s.source) throw new Error("The presentation changed; close this crop and open the current picture again.");
454
+ const changed = await applyCrop(editor, path, s.rect, { loaded: s.loaded, signal: s.controller.signal, meta: { tool: s.tool } });
451
455
  if (session !== s) return;
452
456
  close("done");
453
457
  options.onCommit?.({ path, assetId: changed.assetId });
@@ -469,7 +473,7 @@ export function createImageCropper(root, overlay, options) {
469
473
  for (const control of controls) control.disabled = true;
470
474
  layer.setAttribute("aria-busy", "true");
471
475
  try {
472
- s.loaded = await loadImagePixels(image.assetSrc, { document: doc });
476
+ s.loaded = await loadImagePixels(image.assetSrc, { document: doc, signal: s.controller.signal });
473
477
  } catch (error) {
474
478
  if (session !== s) return false;
475
479
  close("error");
package/dist/index.js CHANGED
@@ -269,7 +269,8 @@ export function createEditorSession(input, options = {}) {
269
269
  return () => listeners.delete(listener);
270
270
  },
271
271
  get(path, fallback) {
272
- return getValueAtPath(presentation, path, fallback);
272
+ const found = readPointer(presentation, splitOpfPath(path));
273
+ return found.found ? clone(found.value) : fallback;
273
274
  },
274
275
  set(path, value, meta = {}) {
275
276
  return commitPatch(createValuePatch(presentation, path, value), {
@@ -38,7 +38,7 @@ export interface AutosaveStatus {
38
38
  savedAt?: number;
39
39
  /** A sentence to show the user for `unavailable` and `error` (and a note on `saved`, such as "history too large to keep"). */
40
40
  message?: string;
41
- error?: "quota" | "write";
41
+ error?: "quota" | "write" | "draft";
42
42
  /** The adapter in use: "indexedDB", "localStorage", "memory" or the host adapter's name. */
43
43
  storage?: string;
44
44
  offer?: RestoreOffer;
@@ -63,8 +63,8 @@ export interface PersistenceOptions {
63
63
  onRestorePrompt?: (offer: RestoreOffer, actions: { restore(): Promise<boolean>; discard(): Promise<boolean> }) => RestoreChoice | Promise<RestoreChoice>;
64
64
  /** Called on every status change, for an indicator. */
65
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;
66
+ /** Called synchronously before each explicit, debounced or lifecycle flush, even before any session edit. Commit the active draft; returning false or throwing refuses the write and reports a draft error. */
67
+ beforeFlush?: () => boolean | void;
68
68
  /** Warn before the page closes while the document is `dirty` (default true). */
69
69
  warnOnUnload?: boolean;
70
70
  /** For tests and frames: the window that receives `beforeunload`, `pagehide` and `visibilitychange` (default the global window; null for none). */
@@ -167,6 +167,8 @@ export function createPersistence(editor, options = {}) {
167
167
  let chain = Promise.resolve();
168
168
  let lastWritten = "";
169
169
  let destroyed = false;
170
+ let preparingFlush = false;
171
+ let flushRefused = false;
170
172
  let status = { state: "starting", available: false, dirty: false };
171
173
  const setStatus = (patch) => {
172
174
  status = { ...status, ...patch, dirty: isDirty() };
@@ -227,6 +229,24 @@ export function createPersistence(editor, options = {}) {
227
229
  function flush() {
228
230
  clearTimeout(timer);
229
231
  firstDirtyAt = 0;
232
+ if (destroyed) return Promise.resolve(false);
233
+ // Commit synchronously, including a draft in an otherwise untouched session. The lifecycle
234
+ // warning below must see that edit before it decides whether leaving would lose work.
235
+ // A commit emits a change; clear its debounce again and avoid recursive host flush hooks.
236
+ if (preparingFlush) return chain;
237
+ preparingFlush = true;
238
+ try {
239
+ if (options.beforeFlush?.() === false) throw new Error("Commit or cancel the active draft first.");
240
+ flushRefused = false;
241
+ } catch (error) {
242
+ flushRefused = true;
243
+ setStatus({ state: "error", error: "draft", message: `Autosave could not commit the active draft (${String(error?.message ?? error)}). Keep this page open to recover your work.` });
244
+ return Promise.resolve(false);
245
+ } finally {
246
+ preparingFlush = false;
247
+ clearTimeout(timer);
248
+ firstDirtyAt = 0;
249
+ }
230
250
  if (!touched) return chain.then(() => true);
231
251
  chain = chain.then(writeRecord, writeRecord);
232
252
  return chain;
@@ -342,15 +362,14 @@ export function createPersistence(editor, options = {}) {
342
362
  schedule();
343
363
  });
344
364
  const beforeUnload = (event) => {
345
- try { options.beforeFlush?.(); } catch { /* an uncommitted draft that cannot commit must not block leaving */ }
346
365
  // Best effort: start the final write; a browser finishes an IndexedDB transaction that began before the page went away.
347
- if (touched) void flush();
348
- if (options.warnOnUnload === false || !isDirty()) return undefined;
366
+ void flush();
367
+ if (options.warnOnUnload === false || (!isDirty() && !flushRefused)) return undefined;
349
368
  event.preventDefault();
350
369
  event.returnValue = "";
351
370
  return "";
352
371
  };
353
- const pageHide = () => { try { options.beforeFlush?.(); } catch { /* see above */ } if (touched) void flush(); };
372
+ const pageHide = () => { void flush(); };
354
373
  const visibility = () => { if (win?.document?.visibilityState === "hidden") pageHide(); };
355
374
  win?.addEventListener?.("beforeunload", beforeUnload);
356
375
  win?.addEventListener?.("pagehide", pageHide);
package/dist/slides.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Catalog } from "@openpresentation/opf";
1
2
  import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
3
 
3
4
  /** One section of the deck: a maximal run of consecutive slides sharing a `section` label (or none). */
@@ -57,8 +58,11 @@ export interface AddSlideOptions {
57
58
  section?: string | null;
58
59
  /** A ready-made slide to insert instead of a blank one. */
59
60
  slide?: Record<string, unknown>;
60
- catalogs?: Record<string, unknown>;
61
+ /** Host catalogs, as for DimensionSwitchOptions. */
62
+ catalogs?: readonly Catalog[];
61
63
  record?: Record<string, unknown>;
64
+ /** Catalog source of record, as for DimensionSwitchOptions. */
65
+ recordSource?: string;
62
66
  }
63
67
 
64
68
  export declare function listSections(presentation: unknown): SlideSection[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -266,11 +266,11 @@
266
266
  "@codemirror/state": "6.7.6",
267
267
  "@codemirror/view": "6.43.14",
268
268
  "@lezer/highlight": "1.2.5",
269
- "@openpresentation/opf": "^0.20.1",
269
+ "@openpresentation/opf": "^0.20.5",
270
270
  "jsonc-parser": "3.3.1"
271
271
  },
272
272
  "peerDependencies": {
273
- "@openpresentation/opf-render": "^0.20.0"
273
+ "@openpresentation/opf-render": "^0.20.4"
274
274
  },
275
275
  "peerDependenciesMeta": {
276
276
  "@openpresentation/opf-render": {
@@ -323,8 +323,8 @@
323
323
  "@expo-google-fonts/roboto-mono": "0.4.2",
324
324
  "@expo-google-fonts/stix-two-math": "0.4.0",
325
325
  "@expo-google-fonts/tinos": "0.4.2",
326
- "@openpresentation/opf-pptx": "^0.20.2",
327
- "@openpresentation/opf-render": "^0.20.0",
326
+ "@openpresentation/opf-pptx": "^0.20.4",
327
+ "@openpresentation/opf-render": "^0.20.4",
328
328
  "@resvg/resvg-js": "2.6.2",
329
329
  "axe-core": "4.14.0",
330
330
  "esbuild": "0.28.2",