@openpresentation/opf-editor 0.14.2 → 0.16.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.
Files changed (50) hide show
  1. package/README.md +93 -35
  2. package/dist/assets.js +1 -1
  3. package/dist/background-options.d.ts +10 -3
  4. package/dist/background-options.js +38 -13
  5. package/dist/canvas.d.ts +8 -1
  6. package/dist/canvas.js +25 -10
  7. package/dist/catalog-control.js +21 -0
  8. package/dist/catalogs.d.ts +48 -0
  9. package/dist/catalogs.js +217 -0
  10. package/dist/checks.js +9 -7
  11. package/dist/content-actions.d.ts +2 -2
  12. package/dist/content-actions.js +3 -3
  13. package/dist/content-controls.js +9 -9
  14. package/dist/design-controls.d.ts +12 -5
  15. package/dist/design-controls.js +286 -82
  16. package/dist/design-options.d.ts +4 -4
  17. package/dist/design-options.js +6 -26
  18. package/dist/export.d.ts +8 -1
  19. package/dist/export.js +14 -4
  20. package/dist/find-replace.js +1 -0
  21. package/dist/font-gate.js +0 -11
  22. package/dist/galleries.js +32 -67
  23. package/dist/image-crop.js +8 -9
  24. package/dist/image-options.d.ts +45 -0
  25. package/dist/image-options.js +195 -0
  26. package/dist/index.d.ts +26 -8
  27. package/dist/index.js +107 -51
  28. package/dist/json-editor.js +2 -2
  29. package/dist/json-menu.js +2 -2
  30. package/dist/json-options.d.ts +3 -3
  31. package/dist/json-options.js +37 -47
  32. package/dist/layout-handles.js +13 -0
  33. package/dist/layout-placeholders.js +15 -0
  34. package/dist/react.d.ts +1 -0
  35. package/dist/react.js +6 -2
  36. package/dist/review-panel.js +8 -2
  37. package/dist/schema-inspector.js +1 -1
  38. package/dist/schema.js +1 -1
  39. package/dist/slide-manager.js +4 -2
  40. package/dist/slides.d.ts +1 -1
  41. package/dist/slides.js +6 -5
  42. package/dist/svelte.d.ts +1 -0
  43. package/dist/svelte.js +4 -4
  44. package/dist/switches.d.ts +31 -16
  45. package/dist/switches.js +194 -92
  46. package/dist/table-options.d.ts +6 -1
  47. package/dist/table-options.js +43 -0
  48. package/dist/transfer.d.ts +12 -1
  49. package/dist/transfer.js +87 -131
  50. package/package.json +22 -9
package/dist/canvas.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createEditorSession,
3
+ mergeCatalogs,
3
4
  opfPathToJsonPointer,
4
5
  applyJsonPatch,
5
6
  getValueAtPath,
@@ -22,7 +23,7 @@ import { createRichTextInput } from "./rich-text-input.js";
22
23
  import { textInputOffsetAtPoint } from "./text-pointer.js";
23
24
  import { createRichTextToolbar } from "./rich-text-toolbar.js";
24
25
  import { createImageCropper } from "./image-cropper.js";
25
- import { FONTS_PENDING, fontGate, fontsPendingError, previewFonts, whenFontsReady } from "./font-gate.js";
26
+ import { FONTS_PENDING, fontGate, fontsPendingError, whenFontsReady } from "./font-gate.js";
26
27
  import { checkFormat, firstErrorMessage } from "./checks.js";
27
28
  export { getEditableFields } from "./canvas-fields.js";
28
29
  export { whenFontsReady, FONTS_PENDING, FONTS_UNAVAILABLE } from "./font-gate.js";
@@ -67,12 +68,16 @@ export function createCanvasEditor(container, options = {}) {
67
68
  win = doc.defaultView;
68
69
  const editor =
69
70
  options.editor ??
70
- createEditorSession(options.presentation, { rejectInvalid: true });
71
+ createEditorSession(options.presentation, { rejectInvalid: true, catalogs: options.catalogs });
71
72
  // `options.fonts` is the renderer's fonts handle (`loadFonts()`): it measures the text and draws the faces. A document whose faces
72
73
  // are not loaded yet is never rendered: the canvas has the handle load them first and shows "Loading fonts…" meanwhile. Without a
73
74
  // handle every document renders at once, with core's portable text estimate.
74
75
  const handle = options.fonts, fonts = fontGate(handle);
75
- const drawOptions = (extra) => ({ ...renderOptions, fonts: previewFonts(handle), ...extra });
76
+ // The renderer draws with the session's registered catalogs (plus any `renderOptions.catalogs`): the same list core resolves with.
77
+ const resolveOptions = () => ({ ...renderOptions, catalogs: mergeCatalogs(editor.catalogs, renderOptions.catalogs) });
78
+ // RR-61: the handle measures, and the canvas embeds no face: the browser handle already added its faces to the page, so
79
+ // `@font-face` data would repeat megabytes of base64 in every redraw. Exports keep the default and carry the faces they draw.
80
+ const drawOptions = (extra) => ({ ...resolveOptions(), fonts: handle, embedFonts: false, ...extra });
76
81
  let slideIndex = options.slideIndex ?? 0,
77
82
  // RR-32: the canvas edits the document as authored, so a template's {{tokens}} stay visible and an inline edit never
78
83
  // overwrites one with its resolved text. The Fill template panel previews the resolved deck. Pass `variables` to override.
@@ -180,11 +185,11 @@ export function createCanvasEditor(container, options = {}) {
180
185
  // A synchronous render or validation of a document whose faces are still loading fails with a clear "fonts-pending"
181
186
  // error and starts the load, so the next attempt succeeds. It never draws glyphs the registry cannot provide.
182
187
  function requireFonts(presentation) {
183
- const pending = fonts?.pending(presentation, renderOptions) ?? [];
188
+ const pending = fonts?.pending(presentation, resolveOptions()) ?? [];
184
189
  if (!pending.length) return;
185
190
  notice.hidden = false;
186
191
  notice.textContent = pendingMessage;
187
- fonts.ensure(presentation, { renderOptions }).then(
192
+ fonts.ensure(presentation, { renderOptions: resolveOptions() }).then(
188
193
  () => { if (!disposed && notice.textContent === pendingMessage) clearNotice(); },
189
194
  (error) => { if (!disposed) report(error); },
190
195
  );
@@ -223,7 +228,7 @@ export function createCanvasEditor(container, options = {}) {
223
228
  if (disposed) return;
224
229
  const token = ++showToken;
225
230
  return whenFontsReady(fonts, target ?? editor.presentation, {
226
- renderOptions,
231
+ renderOptions: resolveOptions(),
227
232
  isCurrent: () => !disposed && token === showToken,
228
233
  loading: (pending) => fontsState("loading", pending),
229
234
  ready: () => {
@@ -239,7 +244,7 @@ export function createCanvasEditor(container, options = {}) {
239
244
  }
240
245
  // Renders now when the document's fonts are loaded (errors throw to the caller), otherwise after loading them.
241
246
  function renderFor(presentation) {
242
- if (fonts?.pending(presentation ?? editor.presentation, renderOptions).length) show(presentation);
247
+ if (fonts?.pending(presentation ?? editor.presentation, resolveOptions()).length) show(presentation);
243
248
  else render(presentation);
244
249
  }
245
250
  function render(presentation = editor.presentation) {
@@ -253,6 +258,9 @@ export function createCanvasEditor(container, options = {}) {
253
258
  const svg = preview.querySelector("svg");
254
259
  svg.setAttribute("role", "group");
255
260
  svg.setAttribute("aria-label", "Editable slide");
261
+ // FA-30: the renderer labels its slide root a "slide" group named by the title; the canvas is named for editing instead, so it drops
262
+ // the roledescription rather than announce "Editable slide, slide".
263
+ svg.removeAttribute("aria-roledescription");
256
264
  svg.removeAttribute("aria-labelledby");
257
265
  const geometry = resolvePresentation(presentation, drawOptions()).slides[
258
266
  slideIndex
@@ -283,6 +291,13 @@ export function createCanvasEditor(container, options = {}) {
283
291
  continue;
284
292
  seen.add(path);
285
293
  node.setAttribute("data-canvas-target", "");
294
+ // FA-30: the renderer hides decorative drawing and a picture or chart with an empty alt (aria-hidden), and names a chart with
295
+ // alt as an image group. An editing target is focusable, so neither it nor a group around it may be hidden or presentational:
296
+ // an ancestor image group (the chart's alt) stays named but becomes a plain group so the targets inside it are reachable.
297
+ for (let up = node; up && up !== svg; up = up.parentNode) {
298
+ up.removeAttribute("aria-hidden");
299
+ if (up !== node && up.getAttribute("role") === "img") up.setAttribute("role", "group");
300
+ }
286
301
  node.setAttribute("role", "button");
287
302
  node.setAttribute("tabindex", "0");
288
303
  node.setAttribute(
@@ -366,11 +381,11 @@ export function createCanvasEditor(container, options = {}) {
366
381
  }
367
382
  // An in-progress edit whose text needs faces that are not loaded yet waits for them, then draws again.
368
383
  function deferDraft(draft) {
369
- if (!fonts?.pending(draft, renderOptions).length) return false;
384
+ if (!fonts?.pending(draft, resolveOptions()).length) return false;
370
385
  active.valid = false;
371
386
  notice.hidden = false;
372
387
  notice.textContent = pendingMessage;
373
- fonts.ensure(draft, { renderOptions }).then(
388
+ fonts.ensure(draft, { renderOptions: resolveOptions() }).then(
374
389
  () => {
375
390
  if (disposed) return;
376
391
  if (notice.textContent === pendingMessage) clearNotice();
@@ -1020,7 +1035,7 @@ export function createCanvasEditor(container, options = {}) {
1020
1035
  get layoutEditing() { return layoutHandles.enabled; },
1021
1036
  /** True while the crop layer is open. */
1022
1037
  get cropping() { return !!imageCropper?.isOpen; },
1023
- /** Open the crop layer for the picture at `path` (an image block, a slide's `image`, a slide image). `tool: "focus"` starts with the focal point. */
1038
+ /** Open the crop layer for the picture at `path` (an image block or a slide's `image`). `tool: "focus"` starts with the focal point. */
1024
1039
  cropImage(path, cropOptions) {
1025
1040
  if (!imageCropper) return Promise.resolve(false);
1026
1041
  return imageCropper.open(path, cropOptions);
@@ -0,0 +1,21 @@
1
+ // Framework-neutral event handling: rejected edits restore the committed value
2
+ // and reach the host callback and a bubbling opferror event.
3
+ export function commitCatalogControl(node, onError, sync, commit) {
4
+ try {
5
+ commit();
6
+ node.setCustomValidity?.("");
7
+ node.removeAttribute?.("aria-invalid");
8
+ node.removeAttribute?.("data-opf-error");
9
+ return true;
10
+ } catch (error) {
11
+ sync();
12
+ node.setCustomValidity?.(error.message);
13
+ node.setAttribute?.("aria-invalid", "true");
14
+ node.setAttribute?.("data-opf-error", error.message);
15
+ onError?.(error);
16
+ const Event = node.ownerDocument?.defaultView?.CustomEvent;
17
+ if (Event) node.dispatchEvent?.(new Event("opferror", { detail: error, bubbles: true }));
18
+ node.reportValidity?.();
19
+ return false;
20
+ }
21
+ }
@@ -0,0 +1,48 @@
1
+ import type { Catalog, CatalogKind, CatalogRecordChange, EmbeddedRecord, UnresolvedReferenceDiagnostic, ValidationReport } from "@openpresentation/opf";
2
+ import type { CatalogOption, EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
3
+
4
+ /** One catalog list: the session's registration first, then each host list; the first catalog with a `source` wins. Throws `invalid-catalogs` for an entry without a source. */
5
+ export declare function mergeCatalogs(...lists: (readonly Catalog[] | Catalog | undefined | null)[]): readonly Catalog[];
6
+ /** The catalogs for one operation: `editor.catalogs`, then `options.catalogs`. */
7
+ export declare function catalogsFor(editor: EditorSession | undefined, options?: { catalogs?: readonly Catalog[] }): readonly Catalog[];
8
+ /** A record's picker label: its name, then its gallery label, then the id. */
9
+ export declare function catalogRecordLabel(record: Record<string, unknown> | undefined, id: string): string;
10
+ /** Core's `catalogRecords` as picker options: embedded records first, then registered ones; `id` is the reference to write. */
11
+ export declare function listCatalogRecords(presentation: unknown, kind: CatalogKind, options?: { catalogs?: readonly Catalog[] }): CatalogOption[];
12
+
13
+ export interface SavedDocument {
14
+ /** The self-contained document: every referenced record embedded once under its group, `x-*` stripped. */
15
+ document: unknown;
16
+ added: EmbeddedRecord[];
17
+ unresolved: UnresolvedReferenceDiagnostic[];
18
+ }
19
+ /** Core's `embed`: the document to save or hand on, which renders the same with no catalog registered. The input is not changed. */
20
+ export declare function prepareSave(presentation: unknown, options?: { catalogs?: readonly Catalog[] }): SavedDocument;
21
+ /** `prepareSave` of the session's document with its registered catalogs (and `options.catalogs`). The session is not changed. */
22
+ export declare function saveDocument(editor: EditorSession, options?: { catalogs?: readonly Catalog[] }): SavedDocument;
23
+
24
+ export interface CatalogUpdateCheck {
25
+ /** One per embedded record (under `default` or a named group) whose registered record differs. */
26
+ changes: CatalogRecordChange[];
27
+ /** RFC 6902; apply only after the author approves (`applyCatalogUpdate`). */
28
+ patch: JsonPatchOperation[];
29
+ }
30
+ /** The "Update from catalog" check (core's `updateFromCatalog`). Applies nothing. `refs` limits it to some references. */
31
+ export declare function checkCatalogUpdates(presentation: unknown, options?: { catalogs?: readonly Catalog[]; refs?: readonly { kind: CatalogKind; reference: string }[] }): CatalogUpdateCheck;
32
+ /** Apply the approved updates (`refs`) as ONE undoable step, recomputed against the session's current document. */
33
+ export declare function applyCatalogUpdate(
34
+ editor: EditorSession,
35
+ options: { refs: readonly { kind: CatalogKind; reference: string }[]; catalogs?: readonly Catalog[]; meta?: Record<string, unknown> },
36
+ ): EditorChange & { changed: boolean; changes: CatalogRecordChange[] };
37
+
38
+ export declare const REFERENCE_FINDING_CODES: readonly ["opf/unresolved-reference", "opf/undeclared-catalog", "opf/catalog-record-not-in-source"];
39
+ /** The reference and embedded-record findings (`REFERENCE_FINDING_CODES`) of a report. */
40
+ export declare function referenceFindings(validation: ValidationReport | undefined): ValidationReport["findings"];
41
+
42
+ export interface CatalogRecordRef { group: string; kind: CatalogKind; id: string }
43
+ /** The record a `opf/catalog-record-not-in-source` finding path names (`/catalogs/<group>/<kind>/<id>`). */
44
+ export declare function catalogRecordAt(path: string): CatalogRecordRef | undefined;
45
+ /** Move a record embedded under `default` or a named group into `catalogs.custom`, rewriting the references that resolved to it. */
46
+ export declare function prepareMoveToCustom(presentation: unknown, target: CatalogRecordRef, options?: { catalogs?: readonly Catalog[] }): { presentation: unknown; patches: JsonPatchOperation[]; changed: boolean; from: CatalogRecordRef; to: CatalogRecordRef };
47
+ /** `prepareMoveToCustom` as one undoable step on the session. */
48
+ export declare function moveToCustom(editor: EditorSession, target: CatalogRecordRef, options?: { catalogs?: readonly Catalog[]; meta?: Record<string, unknown> }): EditorChange & { changed: boolean; from: CatalogRecordRef; to: CatalogRecordRef };
@@ -0,0 +1,217 @@
1
+ // Catalogs (FA-23, OPF 0.15). The editor never ships or imports catalog data: the host registers its catalogs (`Catalog[]`,
2
+ // the shape core's `CatalogOptions.catalogs` takes, for example `defaultCatalog` from `@openpresentation/opf/catalog`), on the
3
+ // session (`createEditorSession(doc, { catalogs })`) or per call. `mergeCatalogs` is the one function that turns those into the
4
+ // one list the editor hands to core (resolution, validation, embedding, copying, updates), to the renderer and to the exporter.
5
+ // Pickers list core's `catalogRecords` and write the reference it gives (`id` or `name:id`); saving and exporting embed every
6
+ // referenced record with core's `embed`, so a saved document renders the same with no catalog registered.
7
+ import { catalogKinds, catalogRecords, embed, moveToCustom as coreMoveToCustom, updateFromCatalog } from "@openpresentation/opf";
8
+ import { OPFEditorError } from "./index.js";
9
+
10
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
11
+
12
+ /**
13
+ * One catalog list from any number of lists (or single catalogs): the session's registration first, then each host list, in order.
14
+ * A catalog is identified by its `source`; the first catalog with a source wins and later ones with the same source are dropped,
15
+ * so the host default (the first entry) never moves. Entries that are not catalogs throw `invalid-catalogs` (core's
16
+ * `OPFCatalogsOptionError` code).
17
+ */
18
+ export function mergeCatalogs(...lists) {
19
+ const merged = [];
20
+ const sources = new Set();
21
+ for (const list of lists) {
22
+ if (list === undefined || list === null) continue;
23
+ for (const catalog of Array.isArray(list) ? list : [list]) {
24
+ if (catalog === undefined || catalog === null) continue;
25
+ if (!isObject(catalog) || typeof catalog.source !== "string" || catalog.source === "")
26
+ throw new OPFEditorError("invalid-catalogs", "A registered catalog is an object with a non-empty source, such as defaultCatalog from @openpresentation/opf/catalog.", { catalog });
27
+ if (sources.has(catalog.source)) continue;
28
+ sources.add(catalog.source);
29
+ merged.push(catalog);
30
+ }
31
+ }
32
+ return Object.freeze(merged);
33
+ }
34
+
35
+ /** The catalogs for one operation: the session's registered list (when `editor` is given), then `options.catalogs`. */
36
+ export function catalogsFor(editor, options = {}) {
37
+ return mergeCatalogs(editor?.catalogs, options?.catalogs);
38
+ }
39
+
40
+ /** The label a picker shows for a record: its name, then its gallery label, then the id. */
41
+ export function catalogRecordLabel(record, id) {
42
+ const gallery = isObject(record?.["x-gallery"]) ? record["x-gallery"] : {};
43
+ for (const value of [record?.name, record?.title, record?.label, gallery.label, gallery.name]) if (typeof value === "string" && value) return value;
44
+ return String(id);
45
+ }
46
+
47
+ /**
48
+ * Every record a picker can offer for `kind`, from core's `catalogRecords`: the document's embedded records first, then the
49
+ * registered catalogs'. Each entry is `{ id, reference, label, description, record, group, source, origin }`; `id` and
50
+ * `reference` are the reference to write (`id` or `name:id`). A reference is listed once.
51
+ */
52
+ export function listCatalogRecords(presentation, kind, options = {}) {
53
+ const seen = new Set();
54
+ const out = [];
55
+ for (const entry of catalogRecords(presentation ?? {}, kind, { catalogs: mergeCatalogs(options.catalogs) })) {
56
+ if (seen.has(entry.reference)) continue;
57
+ seen.add(entry.reference);
58
+ const record = isObject(entry.record) ? entry.record : {};
59
+ out.push({
60
+ id: entry.reference,
61
+ reference: entry.reference,
62
+ label: catalogRecordLabel(record, entry.id),
63
+ ...(typeof record.description === "string" ? { description: record.description } : {}),
64
+ record,
65
+ group: entry.group,
66
+ ...(entry.source !== undefined ? { source: entry.source } : {}),
67
+ origin: entry.origin,
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * The document to save or hand on: core's `embed` adds every record the document references, once, under its catalog group
75
+ * (with the records those reference, `x-*` display metadata stripped), so it renders the same with no catalog registered.
76
+ * Returns `{ document, added, unresolved }`; the input is not changed.
77
+ */
78
+ export function prepareSave(presentation, options = {}) {
79
+ const result = embed(presentation, { catalogs: mergeCatalogs(options.catalogs) });
80
+ return { document: result.document, added: result.added ?? [], unresolved: result.unresolved ?? [] };
81
+ }
82
+
83
+ /** `prepareSave` for the session's document, with the session's catalogs and `options.catalogs`. The session is not changed. */
84
+ export function saveDocument(editor, options = {}) {
85
+ assertSession(editor);
86
+ return prepareSave(editor.presentation, { catalogs: catalogsFor(editor, options) });
87
+ }
88
+
89
+ /**
90
+ * The "Update from catalog" check: core's `updateFromCatalog` compares every record embedded under `default` or a named group
91
+ * (never `custom`) with the registered catalog's current record. Returns `{ changes, patch }`; nothing is applied. `refs` limits
92
+ * the check to some references (`[{ kind, reference }]`).
93
+ */
94
+ export function checkCatalogUpdates(presentation, options = {}) {
95
+ const update = updateFromCatalog(presentation, mergeCatalogs(options.catalogs), options.refs);
96
+ return { changes: update.changes ?? [], patch: update.patch ?? [] };
97
+ }
98
+
99
+ /**
100
+ * Apply an update the author approved, as ONE undoable step. `options.refs` names the approved changes (`[{ kind, reference }]`);
101
+ * the update is recomputed for exactly those against the session's current document, so a stale review never applies. Returns
102
+ * the session change plus `changes` (what was applied) and `changed`.
103
+ */
104
+ export function applyCatalogUpdate(editor, options = {}) {
105
+ assertSession(editor);
106
+ if (!Array.isArray(options.refs) || !options.refs.length) throw new OPFEditorError("no-approved-updates", "Choose the catalog updates to apply.");
107
+ const update = checkCatalogUpdates(editor.presentation, { catalogs: catalogsFor(editor, options), refs: options.refs });
108
+ if (!update.patch.length) return { changed: false, changes: [], presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
109
+ const change = editor.applyPatch(update.patch, { ...options.meta, source: options.meta?.source ?? "catalog-update", catalogRecordEdit: "in-place", catalogUpdate: update.changes.map(({ kind, reference }) => ({ kind, reference })) });
110
+ return { ...change, changed: true, changes: update.changes };
111
+ }
112
+
113
+ /**
114
+ * Core finding codes about catalog references and embedded records, which the editor surfaces next to its catalog controls:
115
+ * a reference that resolves nowhere, a prefix with no catalog group, and a record embedded under a catalog's group that the
116
+ * registered catalog does not publish (offer `moveToCustom`).
117
+ */
118
+ export const REFERENCE_FINDING_CODES = Object.freeze(["opf/unresolved-reference", "opf/undeclared-catalog", "opf/catalog-record-not-in-source"]);
119
+
120
+ /** The reference and embedded-record findings (`REFERENCE_FINDING_CODES`) of a validation report (the session's `validation`). */
121
+ export function referenceFindings(validation) {
122
+ return (validation?.findings ?? []).filter((finding) => REFERENCE_FINDING_CODES.includes(finding.ruleId ?? finding.code));
123
+ }
124
+
125
+ function assertSession(editor) {
126
+ if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
127
+ throw new OPFEditorError("invalid-editor", "Expected an editor session created by createEditorSession.");
128
+ }
129
+
130
+ /**
131
+ * The record an `opf/catalog-record-not-in-source` finding names, from its path (`/catalogs/<group>/<kind>/<id>` or the dotted
132
+ * form): `{ group, kind, id }`, or undefined for a path that names no embedded record.
133
+ */
134
+ export function catalogRecordAt(path) {
135
+ const parts = typeof path === "string" ? (path.startsWith("/") ? path.slice(1).split("/").map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")) : path.split(".")) : [];
136
+ if (parts.length !== 4 || parts[0] !== "catalogs" || !catalogKinds.includes(parts[2])) return undefined;
137
+ return { group: parts[1], kind: parts[2], id: parts[3] };
138
+ }
139
+
140
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
141
+
142
+ /**
143
+ * Move a record embedded under `default` or a named group into `catalogs.custom` (the fix the `opf/catalog-record-not-in-source`
144
+ * finding offers, and the fork an edit makes) with core's `moveToCustom`: the document owns it from then on. Every reference that
145
+ * resolved to it is rewritten to the custom id (`options.id`, else its id, `<id>-2` and up on a conflict), and references written
146
+ * inside it keep naming what they named. Returns `{ presentation, patches, changed, from, to, references }`.
147
+ */
148
+ export function prepareMoveToCustom(presentation, target, options = {}) {
149
+ const { group, kind, id } = target ?? {};
150
+ const groups = isObject(presentation?.catalogs) ? presentation.catalogs : {};
151
+ const record = isObject(groups[group]?.[kind]) ? groups[group][kind][id] : undefined;
152
+ if (group === "custom" || record === undefined) throw new OPFEditorError("not-a-catalog-record", "Choose a record embedded under catalogs.default or a named catalog group.", { group, kind, id });
153
+ // Core's moveToCustom is the one implementation: references follow, the record's own references keep naming what they named.
154
+ const reference = group === "default" ? id : `${group}:${id}`;
155
+ const moved = coreMoveToCustom(presentation, { kind, reference }, { catalogs: mergeCatalogs(options.catalogs), ...(options.id ? { id: options.id } : {}) });
156
+ const patches = [{ op: "test", path: "/catalogs", value: structuredClone(presentation.catalogs) }, ...moved.patch];
157
+ return { presentation: moved.document, patches, changed: true, from: { group, kind, id }, to: { group: "custom", kind, id: moved.to.id }, references: moved.references ?? [] };
158
+ }
159
+
160
+ /**
161
+ * `prepareMoveToCustom` on the session's document, as ONE undoable step (`meta.source: "catalog-move-to-custom"`). `options.id` names
162
+ * the custom record (default: the record's id, `<id>-2` on a conflict).
163
+ */
164
+ export function moveToCustom(editor, target, options = {}) {
165
+ assertSession(editor);
166
+ const prepared = prepareMoveToCustom(editor.presentation, target, { catalogs: catalogsFor(editor, options), ...(options.id ? { id: options.id } : {}) });
167
+ const change = editor.applyPatch(prepared.patches, { ...options.meta, source: options.meta?.source ?? "catalog-move-to-custom", catalogRecordEdit: "in-place" });
168
+ return { ...change, changed: true, from: prepared.from, to: prepared.to };
169
+ }
170
+
171
+ /** The record kinds a user edits, which an edit forks into `catalogs.custom` when they live under a catalog's group. */
172
+ export const FORKED_RECORD_KINDS = Object.freeze(["layouts", "themes", "colorSchemes", "fontSchemes"]);
173
+
174
+ /**
175
+ * Records under `catalogs.default` or a named group whose content an edit from `before` to `after` changed (present in both, not
176
+ * equal). An added or removed record is not an edit. Returns `[{ group, kind, id }]`.
177
+ */
178
+ export function editedCatalogRecords(before, after) {
179
+ const out = [];
180
+ const groupsBefore = isObject(before?.catalogs) ? before.catalogs : {};
181
+ const groupsAfter = isObject(after?.catalogs) ? after.catalogs : {};
182
+ for (const [group, value] of Object.entries(groupsAfter)) {
183
+ if (group === "custom" || !isObject(value) || !isObject(groupsBefore[group])) continue;
184
+ for (const kind of FORKED_RECORD_KINDS) {
185
+ const was = isObject(groupsBefore[group][kind]) ? groupsBefore[group][kind] : {};
186
+ const now = isObject(value[kind]) ? value[kind] : {};
187
+ for (const id of Object.keys(now)) if (Object.hasOwn(was, id) && !sameJson(was[id], now[id])) out.push({ group, kind, id });
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** The notice an editor shows after a fork: the record is the deck's own now and no longer receives catalog updates. */
194
+ export function forkNotice(fork) {
195
+ const label = { layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme" }[fork.from.kind] ?? fork.from.kind;
196
+ return `The ${label} ${fork.from.id} is now this presentation's own (${fork.to.id}); it no longer receives catalog updates.`;
197
+ }
198
+
199
+ /**
200
+ * Owner rule (FA-23): editing a record that lives under `catalogs.default` or a named group forks it into `catalogs.custom` instead
201
+ * of changing the catalog's record in place. Given the document before and after an edit, returns the document after the forks
202
+ * (each edited record moved to custom as `<id>-custom`, or `forkIds[<group>:<kind>:<id>]`, every reference rewritten, the
203
+ * original dropped) and `forks: [{ from, to }]`. Nothing to fork returns `after` and `[]`.
204
+ */
205
+ export function forkEditedRecords(before, after, options = {}) {
206
+ let document = after;
207
+ const forks = [];
208
+ for (const target of editedCatalogRecords(before, after)) {
209
+ const custom = isObject(document.catalogs?.custom?.[target.kind]) ? document.catalogs.custom[target.kind] : {};
210
+ let id = options.forkIds?.[`${target.group}:${target.kind}:${target.id}`] ?? `${target.id}-custom`;
211
+ for (let n = 2; Object.hasOwn(custom, id); n += 1) id = `${target.id}-custom-${n}`;
212
+ const moved = prepareMoveToCustom(document, target, { catalogs: options.catalogs, id });
213
+ document = moved.presentation;
214
+ forks.push({ from: moved.from, to: moved.to });
215
+ }
216
+ return { document, forks };
217
+ }
package/dist/checks.js CHANGED
@@ -1,13 +1,15 @@
1
- // The editor's one use of core's checker. Every edit is validated with `validate(presentation, { only: ["format"] })`: the
2
- // schema and semantic OPF rules, with no layout built, so it is cheap enough to run on every keystroke and undo. The full
3
- // report (accessibility, layout, content) belongs to the Review panel, which runs it on idle (`./review.js`).
1
+ // The editor's one use of core's checker. Every edit is validated with core's `validate` for the `format` category and the two
2
+ // catalog rules (`opf/unresolved-reference`, `opf/undeclared-catalog`, `opf/catalog-record-not-in-source`): the schema and semantic OPF rules, with no
3
+ // layout built, so it is cheap enough to run on every keystroke and undo. `catalogs` are the host's registered catalogs, so a
4
+ // reference that resolves in one of them is not reported. The full report (accessibility, layout, content) belongs to the Review
5
+ // panel, which runs it on idle (`./review.js`).
4
6
  import { validate } from "@openpresentation/opf";
5
7
 
6
- const FORMAT_ONLY = Object.freeze({ only: Object.freeze(["format"]) });
8
+ const EDIT_CATEGORIES = Object.freeze(["format", "opf/unresolved-reference", "opf/undeclared-catalog", "opf/catalog-record-not-in-source"]);
7
9
 
8
- /** Core's `ValidationReport` for the `format` category: `valid`, `findings`, `counts`, `schemaValid`. */
9
- export function checkFormat(presentation) {
10
- return validate(presentation, FORMAT_ONLY);
10
+ /** Core's `ValidationReport` for the `format` category and the catalog reference rules: `valid`, `findings`, `counts`, `schemaValid`. */
11
+ export function checkFormat(presentation, options = {}) {
12
+ return validate(presentation, { only: EDIT_CATEGORIES, ...(options.catalogs?.length ? { catalogs: options.catalogs } : {}) });
11
13
  }
12
14
 
13
15
  /** The error-severity findings of a report: what makes it invalid. */
@@ -72,9 +72,9 @@ export declare function ungroupBlock(editor: EditorSession, groupPath: string, m
72
72
  export declare function placeBlocksInRegions(editor: EditorSession, slideIndex: number, regions: string[], meta?: Record<string, unknown>): ContentActionChange;
73
73
  export declare function regionsAsBlocks(editor: EditorSession, slideIndex: number, meta?: Record<string, unknown>): ContentActionChange;
74
74
  export declare function moveSlideRegion(editor: EditorSession, slideIndex: number, from: string, to: string, options?: { swap?: boolean }, meta?: Record<string, unknown>): ContentActionChange;
75
- /** Move an image block into the slide's design as its slide image, background or watermark. */
75
+ /** Move an image block into the slide's design as its background or watermark. */
76
76
  export declare function moveImageToDesign(editor: EditorSession, blockPath: string, target: ImageTarget, options?: PromoteImageOptions, meta?: Record<string, unknown>): ContentActionChange;
77
- /** Move a slide's own slide image, background image or watermark back into its content as an image block. */
77
+ /** Move a slide's own background image or watermark back into its content as an image block. */
78
78
  export declare function moveImageToContent(editor: EditorSession, slideIndex: number, source: ImageTarget, options?: { index?: number; region?: string }, meta?: Record<string, unknown>): ContentActionChange;
79
79
 
80
80
  export declare function prepareSplitSlide(presentation: unknown, slideIndex: number, options?: { at?: number[]; each?: boolean; repeatHeadings?: boolean }): PreparedContentAction;
@@ -1,4 +1,4 @@
1
- // Content actions (RR-26): list levels, grouping, regions, images between content and design, and slide
1
+ // Content actions (RR-26): list levels, grouping, regions, images between content and design (background or watermark), and slide
2
2
  // split and merge, as editor transactions. The pure transforms live in core (`@openpresentation/opf/convert`);
3
3
  // each action here finds its target in the document, calls the transform, turns the result into a guarded patch
4
4
  // (`test` of what was read, then `replace`, or per-slide `remove` and `add`), validates the document and, in the
@@ -143,12 +143,12 @@ export function prepareMoveRegion(presentation, slideIndex, from, to, options =
143
143
  slideParts(presentation, `slides.${slideIndex}`, "a slide");
144
144
  return slideAction(presentation, slideIndex, (slide) => moveRegion(slide, from, to, options), undefined, options.validate !== false);
145
145
  }
146
- /** Move the image block at `blockPath` into the slide's design as `slideImage`, `background` or `watermark`. */
146
+ /** Move the image block at `blockPath` into the slide's design as its `background` or `watermark` (core's `promoteImage`). */
147
147
  export function prepareImageToDesign(presentation, blockPath, target, options = {}) {
148
148
  const { slideIndex, relative } = slideParts(presentation, blockPath, "an image block");
149
149
  return slideAction(presentation, slideIndex, (slide) => promoteImage(slide, asNumbers(relative), target, options), `slides.${slideIndex}`, options.validate !== false);
150
150
  }
151
- /** Move a slide's own `slideImage`, `background` image or `watermark` back into its content as an image block. */
151
+ /** Move a slide's own `background` image or `watermark` back into its content as an image block (core's `demoteImage`). */
152
152
  export function prepareImageToContent(presentation, slideIndex, source, options = {}) {
153
153
  slideParts(presentation, `slides.${slideIndex}`, "a slide");
154
154
  return slideAction(presentation, slideIndex, (slide) => demoteImage(slide, source, options), undefined, options.validate !== false);
@@ -36,20 +36,20 @@ export const REGION_LAYOUTS = Object.freeze({
36
36
  4: [{ id: "grid", label: "Two by two", regions: ["top:left", "top:right", "bottom:left", "bottom:right"] }],
37
37
  });
38
38
 
39
- /** Where an image block can go in the slide's design. The value is `<target>` or `<target>:<position>`. */
39
+ /**
40
+ * Where an image block can go in the slide's design (OPF 0.15): its background (a full-bleed photo behind the content) or its
41
+ * watermark. Bleeding an image to one edge beside the content is not a move: it is the block's `placement` (image-options.js).
42
+ */
40
43
  export const IMAGE_DESTINATIONS = Object.freeze([
41
- { value: "slideImage:right", label: "Slide image, on the right", target: "slideImage", options: { position: "right" } },
42
- { value: "slideImage:left", label: "Slide image, on the left", target: "slideImage", options: { position: "left" } },
43
- { value: "slideImage:top", label: "Slide image, along the top", target: "slideImage", options: { position: "top" } },
44
- { value: "slideImage:bottom", label: "Slide image, along the bottom", target: "slideImage", options: { position: "bottom" } },
45
- { value: "slideImage:background", label: "Slide image, full slide behind the content", target: "slideImage", options: { position: "background" } },
46
- { value: "background", label: "Slide background", target: "background", options: {} },
44
+ { value: "background", label: "Slide background, full slide behind the content", target: "background", options: {} },
47
45
  { value: "watermark", label: "Watermark", target: "watermark", options: {} },
48
46
  ]);
49
47
 
50
48
  const REGION_KEY = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
51
49
  const regionKeysOfSlide = (slide) => Object.keys(slide).filter((key) => REGION_KEY.test(key));
52
- const DESIGN_IMAGE_LABELS = { slideImage: "slide image", background: "background image", watermark: "watermark" };
50
+ const DESIGN_IMAGE_LABELS = { background: "background image", watermark: "watermark" };
51
+ // An image block's own framing fields (FA-22) do not stop it being "a block that holds only an image".
52
+ const IMAGE_BLOCK_FIELDS = new Set(["id", "extensions", "type", "fit", "focus", "aspectRatio", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "placement"]);
53
53
  const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
54
54
  const lossText = (prepared) => (prepared.loss.length ? `Not carried over: ${prepared.loss.join(", ")}.` : "Nothing is lost.");
55
55
 
@@ -321,6 +321,6 @@ export function createContentControls(ctx) {
321
321
  function isImageBlock(editor, blockPath) {
322
322
  const block = editor.get(blockPath);
323
323
  if (!block || typeof block !== "object" || Array.isArray(block)) return false;
324
- const keys = Object.keys(block).filter((key) => key !== "id" && key !== "extensions" && key !== "type");
324
+ const keys = Object.keys(block).filter((key) => !IMAGE_BLOCK_FIELDS.has(key));
325
325
  return keys.length === 1 && keys[0] === "image";
326
326
  }
@@ -1,7 +1,13 @@
1
+ import type { Catalog } from "@openpresentation/opf";
1
2
  import type { EditorSession } from "./index.js";
3
+ import type { SwitchVocabularies } from "./switches.js";
2
4
 
3
- export type DesignControlSection = "look" | "background" | "slide-image" | "header-footer" | "brand" | "layout-options" | "info" | "selection" | "table" | "slide-content";
4
- /** Every section, in panel order. `selection` and `table` follow the host's current selection; `slide-content` (RR-26: blocks and regions, images between content and design) follows the current slide. */
5
+ export type DesignControlSection = "look" | "background" | "header-footer" | "brand" | "layout-options" | "info" | "selection" | "image" | "table" | "slide-content" | "catalog";
6
+ /**
7
+ * Every section, in panel order. `selection`, `image` (the selected image block's fit, focus, treatments and placement) and `table`
8
+ * follow the host's current selection; `slide-content` (RR-26: blocks and regions, images between content and design) follows the
9
+ * current slide; `catalog` lists the catalog reference findings and runs the explicit "Update from catalog".
10
+ */
5
11
  export declare const DESIGN_CONTROL_SECTIONS: readonly DesignControlSection[];
6
12
 
7
13
  export interface DesignControlsOptions {
@@ -14,9 +20,10 @@ export interface DesignControlsOptions {
14
20
  sections?: readonly DesignControlSection[];
15
21
  /** Initial "Applies to" choice. */
16
22
  scope?: "deck" | "slide";
17
- /** Caller-loaded catalog records by kind (layouts, font schemes, themes, ...) for options the bundled catalogs do not have. */
18
- catalogs?: Record<string, unknown>;
19
- catalogSources?: Record<string, unknown>;
23
+ /** Host catalogs for the pickers, merged after the session's registered ones. */
24
+ catalogs?: readonly Catalog[];
25
+ /** Display metadata for the engine vocabularies (languages, chart types, social platforms): `catalogDisplay` from `@openpresentation/opf/catalog`. */
26
+ vocabularies?: SwitchVocabularies;
20
27
  /** Called after every committed change (never for a refused one). */
21
28
  onChange?: (change: unknown) => void;
22
29
  /** Called after a content conversion or replacement with the path to keep selected (the block, or the inline payload field), since the old selection path may no longer exist. */