@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/README.md +60 -29
- package/dist/assets.js +1 -1
- package/dist/background-options.d.ts +10 -3
- package/dist/background-options.js +38 -13
- package/dist/canvas.d.ts +8 -1
- package/dist/canvas.js +12 -9
- package/dist/catalogs.d.ts +48 -0
- package/dist/catalogs.js +217 -0
- package/dist/checks.js +9 -7
- package/dist/content-actions.d.ts +2 -2
- package/dist/content-actions.js +3 -3
- package/dist/content-controls.js +9 -9
- package/dist/design-controls.d.ts +12 -5
- package/dist/design-controls.js +260 -76
- package/dist/design-options.d.ts +4 -4
- package/dist/design-options.js +6 -26
- package/dist/export.d.ts +2 -0
- package/dist/export.js +8 -3
- package/dist/galleries.js +32 -67
- package/dist/image-crop.js +8 -9
- package/dist/image-options.d.ts +45 -0
- package/dist/image-options.js +195 -0
- package/dist/index.d.ts +25 -8
- package/dist/index.js +91 -47
- package/dist/json-editor.js +2 -2
- package/dist/json-menu.js +2 -2
- package/dist/json-options.d.ts +3 -3
- package/dist/json-options.js +30 -41
- package/dist/react.js +1 -1
- package/dist/review-panel.js +8 -2
- package/dist/schema-inspector.js +1 -1
- package/dist/schema.js +1 -1
- package/dist/slide-manager.js +1 -1
- package/dist/slides.d.ts +1 -1
- package/dist/slides.js +6 -5
- package/dist/svelte.js +1 -2
- package/dist/switches.d.ts +31 -16
- package/dist/switches.js +191 -90
- package/dist/transfer.d.ts +12 -1
- package/dist/transfer.js +87 -131
- package/package.json +9 -5
package/dist/design-controls.js
CHANGED
|
@@ -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", "
|
|
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
|
-
|
|
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
|
|
93
|
-
*
|
|
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
|
-
|
|
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;
|
|
@@ -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.
|
|
@@ -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", "
|
|
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", {
|
|
@@ -393,12 +432,12 @@ export function createDesignControls(container, options = {}) {
|
|
|
393
432
|
body.append(field.wrap);
|
|
394
433
|
syncs.push(() => {
|
|
395
434
|
field.setOptions(listSwitchOptions(editor.presentation, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
|
|
396
|
-
const current = currentSwitchValue(editor.presentation, dimension, perSlide ? scoped() : {});
|
|
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
|
|
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." });
|
|
@@ -410,7 +449,7 @@ export function createDesignControls(container, options = {}) {
|
|
|
410
449
|
body.append(slideSize.wrap);
|
|
411
450
|
syncs.push(() => {
|
|
412
451
|
slideSize.setOptions(listSwitchOptions(editor.presentation, "slide-sizes").map((entry) => ({ value: entry.id, label: entry.label })));
|
|
413
|
-
const { value } = currentSwitchValue(editor.presentation, "slide-sizes");
|
|
452
|
+
const { value } = currentSwitchValue(editor.presentation, "slide-sizes", catalogOptions());
|
|
414
453
|
slideSize.set(value && typeof value === "object" ? "Custom size" : value);
|
|
415
454
|
});
|
|
416
455
|
|
|
@@ -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,
|
|
484
|
-
build: (ref, doc) => prepareBackground(doc,
|
|
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
|
|
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 = () => {
|
|
@@ -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", "Image placeholders", {
|
|
596
|
-
empty: "Presentation default",
|
|
597
|
-
help: "How images fill their layout placeholders across the presentation.",
|
|
598
|
-
onChange: (value) => run(() => switchDimension(editor, "image-treatments", { imageFill: value === "" ? null : value }), "Image 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.presentation, "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.presentation.design?.imageFill ?? "");
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
|
|
622
645
|
// --- header and footer --------------------------------------------------------------------------
|
|
623
646
|
|
|
624
647
|
if (built["header-footer"]) {
|
|
@@ -824,6 +847,7 @@ export function createDesignControls(container, options = {}) {
|
|
|
824
847
|
enumField("contentAlignment", "Content alignment", ["left", "center", "right"], {});
|
|
825
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.");
|
|
826
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.");
|
|
827
851
|
const box = selectField("opt-contentBox", "Content box", {
|
|
828
852
|
empty: "Default",
|
|
829
853
|
onChange: (value) => run(() => setDesignOption(editor, "contentBox", value === "" ? null : value === "yes", scoped()), "Content box changed."),
|
|
@@ -851,7 +875,7 @@ export function createDesignControls(container, options = {}) {
|
|
|
851
875
|
field.set(currentSwitchValue(editor.presentation, dimension).value);
|
|
852
876
|
});
|
|
853
877
|
};
|
|
854
|
-
single("narrative", "Narrative", "narratives", "The narrative plan the deck points at (a catalog
|
|
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.");
|
|
855
879
|
single("tone", "Tone", "tones");
|
|
856
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.");
|
|
857
881
|
const audience = selectField("audience", "Audience", {
|
|
@@ -866,7 +890,7 @@ export function createDesignControls(container, options = {}) {
|
|
|
866
890
|
body.append(audience.wrap);
|
|
867
891
|
syncs.push(() => {
|
|
868
892
|
audience.setOptions(listSwitchOptions(editor.presentation, "audiences", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
|
|
869
|
-
// The root audience is a string, one inline Audience object or an array of both: the picker shows the
|
|
893
|
+
// The root audience is a string, one inline Audience object or an array of both: the picker shows the references.
|
|
870
894
|
const value = editor.presentation.audience;
|
|
871
895
|
const entries = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
872
896
|
audience.set(entries.map((entry) => (entry && typeof entry === "object" ? entry.id : entry)).filter((id) => typeof id === "string"));
|
|
@@ -935,6 +959,159 @@ export function createDesignControls(container, options = {}) {
|
|
|
935
959
|
|
|
936
960
|
if (built["slide-content"]) built["slide-content"].body.append(contentControls.slideNode);
|
|
937
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
|
+
|
|
938
1115
|
// --- table --------------------------------------------------------------------------------------
|
|
939
1116
|
|
|
940
1117
|
if (built.table) {
|
|
@@ -1020,6 +1197,13 @@ export function createDesignControls(container, options = {}) {
|
|
|
1020
1197
|
}
|
|
1021
1198
|
}
|
|
1022
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
|
+
}
|
|
1023
1207
|
const tableUi = dynamic.table;
|
|
1024
1208
|
if (tableUi) {
|
|
1025
1209
|
const parsed = selected ? parseTableCellPath(selected) : undefined;
|
package/dist/design-options.d.ts
CHANGED
|
@@ -7,15 +7,15 @@ export type DesignOptionId =
|
|
|
7
7
|
| "chartPrimary"
|
|
8
8
|
| "listBullet"
|
|
9
9
|
| "contentBox"
|
|
10
|
+
| "imageFit"
|
|
10
11
|
| "accentFont"
|
|
11
12
|
| "logo"
|
|
12
13
|
| "organizationLogo"
|
|
13
|
-
| "watermark"
|
|
14
|
-
| "slideImage";
|
|
14
|
+
| "watermark";
|
|
15
15
|
export interface DesignOptionDescriptor {
|
|
16
16
|
id: DesignOptionId;
|
|
17
17
|
label: string;
|
|
18
|
-
type: "enum" | "boolean" | "font" | "logo" | "organization-logo" | "watermark"
|
|
18
|
+
type: "enum" | "boolean" | "font" | "logo" | "organization-logo" | "watermark";
|
|
19
19
|
/** Allowed values of an enum option. */
|
|
20
20
|
values?: readonly string[];
|
|
21
21
|
scopes: ("deck" | "slide")[];
|
|
@@ -72,7 +72,7 @@ export interface DesignOptionChange extends Omit<EditorChange, "presentation" |
|
|
|
72
72
|
warnings: DesignWarning[];
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Compute the patch for one option. `null` removes it at that scope;
|
|
75
|
+
/** Compute the patch for one option. `null` removes it at that scope; the watermark object merges the fields passed. `imageFit` is the default fit of image blocks (cover, contain, stretch). */
|
|
76
76
|
export declare function prepareDesignOption(presentation: unknown, option: DesignOptionId, value: unknown, options?: DesignOptionOptions): PreparedDesignOption;
|
|
77
77
|
/** Set one option as a single undoable transaction. */
|
|
78
78
|
export declare function setDesignOption(editor: EditorSession, option: DesignOptionId, value: unknown, options?: DesignOptionOptions): DesignOptionChange;
|
package/dist/design-options.js
CHANGED
|
@@ -41,19 +41,18 @@ function checkZoneField(key, value) {
|
|
|
41
41
|
if (key === "dateFormat" && (typeof value !== "string" || !value.trim())) throw bad(`A date format uses tokens such as ${DATE_FORMAT_TOKENS.join(", ")}.`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const SLIDE_IMAGE_POSITIONS = ["background", "top", "bottom", "left", "right"];
|
|
45
|
-
|
|
46
44
|
const ENUMS = Object.freeze({
|
|
47
45
|
titleAlignment: ["left", "center", "right"],
|
|
48
46
|
contentAlignment: ["left", "center", "right"],
|
|
49
47
|
contentDirection: ["horizontal", "vertical"],
|
|
50
48
|
chartPrimary: ["none", "top", "bottom", "left", "right"],
|
|
51
49
|
listBullet: ["character", "image"],
|
|
50
|
+
imageFit: ["cover", "contain", "stretch"],
|
|
52
51
|
});
|
|
53
52
|
|
|
54
53
|
/**
|
|
55
54
|
* Descriptors for every design option, in the order a panel shows them. `type` is "enum", "boolean",
|
|
56
|
-
* "font", "asset", "logo", "watermark"
|
|
55
|
+
* "font", "asset", "logo", "watermark" or "organization-logo"; `scopes` says whether
|
|
57
56
|
* the option applies to the deck, one slide, or both.
|
|
58
57
|
*/
|
|
59
58
|
export const DESIGN_OPTIONS = Object.freeze([
|
|
@@ -63,11 +62,11 @@ export const DESIGN_OPTIONS = Object.freeze([
|
|
|
63
62
|
{ id: "chartPrimary", label: "Primary chart position", type: "enum", values: ENUMS.chartPrimary, scopes: ["deck", "slide"], path: "design.chartPrimary" },
|
|
64
63
|
{ id: "listBullet", label: "List bullets", type: "enum", values: ENUMS.listBullet, scopes: ["deck", "slide"], path: "design.listBullet" },
|
|
65
64
|
{ id: "contentBox", label: "Content box", type: "boolean", scopes: ["deck", "slide"], path: "design.contentBox" },
|
|
65
|
+
{ id: "imageFit", label: "Image fit", type: "enum", values: ENUMS.imageFit, scopes: ["deck", "slide"], path: "design.imageFit" },
|
|
66
66
|
{ id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent" },
|
|
67
67
|
{ id: "logo", label: "Logo", type: "logo", scopes: ["deck", "slide"], path: "design.logo" },
|
|
68
68
|
{ id: "organizationLogo", label: "Organization logo", type: "organization-logo", scopes: ["deck"], path: "organization.logo" },
|
|
69
69
|
{ id: "watermark", label: "Watermark", type: "watermark", scopes: ["deck", "slide"], path: "design.watermark" },
|
|
70
|
-
{ id: "slideImage", label: "Slide image", type: "slide-image", scopes: ["deck", "slide"], path: "design.slideImage" },
|
|
71
70
|
]);
|
|
72
71
|
const BY_ID = Object.fromEntries(DESIGN_OPTIONS.map((option) => [option.id, option]));
|
|
73
72
|
|
|
@@ -173,22 +172,6 @@ function mergedWatermark(existing, value) {
|
|
|
173
172
|
return merged;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
|
-
function mergedSlideImage(existing, value) {
|
|
177
|
-
if (value === null) return null;
|
|
178
|
-
if (typeof value === "string") return value;
|
|
179
|
-
if (!isObject(value)) throw fail("invalid-design-value", "A slide image is an image source or an object with a position.", { value });
|
|
180
|
-
const base = typeof existing === "string" ? { src: existing } : isObject(existing) ? (existing.position ? { ...existing } : { src: existing.src, ...(existing.alt ? { alt: existing.alt } : {}) }) : {};
|
|
181
|
-
const merged = { ...base };
|
|
182
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
183
|
-
if (entry === null || entry === undefined) delete merged[key];
|
|
184
|
-
else merged[key] = entry;
|
|
185
|
-
}
|
|
186
|
-
if (merged.src === undefined) delete merged.src;
|
|
187
|
-
if (merged.position === undefined) merged.position = "background";
|
|
188
|
-
if (!SLIDE_IMAGE_POSITIONS.includes(merged.position)) throw fail("invalid-design-value", `Slide image position is one of ${SLIDE_IMAGE_POSITIONS.join(", ")}.`, { value });
|
|
189
|
-
return merged;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
175
|
function fontSchemeWithAccent(presentation, base, family) {
|
|
193
176
|
// The slide's own font scheme, else the deck's: an object form is kept with its overrides.
|
|
194
177
|
const own = ownDesign(presentation, base).fontScheme;
|
|
@@ -223,8 +206,8 @@ function collapseLogo(set) {
|
|
|
223
206
|
|
|
224
207
|
/**
|
|
225
208
|
* Compute the patch that sets one design option, without touching any session. `value === null`
|
|
226
|
-
* removes the option at that scope so it is inherited again.
|
|
227
|
-
*
|
|
209
|
+
* removes the option at that scope so it is inherited again. The object-valued `watermark` merges the
|
|
210
|
+
* fields you pass into the existing object; a `null` field removes it.
|
|
228
211
|
* Options: `slideIndex` (one slide instead of the deck), `clearSlideOverrides` (deck scope: also
|
|
229
212
|
* remove slide-level values that hide it) and `index` (organizationLogo with several
|
|
230
213
|
* organizations).
|
|
@@ -261,12 +244,9 @@ export function prepareDesignOption(presentation, option, value, options = {}) {
|
|
|
261
244
|
} else if (descriptor.type === "logo") {
|
|
262
245
|
if (value !== null && typeof value !== "string" && !isObject(value)) throw fail("invalid-design-value", "A logo is an image source, an asset object or a set of logo variants.", { option });
|
|
263
246
|
entries = { logo: value };
|
|
264
|
-
} else
|
|
247
|
+
} else {
|
|
265
248
|
const existing = ownDesign(presentation, base).watermark;
|
|
266
249
|
entries = { watermark: mergedWatermark(existing, value) };
|
|
267
|
-
} else {
|
|
268
|
-
const existing = ownDesign(presentation, base).slideImage;
|
|
269
|
-
entries = { slideImage: mergedSlideImage(existing, value) };
|
|
270
250
|
}
|
|
271
251
|
keys = Object.keys(entries);
|
|
272
252
|
patches = designPatches(presentation, base, entries);
|
package/dist/export.d.ts
CHANGED
|
@@ -46,6 +46,8 @@ export interface ExportOptions {
|
|
|
46
46
|
scale?: number;
|
|
47
47
|
/** The options the host draws its preview with (`catalogs`, `date`, ...); the fonts go in `fonts`. */
|
|
48
48
|
renderOptions?: RenderSvgOptions;
|
|
49
|
+
/** Host catalogs (merged with `renderOptions.catalogs`). The deck is embedded first (core `embed`), like a saved file. */
|
|
50
|
+
catalogs?: readonly import("@openpresentation/opf").Catalog[];
|
|
49
51
|
/** The renderer's fonts handle (`loadFonts()`): it measures the text, loads the faces the deck needs before anything is drawn, and its registry's faces are what gets embedded. */
|
|
50
52
|
fonts?: EditorFonts;
|
|
51
53
|
/** Faces to embed instead of the registry's. */
|