@openpresentation/opf-editor 0.16.0 → 0.18.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 +53 -44
- package/dist/assets.js +1 -1
- package/dist/canvas.d.ts +4 -4
- package/dist/canvas.js +4 -4
- package/dist/data.d.ts +3 -3
- package/dist/data.js +3 -3
- package/dist/design-controls.js +217 -63
- package/dist/design-options.d.ts +98 -29
- package/dist/design-options.js +307 -118
- package/dist/export.d.ts +42 -41
- package/dist/export.js +151 -87
- package/dist/find-replace.js +9 -1
- package/dist/font-gate.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -5
- package/dist/review.d.ts +6 -1
- package/dist/review.js +37 -1
- package/dist/template-panel.d.ts +1 -1
- package/dist/template-panel.js +10 -5
- package/dist/templates.d.ts +3 -1
- package/dist/templates.js +1 -1
- package/package.json +6 -5
package/dist/design-options.js
CHANGED
|
@@ -5,27 +5,44 @@
|
|
|
5
5
|
// session form that commits it with `meta.source: "design-option"`.
|
|
6
6
|
import { getValueAtPath, opfPathToJsonPointer } from "./index.js";
|
|
7
7
|
import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
|
|
8
|
+
import { LOGO_SHAPES, listBuiltinVariables } from "@openpresentation/opf";
|
|
9
|
+
import { resolveLogo } from "@openpresentation/opf/composition";
|
|
8
10
|
import { checkFormat } from "./checks.js";
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"stackedDark",
|
|
18
|
-
"icon",
|
|
19
|
-
"iconLight",
|
|
20
|
-
"iconDark",
|
|
21
|
-
"wordmark",
|
|
22
|
-
"wordmarkLight",
|
|
23
|
-
"wordmarkDark",
|
|
24
|
-
]);
|
|
12
|
+
// RR-71 (OPF 0.18): logos live on the organization. `organization.logo` is one image for every shape, or up to four shapes
|
|
13
|
+
// (full, stacked, icon, wordmark), each one image or an { onLight, onDark } pair. They are placed with whole-field
|
|
14
|
+
// `var:organization(.<id>)?.logo(.<shape>)?` references: in a header or footer zone's `image`, and in `design.logo`, which
|
|
15
|
+
// picks the organization and shape that covers, sections and picture bullets draw (or `false` for none).
|
|
16
|
+
export { LOGO_SHAPES };
|
|
17
|
+
/** The backgrounds a logo shape can be split by: one image for both, or one for light and one for dark backgrounds. */
|
|
18
|
+
export const LOGO_BACKGROUNDS = Object.freeze(["both", "onLight", "onDark"]);
|
|
25
19
|
export const HEADER_FOOTER_ZONES = Object.freeze(["left", "center", "right"]);
|
|
26
20
|
// Flag fields of a header/footer zone: false is stored as "absent" (so is a `date` of false).
|
|
27
|
-
const ZONE_FLAGS = ["
|
|
28
|
-
export const ZONE_FIELDS = Object.freeze(["
|
|
21
|
+
const ZONE_FLAGS = ["socials"];
|
|
22
|
+
export const ZONE_FIELDS = Object.freeze(["text", "image", "date", "dateFormat", "socials"]);
|
|
23
|
+
// FA-31, RR-71: generated values are `{{ }}` variables inside a zone's `text` and the logo is the zone's `image`. The keys 0.16 and 0.17 had for them are gone; say what to write instead.
|
|
24
|
+
const REMOVED_ZONE_FIELDS = Object.freeze({
|
|
25
|
+
logo: "Use Insert logo (insertZoneLogo): the zone's image is var:organization.logo.icon.",
|
|
26
|
+
organization: "Write {{organization.name}} in the text.",
|
|
27
|
+
speaker: "Write {{speaker.name}} in the text.",
|
|
28
|
+
section: "Write {{slide.section}} in the text.",
|
|
29
|
+
slideNumber: "Write {{slide.number}} in the text.",
|
|
30
|
+
slideNumberFormat: "Write {{slide.number}} and {{deck.slideCount}} in the text.",
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* The values the "Insert value" menu of a header or footer zone offers, in menu order. `token` is what lands in the zone's `text`.
|
|
34
|
+
* `{{slide.number}}`, `{{deck.slideCount}}` and `{{slide.section}}` vary per slide; the others come from the deck's own fields.
|
|
35
|
+
*/
|
|
36
|
+
export const ZONE_VALUES = Object.freeze(
|
|
37
|
+
[
|
|
38
|
+
{ name: "slide.number", label: "Slide number" },
|
|
39
|
+
{ name: "deck.slideCount", label: "Slide count" },
|
|
40
|
+
{ name: "slide.section", label: "Section" },
|
|
41
|
+
{ name: "organization.name", label: "Organization" },
|
|
42
|
+
{ name: "speaker.name", label: "Speaker" },
|
|
43
|
+
{ name: "deck.name", label: "Deck name" },
|
|
44
|
+
].map((entry) => Object.freeze({ ...entry, token: `{{${entry.name}}}` })),
|
|
45
|
+
);
|
|
29
46
|
/** Date tokens a `dateFormat` understands (English names, independent of the host locale). */
|
|
30
47
|
export const DATE_FORMAT_TOKENS = Object.freeze(["yyyy", "yy", "MMMM", "MMM", "MM", "M", "dd", "d", "EEEE", "EEE"]);
|
|
31
48
|
const ISO_DATE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
|
|
@@ -36,7 +53,6 @@ function checkZoneField(key, value) {
|
|
|
36
53
|
if (ZONE_FLAGS.includes(key) && typeof value !== "boolean") throw bad(`${key} is true or false.`);
|
|
37
54
|
if (key === "text" && typeof value !== "string") throw bad("Text is a string.");
|
|
38
55
|
if (key === "image" && typeof value !== "string" && !isObject(value)) throw bad("An image is a source, an asset reference or an asset object.");
|
|
39
|
-
if (key === "slideNumberFormat" && (typeof value !== "string" || !value.includes("{current}"))) throw bad("The slide number format must contain {current}, for example Page {current} of {total}.");
|
|
40
56
|
if (key === "date" && typeof value !== "boolean" && typeof value !== "string") throw bad("A date is true (the current date) or a fixed date.");
|
|
41
57
|
if (key === "dateFormat" && (typeof value !== "string" || !value.trim())) throw bad(`A date format uses tokens such as ${DATE_FORMAT_TOKENS.join(", ")}.`);
|
|
42
58
|
}
|
|
@@ -52,7 +68,7 @@ const ENUMS = Object.freeze({
|
|
|
52
68
|
|
|
53
69
|
/**
|
|
54
70
|
* Descriptors for every design option, in the order a panel shows them. `type` is "enum", "boolean",
|
|
55
|
-
* "font", "
|
|
71
|
+
* "font", "logo" (`design.logo`: a logo reference or false) or "watermark"; `scopes` says whether
|
|
56
72
|
* the option applies to the deck, one slide, or both.
|
|
57
73
|
*/
|
|
58
74
|
export const DESIGN_OPTIONS = Object.freeze([
|
|
@@ -65,7 +81,6 @@ export const DESIGN_OPTIONS = Object.freeze([
|
|
|
65
81
|
{ id: "imageFit", label: "Image fit", type: "enum", values: ENUMS.imageFit, scopes: ["deck", "slide"], path: "design.imageFit" },
|
|
66
82
|
{ id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent" },
|
|
67
83
|
{ id: "logo", label: "Logo", type: "logo", scopes: ["deck", "slide"], path: "design.logo" },
|
|
68
|
-
{ id: "organizationLogo", label: "Organization logo", type: "organization-logo", scopes: ["deck"], path: "organization.logo" },
|
|
69
84
|
{ id: "watermark", label: "Watermark", type: "watermark", scopes: ["deck", "slide"], path: "design.watermark" },
|
|
70
85
|
]);
|
|
71
86
|
const BY_ID = Object.fromEntries(DESIGN_OPTIONS.map((option) => [option.id, option]));
|
|
@@ -90,43 +105,120 @@ function primaryOrganization(presentation) {
|
|
|
90
105
|
return list.find((entry) => entry?.role === "primary") ?? list[0];
|
|
91
106
|
}
|
|
92
107
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
// --- logo references ----------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const LOGO_REFERENCE = /^var:organization(?:\.([A-Za-z0-9_-]+))?\.logo(?:\.(full|stacked|icon|wordmark))?$/;
|
|
111
|
+
/**
|
|
112
|
+
* Parse a logo reference (`var:organization.logo`, `var:organization.logo.icon`, `var:organization.beta.logo.wordmark`) into
|
|
113
|
+
* `{ organization?, shape? }` (the organization id, and the shape when the reference names one), or null for anything else.
|
|
114
|
+
*/
|
|
115
|
+
export function parseLogoReference(text) {
|
|
116
|
+
const match = typeof text === "string" ? LOGO_REFERENCE.exec(text) : null;
|
|
117
|
+
if (!match) return null;
|
|
118
|
+
return { ...(match[1] !== undefined ? { organization: match[1] } : {}), ...(match[2] !== undefined ? { shape: match[2] } : {}) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The `var:` reference for an organization's logo: `organization` is the organization's id (omit it for the primary
|
|
122
|
+
* organization) and `shape` one of `LOGO_SHAPES` (omit it to leave the shape to where the logo is drawn).
|
|
123
|
+
*/
|
|
124
|
+
export function logoReference({ organization, shape } = {}) {
|
|
125
|
+
if (organization !== undefined && !(typeof organization === "string" && /^[A-Za-z0-9_-]+$/.test(organization)))
|
|
126
|
+
throw fail("invalid-design-value", "An organization is addressed by its id (letters, digits, - and _).", { organization });
|
|
127
|
+
if (shape !== undefined && !LOGO_SHAPES.includes(shape)) throw fail("invalid-design-value", `Logo shape is one of ${LOGO_SHAPES.join(", ")}.`, { shape });
|
|
128
|
+
return `var:organization${organization !== undefined ? `.${organization}` : ""}.logo${shape !== undefined ? `.${shape}` : ""}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The deck's organizations as a panel lists them: `{ index, id, name, primary, hasLogo }`. `id` is what a `var:` reference
|
|
133
|
+
* addresses (an organization without one is only reachable as the primary organization); `primary` marks the organization
|
|
134
|
+
* the unset logo and `var:organization.logo` mean (`role: "primary"`, else the first).
|
|
135
|
+
*/
|
|
136
|
+
export function listOrganizations(presentation) {
|
|
137
|
+
const raw = presentation?.organization;
|
|
138
|
+
const entries = Array.isArray(raw) ? raw : isObject(raw) ? [raw] : [];
|
|
139
|
+
const explicit = entries.findIndex((entry) => isObject(entry) && entry.role === "primary");
|
|
140
|
+
const primary = explicit >= 0 ? explicit : entries.findIndex(isObject);
|
|
141
|
+
return entries.flatMap((entry, index) =>
|
|
142
|
+
isObject(entry)
|
|
143
|
+
? [{ index, ...(typeof entry.id === "string" ? { id: entry.id } : {}), ...(typeof entry.name === "string" ? { name: entry.name } : {}), primary: index === primary, hasLogo: entry.logo !== undefined }]
|
|
144
|
+
: [],
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Whether a logo resolves for the slide: its `design.logo`, then the deck's, then the primary organization's (`shape`: the shape asked for, default full). */
|
|
149
|
+
export function hasResolvableLogo(presentation, slideIndex, { shape } = {}) {
|
|
150
|
+
try {
|
|
151
|
+
return resolveLogo(presentation, presentation.slides?.[slideIndex], { slideIndex, ...(shape ? { shape } : {}) }) !== null;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The variable names a zone's `text` uses (`{{name}}` and `{{name|format}}`); a token with a backslash before it is literal text. */
|
|
158
|
+
function zoneTextVariables(text) {
|
|
159
|
+
const names = [];
|
|
160
|
+
for (const match of text.matchAll(/(\\?)\{\{\s*([A-Za-z0-9_.-]+)\s*(?:\|[^{}]*)?\}\}/g)) if (!match[1]) names.push(match[2]);
|
|
161
|
+
return names;
|
|
96
162
|
}
|
|
97
163
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
164
|
+
function logoResolves(presentation, slide, slideIndex, options) {
|
|
165
|
+
try {
|
|
166
|
+
return resolveLogo(presentation, slide, { slideIndex, ...options }) !== null;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function describeLogoReference(presentation, text) {
|
|
172
|
+
const parsed = parseLogoReference(text);
|
|
173
|
+
const organizations = listOrganizations(presentation);
|
|
174
|
+
const owner = parsed?.organization !== undefined ? organizations.find((entry) => entry.id === parsed.organization) : organizations.find((entry) => entry.primary);
|
|
175
|
+
const who = parsed?.organization !== undefined ? (owner?.name ?? `organization ${parsed.organization}`) : "the primary organization";
|
|
176
|
+
return `the ${parsed?.shape ?? "full"} logo of ${who}`;
|
|
101
177
|
}
|
|
102
178
|
|
|
103
179
|
/**
|
|
104
|
-
* Plain-language warnings for settings that need
|
|
105
|
-
*
|
|
106
|
-
*
|
|
180
|
+
* Plain-language warnings for settings that need content the document does not have: a logo (a header or footer zone whose
|
|
181
|
+
* `image` is a logo reference, `design.logo` naming an organization, picture bullets) that no organization provides, the
|
|
182
|
+
* organization's social profiles, or a value a zone's `text` asks for with a variable (`{{organization.name}}` without an
|
|
183
|
+
* organization, `{{slide.section}}` on a slide without a section).
|
|
184
|
+
* The renderers fall back to a glyph or report unresolved content; the editor surfaces it before export.
|
|
107
185
|
*/
|
|
108
186
|
export function designWarnings(presentation, slideIndex = 0) {
|
|
109
187
|
const slide = presentation.slides?.[slideIndex];
|
|
110
188
|
const design = { ...presentation.design, ...slide?.design };
|
|
111
189
|
const warnings = [];
|
|
112
|
-
|
|
190
|
+
// RR-71: a zone's image that is a logo reference needs the organization to have that logo; an override that names an organization never falls back to another.
|
|
113
191
|
for (const which of ["header", "footer"])
|
|
114
|
-
for (const zone of HEADER_FOOTER_ZONES)
|
|
115
|
-
|
|
116
|
-
|
|
192
|
+
for (const zone of HEADER_FOOTER_ZONES) {
|
|
193
|
+
const image = isObject(design[which]) ? design[which][zone]?.image : undefined;
|
|
194
|
+
if (parseLogoReference(image) && !logoResolves(presentation, slide, slideIndex, { reference: image }))
|
|
195
|
+
warnings.push({ code: "unresolved-logo", path: `design.${which}.${zone}.image`, message: `The ${which} ${zone} zone shows ${describeLogoReference(presentation, image)}, but it has no logo. Add the organization's logo.` });
|
|
196
|
+
}
|
|
197
|
+
if (typeof design.logo === "string" && !logoResolves(presentation, slide, slideIndex, {}))
|
|
198
|
+
warnings.push({ code: "unresolved-logo", path: slide?.design?.logo !== undefined ? `slides.${slideIndex}.design.logo` : "design.logo", message: `The logo setting points at ${describeLogoReference(presentation, design.logo)}, but it has no logo, so covers and sections draw none. Add the organization's logo.` });
|
|
117
199
|
const organization = primaryOrganization(presentation);
|
|
200
|
+
let builtins;
|
|
118
201
|
for (const which of ["header", "footer"])
|
|
119
202
|
for (const zone of HEADER_FOOTER_ZONES) {
|
|
120
203
|
const item = isObject(design[which]) ? design[which][zone] : undefined;
|
|
121
|
-
if (item?.organization === true && !organization)
|
|
122
|
-
warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.organization`, message: `The ${which} ${zone} zone shows the organization, but the presentation has none.` });
|
|
123
|
-
if (item?.speaker === true && !firstSpeaker(presentation)?.name)
|
|
124
|
-
warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.speaker`, message: `The ${which} ${zone} zone shows the speaker, but the presentation has no named speaker.` });
|
|
125
204
|
if (item?.socials === true && !organization?.socials)
|
|
126
205
|
warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.socials`, message: `The ${which} ${zone} zone shows social profiles, but the organization has none.` });
|
|
206
|
+
// FA-31: a value the zone's text asks for and the document cannot give (the core validator reports the same as variable-builtin-missing).
|
|
207
|
+
const path = `design.${which}.${zone}.text`;
|
|
208
|
+
for (const name of new Set(typeof item?.text === "string" ? zoneTextVariables(item.text) : [])) {
|
|
209
|
+
if (name === "slide.section") {
|
|
210
|
+
if (!(typeof slide?.section === "string" && slide.section !== ""))
|
|
211
|
+
warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{slide.section}}, but this slide has no section.` });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
builtins ??= listBuiltinVariables(presentation);
|
|
215
|
+
const entry = builtins.find((candidate) => candidate.name === name);
|
|
216
|
+
if (entry?.scope === "deck" && !entry.available)
|
|
217
|
+
warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{${name}}}, but the presentation has no value for it.` });
|
|
218
|
+
}
|
|
127
219
|
}
|
|
128
|
-
if (
|
|
129
|
-
warnings.push({ code: "unresolved-logo", path: "design.listBullet", message: "Picture bullets use the logo, but no logo
|
|
220
|
+
if (design.listBullet === "image" && !logoResolves(presentation, slide, slideIndex, { shape: "icon" }))
|
|
221
|
+
warnings.push({ code: "unresolved-logo", path: "design.listBullet", message: "Picture bullets use the organization's icon logo, but no logo resolves, so lists draw the character bullet. Add an organization logo." });
|
|
130
222
|
return warnings;
|
|
131
223
|
}
|
|
132
224
|
|
|
@@ -190,27 +282,29 @@ function fontSchemeWithAccent(presentation, base, family) {
|
|
|
190
282
|
return object;
|
|
191
283
|
}
|
|
192
284
|
|
|
193
|
-
function logoSetOf(existing) {
|
|
194
|
-
if (existing === undefined) return {};
|
|
195
|
-
if (typeof existing === "string" || (isObject(existing) && Object.hasOwn(existing, "src"))) return { default: existing };
|
|
196
|
-
return isObject(existing) ? { ...existing } : {};
|
|
197
|
-
}
|
|
198
|
-
function collapseLogo(set) {
|
|
199
|
-
const keys = Object.keys(set);
|
|
200
|
-
if (!keys.length) return null;
|
|
201
|
-
if (keys.length === 1 && keys[0] === "default") return set.default;
|
|
202
|
-
return set;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
285
|
// --- design option ----------------------------------------------------------------------------
|
|
206
286
|
|
|
287
|
+
/**
|
|
288
|
+
* The value `design.logo` takes for a choice: `null` (unset), `false`, a `var:` logo reference, or `{ organization?, shape? }`
|
|
289
|
+
* (an organization id and shape, written as the reference). A reference to an organization the deck does not have is refused.
|
|
290
|
+
*/
|
|
291
|
+
function checkedLogoChoice(presentation, value) {
|
|
292
|
+
if (value === null || value === false) return value;
|
|
293
|
+
const reference = isObject(value) ? logoReference(value) : value;
|
|
294
|
+
const parsed = parseLogoReference(reference);
|
|
295
|
+
if (!parsed) throw fail("invalid-design-value", "The logo is null (the primary organization's), false (none) or a reference such as var:organization.logo.icon or var:organization.beta.logo.", { option: "logo", value });
|
|
296
|
+
if (parsed.organization !== undefined && !listOrganizations(presentation).some((entry) => entry.id === parsed.organization))
|
|
297
|
+
throw fail("unknown-organization", `The presentation has no organization with the id '${parsed.organization}'.`, { organization: parsed.organization });
|
|
298
|
+
return reference;
|
|
299
|
+
}
|
|
300
|
+
|
|
207
301
|
/**
|
|
208
302
|
* Compute the patch that sets one design option, without touching any session. `value === null`
|
|
209
303
|
* removes the option at that scope so it is inherited again. The object-valued `watermark` merges the
|
|
210
304
|
* fields you pass into the existing object; a `null` field removes it.
|
|
211
|
-
* Options: `slideIndex` (one slide instead of the deck)
|
|
212
|
-
* remove slide-level values that hide it)
|
|
213
|
-
*
|
|
305
|
+
* Options: `slideIndex` (one slide instead of the deck) and `clearSlideOverrides` (deck scope: also
|
|
306
|
+
* remove slide-level values that hide it). The `logo` option is `design.logo`: `null` (the primary organization's logo),
|
|
307
|
+
* `false` (no logo), a reference such as `"var:organization.beta.logo.icon"`, or `{ organization?, shape? }` for one.
|
|
214
308
|
*/
|
|
215
309
|
export function prepareDesignOption(presentation, option, value, options = {}) {
|
|
216
310
|
const descriptor = BY_ID[option];
|
|
@@ -218,39 +312,23 @@ export function prepareDesignOption(presentation, option, value, options = {}) {
|
|
|
218
312
|
if (!isObject(presentation)) throw fail("invalid-input", "Design options need an OPF document object.");
|
|
219
313
|
if (value === undefined) throw fail("invalid-design-value", "Pass a value, or null to remove the option.", { option });
|
|
220
314
|
const { scope, base, slideIndex } = scopeOf(presentation, option, options);
|
|
221
|
-
let
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
else if (!present) patches = [{ op: "add", path: opfPathToJsonPointer(path), value: structuredClone(value) }];
|
|
233
|
-
else if (!same(owner.logo, value)) patches = [{ op: "replace", path: opfPathToJsonPointer(path), value: structuredClone(value) }];
|
|
315
|
+
let entries;
|
|
316
|
+
if (descriptor.type === "enum") {
|
|
317
|
+
if (value !== null && !ENUMS[option].includes(value)) throw fail("invalid-design-value", `${descriptor.label} is one of ${ENUMS[option].join(", ")}.`, { option, value });
|
|
318
|
+
entries = { [option]: value };
|
|
319
|
+
} else if (descriptor.type === "boolean") {
|
|
320
|
+
if (value !== null && typeof value !== "boolean") throw fail("invalid-design-value", `${descriptor.label} is true or false.`, { option, value });
|
|
321
|
+
entries = { [option]: value };
|
|
322
|
+
} else if (descriptor.type === "font") {
|
|
323
|
+
entries = { fontScheme: fontSchemeWithAccent(presentation, base, value) };
|
|
324
|
+
} else if (descriptor.type === "logo") {
|
|
325
|
+
entries = { logo: checkedLogoChoice(presentation, value) };
|
|
234
326
|
} else {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (value !== null && !ENUMS[option].includes(value)) throw fail("invalid-design-value", `${descriptor.label} is one of ${ENUMS[option].join(", ")}.`, { option, value });
|
|
238
|
-
entries = { [option]: value };
|
|
239
|
-
} else if (descriptor.type === "boolean") {
|
|
240
|
-
if (value !== null && typeof value !== "boolean") throw fail("invalid-design-value", `${descriptor.label} is true or false.`, { option, value });
|
|
241
|
-
entries = { [option]: value };
|
|
242
|
-
} else if (descriptor.type === "font") {
|
|
243
|
-
entries = { fontScheme: fontSchemeWithAccent(presentation, base, value) };
|
|
244
|
-
} else if (descriptor.type === "logo") {
|
|
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 });
|
|
246
|
-
entries = { logo: value };
|
|
247
|
-
} else {
|
|
248
|
-
const existing = ownDesign(presentation, base).watermark;
|
|
249
|
-
entries = { watermark: mergedWatermark(existing, value) };
|
|
250
|
-
}
|
|
251
|
-
keys = Object.keys(entries);
|
|
252
|
-
patches = designPatches(presentation, base, entries);
|
|
327
|
+
const existing = ownDesign(presentation, base).watermark;
|
|
328
|
+
entries = { watermark: mergedWatermark(existing, value) };
|
|
253
329
|
}
|
|
330
|
+
const keys = Object.keys(entries);
|
|
331
|
+
const patches = designPatches(presentation, base, entries);
|
|
254
332
|
|
|
255
333
|
let shadowedSlides = [];
|
|
256
334
|
if (scope === "deck" && keys.length) {
|
|
@@ -287,16 +365,11 @@ export function setDesignOption(editor, option, value, options = {}) {
|
|
|
287
365
|
/**
|
|
288
366
|
* Read one design option for a panel: `{ value, scope, inherited }` where `scope` is "slide" when the
|
|
289
367
|
* slide's own design sets it, "deck" when the deck does, and "default" when neither does.
|
|
290
|
-
* `accentFont` reads the family name; `
|
|
368
|
+
* `accentFont` reads the family name; `logo` reads `design.logo` (a reference, or false).
|
|
291
369
|
*/
|
|
292
370
|
export function getDesignOption(presentation, option, options = {}) {
|
|
293
371
|
const descriptor = BY_ID[option];
|
|
294
372
|
if (!descriptor) throw fail("unknown-design-option", `Unknown design option: ${option}.`, { option });
|
|
295
|
-
if (descriptor.type === "organization-logo") {
|
|
296
|
-
const organization = presentation.organization;
|
|
297
|
-
const owner = Array.isArray(organization) ? organization[options.index ?? 0] : organization;
|
|
298
|
-
return { value: owner?.logo, scope: owner?.logo === undefined ? "default" : "deck", inherited: false };
|
|
299
|
-
}
|
|
300
373
|
const key = descriptor.type === "font" ? "fontScheme" : option;
|
|
301
374
|
const pick = (design) => {
|
|
302
375
|
const value = design?.[key];
|
|
@@ -309,54 +382,127 @@ export function getDesignOption(presentation, option, options = {}) {
|
|
|
309
382
|
return { value: undefined, scope: "default", inherited: false };
|
|
310
383
|
}
|
|
311
384
|
|
|
312
|
-
// --- logo
|
|
385
|
+
// --- organization logo ------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
const isAsset = (value) => (typeof value === "string" && value.trim() !== "") || (isObject(value) && typeof value.src === "string");
|
|
388
|
+
const isToneSplit = (value) => isObject(value) && !Object.hasOwn(value, "src");
|
|
389
|
+
|
|
390
|
+
/** The organization an edit or a read addresses: its index, or its id; omitted, the primary organization. Returns the list entry. */
|
|
391
|
+
function organizationTarget(presentation, organization, option = "organization") {
|
|
392
|
+
const list = listOrganizations(presentation);
|
|
393
|
+
if (!list.length) throw fail("missing-owner", "Add an organization to the document before setting its logo.", { option });
|
|
394
|
+
if (organization === undefined || organization === null) return list.find((entry) => entry.primary) ?? list[0];
|
|
395
|
+
const found = typeof organization === "number" ? list.find((entry) => entry.index === organization) : list.find((entry) => entry.id === organization);
|
|
396
|
+
if (!found) throw fail("unknown-organization", `The presentation has no organization ${typeof organization === "number" ? `at index ${organization}` : `with the id '${organization}'`}.`, { organization });
|
|
397
|
+
return found;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A shape's value when it is edited for one background (or both); null clears. */
|
|
401
|
+
function editedShape(current, background, source) {
|
|
402
|
+
if (background === "both") return source === null ? undefined : source;
|
|
403
|
+
// A plain image is both backgrounds: editing one background keeps the other as it was.
|
|
404
|
+
const tones = isAsset(current) ? { onLight: current, onDark: current } : isToneSplit(current) ? { ...current } : {};
|
|
405
|
+
if (source === null) delete tones[background];
|
|
406
|
+
else tones[background] = source;
|
|
407
|
+
return Object.keys(tones).length ? tones : undefined;
|
|
408
|
+
}
|
|
313
409
|
|
|
314
410
|
/**
|
|
315
|
-
* Compute the patch that sets (
|
|
316
|
-
*
|
|
317
|
-
*
|
|
411
|
+
* Compute the patch that sets (an image source, asset reference or Asset object) or clears (`null`) the organization's logo.
|
|
412
|
+
* `shape` is `"all"` (one image for every shape: `organization.logo` becomes that image) or one of `LOGO_SHAPES`. `options.background`
|
|
413
|
+
* is `"both"` (default: one image for the shape), `"onLight"` or `"onDark"` (the image for light or dark backgrounds only; the
|
|
414
|
+
* other keeps what the shape had). A bare logo is the full logo for every shape, so setting one shape keeps it as `full`; a lone
|
|
415
|
+
* plain `full` collapses back to the bare image. `options.organization` is the organization's index or id (default: the primary one).
|
|
318
416
|
*/
|
|
319
|
-
export function
|
|
320
|
-
if (!
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (source
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
417
|
+
export function prepareOrganizationLogo(presentation, shape, source, options = {}) {
|
|
418
|
+
if (!isObject(presentation)) throw fail("invalid-input", "Organization logos need an OPF document object.");
|
|
419
|
+
if (shape !== "all" && !LOGO_SHAPES.includes(shape)) throw fail("invalid-design-value", `Logo shape is all or one of ${LOGO_SHAPES.join(", ")}.`, { shape });
|
|
420
|
+
const background = options.background ?? "both";
|
|
421
|
+
if (!LOGO_BACKGROUNDS.includes(background)) throw fail("invalid-design-value", `Logo background is one of ${LOGO_BACKGROUNDS.join(", ")}.`, { background });
|
|
422
|
+
if (shape === "all" && background !== "both") throw fail("invalid-design-value", "One image for every shape has no light or dark variant. Choose a shape to set one.", { shape, background });
|
|
423
|
+
if (source !== null && !isAsset(source)) throw fail("invalid-design-value", "A logo is an image source, an asset reference, an Asset object, or null to clear it.", { shape });
|
|
424
|
+
const clean = typeof source === "string" ? source.trim() : source;
|
|
425
|
+
const target = organizationTarget(presentation, options.organization, "organizationLogo");
|
|
426
|
+
const owner = Array.isArray(presentation.organization) ? presentation.organization[target.index] : presentation.organization;
|
|
427
|
+
const existing = owner.logo;
|
|
428
|
+
let logo;
|
|
429
|
+
if (shape === "all") logo = clean === null ? undefined : clean;
|
|
430
|
+
else {
|
|
431
|
+
const shapes = existing === undefined ? {} : isAsset(existing) ? { full: existing } : structuredClone(existing);
|
|
432
|
+
const next = editedShape(shapes[shape], background, clean);
|
|
433
|
+
if (next === undefined) delete shapes[shape];
|
|
434
|
+
else shapes[shape] = next;
|
|
435
|
+
const keys = Object.keys(shapes);
|
|
436
|
+
logo = !keys.length ? undefined : keys.length === 1 && keys[0] === "full" && isAsset(shapes.full) ? shapes.full : shapes;
|
|
437
|
+
}
|
|
438
|
+
const path = opfPathToJsonPointer(Array.isArray(presentation.organization) ? ["organization", String(target.index), "logo"] : ["organization", "logo"]);
|
|
439
|
+
const present = Object.hasOwn(owner, "logo");
|
|
440
|
+
let patches = [];
|
|
441
|
+
if (logo === undefined) patches = present ? [{ op: "remove", path }] : [];
|
|
442
|
+
else if (!present) patches = [{ op: "add", path, value: structuredClone(logo) }];
|
|
443
|
+
else if (!same(existing, logo)) patches = [{ op: "replace", path, value: structuredClone(logo) }];
|
|
444
|
+
return finish(presentation, patches, { option: "organizationLogo", shape, background, organization: target.index, scope: "deck", shadowed: [] });
|
|
445
|
+
}
|
|
446
|
+
/** Set or clear the organization's logo (see {@link prepareOrganizationLogo}) as a single undoable transaction. */
|
|
447
|
+
export function setOrganizationLogo(editor, shape, source, options = {}) {
|
|
332
448
|
checkEditor(editor);
|
|
333
449
|
const { meta, ...rest } = options;
|
|
334
|
-
return apply(editor,
|
|
450
|
+
return apply(editor, prepareOrganizationLogo(editor.presentation, shape, source, rest), meta);
|
|
335
451
|
}
|
|
336
|
-
/**
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
452
|
+
/**
|
|
453
|
+
* An organization's logo as a panel shows it: `{ organization, all, shapes }`. `organization` is its list entry (`listOrganizations`),
|
|
454
|
+
* `all` the bare image when one image serves every shape, and `shapes` the shapes the logo sets, each `{ both }` (one image) or
|
|
455
|
+
* `{ onLight?, onDark? }`. `null` when the deck has no organization.
|
|
456
|
+
*/
|
|
457
|
+
export function readOrganizationLogo(presentation, options = {}) {
|
|
458
|
+
if (!listOrganizations(presentation).length) return null;
|
|
459
|
+
const target = organizationTarget(presentation, options.organization);
|
|
460
|
+
const owner = Array.isArray(presentation.organization) ? presentation.organization[target.index] : presentation.organization;
|
|
461
|
+
const logo = owner.logo;
|
|
462
|
+
const shapes = {};
|
|
463
|
+
if (isToneSplit(logo))
|
|
464
|
+
for (const shape of LOGO_SHAPES) {
|
|
465
|
+
const value = logo[shape];
|
|
466
|
+
if (isAsset(value)) shapes[shape] = { both: value };
|
|
467
|
+
else if (isObject(value)) shapes[shape] = { ...(value.onLight !== undefined ? { onLight: value.onLight } : {}), ...(value.onDark !== undefined ? { onDark: value.onDark } : {}) };
|
|
468
|
+
}
|
|
469
|
+
return { organization: target, ...(isAsset(logo) ? { all: logo } : {}), shapes };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The `design.logo` choice at a scope as a panel shows it: `{ mode, organization?, shape?, value, scope, inherited }`. `mode` is
|
|
474
|
+
* "primary" (unset), "none" (`false`), "reference" (a `var:` reference to an organization and/or shape) or "custom" (any other value,
|
|
475
|
+
* for example one left over from an older document). `organization` is the id the reference names (absent for the primary one).
|
|
476
|
+
*/
|
|
477
|
+
export function readLogoChoice(presentation, options = {}) {
|
|
478
|
+
const option = getDesignOption(presentation, "logo", options);
|
|
479
|
+
const { value } = option;
|
|
480
|
+
if (value === undefined) return { mode: "primary", ...option };
|
|
481
|
+
if (value === false) return { mode: "none", ...option };
|
|
482
|
+
const parsed = parseLogoReference(value);
|
|
483
|
+
return parsed ? { mode: "reference", ...parsed, ...option } : { mode: "custom", ...option };
|
|
340
484
|
}
|
|
341
485
|
|
|
342
486
|
// --- header and footer zones ------------------------------------------------------------------
|
|
343
487
|
|
|
344
488
|
/**
|
|
345
|
-
* Compute the patch that edits one header or footer zone. `fields` merges into the zone (
|
|
346
|
-
* image,
|
|
347
|
-
*
|
|
489
|
+
* Compute the patch that edits one header or footer zone. `fields` merges into the zone (text,
|
|
490
|
+
* image, date, dateFormat, socials); `null`, `false` for a flag, or an empty string removes a field.
|
|
491
|
+
* Generated values (slide number, slide count, section, organization, speaker, deck name) are `{{ }}`
|
|
492
|
+
* variables in `text` (`ZONE_VALUES`, `insertZoneValue`); the logo is the zone's `image`, a `var:organization.logo.icon`
|
|
493
|
+
* reference (`insertZoneLogo`). A zone left empty is removed, then an empty
|
|
348
494
|
* header or footer, so a slide never carries `{}`. A slide's own header or footer replaces the deck's
|
|
349
495
|
* whole one, so the first edit on a slide that has none of its own starts from a copy of the deck's
|
|
350
496
|
* (the other zones stay); a slide emptied that way hides the furniture (`false`) instead of
|
|
351
497
|
* inheriting it again. Setting a field on a suppressed (`false`) header replaces the suppression.
|
|
352
|
-
* `logo
|
|
498
|
+
* An `image` that is a logo reference reports a warning when the organization has no logo.
|
|
353
499
|
*/
|
|
354
500
|
export function prepareHeaderFooterZone(presentation, which, zone, fields, options = {}) {
|
|
355
501
|
if (!["header", "footer"].includes(which)) throw fail("invalid-design-value", "Choose header or footer.", { which });
|
|
356
502
|
if (!HEADER_FOOTER_ZONES.includes(zone)) throw fail("invalid-design-value", `Zone is one of ${HEADER_FOOTER_ZONES.join(", ")}.`, { zone });
|
|
357
503
|
if (!isObject(fields)) throw fail("invalid-design-value", "Pass the zone fields to change.", { fields });
|
|
358
504
|
const unknown = Object.keys(fields).filter((key) => !ZONE_FIELDS.includes(key));
|
|
359
|
-
if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}
|
|
505
|
+
if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}.${Object.hasOwn(REMOVED_ZONE_FIELDS, unknown[0]) ? ` ${REMOVED_ZONE_FIELDS[unknown[0]]}` : ""}`, { fields });
|
|
360
506
|
const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
|
|
361
507
|
const own = ownDesign(presentation, base)[which];
|
|
362
508
|
const inherited = base.length ? presentation.design?.[which] : undefined;
|
|
@@ -387,6 +533,49 @@ export function setHeaderFooterZone(editor, which, zone, fields, options = {}) {
|
|
|
387
533
|
const { meta, ...rest } = options;
|
|
388
534
|
return apply(editor, prepareHeaderFooterZone(editor.presentation, which, zone, fields, rest), meta);
|
|
389
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Compute the patch that inserts a value's token (`ZONE_VALUES`, for example `slide.number` for `{{slide.number}}`) into a zone's
|
|
538
|
+
* `text` at UTF-16 offsets `start` and `end` (a selection is replaced; default: the end of the text). The text is the one the scope
|
|
539
|
+
* shows (the slide's own zone, else the deck's), or `options.text` when a panel holds edits it has not committed yet.
|
|
540
|
+
*/
|
|
541
|
+
export function prepareZoneValue(presentation, which, zone, name, options = {}) {
|
|
542
|
+
const value = ZONE_VALUES.find((entry) => entry.name === name);
|
|
543
|
+
if (!value) throw fail("invalid-design-value", `Insert one of ${ZONE_VALUES.map((entry) => entry.name).join(", ")}.`, { value: name });
|
|
544
|
+
const { start, end, text: typed, ...rest } = options;
|
|
545
|
+
if (typed !== undefined && typeof typed !== "string") throw fail("invalid-design-value", "Text is a string.", { text: typed });
|
|
546
|
+
const current = typed ?? readHeaderFooterZone(presentation, which, zone, rest).text;
|
|
547
|
+
const text = typeof current === "string" ? current : "";
|
|
548
|
+
const from = start === undefined ? text.length : start;
|
|
549
|
+
const to = end === undefined ? from : end;
|
|
550
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to > text.length) throw fail("invalid-selection", "The selection is outside the text.", { start: from, end: to });
|
|
551
|
+
return prepareHeaderFooterZone(presentation, which, zone, { text: `${text.slice(0, from)}${value.token}${text.slice(to)}` }, rest);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Compute the patch that makes a zone show an organization's logo: its `image` becomes the reference
|
|
555
|
+
* `var:organization.logo.<shape>` (or `var:organization.<id>.logo.<shape>` with `options.organization`, an organization id).
|
|
556
|
+
* `options.shape` is one of `LOGO_SHAPES` (default `icon`, the mark that fits a zone); `full` is written as plain `var:organization.logo`.
|
|
557
|
+
* It replaces an image the zone had. Which image is drawn (light or dark artwork) follows each slide's background.
|
|
558
|
+
*/
|
|
559
|
+
export function prepareZoneLogo(presentation, which, zone, options = {}) {
|
|
560
|
+
const { shape = "icon", organization, ...rest } = options;
|
|
561
|
+
if (!LOGO_SHAPES.includes(shape)) throw fail("invalid-design-value", `Logo shape is one of ${LOGO_SHAPES.join(", ")}.`, { shape });
|
|
562
|
+
if (organization !== undefined && !listOrganizations(presentation).some((entry) => entry.id === organization))
|
|
563
|
+
throw fail("unknown-organization", `The presentation has no organization with the id '${organization}'.`, { organization });
|
|
564
|
+
const reference = logoReference({ ...(organization !== undefined ? { organization } : {}), ...(shape !== "full" ? { shape } : {}) });
|
|
565
|
+
return prepareHeaderFooterZone(presentation, which, zone, { image: reference }, rest);
|
|
566
|
+
}
|
|
567
|
+
/** Make a zone show an organization's logo as a single undoable transaction. See {@link prepareZoneLogo}. */
|
|
568
|
+
export function insertZoneLogo(editor, which, zone, options = {}) {
|
|
569
|
+
checkEditor(editor);
|
|
570
|
+
const { meta, ...rest } = options;
|
|
571
|
+
return apply(editor, prepareZoneLogo(editor.presentation, which, zone, rest), meta);
|
|
572
|
+
}
|
|
573
|
+
/** Insert a value's token into one zone's `text` as a single undoable transaction. */
|
|
574
|
+
export function insertZoneValue(editor, which, zone, name, options = {}) {
|
|
575
|
+
checkEditor(editor);
|
|
576
|
+
const { meta, ...rest } = options;
|
|
577
|
+
return apply(editor, prepareZoneValue(editor.presentation, which, zone, name, rest), meta);
|
|
578
|
+
}
|
|
390
579
|
/**
|
|
391
580
|
* One header or footer zone's fields as they apply at a scope: the slide's own header (or footer) when it
|
|
392
581
|
* has one, else the deck's (`{}` when absent or suppressed). `headerFooterState` says which.
|