@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/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
|
|
@@ -156,11 +155,11 @@ export const isFullRect = (rect, bounds) => rect.x < 0.5 && rect.y < 0.5 && Math
|
|
|
156
155
|
* (the field is `{ src, ... }`); `assetSrc` is what `src` resolves to (the asset's own `src`), and `origin` is the asset a
|
|
157
156
|
* crop of this picture was made from, when there is one.
|
|
158
157
|
*/
|
|
159
|
-
export function describeImage(
|
|
158
|
+
export function describeImage(presentation, path) {
|
|
160
159
|
const segments = String(path).split(".");
|
|
161
160
|
const last = segments.at(-1);
|
|
162
|
-
if (
|
|
163
|
-
const value = getValueAtPath(
|
|
161
|
+
if (last !== "image") return { error: "This is not a picture." };
|
|
162
|
+
const value = getValueAtPath(presentation, path);
|
|
164
163
|
const pointer = opfPathToJsonPointer(segments);
|
|
165
164
|
let form;
|
|
166
165
|
let src;
|
|
@@ -177,7 +176,7 @@ export function describeImage(document, path) {
|
|
|
177
176
|
let assetSrc = src;
|
|
178
177
|
let entry;
|
|
179
178
|
if (assetId !== undefined) {
|
|
180
|
-
entry = getValueAtPath(
|
|
179
|
+
entry = getValueAtPath(presentation, ["assets", assetId]);
|
|
181
180
|
if (entry === undefined) return { error: `The picture asset "${assetId}" is missing from this presentation.` };
|
|
182
181
|
assetSrc = typeof entry === "string" ? entry : entry?.src;
|
|
183
182
|
if (typeof assetSrc !== "string") return { error: `The picture asset "${assetId}" has no source.` };
|
|
@@ -193,22 +192,22 @@ export function describeImage(document, path) {
|
|
|
193
192
|
}
|
|
194
193
|
|
|
195
194
|
/** How many places in the document use `asset:<id>` (the `assets` map itself is not counted). */
|
|
196
|
-
export function countAssetReferences(
|
|
195
|
+
export function countAssetReferences(presentation, id) {
|
|
197
196
|
const reference = `asset:${id}`;
|
|
198
197
|
let count = 0;
|
|
199
198
|
const walk = (value) => {
|
|
200
199
|
if (typeof value === "string") count += value === reference ? 1 : 0;
|
|
201
200
|
else if (Array.isArray(value)) value.forEach(walk);
|
|
202
|
-
else if (isObject(value)) for (const [key, child] of Object.entries(value)) if (key !== "assets" || value !==
|
|
201
|
+
else if (isObject(value)) for (const [key, child] of Object.entries(value)) if (key !== "assets" || value !== presentation) walk(child);
|
|
203
202
|
};
|
|
204
|
-
walk(
|
|
203
|
+
walk(presentation);
|
|
205
204
|
return count;
|
|
206
205
|
}
|
|
207
206
|
|
|
208
207
|
// ---- the change --------------------------------------------------------------------------------------------------------
|
|
209
208
|
|
|
210
|
-
function assetPatches(
|
|
211
|
-
const assets =
|
|
209
|
+
function assetPatches(presentation, id, entry) {
|
|
210
|
+
const assets = presentation?.assets;
|
|
212
211
|
return isObject(assets)
|
|
213
212
|
? [{ op: "add", path: opfPathToJsonPointer(["assets", id]), value: entry }]
|
|
214
213
|
: [{ op: "add", path: "/assets", value: { [id]: entry } }];
|
|
@@ -219,33 +218,33 @@ function assetPatches(document, id, entry) {
|
|
|
219
218
|
* pointing at it. `pixels` is `{ dataUri, mediaType, width, height }` (from `cropImagePixels`). A previous crop's asset that
|
|
220
219
|
* nothing else uses is removed in the same change. Pure.
|
|
221
220
|
*/
|
|
222
|
-
export function prepareCrop(
|
|
223
|
-
const image = describeImage(
|
|
221
|
+
export function prepareCrop(presentation, path, pixels) {
|
|
222
|
+
const image = describeImage(presentation, path);
|
|
224
223
|
if (image.error) throw fail("not-croppable", image.error, { path });
|
|
225
224
|
const origin = image.origin ?? image.assetId;
|
|
226
|
-
const id = uniqueAssetId(
|
|
225
|
+
const id = uniqueAssetId(presentation, `${origin ?? "image"}-crop`);
|
|
227
226
|
const title = (isObject(image.entry) && typeof image.entry.title === "string" ? image.entry.title : origin ?? "Picture").replace(/ \(cropped\)$/, "");
|
|
228
227
|
const entry = { src: pixels.dataUri, mediaType: pixels.mediaType, title: `${title} (cropped)` };
|
|
229
228
|
if (image.alt) entry.alt = image.alt;
|
|
230
229
|
if (origin) entry.description = `Cropped from asset:${origin}`;
|
|
231
|
-
const patches = assetPatches(
|
|
230
|
+
const patches = assetPatches(presentation, id, entry);
|
|
232
231
|
patches.push({ op: "replace", path: image.srcPointer, value: `asset:${id}` });
|
|
233
|
-
if (image.origin && image.assetId && countAssetReferences(
|
|
232
|
+
if (image.origin && image.assetId && countAssetReferences(presentation, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
|
|
234
233
|
return { patches, assetId: id, reference: `asset:${id}`, origin, image };
|
|
235
234
|
}
|
|
236
235
|
|
|
237
236
|
/** The patches that put a cropped picture back to the asset it was cropped from, or null when there is none. */
|
|
238
|
-
export function prepareRestore(
|
|
239
|
-
const image = describeImage(
|
|
240
|
-
if (image.error || !image.origin || getValueAtPath(
|
|
237
|
+
export function prepareRestore(presentation, path) {
|
|
238
|
+
const image = describeImage(presentation, path);
|
|
239
|
+
if (image.error || !image.origin || getValueAtPath(presentation, ["assets", image.origin]) === undefined) return null;
|
|
241
240
|
const patches = [{ op: "replace", path: image.srcPointer, value: `asset:${image.origin}` }];
|
|
242
|
-
if (countAssetReferences(
|
|
241
|
+
if (countAssetReferences(presentation, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
|
|
243
242
|
return { patches, origin: image.origin, image };
|
|
244
243
|
}
|
|
245
244
|
|
|
246
245
|
/** Put a cropped picture back to its original as one undoable change. Returns null when there is no original to restore. */
|
|
247
246
|
export function restoreOriginal(editor, path, meta = {}) {
|
|
248
|
-
const prepared = prepareRestore(editor.
|
|
247
|
+
const prepared = prepareRestore(editor.presentation, path);
|
|
249
248
|
if (!prepared) return null;
|
|
250
249
|
return { ...editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? "image-restore", path }), origin: prepared.origin };
|
|
251
250
|
}
|
|
@@ -325,12 +324,12 @@ export async function cropImagePixels(loaded, rect, { mediaType, maxBytes = DEFA
|
|
|
325
324
|
* `{ assetId, reference, width, height }`.
|
|
326
325
|
*/
|
|
327
326
|
export async function applyCrop(editor, path, rect, options = {}) {
|
|
328
|
-
const image = describeImage(editor.
|
|
327
|
+
const image = describeImage(editor.presentation, path);
|
|
329
328
|
if (image.error) throw fail("not-croppable", image.error, { path });
|
|
330
329
|
const loaded = options.loaded ?? await loadImagePixels(image.assetSrc);
|
|
331
330
|
const pixels = await cropImagePixels(loaded, rect, { mediaType: image.mediaType, maxBytes: options.maxBytes });
|
|
332
331
|
// The document may have changed while the picture was encoding.
|
|
333
|
-
const prepared = prepareCrop(editor.
|
|
332
|
+
const prepared = prepareCrop(editor.presentation, path, pixels);
|
|
334
333
|
const change = editor.applyPatch(prepared.patches, { ...options.meta, source: options.meta?.source ?? "image-crop", path, assetId: prepared.assetId });
|
|
335
334
|
return { ...change, assetId: prepared.assetId, reference: prepared.reference, width: pixels.width, height: pixels.height };
|
|
336
335
|
}
|
package/dist/image-cropper.js
CHANGED
|
@@ -121,7 +121,7 @@ export function createImageCropper(root, overlay, options) {
|
|
|
121
121
|
|
|
122
122
|
function placePill() {
|
|
123
123
|
const target = selected && !session ? frameImage(selected) : null;
|
|
124
|
-
if (!target || !editor || describeImage(editor.
|
|
124
|
+
if (!target || !editor || describeImage(editor.presentation, selected).error) { pill.hidden = true; return; }
|
|
125
125
|
const box = target.getBoundingClientRect();
|
|
126
126
|
const base = root.getBoundingClientRect();
|
|
127
127
|
if (!box.width || !box.height) { pill.hidden = true; return; }
|
|
@@ -135,7 +135,7 @@ export function createImageCropper(root, overlay, options) {
|
|
|
135
135
|
async function open(path, openOptions = {}) {
|
|
136
136
|
if (destroyed || session) return false;
|
|
137
137
|
if (options.beforeOpen && !options.beforeOpen()) return false;
|
|
138
|
-
const image = describeImage(editor.
|
|
138
|
+
const image = describeImage(editor.presentation, path);
|
|
139
139
|
if (image.error) {
|
|
140
140
|
options.report?.(new Error(image.error));
|
|
141
141
|
return false;
|
|
@@ -483,7 +483,7 @@ export function createImageCropper(root, overlay, options) {
|
|
|
483
483
|
aspectSelect.querySelector('option[value="frame"]').disabled = !frame;
|
|
484
484
|
s.rect = fullRect(s.loaded.width, s.loaded.height);
|
|
485
485
|
layout();
|
|
486
|
-
restoreButton.hidden = !prepareRestore(editor.
|
|
486
|
+
restoreButton.hidden = !prepareRestore(editor.presentation, path);
|
|
487
487
|
if (s.tool === "focus") {
|
|
488
488
|
s.tool = "crop";
|
|
489
489
|
setTool("focus");
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import type { PaginationOptions, PaginationResult } from "@openpresentation/opf/pagination";
|
|
2
2
|
import type { Composition, ComposeSlideOptions, SlideComposition } from "@openpresentation/opf/composition";
|
|
3
|
-
|
|
4
|
-
export
|
|
5
|
-
|
|
3
|
+
import type { Catalog, Fonts, PresentationStats, SlideContextDiagnostic, StatsOptions, ValidationReport } from "@openpresentation/opf";
|
|
4
|
+
export * from "./catalogs.js";
|
|
5
|
+
/**
|
|
6
|
+
* What the editor adds to the options of `composeSlide` and `paginateSlide`: `fonts` is the renderer's fonts handle (`loadFonts()`), whose
|
|
7
|
+
* `textMeasurement` measures the text; `catalogs` are host catalogs for this call (merged after the session's registered ones);
|
|
8
|
+
* `onDiagnostic` hears every diagnostic of core's `resolveSlideContext` (`unresolved-reference`). Any other
|
|
9
|
+
* option overrides the resolved one.
|
|
10
|
+
*/
|
|
11
|
+
export interface EditorDiagnosticOptions {
|
|
12
|
+
fonts?: Fonts;
|
|
13
|
+
catalogs?: readonly Catalog[];
|
|
14
|
+
onDiagnostic?: (diagnostic: SlideContextDiagnostic) => void;
|
|
15
|
+
}
|
|
6
16
|
export declare const packageName = "@openpresentation/opf-editor";
|
|
7
17
|
|
|
8
18
|
export declare const releaseLane: Readonly<{
|
|
@@ -29,16 +39,9 @@ export type JsonPatchOperation =
|
|
|
29
39
|
| { op: "copy"; from: string; path: string }
|
|
30
40
|
| { op: "test"; path: string; value: unknown };
|
|
31
41
|
|
|
32
|
-
export interface OPFValidationSummary {
|
|
33
|
-
valid: boolean;
|
|
34
|
-
errors: unknown[];
|
|
35
|
-
warnings: unknown[];
|
|
36
|
-
result: unknown;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
42
|
export interface EditorSnapshot {
|
|
40
|
-
|
|
41
|
-
validation:
|
|
43
|
+
presentation: unknown;
|
|
44
|
+
validation: ValidationReport;
|
|
42
45
|
canUndo: boolean;
|
|
43
46
|
canRedo: boolean;
|
|
44
47
|
undoDepth: number;
|
|
@@ -46,19 +49,20 @@ export interface EditorSnapshot {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export interface EditorChange {
|
|
49
|
-
|
|
52
|
+
presentation: unknown;
|
|
50
53
|
patches: JsonPatchOperation[];
|
|
51
54
|
inversePatches?: JsonPatchOperation[];
|
|
52
55
|
redoPatches?: JsonPatchOperation[];
|
|
53
|
-
validation:
|
|
56
|
+
validation: ValidationReport;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface EditorEvent {
|
|
57
|
-
|
|
60
|
+
/** `catalogs`: the registered catalogs changed (`setCatalogs`); the document did not. */
|
|
61
|
+
type: "patch" | "undo" | "redo" | "restore" | "catalogs";
|
|
58
62
|
patches: JsonPatchOperation[];
|
|
59
63
|
inversePatches?: JsonPatchOperation[];
|
|
60
64
|
redoPatches?: JsonPatchOperation[];
|
|
61
|
-
validation:
|
|
65
|
+
validation: ValidationReport;
|
|
62
66
|
meta?: Record<string, unknown>;
|
|
63
67
|
snapshot: EditorSnapshot;
|
|
64
68
|
}
|
|
@@ -70,8 +74,13 @@ export interface EditorSession {
|
|
|
70
74
|
paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
|
|
71
75
|
composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
|
|
72
76
|
setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
|
|
73
|
-
readonly
|
|
74
|
-
|
|
77
|
+
readonly presentation: unknown;
|
|
78
|
+
/** The `format` report of the document plus the catalog reference rules, checked against the registered catalogs. */
|
|
79
|
+
readonly validation: ValidationReport;
|
|
80
|
+
/** The host's registered catalogs, frozen; the first is the host default. */
|
|
81
|
+
readonly catalogs: readonly Catalog[];
|
|
82
|
+
/** Replace the registered catalogs: re-validates the document and emits one `catalogs` event. */
|
|
83
|
+
setCatalogs(catalogs: readonly Catalog[] | undefined, meta?: Record<string, unknown>): readonly Catalog[];
|
|
75
84
|
readonly canUndo: boolean;
|
|
76
85
|
readonly canRedo: boolean;
|
|
77
86
|
snapshot(): EditorSnapshot;
|
|
@@ -84,26 +93,36 @@ export interface EditorSession {
|
|
|
84
93
|
/** The undo and redo stacks as plain data (oldest entry first), for hosts that persist work. */
|
|
85
94
|
exportHistory(): EditorHistory;
|
|
86
95
|
/** Replace the document and, optionally, the history in one step (one `restore` event). A history that does not replay against the document throws `invalid-history` before anything changes. */
|
|
87
|
-
restoreState(state: {
|
|
96
|
+
restoreState(state: { presentation: unknown; undo?: EditorHistoryEntry[]; redo?: EditorHistoryEntry[] }, meta?: Record<string, unknown>): EditorChange;
|
|
88
97
|
undo(meta?: Record<string, unknown>): EditorChange | null;
|
|
89
98
|
redo(meta?: Record<string, unknown>): EditorChange | null;
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
export interface CreateEditorSessionOptions {
|
|
93
|
-
|
|
102
|
+
/** Refuse every edit whose result has an error finding (the default per edit is `meta.rejectInvalid`). */
|
|
94
103
|
rejectInvalid?: boolean;
|
|
104
|
+
/** The host's catalogs (for example `[defaultCatalog]` from `@openpresentation/opf/catalog`): resolution, validation, pickers, embedding. */
|
|
105
|
+
catalogs?: readonly Catalog[];
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
export interface CatalogOption {
|
|
109
|
+
/** The reference to write: `id` or `name:id`. */
|
|
98
110
|
id: string;
|
|
111
|
+
reference: string;
|
|
99
112
|
label: string;
|
|
113
|
+
description?: string;
|
|
100
114
|
record: Record<string, unknown>;
|
|
115
|
+
/** The catalog group the record resolves in (`custom`, `default` or a named group). */
|
|
116
|
+
group: string;
|
|
117
|
+
source?: string;
|
|
118
|
+
origin: "document" | "host";
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
export interface CatalogOptionsInput {
|
|
104
122
|
presentation?: { catalogs?: Record<string, unknown> } | Record<string, unknown>;
|
|
105
|
-
catalogs
|
|
106
|
-
|
|
123
|
+
/** Host catalogs, merged after `editor`'s registered ones. */
|
|
124
|
+
catalogs?: readonly Catalog[];
|
|
125
|
+
editor?: EditorSession;
|
|
107
126
|
[key: string]: unknown;
|
|
108
127
|
}
|
|
109
128
|
|
|
@@ -160,20 +179,18 @@ export declare function opfPathToJsonPointer(path: string | string[]): string;
|
|
|
160
179
|
|
|
161
180
|
export declare function jsonPointerToOpfPath(pointer: string): string;
|
|
162
181
|
|
|
163
|
-
export declare function getValueAtPath(
|
|
164
|
-
|
|
165
|
-
export declare function hasValueAtPath(document: unknown, path: string | string[]): boolean;
|
|
182
|
+
export declare function getValueAtPath(presentation: unknown, path: string | string[], fallback?: unknown): unknown;
|
|
166
183
|
|
|
167
|
-
export declare function
|
|
184
|
+
export declare function hasValueAtPath(presentation: unknown, path: string | string[]): boolean;
|
|
168
185
|
|
|
169
|
-
export declare function
|
|
186
|
+
export declare function createValuePatch(presentation: unknown, path: string | string[], value: unknown): JsonPatchOperation[];
|
|
170
187
|
|
|
171
|
-
export declare function
|
|
188
|
+
export declare function applyJsonPatch(presentation: unknown, operations: JsonPatchOperation[]): unknown;
|
|
172
189
|
|
|
173
|
-
export declare function
|
|
190
|
+
export declare function invertJsonPatch(presentation: unknown, operations: JsonPatchOperation[]): JsonPatchOperation[];
|
|
174
191
|
|
|
175
|
-
/**
|
|
176
|
-
export declare function
|
|
192
|
+
/** Core's `stats` of the session's document: neutral facts (counts, words, notes coverage, images and alt text, speaking time), never severities. Reads the JSON only. */
|
|
193
|
+
export declare function deckStats(editor: EditorSession, options?: StatsOptions): PresentationStats;
|
|
177
194
|
|
|
178
195
|
export declare function createEditorSession(input: unknown, options?: CreateEditorSessionOptions): EditorSession;
|
|
179
196
|
|