@openpresentation/opf-editor 0.14.2 → 0.15.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/dist/index.js CHANGED
@@ -2,15 +2,37 @@ import { paginateSlide } from "@openpresentation/opf/pagination";
2
2
  import { OPFPatchError, applyPatch as applyCorePatch, applyPatchWithInverse, formatPointer, invertPatch, jsonEqual, parsePointer, readPointer } from "@openpresentation/opf/patch";
3
3
  import { collectReservedPresentationIds } from "./presentation-ids.js";
4
4
  import { composeSlide } from "@openpresentation/opf/composition";
5
- import { catalogKinds, catalogs as bundledCatalogs, resolveSlideContext, stats } from "@openpresentation/opf";
5
+ import { catalogKinds, resolveSlideContext, stats } from "@openpresentation/opf";
6
6
  import { checkFormat, errorFindings } from "./checks.js";
7
+ import { forkEditedRecords, forkNotice, listCatalogRecords, mergeCatalogs } from "./catalogs.js";
8
+
9
+ export {
10
+ REFERENCE_FINDING_CODES,
11
+ applyCatalogUpdate,
12
+ catalogRecordAt,
13
+ catalogRecordLabel,
14
+ catalogsFor,
15
+ checkCatalogUpdates,
16
+ editedCatalogRecords,
17
+ FORKED_RECORD_KINDS,
18
+ forkEditedRecords,
19
+ forkNotice,
20
+ listCatalogRecords,
21
+ mergeCatalogs,
22
+ moveToCustom,
23
+ prepareMoveToCustom,
24
+ prepareSave,
25
+ referenceFindings,
26
+ saveDocument,
27
+ } from "./catalogs.js";
7
28
 
8
29
  // The options `composeSlide` and `paginateSlide` take for one slide of the open deck. Core's `resolveSlideContext` resolves the
9
30
  // 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;
31
+ // theme, engine default), with the session's registered catalogs, so the editor measures and composes what the renderer draws
32
+ // and the exporter writes. `options.fonts` is the renderer's fonts handle (its `textMeasurement` measures); `options.catalogs`
33
+ // adds catalogs for this call; `options.onDiagnostic` hears each diagnostic (`unresolved-reference`);
12
34
  // any other option overrides the resolved one (`layout`, ...).
13
- function slideContext(presentation, slideIndex, { fonts, catalogs, onDiagnostic, ...overrides } = {}) {
35
+ function slideContext(presentation, slideIndex, catalogs, { fonts, catalogs: _extra, onDiagnostic, ...overrides } = {}) {
14
36
  if (!Number.isInteger(slideIndex) || !presentation.slides?.[slideIndex]) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
15
37
  const { options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs });
16
38
  for (const diagnostic of diagnostics) onDiagnostic?.(diagnostic);
@@ -123,7 +145,10 @@ export function deckStats(editor, options) {
123
145
 
124
146
  export function createEditorSession(input, options = {}) {
125
147
  let presentation = parseInput(input);
126
- let validation = checkFormat(presentation);
148
+ // The host's registered catalogs (FA-23): one list, handed to every core call, the renderer and the exporter.
149
+ let catalogs = mergeCatalogs(options.catalogs);
150
+ const check = (document) => checkFormat(document, { catalogs });
151
+ let validation = check(presentation);
127
152
  const undoStack = [];
128
153
  const redoStack = [];
129
154
  const listeners = new Set();
@@ -138,7 +163,7 @@ export function createEditorSession(input, options = {}) {
138
163
 
139
164
  function commitPatch(operations, meta = {}) {
140
165
  assertPatchOperations(operations);
141
- const patches = operations.map(normalizeOperation);
166
+ let patches = operations.map(normalizeOperation);
142
167
  const before = presentation;
143
168
  let next, inversePatches;
144
169
  try {
@@ -146,7 +171,24 @@ export function createEditorSession(input, options = {}) {
146
171
  } catch (error) {
147
172
  throw editorPatchError(error);
148
173
  }
149
- const nextValidation = checkFormat(next);
174
+ // Owner rule (FA-23): an edit of a record under catalogs.default or a named group forks it into catalogs.custom (every reference
175
+ // follows, the original is dropped), in this same undo step. Catalog updates and moves change catalog records on purpose.
176
+ let forks = [];
177
+ if (meta.catalogRecordEdit !== "in-place") {
178
+ const forked = forkEditedRecords(before, next, { catalogs, forkIds: meta.forkIds });
179
+ if (forked.forks.length) {
180
+ forks = forked.forks;
181
+ for (const key of new Set([...Object.keys(next), ...Object.keys(forked.document)]))
182
+ 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]) }];
183
+ try {
184
+ ({ presentation: next, inverse: inversePatches } = applyPatchWithInverse(before, patches));
185
+ } catch (error) {
186
+ throw editorPatchError(error);
187
+ }
188
+ meta = { ...meta, forked: forks, notice: forks.map(forkNotice).join(" ") };
189
+ }
190
+ }
191
+ const nextValidation = check(next);
150
192
 
151
193
  if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
152
194
  throw new OPFEditorError("invalid-opf-edit", "OPF edit produced an invalid document.", {
@@ -174,7 +216,8 @@ export function createEditorSession(input, options = {}) {
174
216
  presentation: clone(presentation),
175
217
  patches: clonePatchOperations(patches),
176
218
  inversePatches: clonePatchOperations(inversePatches),
177
- validation
219
+ validation,
220
+ ...(forks.length ? { forked: forks, notice: meta.notice } : {})
178
221
  };
179
222
  }
180
223
 
@@ -185,6 +228,20 @@ export function createEditorSession(input, options = {}) {
185
228
  get validation() {
186
229
  return validation;
187
230
  },
231
+ /** The catalogs the host registered (`Catalog[]`, frozen): the first is the host default. */
232
+ get catalogs() {
233
+ return catalogs;
234
+ },
235
+ /**
236
+ * Replace the registered catalogs (for example after the host loads a company catalog). The document is not changed; it is
237
+ * re-validated against the new list (reference findings depend on it) and one `catalogs` event tells every view to redraw.
238
+ */
239
+ setCatalogs(next, meta = {}) {
240
+ catalogs = mergeCatalogs(next);
241
+ validation = check(presentation);
242
+ emit({ type: "catalogs", patches: [], validation, meta });
243
+ return catalogs;
244
+ },
188
245
  get canUndo() {
189
246
  return undoStack.length > 0;
190
247
  },
@@ -218,13 +275,15 @@ export function createEditorSession(input, options = {}) {
218
275
  });
219
276
  },
220
277
  composeSlide(slideIndex, options = {}) {
221
- return composeSlide(presentation.slides?.[slideIndex], slideContext(presentation, slideIndex, options));
278
+ return composeSlide(presentation.slides?.[slideIndex], slideContext(presentation, slideIndex, mergeCatalogs(catalogs, options.catalogs), options));
222
279
  },
223
280
  paginateSlide(slideIndex, options = {}, meta = {}) {
281
+ const list = mergeCatalogs(catalogs, options.catalogs);
224
282
  // Core pagination reads the measurement from `fonts`, so the resolved context hands it over that way.
225
- const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, options);
283
+ const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options);
226
284
  const pagination = paginateSlide(presentation.slides[slideIndex], {
227
285
  ...resolved,
286
+ catalogs: list,
228
287
  fonts: options.fonts ?? (textMeasurement ? { textMeasurement } : undefined),
229
288
  reservedIds: collectReservedPresentationIds(presentation),
230
289
  });
@@ -267,7 +326,7 @@ export function createEditorSession(input, options = {}) {
267
326
  restoreState(state, meta = {}) {
268
327
  if (!state || typeof state !== "object") throw new OPFEditorError("invalid-state", "restoreState needs { presentation, undo?, redo? }.");
269
328
  const next = parseInput(state.presentation);
270
- const nextValidation = checkFormat(next);
329
+ const nextValidation = check(next);
271
330
  if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
272
331
  throw new OPFEditorError("invalid-opf-edit", "The restored document is not valid OPF.", { issues: errorFindings(nextValidation) });
273
332
  }
@@ -304,7 +363,7 @@ export function createEditorSession(input, options = {}) {
304
363
  const entry = undoStack.pop();
305
364
  if (!entry) return null;
306
365
  presentation = applyJsonPatch(presentation, entry.inversePatches);
307
- validation = checkFormat(presentation);
366
+ validation = check(presentation);
308
367
  redoStack.push(entry);
309
368
  emit({
310
369
  type: "undo",
@@ -324,7 +383,7 @@ export function createEditorSession(input, options = {}) {
324
383
  const entry = redoStack.pop();
325
384
  if (!entry) return null;
326
385
  presentation = applyJsonPatch(presentation, entry.patches);
327
- validation = checkFormat(presentation);
386
+ validation = check(presentation);
328
387
  undoStack.push(entry);
329
388
  emit({
330
389
  type: "redo",
@@ -415,29 +474,29 @@ export function createSvgTraceBinding(root, editor, options = {}) {
415
474
  };
416
475
  }
417
476
 
477
+ /**
478
+ * The records a catalog control can offer for `catalogKind`, from core's `catalogRecords`: the document's embedded records,
479
+ * then the registered catalogs' (`options.catalogs`, merged with `options.editor`'s). Each is the record itself.
480
+ */
418
481
  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
- ?? [];
482
+ return getCatalogOptions(catalogKind, options).map((option) => option.record);
427
483
  }
428
484
 
485
+ /**
486
+ * Picker options for `catalogKind`: `{ id, reference, label, record, group, source, origin }`, where `id` (= `reference`) is the
487
+ * reference to write, `id` or `name:id`. `options.presentation` is the document (default: `options.editor`'s), `options.catalogs`
488
+ * the host catalogs (merged after the session's registered ones).
489
+ */
429
490
  export function getCatalogOptions(catalogKind, options = {}) {
430
- return getCatalogRecords(catalogKind, options).map((record) => ({
431
- id: record.id,
432
- label: catalogLabel(record),
433
- record
434
- }));
491
+ assertCatalogKind(catalogKind);
492
+ const presentation = options.presentation ?? options.editor?.presentation ?? {};
493
+ return listCatalogRecords(presentation, catalogKind, { catalogs: mergeCatalogs(options.editor?.catalogs, options.catalogs) });
435
494
  }
436
495
 
437
496
  export function assertCatalogId(catalogKind, id, options = {}) {
438
497
  assertCatalogKind(catalogKind);
439
498
  if (typeof id !== "string" || id.length === 0) {
440
- throw new OPFEditorError("invalid-catalog-id", "Catalog control values must be non-empty catalog IDs.", {
499
+ throw new OPFEditorError("invalid-catalog-id", "Catalog control values must be non-empty catalog references.", {
441
500
  catalogKind,
442
501
  id
443
502
  });
@@ -445,7 +504,7 @@ export function assertCatalogId(catalogKind, id, options = {}) {
445
504
 
446
505
  const optionsById = new Set(getCatalogOptions(catalogKind, options).map((option) => option.id));
447
506
  if (!optionsById.has(id)) {
448
- throw new OPFEditorError("unknown-catalog-id", `Unknown ${catalogKind} catalog ID: ${id}.`, {
507
+ throw new OPFEditorError("unknown-catalog-id", `Unknown ${catalogKind} catalog reference: ${id}.`, {
449
508
  catalogKind,
450
509
  id
451
510
  });
@@ -454,16 +513,16 @@ export function assertCatalogId(catalogKind, id, options = {}) {
454
513
  return id;
455
514
  }
456
515
 
516
+ /** Write the catalog reference `id` at `path` (an object reference keeps its overrides and takes the new `id`). */
457
517
  export function setCatalogId(editor, path, catalogKind, id, meta = {}) {
458
518
  assertEditorSession(editor);
459
519
  const catalogId = assertCatalogId(catalogKind, id, {
460
520
  presentation: meta.presentation ?? editor.presentation,
461
- catalogs: meta.catalogs,
462
- catalogSources: meta.catalogSources
521
+ catalogs: mergeCatalogs(editor.catalogs, meta.catalogs)
463
522
  });
464
523
  const current = editor.get(path);
465
524
  const value = catalogReferenceValue(current, catalogId);
466
- const { catalogs, catalogSources, presentation, ...commitMeta } = meta;
525
+ const { catalogs: _catalogs, presentation: _presentation, ...commitMeta } = meta;
467
526
  return editor.set(path, value, {
468
527
  ...commitMeta,
469
528
  catalogKind,
@@ -517,7 +576,7 @@ export function createCatalogSelect(editor, options) {
517
576
  if (options?.label) select.setAttribute("aria-label", options.label);
518
577
 
519
578
  for (const option of getCatalogOptions(catalogKind, {
520
- ...options,
579
+ catalogs: mergeCatalogs(editor.catalogs, options?.catalogs),
521
580
  presentation: options?.presentation ?? editor.presentation
522
581
  })) {
523
582
  const optionElement = documentRef.createElement("option");
@@ -533,7 +592,6 @@ export function createCatalogSelect(editor, options) {
533
592
  const onChange = () => {
534
593
  setCatalogId(editor, path, catalogKind, select.value, {
535
594
  catalogs: options?.catalogs,
536
- catalogSources: options?.catalogSources,
537
595
  presentation: options?.presentation,
538
596
  source: "catalog-select"
539
597
  });
@@ -677,20 +735,6 @@ function assertCatalogKind(catalogKind) {
677
735
  }
678
736
  }
679
737
 
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
738
  function catalogReferenceValue(current, id) {
695
739
  if (current && typeof current === "object" && !Array.isArray(current)) {
696
740
  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,12 +1,23 @@
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
5
  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));
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.
@@ -28,9 +39,8 @@ function placeholderSummary(types) {
28
39
  }).join(" + ");
29
40
  }
30
41
  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."],
42
+ "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."],
43
+ "Document catalog": ["In this document", "Embedded in this document’s catalogs. Takes precedence over the app’s records with the same reference."],
34
44
  "Current value": ["Custom value", "The current value is not in the available catalogs."],
35
45
  "Schema": ["OPF option", "Allowed by the OPF schema."],
36
46
  };
@@ -64,7 +74,7 @@ function describeOptions(choices, records, current, catalog) {
64
74
  }
65
75
  // The installed schema identifies fields; a document field merely named "layout"
66
76
  // inside arbitrary metadata is not mistaken for an OPF layout reference.
67
- export function getJsonFieldContext(source, position, loadedCatalogs = {}) {
77
+ export function getJsonFieldContext(source, position, loadedCatalogs = []) {
68
78
  let presentation;
69
79
  try {
70
80
  presentation = JSON.parse(source);
@@ -87,42 +97,21 @@ export function getJsonFieldContext(source, position, loadedCatalogs = {}) {
87
97
  return null;
88
98
  const schema = schemaAtPath(presentation, path.map(String));
89
99
  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 ?? ""));
100
+ const catalog = catalogAtPath(path);
95
101
  const options = new Map();
96
102
  const records = new Map();
97
- let unloadedSource = false;
103
+ // A document group whose source no registered catalog matches: its records beyond the embedded ones are not available here.
104
+ const registered = new Set((Array.isArray(loadedCatalogs) ? loadedCatalogs : []).map(entry => entry?.source));
105
+ 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
106
  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)) {
107
+ const hostCatalogs = Array.isArray(loadedCatalogs) ? loadedCatalogs : [];
108
+ // A draft whose own catalogs are malformed still offers the host's records; a malformed host list offers nothing.
109
+ const attempt = (document) => { try { return listCatalogRecords(document, catalog, { catalogs: hostCatalogs }); } catch { return undefined; } };
110
+ const available = attempt(presentation) ?? attempt({ ...presentation, catalogs: undefined }) ?? [];
111
+ for (const option of available) {
103
112
  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 });
113
+ options.set(option.id, { value: option.id, label: option.label, description: option.description,
114
+ source: option.origin === "document" ? "Document catalog" : "Loaded catalog" });
126
115
  }
127
116
  }
128
117
  else {
package/dist/react.js CHANGED
@@ -62,7 +62,7 @@ export function createOPFReactComponents(React) {
62
62
  const snapshot = useEditorSnapshot(editor);
63
63
  const value = getValueAtPath(snapshot.presentation, path, "");
64
64
  const currentId = value && typeof value === "object" ? value.id : value;
65
- const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.presentation });
65
+ const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.presentation, catalogs: editor.catalogs });
66
66
 
67
67
  return React.createElement(
68
68
  "select",
@@ -6,6 +6,7 @@
6
6
  // document state: every fix is an undoable edit through the editor session. Importing this module does not need a DOM;
7
7
  // mounting does.
8
8
  import { validate } from "@openpresentation/opf";
9
+ import { mergeCatalogs } from "./catalogs.js";
9
10
  import {
10
11
  applyReviewFix,
11
12
  countFindings,
@@ -115,6 +116,11 @@ function summaryText(counts, scopeLabel) {
115
116
  */
116
117
  export function createReviewPanel(container, options) {
117
118
  const { editor, getSlideIndex, getValidateOptions, onGoTo, onFocusField, onStatus, onIgnoredChange, onChange, review } = options;
119
+ // Core resolves the deck's references with the session's registered catalogs (and any the host adds): the same list as the preview.
120
+ const validateOptions = () => {
121
+ const host = getValidateOptions?.(editor.presentation) ?? {};
122
+ return { ...host, catalogs: mergeCatalogs(editor.catalogs, host.catalogs) };
123
+ };
118
124
  if (review !== undefined && typeof review !== "function") throw new TypeError("The Review panel's review hook must be a function (presentation, options) returning a FindingReport.");
119
125
  if (!editor || typeof editor.subscribe !== "function") throw new Error("The Review panel needs an editor session created by createEditorSession.");
120
126
  const doc = container.ownerDocument;
@@ -171,7 +177,7 @@ export function createReviewPanel(container, options) {
171
177
  function validateNow() {
172
178
  lastAuditError = undefined;
173
179
  try {
174
- coreReport = validate(editor.presentation, getValidateOptions?.(editor.presentation) ?? {});
180
+ coreReport = validate(editor.presentation, validateOptions());
175
181
  } catch (error) {
176
182
  coreReport = undefined;
177
183
  lastAuditError = error;
@@ -204,7 +210,7 @@ export function createReviewPanel(container, options) {
204
210
  if (!coreReport && !lastAuditError) validateNow();
205
211
  renderList();
206
212
  try {
207
- const result = await review(editor.presentation, { signal: controller.signal, report: coreReport, validateOptions: getValidateOptions?.(editor.presentation) ?? {} });
213
+ const result = await review(editor.presentation, { signal: controller.signal, report: coreReport, validateOptions: validateOptions() });
208
214
  if (destroyed || hookRunning !== running) return;
209
215
  if (!result || !Array.isArray(result.findings)) throw new TypeError("The review hook must resolve a FindingReport ({ valid, findings, counts }).");
210
216
  hookRunning = undefined;
@@ -18,7 +18,7 @@ export function createSchemaInspector(container, {editor,path='',onDraft,onCommi
18
18
  function showError(message){errorBox.textContent=message;}
19
19
  function check(){
20
20
  // The draft must be valid OPF (the format category, as every edit); unresolved references (an unknown variable, a missing asset) are listed as notes.
21
- const report=validate(draft,{only:['format','references']});
21
+ const report=validate(draft,{only:['format','references'],...(editor?.catalogs?.length?{catalogs:editor.catalogs}:{})});
22
22
  const errors=report.findings.filter(finding=>finding.severity==='error'&&finding.category==='format'),warnings=report.findings.filter(finding=>finding.category!=='format'&&finding.severity!=='info');
23
23
  const validation={valid:!errors.length};
24
24
  apply.disabled=!dirty||!validation.valid;
package/dist/schema.js CHANGED
@@ -69,7 +69,7 @@ export function schemaAtPath(presentation, path, root = schemas.presentation) {
69
69
  const parts=splitOpfPath(path);let current=root,value=presentation;
70
70
  for(let index=0; index<parts.length;index++) {
71
71
  const key=parts[index], resolved=activeSchema(current,value,root);
72
- if(parts[0]==='catalogs' && parts[2]==='records' && index===3) current=schemas[catalogSchemaNames[parts[1]]]??{};
72
+ if(parts[0]==='catalogs' && index===3 && catalogSchemaNames[parts[2]]) current=schemas[catalogSchemaNames[parts[2]]]??{};
73
73
  else current=Array.isArray(value)||resolved.type==='array' ? resolved.items??{} : resolved.properties?.[key] ?? Object.entries(resolved.patternProperties??{}).find(([pattern])=>new RegExp(pattern).test(key))?.[1] ?? (typeof resolved.additionalProperties==='object'?resolved.additionalProperties:{});
74
74
  value=value?.[key];
75
75
  }
@@ -482,7 +482,7 @@ export function createSlideManager(container, options) {
482
482
  });
483
483
  }
484
484
  function layoutChoices() {
485
- return options.layoutOptions ? options.layoutOptions(deck()) : listSwitchOptions(deck(), "layouts");
485
+ return options.layoutOptions ? options.layoutOptions(deck()) : listSwitchOptions(deck(), "layouts", { catalogs: editor.catalogs });
486
486
  }
487
487
  function openLayoutPicker() {
488
488
  const choices = layoutChoices();
package/dist/slides.d.ts CHANGED
@@ -48,7 +48,7 @@ export type MoveSectionMode = "adopt" | "keep" | string | null;
48
48
  export interface AddSlideOptions {
49
49
  /** The index the slide takes (default: the end). */
50
50
  at?: number;
51
- /** A layouts catalog id; the layout's placeholders are added as empty slots. */
51
+ /** A layout reference (`id` or `name:id`); the layout's placeholders are added as empty slots. */
52
52
  layout?: string;
53
53
  title?: string;
54
54
  text?: string;
package/dist/slides.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { checkedDocument, fail, same } from "./edit-helpers.js";
12
12
  import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
13
13
  import { prepareDimensionSwitch } from "./switches.js";
14
+ import { mergeCatalogs } from "./catalogs.js";
14
15
  import { checkFormat } from "./checks.js";
15
16
 
16
17
  const isIndex = (value) => Number.isInteger(value) && value >= 0;
@@ -130,10 +131,10 @@ function newSlideId(presentation) {
130
131
  }
131
132
 
132
133
  /**
133
- * Compute adding a slide. Options: `at` (the index the slide takes, default the end), `layout` (a layouts
134
- * catalog id; the layout's placeholders are added as empty slots, exactly like switching a slide's layout),
134
+ * Compute adding a slide. Options: `at` (the index the slide takes, default the end), `layout` (a layout
135
+ * reference, `id` or `name:id`; the layout's placeholders are added as empty slots, exactly like switching a slide's layout),
135
136
  * `title`, `text`, `id`, `section` (default: the section of the slide before it), `slide` (a ready slide to
136
- * insert, for hosts that build their own), `catalogs`/`record` for catalog lookup (as `prepareDimensionSwitch`).
137
+ * insert, for hosts that build their own), `catalogs`/`record`/`recordSource` for catalog lookup (as `prepareDimensionSwitch`).
137
138
  */
138
139
  export function prepareAddSlide(presentation, options = {}) {
139
140
  const slides = requireSlides(presentation);
@@ -153,7 +154,7 @@ export function prepareAddSlide(presentation, options = {}) {
153
154
  // Switch the layout on a scratch copy to reuse the placeholder rules and the catalog lookup.
154
155
  const scratch = { ...clone(presentation), slides: [...clone(slides)] };
155
156
  scratch.slides.splice(at, 0, slide);
156
- const switched = prepareDimensionSwitch(scratch, "layouts", options.layout, { slideIndex: at, catalogs: options.catalogs, record: options.record });
157
+ const switched = prepareDimensionSwitch(scratch, "layouts", options.layout, { slideIndex: at, catalogs: options.catalogs, record: options.record, recordSource: options.recordSource });
157
158
  const catalogOps = switched.patches.filter((patch) => !patch.path.startsWith("/slides/"));
158
159
  slide = switched.presentation.slides[at];
159
160
  const patches = [...catalogOps, { op: "add", path: slidePointer(at), value: slide }];
@@ -164,7 +165,7 @@ export function prepareAddSlide(presentation, options = {}) {
164
165
  /** Add a slide (see `prepareAddSlide`) as one undoable step. The change reports `index`, `id` and `selection`. */
165
166
  export function addSlide(editor, options = {}, meta) {
166
167
  requireEditor(editor);
167
- return apply(editor, prepareAddSlide(editor.presentation, options), meta, "add");
168
+ return apply(editor, prepareAddSlide(editor.presentation, { ...options, catalogs: mergeCatalogs(editor.catalogs, options.catalogs) }), meta, "add");
168
169
  }
169
170
 
170
171
  // --- duplicate -------------------------------------------------------------------------------------
package/dist/svelte.js CHANGED
@@ -50,8 +50,8 @@ export function opfCatalogSelect(node, params) {
50
50
  node.setAttribute?.("data-opf-catalog-kind", catalogKind);
51
51
  if (label) node.setAttribute?.("aria-label", label);
52
52
  replaceOptions(node, getCatalogOptions(catalogKind, {
53
+ editor,
53
54
  catalogs: nextParams.catalogs,
54
- catalogSources: nextParams.catalogSources,
55
55
  presentation: nextParams.presentation ?? editor.presentation
56
56
  }));
57
57
 
@@ -61,7 +61,6 @@ export function opfCatalogSelect(node, params) {
61
61
  };
62
62
  const onChange = () => setCatalogId(editor, path, catalogKind, node.value, {
63
63
  catalogs: nextParams.catalogs,
64
- catalogSources: nextParams.catalogSources,
65
64
  presentation: nextParams.presentation,
66
65
  source: "svelte-catalog-select"
67
66
  });