@openpresentation/opf-editor 0.12.1 → 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.
Files changed (103) hide show
  1. package/README.md +122 -80
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +16 -16
  6. package/dist/background-options.d.ts +12 -5
  7. package/dist/background-options.js +44 -19
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +22 -21
  11. package/dist/blocks.d.ts +8 -8
  12. package/dist/blocks.js +41 -40
  13. package/dist/canvas-fields.js +35 -12
  14. package/dist/canvas.d.ts +30 -31
  15. package/dist/canvas.js +64 -68
  16. package/dist/catalogs.d.ts +48 -0
  17. package/dist/catalogs.js +217 -0
  18. package/dist/chart-data.d.ts +10 -10
  19. package/dist/chart-data.js +21 -21
  20. package/dist/chart-options-panel.d.ts +2 -2
  21. package/dist/chart-options-panel.js +82 -8
  22. package/dist/chart-options.d.ts +35 -9
  23. package/dist/chart-options.js +133 -40
  24. package/dist/checks.js +23 -0
  25. package/dist/color-authoring.js +1 -1
  26. package/dist/content-actions.d.ts +17 -17
  27. package/dist/content-actions.js +74 -73
  28. package/dist/content-controls.js +21 -21
  29. package/dist/data-grid.js +7 -8
  30. package/dist/data.d.ts +4 -4
  31. package/dist/data.js +5 -5
  32. package/dist/dataset-refs.d.ts +2 -2
  33. package/dist/dataset-refs.js +5 -5
  34. package/dist/design-controls.d.ts +12 -5
  35. package/dist/design-controls.js +312 -111
  36. package/dist/design-options.d.ts +20 -18
  37. package/dist/design-options.js +91 -99
  38. package/dist/edit-helpers.js +8 -7
  39. package/dist/exact-source.js +5 -5
  40. package/dist/export.d.ts +7 -7
  41. package/dist/export.js +48 -17
  42. package/dist/find-panel.js +5 -5
  43. package/dist/find-replace.d.ts +3 -3
  44. package/dist/find-replace.js +23 -20
  45. package/dist/font-gate.js +49 -58
  46. package/dist/galleries.js +50 -101
  47. package/dist/grid-model.d.ts +25 -27
  48. package/dist/grid-model.js +92 -102
  49. package/dist/image-crop.d.ts +4 -4
  50. package/dist/image-crop.js +28 -29
  51. package/dist/image-cropper.js +3 -3
  52. package/dist/image-options.d.ts +45 -0
  53. package/dist/image-options.js +195 -0
  54. package/dist/index.d.ts +48 -31
  55. package/dist/index.js +154 -126
  56. package/dist/json-editor.js +2 -2
  57. package/dist/json-menu.js +2 -2
  58. package/dist/json-options.d.ts +3 -3
  59. package/dist/json-options.js +44 -54
  60. package/dist/layout-handles.js +5 -5
  61. package/dist/layout-placeholders.js +4 -4
  62. package/dist/layout.d.ts +2 -2
  63. package/dist/layout.js +8 -7
  64. package/dist/numbering-panel.js +3 -5
  65. package/dist/numbering.d.ts +3 -5
  66. package/dist/numbering.js +14 -26
  67. package/dist/outline-view.js +24 -24
  68. package/dist/outline.d.ts +12 -12
  69. package/dist/outline.js +52 -50
  70. package/dist/persistence.d.ts +1 -1
  71. package/dist/persistence.js +19 -18
  72. package/dist/presentation-ids.js +2 -2
  73. package/dist/react.js +3 -3
  74. package/dist/review-panel.d.ts +30 -13
  75. package/dist/review-panel.js +160 -53
  76. package/dist/review.d.ts +36 -43
  77. package/dist/review.js +92 -53
  78. package/dist/rich-text-input.js +4 -3
  79. package/dist/rich-text-toolbar.js +5 -3
  80. package/dist/rich-text.js +5 -2
  81. package/dist/schema-inspector.d.ts +2 -2
  82. package/dist/schema-inspector.js +16 -11
  83. package/dist/schema.d.ts +1 -1
  84. package/dist/schema.js +26 -6
  85. package/dist/slide-manager.d.ts +2 -2
  86. package/dist/slide-manager.js +19 -19
  87. package/dist/slides.d.ts +21 -21
  88. package/dist/slides.js +104 -101
  89. package/dist/svelte.js +2 -3
  90. package/dist/switches.d.ts +37 -22
  91. package/dist/switches.js +282 -171
  92. package/dist/table-options.d.ts +8 -8
  93. package/dist/table-options.js +28 -27
  94. package/dist/table-structure.d.ts +10 -10
  95. package/dist/table-structure.js +21 -21
  96. package/dist/template-panel.d.ts +3 -3
  97. package/dist/template-panel.js +37 -13
  98. package/dist/templates.d.ts +22 -11
  99. package/dist/templates.js +48 -55
  100. package/dist/transfer.d.ts +15 -4
  101. package/dist/transfer.js +107 -161
  102. package/package.json +12 -6
  103. package/dist/font-defaults.js +0 -25
@@ -34,15 +34,18 @@ import {
34
34
  setLogoVariant,
35
35
  } from "./design-options.js";
36
36
  import { createContentControls } from "./content-controls.js";
37
+ import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
38
+ import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
37
39
  import { describeTableCell, mergeTableCells, parseTableCellPath, readTableStyle, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
38
40
 
39
41
  /** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
40
- export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "slide-image", "header-footer", "brand", "layout-options", "info", "selection", "table", "slide-content"]);
42
+ export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
41
43
 
42
44
  const SECTION_TITLES = {
43
45
  look: "Look and language",
44
46
  background: "Background",
45
- "slide-image": "Slide image",
47
+ image: "Selected image",
48
+ catalog: "Catalog records",
46
49
  "header-footer": "Header and footer",
47
50
  brand: "Logo, watermark and bullets",
48
51
  "layout-options": "Alignment and arrangement",
@@ -51,7 +54,7 @@ const SECTION_TITLES = {
51
54
  table: "Table",
52
55
  "slide-content": "Slide structure",
53
56
  };
54
- const OPEN_BY_DEFAULT = new Set(["look", "selection", "table"]);
57
+ const OPEN_BY_DEFAULT = new Set(["look", "selection", "image", "table"]);
55
58
  const LOGO_VARIANT_LABELS = {
56
59
  default: "Default",
57
60
  light: "Light (for dark backgrounds)",
@@ -75,8 +78,6 @@ const CELL_FILLS = [
75
78
  ["background", "Background"],
76
79
  ["text", "Text color"],
77
80
  ];
78
- const SHAPES = ["rectangle", "rounded", "circle", "hexagon"];
79
- const POSITIONS = ["background", "top", "bottom", "left", "right"];
80
81
  const BLOCK_REPLACEMENTS = ["text", "list", "chart", "table", "metric", "quote", "code", "timeline", "group"];
81
82
 
82
83
  let instances = 0;
@@ -89,8 +90,9 @@ const titleCase = (text) => text.charAt(0).toUpperCase() + text.slice(1);
89
90
  *
90
91
  * Options: `editor` (an editor session), `getSlideIndex()` and `getSelectedPath()` (the host's current
91
92
  * slide and selection; call `refresh()` when they change), `sections` (default: all), `scope`
92
- * ("deck" or "slide", the initial "Applies to" choice), `catalogs`/`catalogSources` (caller-loaded
93
- * catalog records), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
93
+ * ("deck" or "slide", the initial "Applies to" choice), `catalogs` (host catalogs, `Catalog[]`, merged after the session's
94
+ * registered ones), `vocabularies` (display metadata for languages, chart types and social platforms: `catalogDisplay`
95
+ * from `@openpresentation/opf/catalog`), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
94
96
  * after a content conversion or replacement with the path to keep selected (the block, or the inline
95
97
  * payload field), because the host's old selection path may no longer exist.
96
98
  */
@@ -103,7 +105,8 @@ export function createDesignControls(container, options = {}) {
103
105
  const sections = (options.sections ?? DESIGN_CONTROL_SECTIONS).filter((name) => DESIGN_CONTROL_SECTIONS.includes(name));
104
106
  const getSlide = () => options.getSlideIndex?.() ?? 0;
105
107
  const getSelected = () => options.getSelectedPath?.();
106
- const catalogOptions = () => ({ catalogs: options.catalogs, catalogSources: options.catalogSources });
108
+ // One catalog list for every picker and switch: the session's registered catalogs, then the panel's own `catalogs`.
109
+ const catalogOptions = () => ({ catalogs: mergeCatalogs(editor.catalogs, options.catalogs), vocabularies: options.vocabularies });
107
110
  const state = { scope: options.scope === "slide" ? "slide" : "deck" };
108
111
  const syncs = [];
109
112
  let destroyed = false;
@@ -143,7 +146,7 @@ export function createDesignControls(container, options = {}) {
143
146
  };
144
147
  const reselect = (blockPath) => {
145
148
  if (!blockPath || !options.onSelectPath) return;
146
- const content = readBlockContent(editor.document, blockPath);
149
+ const content = readBlockContent(editor.presentation, blockPath);
147
150
  options.onSelectPath(content && !content.explicit ? `${blockPath}.${content.key}` : blockPath);
148
151
  };
149
152
  /** Run a change; report its outcome; on failure restore every control to the document. */
@@ -151,8 +154,10 @@ export function createDesignControls(container, options = {}) {
151
154
  try {
152
155
  const change = action();
153
156
  const warnings = change?.warnings?.length ? ` ${change.warnings.map((warning) => warning.message).join(" ")}` : "";
157
+ // An edit of a catalog's record forked it into the deck's own records: say so (FA-23).
158
+ const notice = change?.notice ? ` ${change.notice}` : "";
154
159
  if (change && change.changed === false) say("Already set. Nothing changed.");
155
- else say(`${typeof done === "function" ? done(change) : (done ?? "Changed.")}${warnings} Undo restores the previous state.`);
160
+ else say(`${typeof done === "function" ? done(change) : (done ?? "Changed.")}${warnings}${notice} Undo restores the previous state.`);
156
161
  options.onChange?.(change);
157
162
  } catch (error) {
158
163
  complain(messageOf(error));
@@ -270,6 +275,40 @@ export function createDesignControls(container, options = {}) {
270
275
 
271
276
  const button = (label, onClick, { quiet = false, title } = {}) => h("button", { type: "button", class: quiet ? "quiet" : "secondary", title, onclick: onClick }, label);
272
277
 
278
+ /**
279
+ * The overlay of an image background or an image block: a scrim over the whole picture, or a band along one edge.
280
+ * `value()` is the overlay to write (undefined without a color), `set(overlay)` loads one. `onCommit` (optional) runs on a change.
281
+ */
282
+ function overlayFields(name, label, { onCommit } = {}) {
283
+ const commit = () => onCommit?.();
284
+ const color = textField(`${name}-color`, `${label} color`, { list: true, placeholder: "dark1 or #000000", help: "A theme color such as dark1, a hex color, or var:<id>. Clear it to remove the overlay.", onCommit: commit });
285
+ color.setList(COLOR_NAMES);
286
+ const opacity = numberField(`${name}-opacity`, `${label} opacity (0 to 1)`, { min: 0, max: 1, step: 0.05, onCommit: commit });
287
+ const edge = selectField(`${name}-edge`, `${label} band`, { empty: "Whole picture", help: "Cover only a band along one edge, for example a caption strip.", onChange: commit });
288
+ edge.setOptions(IMAGE_EDGES.map((value) => ({ value, label: `Along the ${value}` })));
289
+ const size = numberField(`${name}-size`, `${label} band size (0.05 to 1)`, { min: 0.05, max: 1, step: 0.05, onCommit: commit });
290
+ const wrap = h("fieldset", { class: "opf-dc-fieldset", "data-role": name }, h("legend", {}, label), color.wrap, opacity.wrap, edge.wrap, size.wrap);
291
+ return {
292
+ wrap,
293
+ value() {
294
+ const ref = color.input.value.trim();
295
+ if (!ref) return undefined;
296
+ return {
297
+ color: ref,
298
+ opacity: opacity.input.value === "" ? 0.4 : Number(opacity.input.value),
299
+ ...(edge.select.value ? { edge: edge.select.value, ...(size.input.value === "" ? {} : { size: Number(size.input.value) }) } : {}),
300
+ };
301
+ },
302
+ set(overlay) {
303
+ color.set(overlay?.color ?? "");
304
+ opacity.set(overlay?.opacity === undefined ? "" : String(overlay.opacity));
305
+ edge.set(overlay?.edge ?? "");
306
+ size.set(overlay?.size === undefined ? "" : String(overlay.size));
307
+ size.wrap.hidden = !overlay?.edge && !edge.select.value;
308
+ },
309
+ };
310
+ }
311
+
273
312
  /**
274
313
  * Source text, file upload and alt text for one image. `apply(ref | null)` commits a typed source (null removes it);
275
314
  * `build(ref, document)` prepares the change an upload makes, after its asset patch, in the same undo step.
@@ -299,7 +338,7 @@ export function createDesignControls(container, options = {}) {
299
338
  help: "Describes the image for screen readers. Saved with the image's asset; typed before an upload, it is used for that upload. Press Enter to apply.",
300
339
  onCommit: (value) => {
301
340
  const id = assetIdOf(lastRef);
302
- if (!id || editor.document.assets?.[id] === undefined) return;
341
+ if (!id || editor.presentation.assets?.[id] === undefined) return;
303
342
  run(() => setAssetAlt(editor, id, value), "Alt text saved.");
304
343
  },
305
344
  });
@@ -333,7 +372,7 @@ export function createDesignControls(container, options = {}) {
333
372
  source.setList(assetList());
334
373
  source.set(lastRef, note);
335
374
  const id = assetIdOf(lastRef);
336
- const entry = id ? editor.document.assets?.[id] : undefined;
375
+ const entry = id ? editor.presentation.assets?.[id] : undefined;
337
376
  // Alt text belongs to one image: another target (variant, zone, slide) never inherits what was typed for the last one.
338
377
  alt.set(entry && typeof entry === "object" && typeof entry.alt === "string" ? entry.alt : "");
339
378
  // The field keeps what is being typed, except when the image it describes has changed.
@@ -354,7 +393,7 @@ export function createDesignControls(container, options = {}) {
354
393
  const scopeIndex = () => (state.scope === "slide" ? getSlide() : undefined);
355
394
  const scoped = (extra = {}) => (state.scope === "slide" ? { slideIndex: getSlide(), ...extra } : extra);
356
395
  const sourceNote = (scope, inherited) => (state.scope === "slide" ? (scope === "slide" ? "set on this slide" : inherited ? "from the presentation" : "default") : "");
357
- const scopeNeeded = sections.some((name) => ["look", "background", "slide-image", "header-footer", "brand", "layout-options"].includes(name));
396
+ const scopeNeeded = sections.some((name) => ["look", "background", "header-footer", "brand", "layout-options"].includes(name));
358
397
  let scopeField;
359
398
  if (scopeNeeded) {
360
399
  scopeField = selectField("scope", "Applies to", {
@@ -372,7 +411,7 @@ export function createDesignControls(container, options = {}) {
372
411
  root.append(scopeField.wrap);
373
412
  }
374
413
 
375
- const assetList = () => Object.keys(editor.document.assets ?? {}).map((id) => `asset:${id}`);
414
+ const assetList = () => Object.keys(editor.presentation.assets ?? {}).map((id) => `asset:${id}`);
376
415
  const stringOf = (value) => (typeof value === "string" ? value : value && typeof value === "object" && typeof value.src === "string" ? value.src : "");
377
416
  const isCustomObject = (value) => Boolean(value) && typeof value === "object";
378
417
 
@@ -392,13 +431,13 @@ export function createDesignControls(container, options = {}) {
392
431
  });
393
432
  body.append(field.wrap);
394
433
  syncs.push(() => {
395
- field.setOptions(listSwitchOptions(editor.document, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
396
- const current = currentSwitchValue(editor.document, dimension, perSlide ? scoped() : {});
434
+ field.setOptions(listSwitchOptions(editor.presentation, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
435
+ const current = currentSwitchValue(editor.presentation, dimension, { ...catalogOptions(), ...(perSlide ? scoped() : {}) });
397
436
  field.set(current.value, perSlide ? sourceNote(current.scope, current.value !== undefined) : "");
398
437
  });
399
438
  return field;
400
439
  };
401
- catalogField("theme", "Theme", "themes", { help: "A theme also sets its color scheme, font scheme and background." });
440
+ catalogField("theme", "Theme", "themes", { help: "A theme brings its own color scheme, font scheme and background: choosing one removes those overrides here." });
402
441
  catalogField("color-scheme", "Color scheme", "color-schemes");
403
442
  catalogField("font-scheme", "Font scheme", "font-schemes", { help: "Fonts the preview and export use. The preview shows an open look-alike where a font is not bundled." });
404
443
  catalogField("language", "Language", "languages", { perSlide: false, help: "Sets the language for the whole presentation, including its script fonts." });
@@ -409,8 +448,8 @@ export function createDesignControls(container, options = {}) {
409
448
  });
410
449
  body.append(slideSize.wrap);
411
450
  syncs.push(() => {
412
- slideSize.setOptions(listSwitchOptions(editor.document, "slide-sizes").map((entry) => ({ value: entry.id, label: entry.label })));
413
- const { value } = currentSwitchValue(editor.document, "slide-sizes");
451
+ slideSize.setOptions(listSwitchOptions(editor.presentation, "slide-sizes").map((entry) => ({ value: entry.id, label: entry.label })));
452
+ const { value } = currentSwitchValue(editor.presentation, "slide-sizes", catalogOptions());
414
453
  slideSize.set(value && typeof value === "object" ? "Custom size" : value);
415
454
  });
416
455
 
@@ -420,8 +459,8 @@ export function createDesignControls(container, options = {}) {
420
459
  });
421
460
  body.append(layout.wrap);
422
461
  syncs.push(() => {
423
- layout.setOptions(listSwitchOptions(editor.document, "layouts", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
424
- layout.set(currentSwitchValue(editor.document, "layouts", { slideIndex: getSlide() }).value);
462
+ layout.setOptions(listSwitchOptions(editor.presentation, "layouts", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
463
+ layout.set(currentSwitchValue(editor.presentation, "layouts", { slideIndex: getSlide() }).value);
425
464
  });
426
465
  }
427
466
 
@@ -477,14 +516,45 @@ export function createDesignControls(container, options = {}) {
477
516
  gradientPanel.append(angle.wrap, stopsBox, addStopButton);
478
517
 
479
518
  const imagePanel = panel("image", "Image");
480
- const fit = selectField("bg-fit", "Fit", { help: "Cover fills the slide, contain shows the whole image, tile repeats it." });
519
+ const fit = selectField("bg-fit", "Fit", { help: "Cover fills the slide around the focus point, contain shows the whole image, stretch fills it exactly, tile repeats it." });
481
520
  fit.setOptions(IMAGE_BACKGROUND_FITS.map((value) => ({ value, label: titleCase(value) })));
521
+ const bgAlt = textField("bg-alt", "Background alt text", {
522
+ help: "Describe the photo when it carries meaning: PowerPoint then gets a picture at the back that screen readers read. Leave it empty for a decorative background.",
523
+ });
524
+ const focusX = numberField("bg-focus-x", "Focus across (0 left to 1 right)", { min: 0, max: 1, step: 0.05 });
525
+ const focusY = numberField("bg-focus-y", "Focus down (0 top to 1 bottom)", { min: 0, max: 1, step: 0.05 });
526
+ const overlay = overlayFields("bg-overlay", "Overlay");
527
+ const bgRecolor = selectField("bg-recolor", "Recolor", { empty: "None", help: "Grayscale, or a duotone from a dark to a light color. Changes the picture's pixels only, not the overlay." });
528
+ bgRecolor.setOptions([
529
+ { value: "grayscale", label: "Grayscale" },
530
+ { value: "duotone", label: "Duotone" },
531
+ ]);
532
+ const bgDuoDark = textField("bg-duo-dark", "Background duotone dark color", { list: true, placeholder: "dark1" });
533
+ const bgDuoLight = textField("bg-duo-light", "Background duotone light color", { list: true, placeholder: "accent1" });
534
+ bgDuoDark.setList(COLOR_NAMES);
535
+ bgDuoLight.setList(COLOR_NAMES);
536
+ const showDuotone = () => { for (const field of [bgDuoDark, bgDuoLight]) field.wrap.hidden = bgRecolor.select.value !== "duotone"; };
537
+ bgRecolor.select.addEventListener("change", showDuotone);
538
+ const recolorValue = () => (bgRecolor.select.value === "grayscale" ? "grayscale" : bgRecolor.select.value === "duotone" ? { dark: bgDuoDark.input.value.trim(), light: bgDuoLight.input.value.trim() } : undefined);
539
+ const imageSpec = (src) => {
540
+ const x = focusX.input.value, y = focusY.input.value;
541
+ return {
542
+ type: "image",
543
+ src,
544
+ ...(fit.select.value ? { fit: fit.select.value } : {}),
545
+ ...(bgAlt.input.value.trim() ? { alt: bgAlt.input.value.trim() } : {}),
546
+ ...(x !== "" || y !== "" ? { focus: { x: x === "" ? 0.5 : Number(x), y: y === "" ? 0.5 : Number(y) } } : {}),
547
+ ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }),
548
+ ...(recolorValue() ? { recolor: recolorValue() } : {}),
549
+ ...(overlay.value() ? { overlay: overlay.value() } : {}),
550
+ };
551
+ };
482
552
  const bgImage = imageCluster("bg-image", "Background image", {
483
- apply: (ref) => (ref === null ? setBackground(editor, null, scoped()) : setBackground(editor, { type: "image", image: { src: ref, fit: fit.select.value || undefined }, ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }) }, scoped())),
484
- build: (ref, doc) => prepareBackground(doc, { type: "image", image: { src: ref, fit: fit.select.value || undefined }, ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }) }, scoped()),
553
+ apply: (ref) => (ref === null ? setBackground(editor, null, scoped()) : setBackground(editor, imageSpec(ref), scoped())),
554
+ build: (ref, doc) => prepareBackground(doc, imageSpec(ref), scoped()),
485
555
  help: "An asset reference, a web address or a data address. Press Enter or choose a file to apply it with the settings here.",
486
556
  });
487
- imagePanel.append(bgImage.wrap, fit.wrap);
557
+ imagePanel.append(bgImage.wrap, fit.wrap, bgAlt.wrap, focusX.wrap, focusY.wrap, bgRecolor.wrap, bgDuoDark.wrap, bgDuoLight.wrap, overlay.wrap);
488
558
 
489
559
  const patternPanel = panel("pattern", "Pattern");
490
560
  const preset = selectField("bg-preset", "Pattern", { help: "The 54 PowerPoint presets. PPTX export writes them as native pattern fills." });
@@ -506,7 +576,7 @@ export function createDesignControls(container, options = {}) {
506
576
  if (type === "solid") return withOpacity({ type: "solid", color: solidColor.input.value.trim() });
507
577
  if (type === "gradient")
508
578
  return withOpacity({ type: "gradient", gradient: { ...(angle.input.value === "" ? {} : { angle: Number(angle.input.value) }), stops: stopRows.map((row) => ({ color: row.color.input.value.trim(), position: row.position.input.value })) } });
509
- if (type === "image") return withOpacity({ type: "image", image: { src: bgImage.source.input.value.trim(), fit: fit.select.value } });
579
+ if (type === "image") return imageSpec(bgImage.source.input.value.trim());
510
580
  return withOpacity({ type: "pattern", pattern: { preset: preset.select.value, foregroundColor: patternFg.input.value.trim() || undefined, backgroundColor: patternBg.input.value.trim() || undefined } });
511
581
  };
512
582
  const showType = () => {
@@ -525,7 +595,7 @@ export function createDesignControls(container, options = {}) {
525
595
  body.append(typeSelect.wrap, themePanel, solidPanel, gradientPanel, imagePanel, patternPanel, opacity.wrap, h("div", { class: "opf-dc-actions" }, apply, remove));
526
596
 
527
597
  const load = () => {
528
- const current = readBackground(editor.document, scoped());
598
+ const current = readBackground(editor.presentation, scoped());
529
599
  const note = sourceNote(current.scope, current.type !== undefined);
530
600
  typeSelect.set(current.type ?? "theme", note);
531
601
  slot.set(current.type === "theme" ? current.slot : "light1");
@@ -540,6 +610,15 @@ export function createDesignControls(container, options = {}) {
540
610
  renderStops();
541
611
  fit.set(current.type === "image" ? (current.fit ?? "cover") : "cover");
542
612
  bgImage.set(current.type === "image" ? (current.src ?? "") : "", "", target());
613
+ bgAlt.set(current.type === "image" ? (current.alt ?? "") : "");
614
+ focusX.set(current.type === "image" && current.focus ? String(current.focus.x) : "");
615
+ focusY.set(current.type === "image" && current.focus ? String(current.focus.y) : "");
616
+ overlay.set(current.type === "image" ? current.overlay : undefined);
617
+ const recolor = current.type === "image" ? current.recolor : undefined;
618
+ bgRecolor.set(recolor === "grayscale" ? "grayscale" : recolor && typeof recolor === "object" ? "duotone" : "");
619
+ bgDuoDark.set(recolor && typeof recolor === "object" ? recolor.dark : "");
620
+ bgDuoLight.set(recolor && typeof recolor === "object" ? recolor.light : "");
621
+ showDuotone();
543
622
  preset.set(current.type === "pattern" ? current.preset : "pct5");
544
623
  patternFg.set(current.type === "pattern" ? (current.foregroundColor ?? "") : "");
545
624
  patternBg.set(current.type === "pattern" ? (current.backgroundColor ?? "") : "");
@@ -563,62 +642,6 @@ export function createDesignControls(container, options = {}) {
563
642
  });
564
643
  }
565
644
 
566
- // --- slide image --------------------------------------------------------------------------------
567
-
568
- if (built["slide-image"]) {
569
- const { body } = built["slide-image"];
570
- const image = (fields, done) => run(() => setDesignOption(editor, "slideImage", fields, scoped()), done ?? "Slide image changed.");
571
- const position = selectField("image-position", "Position", {
572
- empty: "None",
573
- help: "Where the slide image sits. Choose None to remove it.",
574
- onChange: (value) => (value === "" ? run(() => setDesignOption(editor, "slideImage", null, scoped()), "Slide image removed.") : image({ position: value }, `Slide image placed ${value}.`)),
575
- });
576
- position.setOptions(POSITIONS.map((value) => ({ value, label: titleCase(value) })));
577
- const source = imageCluster("image", "Slide image", {
578
- apply: (ref) => setDesignOption(editor, "slideImage", { src: ref }, scoped()),
579
- build: (ref, doc) => prepareDesignOption(doc, "slideImage", { src: ref }, scoped()),
580
- });
581
- const fill = selectField("image-fill", "Fit", { empty: "Presentation default", onChange: (value) => image({ fill: value === "" ? null : value }, "Image fit changed.") });
582
- fill.setOptions([
583
- { value: "crop", label: "Crop to fill" },
584
- { value: "fit", label: "Fit whole image" },
585
- ]);
586
- const shape = selectField("image-shape", "Shape", { empty: "Rectangle", onChange: (value) => image({ shape: value === "" ? null : value }, "Image shape changed.") });
587
- shape.setOptions(SHAPES.filter((value) => value !== "rectangle").map((value) => ({ value, label: titleCase(value) })));
588
- const size = numberField("image-size", "Size (share of the slide, 0.1 to 0.9)", {
589
- min: 0.1,
590
- max: 0.9,
591
- step: 0.05,
592
- onCommit: (value) => image({ size: value }, "Image size changed."),
593
- });
594
- const inset = checkField("image-inset", "Inset inside the slide padding", { onChange: (checked) => image({ inset: checked ? true : null }, "Image inset changed.") });
595
- const placeholderFill = selectField("image-placeholder-fill", "Picture placeholders", {
596
- empty: "Presentation default",
597
- help: "How pictures fill their layout placeholders across the presentation.",
598
- onChange: (value) => run(() => switchDimension(editor, "image-treatments", { imageFill: value === "" ? null : value }), "Picture placeholder fill changed."),
599
- });
600
- placeholderFill.setOptions([
601
- { value: "crop", label: "Crop to fill" },
602
- { value: "fit", label: "Fit whole image" },
603
- ]);
604
- body.append(position.wrap, source.wrap, fill.wrap, shape.wrap, size.wrap, inset.wrap, placeholderFill.wrap);
605
- syncs.push(() => {
606
- const option = getDesignOption(editor.document, "slideImage", scoped());
607
- const value = option.value;
608
- const object = value && typeof value === "object" && !Array.isArray(value) && value.position ? value : undefined;
609
- const note = sourceNote(option.scope, option.value !== undefined);
610
- position.set(object?.position ?? (value === undefined ? "" : "(source only)"), note);
611
- source.set(stringOf(value), "", target());
612
- fill.set(object?.fill ?? "");
613
- shape.set(object?.shape && object.shape !== "rectangle" ? object.shape : "");
614
- size.set(object?.size === undefined ? "" : String(object.size));
615
- inset.set(object?.inset === true);
616
- // Fit, shape, size and inset only make sense once the image has a position.
617
- for (const control of [fill, shape, size, inset]) control.wrap.hidden = !object;
618
- placeholderFill.set(editor.document.design?.imageFill ?? "");
619
- });
620
- }
621
-
622
645
  // --- header and footer --------------------------------------------------------------------------
623
646
 
624
647
  if (built["header-footer"]) {
@@ -632,7 +655,7 @@ export function createDesignControls(container, options = {}) {
632
655
  { value: "footer", label: "Footer" },
633
656
  ]);
634
657
  furniture.set(pick.which);
635
- const zoneSelect = selectField("hf-zone", "Zone", { help: "Each zone stacks its parts top to bottom: logo, image, text, organization, socials, section, slide number, date." });
658
+ const zoneSelect = selectField("hf-zone", "Zone", { help: "Each zone stacks its parts top to bottom: logo, image, text, organization, speaker, socials, section, slide number, date." });
636
659
  zoneSelect.setOptions(HEADER_FOOTER_ZONES.map((value) => ({ value, label: titleCase(value) })));
637
660
  zoneSelect.set(pick.zone);
638
661
  furniture.select.addEventListener("change", () => {
@@ -678,22 +701,23 @@ export function createDesignControls(container, options = {}) {
678
701
  onCommit: (value) => edit({ dateFormat: value.trim() }, `${where()} date format changed.`),
679
702
  });
680
703
  const organization = checkField("hf-organization", "Show the organization name", { onChange: (checked) => edit({ organization: checked }, `${where()} organization ${checked ? "shown" : "removed"}.`) });
704
+ const speaker = checkField("hf-speaker", "Show the speaker name and title", { onChange: (checked) => edit({ speaker: checked }, `${where()} speaker ${checked ? "shown" : "removed"}.`) });
681
705
  const socials = checkField("hf-socials", "Show the organization's social profiles", { onChange: (checked) => edit({ socials: checked }, `${where()} social profiles ${checked ? "shown" : "removed"}.`) });
682
706
  const section = checkField("hf-section", "Show the section label", { onChange: (checked) => edit({ section: checked }, `${where()} section label ${checked ? "shown" : "removed"}.`) });
683
707
  const summary = h("ul", { class: "opf-dc-summary", "aria-label": "Zones in use" });
684
708
  const warnings = h("ul", { class: "opf-dc-warnings", "aria-label": "Header and footer warnings" });
685
- const controls = [text, logo, image, number, numberFormat, dateNow, dateFixed, dateFormat, organization, socials, section];
709
+ const controls = [text, logo, image, number, numberFormat, dateNow, dateFixed, dateFormat, organization, speaker, socials, section];
686
710
  body.append(furniture.wrap, hide.wrap, zoneSelect.wrap, summary, ...controls.map((control) => control.wrap), warnings);
687
711
  const describe = (fields) =>
688
712
  Object.entries(fields)
689
713
  .map(([key, value]) => (typeof value === "boolean" ? key : `${key} ${typeof value === "string" ? `“${value}”` : ""}`.trim()))
690
714
  .join(", ");
691
715
  syncs.push(() => {
692
- const state = headerFooterState(editor.document, which(), scoped());
716
+ const state = headerFooterState(editor.presentation, which(), scoped());
693
717
  // Offered on a slide that inherits the deck's furniture or already hides it, never over zones the slide set itself; at the deck only to undo a hidden state.
694
718
  hide.wrap.hidden = scopeIndex() === undefined ? !state.hidden : state.own && !state.hidden;
695
719
  hide.set(state.hidden, state.inherited && !state.hidden ? "from the presentation" : "");
696
- const fields = readHeaderFooterZone(editor.document, which(), zone(), scoped());
720
+ const fields = readHeaderFooterZone(editor.presentation, which(), zone(), scoped());
697
721
  text.set(typeof fields.text === "string" ? fields.text : "");
698
722
  logo.set(fields.logo === true);
699
723
  image.set(stringOf(fields.image), "", `${target()}:${which()}:${zone()}`);
@@ -703,16 +727,17 @@ export function createDesignControls(container, options = {}) {
703
727
  dateFixed.set(typeof fields.date === "string" ? fields.date : "");
704
728
  dateFormat.set(typeof fields.dateFormat === "string" ? fields.dateFormat : "");
705
729
  organization.set(fields.organization === true);
730
+ speaker.set(fields.speaker === true);
706
731
  socials.set(fields.socials === true);
707
732
  section.set(fields.section === true);
708
733
  for (const control of controls) for (const input of control.wrap.querySelectorAll("input,select,button")) input.disabled = state.hidden;
709
734
  summary.replaceChildren(
710
735
  ...HEADER_FOOTER_ZONES.map((name) => {
711
- const zoneFields = readHeaderFooterZone(editor.document, which(), name, scoped());
736
+ const zoneFields = readHeaderFooterZone(editor.presentation, which(), name, scoped());
712
737
  return h("li", {}, `${titleCase(name)}: ${Object.keys(zoneFields).length ? describe(zoneFields) : "empty"}`);
713
738
  }),
714
739
  );
715
- warnings.replaceChildren(...designWarnings(editor.document, getSlide()).filter((warning) => /^design\.(header|footer)/.test(warning.path)).map((warning) => h("li", {}, warning.message)));
740
+ warnings.replaceChildren(...designWarnings(editor.presentation, getSlide()).filter((warning) => /^design\.(header|footer)/.test(warning.path)).map((warning) => h("li", {}, warning.message)));
716
741
  });
717
742
  }
718
743
 
@@ -752,6 +777,18 @@ export function createDesignControls(container, options = {}) {
752
777
  apply: (ref) => setDesignOption(editor, "watermark", ref === null ? null : { src: ref }, scoped()),
753
778
  build: (ref, doc) => prepareDesignOption(doc, "watermark", { src: ref }, scoped()),
754
779
  });
780
+ // FA-13: a watermark is an image or a text stamp; the text field and the image cluster replace each other.
781
+ const watermarkText = textField("watermark-text", "Watermark text", {
782
+ placeholder: "DRAFT",
783
+ help: "A text stamp centered on the slide, drawn diagonally in the theme text color at the opacity below. Press Enter to apply; clear the field to remove it. It replaces a watermark image.",
784
+ onCommit: (value) => run(() => {
785
+ const text = value.trim();
786
+ const current = getDesignOption(editor.presentation, "watermark", scoped()).value;
787
+ if (text === "") return current && typeof current === "object" && typeof current.text === "string" ? setDesignOption(editor, "watermark", null, scoped()) : { changed: false };
788
+ const opacity = current && typeof current === "object" && typeof current.opacity === "number" ? current.opacity : 0.1;
789
+ return setDesignOption(editor, "watermark", { text, opacity }, scoped());
790
+ }, value.trim() === "" ? "Watermark removed." : "Watermark text set."),
791
+ });
755
792
  const watermarkOpacity = numberField("watermark-opacity", "Watermark opacity (0 to 1)", {
756
793
  min: 0,
757
794
  max: 1,
@@ -761,30 +798,31 @@ export function createDesignControls(container, options = {}) {
761
798
  const watermarkOff = checkField("watermark-off", "Hide the inherited watermark", {
762
799
  onChange: (checked) => run(() => setDesignOption(editor, "watermark", checked ? false : null, scoped()), checked ? "Watermark hidden." : "Watermark restored."),
763
800
  });
764
- body.append(variant.wrap, logoSource.wrap, orgLogo.wrap, bullet.wrap, accent.wrap, watermarkSource.wrap, watermarkOpacity.wrap, watermarkOff.wrap);
801
+ body.append(variant.wrap, logoSource.wrap, orgLogo.wrap, bullet.wrap, accent.wrap, watermarkSource.wrap, watermarkText.wrap, watermarkOpacity.wrap, watermarkOff.wrap);
765
802
  const warningList = h("ul", { class: "opf-dc-warnings", "aria-label": "Logo warnings" });
766
803
  body.append(warningList);
767
804
  syncs.push(() => {
768
- const variants = readLogoVariants(editor.document, scoped());
805
+ const variants = readLogoVariants(editor.presentation, scoped());
769
806
  logoSource.set(stringOf(variants[variant.select.value]), "", `${target()}:${variant.select.value}`);
770
- const organization = editor.document.organization;
807
+ const organization = editor.presentation.organization;
771
808
  const owner = Array.isArray(organization) ? organization[0] : organization;
772
809
  orgLogo.set(stringOf(owner?.logo), "", "org");
773
810
  for (const input of orgLogo.wrap.querySelectorAll("input")) input.disabled = !owner;
774
- const bulletOption = getDesignOption(editor.document, "listBullet", scoped());
811
+ const bulletOption = getDesignOption(editor.presentation, "listBullet", scoped());
775
812
  bullet.set(bulletOption.value ?? "", sourceNote(bulletOption.scope, bulletOption.value !== undefined));
776
- const accentOption = getDesignOption(editor.document, "accentFont", scoped());
813
+ const accentOption = getDesignOption(editor.presentation, "accentFont", scoped());
777
814
  accent.set(accentOption.value ?? "", sourceNote(accentOption.scope, accentOption.value !== undefined));
778
- const watermark = getDesignOption(editor.document, "watermark", scoped());
815
+ const watermark = getDesignOption(editor.presentation, "watermark", scoped());
779
816
  const mark = watermark.value;
780
- // The opacity belongs to a watermark image; without one there is nothing to fade.
781
- for (const input of watermarkOpacity.wrap.querySelectorAll("input")) input.disabled = !(typeof mark === "string" || (mark && typeof mark === "object" && typeof mark.src === "string"));
817
+ // The opacity belongs to a watermark image or text; without one there is nothing to fade.
818
+ for (const input of watermarkOpacity.wrap.querySelectorAll("input")) input.disabled = !(typeof mark === "string" || (mark && typeof mark === "object" && (typeof mark.src === "string" || typeof mark.text === "string")));
819
+ watermarkText.set(mark && typeof mark === "object" && typeof mark.text === "string" ? mark.text : "", sourceNote(watermark.scope, mark !== undefined));
782
820
  watermarkSource.set(typeof mark === "string" ? mark : mark && typeof mark === "object" ? stringOf(mark) : "", sourceNote(watermark.scope, mark !== undefined), target());
783
821
  watermarkOpacity.set(mark && typeof mark === "object" && typeof mark.opacity === "number" ? String(mark.opacity) : "");
784
- const own = scopeIndex() === undefined ? editor.document.design?.watermark : editor.document.slides?.[scopeIndex()]?.design?.watermark;
822
+ const own = scopeIndex() === undefined ? editor.presentation.design?.watermark : editor.presentation.slides?.[scopeIndex()]?.design?.watermark;
785
823
  watermarkOff.set(own === false);
786
824
  watermarkOff.wrap.hidden = state.scope !== "slide";
787
- warningList.replaceChildren(...designWarnings(editor.document, getSlide()).filter((warning) => warning.path === "design.listBullet").map((warning) => h("li", {}, warning.message)));
825
+ warningList.replaceChildren(...designWarnings(editor.presentation, getSlide()).filter((warning) => warning.path === "design.listBullet").map((warning) => h("li", {}, warning.message)));
788
826
  });
789
827
  }
790
828
 
@@ -801,7 +839,7 @@ export function createDesignControls(container, options = {}) {
801
839
  field.setOptions(values.map((value) => ({ value, label: labels[value] ?? titleCase(value) })));
802
840
  body.append(field.wrap);
803
841
  syncs.push(() => {
804
- const option = getDesignOption(editor.document, id, scoped());
842
+ const option = getDesignOption(editor.presentation, id, scoped());
805
843
  field.set(option.value ?? "", sourceNote(option.scope, option.value !== undefined));
806
844
  });
807
845
  };
@@ -809,6 +847,7 @@ export function createDesignControls(container, options = {}) {
809
847
  enumField("contentAlignment", "Content alignment", ["left", "center", "right"], {});
810
848
  enumField("contentDirection", "Content direction", ["horizontal", "vertical"], { horizontal: "Horizontal (row)", vertical: "Vertical (column)" }, "The axis parallel content is arranged along when a slide sets no arrangement of its own.");
811
849
  enumField("chartPrimary", "Primary chart position", ["none", "top", "bottom", "left", "right"], { none: "None (equal)" }, "Where the main chart sits next to supporting content.");
850
+ enumField("imageFit", "Image fit", IMAGE_FITS, { cover: "Cover (crop to fill)", contain: "Contain (whole image)", stretch: "Stretch to the frame" }, "How pictures fill their frames when an image block does not choose its own fit.");
812
851
  const box = selectField("opt-contentBox", "Content box", {
813
852
  empty: "Default",
814
853
  onChange: (value) => run(() => setDesignOption(editor, "contentBox", value === "" ? null : value === "yes", scoped()), "Content box changed."),
@@ -819,7 +858,7 @@ export function createDesignControls(container, options = {}) {
819
858
  ]);
820
859
  body.append(box.wrap);
821
860
  syncs.push(() => {
822
- const option = getDesignOption(editor.document, "contentBox", scoped());
861
+ const option = getDesignOption(editor.presentation, "contentBox", scoped());
823
862
  box.set(option.value === undefined ? "" : option.value ? "yes" : "no", sourceNote(option.scope, option.value !== undefined));
824
863
  });
825
864
  }
@@ -832,11 +871,11 @@ export function createDesignControls(container, options = {}) {
832
871
  const field = selectField(name, label, { help, onChange: (value) => run(() => switchDimension(editor, dimension, value, catalogOptions()), `${label} set to ${field.select.selectedOptions[0]?.textContent ?? value}.`) });
833
872
  body.append(field.wrap);
834
873
  syncs.push(() => {
835
- field.setOptions(listSwitchOptions(editor.document, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
836
- field.set(currentSwitchValue(editor.document, dimension).value);
874
+ field.setOptions(listSwitchOptions(editor.presentation, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
875
+ field.set(currentSwitchValue(editor.presentation, dimension).value);
837
876
  });
838
877
  };
839
- single("narrative", "Narrative", "narratives", "The storyline the deck follows. Authoring metadata: the slides do not change.");
878
+ single("narrative", "Narrative", "narratives", "The narrative plan the deck points at (a catalog reference; a custom one is a record embedded in the document's catalogs). The slides do not change, and keep their beat links.");
840
879
  single("tone", "Tone", "tones");
841
880
  single("purpose", "Purpose", "purposes", "What the deck is for. Authoring metadata: the slides do not change. A goal written in the JSON source shows as custom.");
842
881
  const audience = selectField("audience", "Audience", {
@@ -850,9 +889,11 @@ export function createDesignControls(container, options = {}) {
850
889
  });
851
890
  body.append(audience.wrap);
852
891
  syncs.push(() => {
853
- audience.setOptions(listSwitchOptions(editor.document, "audiences", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
854
- const value = editor.document.audience;
855
- audience.set(Array.isArray(value) ? value : value === undefined ? [] : [value]);
892
+ audience.setOptions(listSwitchOptions(editor.presentation, "audiences", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
893
+ // The root audience is a string, one inline Audience object or an array of both: the picker shows the references.
894
+ const value = editor.presentation.audience;
895
+ const entries = Array.isArray(value) ? value : value === undefined ? [] : [value];
896
+ audience.set(entries.map((entry) => (entry && typeof entry === "object" ? entry.id : entry)).filter((id) => typeof id === "string"));
856
897
  });
857
898
 
858
899
  const owner = selectField("social-owner", "Socials belong to");
@@ -866,17 +907,17 @@ export function createDesignControls(container, options = {}) {
866
907
  handle.input.addEventListener("change", applySocial);
867
908
  body.append(owner.wrap, platform.wrap, handle.wrap, button("Save handle", applySocial));
868
909
  const showHandle = () => {
869
- const host = editor.document[owner.select.value];
910
+ const host = editor.presentation[owner.select.value];
870
911
  const target = Array.isArray(host) ? host[0] : host;
871
912
  handle.set(typeof target?.socials?.[platform.select.value] === "string" ? target.socials[platform.select.value] : "");
872
913
  };
873
914
  owner.select.addEventListener("change", showHandle);
874
915
  platform.select.addEventListener("change", showHandle);
875
916
  syncs.push(() => {
876
- const owners = ["speaker", "organization"].filter((name) => editor.document[name]);
917
+ const owners = ["speaker", "organization"].filter((name) => editor.presentation[name]);
877
918
  owner.setOptions(owners.length ? owners.map((name) => ({ value: name, label: titleCase(name) })) : [{ value: "speaker", label: "Speaker (add one first)", disabled: true }]);
878
919
  if (!owner.select.value || !owners.includes(owner.select.value)) owner.select.value = owners[0] ?? "speaker";
879
- platform.setOptions(listSwitchOptions(editor.document, "socials", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
920
+ platform.setOptions(listSwitchOptions(editor.presentation, "socials", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
880
921
  showHandle();
881
922
  });
882
923
  }
@@ -918,6 +959,159 @@ export function createDesignControls(container, options = {}) {
918
959
 
919
960
  if (built["slide-content"]) built["slide-content"].body.append(contentControls.slideNode);
920
961
 
962
+ // --- selected image (FA-22): fit, focus, treatments and placement of one image block -----------------------------
963
+
964
+ if (built.image) {
965
+ const { body, details } = built.image;
966
+ const summary = h("p", { class: "opf-dc-help", "data-role": "image-summary" });
967
+ const treat = (fields, done) => run(() => setImageTreatment(editor, dynamic.imagePath, fields), done);
968
+ const fit = selectField("img-fit", "Fit", {
969
+ empty: "Presentation default",
970
+ help: "Cover fills the frame around the focus point, contain shows the whole picture, stretch fills the frame exactly.",
971
+ onChange: (value) => treat({ fit: value === "" ? null : value }, "Image fit changed."),
972
+ });
973
+ fit.setOptions(IMAGE_FITS.map((value) => ({ value, label: titleCase(value) })));
974
+ const focusCommit = () => {
975
+ const x = focusX.input.value, y = focusY.input.value;
976
+ treat({ focus: x === "" && y === "" ? null : { x: x === "" ? 0.5 : Number(x), y: y === "" ? 0.5 : Number(y) } }, "Focus point changed.");
977
+ };
978
+ const focusX = numberField("img-focus-x", "Focus across (0 left to 1 right)", { min: 0, max: 1, step: 0.05, help: "The point a cover fit keeps in view. Clear both to center it.", onCommit: focusCommit });
979
+ const focusY = numberField("img-focus-y", "Focus down (0 top to 1 bottom)", { min: 0, max: 1, step: 0.05, onCommit: focusCommit });
980
+ const shape = selectField("img-shape", "Shape", { empty: "Rectangle", onChange: (value) => treat({ shape: value === "" ? null : value }, "Image shape changed.") });
981
+ shape.setOptions(IMAGE_SHAPES.filter((value) => value !== "rectangle").map((value) => ({ value, label: titleCase(value) })));
982
+ const radius = numberField("img-radius", "Corner radius (0 to 0.5 of the shorter side)", { min: 0, max: 0.5, step: 0.02, onCommit: (value) => treat({ cornerRadius: value }, "Corner radius changed.") });
983
+ const aspect = numberField("img-aspect", "Frame aspect ratio (width / height)", { min: 0.1, max: 10, step: 0.01, help: "For example 1.78 for 16:9, 1 for a square. Clear it to use the whole region.", onCommit: (value) => treat({ aspectRatio: value }, "Frame aspect ratio changed.") });
984
+ const opacity = numberField("img-opacity", "Opacity (0 to 1)", { min: 0, max: 1, step: 0.05, onCommit: (value) => treat({ opacity: value }, "Image opacity changed.") });
985
+ const borderCommit = () => {
986
+ const ref = borderColor.input.value.trim();
987
+ treat({ border: ref ? { color: ref, width: borderWidth.input.value === "" ? 2 : Number(borderWidth.input.value) } : null }, ref ? "Border changed." : "Border removed.");
988
+ };
989
+ const borderColor = textField("img-border-color", "Border color", { list: true, placeholder: "dark1 or #1F2937", help: "Clear it to remove the border.", onCommit: borderCommit });
990
+ borderColor.setList(COLOR_NAMES);
991
+ const borderWidth = numberField("img-border-width", "Border width (0 to 64)", { min: 0, max: 64, step: 1, onCommit: borderCommit });
992
+ const recolorCommit = () => {
993
+ const kind = recolor.select.value;
994
+ if (kind === "duotone" && (!duoDark.input.value.trim() || !duoLight.input.value.trim())) return say("Enter the dark and light duotone colors to apply it.");
995
+ treat({ recolor: kind === "" ? null : kind === "grayscale" ? "grayscale" : { dark: duoDark.input.value.trim(), light: duoLight.input.value.trim() } }, "Recolor changed.");
996
+ };
997
+ const recolor = selectField("img-recolor", "Recolor", { empty: "None", onChange: recolorCommit });
998
+ recolor.setOptions([
999
+ { value: "grayscale", label: "Grayscale" },
1000
+ { value: "duotone", label: "Duotone" },
1001
+ ]);
1002
+ const duoDark = textField("img-duo-dark", "Duotone dark color", { list: true, placeholder: "dark1", onCommit: recolorCommit });
1003
+ const duoLight = textField("img-duo-light", "Duotone light color", { list: true, placeholder: "accent1", onCommit: recolorCommit });
1004
+ duoDark.setList(COLOR_NAMES);
1005
+ duoLight.setList(COLOR_NAMES);
1006
+ const overlay = overlayFields("img-overlay", "Overlay", { onCommit: () => treat({ overlay: overlay.value() ?? null }, "Overlay changed.") });
1007
+ const placementCommit = () => {
1008
+ const edge = placeEdge.select.value;
1009
+ treat(
1010
+ { placement: edge === "" ? null : { edge, ...(placeSize.input.value === "" ? {} : { size: Number(placeSize.input.value) }), ...(placeInset.input.checked ? { inset: true } : {}) } },
1011
+ edge === "" ? "The image flows with the content again." : `The image bleeds to the ${edge} edge.`,
1012
+ );
1013
+ };
1014
+ const placeEdge = selectField("img-place-edge", "Bleed to an edge", {
1015
+ empty: "No: flows with the content",
1016
+ help: "The image takes a band along that slide edge, edge to edge, and the title and content use the rest of the slide.",
1017
+ onChange: placementCommit,
1018
+ });
1019
+ const placeSize = numberField("img-place-size", "Share of the slide (0.1 to 0.9)", { min: 0.1, max: 0.9, step: 0.05, onCommit: placementCommit });
1020
+ const placeInset = checkField("img-place-inset", "Inset inside the slide padding", { onChange: placementCommit });
1021
+ const placement = h("fieldset", { class: "opf-dc-fieldset", "data-role": "img-placement" }, h("legend", {}, "Placement"), placeEdge.wrap, placeSize.wrap, placeInset.wrap);
1022
+ body.append(summary, fit.wrap, focusX.wrap, focusY.wrap, shape.wrap, radius.wrap, aspect.wrap, opacity.wrap, borderColor.wrap, borderWidth.wrap, recolor.wrap, duoDark.wrap, duoLight.wrap, overlay.wrap, placement);
1023
+ dynamic.image = {
1024
+ details,
1025
+ load(path) {
1026
+ const current = readImageTreatments(editor.presentation, path);
1027
+ summary.textContent = `Image block (${current.path})`;
1028
+ fit.set(current.fit ?? "");
1029
+ focusX.set(current.focus ? String(current.focus.x) : "");
1030
+ focusY.set(current.focus ? String(current.focus.y) : "");
1031
+ shape.set(current.shape && current.shape !== "rectangle" ? current.shape : "");
1032
+ radius.set(current.cornerRadius === undefined ? "" : String(current.cornerRadius));
1033
+ radius.wrap.hidden = current.shape !== "rounded";
1034
+ aspect.set(current.aspectRatio === undefined ? "" : String(current.aspectRatio));
1035
+ opacity.set(current.opacity === undefined ? "" : String(current.opacity));
1036
+ borderColor.set(current.border?.color ?? "");
1037
+ borderWidth.set(current.border?.width === undefined ? "" : String(current.border.width));
1038
+ const duotone = current.recolor && typeof current.recolor === "object";
1039
+ recolor.set(current.recolor === "grayscale" ? "grayscale" : duotone ? "duotone" : "");
1040
+ duoDark.set(duotone ? current.recolor.dark : "");
1041
+ duoLight.set(duotone ? current.recolor.light : "");
1042
+ for (const field of [duoDark, duoLight]) field.wrap.hidden = !(duotone || recolor.select.value === "duotone");
1043
+ overlay.set(current.overlay);
1044
+ placement.hidden = !current.placeable;
1045
+ placeEdge.setOptions(IMAGE_EDGES.map((edge) => ({ value: edge, label: titleCase(edge), disabled: current.usedEdges.includes(edge), title: current.usedEdges.includes(edge) ? "Another image already bleeds to this edge." : undefined })));
1046
+ placeEdge.set(current.placement?.edge ?? "");
1047
+ placeSize.set(current.placement?.size === undefined ? "" : String(current.placement.size));
1048
+ placeInset.set(current.placement?.inset === true);
1049
+ for (const control of [placeSize, placeInset]) control.wrap.hidden = !current.placement;
1050
+ },
1051
+ };
1052
+ }
1053
+
1054
+ // --- catalog records (FA-23): reference findings and the explicit "Update from catalog" ---------------------------
1055
+
1056
+ if (built.catalog) {
1057
+ const { body } = built.catalog;
1058
+ const findings = h("ul", { class: "opf-dc-warnings", "aria-label": "Catalog reference problems", "data-role": "reference-findings" });
1059
+ const intro = h("p", { class: "opf-dc-help" }, "The document embeds every catalog record it uses when it is saved. Updating replaces an embedded record with the catalog's current one, only after you approve it.");
1060
+ const updates = h("ul", { class: "opf-dc-summary", "aria-label": "Catalog updates", "data-role": "catalog-updates" });
1061
+ const review = { changes: [] };
1062
+ const check = button("Check for catalog updates", () => {
1063
+ try {
1064
+ const update = checkCatalogUpdates(editor.presentation, catalogOptions());
1065
+ review.changes = update.changes;
1066
+ renderUpdates();
1067
+ say(update.changes.length ? `${update.changes.length} embedded record${update.changes.length === 1 ? " differs" : "s differ"} from the catalog. Choose what to update.` : "Every embedded record matches its catalog.");
1068
+ } catch (error) {
1069
+ complain(messageOf(error));
1070
+ }
1071
+ });
1072
+ const applyButton = button("Apply selected updates", () => {
1073
+ const refs = [...updates.querySelectorAll("input[type=checkbox]:checked")].map((input) => review.changes[Number(input.value)]).filter(Boolean).map(({ kind, reference }) => ({ kind, reference }));
1074
+ if (!refs.length) return complain("Choose at least one update to apply.");
1075
+ run(() => {
1076
+ const change = applyCatalogUpdate(editor, { ...catalogOptions(), refs });
1077
+ review.changes = [];
1078
+ renderUpdates();
1079
+ return change;
1080
+ }, (change) => `Updated ${change.changes?.length ?? 0} record${change.changes?.length === 1 ? "" : "s"} from the catalog.`);
1081
+ });
1082
+ const renderUpdates = () => {
1083
+ updates.replaceChildren(
1084
+ ...review.changes.map((change, index) => {
1085
+ const id = nextId("catalog-update");
1086
+ const fields = [...new Set([...Object.keys(change.embedded ?? {}), ...Object.keys(change.current ?? {})])].filter((key) => JSON.stringify(change.embedded?.[key]) !== JSON.stringify(change.current?.[key]));
1087
+ return h("li", {}, h("input", { type: "checkbox", id, value: String(index) }), h("label", { for: id }, `${change.kind} ${change.reference} (${change.source}): ${fields.length ? `changes ${fields.join(", ")}` : "changed"}`));
1088
+ }),
1089
+ );
1090
+ applyButton.hidden = review.changes.length === 0;
1091
+ };
1092
+ renderUpdates();
1093
+ body.append(intro, findings, h("div", { class: "opf-dc-actions" }, check, applyButton), updates);
1094
+ syncs.push(() => {
1095
+ const list = referenceFindings(editor.validation);
1096
+ findings.replaceChildren(
1097
+ ...list.map((finding) => {
1098
+ const item = h("li", { "data-rule": finding.ruleId }, `${finding.path ? `${finding.path}: ` : ""}${finding.message}`);
1099
+ // A record the registered catalog does not publish belongs to the document: offer to move it to custom.
1100
+ const target = finding.ruleId === "opf/catalog-record-not-in-source" ? catalogRecordAt(finding.path) : undefined;
1101
+ if (target)
1102
+ item.append(
1103
+ " ",
1104
+ button(`Move ${target.id} to this document's own records`, () =>
1105
+ run(() => moveToCustom(editor, target, catalogOptions()), (change) => `Moved ${target.kind} ${target.id} to catalogs.custom${change.to.id === target.id ? "" : ` as ${change.to.id}`}; its references follow.`),
1106
+ ),
1107
+ );
1108
+ return item;
1109
+ }),
1110
+ );
1111
+ findings.hidden = list.length === 0;
1112
+ });
1113
+ }
1114
+
921
1115
  // --- table --------------------------------------------------------------------------------------
922
1116
 
923
1117
  if (built.table) {
@@ -972,7 +1166,7 @@ export function createDesignControls(container, options = {}) {
972
1166
 
973
1167
  function syncSelection() {
974
1168
  const selected = getSelected();
975
- const document_ = editor.document;
1169
+ const document_ = editor.presentation;
976
1170
  const blockPath = selected ? blockPathForSelection(document_, selected) : undefined;
977
1171
  const content = blockPath ? readBlockContent(document_, blockPath) : undefined;
978
1172
  dynamic.blockPath = blockPath;
@@ -1003,6 +1197,13 @@ export function createDesignControls(container, options = {}) {
1003
1197
  }
1004
1198
  }
1005
1199
  }
1200
+ const imageUi = dynamic.image;
1201
+ if (imageUi) {
1202
+ // The slide's own image (a root `image`) is not an image block: it takes no framing fields.
1203
+ dynamic.imagePath = content?.key === "image" && blockPath.split(".").length > 2 ? blockPath : undefined;
1204
+ imageUi.details.hidden = !dynamic.imagePath;
1205
+ if (dynamic.imagePath) imageUi.load(dynamic.imagePath);
1206
+ }
1006
1207
  const tableUi = dynamic.table;
1007
1208
  if (tableUi) {
1008
1209
  const parsed = selected ? parseTableCellPath(selected) : undefined;