@openpresentation/opf-editor 0.14.2 → 0.16.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 +93 -35
- package/dist/assets.js +1 -1
- package/dist/background-options.d.ts +10 -3
- package/dist/background-options.js +38 -13
- package/dist/canvas.d.ts +8 -1
- package/dist/canvas.js +25 -10
- package/dist/catalog-control.js +21 -0
- package/dist/catalogs.d.ts +48 -0
- package/dist/catalogs.js +217 -0
- package/dist/checks.js +9 -7
- package/dist/content-actions.d.ts +2 -2
- package/dist/content-actions.js +3 -3
- package/dist/content-controls.js +9 -9
- package/dist/design-controls.d.ts +12 -5
- package/dist/design-controls.js +286 -82
- package/dist/design-options.d.ts +4 -4
- package/dist/design-options.js +6 -26
- package/dist/export.d.ts +8 -1
- package/dist/export.js +14 -4
- package/dist/find-replace.js +1 -0
- package/dist/font-gate.js +0 -11
- package/dist/galleries.js +32 -67
- package/dist/image-crop.js +8 -9
- package/dist/image-options.d.ts +45 -0
- package/dist/image-options.js +195 -0
- package/dist/index.d.ts +26 -8
- package/dist/index.js +107 -51
- 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 +37 -47
- package/dist/layout-handles.js +13 -0
- package/dist/layout-placeholders.js +15 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +6 -2
- package/dist/review-panel.js +8 -2
- package/dist/schema-inspector.js +1 -1
- package/dist/schema.js +1 -1
- package/dist/slide-manager.js +4 -2
- package/dist/slides.d.ts +1 -1
- package/dist/slides.js +6 -5
- package/dist/svelte.d.ts +1 -0
- package/dist/svelte.js +4 -4
- package/dist/switches.d.ts +31 -16
- package/dist/switches.js +194 -92
- package/dist/table-options.d.ts +6 -1
- package/dist/table-options.js +43 -0
- package/dist/transfer.d.ts +12 -1
- package/dist/transfer.js +87 -131
- package/package.json +22 -9
package/dist/design-options.js
CHANGED
|
@@ -41,19 +41,18 @@ function checkZoneField(key, value) {
|
|
|
41
41
|
if (key === "dateFormat" && (typeof value !== "string" || !value.trim())) throw bad(`A date format uses tokens such as ${DATE_FORMAT_TOKENS.join(", ")}.`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const SLIDE_IMAGE_POSITIONS = ["background", "top", "bottom", "left", "right"];
|
|
45
|
-
|
|
46
44
|
const ENUMS = Object.freeze({
|
|
47
45
|
titleAlignment: ["left", "center", "right"],
|
|
48
46
|
contentAlignment: ["left", "center", "right"],
|
|
49
47
|
contentDirection: ["horizontal", "vertical"],
|
|
50
48
|
chartPrimary: ["none", "top", "bottom", "left", "right"],
|
|
51
49
|
listBullet: ["character", "image"],
|
|
50
|
+
imageFit: ["cover", "contain", "stretch"],
|
|
52
51
|
});
|
|
53
52
|
|
|
54
53
|
/**
|
|
55
54
|
* Descriptors for every design option, in the order a panel shows them. `type` is "enum", "boolean",
|
|
56
|
-
* "font", "asset", "logo", "watermark"
|
|
55
|
+
* "font", "asset", "logo", "watermark" or "organization-logo"; `scopes` says whether
|
|
57
56
|
* the option applies to the deck, one slide, or both.
|
|
58
57
|
*/
|
|
59
58
|
export const DESIGN_OPTIONS = Object.freeze([
|
|
@@ -63,11 +62,11 @@ export const DESIGN_OPTIONS = Object.freeze([
|
|
|
63
62
|
{ id: "chartPrimary", label: "Primary chart position", type: "enum", values: ENUMS.chartPrimary, scopes: ["deck", "slide"], path: "design.chartPrimary" },
|
|
64
63
|
{ id: "listBullet", label: "List bullets", type: "enum", values: ENUMS.listBullet, scopes: ["deck", "slide"], path: "design.listBullet" },
|
|
65
64
|
{ id: "contentBox", label: "Content box", type: "boolean", scopes: ["deck", "slide"], path: "design.contentBox" },
|
|
65
|
+
{ id: "imageFit", label: "Image fit", type: "enum", values: ENUMS.imageFit, scopes: ["deck", "slide"], path: "design.imageFit" },
|
|
66
66
|
{ id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent" },
|
|
67
67
|
{ id: "logo", label: "Logo", type: "logo", scopes: ["deck", "slide"], path: "design.logo" },
|
|
68
68
|
{ id: "organizationLogo", label: "Organization logo", type: "organization-logo", scopes: ["deck"], path: "organization.logo" },
|
|
69
69
|
{ id: "watermark", label: "Watermark", type: "watermark", scopes: ["deck", "slide"], path: "design.watermark" },
|
|
70
|
-
{ id: "slideImage", label: "Slide image", type: "slide-image", scopes: ["deck", "slide"], path: "design.slideImage" },
|
|
71
70
|
]);
|
|
72
71
|
const BY_ID = Object.fromEntries(DESIGN_OPTIONS.map((option) => [option.id, option]));
|
|
73
72
|
|
|
@@ -173,22 +172,6 @@ function mergedWatermark(existing, value) {
|
|
|
173
172
|
return merged;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
|
-
function mergedSlideImage(existing, value) {
|
|
177
|
-
if (value === null) return null;
|
|
178
|
-
if (typeof value === "string") return value;
|
|
179
|
-
if (!isObject(value)) throw fail("invalid-design-value", "A slide image is an image source or an object with a position.", { value });
|
|
180
|
-
const base = typeof existing === "string" ? { src: existing } : isObject(existing) ? (existing.position ? { ...existing } : { src: existing.src, ...(existing.alt ? { alt: existing.alt } : {}) }) : {};
|
|
181
|
-
const merged = { ...base };
|
|
182
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
183
|
-
if (entry === null || entry === undefined) delete merged[key];
|
|
184
|
-
else merged[key] = entry;
|
|
185
|
-
}
|
|
186
|
-
if (merged.src === undefined) delete merged.src;
|
|
187
|
-
if (merged.position === undefined) merged.position = "background";
|
|
188
|
-
if (!SLIDE_IMAGE_POSITIONS.includes(merged.position)) throw fail("invalid-design-value", `Slide image position is one of ${SLIDE_IMAGE_POSITIONS.join(", ")}.`, { value });
|
|
189
|
-
return merged;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
175
|
function fontSchemeWithAccent(presentation, base, family) {
|
|
193
176
|
// The slide's own font scheme, else the deck's: an object form is kept with its overrides.
|
|
194
177
|
const own = ownDesign(presentation, base).fontScheme;
|
|
@@ -223,8 +206,8 @@ function collapseLogo(set) {
|
|
|
223
206
|
|
|
224
207
|
/**
|
|
225
208
|
* Compute the patch that sets one design option, without touching any session. `value === null`
|
|
226
|
-
* removes the option at that scope so it is inherited again.
|
|
227
|
-
*
|
|
209
|
+
* removes the option at that scope so it is inherited again. The object-valued `watermark` merges the
|
|
210
|
+
* fields you pass into the existing object; a `null` field removes it.
|
|
228
211
|
* Options: `slideIndex` (one slide instead of the deck), `clearSlideOverrides` (deck scope: also
|
|
229
212
|
* remove slide-level values that hide it) and `index` (organizationLogo with several
|
|
230
213
|
* organizations).
|
|
@@ -261,12 +244,9 @@ export function prepareDesignOption(presentation, option, value, options = {}) {
|
|
|
261
244
|
} else if (descriptor.type === "logo") {
|
|
262
245
|
if (value !== null && typeof value !== "string" && !isObject(value)) throw fail("invalid-design-value", "A logo is an image source, an asset object or a set of logo variants.", { option });
|
|
263
246
|
entries = { logo: value };
|
|
264
|
-
} else
|
|
247
|
+
} else {
|
|
265
248
|
const existing = ownDesign(presentation, base).watermark;
|
|
266
249
|
entries = { watermark: mergedWatermark(existing, value) };
|
|
267
|
-
} else {
|
|
268
|
-
const existing = ownDesign(presentation, base).slideImage;
|
|
269
|
-
entries = { slideImage: mergedSlideImage(existing, value) };
|
|
270
250
|
}
|
|
271
251
|
keys = Object.keys(entries);
|
|
272
252
|
patches = designPatches(presentation, base, entries);
|
package/dist/export.d.ts
CHANGED
|
@@ -42,10 +42,17 @@ export interface ExportOptions {
|
|
|
42
42
|
includeHidden?: boolean;
|
|
43
43
|
/** "vector" (default): selectable text and vector shapes; "raster": an image per slide. */
|
|
44
44
|
pdfMode?: "vector" | "raster";
|
|
45
|
+
/**
|
|
46
|
+
* Raster PDF only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry imports no PDF library (opf-render 0.16), so the host passes it;
|
|
47
|
+
* without it a `pdfMode: "raster"` PDF rejects with the renderer's `converter-missing`. Vector PDF, PNG and SVG need no option.
|
|
48
|
+
*/
|
|
49
|
+
pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
|
|
45
50
|
/** PNG pixel density (and the raster PDF's), 1 to 4; default 2. */
|
|
46
51
|
scale?: number;
|
|
47
52
|
/** The options the host draws its preview with (`catalogs`, `date`, ...); the fonts go in `fonts`. */
|
|
48
53
|
renderOptions?: RenderSvgOptions;
|
|
54
|
+
/** Host catalogs (merged with `renderOptions.catalogs`). The deck is embedded first (core `embed`), like a saved file. */
|
|
55
|
+
catalogs?: readonly import("@openpresentation/opf").Catalog[];
|
|
49
56
|
/** The renderer's fonts handle (`loadFonts()`): it measures the text, loads the faces the deck needs before anything is drawn, and its registry's faces are what gets embedded. */
|
|
50
57
|
fonts?: EditorFonts;
|
|
51
58
|
/** Faces to embed instead of the registry's. */
|
|
@@ -70,6 +77,6 @@ export interface ExportResult {
|
|
|
70
77
|
}
|
|
71
78
|
/**
|
|
72
79
|
* Draw the deck with the renderer the preview uses and convert it. Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed` (the license rule left the PDF converter no face for some text), `export-no-slides`,
|
|
73
|
-
* `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable` or a renderer code.
|
|
80
|
+
* `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable` or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
|
|
74
81
|
*/
|
|
75
82
|
export declare function exportDeck(deck: unknown, options: ExportOptions): Promise<ExportResult>;
|
package/dist/export.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// // result.download is { name, type, bytes }: one PDF, one PNG or SVG, or a ZIP of the slides.
|
|
9
9
|
|
|
10
10
|
import { renderSvg } from "@openpresentation/opf-render/svg";
|
|
11
|
+
import { mergeCatalogs, prepareSave } from "./catalogs.js";
|
|
11
12
|
import { fontGate } from "./font-gate.js";
|
|
12
13
|
import { createZip } from "./zip.js";
|
|
13
14
|
|
|
@@ -123,8 +124,13 @@ async function loadConverters(options) {
|
|
|
123
124
|
* - `format`: "pdf", "png" or "svg" (required).
|
|
124
125
|
* - `slides`: "current" (with `slideIndex`), "all" (default; hidden slides only with `includeHidden`) or an array of slide numbers.
|
|
125
126
|
* - `pdfMode`: "vector" (default: selectable text, vector shapes) or "raster" (each slide an image).
|
|
127
|
+
* - `pdfLib`: for `pdfMode: "raster"` only, the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry imports no PDF library (RR-63, opf-render 0.16),
|
|
128
|
+
* so a host that offers the raster PDF imports pdf-lib itself; without it a raster PDF rejects with the renderer's `converter-missing` (its `details.install` names the package).
|
|
129
|
+
* Vector PDF, PNG and SVG need no option.
|
|
126
130
|
* - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
|
|
127
131
|
* - `renderOptions`: the render options the host draws with (`catalogs`, `date`, ...), the same as its preview.
|
|
132
|
+
* - `catalogs`: the host's registered catalogs (`Catalog[]`, merged with `renderOptions.catalogs`). The deck is embedded first (core's
|
|
133
|
+
* `embed`), so what is drawn is the self-contained document a save writes.
|
|
128
134
|
* - `fonts`: the renderer's fonts handle (`loadFonts()` from `@openpresentation/opf-render/fonts-browser`), the one the preview uses. Its
|
|
129
135
|
* `textMeasurement` lays the slides out, the faces the deck needs load through its `ensure` before anything is drawn, and its registry's
|
|
130
136
|
* faces are what gets embedded: the SVG carries the faces its slides draw, and the PDF embeds the registry's faces (script faces included).
|
|
@@ -134,9 +140,9 @@ async function loadConverters(options) {
|
|
|
134
140
|
* `export-fonts-unlicensed` (the converter's error is its `cause`).
|
|
135
141
|
* - `signal`, `onProgress({ stage, done, total, message })`, `onDiagnostic(diagnostic)`.
|
|
136
142
|
* Resolves `{ download, files, diagnostics, slides }`. Rejects with an error whose `code` is `export-aborted`,
|
|
137
|
-
* `export-fonts-unlicensed`, `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code.
|
|
143
|
+
* `export-fonts-unlicensed`, `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
|
|
138
144
|
*/
|
|
139
|
-
export async function exportDeck(
|
|
145
|
+
export async function exportDeck(input, options = {}) {
|
|
140
146
|
const format = EXPORT_FORMATS[options.format];
|
|
141
147
|
if (!format) throw exportError("export-format", `Choose a format: ${Object.keys(EXPORT_FORMATS).join(", ")}.`);
|
|
142
148
|
const { signal } = options;
|
|
@@ -144,12 +150,14 @@ export async function exportDeck(deck, options = {}) {
|
|
|
144
150
|
const note = (diagnostic) => { diagnostics.push(diagnostic); options.onDiagnostic?.(diagnostic); };
|
|
145
151
|
const progress = (stage, done, total, message) => options.onProgress?.({ stage, done, total, message });
|
|
146
152
|
const check = () => { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); };
|
|
147
|
-
const indexes = slidesToExport(
|
|
153
|
+
const indexes = slidesToExport(input, options);
|
|
148
154
|
if (!indexes.length) throw exportError("export-no-slides", "There are no slides to export.");
|
|
149
155
|
const selected = new Set(indexes);
|
|
150
156
|
const keep = (diagnostic) => diagnostic.slide === undefined || selected.has(diagnostic.slide);
|
|
151
157
|
const scale = Math.min(MAX_PNG_SCALE, Math.max(1, Number(options.scale) || 2));
|
|
152
|
-
const
|
|
158
|
+
const catalogs = mergeCatalogs(options.catalogs, options.renderOptions?.catalogs);
|
|
159
|
+
const renderOptions = { ...(options.renderOptions ?? {}), catalogs };
|
|
160
|
+
const deck = prepareSave(input, { catalogs }).document;
|
|
153
161
|
|
|
154
162
|
check();
|
|
155
163
|
const gate = fontGate(options.fonts);
|
|
@@ -208,6 +216,8 @@ export async function exportDeck(deck, options = {}) {
|
|
|
208
216
|
metadata,
|
|
209
217
|
signal,
|
|
210
218
|
fontData: pdfFontData(embeddedFonts),
|
|
219
|
+
// RR-63: the renderer's browser entry imports no pdf-lib; raster mode takes the module the host imported.
|
|
220
|
+
...(options.pdfMode === "raster" && options.pdfLib ? { pdfLib: options.pdfLib } : {}),
|
|
211
221
|
...(options.fallbackFamily ? { defaultFontFamily: options.fallbackFamily } : {}),
|
|
212
222
|
onDiagnostic: (diagnostic) => { const described = describeDiagnostic(diagnostic, "pdf"); if (keep(described)) note(described); },
|
|
213
223
|
onProgress: ({ page, pages }) => progress("convert", page, pages, `Page ${page} of ${pages}`),
|
package/dist/find-replace.js
CHANGED
|
@@ -107,6 +107,7 @@ function payloadFields(fields, state, base, payload, prefix = "") {
|
|
|
107
107
|
}));
|
|
108
108
|
}
|
|
109
109
|
if (isObject(payload.chart)) field(fields, state, at("chart", "alt"), payload.chart.alt, `${prefix}Chart alt text`, "alt");
|
|
110
|
+
if (isObject(payload.table)) field(fields, state, at("table", "alt"), payload.table.alt, `${prefix}Table alt text`, "alt");
|
|
110
111
|
const table = payload.table;
|
|
111
112
|
if (isObject(table)) {
|
|
112
113
|
if (Array.isArray(table.columns)) table.columns.forEach((cell, c) => (isObject(cell) && !("value" in cell) ? field(fields, state, at("table", "columns", String(c), "name"), cell.name, `${prefix}Table header ${c + 1}`, "table") : cellFields(fields, state, at("table", "columns", String(c)), cell, `${prefix}Table header ${c + 1}`)));
|
package/dist/font-gate.js
CHANGED
|
@@ -13,17 +13,6 @@ export const FONTS_UNAVAILABLE = "fonts-unavailable";
|
|
|
13
13
|
|
|
14
14
|
const GATE = Symbol("opf-editor.fontGate");
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
* The fonts a preview draws with: the handle's text measurement and nothing else. A full handle also lists `embeddedFonts`, which
|
|
18
|
-
* the renderer writes into every SVG it returns (an export needs that, so the SVG carries its faces); a preview inside a page
|
|
19
|
-
* that already loaded the faces would repeat megabytes of base64 in every canvas, thumbnail and panel preview. The measurement
|
|
20
|
-
* is read from the handle at each use, so faces it loads later still measure.
|
|
21
|
-
*/
|
|
22
|
-
export function previewFonts(fonts) {
|
|
23
|
-
if (!fonts || typeof fonts !== "object") return undefined;
|
|
24
|
-
return { get textMeasurement() { return fonts.textMeasurement; } };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
16
|
/**
|
|
28
17
|
* The gate over a fonts handle: the two calls the canvas and the export need, with the editor's error contract.
|
|
29
18
|
*
|
package/dist/galleries.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { assertOpf, unwrapOpf, MAX_OPF_BYTES } from "./transfer.js";
|
|
2
|
-
import { catalogs } from "@openpresentation/opf";
|
|
3
2
|
export function galleryUrl(input, base) {
|
|
4
3
|
const url = new URL(input, base);
|
|
5
4
|
if (
|
|
@@ -133,8 +132,6 @@ function fontRole(value) {
|
|
|
133
132
|
// pair). `accent` is not part of the record schema and is dropped.
|
|
134
133
|
function fontSchemeRecord(source, id) {
|
|
135
134
|
const record = {
|
|
136
|
-
$schema: "https://openpresentation.org/schema/opf-font-scheme/v1",
|
|
137
|
-
id,
|
|
138
135
|
name: source.name ?? id,
|
|
139
136
|
major:
|
|
140
137
|
typeof source.major === "string"
|
|
@@ -151,10 +148,25 @@ function fontSchemeRecord(source, id) {
|
|
|
151
148
|
if (code) record.code = code;
|
|
152
149
|
return record;
|
|
153
150
|
}
|
|
154
|
-
|
|
151
|
+
const isPlain = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
152
|
+
// Embedded records carry no `$schema`, `id` or `x-*` display metadata (OPF 0.15): the key is the id.
|
|
153
|
+
function embeddedRecord(source) {
|
|
154
|
+
return Object.fromEntries(
|
|
155
|
+
Object.entries(structuredClone(source)).filter(
|
|
156
|
+
([key]) => key !== "$schema" && key !== "id" && key !== "slug" && !key.startsWith("x-"),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A gallery item that describes one catalog record (a layout, theme, colour or font scheme) is embedded in the
|
|
162
|
+
* document it previews: under `default` with the gallery's origin as its `source` when the document has no default
|
|
163
|
+
* group (or its default is that gallery), else under `custom` (an item with no gallery URL, or another default
|
|
164
|
+
* catalog). Records the document already embeds are kept.
|
|
165
|
+
*/
|
|
166
|
+
function attachDefinition(presentation, descriptor, gallerySource) {
|
|
155
167
|
const source = descriptor.metadata?.source,
|
|
156
168
|
category = descriptor.category ?? descriptor.metadata?.category;
|
|
157
|
-
if (!source) return;
|
|
169
|
+
if (!isPlain(source)) return;
|
|
158
170
|
const kind = {
|
|
159
171
|
layouts: "layouts",
|
|
160
172
|
"font-schemes": "fontSchemes",
|
|
@@ -163,12 +175,13 @@ function attachDefinition(presentation, descriptor) {
|
|
|
163
175
|
}[category];
|
|
164
176
|
if (!kind) return;
|
|
165
177
|
const id = source.id ?? source.slug;
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
171
|
-
|
|
178
|
+
if (typeof id !== "string" || !/^[a-z][a-z0-9-]*$/.test(id)) return;
|
|
179
|
+
const groups = (presentation.catalogs ??= {});
|
|
180
|
+
if (groups.custom?.[kind]?.[id] !== undefined || groups.default?.[kind]?.[id] !== undefined) return;
|
|
181
|
+
const group = gallerySource && (groups.default === undefined || groups.default?.source === gallerySource) ? "default" : "custom";
|
|
182
|
+
if (group === "default") groups.default ??= { source: gallerySource };
|
|
183
|
+
else groups.custom ??= {};
|
|
184
|
+
groups[group][kind] ??= {};
|
|
172
185
|
let record;
|
|
173
186
|
if (kind === "layouts") {
|
|
174
187
|
// A gallery layout item is a layout record: its placeholders, design hints and composition are the
|
|
@@ -176,33 +189,17 @@ function attachDefinition(presentation, descriptor) {
|
|
|
176
189
|
const placeholders = Array.isArray(source.placeholders)
|
|
177
190
|
? source.placeholders
|
|
178
191
|
.filter((placeholder) => typeof placeholder?.type === "string")
|
|
179
|
-
.map((placeholder) => (
|
|
192
|
+
.map((placeholder) => structuredClone(placeholder))
|
|
180
193
|
: [{ type: "title" }, { type: "text" }];
|
|
181
|
-
const plain = (value) =>
|
|
182
|
-
value && typeof value === "object" && !Array.isArray(value)
|
|
183
|
-
? structuredClone(value)
|
|
184
|
-
: undefined;
|
|
185
194
|
record = {
|
|
186
|
-
$schema: "https://openpresentation.org/schema/opf-layout/v1",
|
|
187
|
-
id,
|
|
188
195
|
name: source.label ?? source.name ?? id,
|
|
189
|
-
...(
|
|
196
|
+
...(isPlain(source.design) ? { design: structuredClone(source.design) } : {}),
|
|
190
197
|
placeholders,
|
|
191
|
-
...(
|
|
192
|
-
? { composition: plain(source.composition) }
|
|
193
|
-
: {}),
|
|
198
|
+
...(isPlain(source.composition) ? { composition: structuredClone(source.composition) } : {}),
|
|
194
199
|
};
|
|
195
200
|
} else if (kind === "fontSchemes") record = fontSchemeRecord(source, id);
|
|
196
|
-
else
|
|
197
|
-
|
|
198
|
-
...source,
|
|
199
|
-
$schema: `https://openpresentation.org/schema/opf-${kind === "themes" ? "theme" : "color-scheme"}/v1`,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
presentation.catalogs ??= {};
|
|
203
|
-
presentation.catalogs[kind] ??= { records: [] };
|
|
204
|
-
presentation.catalogs[kind].records ??= [];
|
|
205
|
-
presentation.catalogs[kind].records.push(record);
|
|
201
|
+
else record = embeddedRecord(source);
|
|
202
|
+
groups[group][kind][id] = record;
|
|
206
203
|
}
|
|
207
204
|
export async function loadOpfGalleryItem(item, { gallery, ...options } = {}) {
|
|
208
205
|
const base = gallery ?? options.base;
|
|
@@ -221,41 +218,9 @@ export async function loadOpfGalleryItem(item, { gallery, ...options } = {}) {
|
|
|
221
218
|
const presentation = structuredClone(unwrapOpf(descriptor));
|
|
222
219
|
if (!presentation?.slides)
|
|
223
220
|
throw new Error("This gallery item does not include a presentation.");
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
url &&
|
|
229
|
-
["www.pptx.gallery", "pptx.gallery"].includes(new URL(url).hostname)
|
|
230
|
-
) {
|
|
231
|
-
const missing = [
|
|
232
|
-
...new Set(
|
|
233
|
-
presentation.slides
|
|
234
|
-
.map((slide) => slide.layout)
|
|
235
|
-
.filter(
|
|
236
|
-
(id) =>
|
|
237
|
-
typeof id === "string" &&
|
|
238
|
-
!catalogs.layouts.some((record) => record.id === id) &&
|
|
239
|
-
!presentation.catalogs?.layouts?.records?.some(
|
|
240
|
-
(record) => record.id === id,
|
|
241
|
-
),
|
|
242
|
-
),
|
|
243
|
-
),
|
|
244
|
-
];
|
|
245
|
-
if (missing.length > 12)
|
|
246
|
-
throw new Error(
|
|
247
|
-
"This item needs too many layout definitions. Supply self-contained OPF.",
|
|
248
|
-
);
|
|
249
|
-
for (const id of missing) {
|
|
250
|
-
if (!/^[a-z][a-z0-9-]*$/.test(id))
|
|
251
|
-
throw new Error("Unsupported layout reference.");
|
|
252
|
-
const dependency = await fetchGalleryJson(
|
|
253
|
-
`/registry/layouts/${id}.json`,
|
|
254
|
-
{ ...options, base: url, origin: new URL(url).origin },
|
|
255
|
-
);
|
|
256
|
-
attachDefinition(presentation, dependency);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
221
|
+
// A gallery publishes self-contained OPF (every record a document uses is embedded, FA-23); the item's own record is
|
|
222
|
+
// embedded too when the snippet omits it. Nothing else is fetched.
|
|
223
|
+
attachDefinition(presentation, descriptor, url ? new URL(url).origin : undefined);
|
|
259
224
|
assertOpf(presentation);
|
|
260
225
|
return presentation;
|
|
261
226
|
}
|
package/dist/image-crop.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// Image crop and focal point (RR-25): the model.
|
|
2
2
|
//
|
|
3
|
-
// The OPF schema has no crop rectangle
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// placement, and none for a picture whose shape matches its frame).
|
|
3
|
+
// The OPF schema has no crop rectangle on an image (`Asset` is `src`, `alt`, `title`, `description`, `mediaType`, `format`).
|
|
4
|
+
// An image block has a `fit` (cover, contain, stretch) and, since OPF 0.15, a `focus` point that a cover fit keeps in view
|
|
5
|
+
// (image-options.js sets both). A crop that removes part of the picture is still not a field, so the editor writes it into
|
|
6
|
+
// the picture itself: the cropped pixels become a new entry of `assets` and the image points at it, in ONE undoable change.
|
|
7
|
+
// Because the preview and the PPTX export both read those pixels, they cannot disagree about what is shown; the block's fit
|
|
8
|
+
// then places the cropped picture in its frame exactly as before (opf-pptx writes the usual `a:srcRect` for that placement,
|
|
9
|
+
// and none for a picture whose shape matches its frame).
|
|
11
10
|
//
|
|
12
11
|
// A focal point works the same way: the picture is cut to the frame's shape around the chosen point, so a centred cover
|
|
13
12
|
// fit shows what was chosen. The original asset stays in `assets`, and the new asset's `description` says "Cropped from
|
|
@@ -159,7 +158,7 @@ export const isFullRect = (rect, bounds) => rect.x < 0.5 && rect.y < 0.5 && Math
|
|
|
159
158
|
export function describeImage(presentation, path) {
|
|
160
159
|
const segments = String(path).split(".");
|
|
161
160
|
const last = segments.at(-1);
|
|
162
|
-
if (
|
|
161
|
+
if (last !== "image") return { error: "This is not a picture." };
|
|
163
162
|
const value = getValueAtPath(presentation, path);
|
|
164
163
|
const pointer = opfPathToJsonPointer(segments);
|
|
165
164
|
let form;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export declare const IMAGE_FITS: readonly ["cover", "contain", "stretch"];
|
|
4
|
+
export declare const IMAGE_SHAPES: readonly ["rectangle", "rounded", "circle", "hexagon"];
|
|
5
|
+
export declare const IMAGE_EDGES: readonly ["left", "right", "top", "bottom"];
|
|
6
|
+
export declare const IMAGE_TREATMENT_FIELDS: readonly ["fit", "focus", "aspectRatio", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "placement"];
|
|
7
|
+
|
|
8
|
+
export interface ImageFocus { x: number; y: number }
|
|
9
|
+
/** One overlay, shared by image backgrounds and image blocks: the whole picture, or a band along `edge` (`size` 0.05 to 1). */
|
|
10
|
+
export interface ImageOverlay { color: string; opacity: number; edge?: (typeof IMAGE_EDGES)[number]; size?: number }
|
|
11
|
+
/** The block bleeds to `edge` and takes `size` (0.1 to 0.9, default 0.5) of the slide; `inset` keeps it inside the padding. */
|
|
12
|
+
export interface ImagePlacement { edge: (typeof IMAGE_EDGES)[number]; size?: number; inset?: boolean }
|
|
13
|
+
export interface ImageTreatments {
|
|
14
|
+
fit?: (typeof IMAGE_FITS)[number] | null;
|
|
15
|
+
focus?: ImageFocus | null;
|
|
16
|
+
aspectRatio?: number | null;
|
|
17
|
+
shape?: (typeof IMAGE_SHAPES)[number] | null;
|
|
18
|
+
cornerRadius?: number | null;
|
|
19
|
+
border?: { color: string; width: number } | null;
|
|
20
|
+
opacity?: number | null;
|
|
21
|
+
recolor?: "grayscale" | { dark: string; light: string } | null;
|
|
22
|
+
overlay?: ImageOverlay | null;
|
|
23
|
+
placement?: ImagePlacement | null;
|
|
24
|
+
}
|
|
25
|
+
export interface ImageTreatmentState extends Omit<ImageTreatments, "fit" | "focus"> {
|
|
26
|
+
path: string;
|
|
27
|
+
fit?: (typeof IMAGE_FITS)[number];
|
|
28
|
+
focus?: ImageFocus;
|
|
29
|
+
/** Whether the block may be placed (a top-level block, `slides.N.blocks.I`). */
|
|
30
|
+
placeable: boolean;
|
|
31
|
+
/** Edges other placed blocks of the slide already take. */
|
|
32
|
+
usedEdges: (typeof IMAGE_EDGES)[number][];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export declare function normalizeFocus(value: unknown): ImageFocus;
|
|
36
|
+
export declare function normalizeOverlay(value: unknown): ImageOverlay;
|
|
37
|
+
export declare function normalizeRecolor(value: unknown): "grayscale" | { dark: string; light: string };
|
|
38
|
+
export declare function normalizePlacement(value: unknown): ImagePlacement;
|
|
39
|
+
/** The image block's framing for a form. Throws `not-an-image-block`. */
|
|
40
|
+
export declare function readImageTreatments(presentation: unknown, blockPath: string | string[]): ImageTreatmentState;
|
|
41
|
+
/** The patches that set (a value) or remove (`null`) the fields. Throws `invalid-image-treatment`, `placement-not-top-level` or `placement-edge-taken`. */
|
|
42
|
+
export declare function imageTreatmentPatches(presentation: unknown, blockPath: string | string[], fields: ImageTreatments): JsonPatchOperation[];
|
|
43
|
+
export declare function prepareImageTreatment(presentation: unknown, blockPath: string | string[], fields: ImageTreatments): { presentation: unknown; patches: JsonPatchOperation[]; changed: boolean; path: string; slideIndex: number };
|
|
44
|
+
/** Set or remove treatment fields of one image block as one undoable transaction. */
|
|
45
|
+
export declare function setImageTreatment(editor: EditorSession, blockPath: string | string[], fields: ImageTreatments, meta?: Record<string, unknown>): EditorChange & { changed: boolean; path: string; slideIndex: number };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Image blocks (FA-22/FA-23, OPF 0.15): every content picture is an image block, and its framing lives on the block: fit and
|
|
2
|
+
// focus, the treatments (shape, corner radius, border, opacity, recolor, overlay, aspect ratio) and placement (bleed to one slide
|
|
3
|
+
// edge, the only thing that moves content aside). Each change is one validated JSON Patch applied as one undoable transaction;
|
|
4
|
+
// the `prepare…` form returns the patch without touching a session. `design.imageFit` (a design option) is the deck or slide
|
|
5
|
+
// default fit.
|
|
6
|
+
import { getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
7
|
+
import { checkedDocument, fail, same } from "./edit-helpers.js";
|
|
8
|
+
import { checkFormat } from "./checks.js";
|
|
9
|
+
import { isAuthoringColorRef } from "./color-authoring.js";
|
|
10
|
+
|
|
11
|
+
export const IMAGE_FITS = Object.freeze(["cover", "contain", "stretch"]);
|
|
12
|
+
export const IMAGE_SHAPES = Object.freeze(["rectangle", "rounded", "circle", "hexagon"]);
|
|
13
|
+
export const IMAGE_EDGES = Object.freeze(["left", "right", "top", "bottom"]);
|
|
14
|
+
/** The fields an image block carries besides its `image`, in schema order. */
|
|
15
|
+
export const IMAGE_TREATMENT_FIELDS = Object.freeze(["fit", "focus", "aspectRatio", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "placement"]);
|
|
16
|
+
|
|
17
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
const bad = (message, details) => fail("invalid-image-treatment", message, details);
|
|
19
|
+
const fraction = (value, min = 0, max = 1) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
|
20
|
+
const colorRef = (value, what) => {
|
|
21
|
+
if (typeof value !== "string" || !value.trim() || !isAuthoringColorRef(value)) throw bad(`${what} must be a hex color such as #1F2937, a scheme color such as accent1 or surface, or var:<id>.`, { value });
|
|
22
|
+
return value.trim();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A focus point `{ x, y }`, each 0 (left/top) to 1 (right/bottom). */
|
|
26
|
+
export function normalizeFocus(value) {
|
|
27
|
+
if (!isObject(value) || !fraction(Number(value.x)) || !fraction(Number(value.y))) throw bad("A focus point is { x, y }, each from 0 (left or top) to 1 (right or bottom).", { value });
|
|
28
|
+
return { x: Number(value.x), y: Number(value.y) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An overlay `{ color, opacity, edge?, size? }`, shared by image backgrounds and image blocks: a scrim over the whole picture, or a
|
|
33
|
+
* band along one edge (`size` 0.05 to 1, default 0.3). `color` is any ColorRef, theme roles included.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeOverlay(value) {
|
|
36
|
+
if (!isObject(value)) throw bad("An overlay is { color, opacity }, with an optional edge and size for a band.", { value });
|
|
37
|
+
const out = { color: colorRef(value.color, "The overlay color"), opacity: Number(value.opacity) };
|
|
38
|
+
if (value.opacity === undefined || value.opacity === "" || !fraction(out.opacity)) throw bad("The overlay opacity is a number from 0 to 1.", { value });
|
|
39
|
+
if (value.edge !== undefined && value.edge !== null && value.edge !== "") {
|
|
40
|
+
if (!IMAGE_EDGES.includes(value.edge)) throw bad(`An overlay band runs along one edge: ${IMAGE_EDGES.join(", ")}.`, { value });
|
|
41
|
+
out.edge = value.edge;
|
|
42
|
+
if (value.size !== undefined && value.size !== null && value.size !== "") {
|
|
43
|
+
const size = Number(value.size);
|
|
44
|
+
if (!fraction(size, 0.05, 1)) throw bad("The overlay band size is a share of the picture from 0.05 to 1.", { value });
|
|
45
|
+
out.size = size;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A recolor: `"grayscale"` or a duotone `{ dark, light }` (ColorRefs), shared by image backgrounds and image blocks. */
|
|
52
|
+
export function normalizeRecolor(value) {
|
|
53
|
+
if (value === "grayscale") return value;
|
|
54
|
+
if (isObject(value)) return { dark: colorRef(value.dark, "The duotone dark color"), light: colorRef(value.light, "The duotone light color") };
|
|
55
|
+
throw bad('Recolor is "grayscale" or a duotone { dark, light }.', { field: "recolor", value });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A placement `{ edge, size?, inset? }`: the block bleeds to that slide edge and takes `size` (0.1 to 0.9, default 0.5) of the slide. */
|
|
59
|
+
export function normalizePlacement(value) {
|
|
60
|
+
if (!isObject(value) || !IMAGE_EDGES.includes(value.edge)) throw bad(`A placement names the slide edge the image bleeds to: ${IMAGE_EDGES.join(", ")}.`, { value });
|
|
61
|
+
const out = { edge: value.edge };
|
|
62
|
+
if (value.size !== undefined && value.size !== null && value.size !== "") {
|
|
63
|
+
const size = Number(value.size);
|
|
64
|
+
if (!fraction(size, 0.1, 0.9)) throw bad("The placed image's share of the slide is from 0.1 to 0.9.", { value });
|
|
65
|
+
out.size = size;
|
|
66
|
+
}
|
|
67
|
+
if (value.inset === true) out.inset = true;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeField(key, value) {
|
|
72
|
+
switch (key) {
|
|
73
|
+
case "fit":
|
|
74
|
+
if (!IMAGE_FITS.includes(value)) throw bad(`Image fit is one of ${IMAGE_FITS.join(", ")}.`, { field: key, value });
|
|
75
|
+
return value;
|
|
76
|
+
case "focus":
|
|
77
|
+
return normalizeFocus(value);
|
|
78
|
+
case "aspectRatio": {
|
|
79
|
+
const ratio = Number(value);
|
|
80
|
+
if (!(Number.isFinite(ratio) && ratio > 0 && ratio <= 10)) throw bad("The frame's aspect ratio is a width-to-height number above 0 and up to 10, for example 1.78.", { field: key, value });
|
|
81
|
+
return ratio;
|
|
82
|
+
}
|
|
83
|
+
case "shape":
|
|
84
|
+
if (!IMAGE_SHAPES.includes(value)) throw bad(`Image shape is one of ${IMAGE_SHAPES.join(", ")}.`, { field: key, value });
|
|
85
|
+
return value;
|
|
86
|
+
case "cornerRadius": {
|
|
87
|
+
const radius = Number(value);
|
|
88
|
+
if (!fraction(radius, 0, 0.5)) throw bad("The corner radius is a share of the frame's shorter side from 0 to 0.5.", { field: key, value });
|
|
89
|
+
return radius;
|
|
90
|
+
}
|
|
91
|
+
case "border": {
|
|
92
|
+
if (!isObject(value)) throw bad("A border is { color, width }.", { field: key, value });
|
|
93
|
+
const width = Number(value.width);
|
|
94
|
+
if (!fraction(width, 0, 64)) throw bad("The border width is from 0 to 64 reference pixels.", { field: key, value });
|
|
95
|
+
return { color: colorRef(value.color, "The border color"), width };
|
|
96
|
+
}
|
|
97
|
+
case "opacity": {
|
|
98
|
+
const opacity = Number(value);
|
|
99
|
+
if (!fraction(opacity)) throw bad("Image opacity is a number from 0 to 1.", { field: key, value });
|
|
100
|
+
return opacity;
|
|
101
|
+
}
|
|
102
|
+
case "recolor":
|
|
103
|
+
return normalizeRecolor(value);
|
|
104
|
+
case "overlay":
|
|
105
|
+
return normalizeOverlay(value);
|
|
106
|
+
case "placement":
|
|
107
|
+
return normalizePlacement(value);
|
|
108
|
+
default:
|
|
109
|
+
throw bad(`Unknown image treatment: ${key}. Use ${IMAGE_TREATMENT_FIELDS.join(", ")}.`, { field: key });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The payload that holds the picture at `blockPath` (a block, a region payload or the slide itself for `Slide.image`). */
|
|
114
|
+
function imagePayload(presentation, blockPath) {
|
|
115
|
+
const parts = splitOpfPath(blockPath);
|
|
116
|
+
if (parts[0] === "slides" && parts.at(-1) === "image") parts.pop();
|
|
117
|
+
if (parts[0] !== "slides" || !presentation.slides?.[Number(parts[1])]) throw fail("not-an-image-block", "Choose an image block on a slide.", { path: blockPath });
|
|
118
|
+
// The slide's own `image` (Slide.image) takes no framing fields: the slide object is not an image block.
|
|
119
|
+
if (parts.length === 2) throw fail("not-an-image-block", "The slide's own image takes no fit, treatments or placement. Make it an image block (in blocks) to frame it.", { path: blockPath });
|
|
120
|
+
const payload = getValueAtPath(presentation, parts);
|
|
121
|
+
if (!isObject(payload) || payload.image === undefined) throw fail("not-an-image-block", "Choose an image block: these settings frame a picture.", { path: blockPath });
|
|
122
|
+
return { parts, payload };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Only a top-level block (slides.N.blocks.I) may be placed (core's image-placement-invalid rule; the slide's own image cannot).
|
|
126
|
+
const placeable = (parts) => parts.length === 4 && parts[2] === "blocks";
|
|
127
|
+
|
|
128
|
+
/** Edges already taken by another placed top-level block of the slide. */
|
|
129
|
+
function usedEdges(presentation, parts) {
|
|
130
|
+
const slide = presentation.slides[Number(parts[1])];
|
|
131
|
+
const self = parts.length === 4 ? Number(parts[3]) : -1;
|
|
132
|
+
const edges = new Set();
|
|
133
|
+
for (const [index, block] of (Array.isArray(slide?.blocks) ? slide.blocks : []).entries()) if (index !== self && isObject(block?.placement)) edges.add(block.placement.edge);
|
|
134
|
+
return edges;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The image block's framing as a form shows it: `{ path, fit, focus, aspectRatio, shape, cornerRadius, border, opacity, recolor,
|
|
139
|
+
* overlay, placement, placeable, usedEdges }`. Fields the block does not set are undefined (the effective fit is then
|
|
140
|
+
* `design.imageFit`, else cover).
|
|
141
|
+
*/
|
|
142
|
+
export function readImageTreatments(presentation, blockPath) {
|
|
143
|
+
const { parts, payload } = imagePayload(presentation, blockPath);
|
|
144
|
+
const out = { path: parts.join(".") };
|
|
145
|
+
for (const key of IMAGE_TREATMENT_FIELDS) out[key] = payload[key] === undefined ? undefined : structuredClone(payload[key]);
|
|
146
|
+
out.placeable = placeable(parts);
|
|
147
|
+
out.usedEdges = [...usedEdges(presentation, parts)];
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The patches that set (a value) or remove (`null`) treatment fields of the image block at `blockPath`. */
|
|
152
|
+
export function imageTreatmentPatches(presentation, blockPath, fields) {
|
|
153
|
+
if (!isObject(fields)) throw bad("Pass the image settings to change, for example { fit: \"contain\" }.", { fields });
|
|
154
|
+
const { parts, payload } = imagePayload(presentation, blockPath);
|
|
155
|
+
const patches = [];
|
|
156
|
+
for (const [key, raw] of Object.entries(fields)) {
|
|
157
|
+
if (raw === undefined) continue;
|
|
158
|
+
const at = opfPathToJsonPointer([...parts, key]);
|
|
159
|
+
if (raw === null) {
|
|
160
|
+
if (payload[key] !== undefined) patches.push({ op: "remove", path: at });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const value = normalizeField(key, raw);
|
|
164
|
+
if (key === "placement") {
|
|
165
|
+
if (!placeable(parts)) throw fail("placement-not-top-level", "Only an image block directly in the slide's blocks can bleed to an edge, not one inside a group or region or the slide's own image.", { path: blockPath });
|
|
166
|
+
if (usedEdges(presentation, parts).has(value.edge)) throw fail("placement-edge-taken", `Another image already bleeds to the ${value.edge} edge of this slide. Choose another edge or move that image first.`, { path: blockPath, edge: value.edge });
|
|
167
|
+
}
|
|
168
|
+
if (payload[key] === undefined) patches.push({ op: "add", path: at, value });
|
|
169
|
+
else if (!same(payload[key], value)) patches.push({ op: "replace", path: at, value });
|
|
170
|
+
}
|
|
171
|
+
return patches;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Compute the patch for `setImageTreatment` without touching a session: `{ presentation, patches, changed, path, slideIndex }`. */
|
|
175
|
+
export function prepareImageTreatment(presentation, blockPath, fields) {
|
|
176
|
+
const before = checkFormat(presentation);
|
|
177
|
+
const patches = imageTreatmentPatches(presentation, blockPath, fields);
|
|
178
|
+
const next = checkedDocument(presentation, patches, before);
|
|
179
|
+
const parts = splitOpfPath(blockPath);
|
|
180
|
+
return { presentation: structuredClone(next), patches, changed: patches.length > 0, path: parts.join("."), slideIndex: Number(parts[1]) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Set or remove (`null`) treatment fields of one image block as a single undoable transaction:
|
|
185
|
+
* `setImageTreatment(editor, "slides.1.blocks.0", { fit: "contain", placement: { edge: "right", size: 0.45 } })`.
|
|
186
|
+
*/
|
|
187
|
+
export function setImageTreatment(editor, blockPath, fields, meta = {}) {
|
|
188
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
189
|
+
const prepared = prepareImageTreatment(editor.presentation, blockPath, fields);
|
|
190
|
+
const { presentation, patches, ...summary } = prepared;
|
|
191
|
+
void presentation;
|
|
192
|
+
if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
|
|
193
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "image-treatment", path: prepared.path });
|
|
194
|
+
return { ...change, ...summary };
|
|
195
|
+
}
|