@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/templates.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// with variables; filling it resolves the variables to a concrete deck (core resolveVariables).
|
|
3
3
|
// Everything here reads and writes the document through the session, so a fill, a declaration
|
|
4
4
|
// and a token insertion are validated, undoable JSON Patch edits like any other. Importing this
|
|
5
|
-
// module needs no DOM.
|
|
6
|
-
|
|
7
|
-
import * as core from "@openpresentation/opf";
|
|
5
|
+
// module needs no DOM.
|
|
6
|
+
import { coerceVariableValue, hasContentVariables, listBuiltinVariables, listVariables, resolveVariables, variableDeclarations } from "@openpresentation/opf";
|
|
8
7
|
import { OPFEditorError, getValueAtPath, opfPathToJsonPointer } from "./index.js";
|
|
9
8
|
|
|
10
9
|
const VARIABLE_ID = /^[a-z][a-z0-9-]*$/;
|
|
10
|
+
// A built-in variable name (core FA-04): `speakers`, or deck/speaker/organization plus one or two dotted segments.
|
|
11
|
+
const BUILTIN_NAME = /^(?:speakers|(?:deck|speaker|organization)(?:.[A-Za-z0-9_-]+){1,2})$/;
|
|
11
12
|
const KINDS = ["color", "text", "number", "date", "image", "url", "list"];
|
|
12
13
|
|
|
13
14
|
/** The form control each variable kind uses. */
|
|
@@ -25,12 +26,6 @@ function fail(code, message, details) {
|
|
|
25
26
|
return new OPFEditorError(code, message, details);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function requireCore() {
|
|
29
|
-
if (typeof core.resolveVariables !== "function" || typeof core.listVariables !== "function") {
|
|
30
|
-
throw fail("templates-unavailable", "Templates need a core release that ships resolveVariables (@openpresentation/opf after 0.11.4).");
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
29
|
function checkEditor(editor) {
|
|
35
30
|
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") {
|
|
36
31
|
throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
@@ -39,19 +34,22 @@ function checkEditor(editor) {
|
|
|
39
34
|
|
|
40
35
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
36
|
|
|
42
|
-
/** True when the core this editor runs on can resolve template variables. */
|
|
43
|
-
export function templatesAvailable() {
|
|
44
|
-
return typeof core.resolveVariables === "function" && typeof core.listVariables === "function";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
37
|
/** True when the document is marked as a template (`template: true`). */
|
|
48
|
-
export function isTemplateDocument(
|
|
49
|
-
return isObject(
|
|
38
|
+
export function isTemplateDocument(presentation) {
|
|
39
|
+
return isObject(presentation) && presentation.template === true;
|
|
50
40
|
}
|
|
51
41
|
|
|
52
42
|
/** True when the document is a template or declares a variable that is not a color. */
|
|
53
|
-
export function hasTemplateVariables(
|
|
54
|
-
return
|
|
43
|
+
export function hasTemplateVariables(presentation) {
|
|
44
|
+
return hasContentVariables(presentation);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The presentation's built-in variables (read-only values from its own speaker, organization and deck metadata) with
|
|
49
|
+
* kind, label, current value, whether the presentation has a source value and where each is used.
|
|
50
|
+
*/
|
|
51
|
+
export function listBuiltins(presentation) {
|
|
52
|
+
return listBuiltinVariables(presentation);
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
function humanize(id) {
|
|
@@ -80,10 +78,9 @@ export function valueToFieldText(kind, value) {
|
|
|
80
78
|
* applies again). Returns `{ ok: true, value }` (value undefined when cleared) or `{ ok: false, message }`.
|
|
81
79
|
*/
|
|
82
80
|
export function fieldTextToValue(kind, text) {
|
|
83
|
-
requireCore();
|
|
84
81
|
if (typeof text !== "string") return { ok: false, message: "Expected text." };
|
|
85
82
|
if (text.trim() === "") return { ok: true, value: undefined };
|
|
86
|
-
const coerced =
|
|
83
|
+
const coerced = coerceVariableValue(kind, kind === "text" ? text : text.trim());
|
|
87
84
|
return coerced.ok ? { ok: true, value: coerced.value } : { ok: false, message: `${coerced.message}.` };
|
|
88
85
|
}
|
|
89
86
|
|
|
@@ -92,10 +89,9 @@ export function fieldTextToValue(kind, text) {
|
|
|
92
89
|
* `status` is "filled" (a value was supplied), "default" (the declaration has a value), "unfilled"
|
|
93
90
|
* (required, no value) or "optional" (not required, no value).
|
|
94
91
|
*/
|
|
95
|
-
export function listTemplateFields(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return core.listVariables(document, values).map((info) => {
|
|
92
|
+
export function listTemplateFields(presentation, values = {}) {
|
|
93
|
+
const declared = new Map(variableDeclarations(presentation).map((declaration) => [declaration.id, declaration]));
|
|
94
|
+
return listVariables(presentation, values).map((info) => {
|
|
99
95
|
const declaration = declared.get(info.id);
|
|
100
96
|
const supplied = Object.hasOwn(values, info.id) && values[info.id] !== undefined && values[info.id] !== null;
|
|
101
97
|
const defaultValue = declaration?.value;
|
|
@@ -122,12 +118,12 @@ export function listTemplateFields(document, values = {}) {
|
|
|
122
118
|
}
|
|
123
119
|
|
|
124
120
|
/** The summary a panel shows: how many required variables still need a value. */
|
|
125
|
-
export function templateStatus(
|
|
126
|
-
const fields = listTemplateFields(
|
|
121
|
+
export function templateStatus(presentation, values = {}) {
|
|
122
|
+
const fields = listTemplateFields(presentation, values);
|
|
127
123
|
const required = fields.filter((field) => field.required);
|
|
128
124
|
const unfilled = fields.filter((field) => field.status === "unfilled").map((field) => field.id);
|
|
129
125
|
return {
|
|
130
|
-
template: isTemplateDocument(
|
|
126
|
+
template: isTemplateDocument(presentation),
|
|
131
127
|
fieldCount: fields.length,
|
|
132
128
|
requiredCount: required.length,
|
|
133
129
|
filledRequiredCount: required.length - unfilled.length,
|
|
@@ -140,9 +136,8 @@ export function templateStatus(document, values = {}) {
|
|
|
140
136
|
* Preview data for the supplied values: the concrete deck with each unfilled variable's example (or its
|
|
141
137
|
* token when it has none). Never throws for missing values; the diagnostics say what was unfilled.
|
|
142
138
|
*/
|
|
143
|
-
export function previewTemplate(
|
|
144
|
-
|
|
145
|
-
return core.resolveVariables(document, values, { examples: true, partial: true });
|
|
139
|
+
export function previewTemplate(presentation, values = {}) {
|
|
140
|
+
return resolveVariables(presentation, values, { examples: true, partial: true });
|
|
146
141
|
}
|
|
147
142
|
|
|
148
143
|
/**
|
|
@@ -151,34 +146,33 @@ export function previewTemplate(document, values = {}) {
|
|
|
151
146
|
* document, `subscribe` notifies listeners (a panel redraws on it).
|
|
152
147
|
*/
|
|
153
148
|
export function createTemplateFill(editor, options = {}) {
|
|
154
|
-
requireCore();
|
|
155
149
|
checkEditor(editor);
|
|
156
150
|
let values = {};
|
|
157
151
|
const listeners = new Set();
|
|
158
152
|
const emit = () => {
|
|
159
153
|
for (const listener of listeners) listener(api);
|
|
160
154
|
};
|
|
161
|
-
const declared = () => new Set(
|
|
162
|
-
const kindOf = (id) =>
|
|
155
|
+
const declared = () => new Set(variableDeclarations(editor.presentation).map((declaration) => declaration.id));
|
|
156
|
+
const kindOf = (id) => variableDeclarations(editor.presentation).find((declaration) => declaration.id === id)?.kind;
|
|
163
157
|
const api = {
|
|
164
158
|
get values() {
|
|
165
159
|
return structuredClone(values);
|
|
166
160
|
},
|
|
167
161
|
fields() {
|
|
168
|
-
return listTemplateFields(editor.
|
|
162
|
+
return listTemplateFields(editor.presentation, values);
|
|
169
163
|
},
|
|
170
164
|
status() {
|
|
171
|
-
return templateStatus(editor.
|
|
165
|
+
return templateStatus(editor.presentation, values);
|
|
172
166
|
},
|
|
173
167
|
preview() {
|
|
174
|
-
return previewTemplate(editor.
|
|
168
|
+
return previewTemplate(editor.presentation, values);
|
|
175
169
|
},
|
|
176
170
|
set(id, value) {
|
|
177
171
|
if (!declared().has(id)) throw fail("unknown-variable", `No variable '${id}' is declared.`, { id });
|
|
178
172
|
if (value === undefined || value === null) {
|
|
179
173
|
delete values[id];
|
|
180
174
|
} else {
|
|
181
|
-
const coerced =
|
|
175
|
+
const coerced = coerceVariableValue(kindOf(id), value);
|
|
182
176
|
if (!coerced.ok) throw fail("invalid-variable-value", `Value for '${id}': ${coerced.message}.`, { id });
|
|
183
177
|
values[id] = coerced.value;
|
|
184
178
|
}
|
|
@@ -218,8 +212,8 @@ export function createTemplateFill(editor, options = {}) {
|
|
|
218
212
|
* `partial` is set (the unfilled ones stay declared for a later pass).
|
|
219
213
|
*/
|
|
220
214
|
apply({ partial = false, meta = {} } = {}) {
|
|
221
|
-
const
|
|
222
|
-
const result =
|
|
215
|
+
const presentation = editor.presentation;
|
|
216
|
+
const result = resolveVariables(presentation, values, { partial, template: false });
|
|
223
217
|
const errors = result.diagnostics.filter((entry) => entry.severity === "error");
|
|
224
218
|
if (errors.length) {
|
|
225
219
|
const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
|
|
@@ -239,27 +233,27 @@ export function createTemplateFill(editor, options = {}) {
|
|
|
239
233
|
return api;
|
|
240
234
|
}
|
|
241
235
|
|
|
242
|
-
/** The token text for a variable: `{{id}}`, or `{{id|format}}` with a one-off format. */
|
|
236
|
+
/** The token text for a variable or a built-in name: `{{id}}`, or `{{id|format}}` with a one-off format. */
|
|
243
237
|
export function variableToken(id, format) {
|
|
244
|
-
if (!VARIABLE_ID.test(id)) throw fail("invalid-variable-id", "A variable id is lowercase kebab-case: letters, digits and hyphens, starting with a letter.", { id });
|
|
238
|
+
if (!VARIABLE_ID.test(id) && !BUILTIN_NAME.test(id)) throw fail("invalid-variable-id", "A variable id is lowercase kebab-case: letters, digits and hyphens, starting with a letter.", { id });
|
|
245
239
|
return format ? `{{${id}|${format}}}` : `{{${id}}}`;
|
|
246
240
|
}
|
|
247
241
|
|
|
248
242
|
/** An unused variable id derived from a label, for a "new variable" form. */
|
|
249
|
-
export function suggestVariableId(
|
|
243
|
+
export function suggestVariableId(presentation, label = "value") {
|
|
250
244
|
const base = String(label).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "") || "value";
|
|
251
|
-
const taken = new Set(isObject(
|
|
245
|
+
const taken = new Set(isObject(presentation?.variables) ? Object.keys(presentation.variables) : []);
|
|
252
246
|
if (!taken.has(base)) return base;
|
|
253
247
|
for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
|
|
254
248
|
}
|
|
255
249
|
|
|
256
|
-
function declarationPatches(
|
|
250
|
+
function declarationPatches(presentation, id, declaration) {
|
|
257
251
|
if (!VARIABLE_ID.test(id)) throw fail("invalid-variable-id", "A variable id is lowercase kebab-case: letters, digits and hyphens, starting with a letter.", { id });
|
|
258
252
|
const entry = typeof declaration === "string" ? declaration : structuredClone(declaration);
|
|
259
253
|
if (typeof entry !== "string" && !(isObject(entry) && KINDS.includes(entry.type))) {
|
|
260
254
|
throw fail("invalid-variable", `A variable declaration is a hex color or an object whose type is one of ${KINDS.join(", ")}.`, { id });
|
|
261
255
|
}
|
|
262
|
-
const variables =
|
|
256
|
+
const variables = presentation.variables;
|
|
263
257
|
if (variables !== undefined && !isObject(variables)) throw fail("invalid-variable", "The document's variables field is not an object.", { id });
|
|
264
258
|
if (variables && Object.hasOwn(variables, id)) throw fail("variable-exists", `A variable '${id}' is already declared.`, { id });
|
|
265
259
|
return variables === undefined
|
|
@@ -269,22 +263,21 @@ function declarationPatches(document, id, declaration) {
|
|
|
269
263
|
|
|
270
264
|
/** Declare a variable as one undoable edit. The resulting document must validate (a required variable with no value needs `template: true`). */
|
|
271
265
|
export function declareVariable(editor, id, declaration, meta = {}) {
|
|
272
|
-
requireCore();
|
|
273
266
|
checkEditor(editor);
|
|
274
|
-
return editor.applyPatch(declarationPatches(editor.
|
|
267
|
+
return editor.applyPatch(declarationPatches(editor.presentation, id, declaration), { source: "template-variable", rejectInvalid: true, ...meta });
|
|
275
268
|
}
|
|
276
269
|
|
|
277
270
|
/** Mark the document as a template (or, with `false`, as a normal deck) as one undoable edit. */
|
|
278
271
|
export function setTemplate(editor, enabled, meta = {}) {
|
|
279
272
|
checkEditor(editor);
|
|
280
|
-
const
|
|
281
|
-
const present = Object.hasOwn(
|
|
273
|
+
const presentation = editor.presentation;
|
|
274
|
+
const present = Object.hasOwn(presentation, "template");
|
|
282
275
|
const patches = enabled
|
|
283
276
|
? [{ op: present ? "replace" : "add", path: "/template", value: true }]
|
|
284
277
|
: present
|
|
285
278
|
? [{ op: "remove", path: "/template" }]
|
|
286
279
|
: [];
|
|
287
|
-
if (!patches.length) return {
|
|
280
|
+
if (!patches.length) return { presentation, patches: [], inversePatches: [], validation: editor.validation };
|
|
288
281
|
return editor.applyPatch(patches, { source: "template-mode", rejectInvalid: true, ...meta });
|
|
289
282
|
}
|
|
290
283
|
|
|
@@ -295,18 +288,18 @@ export function setTemplate(editor, enabled, meta = {}) {
|
|
|
295
288
|
* the declaration, and both changes commit together.
|
|
296
289
|
*/
|
|
297
290
|
export function insertVariableToken(editor, path, id, { start, end, runIndex, format, declare, meta = {} } = {}) {
|
|
298
|
-
requireCore();
|
|
299
291
|
checkEditor(editor);
|
|
300
|
-
const
|
|
292
|
+
const presentation = editor.presentation;
|
|
301
293
|
const token = variableToken(id, format);
|
|
302
294
|
const patches = [];
|
|
303
|
-
|
|
295
|
+
// A built-in needs no declaration; an unknown built-in path fails the validated edit.
|
|
296
|
+
const declaredNow = BUILTIN_NAME.test(id) || (isObject(presentation.variables) && Object.hasOwn(presentation.variables, id));
|
|
304
297
|
if (!declaredNow) {
|
|
305
298
|
if (declare === undefined) throw fail("unknown-variable", `No variable '${id}' is declared. Declare it first, or pass its declaration.`, { id });
|
|
306
|
-
patches.push(...declarationPatches(
|
|
299
|
+
patches.push(...declarationPatches(presentation, id, declare));
|
|
307
300
|
}
|
|
308
301
|
const pointer = opfPathToJsonPointer(path);
|
|
309
|
-
const current = getValueAtPath(
|
|
302
|
+
const current = getValueAtPath(presentation, path);
|
|
310
303
|
const splice = (text) => {
|
|
311
304
|
const from = start === undefined ? text.length : start;
|
|
312
305
|
const to = end === undefined ? from : end;
|
package/dist/transfer.d.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
|
+
import type { Catalog, CopiedRecordRename } from "@openpresentation/opf";
|
|
1
2
|
export declare const MAX_OPF_BYTES: number;
|
|
2
3
|
export interface OpfTransfer {
|
|
3
4
|
kind: "presentation" | "slides" | "selection";
|
|
4
5
|
value: unknown;
|
|
5
|
-
|
|
6
|
+
presentation?: any;
|
|
6
7
|
}
|
|
7
|
-
export declare function assertOpf(
|
|
8
|
+
export declare function assertOpf(presentation: any): any;
|
|
8
9
|
export declare function unwrapOpf(value: any): any;
|
|
9
10
|
export declare function parseOpfTransfer(text: string): OpfTransfer;
|
|
10
11
|
export declare function serializeOpfTransfer(
|
|
11
|
-
|
|
12
|
+
presentation: any,
|
|
12
13
|
options?: {
|
|
13
14
|
scope?: "presentation" | "slide" | "selection";
|
|
14
15
|
slideIndex?: number;
|
|
15
16
|
path?: string;
|
|
16
17
|
format?: "pretty" | "compact" | "markdown";
|
|
18
|
+
/** Host catalogs: a presentation or slide is embedded (core `embed`) so it renders the same where none is registered. */
|
|
19
|
+
catalogs?: readonly Catalog[];
|
|
17
20
|
},
|
|
18
21
|
): string;
|
|
19
22
|
export declare function prepareOpfImport(
|
|
@@ -23,5 +26,13 @@ export declare function prepareOpfImport(
|
|
|
23
26
|
mode?: "insert" | "replace" | "selection";
|
|
24
27
|
slideIndex?: number;
|
|
25
28
|
path?: string;
|
|
29
|
+
/** Host catalogs, for resolving and copying the inserted slides' records (core `copySlides`). */
|
|
30
|
+
catalogs?: readonly Catalog[];
|
|
26
31
|
},
|
|
27
|
-
): {
|
|
32
|
+
): {
|
|
33
|
+
presentation: any;
|
|
34
|
+
slideIndex: number;
|
|
35
|
+
/** Records that did not keep their reference while inserting: tell the user. */
|
|
36
|
+
renamed: CopiedRecordRename[];
|
|
37
|
+
addedGroups: { name: string; source?: string }[];
|
|
38
|
+
};
|
package/dist/transfer.js
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
createValuePatch,
|
|
4
|
-
getValueAtPath,
|
|
5
|
-
validateOpfDocument,
|
|
6
|
-
} from "./index.js";
|
|
7
|
-
import { catalogs } from "@openpresentation/opf";
|
|
1
|
+
import { applyJsonPatch, createValuePatch, getValueAtPath } from "./index.js";
|
|
2
|
+
import { ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_THEME, copySlides, parseReference, resolveReference } from "@openpresentation/opf";
|
|
8
3
|
import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
|
|
9
|
-
import {
|
|
4
|
+
import { checkFormat, firstErrorMessage } from "./checks.js";
|
|
5
|
+
import { mergeCatalogs, prepareSave } from "./catalogs.js";
|
|
10
6
|
export const MAX_OPF_BYTES = 20 * 1024 * 1024;
|
|
11
7
|
const clone = (value) => structuredClone(value);
|
|
12
|
-
export function assertOpf(
|
|
13
|
-
bounded(
|
|
14
|
-
const result =
|
|
8
|
+
export function assertOpf(presentation) {
|
|
9
|
+
bounded(presentation);
|
|
10
|
+
const result = checkFormat(presentation);
|
|
15
11
|
if (!result.valid)
|
|
16
|
-
throw new Error(result
|
|
17
|
-
return
|
|
12
|
+
throw new Error(firstErrorMessage(result, "Invalid OPF document."));
|
|
13
|
+
return presentation;
|
|
18
14
|
}
|
|
19
15
|
function bounded(value, depth = 0, budget = { remaining: 250000 }) {
|
|
20
16
|
if (depth > 64 || --budget.remaining < 0)
|
|
@@ -36,7 +32,7 @@ export function unwrapOpf(value, depth = 0) {
|
|
|
36
32
|
if (snippet) return unwrapOpf(snippet.value ?? JSON.parse(snippet.source), depth + 1);
|
|
37
33
|
}
|
|
38
34
|
if (value?.opf && typeof value.opf === "object") return unwrapOpf(value.opf, depth + 1);
|
|
39
|
-
if (value?.
|
|
35
|
+
if (value?.presentation) return unwrapOpf(value.presentation, depth + 1);
|
|
40
36
|
return value;
|
|
41
37
|
}
|
|
42
38
|
export function parseOpfTransfer(text) {
|
|
@@ -63,123 +59,110 @@ export function parseOpfTransfer(text) {
|
|
|
63
59
|
bounded(value);
|
|
64
60
|
if (value?.slides) {
|
|
65
61
|
assertOpf(value);
|
|
66
|
-
return { kind: "presentation", value,
|
|
62
|
+
return { kind: "presentation", value, presentation: clone(value) };
|
|
67
63
|
}
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
return { kind: "slides", value,
|
|
64
|
+
const presentation = { slides: Array.isArray(value) ? value : [value] };
|
|
65
|
+
if (checkFormat(presentation).valid)
|
|
66
|
+
return { kind: "slides", value, presentation };
|
|
71
67
|
return { kind: "selection", value };
|
|
72
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The OPF text to copy or save. A presentation or a slide is self-contained: core's `embed` adds every catalog record it
|
|
71
|
+
* references (from `catalogs`, the host's registered list), so it renders the same where no catalog is registered.
|
|
72
|
+
*/
|
|
73
73
|
export function serializeOpfTransfer(
|
|
74
|
-
|
|
75
|
-
{ scope = "presentation", slideIndex = 0, path, format = "pretty" } = {},
|
|
74
|
+
presentation,
|
|
75
|
+
{ scope = "presentation", slideIndex = 0, path, format = "pretty", catalogs } = {},
|
|
76
76
|
) {
|
|
77
|
-
let value =
|
|
77
|
+
let value = presentation;
|
|
78
78
|
if (scope === "slide") {
|
|
79
|
-
if (!
|
|
79
|
+
if (!presentation.slides?.[slideIndex])
|
|
80
80
|
throw new Error("Select a slide first.");
|
|
81
|
-
value = { ...
|
|
81
|
+
value = prepareSave({ ...presentation, slides: [presentation.slides[slideIndex]] }, { catalogs }).document;
|
|
82
|
+
} else if (scope === "presentation") {
|
|
83
|
+
value = prepareSave(presentation, { catalogs }).document;
|
|
82
84
|
} else if (scope === "selection") {
|
|
83
|
-
value = getValueAtPath(
|
|
85
|
+
value = getValueAtPath(presentation, path);
|
|
84
86
|
if (value === undefined) throw new Error("Select some content first.");
|
|
85
87
|
}
|
|
86
88
|
const json = JSON.stringify(value, null, format === "compact" ? 0 : 2);
|
|
87
89
|
return format === "markdown" ? `\`\`\`json\n${json}\n\`\`\`` : json;
|
|
88
90
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
91
|
+
const RESERVED_GROUPS = new Set(["default", "custom"]);
|
|
92
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
93
|
+
// A reference read out of a record of `group` keeps naming that group's record when it is written on a slide.
|
|
94
|
+
function qualified(value, group) {
|
|
95
|
+
const qualify = (reference) => {
|
|
96
|
+
const parsed = typeof reference === "string" ? parseReference(reference) : undefined;
|
|
97
|
+
return !parsed || parsed.group || RESERVED_GROUPS.has(group) ? reference : `${group}:${parsed.id}`;
|
|
98
|
+
};
|
|
99
|
+
if (typeof value === "string") return qualify(value);
|
|
100
|
+
if (isObject(value) && typeof value.id === "string") return { ...clone(value), id: qualify(value.id) };
|
|
101
|
+
return value === undefined ? undefined : clone(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Freeze the source deck's look on its slides before they move: each slide carries its colour scheme, font scheme, background
|
|
106
|
+
* and header/footer, so the target deck's design does not restyle it. A value the source takes from its theme is written with
|
|
107
|
+
* the theme's catalog group; one it takes from the engine default is written only when the target would not use the engine
|
|
108
|
+
* default too (core's `ENGINE_DEFAULT_*`, never a catalog id).
|
|
109
|
+
*/
|
|
110
|
+
function frozenSlides(incoming, current, catalogs) {
|
|
111
|
+
const target = isObject(current.design) ? current.design : {};
|
|
112
|
+
const targetDefault = (key) => target[key] === undefined && target.theme === undefined;
|
|
113
|
+
return incoming.slides.map((slide) => {
|
|
114
|
+
// A slide's design cannot set dimensions (a PPTX has one slide size): the inserted slide takes the host deck's.
|
|
115
|
+
const { dimensions: _deckSize, ...design } = { ...incoming.design, ...slide.design };
|
|
116
|
+
const theme = typeof design.theme === "string" ? resolveReference(incoming, "themes", design.theme, { catalogs }) : undefined;
|
|
117
|
+
const record = isObject(theme?.record) ? theme.record : {};
|
|
118
|
+
const next = { ...design };
|
|
119
|
+
const freeze = (key, engineDefault) => {
|
|
120
|
+
if (design[key] !== undefined) return;
|
|
121
|
+
if (record[key] !== undefined) next[key] = qualified(record[key], theme.group);
|
|
122
|
+
else if (!targetDefault(key)) next[key] = clone(engineDefault);
|
|
123
|
+
};
|
|
124
|
+
freeze("colorScheme", ENGINE_DEFAULT_COLOR_SCHEME);
|
|
125
|
+
freeze("fontScheme", ENGINE_DEFAULT_FONT_SCHEME);
|
|
126
|
+
freeze("background", ENGINE_DEFAULT_THEME.background);
|
|
127
|
+
next.header = design.header ?? false;
|
|
128
|
+
next.footer = design.footer ?? false;
|
|
129
|
+
return { ...slide, design: next };
|
|
130
|
+
});
|
|
107
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Apply a parsed transfer to the current document. `mode` "selection" replaces the selected value, "replace" opens the transfer
|
|
135
|
+
* as the presentation, and "insert" copies its slides in after `slideIndex` with core's `copySlides`: catalog groups match by
|
|
136
|
+
* `source`, identical records are reused, a differing record is renamed, and `renamed` / `addedGroups` say what changed so the
|
|
137
|
+
* host can tell the user. Assets and shared datasets come along under free ids. `catalogs` are the host's registered catalogs.
|
|
138
|
+
* Returns `{ presentation, slideIndex, renamed, addedGroups }`.
|
|
139
|
+
*/
|
|
108
140
|
export function prepareOpfImport(
|
|
109
141
|
current,
|
|
110
142
|
transfer,
|
|
111
|
-
{ mode = "insert", slideIndex = 0, path } = {},
|
|
143
|
+
{ mode = "insert", slideIndex = 0, path, catalogs } = {},
|
|
112
144
|
) {
|
|
113
145
|
if (mode === "selection") {
|
|
114
|
-
const
|
|
146
|
+
const presentation = applyJsonPatch(
|
|
115
147
|
current,
|
|
116
148
|
createValuePatch(current, path, transfer.value),
|
|
117
149
|
);
|
|
118
|
-
assertOpf(
|
|
119
|
-
return {
|
|
150
|
+
assertOpf(presentation);
|
|
151
|
+
return { presentation, slideIndex, renamed: [], addedGroups: [] };
|
|
120
152
|
}
|
|
121
|
-
if (!transfer.
|
|
153
|
+
if (!transfer.presentation)
|
|
122
154
|
throw new Error(
|
|
123
155
|
"This is a content fragment. Choose Replace selected content.",
|
|
124
156
|
);
|
|
125
157
|
if (mode === "replace")
|
|
126
|
-
return {
|
|
158
|
+
return { presentation: assertOpf(clone(transfer.presentation)), slideIndex: 0, renamed: [], addedGroups: [] };
|
|
127
159
|
if (mode !== "insert") throw new Error("Unknown import action.");
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
incoming.slides = incoming
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
...slide,
|
|
136
|
-
design: {
|
|
137
|
-
...design,
|
|
138
|
-
theme: design.theme ?? "minimal",
|
|
139
|
-
colorScheme: design.colorScheme ?? theme.colorScheme ?? "cool-horizon",
|
|
140
|
-
fontScheme: design.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
|
|
141
|
-
dimensions: design.dimensions ?? theme.dimensions ?? "widescreen",
|
|
142
|
-
background: design.background ?? theme.background ?? "#FFFFFF",
|
|
143
|
-
header: design.header ?? false,
|
|
144
|
-
footer: design.footer ?? false,
|
|
145
|
-
},
|
|
146
|
-
};
|
|
147
|
-
});
|
|
148
|
-
const mappings = {},
|
|
149
|
-
assetMap = new Map();
|
|
150
|
-
let chartTypes = new Map();
|
|
151
|
-
for (const [kind, catalog] of Object.entries(incoming.catalogs ?? {})) {
|
|
152
|
-
const existing = document.catalogs?.[kind];
|
|
153
|
-
if (
|
|
154
|
-
catalog.source &&
|
|
155
|
-
existing?.source &&
|
|
156
|
-
JSON.stringify(catalog.source) !== JSON.stringify(existing.source)
|
|
157
|
-
)
|
|
158
|
-
throw new Error(
|
|
159
|
-
`The ${kind} gallery sources conflict. Open this as a separate presentation instead.`,
|
|
160
|
-
);
|
|
161
|
-
if (catalog.source && !existing?.source)
|
|
162
|
-
throw new Error(
|
|
163
|
-
`Resolve ${kind} records into this OPF before inserting, or open it as a presentation.`,
|
|
164
|
-
);
|
|
165
|
-
const used = new Set(
|
|
166
|
-
[...(existing?.records ?? []), ...(catalogs[kind] ?? [])].map(
|
|
167
|
-
(record) => record.id,
|
|
168
|
-
),
|
|
169
|
-
);
|
|
170
|
-
const names = new Map();
|
|
171
|
-
for (const record of catalog.records ?? []) {
|
|
172
|
-
let id = `import-${record.id}`,
|
|
173
|
-
n = 2;
|
|
174
|
-
while (used.has(id)) id = `import-${record.id}-${n++}`;
|
|
175
|
-
used.add(id);
|
|
176
|
-
names.set(record.id, id);
|
|
177
|
-
record.id = id;
|
|
178
|
-
}
|
|
179
|
-
if (catalogKeys[kind]) mappings[catalogKeys[kind]] = names;
|
|
180
|
-
if (kind === "chartTypes") chartTypes = names;
|
|
181
|
-
}
|
|
182
|
-
const assetIds = new Set(Object.keys(document.assets ?? {}));
|
|
160
|
+
const list = mergeCatalogs(catalogs);
|
|
161
|
+
const incoming = clone(transfer.presentation),
|
|
162
|
+
target = clone(current);
|
|
163
|
+
incoming.slides = frozenSlides(incoming, current, list);
|
|
164
|
+
const assetMap = new Map();
|
|
165
|
+
const assetIds = new Set(Object.keys(target.assets ?? {}));
|
|
183
166
|
for (const id of Object.keys(incoming.assets ?? {})) {
|
|
184
167
|
let next = id,
|
|
185
168
|
n = 2;
|
|
@@ -197,11 +180,11 @@ export function prepareOpfImport(
|
|
|
197
180
|
else if (key === "dataset" && typeof value === "string") usedDatasets.add(value);
|
|
198
181
|
};
|
|
199
182
|
collectDatasets(incoming.slides);
|
|
200
|
-
const datasetIds = new Set(Object.keys(
|
|
183
|
+
const datasetIds = new Set(Object.keys(target.datasets ?? {}));
|
|
201
184
|
for (const id of usedDatasets) {
|
|
202
185
|
const mine = incoming.datasets && Object.hasOwn(incoming.datasets, id) ? incoming.datasets[id] : undefined;
|
|
203
186
|
if (mine === undefined) continue;
|
|
204
|
-
if (datasetIds.has(id) && JSON.stringify(
|
|
187
|
+
if (datasetIds.has(id) && JSON.stringify(target.datasets[id]) === JSON.stringify(mine)) {
|
|
205
188
|
datasetMap.set(id, id);
|
|
206
189
|
continue;
|
|
207
190
|
}
|
|
@@ -213,70 +196,33 @@ export function prepareOpfImport(
|
|
|
213
196
|
}
|
|
214
197
|
const rewrite = (value, key = "") => {
|
|
215
198
|
if (Array.isArray(value)) return value.map((child) => rewrite(child, key));
|
|
216
|
-
if (value && typeof value === "object")
|
|
217
|
-
|
|
218
|
-
Object.entries(value).map(([childKey, child]) => [
|
|
219
|
-
childKey,
|
|
220
|
-
rewrite(child, childKey),
|
|
221
|
-
]),
|
|
222
|
-
);
|
|
223
|
-
if (key === "socials" && mappings.platform) {
|
|
224
|
-
for (const [id, child] of Object.entries(result)) {
|
|
225
|
-
if (mappings.platform.has(id)) {
|
|
226
|
-
delete result[id];
|
|
227
|
-
result[mappings.platform.get(id)] = child;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (mappings[key]?.has(value.id)) result.id = mappings[key].get(value.id);
|
|
232
|
-
if (value.data && chartTypes.has(value.type))
|
|
233
|
-
result.type = chartTypes.get(value.type);
|
|
234
|
-
return result;
|
|
235
|
-
}
|
|
199
|
+
if (value && typeof value === "object")
|
|
200
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, rewrite(child, childKey)]));
|
|
236
201
|
if (typeof value === "string") {
|
|
237
202
|
if (key === "dataset" && datasetMap.has(value)) return datasetMap.get(value);
|
|
238
|
-
if (
|
|
239
|
-
if (
|
|
240
|
-
["src", "image", "video", "poster"].includes(key) &&
|
|
241
|
-
value.startsWith("asset:") &&
|
|
242
|
-
assetMap.has(value.slice(6))
|
|
243
|
-
)
|
|
203
|
+
if (["src", "image", "video", "poster", "background"].includes(key) && value.startsWith("asset:") && assetMap.has(value.slice(6)))
|
|
244
204
|
return `asset:${assetMap.get(value.slice(6))}`;
|
|
245
205
|
}
|
|
246
206
|
return value;
|
|
247
207
|
};
|
|
248
|
-
|
|
249
|
-
if (incoming.catalogs) {
|
|
250
|
-
document.catalogs ??= {};
|
|
251
|
-
for (const [kind, catalog] of Object.entries(rewritten.catalogs))
|
|
252
|
-
document.catalogs[kind] = {
|
|
253
|
-
...document.catalogs[kind],
|
|
254
|
-
...catalog,
|
|
255
|
-
records: [
|
|
256
|
-
...(document.catalogs[kind]?.records ?? []),
|
|
257
|
-
...(catalog.records ?? []),
|
|
258
|
-
],
|
|
259
|
-
};
|
|
260
|
-
}
|
|
208
|
+
incoming.slides = rewrite(incoming.slides);
|
|
261
209
|
if (incoming.assets) {
|
|
262
|
-
|
|
263
|
-
for (const [id, asset] of Object.entries(
|
|
264
|
-
|
|
265
|
-
typeof asset === "string" &&
|
|
266
|
-
asset.startsWith("asset:") &&
|
|
267
|
-
assetMap.has(asset.slice(6))
|
|
268
|
-
? `asset:${assetMap.get(asset.slice(6))}`
|
|
269
|
-
: asset;
|
|
210
|
+
target.assets ??= {};
|
|
211
|
+
for (const [id, asset] of Object.entries(rewrite(incoming.assets)))
|
|
212
|
+
target.assets[assetMap.get(id)] =
|
|
213
|
+
typeof asset === "string" && asset.startsWith("asset:") && assetMap.has(asset.slice(6)) ? `asset:${assetMap.get(asset.slice(6))}` : asset;
|
|
270
214
|
}
|
|
271
215
|
for (const [id, next] of datasetMap) {
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
216
|
+
if (target.datasets && Object.hasOwn(target.datasets, next)) continue;
|
|
217
|
+
target.datasets ??= {};
|
|
218
|
+
target.datasets[next] = clone(incoming.datasets[id]);
|
|
275
219
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
220
|
+
const index = Math.max(0, Math.min(target.slides.length, slideIndex + 1));
|
|
221
|
+
// Core copies the catalog records the slides use and rewrites their references (FA-20).
|
|
222
|
+
const copied = copySlides(incoming, target, incoming.slides.map((_, at) => at), { catalogs: list, at: index });
|
|
223
|
+
const presentation = clone(copied.document);
|
|
224
|
+
const ids = new Set(collectReservedPresentationIds({ ...presentation, slides: presentation.slides.filter((_, at) => !copied.slides.includes(at)) }));
|
|
225
|
+
for (const at of copied.slides) remapSlideTreeIds(presentation.slides[at], ids);
|
|
226
|
+
assertOpf(presentation);
|
|
227
|
+
return { presentation, slideIndex: copied.slides[0] ?? index, renamed: copied.renamed ?? [], addedGroups: copied.addedGroups ?? [] };
|
|
282
228
|
}
|