@openpresentation/opf-editor 0.26.9 → 0.27.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 CHANGED
@@ -2,6 +2,38 @@
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
+ ## Embed the editor (CE-06)
6
+
7
+ One call mounts the whole editor, over one session and one fonts handle: the source pane, the canvas, the slide row, Present, Export, the sorter, the outline, Layout Studio, Review, Find and autosave, each part optional.
8
+
9
+ ```js
10
+ import "@openpresentation/opf-editor/styles.css";
11
+ import { createOpfEditor } from "@openpresentation/opf-editor/workspace";
12
+
13
+ const editor = createOpfEditor(element, {
14
+ document, // an OPF object or its JSON text, or `session` to adopt one
15
+ catalogs: [gallery],
16
+ fonts, // the renderer's fonts handle, a lease { fonts, release }, a promise or a function of either
17
+ persistence: { key: "my-deck" }, // optional autosave and restore prompt
18
+ theme: { colorScheme: "auto", tokens: { accent: "hsl(262 60% 38%)" } },
19
+ onChange: ({ document }) => save(document),
20
+ });
21
+ await editor.ready;
22
+ editor.setView("outline");
23
+ editor.destroy();
24
+ ```
25
+
26
+ From React 18 or 19 (StrictMode-safe, renders an empty container on the server):
27
+
28
+ ```jsx
29
+ import { OpfEditor, useOpfDocument } from "@openpresentation/opf-editor/react";
30
+
31
+ <OpfEditor document={deck} fonts={loadFonts} catalogs={[gallery]} theme={theme} onEditor={setEditor} />;
32
+ const document = useOpfDocument(editor); // also useOpfSelection(editor), useOpfEditor(options)
33
+ ```
34
+
35
+ [docs/embedding.md](docs/embedding.md) lists the layouts, `panels`, `slots` (your own arrangement), every option, the handle, the events and the React bindings; [docs/theming.md](docs/theming.md) is the token contract (surface, text, accent, border, radius and font, light and dark) and the `theme` option. `examples/playground.js` is a thin user of it.
36
+
5
37
  ## Install the editor and optional export dependencies (RR-63)
6
38
 
7
39
  `@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:
@@ -356,6 +388,25 @@ editor.redo();
356
388
 
357
389
  `createSvgTraceBinding` only reads `data-opf-path`; it does not require a hosted service, a second renderer, storage, analytics, auth, or product chrome. Host applications decide how selected paths appear in their own inspector UI.
358
390
 
391
+ ### Ids, id-addressed events and `expectHash` (CE-04)
392
+
393
+ Slides and blocks can carry an `id` (core's `assignIds` fills the missing ones; `editor.assignIds()` does it as one undoable edit and returns `null` when none is missing). A path may name an element by id instead of by index, `slides.@intro.title` or `/slides/@intro/blocks/@kpi/text`, in `get`, `set` and `applyPatch`; the session resolves it against the open document.
394
+
395
+ By default the events are unchanged and carry index paths. Create the session with `idPaths: true` and every change and every `subscribe` event also carries the same patches addressed by id, each written against the document it applies to:
396
+
397
+ ```js
398
+ const editor = createEditorSession(doc, { idPaths: true });
399
+ editor.subscribe((event) => {
400
+ event.patches; // [{ op: "replace", path: "/slides/1/blocks/1/text", value: "Edited" }]
401
+ event.idPatches; // [{ op: "replace", path: "/slides/@kpis/blocks/@note/text", value: "Edited" }]
402
+ event.idInversePatches; // patch events; undo events have idPatches and idRedoPatches
403
+ });
404
+ ```
405
+
406
+ The id form still means the same thing on a replica whose slides are in another order, so a host can send it to a sync server or attach comments to it. Elements without an `id` keep their indexes, and an insertion keeps its index (or `-`) as the last token. It costs one extra walk of each patch.
407
+
408
+ `editor.documentHash()` is core's `documentHash` of the open document (`sha256:` and 64 hex digits, independent of key order). Pass it to `editor.applyPatch(ops, meta, { expectHash })` and the edit applies only while the document is still that one; otherwise it throws an `OPFEditorError` with code `patch-hash-mismatch` (`details.expected`, `details.actual`), changes nothing and emits nothing.
409
+
359
410
  ## Catalog Controls
360
411
 
361
412
  Catalog helpers commit catalog references (`id` or `name:id`) that resolve in the document or the session's registered catalogs, instead of accepting freeform incompatible values:
@@ -767,10 +818,11 @@ import "@openpresentation/opf-editor/styles.css";
767
818
 
768
819
  `pnpm run build` writes `dist/styles.css`: the token layer (`src/styles/tokens.css`) first, then every other `src/styles/*.css` in alphabetical order. It is plain CSS with no preprocessor, no `@import` and no `url()`: nothing is fetched. The package bundles no fonts either; load Inter and Geist Mono yourself (or leave the fallbacks: `system-ui` and `ui-monospace`). The panels that existed before it (design controls, review, slide manager, find, fill template, numbering, chart options, data grid, crop, the custom layout editor, the JSON editor and the restore banner) are styled with the tokens and nothing else. The find, review, crop and data-grid components still inject a small fallback stylesheet of their own, so they look as before when you do not import `styles.css`; with it, its rules win whichever `<style>` comes last.
769
820
 
770
- **Tokens.** Every rule reads only `--opf-ui-*` custom properties, defined on `:root` and on `.opf-ui`, light by default. Override any of them on `:root`, on a wrapper with the class `opf-ui`, or on one component's container:
821
+ **Tokens.** Every rule reads only `--opf-ui-*` custom properties, defined on `:root` and on `.opf-ui`, light by default. The whole contract, with light and dark values and what each token is for, is in [docs/theming.md](docs/theming.md). `createOpfEditor({ theme })` sets them for you; by hand, set them on the `.opf-ui` elements themselves (every component root carries the class and declares its own defaults, so a value set on a wrapper does not reach it) with a selector that outranks `.dark .opf-ui`:
771
822
 
772
823
  ```css
773
- .my-editor.opf-ui {
824
+ html .opf-ui,
825
+ html.dark .opf-ui {
774
826
  --opf-ui-accent: hsl(262 60% 38%);
775
827
  --opf-ui-accent-strong: hsl(262 60% 38%);
776
828
  --opf-ui-ring: hsl(262 60% 38%);
@@ -792,7 +844,7 @@ import "@openpresentation/opf-editor/styles.css";
792
844
 
793
845
  Put the class `opf-ui` on an element that wraps a mounted component to get the base rules: the font, the text color, `box-sizing: border-box`, a 2px `--opf-ui-ring` outline with a 2px offset on `:focus-visible`, and no transitions under `prefers-reduced-motion: reduce`.
794
846
 
795
- **Dark mode.** The dark values apply under `.dark .opf-ui`, `.opf-ui.dark`, `[data-theme="dark"]` (on the root or any ancestor) and `:root.dark`. The host decides when: toggle `class="dark"` or `data-theme="dark"` on `<html>`, or on the wrapper. The stylesheet does not read `prefers-color-scheme`, so a host that wants to follow the system setting sets the attribute from `matchMedia("(prefers-color-scheme: dark)")`. Dark also sets `color-scheme: dark`, so native controls follow.
847
+ **Dark mode.** The dark values apply under `.dark .opf-ui`, `.opf-ui.dark`, `[data-theme="dark"]` (on the root or any ancestor) and `:root.dark`; `[data-theme="light"]` on an ancestor or on the surface forces light inside a dark page. The host decides when: toggle `class="dark"` or `data-theme="dark"` on `<html>`, or on the wrapper. The stylesheet does not read `prefers-color-scheme`; `createOpfEditor({ theme: { colorScheme: "auto" } })` follows it for you, and a host that mounts components itself sets the attribute from `matchMedia("(prefers-color-scheme: dark)")`. Dark also sets `color-scheme: dark`, so native controls follow.
796
848
 
797
849
  **Touch.** `data-density="touch"` on an `.opf-ui` element raises `--opf-ui-control-h` from 32px to 40px for everything inside it (the mobile design). The new components take the same value as a `density: "comfortable" | "touch"` option.
798
850
 
@@ -973,7 +1025,9 @@ Put the opened document in the session with `editor.replaceDocument(document)` (
973
1025
 
974
1026
  ## Optional React Bindings
975
1027
 
976
- React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
1028
+ React bindings are isolated under `@openpresentation/opf-editor/react`; React (18 or later) is an optional peer, needed only by that entry point. The core package does not add React to the critical path.
1029
+
1030
+ `<OpfEditor>`, `useOpfEditor`, `useOpfDocument` and `useOpfSelection` mount and read the embeddable editor ([Embed the editor](#embed-the-editor-ce-06)). The older factory below builds two controls over a session you already have, with the React you pass:
977
1031
 
978
1032
  ```jsx
979
1033
  import { createOPFReactComponents } from "@openpresentation/opf-editor/react";
@@ -0,0 +1,15 @@
1
+ import type { EditorSession } from "./index.js";
2
+ import type { CanvasEditor } from "./canvas.js";
3
+
4
+ /**
5
+ * For a host that mounts the canvas itself (`createCanvasEditor`) and draws `{{variables}}` resolved (`renderOptions.variables: {}`): a press on text
6
+ * that holds a variable token opens the field's properties form (which edits the authored value) instead of an inline edit of the resolved glyphs,
7
+ * and a press on text with no field of its own calls `focusSource`. `resolved()` says whether the canvas draws variables resolved now (a template deck
8
+ * draws them as authored). Returns a function that removes the listeners. `createOpfEditor({ variables: "resolved" })` (`@openpresentation/opf-editor/workspace`) does this for you.
9
+ */
10
+ export declare function installCanvasVariableEditing(
11
+ host: HTMLElement,
12
+ editor: EditorSession,
13
+ canvas: CanvasEditor,
14
+ options?: { resolved?: () => boolean; focusSource?: () => void },
15
+ ): () => void;
@@ -0,0 +1,52 @@
1
+ // CE-06 (promoted from openpresentation.org's playground runtime): editing a document whose `{{variables}}` the canvas draws resolved.
2
+ // With `variables: "resolved"` the canvas shows a deck's variables filled in, and a glyph offset of the resolved text does not address
3
+ // the authored `{{token}}`. A press on such text opens the field's properties form (which edits the authored value) instead of an
4
+ // inline text edit, and a press on text that has no field of its own focuses the source pane.
5
+
6
+ function hasSourceToken(value) {
7
+ if (typeof value === "string") return value.includes("{{") || value.startsWith("var:");
8
+ if (Array.isArray(value)) return value.some(hasSourceToken);
9
+ return value !== null && typeof value === "object" && Object.values(value).some(hasSourceToken);
10
+ }
11
+
12
+ /**
13
+ * Route inline edits of text that holds a variable token to the canvas's properties form. `resolved()` says whether the canvas currently
14
+ * draws variables resolved (a template deck draws them as authored). Returns a function that removes the listeners.
15
+ */
16
+ export function installCanvasVariableEditing(host, editor, canvas, { resolved = () => true, focusSource } = {}) {
17
+ function route(event) {
18
+ if (!resolved() || !(event.target instanceof Element)) return;
19
+ const target = event.target.closest('svg [data-opf-path][role="button"]');
20
+ if (!target || !host.contains(target) || !target.querySelector("text")) return;
21
+ const path = target.getAttribute("data-opf-path");
22
+ const value = editor.get(path);
23
+ const inline = ((typeof value === "string" || typeof value === "number") && !/\.(image|video)$/.test(path))
24
+ || (Array.isArray(value) && target.hasAttribute("data-opf-rich-text"));
25
+ if (value !== undefined && (!inline || !hasSourceToken(value))) return;
26
+ event.preventDefault();
27
+ event.stopImmediatePropagation();
28
+ if (value === undefined) {
29
+ focusSource?.();
30
+ return;
31
+ }
32
+ // The click following pointerdown must not commit and reopen the same form.
33
+ if (canvas.editingPath !== path) canvas.editProperties(path);
34
+ }
35
+ function pointer(event) {
36
+ // A touch press may begin scrolling. Its actual click opens the form.
37
+ if (event.pointerType !== "touch" && event.button === 0) route(event);
38
+ }
39
+ function key(event) {
40
+ if (!event.isComposing && event.keyCode !== 229 && ["Enter", " ", "F2"].includes(event.key)) route(event);
41
+ }
42
+ host.addEventListener("pointerdown", pointer, true);
43
+ host.addEventListener("click", route, true);
44
+ host.addEventListener("dblclick", route, true);
45
+ host.addEventListener("keydown", key, true);
46
+ return () => {
47
+ host.removeEventListener("pointerdown", pointer, true);
48
+ host.removeEventListener("click", route, true);
49
+ host.removeEventListener("dblclick", route, true);
50
+ host.removeEventListener("keydown", key, true);
51
+ };
52
+ }
package/dist/canvas.js CHANGED
@@ -113,7 +113,7 @@ export function createCanvasEditor(container, options = {}) {
113
113
  notice.hidden = true;
114
114
  const style = doc.createElement("style");
115
115
  style.textContent =
116
- ".opf-canvas-preview>svg{display:block;width:100%;height:auto}.opf-canvas [data-canvas-target]{cursor:text}.opf-canvas .opf-inline-input::selection{background:#8975d933;color:var(--opf-inline-selection-color,transparent)}.opf-canvas [data-canvas-target]:focus{outline:none}.opf-canvas [data-canvas-target]:hover>.opf-selection{stroke:#9c90df}.opf-canvas [data-canvas-selected]>.opf-selection,.opf-canvas [data-canvas-target]:focus-visible>.opf-selection{stroke:#7765d0}.opf-canvas [data-canvas-target][data-canvas-editing]>.opf-selection{stroke:none}.opf-canvas button:focus-visible,.opf-canvas textarea:focus-visible,.opf-canvas input:focus-visible{outline:2px solid #8170d5;outline-offset:2px}";
116
+ ".opf-canvas-preview>svg{display:block;width:100%;height:auto}.opf-canvas [data-canvas-target]{cursor:text}.opf-canvas .opf-inline-input::selection{background:#8975d933;color:var(--opf-inline-selection-color,transparent)}.opf-canvas [data-canvas-target]:focus,.opf-canvas .opf-selection:focus{outline:none}.opf-canvas [data-canvas-target]:hover>.opf-selection{stroke:#9c90df}.opf-canvas [data-canvas-selected]>.opf-selection,.opf-canvas [data-canvas-target]:focus-visible>.opf-selection,.opf-canvas .opf-selection:focus-visible{stroke:#7765d0}.opf-canvas [data-canvas-target][data-canvas-editing]>.opf-selection{stroke:none}.opf-canvas button:focus-visible,.opf-canvas textarea:focus-visible,.opf-canvas input:focus-visible{outline:2px solid #8170d5;outline-offset:2px}";
117
117
  root.append(style, preview, overlay, notice);
118
118
  container.replaceChildren(root);
119
119
  const report = (error) => {
@@ -167,6 +167,14 @@ export function createCanvasEditor(container, options = {}) {
167
167
  (node) => node.getAttribute("data-opf-path") === path,
168
168
  );
169
169
  }
170
+ // A target that holds other targets (a table with its cells, a chart with its labels) is a plain group, so it is never focusable
171
+ // itself (axe nested-interactive); keyboard focus for it lives on its selection handle, the first child. A leaf target takes focus.
172
+ function handleOf(node) {
173
+ return node?.getAttribute("role") === "group" ? node.querySelector(":scope > .opf-selection[tabindex]") : null;
174
+ }
175
+ function focusTarget(node, options = { preventScroll: false }) {
176
+ (handleOf(node) ?? node)?.focus?.(options);
177
+ }
170
178
  function choose(path) {
171
179
  selectedPath = path;
172
180
  imageCropper?.sync(path);
@@ -350,7 +358,12 @@ export function createCanvasEditor(container, options = {}) {
350
358
  // A touch tap has no pointerdown entry (a touch press may start a scroll): it enters on the click itself.
351
359
  const kind = editKind(path);
352
360
  if (textEntry === "click" && event.detail > 0 && (kind === "text" || kind === "rich-text")) startEdit(path, { point: event });
353
- else choose(path);
361
+ else {
362
+ choose(path);
363
+ // A press on a container's own drawing (not on a child target) used to focus the container; now it focuses its handle.
364
+ const handle = handleOf(node);
365
+ if (handle && !event.target.closest?.(".opf-selection")) handle.focus({ preventScroll: true });
366
+ }
354
367
  });
355
368
  node.addEventListener("dblclick", (event) => {
356
369
  event.preventDefault();
@@ -368,6 +381,21 @@ export function createCanvasEditor(container, options = {}) {
368
381
  });
369
382
  if (path === selectedPath) node.setAttribute("data-canvas-selected", "");
370
383
  }
384
+ // WCAG 4.1.2 / axe nested-interactive: an interactive element must not contain interactive descendants. A target that holds
385
+ // other targets is a named group; the button and the tab stop move to its selection handle (the transparent rect that already
386
+ // covers it and takes its pointer presses), so the container stays selectable by click, Tab and Enter, and its children stay buttons.
387
+ for (const node of svg.querySelectorAll("g[data-canvas-target]")) {
388
+ if (!node.querySelector("[data-canvas-target]")) continue;
389
+ const handle = node.querySelector(":scope > .opf-selection");
390
+ if (!handle) continue;
391
+ const field = node.getAttribute("data-opf-path").split(".").at(-1);
392
+ handle.setAttribute("role", "button");
393
+ handle.setAttribute("tabindex", "0");
394
+ handle.setAttribute("aria-label", node.getAttribute("aria-label"));
395
+ node.setAttribute("role", "group");
396
+ node.removeAttribute("tabindex");
397
+ node.setAttribute("aria-label", `${field.charAt(0).toUpperCase()}${field.slice(1)}`);
398
+ }
371
399
  layoutHandles.update(presentation, geometry);
372
400
  blockControls.update(presentation, geometry);
373
401
  imageCropper?.update();
@@ -924,7 +952,7 @@ export function createCanvasEditor(container, options = {}) {
924
952
  options.propertiesContainer?.replaceChildren();
925
953
  clearNotice();
926
954
  render();
927
- getTarget(edit.path)?.focus();
955
+ focusTarget(getTarget(edit.path));
928
956
  options.onCommit?.({ path: edit.path, editor });
929
957
  return true;
930
958
  } catch (error) {
@@ -949,7 +977,7 @@ export function createCanvasEditor(container, options = {}) {
949
977
  options.propertiesContainer?.replaceChildren();
950
978
  if (clear) clearNotice();
951
979
  render();
952
- getTarget(path)?.focus();
980
+ focusTarget(getTarget(path));
953
981
  options.onCancel?.({ path });
954
982
  }
955
983
  function setSlide(index) {
@@ -984,7 +1012,7 @@ export function createCanvasEditor(container, options = {}) {
984
1012
  if (!node) continue;
985
1013
  choose(candidate);
986
1014
  node.scrollIntoView?.({ block: "nearest", inline: "nearest" });
987
- if (focus) node.focus?.({ preventScroll: true });
1015
+ if (focus) focusTarget(node, { preventScroll: true });
988
1016
  return candidate;
989
1017
  }
990
1018
  return null;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { PaginationOptions, PaginationResult } from "@openpresentation/opf/pagination";
2
2
  import type { Composition, ComposeSlideOptions, SlideComposition } from "@openpresentation/opf/composition";
3
3
  import type { Catalog, Fonts, PresentationStats, SlideContextDiagnostic, StatsOptions, ValidationReport } from "@openpresentation/opf";
4
+ import type { AssignIdsOptions } from "@openpresentation/opf/ids";
4
5
  export * from "./catalogs.js";
5
6
  /**
6
7
  * What the editor adds to the options of `composeSlide` and `paginateSlide`: `fonts` is the renderer's fonts handle (`loadFonts()`), whose
@@ -31,7 +32,10 @@ export declare const runtimePolicy: Readonly<{
31
32
  deterministicLocalExecution: true;
32
33
  }>;
33
34
 
34
- /** RFC 6902 operations, executed by core's `@openpresentation/opf/patch`. Paths are JSON Pointers or dotted OPF paths. */
35
+ /**
36
+ * RFC 6902 operations, executed by core's `@openpresentation/opf/patch`. Paths are JSON Pointers or dotted OPF paths. In an array, a
37
+ * token `@id` names the element whose `id` is `id` (`/slides/@intro/title`, or `slides.@intro.title` as an OPF path).
38
+ */
35
39
  export type JsonPatchOperation =
36
40
  | { op: "add"; path: string; value: unknown }
37
41
  | { op: "replace"; path: string; value: unknown }
@@ -54,6 +58,14 @@ export interface EditorChange {
54
58
  patches: JsonPatchOperation[];
55
59
  inversePatches?: JsonPatchOperation[];
56
60
  redoPatches?: JsonPatchOperation[];
61
+ /**
62
+ * With the session option `idPaths`: `patches`, `inversePatches` and `redoPatches` addressed by id (`/slides/@intro/title`), each
63
+ * written against the document it applies to (core's `toIdPatch`). Elements without an `id` keep their indexes; an insertion keeps
64
+ * its index (or `-`) as the last token.
65
+ */
66
+ idPatches?: JsonPatchOperation[];
67
+ idInversePatches?: JsonPatchOperation[];
68
+ idRedoPatches?: JsonPatchOperation[];
57
69
  validation: ValidationReport;
58
70
  }
59
71
 
@@ -63,6 +75,10 @@ export interface EditorEvent {
63
75
  patches: JsonPatchOperation[];
64
76
  inversePatches?: JsonPatchOperation[];
65
77
  redoPatches?: JsonPatchOperation[];
78
+ /** With the session option `idPaths`: the id-addressed form of `patches`, `inversePatches` and `redoPatches` (see `EditorChange`). `[]` for `restore` and `catalogs`. */
79
+ idPatches?: JsonPatchOperation[];
80
+ idInversePatches?: JsonPatchOperation[];
81
+ idRedoPatches?: JsonPatchOperation[];
66
82
  validation: ValidationReport;
67
83
  meta?: Record<string, unknown>;
68
84
  snapshot: EditorSnapshot;
@@ -79,6 +95,12 @@ export interface PrecomputedValidation {
79
95
  }
80
96
  export interface ApplyPatchOptions {
81
97
  validated?: PrecomputedValidation;
98
+ /**
99
+ * Apply the edit only while the open document is the one this hash (`session.documentHash()` or core's `documentHash`) was taken
100
+ * from. A document that changed since throws an `OPFEditorError` with code `patch-hash-mismatch` and `details.expected` and
101
+ * `details.actual`; nothing is applied. A value that is not a hash throws `invalid-expect-hash`.
102
+ */
103
+ expectHash?: string;
82
104
  }
83
105
 
84
106
  export interface EditorSession {
@@ -107,6 +129,14 @@ export interface EditorSession {
107
129
  * current `catalogs` list (the same object); otherwise the session validates the document itself. It is never stored in `meta` or the history.
108
130
  */
109
131
  applyPatch(operations: JsonPatchOperation[], meta?: Record<string, unknown>, options?: ApplyPatchOptions): EditorChange;
132
+ /** The content hash of the open document (core's `documentHash`: `sha256:` and 64 hex digits, independent of key order). Use it as `applyPatch`'s `expectHash`. */
133
+ documentHash(): string;
134
+ /**
135
+ * Give every slide and block without an `id` one (core's `assignIds`) as one undoable edit. Returns the change, or `null` when every
136
+ * slide and block already has an id. `meta.source` defaults to `"assign-ids"`. A `generate` that returns an unusable id throws an
137
+ * `OPFEditorError` with code `invalid-id` or `duplicate-id`.
138
+ */
139
+ assignIds(options?: AssignIdsOptions, meta?: Record<string, unknown>): EditorChange | null;
110
140
  /**
111
141
  * Open a different document in the session: the whole document is replaced as one undoable change, and the replacement is never
112
142
  * read as an edit of the catalog records the two documents share, so nothing under `catalogs.default` or a named group is forked
@@ -128,6 +158,12 @@ export interface CreateEditorSessionOptions {
128
158
  rejectInvalid?: boolean;
129
159
  /** The host's catalogs (for example `[gallery]` from `@openpresentation/gallery`): resolution, validation, pickers, embedding. */
130
160
  catalogs?: readonly Catalog[];
161
+ /**
162
+ * Also report every change and event by id: `idPatches`, `idInversePatches` and `idRedoPatches` next to `patches`,
163
+ * `inversePatches` and `redoPatches`, so a host can sync, comment or merge by `/slides/@intro/blocks/@kpi/text`
164
+ * instead of by indexes that move. Default false (the events are unchanged); it costs one extra walk of each patch.
165
+ */
166
+ idPaths?: boolean;
131
167
  }
132
168
 
133
169
  export interface CatalogOption {
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { commitCatalogControl } from "./catalog-control.js";
2
2
  import { paginateSlide } from "@openpresentation/opf/pagination";
3
3
  import { OPFPatchError, applyPatch as applyCorePatch, applyPatchWithInverse, formatPointer, invertPatch, jsonEqual, parsePointer, readPointer } from "@openpresentation/opf/patch";
4
+ import { OPFIdError, assignIds, documentHash, toIdPatch } from "@openpresentation/opf/ids";
4
5
  import { collectReservedPresentationIds } from "./presentation-ids.js";
5
6
  import { composeSlide } from "@openpresentation/opf/composition";
6
7
  import { catalogKinds, resolveSlideContext, stats } from "@openpresentation/opf";
@@ -157,6 +158,18 @@ export function createEditorSession(input, options = {}) {
157
158
  const redoStack = [];
158
159
  const listeners = new Set();
159
160
  const rejectInvalid = Boolean(options.rejectInvalid);
161
+ // CE-04: with `idPaths`, every event and change also carries the same patches addressed by id (`/slides/@intro/title`), which stay
162
+ // true when slides and blocks move, for hosts that sync or comment by id. Each is written against the document it applies to.
163
+ // It costs one walk of each patch over a copy of the document, so it is off by default.
164
+ const idPaths = Boolean(options.idPaths);
165
+ const idForms = (fields) => {
166
+ if (!idPaths) return {};
167
+ const out = {};
168
+ if (fields.patches) out.idPatches = toIdPatch(fields.patches[0], fields.patches[1]);
169
+ if (fields.inversePatches) out.idInversePatches = toIdPatch(fields.inversePatches[0], fields.inversePatches[1]);
170
+ if (fields.redoPatches) out.idRedoPatches = toIdPatch(fields.redoPatches[0], fields.redoPatches[1]);
171
+ return out;
172
+ };
160
173
  let snapshotCache;
161
174
 
162
175
  function emit(event) {
@@ -178,7 +191,8 @@ export function createEditorSession(input, options = {}) {
178
191
  const before = presentation;
179
192
  let next, inversePatches;
180
193
  try {
181
- ({ presentation: next, inverse: inversePatches } = applyPatchWithInverse(before, patches));
194
+ // `expectHash` (CE-04) is the precondition of this edit: the session's document must still be the one the caller hashed.
195
+ ({ presentation: next, inverse: inversePatches } = applyPatchWithInverse(before, patches, applyOptions?.expectHash === undefined ? {} : { expectHash: applyOptions.expectHash }));
182
196
  } catch (error) {
183
197
  throw editorPatchError(error);
184
198
  }
@@ -215,10 +229,12 @@ export function createEditorSession(input, options = {}) {
215
229
  validation = nextValidation;
216
230
  undoStack.push({ patches: clonePatchOperations(patches), inversePatches, meta });
217
231
  redoStack.length = 0;
232
+ const ids = idForms({ patches: [before, patches], inversePatches: [next, inversePatches] });
218
233
  emit({
219
234
  type: "patch",
220
235
  patches: clonePatchOperations(patches),
221
236
  inversePatches: clonePatchOperations(inversePatches),
237
+ ...ids,
222
238
  validation,
223
239
  meta
224
240
  });
@@ -227,6 +243,7 @@ export function createEditorSession(input, options = {}) {
227
243
  presentation: clone(presentation),
228
244
  patches: clonePatchOperations(patches),
229
245
  inversePatches: clonePatchOperations(inversePatches),
246
+ ...ids,
230
247
  validation,
231
248
  ...(forks.length ? { forked: forks, notice: meta.notice } : {})
232
249
  };
@@ -250,7 +267,7 @@ export function createEditorSession(input, options = {}) {
250
267
  setCatalogs(next, meta = {}) {
251
268
  catalogs = mergeCatalogs(next);
252
269
  validation = check(presentation);
253
- emit({ type: "catalogs", patches: [], validation, meta });
270
+ emit({ type: "catalogs", patches: [], ...(idPaths ? { idPatches: [] } : {}), validation, meta });
254
271
  return catalogs;
255
272
  },
256
273
  get canUndo() {
@@ -322,6 +339,27 @@ export function createEditorSession(input, options = {}) {
322
339
  return setCatalogId(editor, path, catalogKind, id, meta);
323
340
  },
324
341
  applyPatch: commitPatch,
342
+ /**
343
+ * The content hash of the open document (core's `documentHash`): pass it as `applyPatch(ops, meta, { expectHash })` to apply an edit
344
+ * only while the document is still the one the hash was taken from. Reads the session's document without copying it.
345
+ */
346
+ documentHash() {
347
+ return documentHash(presentation);
348
+ },
349
+ /**
350
+ * Give every slide and block without an `id` one (core's `assignIds`) as ONE undoable edit, and return the change, or `null` when
351
+ * every slide and block already has an id. `options` are core's (`generate`); `meta.source` defaults to `"assign-ids"`.
352
+ */
353
+ assignIds(assignOptions = {}, meta = {}) {
354
+ let result;
355
+ try {
356
+ result = assignIds(presentation, assignOptions);
357
+ } catch (error) {
358
+ throw error instanceof OPFIdError ? new OPFEditorError(error.code, error.message, { path: error.path }) : error;
359
+ }
360
+ if (!result.patch.length) return null;
361
+ return commitPatch(result.patch, { ...meta, source: meta.source ?? "assign-ids" });
362
+ },
325
363
  /**
326
364
  * Open a different document in the session (a host's example menu, File > Open, "start over"): the whole document is replaced as ONE
327
365
  * undoable change (Undo restores the previous document, Redo the new one). Unlike an `applyPatch` that replaces the root, this is
@@ -386,19 +424,22 @@ export function createEditorSession(input, options = {}) {
386
424
  redoStack.length = 0;
387
425
  undoStack.push(...undo.map(entry));
388
426
  redoStack.push(...redo.map(entry));
389
- emit({ type: "restore", patches: [], validation, meta });
427
+ emit({ type: "restore", patches: [], ...(idPaths ? { idPatches: [] } : {}), validation, meta });
390
428
  return { presentation: clone(presentation), patches: [], validation };
391
429
  },
392
430
  undo(meta = {}) {
393
431
  const entry = undoStack.pop();
394
432
  if (!entry) return null;
433
+ const before = presentation;
395
434
  presentation = applyJsonPatch(presentation, entry.inversePatches);
396
435
  validation = check(presentation);
397
436
  redoStack.push(entry);
437
+ const ids = idForms({ patches: [before, entry.inversePatches], redoPatches: [presentation, entry.patches] });
398
438
  emit({
399
439
  type: "undo",
400
440
  patches: clonePatchOperations(entry.inversePatches),
401
441
  redoPatches: clonePatchOperations(entry.patches),
442
+ ...ids,
402
443
  validation,
403
444
  meta
404
445
  });
@@ -406,19 +447,23 @@ export function createEditorSession(input, options = {}) {
406
447
  presentation: clone(presentation),
407
448
  patches: clonePatchOperations(entry.inversePatches),
408
449
  redoPatches: clonePatchOperations(entry.patches),
450
+ ...ids,
409
451
  validation
410
452
  };
411
453
  },
412
454
  redo(meta = {}) {
413
455
  const entry = redoStack.pop();
414
456
  if (!entry) return null;
457
+ const before = presentation;
415
458
  presentation = applyJsonPatch(presentation, entry.patches);
416
459
  validation = check(presentation);
417
460
  undoStack.push(entry);
461
+ const ids = idForms({ patches: [before, entry.patches], inversePatches: [presentation, entry.inversePatches] });
418
462
  emit({
419
463
  type: "redo",
420
464
  patches: clonePatchOperations(entry.patches),
421
465
  inversePatches: clonePatchOperations(entry.inversePatches),
466
+ ...ids,
422
467
  validation,
423
468
  meta
424
469
  });
@@ -426,6 +471,7 @@ export function createEditorSession(input, options = {}) {
426
471
  presentation: clone(presentation),
427
472
  patches: clonePatchOperations(entry.patches),
428
473
  inversePatches: clonePatchOperations(entry.inversePatches),
474
+ ...ids,
429
475
  validation
430
476
  };
431
477
  }
@@ -725,6 +771,7 @@ function editorPatchError(error) {
725
771
  if (error.operation !== undefined) details.operation = error.operation;
726
772
  if (error.index !== undefined) details.index = error.index;
727
773
  if (error.validation) details.issues = errorFindings(error.validation);
774
+ if (error.code === "patch-hash-mismatch") Object.assign(details, { expected: error.expected, actual: error.actual });
728
775
  return new OPFEditorError(error.code, error.message, details);
729
776
  }
730
777
 
package/dist/presenter.js CHANGED
@@ -19,25 +19,40 @@ const CLICK_GUARD_MS = 450;
19
19
  const NEXT_KEYS = new Set(["ArrowRight", " ", "PageDown", "Enter"]);
20
20
  const PREVIOUS_KEYS = new Set(["ArrowLeft", "PageUp", "Backspace"]);
21
21
 
22
- // Full screen belongs to the document, not to one presenter: the browser ends whatever is full screen when an exit it was asked for runs, and tells
23
- // every listener. A presenter that closed early asks for its exit late (when its pending full screen request resolves), possibly after a newer
24
- // presenter took full screen. So the exits the presenters asked for are counted per document, and any presenter reads a `fullscreenchange` that
25
- // answers one of them as ours, not as the user leaving full screen.
26
- const exitRequests = new WeakMap(); // Document -> Set of exits asked for and not yet reported
22
+ // Full screen belongs to the document, not to one presenter, and the browser answers a request later than it was made. Two things follow, and
23
+ // both are kept per document so that presenters (any number can be mounted) see the same facts.
24
+ // 1. An exit a presenter asked for can run after a newer presenter took full screen, and the browser then ends that one's full screen too.
25
+ // A presenter whose request resolved after it was closed therefore leaves full screen only when no newer request is in flight (that request
26
+ // replaces it); otherwise it marks its element abandoned, and the newer presenter gives the screen back if its own request does not get it.
27
+ // 2. The `fullscreenchange` an exit answers must not read as the user leaving. The exits presenters asked for are listed until their event is
28
+ // seen and the exit has settled, in whichever order those two happen. An event that finds the screen taken by an element drops the exits that
29
+ // already settled, since events arrive in the order of the changes: a later exit event is the user's, however soon it follows.
30
+ const documentStates = new WeakMap(); // Document -> { requests (counted), inFlight (their numbers, until answered), exits, abandoned (elements) }
27
31
  const attributedEvents = new WeakMap(); // fullscreenchange Event -> whether it answered an exit a presenter asked for
28
- function noteExit(doc, win) {
29
- let requests = exitRequests.get(doc);
30
- if (!requests) exitRequests.set(doc, requests = new Set());
31
- const request = {};
32
- requests.add(request);
33
- // The browser reports an exit with the event; when it does not (the exit was refused, or two exits became one event), stop counting it soon.
34
- return { forget: () => requests.delete(request), forgetSoon: () => win.setTimeout(() => requests.delete(request), 500) };
32
+ function stateOf(doc) {
33
+ let state = documentStates.get(doc);
34
+ if (!state) documentStates.set(doc, state = { requests: 0, inFlight: new Set(), exits: new Set(), abandoned: new WeakSet() });
35
+ return state;
36
+ }
37
+ function noteExit(doc) {
38
+ const { exits } = stateOf(doc);
39
+ const exit = { settled: false, seen: false };
40
+ exits.add(exit);
41
+ return {
42
+ // The browser reports an exit with an event; when none came (the exit was refused, or two exits became one event) it is not waited for.
43
+ settle: (done) => { if (!done || exit.seen) exits.delete(exit); else exit.settled = true; },
44
+ };
35
45
  }
36
46
  function answersAnExit(doc, event) {
37
47
  if (attributedEvents.has(event)) return attributedEvents.get(event);
38
- const requests = exitRequests.get(doc);
39
- const ours = Boolean(requests?.size) && !doc.fullscreenElement;
40
- if (ours) requests.delete(requests.values().next().value);
48
+ const { exits } = stateOf(doc);
49
+ let ours = false;
50
+ if (doc.fullscreenElement) {
51
+ for (const exit of exits) if (exit.settled) exits.delete(exit);
52
+ } else {
53
+ const exit = [...exits].find((candidate) => !candidate.seen);
54
+ if (exit) { ours = true; if (exit.settled) exits.delete(exit); else exit.seen = true; }
55
+ }
41
56
  attributedEvents.set(event, ours);
42
57
  return ours;
43
58
  }
@@ -92,7 +107,7 @@ export function createPresenter(container, options = {}) {
92
107
  // Drawn slides by deck index, for the deck object they were drawn from: a new snapshot (an edit) or a new start() draws again.
93
108
  let drawn = new Map(), drawnFor;
94
109
 
95
- let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, returnTo = null;
110
+ let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, returnTo = null, pendingRequests = 0;
96
111
  let counterTimer, preloadTimer, renderToken = 0, swipe = null, clickGuardUntil = 0;
97
112
 
98
113
  // ---- the DOM ----------------------------------------------------------------------------------------------------------------------------
@@ -216,36 +231,57 @@ export function createPresenter(container, options = {}) {
216
231
  function enterFullscreen() {
217
232
  const request = root.requestFullscreen ?? root.webkitRequestFullscreen;
218
233
  if (typeof request !== "function" || doc.fullscreenEnabled === false) return Promise.resolve("overlay");
234
+ const state = stateOf(doc);
235
+ const ticket = ++state.requests;
236
+ state.inFlight.add(ticket);
237
+ pendingRequests++;
238
+ state.abandoned.delete(root);
239
+ const answered = () => { state.inFlight.delete(ticket); pendingRequests--; };
219
240
  let attempt;
220
241
  try { attempt = Promise.resolve(request.call(root, { navigationUI: "hide" })); } catch (error) { attempt = Promise.reject(error); }
221
242
  return attempt.then(
222
243
  () => {
223
- if (doc.fullscreenElement !== root) return "overlay";
224
- // stop() ran while the request was pending (a quick Esc or close), so it saw no full screen to leave: give the screen back now, or the
225
- // page stays full screen behind a hidden presenter.
226
- if (!active) { leaveFullscreen(); return "overlay"; }
244
+ answered();
245
+ if (doc.fullscreenElement !== root) return reclaimAbandoned();
246
+ if (!active) {
247
+ // stop() ran while the request was pending (a quick Esc or close), so it saw no full screen to leave: give the screen back now, or the
248
+ // page stays full screen behind a hidden presenter. Not when a newer request is still in flight: it replaces this element, and an exit asked
249
+ // for now would run after it and end the newer presenter's full screen.
250
+ if ([...state.inFlight].some((newer) => newer > ticket)) state.abandoned.add(root); else leaveFullscreen();
251
+ return "overlay";
252
+ }
227
253
  enteredFullscreen = true;
228
254
  root.dataset.mode = "fullscreen";
229
255
  return "fullscreen";
230
256
  },
231
- () => "overlay",
257
+ () => { answered(); return reclaimAbandoned(); },
232
258
  );
233
259
  }
260
+ // This presenter did not get the screen (refused, or taken by another element): if an earlier, closed presenter's element still holds it
261
+ // because that presenter left the exit to this request, give it back.
262
+ function reclaimAbandoned() {
263
+ const holder = doc.fullscreenElement, { abandoned } = stateOf(doc);
264
+ if (holder && holder !== root && abandoned.has(holder)) { abandoned.delete(holder); leaveFullscreen(); } else if (!active) restoreFocus();
265
+ return "overlay";
266
+ }
234
267
  // Ask the browser to leave full screen; onFullscreenChange then sees that the event answers an exit asked for (by this presenter or an earlier
235
- // one) and does not read it as the user's. Focus goes back to the element that opened the presentation once the exit is done, because the page
236
- // behind a full screen element takes no focus until then.
268
+ // one) and does not read it as the user's. Focus goes back to the element that opened the presentation once the exit is done (or refused: the
269
+ // screen was already gone), because the page behind a full screen element takes no focus until then.
237
270
  function leaveFullscreen() {
238
- const exit = noteExit(doc, win);
239
- Promise.resolve(doc.exitFullscreen?.()).then(() => { exit.forgetSoon(); restoreFocus(); }, exit.forget);
271
+ const exit = noteExit(doc);
272
+ Promise.resolve(doc.exitFullscreen?.()).then(() => { exit.settle(true); restoreFocus(); }, () => { exit.settle(false); restoreFocus(); });
240
273
  }
241
- // Only when nobody took focus since: the host may move it in `onExit`.
274
+ // Only when nobody took focus since: the host may move it in `onExit`. While the screen is still full screen the page behind takes no focus, and
275
+ // a browser reports the end of an exit's promise before the end of full screen; so the element is kept until the `fullscreenchange` that ends it (and while a request of this presenter is unanswered: it may still take the screen).
242
276
  function restoreFocus() {
243
277
  const now = doc.activeElement;
244
278
  if (returnTo?.isConnected && typeof returnTo.focus === "function" && (!now || now === doc.body || now === root)) returnTo.focus({ preventScroll: true });
279
+ if (!doc.fullscreenElement && !pendingRequests) returnTo = null;
245
280
  }
246
281
  // The browser takes full screen away with Esc (that key never reaches the page) or the host exits it: the presentation ends with it.
247
282
  function onFullscreenChange(event) {
248
283
  if (doc.fullscreenElement === root) return;
284
+ if (!active && !doc.fullscreenElement) restoreFocus();
249
285
  if (answersAnExit(doc, event)) {
250
286
  // An exit that a presenter asked for finished: ours from stop(), or an earlier presenter's that ran after this one took full screen. A
251
287
  // presentation still running carries on as the overlay.
@@ -321,6 +357,7 @@ export function createPresenter(container, options = {}) {
321
357
  const wanted = slideIndex ?? options.slideIndex ?? 0;
322
358
  if (active) { show(wanted); return Promise.resolve(root.dataset.mode); }
323
359
  active = true;
360
+ returnTo = null;
324
361
  opener = doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : null;
325
362
  current = -1;
326
363
  drawn = new Map();