@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/index.d.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  import type { PaginationOptions, PaginationResult } from "@openpresentation/opf/pagination";
2
2
  import type { Composition, ComposeSlideOptions, SlideComposition } from "@openpresentation/opf/composition";
3
- import type { Fonts, PresentationStats, SlideContextDiagnostic, StatsOptions, ValidationReport } from "@openpresentation/opf";
3
+ import type { Catalog, Fonts, PresentationStats, SlideContextDiagnostic, StatsOptions, ValidationReport } from "@openpresentation/opf";
4
+ export * from "./catalogs.js";
4
5
  /**
5
6
  * What the editor adds to the options of `composeSlide` and `paginateSlide`: `fonts` is the renderer's fonts handle (`loadFonts()`), whose
6
- * `textMeasurement` measures the text; `catalogs` are host catalog records; `onDiagnostic` hears every `unresolved-font-scheme`,
7
- * `unresolved-layout`, `unresolved-theme` and `unresolved-color-scheme` diagnostic of core's `resolveSlideContext`. Any other option overrides
8
- * the resolved one.
7
+ * `textMeasurement` measures the text; `catalogs` are host catalogs for this call (merged after the session's registered ones);
8
+ * `onDiagnostic` hears every diagnostic of core's `resolveSlideContext` (`unresolved-reference`). Any other
9
+ * option overrides the resolved one.
9
10
  */
10
11
  export interface EditorDiagnosticOptions {
11
12
  fonts?: Fonts;
12
- catalogs?: Record<string, readonly unknown[]>;
13
+ catalogs?: readonly Catalog[];
13
14
  onDiagnostic?: (diagnostic: SlideContextDiagnostic) => void;
14
15
  }
15
16
  export declare const packageName = "@openpresentation/opf-editor";
@@ -56,7 +57,8 @@ export interface EditorChange {
56
57
  }
57
58
 
58
59
  export interface EditorEvent {
59
- type: "patch" | "undo" | "redo" | "restore";
60
+ /** `catalogs`: the registered catalogs changed (`setCatalogs`); the document did not. */
61
+ type: "patch" | "undo" | "redo" | "restore" | "catalogs";
60
62
  patches: JsonPatchOperation[];
61
63
  inversePatches?: JsonPatchOperation[];
62
64
  redoPatches?: JsonPatchOperation[];
@@ -73,7 +75,12 @@ export interface EditorSession {
73
75
  composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
74
76
  setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
75
77
  readonly presentation: unknown;
78
+ /** The `format` report of the document plus the catalog reference rules, checked against the registered catalogs. */
76
79
  readonly validation: ValidationReport;
80
+ /** The host's registered catalogs, frozen; the first is the host default. */
81
+ readonly catalogs: readonly Catalog[];
82
+ /** Replace the registered catalogs: re-validates the document and emits one `catalogs` event. */
83
+ setCatalogs(catalogs: readonly Catalog[] | undefined, meta?: Record<string, unknown>): readonly Catalog[];
77
84
  readonly canUndo: boolean;
78
85
  readonly canRedo: boolean;
79
86
  snapshot(): EditorSnapshot;
@@ -94,18 +101,28 @@ export interface EditorSession {
94
101
  export interface CreateEditorSessionOptions {
95
102
  /** Refuse every edit whose result has an error finding (the default per edit is `meta.rejectInvalid`). */
96
103
  rejectInvalid?: boolean;
104
+ /** The host's catalogs (for example `[defaultCatalog]` from `@openpresentation/opf/catalog`): resolution, validation, pickers, embedding. */
105
+ catalogs?: readonly Catalog[];
97
106
  }
98
107
 
99
108
  export interface CatalogOption {
109
+ /** The reference to write: `id` or `name:id`. */
100
110
  id: string;
111
+ reference: string;
101
112
  label: string;
113
+ description?: string;
102
114
  record: Record<string, unknown>;
115
+ /** The catalog group the record resolves in (`custom`, `default` or a named group). */
116
+ group: string;
117
+ source?: string;
118
+ origin: "document" | "host";
103
119
  }
104
120
 
105
121
  export interface CatalogOptionsInput {
106
122
  presentation?: { catalogs?: Record<string, unknown> } | Record<string, unknown>;
107
- catalogs?: Record<string, unknown>;
108
- catalogSources?: Record<string, unknown>;
123
+ /** Host catalogs, merged after `editor`'s registered ones. */
124
+ catalogs?: readonly Catalog[];
125
+ editor?: EditorSession;
109
126
  [key: string]: unknown;
110
127
  }
111
128
 
@@ -145,6 +162,7 @@ export interface CatalogSelectOptions extends CatalogOptionsInput {
145
162
  path: string;
146
163
  catalogKind: string;
147
164
  label?: string;
165
+ onError?: (error: Error) => void;
148
166
  document?: { createElement(tagName: string): any };
149
167
  }
150
168
 
package/dist/index.js CHANGED
@@ -1,16 +1,39 @@
1
+ import { commitCatalogControl } from "./catalog-control.js";
1
2
  import { paginateSlide } from "@openpresentation/opf/pagination";
2
3
  import { OPFPatchError, applyPatch as applyCorePatch, applyPatchWithInverse, formatPointer, invertPatch, jsonEqual, parsePointer, readPointer } from "@openpresentation/opf/patch";
3
4
  import { collectReservedPresentationIds } from "./presentation-ids.js";
4
5
  import { composeSlide } from "@openpresentation/opf/composition";
5
- import { catalogKinds, catalogs as bundledCatalogs, resolveSlideContext, stats } from "@openpresentation/opf";
6
+ import { catalogKinds, resolveSlideContext, stats } from "@openpresentation/opf";
6
7
  import { checkFormat, errorFindings } from "./checks.js";
8
+ import { forkEditedRecords, forkNotice, listCatalogRecords, mergeCatalogs } from "./catalogs.js";
9
+
10
+ export {
11
+ REFERENCE_FINDING_CODES,
12
+ applyCatalogUpdate,
13
+ catalogRecordAt,
14
+ catalogRecordLabel,
15
+ catalogsFor,
16
+ checkCatalogUpdates,
17
+ editedCatalogRecords,
18
+ FORKED_RECORD_KINDS,
19
+ forkEditedRecords,
20
+ forkNotice,
21
+ listCatalogRecords,
22
+ mergeCatalogs,
23
+ moveToCustom,
24
+ prepareMoveToCustom,
25
+ prepareSave,
26
+ referenceFindings,
27
+ saveDocument,
28
+ } from "./catalogs.js";
7
29
 
8
30
  // The options `composeSlide` and `paginateSlide` take for one slide of the open deck. Core's `resolveSlideContext` resolves the
9
31
  // slide's canvas, layout, theme, colour scheme and font families the one way every engine does (slide design, deck design,
10
- // theme, default), so the editor measures and composes what the renderer draws and the exporter writes. `options.fonts` is the
11
- // renderer's fonts handle (its `textMeasurement` measures); `options.onDiagnostic` hears each `unresolved-*` diagnostic;
32
+ // theme, engine default), with the session's registered catalogs, so the editor measures and composes what the renderer draws
33
+ // and the exporter writes. `options.fonts` is the renderer's fonts handle (its `textMeasurement` measures); `options.catalogs`
34
+ // adds catalogs for this call; `options.onDiagnostic` hears each diagnostic (`unresolved-reference`);
12
35
  // any other option overrides the resolved one (`layout`, ...).
13
- function slideContext(presentation, slideIndex, { fonts, catalogs, onDiagnostic, ...overrides } = {}) {
36
+ function slideContext(presentation, slideIndex, catalogs, { fonts, catalogs: _extra, onDiagnostic, ...overrides } = {}) {
14
37
  if (!Number.isInteger(slideIndex) || !presentation.slides?.[slideIndex]) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
15
38
  const { options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs });
16
39
  for (const diagnostic of diagnostics) onDiagnostic?.(diagnostic);
@@ -123,7 +146,10 @@ export function deckStats(editor, options) {
123
146
 
124
147
  export function createEditorSession(input, options = {}) {
125
148
  let presentation = parseInput(input);
126
- let validation = checkFormat(presentation);
149
+ // The host's registered catalogs (FA-23): one list, handed to every core call, the renderer and the exporter.
150
+ let catalogs = mergeCatalogs(options.catalogs);
151
+ const check = (document) => checkFormat(document, { catalogs });
152
+ let validation = check(presentation);
127
153
  const undoStack = [];
128
154
  const redoStack = [];
129
155
  const listeners = new Set();
@@ -138,7 +164,7 @@ export function createEditorSession(input, options = {}) {
138
164
 
139
165
  function commitPatch(operations, meta = {}) {
140
166
  assertPatchOperations(operations);
141
- const patches = operations.map(normalizeOperation);
167
+ let patches = operations.map(normalizeOperation);
142
168
  const before = presentation;
143
169
  let next, inversePatches;
144
170
  try {
@@ -146,7 +172,24 @@ export function createEditorSession(input, options = {}) {
146
172
  } catch (error) {
147
173
  throw editorPatchError(error);
148
174
  }
149
- const nextValidation = checkFormat(next);
175
+ // Owner rule (FA-23): an edit of a record under catalogs.default or a named group forks it into catalogs.custom (every reference
176
+ // follows, the original is dropped), in this same undo step. Catalog updates and moves change catalog records on purpose.
177
+ let forks = [];
178
+ if (meta.catalogRecordEdit !== "in-place") {
179
+ const forked = forkEditedRecords(before, next, { catalogs, forkIds: meta.forkIds });
180
+ if (forked.forks.length) {
181
+ forks = forked.forks;
182
+ for (const key of new Set([...Object.keys(next), ...Object.keys(forked.document)]))
183
+ if (!jsonEqual(next[key], forked.document[key])) patches = [...patches, forked.document[key] === undefined ? { op: "remove", path: formatPointer([key]) } : { op: Object.hasOwn(next, key) ? "replace" : "add", path: formatPointer([key]), value: clone(forked.document[key]) }];
184
+ try {
185
+ ({ presentation: next, inverse: inversePatches } = applyPatchWithInverse(before, patches));
186
+ } catch (error) {
187
+ throw editorPatchError(error);
188
+ }
189
+ meta = { ...meta, forked: forks, notice: forks.map(forkNotice).join(" ") };
190
+ }
191
+ }
192
+ const nextValidation = check(next);
150
193
 
151
194
  if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
152
195
  throw new OPFEditorError("invalid-opf-edit", "OPF edit produced an invalid document.", {
@@ -174,7 +217,8 @@ export function createEditorSession(input, options = {}) {
174
217
  presentation: clone(presentation),
175
218
  patches: clonePatchOperations(patches),
176
219
  inversePatches: clonePatchOperations(inversePatches),
177
- validation
220
+ validation,
221
+ ...(forks.length ? { forked: forks, notice: meta.notice } : {})
178
222
  };
179
223
  }
180
224
 
@@ -185,6 +229,20 @@ export function createEditorSession(input, options = {}) {
185
229
  get validation() {
186
230
  return validation;
187
231
  },
232
+ /** The catalogs the host registered (`Catalog[]`, frozen): the first is the host default. */
233
+ get catalogs() {
234
+ return catalogs;
235
+ },
236
+ /**
237
+ * Replace the registered catalogs (for example after the host loads a company catalog). The document is not changed; it is
238
+ * re-validated against the new list (reference findings depend on it) and one `catalogs` event tells every view to redraw.
239
+ */
240
+ setCatalogs(next, meta = {}) {
241
+ catalogs = mergeCatalogs(next);
242
+ validation = check(presentation);
243
+ emit({ type: "catalogs", patches: [], validation, meta });
244
+ return catalogs;
245
+ },
188
246
  get canUndo() {
189
247
  return undoStack.length > 0;
190
248
  },
@@ -218,13 +276,15 @@ export function createEditorSession(input, options = {}) {
218
276
  });
219
277
  },
220
278
  composeSlide(slideIndex, options = {}) {
221
- return composeSlide(presentation.slides?.[slideIndex], slideContext(presentation, slideIndex, options));
279
+ return composeSlide(presentation.slides?.[slideIndex], slideContext(presentation, slideIndex, mergeCatalogs(catalogs, options.catalogs), options));
222
280
  },
223
281
  paginateSlide(slideIndex, options = {}, meta = {}) {
282
+ const list = mergeCatalogs(catalogs, options.catalogs);
224
283
  // Core pagination reads the measurement from `fonts`, so the resolved context hands it over that way.
225
- const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, options);
284
+ const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options);
226
285
  const pagination = paginateSlide(presentation.slides[slideIndex], {
227
286
  ...resolved,
287
+ catalogs: list,
228
288
  fonts: options.fonts ?? (textMeasurement ? { textMeasurement } : undefined),
229
289
  reservedIds: collectReservedPresentationIds(presentation),
230
290
  });
@@ -267,7 +327,7 @@ export function createEditorSession(input, options = {}) {
267
327
  restoreState(state, meta = {}) {
268
328
  if (!state || typeof state !== "object") throw new OPFEditorError("invalid-state", "restoreState needs { presentation, undo?, redo? }.");
269
329
  const next = parseInput(state.presentation);
270
- const nextValidation = checkFormat(next);
330
+ const nextValidation = check(next);
271
331
  if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
272
332
  throw new OPFEditorError("invalid-opf-edit", "The restored document is not valid OPF.", { issues: errorFindings(nextValidation) });
273
333
  }
@@ -304,7 +364,7 @@ export function createEditorSession(input, options = {}) {
304
364
  const entry = undoStack.pop();
305
365
  if (!entry) return null;
306
366
  presentation = applyJsonPatch(presentation, entry.inversePatches);
307
- validation = checkFormat(presentation);
367
+ validation = check(presentation);
308
368
  redoStack.push(entry);
309
369
  emit({
310
370
  type: "undo",
@@ -324,7 +384,7 @@ export function createEditorSession(input, options = {}) {
324
384
  const entry = redoStack.pop();
325
385
  if (!entry) return null;
326
386
  presentation = applyJsonPatch(presentation, entry.patches);
327
- validation = checkFormat(presentation);
387
+ validation = check(presentation);
328
388
  undoStack.push(entry);
329
389
  emit({
330
390
  type: "redo",
@@ -415,29 +475,29 @@ export function createSvgTraceBinding(root, editor, options = {}) {
415
475
  };
416
476
  }
417
477
 
478
+ /**
479
+ * The records a catalog control can offer for `catalogKind`, from core's `catalogRecords`: the document's embedded records,
480
+ * then the registered catalogs' (`options.catalogs`, merged with `options.editor`'s). Each is the record itself.
481
+ */
418
482
  export function getCatalogRecords(catalogKind, options = {}) {
419
- assertCatalogKind(catalogKind);
420
- const presentationCatalog = options.presentation?.catalogs?.[catalogKind];
421
- const explicitCatalog = options.catalogs?.[catalogKind] ?? options.catalogSources?.[catalogKind];
422
-
423
- return firstRecords(presentationCatalog)
424
- ?? firstRecords(explicitCatalog)
425
- ?? firstRecords(bundledCatalogs[catalogKind])
426
- ?? [];
483
+ return getCatalogOptions(catalogKind, options).map((option) => option.record);
427
484
  }
428
485
 
486
+ /**
487
+ * Picker options for `catalogKind`: `{ id, reference, label, record, group, source, origin }`, where `id` (= `reference`) is the
488
+ * reference to write, `id` or `name:id`. `options.presentation` is the document (default: `options.editor`'s), `options.catalogs`
489
+ * the host catalogs (merged after the session's registered ones).
490
+ */
429
491
  export function getCatalogOptions(catalogKind, options = {}) {
430
- return getCatalogRecords(catalogKind, options).map((record) => ({
431
- id: record.id,
432
- label: catalogLabel(record),
433
- record
434
- }));
492
+ assertCatalogKind(catalogKind);
493
+ const presentation = options.presentation ?? options.editor?.presentation ?? {};
494
+ return listCatalogRecords(presentation, catalogKind, { catalogs: mergeCatalogs(options.editor?.catalogs, options.catalogs) });
435
495
  }
436
496
 
437
497
  export function assertCatalogId(catalogKind, id, options = {}) {
438
498
  assertCatalogKind(catalogKind);
439
499
  if (typeof id !== "string" || id.length === 0) {
440
- throw new OPFEditorError("invalid-catalog-id", "Catalog control values must be non-empty catalog IDs.", {
500
+ throw new OPFEditorError("invalid-catalog-id", "Catalog control values must be non-empty catalog references.", {
441
501
  catalogKind,
442
502
  id
443
503
  });
@@ -445,7 +505,7 @@ export function assertCatalogId(catalogKind, id, options = {}) {
445
505
 
446
506
  const optionsById = new Set(getCatalogOptions(catalogKind, options).map((option) => option.id));
447
507
  if (!optionsById.has(id)) {
448
- throw new OPFEditorError("unknown-catalog-id", `Unknown ${catalogKind} catalog ID: ${id}.`, {
508
+ throw new OPFEditorError("unknown-catalog-id", `Unknown ${catalogKind} catalog reference: ${id}.`, {
449
509
  catalogKind,
450
510
  id
451
511
  });
@@ -454,18 +514,29 @@ export function assertCatalogId(catalogKind, id, options = {}) {
454
514
  return id;
455
515
  }
456
516
 
517
+ /** Write the catalog reference `id` at `path` (an object reference keeps its overrides and takes the new `id`). */
457
518
  export function setCatalogId(editor, path, catalogKind, id, meta = {}) {
458
519
  assertEditorSession(editor);
459
520
  const catalogId = assertCatalogId(catalogKind, id, {
460
521
  presentation: meta.presentation ?? editor.presentation,
461
- catalogs: meta.catalogs,
462
- catalogSources: meta.catalogSources
522
+ catalogs: mergeCatalogs(editor.catalogs, meta.catalogs)
463
523
  });
464
524
  const current = editor.get(path);
465
525
  const value = catalogReferenceValue(current, catalogId);
466
- const { catalogs, catalogSources, presentation, ...commitMeta } = meta;
467
- return editor.set(path, value, {
526
+ const { catalogs: _catalogs, presentation: _presentation, ...commitMeta } = meta;
527
+ // Only documented design references may initialize their optional design parent.
528
+ // Ordinary set/applyPatch retain RFC 6902's existing-parent requirement.
529
+ const segments = splitOpfPath(path);
530
+ const designField = { themes: "theme", colorSchemes: "colorScheme", fontSchemes: "fontScheme" }[catalogKind];
531
+ const deckDesign = segments.length === 2 && segments[0] === "design";
532
+ const slideDesign = segments.length === 4 && segments[0] === "slides" && /^(0|[1-9][0-9]*)$/.test(segments[1]) && segments[2] === "design" && editor.get(segments.slice(0, 2));
533
+ const parent = segments.slice(0, -1);
534
+ const patches = designField === segments.at(-1) && (deckDesign || slideDesign) && !hasValueAtPath(editor.presentation, parent)
535
+ ? [{ op: "add", path: opfPathToJsonPointer(parent), value: { [designField]: value } }]
536
+ : createValuePatch(editor.presentation, path, value);
537
+ return editor.applyPatch(patches, {
468
538
  ...commitMeta,
539
+ rejectInvalid: true,
469
540
  catalogKind,
470
541
  catalogId,
471
542
  source: commitMeta.source ?? "catalog-control"
@@ -517,7 +588,7 @@ export function createCatalogSelect(editor, options) {
517
588
  if (options?.label) select.setAttribute("aria-label", options.label);
518
589
 
519
590
  for (const option of getCatalogOptions(catalogKind, {
520
- ...options,
591
+ catalogs: mergeCatalogs(editor.catalogs, options?.catalogs),
521
592
  presentation: options?.presentation ?? editor.presentation
522
593
  })) {
523
594
  const optionElement = documentRef.createElement("option");
@@ -530,14 +601,13 @@ export function createCatalogSelect(editor, options) {
530
601
  const current = editor.get(path);
531
602
  select.value = typeof current === "object" && current ? current.id : current ?? "";
532
603
  };
533
- const onChange = () => {
534
- setCatalogId(editor, path, catalogKind, select.value, {
604
+ const onChange = () => commitCatalogControl(select, options?.onError, updateValue, () => {
605
+ return setCatalogId(editor, path, catalogKind, select.value, {
535
606
  catalogs: options?.catalogs,
536
- catalogSources: options?.catalogSources,
537
607
  presentation: options?.presentation,
538
608
  source: "catalog-select"
539
609
  });
540
- };
610
+ });
541
611
  const unsubscribe = editor.subscribe(updateValue);
542
612
 
543
613
  select.addEventListener?.("change", onChange);
@@ -677,20 +747,6 @@ function assertCatalogKind(catalogKind) {
677
747
  }
678
748
  }
679
749
 
680
- function firstRecords(value) {
681
- if (Array.isArray(value)) return value.filter(isCatalogRecord);
682
- if (Array.isArray(value?.records)) return value.records.filter(isCatalogRecord);
683
- return null;
684
- }
685
-
686
- function isCatalogRecord(record) {
687
- return Boolean(record && typeof record === "object" && typeof record.id === "string");
688
- }
689
-
690
- function catalogLabel(record) {
691
- return String(record.name ?? record.title ?? record.label ?? record.id);
692
- }
693
-
694
750
  function catalogReferenceValue(current, id) {
695
751
  if (current && typeof current === "object" && !Array.isArray(current)) {
696
752
  return { ...current, id };
@@ -103,7 +103,7 @@ function skipDelimiter(view, delimiter) {
103
103
  }
104
104
  function mountJsonCodeEditor(parent, options) {
105
105
  const attributes = new Compartment(), indent = new Compartment();
106
- let disposed=false, menu=null, catalogs=options.catalogs??{}, generation=0;
106
+ let disposed=false, menu=null, catalogs=options.catalogs??[], generation=0;
107
107
  function closeOptions(){generation++;menu?.destroy();menu=null;}
108
108
  async function openOptions(left,top){
109
109
  closeOptions();
@@ -244,7 +244,7 @@ function mountJsonCodeEditor(parent, options) {
244
244
  return {
245
245
  api,
246
246
  openOptions,
247
- setCatalogs(value){catalogs=value??{};closeOptions();},
247
+ setCatalogs(value){catalogs=value??[];closeOptions();},
248
248
  update(code, invalid=false, field=null, describedBy) {
249
249
  if(code!==api.getValue())closeOptions();
250
250
  const current = view.state.doc.toString(), normalized=textInputMap(code).text;
package/dist/json-menu.js CHANGED
@@ -26,9 +26,9 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
26
26
  const error=add('p',undefined,'json-field-empty');error.setAttribute('role','alert');error.hidden=true;
27
27
  if(isLayout){
28
28
  const help=add('details',undefined,'json-field-catalog-help');add('summary','Where do these layouts come from?',undefined,help);
29
- add('p','Standard OPF: included with OPF. Provided by app: extra records supplied by the host app. In this document: records in your JSON, which override matching IDs. External galleries are not automatically loaded.',undefined,help);
29
+ add('p','Provided by app: records from the catalogs the host app registered. In this document: records embedded in your JSON, which take precedence. Nothing is fetched; saving embeds every record the document uses.',undefined,help);
30
30
  }
31
- add('p',context.unloadedSource?'Using available records. External catalog URLs are not loaded here.':'Choose an option or press Escape to keep editing JSON.','json-field-footnote');
31
+ add('p',context.unloadedSource?'Using the embedded records: a catalog this document names is not registered here.':'Choose an option or press Escape to keep editing JSON.','json-field-footnote');
32
32
  let selected=Math.max(0,context.options.findIndex(option=>option.value===context.value)),choices=[],disposed=false;
33
33
  function close(restore=false){if(disposed)return;disposed=true;observer.disconnect();root.remove();doc.removeEventListener('pointerdown',outside);doc.removeEventListener('wheel',outside,true);doc.removeEventListener('touchmove',outside,true);win.removeEventListener('resize',resize);onClose();if(restore){api.focus();api.setSelection(...selection);}}
34
34
  function outside(event){if(!root.contains(event.target))close();}
@@ -1,11 +1,11 @@
1
- /** Already-loaded catalog records by kind. No remote catalog URLs are fetched. */
2
- export type JsonCatalogContext = Readonly<Record<string, readonly Record<string, unknown>[]>>;
1
+ /** The host's catalogs (core `Catalog[]`): records offered after the document's embedded ones. */
2
+ export type JsonCatalogContext = readonly import("@openpresentation/opf").Catalog[];
3
3
  export type JsonOptionValue = string | number | boolean;
4
4
  export interface JsonFieldOption {
5
5
  value: JsonOptionValue;
6
6
  label: string;
7
7
  description?: string;
8
- source: 'Built-in catalog' | 'Loaded catalog' | 'Document catalog' | 'Schema' | 'Current value';
8
+ source: 'Loaded catalog' | 'Document catalog' | 'Schema' | 'Current value';
9
9
  suggested?: boolean;
10
10
  /** User-facing provenance; source remains stable for existing consumers. */
11
11
  sourceLabel?: string;
@@ -1,19 +1,29 @@
1
- import { catalogKinds, catalogSchemaNames, schemas, validateCatalogRecord } from "@openpresentation/opf";
2
1
  import { checkFormat, firstErrorMessage } from "./checks.js";
3
- import { getCatalogOptions } from "./index.js";
2
+ import { listCatalogRecords } from "./catalogs.js";
4
3
  import { schemaAtPath, schemaVariants } from "./schema.js";
5
4
  import { applyEdits, findNodeAtLocation, findNodeAtOffset, getNodePath, modify, parseTree } from "jsonc-parser";
6
- import { populateLayoutPlaceholders } from "./layout-placeholders.js";
7
- function catalogFor(description) {
8
- return catalogKinds.find(kind => description.includes(`catalogs.${kind}`)
9
- || new RegExp(`\\b${kind}['’]? catalog`).test(description));
5
+ import { layoutSlotSummary, layoutSlotTypes, populateLayoutPlaceholders } from "./layout-placeholders.js";
6
+ // The catalog kind a field references (OPF 0.15: every content kind is `id` or `name:id`), read from where the field sits.
7
+ const ROOT_REFERENCES = { narrative: "narratives", audience: "audiences", purpose: "purposes", tone: "tones" };
8
+ const DESIGN_REFERENCES = { theme: "themes", colorScheme: "colorSchemes", fontScheme: "fontSchemes" };
9
+ function catalogAtPath(path) {
10
+ const parts = path.map(String);
11
+ // A reference's object form names its record with `id`; an audience array's entries are references too.
12
+ const field = parts.at(-1) === "id" ? parts.slice(0, -1) : parts;
13
+ const tail = /^\d+$/.test(field.at(-1) ?? "") && field.at(-2) === "audience" ? field.slice(0, -1) : field;
14
+ const key = tail.at(-1);
15
+ if (tail.length === 3 && tail[0] === "slides" && key === "layout") return "layouts";
16
+ if (tail.length === 1 && ROOT_REFERENCES[key]) return ROOT_REFERENCES[key];
17
+ if (DESIGN_REFERENCES[key] && ((tail.length === 2 && tail[0] === "design") || (tail.length === 4 && tail[0] === "slides" && tail[2] === "design"))) return DESIGN_REFERENCES[key];
18
+ // Inside an embedded record: a theme's colour or font scheme.
19
+ if (tail.length === 5 && tail[0] === "catalogs" && tail[2] === "themes" && (key === "colorScheme" || key === "fontScheme")) return DESIGN_REFERENCES[key];
20
+ return undefined;
10
21
  }
11
22
  // Compare declared placeholders, including multiplicity. This describes a layout
12
23
  // contract, not a claim that arbitrary slide content will render without overflow.
24
+ // FA-26: a record's placeholder groups compare by their leaf regions, the slots its content fills.
13
25
  function layoutTypes(record) {
14
- if (!Array.isArray(record?.placeholders))
15
- return undefined;
16
- return record.placeholders.map(p => p.type).sort();
26
+ return layoutSlotTypes(record)?.sort();
17
27
  }
18
28
  function placeholderSummary(types) {
19
29
  if (!types)
@@ -28,9 +38,8 @@ function placeholderSummary(types) {
28
38
  }).join(" + ");
29
39
  }
30
40
  const sourceDetails = {
31
- "Built-in catalog": ["Standard OPF", "Included with OPF and available offline."],
32
- "Loaded catalog": ["Provided by app", "Additional records supplied to the preview by this app; these are not fetched from a URL."],
33
- "Document catalog": ["In this document", "Defined in this document’s catalogs. Takes precedence over app and standard records with the same ID."],
41
+ "Loaded catalog": ["Provided by app", "Records from a catalog this app registered; nothing is fetched from a URL. Saving embeds the record in the document."],
42
+ "Document catalog": ["In this document", "Embedded in this document’s catalogs. Takes precedence over the app’s records with the same reference."],
34
43
  "Current value": ["Custom value", "The current value is not in the available catalogs."],
35
44
  "Schema": ["OPF option", "Allowed by the OPF schema."],
36
45
  };
@@ -47,8 +56,10 @@ function describeOptions(choices, records, current, catalog) {
47
56
  if (catalog !== "layouts")
48
57
  continue;
49
58
  const types = layoutTypes(option.value === current ? currentRecord : records.get(String(option.value)));
50
- option.placeholderTypes = records.get(String(option.value))?.placeholders?.map(placeholder => placeholder.type);
51
- option.placeholders = placeholderSummary(types);
59
+ const record = records.get(String(option.value));
60
+ option.placeholderTypes = layoutSlotTypes(record);
61
+ // A record with placeholder groups also shows how its slots nest.
62
+ option.placeholders = placeholderSummary(types) + (record?.placeholders?.some?.(entry => entry?.type === "group") ? ` · ${layoutSlotSummary(record)}` : "");
52
63
  const same = types && currentTypes && signature(types) === signature(currentTypes);
53
64
  const equivalent = types && currentTypes && signature(compatible(types)) === signature(compatible(currentTypes));
54
65
  const sameKinds = types && currentTypes && signature([...new Set(compatible(types))]) === signature([...new Set(compatible(currentTypes))]);
@@ -64,7 +75,7 @@ function describeOptions(choices, records, current, catalog) {
64
75
  }
65
76
  // The installed schema identifies fields; a document field merely named "layout"
66
77
  // inside arbitrary metadata is not mistaken for an OPF layout reference.
67
- export function getJsonFieldContext(source, position, loadedCatalogs = {}) {
78
+ export function getJsonFieldContext(source, position, loadedCatalogs = []) {
68
79
  let presentation;
69
80
  try {
70
81
  presentation = JSON.parse(source);
@@ -87,42 +98,21 @@ export function getJsonFieldContext(source, position, loadedCatalogs = {}) {
87
98
  return null;
88
99
  const schema = schemaAtPath(presentation, path.map(String));
89
100
  const variants = schemaVariants(schema);
90
- const description = String(schema.description ?? "");
91
- let catalog = catalogFor(description);
92
- // Audience arrays inherit the catalog context from the array property.
93
- if (!catalog && typeof path.at(-1) === "number")
94
- catalog = catalogFor(String(schemaAtPath(presentation, path.slice(0, -1).map(String)).description ?? ""));
101
+ const catalog = catalogAtPath(path);
95
102
  const options = new Map();
96
103
  const records = new Map();
97
- let unloadedSource = false;
104
+ // A document group whose source no registered catalog matches: its records beyond the embedded ones are not available here.
105
+ const registered = new Set((Array.isArray(loadedCatalogs) ? loadedCatalogs : []).map(entry => entry?.source));
106
+ const unloadedSource = Boolean(catalog) && Object.entries(presentation.catalogs ?? {}).some(([name, group]) => name !== "custom" && group && typeof group === "object" && typeof group.source === "string" && !registered.has(group.source));
98
107
  if (catalog && typeof node.value === "string") {
99
- const entries = presentation.catalogs;
100
- const local = entries?.[catalog];
101
- unloadedSource = Boolean(local?.source);
102
- for (const option of getCatalogOptions(catalog)) {
108
+ const hostCatalogs = Array.isArray(loadedCatalogs) ? loadedCatalogs : [];
109
+ // A draft whose own catalogs are malformed still offers the host's records; a malformed host list offers nothing.
110
+ const attempt = (document) => { try { return listCatalogRecords(document, catalog, { catalogs: hostCatalogs }); } catch { return undefined; } };
111
+ const available = attempt(presentation) ?? attempt({ ...presentation, catalogs: undefined }) ?? [];
112
+ for (const option of available) {
103
113
  records.set(option.id, option.record);
104
- options.set(option.id, { value: option.id, label: option.label, description: typeof option.record.description === "string" ? option.record.description : undefined,
105
- source: "Built-in catalog" });
106
- }
107
- for (const { value, source } of [...(Array.isArray(loadedCatalogs?.[catalog]) ? loadedCatalogs[catalog] : []).map(value => ({ value, source: "Loaded catalog" })),
108
- ...(Array.isArray(local?.records) ? local.records : []).map(value => ({ value, source: "Document catalog" }))]) {
109
- if (!value || typeof value !== "object")
110
- continue;
111
- const record = value;
112
- if (typeof record.id !== "string")
113
- continue;
114
- // Inline records may omit the standalone document's $schema marker.
115
- // Supply it only for validation; never change the authored record.
116
- const schemaId = schemas[catalogSchemaNames[catalog]].$id;
117
- if (!validateCatalogRecord(catalog, { $schema: schemaId, ...record }).valid) {
118
- options.delete(record.id);
119
- records.delete(record.id);
120
- continue;
121
- }
122
- records.set(record.id, record);
123
- options.set(record.id, { value: record.id, label: typeof record.name === "string" ? record.name : record.id,
124
- description: typeof record.description === "string" ? record.description : undefined,
125
- source });
114
+ options.set(option.id, { value: option.id, label: option.label, description: option.description,
115
+ source: option.origin === "document" ? "Document catalog" : "Loaded catalog" });
126
116
  }
127
117
  }
128
118
  else {
@@ -60,6 +60,19 @@ export function createLayoutHandles(root, options) {
60
60
  lastGeometry=geometry;
61
61
  layer.replaceChildren();
62
62
  if(!enabled||options.isTextEditing())return;
63
+ // FA-26: the slots of a layout record with placeholder groups, drawn under the handles while arranging: each group and
64
+ // region as a dashed outline at its cell, deeper groups inset, an empty region labelled with its kind. They are the
65
+ // record's, not the document's, so they are not editable here; the content in a slot keeps its own selection.
66
+ for(const slot of geometry.slots??[]){
67
+ const node=doc.createElement('div');
68
+ node.dataset.layoutSlot=slot.path;node.dataset.layoutSlotType=slot.type;node.setAttribute('aria-hidden','true');
69
+ const group=slot.type==='group',empty=!group&&!slot.content;
70
+ node.title=group?`Layout group (${slot.path})`:`${empty?'Empty ':''}${slot.type} slot (${slot.path})`;
71
+ const inset=group?Math.min(4,slot.depth*2):0;
72
+ node.style.cssText=`position:absolute;pointer-events:none;box-sizing:border-box;left:calc(${slot.box.x/geometry.width*100}% + ${inset}px);top:calc(${slot.box.y/geometry.height*100}% + ${inset}px);width:calc(${slot.box.width/geometry.width*100}% - ${2*inset}px);height:calc(${slot.box.height/geometry.height*100}% - ${2*inset}px);border:1px dashed ${group?'#8170d5':'#8a78d7aa'};border-radius:4px;${empty?'background:#8a78d714;':''}`;
73
+ if(empty){const label=doc.createElement('span');label.textContent=`Empty ${slot.type} slot`;label.style.cssText='position:absolute;left:6px;top:4px;font:11px system-ui;color:#675394';node.append(label);}
74
+ layer.append(node);
75
+ }
63
76
  for(const flow of geometry.flows??[]){
64
77
  const {axis,size,tracks}=trackData(flow);
65
78
  if(tracks.length<2||tracks.length>12||flow.itemCount!==flow.slotCount)continue;
@@ -1,5 +1,20 @@
1
+ import {layoutLeaves,layoutStructure} from '@openpresentation/opf/composition';
1
2
  import {listBlockContainers,prepareBlockInsert} from './blocks.js';
2
3
 
4
+ /**
5
+ * The slot kinds a layout record declares, in reading order: its headings and the leaf regions of its placeholder groups
6
+ * (FA-26), the kinds a slide's flat content fills. Undefined when the record states no placeholders.
7
+ */
8
+ export function layoutSlotTypes(record){
9
+ if(!Array.isArray(record?.placeholders))return undefined;
10
+ try{return layoutLeaves(record).map(leaf=>leaf.type);}catch{return record.placeholders.filter(entry=>entry?.type!=='group').map(entry=>entry?.type);}
11
+ }
12
+ /** A one-line description of a record's slots, nested groups in parentheses: `title, column (text, text), chart`. */
13
+ export function layoutSlotSummary(record){
14
+ if(!Array.isArray(record?.placeholders))return '';
15
+ try{return layoutStructure(record);}catch{return layoutSlotTypes(record).join(', ');}
16
+ }
17
+
3
18
  const payloadFields=['text','items','bullets','image','video','chart','table','code','metric','quote','timeline'];
4
19
  const headingFields=new Set(['title','subtitle','tag']);
5
20
  const fields={text:'text',list:'items',image:'image',video:'video',chart:'chart',table:'table',code:'code',metric:'metric',quote:'quote',timeline:'timeline'};
package/dist/react.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface OPFCatalogSelectProps {
14
14
  path: string;
15
15
  catalogKind: string;
16
16
  label?: string;
17
+ onError?: (error: Error) => void;
17
18
  catalogOptions?: CatalogOption[];
18
19
  selectProps?: Record<string, unknown>;
19
20
  [key: string]: unknown;
package/dist/react.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commitCatalogControl } from "./catalog-control.js";
1
2
  import {
2
3
  getCatalogOptions,
3
4
  getValueAtPath,
@@ -56,13 +57,14 @@ export function createOPFReactComponents(React) {
56
57
  catalogKind,
57
58
  label,
58
59
  catalogOptions,
60
+ onError,
59
61
  selectProps = {},
60
62
  ...props
61
63
  }) {
62
64
  const snapshot = useEditorSnapshot(editor);
63
65
  const value = getValueAtPath(snapshot.presentation, path, "");
64
66
  const currentId = value && typeof value === "object" ? value.id : value;
65
- const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.presentation });
67
+ const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.presentation, catalogs: editor.catalogs });
66
68
 
67
69
  return React.createElement(
68
70
  "select",
@@ -78,7 +80,9 @@ export function createOPFReactComponents(React) {
78
80
  selectProps.onChange?.(event);
79
81
  props.onChange?.(event);
80
82
  if (!event.defaultPrevented) {
81
- setCatalogId(editor, path, catalogKind, event.currentTarget.value, { source: "react-catalog-select" });
83
+ const node = event.currentTarget;
84
+ commitCatalogControl(node, onError, () => { node.value = currentId ?? ""; }, () =>
85
+ setCatalogId(editor, path, catalogKind, node.value, { source: "react-catalog-select" }));
82
86
  }
83
87
  }
84
88
  },