@openpresentation/opf-editor 0.10.6 → 0.11.1
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 +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Footnotes, citations and captions (RR-34). Headless helpers that edit the core fields as one
|
|
2
|
+
// validated, undoable session change each: `caption` on image, chart, table and video blocks, the
|
|
3
|
+
// deck's `references` list, and `cite` / `footnote` on a text, bullet or list item run. Every
|
|
4
|
+
// `prepare*` returns the guarded patches (a test on the edited container plus one replace) and the
|
|
5
|
+
// validated candidate without applying them; the matching verb commits through the session so the
|
|
6
|
+
// canvas redraws and Undo restores the document. Nothing here invents text or ids: a reference is
|
|
7
|
+
// what the caller supplies, and removing a cited reference refuses unless `force` also removes its
|
|
8
|
+
// cites. Numbering comes from core (`collectCitations`), so the panel shows what the engines draw.
|
|
9
|
+
import { getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
|
|
10
|
+
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
11
|
+
// Namespace import: a core published before RR-34 has no citation helpers; the verbs then throw annotations-unavailable.
|
|
12
|
+
import * as opfCore from "@openpresentation/opf";
|
|
13
|
+
|
|
14
|
+
export const CAPTION_POSITIONS = Object.freeze(["below", "above"]);
|
|
15
|
+
export const CAPTION_ALIGNMENTS = Object.freeze(["left", "center", "right"]);
|
|
16
|
+
export const CAPTIONABLE_FIELDS = Object.freeze(["image", "chart", "table", "video"]);
|
|
17
|
+
const TEXT_FIELDS = ["text", "bullets", "items"];
|
|
18
|
+
|
|
19
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
20
|
+
const clone = (value) => structuredClone(value);
|
|
21
|
+
const isRichText = (value) => typeof value === "string" || (Array.isArray(value) && value.every((run) => typeof run === "string" || (isObject(run) && typeof run.text === "string")));
|
|
22
|
+
|
|
23
|
+
function checkEditor(editor) {
|
|
24
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
25
|
+
}
|
|
26
|
+
function commit(editor, prepared, meta = {}) {
|
|
27
|
+
const { document, patches, ...summary } = prepared;
|
|
28
|
+
void document;
|
|
29
|
+
if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
30
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "annotation", action: prepared.action, path: prepared.path });
|
|
31
|
+
return { ...change, ...summary };
|
|
32
|
+
}
|
|
33
|
+
// One guarded replace of the container at `parts`; the candidate must validate when the document did.
|
|
34
|
+
function transaction(document, parts, next, extra) {
|
|
35
|
+
const current = getValueAtPath(document, parts);
|
|
36
|
+
const pointer = opfPathToJsonPointer(parts);
|
|
37
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
38
|
+
const patches = changed ? [{ op: "test", path: pointer, value: clone(current) }, { op: "replace", path: pointer, value: clone(next) }] : [];
|
|
39
|
+
const before = validateOpfDocument(document);
|
|
40
|
+
const result = changed ? checkedDocument(document, patches, before) : document;
|
|
41
|
+
return { ...extra, path: parts.join("."), document: clone(result), patches, changed };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- captions -----------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function captionHost(document, blockPath) {
|
|
47
|
+
let parts;
|
|
48
|
+
try { parts = splitOpfPath(blockPath); } catch { throw fail("invalid-path", `Invalid block path ${JSON.stringify(blockPath)}.`); }
|
|
49
|
+
const host = getValueAtPath(document, parts);
|
|
50
|
+
if (!isObject(host)) throw fail("invalid-path", `No block at ${parts.join(".")}.`);
|
|
51
|
+
if (Array.isArray(host.blocks)) throw fail("caption-unsupported", "A group cannot carry a caption; choose one of its blocks.", { path: parts.join(".") });
|
|
52
|
+
const fields = CAPTIONABLE_FIELDS.filter((field) => host[field] !== undefined);
|
|
53
|
+
if (fields.length !== 1) throw fail("caption-unsupported", fields.length ? "This slide root holds several captionable payloads; put the caption on a block." : "Only an image, chart, table or video payload takes a caption.", { path: parts.join("."), fields });
|
|
54
|
+
return { parts, host, field: fields[0] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalize a caption value to `{ text, position, align }`, or undefined. */
|
|
58
|
+
export function normalizeCaption(value) {
|
|
59
|
+
if (typeof opfCore.captionSettings === "function") return opfCore.captionSettings(value);
|
|
60
|
+
if (value === undefined || value === null) return undefined;
|
|
61
|
+
if (typeof value === "string" || Array.isArray(value)) return { text: value, position: "below", align: "left" };
|
|
62
|
+
if (!isObject(value) || value.text === undefined) return undefined;
|
|
63
|
+
return { text: value.text, position: value.position === "above" ? "above" : "below", align: CAPTION_ALIGNMENTS.includes(value.align) ? value.align : "left" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every block (and one-payload slide root) that can carry a caption, with its current caption. */
|
|
67
|
+
export function captionTargets(document) {
|
|
68
|
+
const targets = [];
|
|
69
|
+
const visit = (host, path) => {
|
|
70
|
+
if (!isObject(host)) return;
|
|
71
|
+
if (Array.isArray(host.blocks)) { host.blocks.forEach((block, index) => visit(block, `${path}.blocks.${index}`)); return; }
|
|
72
|
+
const fields = CAPTIONABLE_FIELDS.filter((field) => host[field] !== undefined);
|
|
73
|
+
if (fields.length === 1) targets.push({ blockPath: path, field: fields[0], caption: normalizeCaption(host.caption) });
|
|
74
|
+
};
|
|
75
|
+
(document?.slides ?? []).forEach((slide, index) => {
|
|
76
|
+
if (!isObject(slide)) return;
|
|
77
|
+
const regions = Object.keys(slide).filter((key) => /^(top|middle|bottom|left|center|right)([+:]|$)/.test(key) && isObject(slide[key])).sort();
|
|
78
|
+
if (regions.length) for (const key of regions) visit(slide[key], `slides.${index}.${key}`);
|
|
79
|
+
else visit(slide, `slides.${index}`);
|
|
80
|
+
});
|
|
81
|
+
return targets;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The caption of the block at `blockPath` (`{ field, caption }`; `caption` undefined when none). */
|
|
85
|
+
export function readCaption(document, blockPath) {
|
|
86
|
+
const { host, field } = captionHost(document, blockPath);
|
|
87
|
+
return { blockPath: splitOpfPath(blockPath).join("."), field, caption: normalizeCaption(host.caption) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Set (a string, TextRun[] or `{ text, position?, align? }`) or remove (`null`) the caption of the
|
|
92
|
+
* block at `blockPath`. A caption at the default position and alignment is stored in its short form.
|
|
93
|
+
*/
|
|
94
|
+
export function prepareCaption(document, blockPath, caption) {
|
|
95
|
+
const { parts, host, field } = captionHost(document, blockPath);
|
|
96
|
+
const next = clone(host);
|
|
97
|
+
if (caption === null || caption === undefined) delete next.caption;
|
|
98
|
+
else {
|
|
99
|
+
const settings = normalizeCaption(caption);
|
|
100
|
+
const objectForm = isObject(caption);
|
|
101
|
+
if (!settings || !isRichText(settings.text) || (objectForm && caption.position !== undefined && !CAPTION_POSITIONS.includes(caption.position)) || (objectForm && caption.align !== undefined && !CAPTION_ALIGNMENTS.includes(caption.align)))
|
|
102
|
+
throw fail("invalid-caption", "A caption is a string, TextRun[] or { text, position: below|above, align: left|center|right }.");
|
|
103
|
+
next.caption = settings.position === "below" && settings.align === "left" ? clone(settings.text) : { text: clone(settings.text), position: settings.position, align: settings.align };
|
|
104
|
+
}
|
|
105
|
+
return transaction(document, parts, next, { action: "caption", field });
|
|
106
|
+
}
|
|
107
|
+
export function setCaption(editor, blockPath, caption, meta = {}) {
|
|
108
|
+
checkEditor(editor);
|
|
109
|
+
return commit(editor, prepareCaption(editor.document, blockPath, caption), meta);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- references ---------------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function citationsOf(document) {
|
|
115
|
+
if (typeof opfCore.collectCitations !== "function") throw fail("annotations-unavailable", "The installed @openpresentation/opf has no citation helpers (needs the core that ships RR-34).");
|
|
116
|
+
return opfCore.collectCitations(document);
|
|
117
|
+
}
|
|
118
|
+
function referenceList(document) {
|
|
119
|
+
return Array.isArray(document?.references) ? document.references : [];
|
|
120
|
+
}
|
|
121
|
+
function checkReference(reference, existing, { allowExisting = false } = {}) {
|
|
122
|
+
if (!isObject(reference) || typeof reference.id !== "string" || !reference.id) throw fail("invalid-reference", "A reference needs a non-empty string id.");
|
|
123
|
+
if (!isRichText(reference.text) || (typeof reference.text === "string" && !reference.text)) throw fail("invalid-reference", "A reference needs text (a string or TextRun[]).", { id: reference.id });
|
|
124
|
+
if (reference.url !== undefined && (typeof reference.url !== "string" || !reference.url)) throw fail("invalid-reference", "A reference url is a non-empty string.", { id: reference.id });
|
|
125
|
+
if (!allowExisting && existing.some((item) => item?.id === reference.id)) throw fail("duplicate-reference", `Reference '${reference.id}' already exists.`, { id: reference.id });
|
|
126
|
+
const result = { id: reference.id, text: clone(reference.text) };
|
|
127
|
+
if (reference.url !== undefined) result.url = reference.url;
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The deck's references with their marker numbers and whether a run cites them. */
|
|
132
|
+
export function listReferences(document) {
|
|
133
|
+
const list = referenceList(document);
|
|
134
|
+
const citations = typeof opfCore.collectCitations === "function" ? opfCore.collectCitations(document) : undefined;
|
|
135
|
+
return list.map((reference, index) => {
|
|
136
|
+
const note = citations?.references.find((item) => item.id === reference?.id);
|
|
137
|
+
return { index, id: reference?.id, text: reference?.text, url: reference?.url, cited: Boolean(note), ...(note ? { number: note.number } : {}) };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** Append a reference `{ id, text, url? }`; ids must be unique. */
|
|
141
|
+
export function prepareReference(document, reference) {
|
|
142
|
+
const list = referenceList(document);
|
|
143
|
+
const next = [...clone(list), checkReference(reference, list)];
|
|
144
|
+
return transaction(document, ["references"], next, { action: "add-reference", id: reference.id });
|
|
145
|
+
}
|
|
146
|
+
export function addReference(editor, reference, meta = {}) {
|
|
147
|
+
checkEditor(editor);
|
|
148
|
+
return commit(editor, prepareReference(editor.document, reference), meta);
|
|
149
|
+
}
|
|
150
|
+
/** Change the text and/or url of the reference `id` (`url: null` removes the url); the id itself does not change. */
|
|
151
|
+
export function prepareReferenceUpdate(document, id, fields) {
|
|
152
|
+
const list = referenceList(document), index = list.findIndex((item) => item?.id === id);
|
|
153
|
+
if (index < 0) throw fail("unknown-reference", `No reference '${id}'.`, { id });
|
|
154
|
+
const merged = { ...list[index], ...(fields?.text !== undefined ? { text: fields.text } : {}), ...(fields?.url !== undefined ? { url: fields.url } : {}) };
|
|
155
|
+
if (fields?.url === null) delete merged.url;
|
|
156
|
+
const next = clone(list);
|
|
157
|
+
next[index] = checkReference(merged, list, { allowExisting: true });
|
|
158
|
+
return transaction(document, ["references"], next, { action: "update-reference", id });
|
|
159
|
+
}
|
|
160
|
+
export function updateReference(editor, id, fields, meta = {}) {
|
|
161
|
+
checkEditor(editor);
|
|
162
|
+
return commit(editor, prepareReferenceUpdate(editor.document, id, fields), meta);
|
|
163
|
+
}
|
|
164
|
+
/** Remove the reference `id`. Refuses while a run cites it unless `force`, which also removes those cites. */
|
|
165
|
+
export function prepareReferenceRemoval(document, id, { force = false } = {}) {
|
|
166
|
+
const list = referenceList(document), index = list.findIndex((item) => item?.id === id);
|
|
167
|
+
if (index < 0) throw fail("unknown-reference", `No reference '${id}'.`, { id });
|
|
168
|
+
const citing = [];
|
|
169
|
+
if (typeof opfCore.walkCitationRuns === "function") (document.slides ?? []).forEach((slide, slideIndex) => opfCore.walkCitationRuns(slide, `slides.${slideIndex}`, (entry) => { if (entry.cite.includes(id)) citing.push(entry.path); }));
|
|
170
|
+
if (citing.length && !force) throw fail("reference-cited", `Reference '${id}' is cited by ${citing.length} run${citing.length === 1 ? "" : "s"}; pass force to remove the citations too.`, { id, runs: citing });
|
|
171
|
+
const next = clone(document);
|
|
172
|
+
next.references = list.filter((_, position) => position !== index);
|
|
173
|
+
if (!next.references.length) delete next.references;
|
|
174
|
+
for (const runPath of citing) {
|
|
175
|
+
const { parts, index: runIndex, run } = runAt(next, runPath);
|
|
176
|
+
const runs = getValueAtPath(next, parts);
|
|
177
|
+
runs[runIndex] = withoutCite(run, id);
|
|
178
|
+
}
|
|
179
|
+
const pointer = "";
|
|
180
|
+
const changed = JSON.stringify(document) !== JSON.stringify(next);
|
|
181
|
+
const patches = changed ? [{ op: "test", path: pointer, value: clone(document) }, { op: "replace", path: pointer, value: clone(next) }] : [];
|
|
182
|
+
const before = validateOpfDocument(document);
|
|
183
|
+
const result = changed ? checkedDocument(document, patches, before) : document;
|
|
184
|
+
return { action: "remove-reference", id, removedCites: citing, path: "references", document: clone(result), patches, changed };
|
|
185
|
+
}
|
|
186
|
+
export function removeReference(editor, id, options = {}, meta = {}) {
|
|
187
|
+
checkEditor(editor);
|
|
188
|
+
return commit(editor, prepareReferenceRemoval(editor.document, id, options), meta);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- citations and footnotes --------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
function withoutCite(run, id) {
|
|
194
|
+
const next = typeof run === "string" ? { text: run } : { ...run };
|
|
195
|
+
const ids = (Array.isArray(next.cite) ? next.cite : next.cite !== undefined ? [next.cite] : []).filter((item) => item !== id);
|
|
196
|
+
if (!ids.length) delete next.cite; else next.cite = ids.length === 1 ? ids[0] : ids;
|
|
197
|
+
return simplifyRun(next);
|
|
198
|
+
}
|
|
199
|
+
const simplifyRun = (run) => Object.keys(run).length === 1 && typeof run.text === "string" ? run.text : run;
|
|
200
|
+
|
|
201
|
+
/** The run at `runPath` (`slides.0.text.2`, `slides.0.blocks.1.items.0.text.1`): its run array path, index and value. */
|
|
202
|
+
export function runAt(document, runPath) {
|
|
203
|
+
let parts;
|
|
204
|
+
try { parts = splitOpfPath(runPath); } catch { throw fail("invalid-path", `Invalid run path ${JSON.stringify(runPath)}.`); }
|
|
205
|
+
if (parts.length < 3 || !/^(0|[1-9]\d*)$/.test(parts[parts.length - 1])) throw fail("invalid-path", `${parts.join(".")} is not a run path.`);
|
|
206
|
+
const arrayParts = parts.slice(0, -1), index = Number(parts[parts.length - 1]);
|
|
207
|
+
const runs = getValueAtPath(document, arrayParts);
|
|
208
|
+
const field = [...arrayParts].reverse().find((segment) => TEXT_FIELDS.includes(segment));
|
|
209
|
+
if (!Array.isArray(runs) || !field || !(index in runs)) throw fail("invalid-path", `${parts.join(".")} is not a run of a text, bullets or items payload.`);
|
|
210
|
+
const run = runs[index];
|
|
211
|
+
if (typeof run !== "string" && !(isObject(run) && typeof run.text === "string")) throw fail("invalid-path", `${parts.join(".")} is not a text run.`);
|
|
212
|
+
return { parts: arrayParts, index, run };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Make the run at `runPath` cite `ids` (a string or array; replaces its current cites). The ids must exist in references. */
|
|
216
|
+
export function prepareCite(document, runPath, ids) {
|
|
217
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
218
|
+
if (!list.length || !list.every((id) => typeof id === "string" && id)) throw fail("invalid-reference", "cite needs one or more reference ids.");
|
|
219
|
+
const known = new Set(referenceList(document).map((item) => item?.id));
|
|
220
|
+
const unknown = list.filter((id) => !known.has(id));
|
|
221
|
+
if (unknown.length) throw fail("unknown-reference", `Unknown reference${unknown.length === 1 ? "" : "s"} ${unknown.map((id) => `'${id}'`).join(", ")}; add them with addReference first.`, { ids: unknown });
|
|
222
|
+
const { parts, index, run } = runAt(document, runPath);
|
|
223
|
+
const runs = clone(getValueAtPath(document, parts));
|
|
224
|
+
const next = typeof run === "string" ? { text: run } : { ...clone(run) };
|
|
225
|
+
if (!next.text) throw fail("invalid-run", "An empty run cannot carry a marker.", { path: runPath });
|
|
226
|
+
const unique = [...new Set(list)];
|
|
227
|
+
next.cite = unique.length === 1 ? unique[0] : unique;
|
|
228
|
+
runs[index] = next;
|
|
229
|
+
return transaction(document, parts, runs, { action: "cite", runPath: [...parts, index].join("."), ids: unique });
|
|
230
|
+
}
|
|
231
|
+
export function citeRun(editor, runPath, ids, meta = {}) {
|
|
232
|
+
checkEditor(editor);
|
|
233
|
+
return commit(editor, prepareCite(editor.document, runPath, ids), meta);
|
|
234
|
+
}
|
|
235
|
+
/** Remove every cite from the run at `runPath`. */
|
|
236
|
+
export function prepareUncite(document, runPath) {
|
|
237
|
+
const { parts, index, run } = runAt(document, runPath);
|
|
238
|
+
const runs = clone(getValueAtPath(document, parts));
|
|
239
|
+
const next = typeof run === "string" ? run : { ...clone(run) };
|
|
240
|
+
if (typeof next === "object") delete next.cite;
|
|
241
|
+
runs[index] = typeof next === "object" ? simplifyRun(next) : next;
|
|
242
|
+
return transaction(document, parts, runs, { action: "uncite", runPath: [...parts, index].join(".") });
|
|
243
|
+
}
|
|
244
|
+
export function unciteRun(editor, runPath, meta = {}) {
|
|
245
|
+
checkEditor(editor);
|
|
246
|
+
return commit(editor, prepareUncite(editor.document, runPath), meta);
|
|
247
|
+
}
|
|
248
|
+
/** Set (a string or TextRun[]) or remove (`null`) the inline footnote of the run at `runPath`. */
|
|
249
|
+
export function prepareFootnote(document, runPath, text) {
|
|
250
|
+
const { parts, index, run } = runAt(document, runPath);
|
|
251
|
+
const runs = clone(getValueAtPath(document, parts));
|
|
252
|
+
const next = typeof run === "string" ? { text: run } : { ...clone(run) };
|
|
253
|
+
if (text === null || text === undefined) delete next.footnote;
|
|
254
|
+
else {
|
|
255
|
+
if (!isRichText(text) || (typeof text === "string" && !text) || (Array.isArray(text) && !text.length)) throw fail("invalid-footnote", "A footnote is a non-empty string or TextRun[].");
|
|
256
|
+
if (!next.text) throw fail("invalid-run", "An empty run cannot carry a marker.", { path: runPath });
|
|
257
|
+
next.footnote = clone(text);
|
|
258
|
+
}
|
|
259
|
+
runs[index] = simplifyRun(next);
|
|
260
|
+
return transaction(document, parts, runs, { action: "footnote", runPath: [...parts, index].join(".") });
|
|
261
|
+
}
|
|
262
|
+
export function setFootnote(editor, runPath, text, meta = {}) {
|
|
263
|
+
checkEditor(editor);
|
|
264
|
+
return commit(editor, prepareFootnote(editor.document, runPath, text), meta);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The deck numbering the engines draw: notes in number order, cited references, per-slide markers, unused ids. */
|
|
268
|
+
export function listCitations(document) {
|
|
269
|
+
const citations = citationsOf(document);
|
|
270
|
+
return {
|
|
271
|
+
notes: citations.notes.map((note) => ({ ...note })),
|
|
272
|
+
references: citations.references.map((note) => ({ ...note })),
|
|
273
|
+
unused: [...citations.unused],
|
|
274
|
+
slides: [...citations.slides.entries()].map(([slideIndex, slide]) => ({ slideIndex, markers: slide.marked.map((marker) => ({ path: marker.path, text: marker.text, numbers: [...marker.numbers] })), notes: slide.notes.map((note) => note.number) })),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** An ordinary list slide of the cited references (core `referencesSlide`), ready for `prepareBlockInsert` or a slide insert. */
|
|
278
|
+
export function referencesSlideFor(document, options = {}) {
|
|
279
|
+
if (typeof opfCore.referencesSlide !== "function") throw fail("annotations-unavailable", "The installed @openpresentation/opf has no referencesSlide (needs the core that ships RR-34).");
|
|
280
|
+
return opfCore.referencesSlide(document, options);
|
|
281
|
+
}
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export declare const IMAGE_MEDIA_TYPES: Readonly<Record<string, readonly string[]>>;
|
|
4
|
+
/** The `accept` value for a file input: image/png,image/jpeg,image/gif,image/webp,image/svg+xml. */
|
|
5
|
+
export declare const IMAGE_ACCEPT: string;
|
|
6
|
+
/** Default size cap for an uploaded image: 2 MiB. */
|
|
7
|
+
export declare const DEFAULT_MAX_IMAGE_BYTES: number;
|
|
8
|
+
|
|
9
|
+
export interface ValidatedImage {
|
|
10
|
+
name: string;
|
|
11
|
+
mediaType: string;
|
|
12
|
+
bytes: Uint8Array;
|
|
13
|
+
size: number;
|
|
14
|
+
}
|
|
15
|
+
export interface PreparedImageAsset {
|
|
16
|
+
id: string;
|
|
17
|
+
/** `asset:<id>` */
|
|
18
|
+
reference: string;
|
|
19
|
+
entry: { src: string; mediaType: string; title: string; alt?: string };
|
|
20
|
+
patches: JsonPatchOperation[];
|
|
21
|
+
}
|
|
22
|
+
export interface ImageUploadOptions {
|
|
23
|
+
/** Saved on the asset (or passed to `onAddAsset`). */
|
|
24
|
+
alt?: string;
|
|
25
|
+
/** Size cap in bytes (default 2 MiB). */
|
|
26
|
+
maxBytes?: number;
|
|
27
|
+
/** For hosts that store images elsewhere: receives the validated bytes and returns the reference to use; nothing is added to `assets`. */
|
|
28
|
+
onAddAsset?: (image: { name: string; mediaType: string; bytes: Uint8Array; size: number; alt?: string; file: unknown }) => string | { src: string } | Promise<string | { src: string }>;
|
|
29
|
+
meta?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
export interface ImageUploadChange extends Omit<EditorChange, "document" | "patches"> {
|
|
32
|
+
document: unknown;
|
|
33
|
+
patches: JsonPatchOperation[];
|
|
34
|
+
/** The new asset's id (absent with `onAddAsset`). */
|
|
35
|
+
assetId?: string;
|
|
36
|
+
reference: string;
|
|
37
|
+
changed: boolean;
|
|
38
|
+
prepared: { patches: JsonPatchOperation[]; [key: string]: unknown };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The image type the first bytes say it is (SVG by its root element), or undefined. */
|
|
42
|
+
export declare function sniffImageType(bytes: Uint8Array): string | undefined;
|
|
43
|
+
/** Validate bytes and name: type, contents matching the claimed type, size cap, and no script in SVG. Throws `invalid-image` or `image-too-large` with a message that says what to do. */
|
|
44
|
+
export declare function checkImageBytes(bytes: Uint8Array, options?: { name?: string; declaredType?: string; maxBytes?: number }): { mediaType: string };
|
|
45
|
+
/** Read a File or Blob into validated bytes. */
|
|
46
|
+
export declare function readImageFile(file: { name?: string; type?: string; size?: number; arrayBuffer(): Promise<ArrayBuffer> }, options?: { maxBytes?: number }): Promise<ValidatedImage>;
|
|
47
|
+
export declare function imageDataUri(bytes: Uint8Array, mediaType: string): string;
|
|
48
|
+
/** A free asset id for a file name (`logo`, then `logo-2`). */
|
|
49
|
+
export declare function uniqueAssetId(document: unknown, name: string): string;
|
|
50
|
+
/** The patch that adds one validated image to `assets`. */
|
|
51
|
+
export declare function prepareImageAsset(document: unknown, image: ValidatedImage, options?: { alt?: string; id?: string }): PreparedImageAsset;
|
|
52
|
+
/**
|
|
53
|
+
* Add an uploaded image and use it in one undoable transaction. `build(reference, document)` returns the prepared change
|
|
54
|
+
* that uses the image (any `prepare...` function of this package); its patches run after the asset patch.
|
|
55
|
+
*/
|
|
56
|
+
export declare function applyImageUpload(
|
|
57
|
+
editor: EditorSession,
|
|
58
|
+
file: { name?: string; type?: string; size?: number; arrayBuffer(): Promise<ArrayBuffer> },
|
|
59
|
+
build: (reference: string, document: any) => { patches: JsonPatchOperation[] },
|
|
60
|
+
options?: ImageUploadOptions,
|
|
61
|
+
): Promise<ImageUploadChange>;
|
|
62
|
+
/** The patch that sets (or, for an empty string, removes) an asset's alt text. */
|
|
63
|
+
export declare function prepareAssetAlt(document: unknown, assetId: string, alt: string): { assetId: string; patches: JsonPatchOperation[]; changed: boolean };
|
|
64
|
+
/** Set an asset's alt text as one undoable transaction. */
|
|
65
|
+
export declare function setAssetAlt(editor: EditorSession, assetId: string, alt: string, meta?: Record<string, unknown>): unknown;
|
|
66
|
+
/** The `assets` id a reference names (`asset:logo` gives `logo`), or undefined for a URL or data address. */
|
|
67
|
+
export declare function assetIdOf(reference: unknown): string | undefined;
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Image assets for the design controls (RR-06): turn a local file into an entry of the document's
|
|
2
|
+
// `assets` map and reference it from a logo, watermark, slide image, background or header/footer
|
|
3
|
+
// zone in the same undoable patch. The file is validated before anything changes: the type must be
|
|
4
|
+
// PNG, JPEG, GIF, WebP or SVG, its bytes must match that type, and its size must be under a cap with a
|
|
5
|
+
// clear error. A host that keeps images elsewhere passes `onAddAsset` and gets the bytes instead.
|
|
6
|
+
import { applyJsonPatch, getValueAtPath, opfPathToJsonPointer } from "./index.js";
|
|
7
|
+
import { fail } from "./edit-helpers.js";
|
|
8
|
+
|
|
9
|
+
/** Accepted image types by media type, with their usual extensions. */
|
|
10
|
+
export const IMAGE_MEDIA_TYPES = Object.freeze({
|
|
11
|
+
"image/png": ["png"],
|
|
12
|
+
"image/jpeg": ["jpg", "jpeg"],
|
|
13
|
+
"image/gif": ["gif"],
|
|
14
|
+
"image/webp": ["webp"],
|
|
15
|
+
"image/svg+xml": ["svg"],
|
|
16
|
+
});
|
|
17
|
+
/** The `accept` value for a file input. */
|
|
18
|
+
export const IMAGE_ACCEPT = Object.keys(IMAGE_MEDIA_TYPES).join(",");
|
|
19
|
+
/** Default size cap for an uploaded image (2 MiB). Raise it with the `maxBytes` option. */
|
|
20
|
+
export const DEFAULT_MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
const formatSize = (bytes) => (bytes < 1024 ? `${bytes} bytes` : bytes >= 1024 * 1024 ? `${+(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`);
|
|
23
|
+
const formatKb = (bytes) => `${+(bytes / 1024).toFixed(1)} KB`;
|
|
24
|
+
// "2 MB; the limit is 2 MB" tells nobody anything: when the two round alike, say it in kilobytes.
|
|
25
|
+
function tooLarge(name, size, maxBytes) {
|
|
26
|
+
const [actual, limit] = formatSize(size) === formatSize(maxBytes) ? [formatKb(size), formatKb(maxBytes)] : [formatSize(size), formatSize(maxBytes)];
|
|
27
|
+
return `${name ? `"${name}"` : "This file"} is ${actual}; the limit is ${limit}. Resize or compress it, or host it and use a web address instead.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The image type the first bytes of `bytes` say it is, or undefined. SVG is recognized by its root element. */
|
|
31
|
+
export function sniffImageType(bytes) {
|
|
32
|
+
const at = (index) => bytes[index];
|
|
33
|
+
if (bytes.length >= 8 && at(0) === 0x89 && at(1) === 0x50 && at(2) === 0x4e && at(3) === 0x47) return "image/png";
|
|
34
|
+
if (bytes.length >= 3 && at(0) === 0xff && at(1) === 0xd8 && at(2) === 0xff) return "image/jpeg";
|
|
35
|
+
if (bytes.length >= 6 && at(0) === 0x47 && at(1) === 0x49 && at(2) === 0x46 && at(3) === 0x38) return "image/gif";
|
|
36
|
+
if (bytes.length >= 12 && at(0) === 0x52 && at(1) === 0x49 && at(2) === 0x46 && at(3) === 0x46 && at(8) === 0x57 && at(9) === 0x45 && at(10) === 0x42 && at(11) === 0x50) return "image/webp";
|
|
37
|
+
const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, 2048)).replace(/^/, "").trimStart();
|
|
38
|
+
if (/^(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i.test(head)) return "image/svg+xml";
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mediaTypeFromName(name) {
|
|
43
|
+
const extension = /\.([a-z0-9]+)$/i.exec(name ?? "")?.[1]?.toLowerCase();
|
|
44
|
+
return Object.entries(IMAGE_MEDIA_TYPES).find(([, extensions]) => extensions.includes(extension))?.[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// An SVG used as an image cannot run script in a browser, but the same bytes travel in the document
|
|
48
|
+
// to other tools; refuse the constructs that exist only to run or fetch things.
|
|
49
|
+
function unsafeSvg(bytes) {
|
|
50
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
51
|
+
return /<script[\s>]|<foreignObject[\s>]|\son[a-z]+\s*=|javascript:|<iframe[\s>]|<!ENTITY/i.test(text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a file's bytes and name. Returns `{ mediaType }` or throws `invalid-image` with a message that says what to do.
|
|
56
|
+
* `declaredType` is the browser-reported type (may be empty); the bytes decide.
|
|
57
|
+
*/
|
|
58
|
+
export function checkImageBytes(bytes, { name, declaredType, maxBytes = DEFAULT_MAX_IMAGE_BYTES } = {}) {
|
|
59
|
+
const label = name ? `"${name}"` : "This file";
|
|
60
|
+
if (!bytes || !bytes.length) throw fail("invalid-image", `${label} is empty.`, { name });
|
|
61
|
+
if (bytes.length > maxBytes)
|
|
62
|
+
throw fail("image-too-large", tooLarge(name, bytes.length, maxBytes), { name, size: bytes.length, maxBytes });
|
|
63
|
+
const mediaType = sniffImageType(bytes);
|
|
64
|
+
if (!mediaType) throw fail("invalid-image", `${label} is not a PNG, JPEG, GIF, WebP or SVG image.`, { name, declaredType });
|
|
65
|
+
const claimed = declaredType && IMAGE_MEDIA_TYPES[declaredType] ? declaredType : mediaTypeFromName(name);
|
|
66
|
+
if (claimed && claimed !== mediaType) throw fail("invalid-image", `${label} says it is ${claimed.replace("image/", "").toUpperCase()} but its contents are ${mediaType.replace("image/", "").toUpperCase()}. Save it as the right type and choose it again.`, { name, claimed, mediaType });
|
|
67
|
+
if (mediaType === "image/svg+xml" && unsafeSvg(bytes)) throw fail("invalid-image", `${label} contains script or embedded content that an image must not carry. Export a plain SVG and choose it again.`, { name });
|
|
68
|
+
return { mediaType };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read a File or Blob into validated bytes: `{ name, mediaType, bytes, size }`. Rejects before reading when the size is already over the cap. */
|
|
72
|
+
export async function readImageFile(file, { maxBytes = DEFAULT_MAX_IMAGE_BYTES } = {}) {
|
|
73
|
+
if (!file || typeof file.arrayBuffer !== "function") throw fail("invalid-image", "Choose an image file.", {});
|
|
74
|
+
if (typeof file.size === "number" && file.size > maxBytes)
|
|
75
|
+
throw fail("image-too-large", tooLarge(file.name, file.size, maxBytes), { name: file.name, size: file.size, maxBytes });
|
|
76
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
77
|
+
const { mediaType } = checkImageBytes(bytes, { name: file.name, declaredType: file.type, maxBytes });
|
|
78
|
+
return { name: file.name ?? "image", mediaType, bytes, size: bytes.length };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toBase64(bytes) {
|
|
82
|
+
let binary = "";
|
|
83
|
+
for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
84
|
+
return btoa(binary);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A data URI for validated image bytes. */
|
|
88
|
+
export function imageDataUri(bytes, mediaType) {
|
|
89
|
+
return `data:${mediaType};base64,${toBase64(bytes)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An asset id for a file name that is free in the document (`logo`, then `logo-2`, ...). */
|
|
93
|
+
export function uniqueAssetId(document, name) {
|
|
94
|
+
const base = String(name ?? "image").replace(/\.[a-z0-9]+$/i, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "image";
|
|
95
|
+
const taken = document?.assets && typeof document.assets === "object" ? document.assets : {};
|
|
96
|
+
if (!Object.hasOwn(taken, base)) return base;
|
|
97
|
+
for (let n = 2; ; n += 1) if (!Object.hasOwn(taken, `${base}-${n}`)) return `${base}-${n}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The patch that adds one validated image to `assets` (the entry is `{ src: dataUri, mediaType, title, alt? }`),
|
|
102
|
+
* with the `asset:<id>` reference to use. Pure: touches no session.
|
|
103
|
+
*/
|
|
104
|
+
export function prepareImageAsset(document, image, { alt, id } = {}) {
|
|
105
|
+
const assetId = id ?? uniqueAssetId(document, image.name);
|
|
106
|
+
if (document?.assets && Object.hasOwn(document.assets, assetId)) throw fail("asset-exists", `An asset named "${assetId}" already exists.`, { id: assetId });
|
|
107
|
+
const entry = { src: imageDataUri(image.bytes, image.mediaType), mediaType: image.mediaType, title: image.name };
|
|
108
|
+
if (typeof alt === "string" && alt.trim()) entry.alt = alt.trim();
|
|
109
|
+
const patches = document?.assets && typeof document.assets === "object" && !Array.isArray(document.assets)
|
|
110
|
+
? [{ op: "add", path: opfPathToJsonPointer(["assets", assetId]), value: entry }]
|
|
111
|
+
: [{ op: "add", path: "/assets", value: { [assetId]: entry } }];
|
|
112
|
+
return { id: assetId, reference: `asset:${assetId}`, entry, patches };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Add an uploaded image and use it in one undoable transaction. `build(reference, document)` returns the
|
|
117
|
+
* prepared change that uses the image (any `prepare...` function of this package, for example
|
|
118
|
+
* `(ref, doc) => prepareLogoVariant(doc, "light", ref)`); its patches run after the asset patch.
|
|
119
|
+
*
|
|
120
|
+
* Options: `alt` (saved on the asset), `maxBytes`, `meta`, and `onAddAsset({ name, mediaType, bytes, size, alt, file })`
|
|
121
|
+
* for a host that stores images itself: it returns the reference to use (a web address or an `asset:` id the
|
|
122
|
+
* host added) and nothing is added to `assets`.
|
|
123
|
+
*/
|
|
124
|
+
export async function applyImageUpload(editor, file, build, options = {}) {
|
|
125
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
126
|
+
const image = await readImageFile(file, { maxBytes: options.maxBytes });
|
|
127
|
+
const { alt, meta } = options;
|
|
128
|
+
let reference;
|
|
129
|
+
let assetPatches = [];
|
|
130
|
+
let assetId;
|
|
131
|
+
if (typeof options.onAddAsset === "function") {
|
|
132
|
+
const stored = await options.onAddAsset({ name: image.name, mediaType: image.mediaType, bytes: image.bytes, size: image.size, alt: alt?.trim() || undefined, file });
|
|
133
|
+
reference = typeof stored === "string" ? stored : stored?.src;
|
|
134
|
+
if (typeof reference !== "string" || !reference.trim()) throw fail("invalid-asset-reference", "The host's onAddAsset must return the image's reference (a web address or an asset: id).", {});
|
|
135
|
+
} else {
|
|
136
|
+
// Build against the document as it is after the read: the file may have taken a moment to arrive.
|
|
137
|
+
const added = prepareImageAsset(editor.document, image, { alt });
|
|
138
|
+
reference = added.reference;
|
|
139
|
+
assetPatches = added.patches;
|
|
140
|
+
assetId = added.id;
|
|
141
|
+
}
|
|
142
|
+
const document = assetPatches.length ? applyJsonPatch(editor.document, assetPatches) : editor.document;
|
|
143
|
+
const prepared = build(reference, document);
|
|
144
|
+
const patches = [...assetPatches, ...prepared.patches];
|
|
145
|
+
const summary = { assetId, reference, changed: patches.length > 0, prepared };
|
|
146
|
+
if (!patches.length) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
147
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta?.source ?? "image-upload", ...(assetId ? { assetId } : {}), reference });
|
|
148
|
+
return { ...change, ...summary };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The patch that sets (or, for an empty string, removes) an asset's alt text. The asset must exist in `assets`. */
|
|
152
|
+
export function prepareAssetAlt(document, assetId, alt) {
|
|
153
|
+
const entry = getValueAtPath(document, ["assets", assetId]);
|
|
154
|
+
if (entry === undefined) throw fail("unknown-asset", `There is no asset named "${assetId}".`, { assetId });
|
|
155
|
+
const object = typeof entry === "string" ? { src: entry } : { ...entry };
|
|
156
|
+
const text = typeof alt === "string" ? alt.trim() : "";
|
|
157
|
+
if (text) object.alt = text;
|
|
158
|
+
else delete object.alt;
|
|
159
|
+
const value = Object.keys(object).length === 1 && typeof object.src === "string" ? object.src : object;
|
|
160
|
+
const same = JSON.stringify(value) === JSON.stringify(entry);
|
|
161
|
+
return { assetId, patches: same ? [] : [{ op: "replace", path: opfPathToJsonPointer(["assets", assetId]), value }], changed: !same };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Set an asset's alt text as one undoable transaction. */
|
|
165
|
+
export function setAssetAlt(editor, assetId, alt, meta = {}) {
|
|
166
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
167
|
+
const prepared = prepareAssetAlt(editor.document, assetId, alt);
|
|
168
|
+
if (!prepared.changed) return { ...prepared, document: editor.document, inversePatches: [], validation: editor.validation };
|
|
169
|
+
return { ...editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? "asset-alt", assetId }), assetId, changed: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The `assets` id a reference names (`asset:logo` gives `logo`), or undefined for a URL or data address. */
|
|
173
|
+
export function assetIdOf(reference) {
|
|
174
|
+
const source = typeof reference === "string" ? reference : reference && typeof reference === "object" ? reference.src : undefined;
|
|
175
|
+
return typeof source === "string" && source.startsWith("asset:") ? source.slice("asset:".length) : undefined;
|
|
176
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { DimensionSwitchChange, DimensionSwitchOptions, PreparedDimensionSwitch } from "./switches.js";
|
|
3
|
+
|
|
4
|
+
export declare const BACKGROUND_TYPES: readonly ["theme", "solid", "gradient", "image", "pattern"];
|
|
5
|
+
export declare const THEME_BACKGROUND_SLOTS: readonly ["light1", "light2", "dark1", "dark2"];
|
|
6
|
+
export declare const IMAGE_BACKGROUND_FITS: readonly ["cover", "contain", "tile"];
|
|
7
|
+
/** Scheme slots and roles a ColorRef may name. */
|
|
8
|
+
export declare const COLOR_NAMES: readonly string[];
|
|
9
|
+
/** The 54 DrawingML preset patterns by family. */
|
|
10
|
+
export declare const PATTERN_GROUPS: Readonly<Record<string, readonly string[]>>;
|
|
11
|
+
export declare const PATTERN_PRESETS: readonly string[];
|
|
12
|
+
|
|
13
|
+
export interface GradientStop {
|
|
14
|
+
color: string;
|
|
15
|
+
position: number;
|
|
16
|
+
}
|
|
17
|
+
export type BackgroundSpec =
|
|
18
|
+
| string
|
|
19
|
+
| { type: "theme"; slot: (typeof THEME_BACKGROUND_SLOTS)[number] }
|
|
20
|
+
| { type: "solid"; color: string; opacity?: number }
|
|
21
|
+
| { type: "gradient"; gradient: { angle?: number; stops: GradientStop[] }; opacity?: number }
|
|
22
|
+
| { type: "image"; image: { src: string; fit?: (typeof IMAGE_BACKGROUND_FITS)[number] }; opacity?: number }
|
|
23
|
+
| { type: "pattern"; pattern: { preset: string; foregroundColor?: string; backgroundColor?: string }; opacity?: number };
|
|
24
|
+
|
|
25
|
+
/** Whether `value` is a ColorRef: a hex color, a scheme slot or role name, or `var:<id>`. */
|
|
26
|
+
export declare function isColorRef(value: unknown): boolean;
|
|
27
|
+
/** Validate a background description and return the value to store (a theme slot or plain hex stays the shorthand string). Throws `invalid-background`. */
|
|
28
|
+
export declare function normalizeBackground(spec: BackgroundSpec): string | Record<string, unknown>;
|
|
29
|
+
export declare function prepareBackground(document: unknown, spec: BackgroundSpec | null, options?: DimensionSwitchOptions): PreparedDimensionSwitch;
|
|
30
|
+
/** Set the background as one undoable transaction; `null` removes it. */
|
|
31
|
+
export declare function setBackground(editor: EditorSession, spec: BackgroundSpec | null, options?: DimensionSwitchOptions): DimensionSwitchChange;
|
|
32
|
+
export interface BackgroundState {
|
|
33
|
+
type?: "theme" | "solid" | "gradient" | "image" | "pattern";
|
|
34
|
+
slot?: string;
|
|
35
|
+
color?: string;
|
|
36
|
+
opacity?: number;
|
|
37
|
+
angle?: number;
|
|
38
|
+
stops?: GradientStop[];
|
|
39
|
+
src?: string;
|
|
40
|
+
fit?: string;
|
|
41
|
+
preset?: string;
|
|
42
|
+
foregroundColor?: string;
|
|
43
|
+
backgroundColor?: string;
|
|
44
|
+
scope: "deck" | "slide";
|
|
45
|
+
value?: unknown;
|
|
46
|
+
}
|
|
47
|
+
/** The background that applies at a scope, flattened for a form. */
|
|
48
|
+
export declare function readBackground(document: unknown, options?: { slideIndex?: number }): BackgroundState;
|