@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.
- package/README.md +122 -80
- package/dist/annotations.d.ts +16 -16
- package/dist/annotations.js +71 -79
- package/dist/assets.d.ts +7 -7
- package/dist/assets.js +16 -16
- package/dist/background-options.d.ts +12 -5
- package/dist/background-options.js +44 -19
- package/dist/block-controls.js +7 -7
- package/dist/block-convert.d.ts +7 -7
- package/dist/block-convert.js +22 -21
- package/dist/blocks.d.ts +8 -8
- package/dist/blocks.js +41 -40
- package/dist/canvas-fields.js +35 -12
- package/dist/canvas.d.ts +30 -31
- package/dist/canvas.js +64 -68
- package/dist/catalogs.d.ts +48 -0
- package/dist/catalogs.js +217 -0
- package/dist/chart-data.d.ts +10 -10
- package/dist/chart-data.js +21 -21
- package/dist/chart-options-panel.d.ts +2 -2
- package/dist/chart-options-panel.js +82 -8
- package/dist/chart-options.d.ts +35 -9
- package/dist/chart-options.js +133 -40
- package/dist/checks.js +23 -0
- package/dist/color-authoring.js +1 -1
- package/dist/content-actions.d.ts +17 -17
- package/dist/content-actions.js +74 -73
- package/dist/content-controls.js +21 -21
- package/dist/data-grid.js +7 -8
- package/dist/data.d.ts +4 -4
- package/dist/data.js +5 -5
- package/dist/dataset-refs.d.ts +2 -2
- package/dist/dataset-refs.js +5 -5
- package/dist/design-controls.d.ts +12 -5
- package/dist/design-controls.js +312 -111
- package/dist/design-options.d.ts +20 -18
- package/dist/design-options.js +91 -99
- package/dist/edit-helpers.js +8 -7
- package/dist/exact-source.js +5 -5
- package/dist/export.d.ts +7 -7
- package/dist/export.js +48 -17
- package/dist/find-panel.js +5 -5
- package/dist/find-replace.d.ts +3 -3
- package/dist/find-replace.js +23 -20
- package/dist/font-gate.js +49 -58
- package/dist/galleries.js +50 -101
- package/dist/grid-model.d.ts +25 -27
- package/dist/grid-model.js +92 -102
- package/dist/image-crop.d.ts +4 -4
- package/dist/image-crop.js +28 -29
- package/dist/image-cropper.js +3 -3
- package/dist/image-options.d.ts +45 -0
- package/dist/image-options.js +195 -0
- package/dist/index.d.ts +48 -31
- package/dist/index.js +154 -126
- 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 +44 -54
- package/dist/layout-handles.js +5 -5
- package/dist/layout-placeholders.js +4 -4
- package/dist/layout.d.ts +2 -2
- package/dist/layout.js +8 -7
- package/dist/numbering-panel.js +3 -5
- package/dist/numbering.d.ts +3 -5
- package/dist/numbering.js +14 -26
- package/dist/outline-view.js +24 -24
- package/dist/outline.d.ts +12 -12
- package/dist/outline.js +52 -50
- package/dist/persistence.d.ts +1 -1
- package/dist/persistence.js +19 -18
- package/dist/presentation-ids.js +2 -2
- package/dist/react.js +3 -3
- package/dist/review-panel.d.ts +30 -13
- package/dist/review-panel.js +160 -53
- package/dist/review.d.ts +36 -43
- package/dist/review.js +92 -53
- package/dist/rich-text-input.js +4 -3
- package/dist/rich-text-toolbar.js +5 -3
- package/dist/rich-text.js +5 -2
- package/dist/schema-inspector.d.ts +2 -2
- package/dist/schema-inspector.js +16 -11
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +26 -6
- package/dist/slide-manager.d.ts +2 -2
- package/dist/slide-manager.js +19 -19
- package/dist/slides.d.ts +21 -21
- package/dist/slides.js +104 -101
- package/dist/svelte.js +2 -3
- package/dist/switches.d.ts +37 -22
- package/dist/switches.js +282 -171
- package/dist/table-options.d.ts +8 -8
- package/dist/table-options.js +28 -27
- package/dist/table-structure.d.ts +10 -10
- package/dist/table-structure.js +21 -21
- package/dist/template-panel.d.ts +3 -3
- package/dist/template-panel.js +37 -13
- package/dist/templates.d.ts +22 -11
- package/dist/templates.js +48 -55
- package/dist/transfer.d.ts +15 -4
- package/dist/transfer.js +107 -161
- package/package.json +12 -6
- package/dist/font-defaults.js +0 -25
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;
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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", "
|
|
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.
|
|
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.
|
|
396
|
-
const current = currentSwitchValue(editor.
|
|
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
|
|
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.
|
|
413
|
-
const { value } = currentSwitchValue(editor.
|
|
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.
|
|
424
|
-
layout.set(currentSwitchValue(editor.
|
|
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,
|
|
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 = () => {
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
805
|
+
const variants = readLogoVariants(editor.presentation, scoped());
|
|
769
806
|
logoSource.set(stringOf(variants[variant.select.value]), "", `${target()}:${variant.select.value}`);
|
|
770
|
-
const organization = editor.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
836
|
-
field.set(currentSwitchValue(editor.
|
|
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
|
|
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.
|
|
854
|
-
|
|
855
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|