@openpresentation/opf-editor 0.10.6 → 0.11.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.
Files changed (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
@@ -0,0 +1,336 @@
1
+ // Image crop and focal point (RR-25): the model.
2
+ //
3
+ // The OPF schema has no crop rectangle and no focal point on an image (`Asset` is `src`, `alt`, `title`, `description`,
4
+ // `mediaType`, `format`; the only fit controls are `design.imageFill` and a slide image's `fill`, both "crop" or "fit",
5
+ // and a crop is always centred). `crop` and `focalPoint` are listed as deferred fields in core's
6
+ // docs/content-item-design-overrides.md. So the editor writes a crop into the picture itself: the cropped pixels become a
7
+ // new entry of `assets` and the image points at it, in ONE undoable change. Because the preview and the PPTX export both
8
+ // read those pixels, they cannot disagree about what is shown; the fit the document already has (centred "crop" cover, or
9
+ // "fit") then places the cropped picture in its frame exactly as before (opf-pptx writes the usual `a:srcRect` for that
10
+ // placement, and none for a picture whose shape matches its frame).
11
+ //
12
+ // 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
+ // fit shows what was chosen. The original asset stays in `assets`, and the new asset's `description` says "Cropped from
14
+ // asset:<id>", which is how "Restore original" finds it. This is the editor's way of working inside the current schema, not
15
+ // a new OPF field.
16
+ //
17
+ // This module is the pure part (rectangle maths, the patch that applies a crop) plus `cropImagePixels`, which needs a
18
+ // browser canvas. `image-cropper.js` is the DOM that drives it.
19
+ import { OPFEditorError, getValueAtPath, opfPathToJsonPointer } from "./index.js";
20
+ import { DEFAULT_MAX_IMAGE_BYTES, assetIdOf, imageDataUri, sniffImageType, uniqueAssetId } from "./assets.js";
21
+
22
+ /** The smallest crop side, in source pixels. */
23
+ export const MIN_CROP_SIDE = 8;
24
+ const PROVENANCE = /^Cropped from asset:(.+)$/;
25
+
26
+ const fail = (code, message, details) => new OPFEditorError(code, message, details);
27
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
28
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), Math.max(low, high));
29
+
30
+ /** Aspect choices of the crop tool. `ratio` is width / height; `free` and the frame/original ones have none fixed here. */
31
+ export const CROP_ASPECTS = Object.freeze([
32
+ { id: "free", label: "Free" },
33
+ { id: "original", label: "Original" },
34
+ { id: "frame", label: "Frame" },
35
+ { id: "1:1", label: "Square 1:1", ratio: 1 },
36
+ { id: "4:3", label: "4:3", ratio: 4 / 3 },
37
+ { id: "3:2", label: "3:2", ratio: 3 / 2 },
38
+ { id: "16:9", label: "16:9", ratio: 16 / 9 },
39
+ { id: "3:4", label: "Portrait 3:4", ratio: 3 / 4 },
40
+ { id: "2:3", label: "Portrait 2:3", ratio: 2 / 3 },
41
+ { id: "9:16", label: "Portrait 9:16", ratio: 9 / 16 },
42
+ ]);
43
+
44
+ /** The ratio of an aspect id for an image and frame, or undefined for "free". */
45
+ export function aspectRatioFor(id, { width, height, frame } = {}) {
46
+ if (id === "free" || id === undefined) return undefined;
47
+ if (id === "original") return width / height;
48
+ if (id === "frame") return frame > 0 ? frame : undefined;
49
+ return CROP_ASPECTS.find((aspect) => aspect.id === id)?.ratio;
50
+ }
51
+
52
+ // ---- rectangle maths (source pixels; rect is { x, y, width, height }) -------------------------------------------------
53
+
54
+ /** The whole image. */
55
+ export const fullRect = (width, height) => ({ x: 0, y: 0, width, height });
56
+
57
+ /** Keep a rectangle inside the image and at least `min` on each side. */
58
+ export function clampRect(rect, bounds, min = MIN_CROP_SIDE) {
59
+ const width = clamp(rect.width, Math.min(min, bounds.width), bounds.width);
60
+ const height = clamp(rect.height, Math.min(min, bounds.height), bounds.height);
61
+ return { x: clamp(rect.x, 0, bounds.width - width), y: clamp(rect.y, 0, bounds.height - height), width, height };
62
+ }
63
+
64
+ /** Move a rectangle by a distance, staying inside the image. */
65
+ export function moveRect(rect, dx, dy, bounds) {
66
+ return { ...rect, x: clamp(rect.x + dx, 0, bounds.width - rect.width), y: clamp(rect.y + dy, 0, bounds.height - rect.height) };
67
+ }
68
+
69
+ /**
70
+ * Drag one handle of a crop rectangle to `point` (a source-pixel position). `handle` is `n`, `ne`, `e`, `se`, `s`, `sw`, `w`
71
+ * or `nw`. With `aspect` (width / height) the ratio is kept: a corner follows the pointer along the diagonal and an edge
72
+ * resizes about the rectangle's centre on the other axis. The result stays inside `bounds` and keeps at least `min`.
73
+ */
74
+ export function resizeRect(start, handle, point, bounds, { aspect, min = MIN_CROP_SIDE } = {}) {
75
+ const dx = handle.includes("e") ? 1 : handle.includes("w") ? -1 : 0;
76
+ const dy = handle.includes("s") ? 1 : handle.includes("n") ? -1 : 0;
77
+ const right = start.x + start.width;
78
+ const bottom = start.y + start.height;
79
+ let left = dx < 0 ? clamp(point.x, 0, right - min) : start.x;
80
+ let top = dy < 0 ? clamp(point.y, 0, bottom - min) : start.y;
81
+ let farRight = dx > 0 ? clamp(point.x, start.x + min, bounds.width) : right;
82
+ let farBottom = dy > 0 ? clamp(point.y, start.y + min, bounds.height) : bottom;
83
+ if (!aspect) return { x: left, y: top, width: farRight - left, height: farBottom - top };
84
+ const minWidth = Math.max(min, min * aspect);
85
+ if (dx !== 0 && dy !== 0) {
86
+ const roomWidth = dx > 0 ? bounds.width - start.x : right;
87
+ const roomHeight = dy > 0 ? bounds.height - start.y : bottom;
88
+ const maxWidth = Math.min(roomWidth, roomHeight * aspect);
89
+ const width = clamp(Math.max(farRight - left, (farBottom - top) * aspect), Math.min(minWidth, maxWidth), maxWidth);
90
+ const height = width / aspect;
91
+ return { x: dx > 0 ? start.x : right - width, y: dy > 0 ? start.y : bottom - height, width, height };
92
+ }
93
+ if (dx !== 0) {
94
+ const centerY = start.y + start.height / 2;
95
+ const roomWidth = dx > 0 ? bounds.width - start.x : right;
96
+ const maxWidth = Math.min(roomWidth, 2 * Math.min(centerY, bounds.height - centerY) * aspect);
97
+ const width = clamp(farRight - left, Math.min(minWidth, maxWidth), maxWidth);
98
+ const height = width / aspect;
99
+ return { x: dx > 0 ? start.x : right - width, y: centerY - height / 2, width, height };
100
+ }
101
+ const centerX = start.x + start.width / 2;
102
+ const roomHeight = dy > 0 ? bounds.height - start.y : bottom;
103
+ const maxHeight = Math.min(roomHeight, (2 * Math.min(centerX, bounds.width - centerX)) / aspect);
104
+ const height = clamp(farBottom - top, Math.min(min, maxHeight), maxHeight);
105
+ const width = height * aspect;
106
+ return { x: centerX - width / 2, y: dy > 0 ? start.y : bottom - height, width, height };
107
+ }
108
+
109
+ /** The largest rectangle with `aspect` that fits inside `rect`, centred on it. */
110
+ export function fitAspect(rect, aspect) {
111
+ if (!aspect) return rect;
112
+ let width = rect.width;
113
+ let height = width / aspect;
114
+ if (height > rect.height) {
115
+ height = rect.height;
116
+ width = height * aspect;
117
+ }
118
+ return { x: rect.x + (rect.width - width) / 2, y: rect.y + (rect.height - height) / 2, width, height };
119
+ }
120
+
121
+ /**
122
+ * The crop a focal point asks for: the largest window of the frame's `aspect` (width / height) in the image, divided by
123
+ * `zoom` (1 or more), centred on `focal` (`{ x, y }` as fractions of the image) and kept inside the image.
124
+ */
125
+ export function focalWindow(bounds, aspect, focal, zoom = 1) {
126
+ const whole = fitAspect(fullRect(bounds.width, bounds.height), aspect);
127
+ const scale = 1 / Math.max(1, zoom);
128
+ const width = Math.max(Math.min(MIN_CROP_SIDE, bounds.width), whole.width * scale);
129
+ const height = Math.max(Math.min(MIN_CROP_SIDE, bounds.height), whole.height * scale);
130
+ return {
131
+ x: clamp(focal.x * bounds.width - width / 2, 0, bounds.width - width),
132
+ y: clamp(focal.y * bounds.height - height / 2, 0, bounds.height - height),
133
+ width,
134
+ height,
135
+ };
136
+ }
137
+
138
+ /** The centre of a crop rectangle as fractions of the image: the focal point it shows. */
139
+ export const focalPointOf = (rect, bounds) => ({ x: (rect.x + rect.width / 2) / bounds.width, y: (rect.y + rect.height / 2) / bounds.height });
140
+
141
+ /** Whole source pixels, at least 1 by 1, inside the image. */
142
+ export function roundRect(rect, bounds) {
143
+ const x = clamp(Math.round(rect.x), 0, bounds.width - 1);
144
+ const y = clamp(Math.round(rect.y), 0, bounds.height - 1);
145
+ return { x, y, width: clamp(Math.round(rect.width), 1, bounds.width - x), height: clamp(Math.round(rect.height), 1, bounds.height - y) };
146
+ }
147
+
148
+ /** True when the rectangle is the whole image (to within half a pixel). */
149
+ export const isFullRect = (rect, bounds) => rect.x < 0.5 && rect.y < 0.5 && Math.abs(rect.width - bounds.width) < 0.5 && Math.abs(rect.height - bounds.height) < 0.5;
150
+
151
+ // ---- locating and reading an image field ------------------------------------------------------------------------------
152
+
153
+ /**
154
+ * What a path holds when it is a picture: `{ path, pointer, form, srcPointer, src, assetId, assetSrc, alt, mediaType, origin }`
155
+ * or `{ error }` with the reason it is not a croppable picture. `form` is "string" (the field is the source) or "object"
156
+ * (the field is `{ src, ... }`); `assetSrc` is what `src` resolves to (the asset's own `src`), and `origin` is the asset a
157
+ * crop of this picture was made from, when there is one.
158
+ */
159
+ export function describeImage(document, path) {
160
+ const segments = String(path).split(".");
161
+ const last = segments.at(-1);
162
+ if (!["image", "slideImage"].includes(last)) return { error: "This is not a picture." };
163
+ const value = getValueAtPath(document, path);
164
+ const pointer = opfPathToJsonPointer(segments);
165
+ let form;
166
+ let src;
167
+ let alt;
168
+ if (typeof value === "string") {
169
+ form = "string";
170
+ src = value;
171
+ } else if (isObject(value) && typeof value.src === "string") {
172
+ form = "object";
173
+ src = value.src;
174
+ alt = typeof value.alt === "string" ? value.alt : undefined;
175
+ } else return { error: "This picture has no source to crop." };
176
+ const assetId = assetIdOf(src);
177
+ let assetSrc = src;
178
+ let entry;
179
+ if (assetId !== undefined) {
180
+ entry = getValueAtPath(document, ["assets", assetId]);
181
+ if (entry === undefined) return { error: `The picture asset "${assetId}" is missing from this presentation.` };
182
+ assetSrc = typeof entry === "string" ? entry : entry?.src;
183
+ if (typeof assetSrc !== "string") return { error: `The picture asset "${assetId}" has no source.` };
184
+ if (typeof entry === "object" && alt === undefined && typeof entry.alt === "string") alt = entry.alt;
185
+ }
186
+ const dataType = /^data:([^;,]+)/.exec(assetSrc)?.[1];
187
+ const mediaType = (isObject(entry) && entry.mediaType) || dataType;
188
+ const origin = isObject(entry) && typeof entry.description === "string" ? PROVENANCE.exec(entry.description)?.[1] : undefined;
189
+ if (mediaType === "image/svg+xml" || /\.svg(?:[?#].*)?$/i.test(assetSrc)) {
190
+ return { error: "A vector (SVG) picture has no pixels to crop; it stays sharp at any size. Use Fit instead, or replace it with a PNG or JPEG." };
191
+ }
192
+ return { path, pointer, form, srcPointer: form === "object" ? `${pointer}/src` : pointer, src, assetId, assetSrc, alt, mediaType, origin, entry };
193
+ }
194
+
195
+ /** How many places in the document use `asset:<id>` (the `assets` map itself is not counted). */
196
+ export function countAssetReferences(document, id) {
197
+ const reference = `asset:${id}`;
198
+ let count = 0;
199
+ const walk = (value) => {
200
+ if (typeof value === "string") count += value === reference ? 1 : 0;
201
+ 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 !== document) walk(child);
203
+ };
204
+ walk(document);
205
+ return count;
206
+ }
207
+
208
+ // ---- the change --------------------------------------------------------------------------------------------------------
209
+
210
+ function assetPatches(document, id, entry) {
211
+ const assets = document?.assets;
212
+ return isObject(assets)
213
+ ? [{ op: "add", path: opfPathToJsonPointer(["assets", id]), value: entry }]
214
+ : [{ op: "add", path: "/assets", value: { [id]: entry } }];
215
+ }
216
+
217
+ /**
218
+ * The patches that apply a cropped picture: a new asset (`{ src, mediaType, title, alt?, description }`) and the image
219
+ * pointing at it. `pixels` is `{ dataUri, mediaType, width, height }` (from `cropImagePixels`). A previous crop's asset that
220
+ * nothing else uses is removed in the same change. Pure.
221
+ */
222
+ export function prepareCrop(document, path, pixels) {
223
+ const image = describeImage(document, path);
224
+ if (image.error) throw fail("not-croppable", image.error, { path });
225
+ const origin = image.origin ?? image.assetId;
226
+ const id = uniqueAssetId(document, `${origin ?? "image"}-crop`);
227
+ const title = (isObject(image.entry) && typeof image.entry.title === "string" ? image.entry.title : origin ?? "Picture").replace(/ \(cropped\)$/, "");
228
+ const entry = { src: pixels.dataUri, mediaType: pixels.mediaType, title: `${title} (cropped)` };
229
+ if (image.alt) entry.alt = image.alt;
230
+ if (origin) entry.description = `Cropped from asset:${origin}`;
231
+ const patches = assetPatches(document, id, entry);
232
+ patches.push({ op: "replace", path: image.srcPointer, value: `asset:${id}` });
233
+ if (image.origin && image.assetId && countAssetReferences(document, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
234
+ return { patches, assetId: id, reference: `asset:${id}`, origin, image };
235
+ }
236
+
237
+ /** 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(document, path) {
239
+ const image = describeImage(document, path);
240
+ if (image.error || !image.origin || getValueAtPath(document, ["assets", image.origin]) === undefined) return null;
241
+ const patches = [{ op: "replace", path: image.srcPointer, value: `asset:${image.origin}` }];
242
+ if (countAssetReferences(document, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
243
+ return { patches, origin: image.origin, image };
244
+ }
245
+
246
+ /** Put a cropped picture back to its original as one undoable change. Returns null when there is no original to restore. */
247
+ export function restoreOriginal(editor, path, meta = {}) {
248
+ const prepared = prepareRestore(editor.document, path);
249
+ if (!prepared) return null;
250
+ return { ...editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? "image-restore", path }), origin: prepared.origin };
251
+ }
252
+
253
+ // ---- pixels (browser) --------------------------------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Load an image's pixels: `{ image, width, height }`. Rejects with a readable `image-unreadable` error. A picture hosted on
257
+ * another origin may be refused by the browser when its pixels are read, which `cropImagePixels` reports.
258
+ */
259
+ export async function loadImagePixels(src, { document: doc = globalThis.document, signal } = {}) {
260
+ const win = doc.defaultView ?? globalThis;
261
+ const attempt = (anonymous) => new Promise((resolve, reject) => {
262
+ const image = new win.Image();
263
+ if (anonymous) image.crossOrigin = "anonymous";
264
+ image.decoding = "async";
265
+ image.onload = () => resolve(image);
266
+ image.onerror = () => reject(fail("image-unreadable", "The picture could not be loaded. If it is hosted elsewhere, upload it to the presentation first.", { src: src.slice(0, 80) }));
267
+ signal?.addEventListener("abort", () => { image.src = ""; }, { once: true });
268
+ image.src = src;
269
+ });
270
+ // A picture on another site is read with CORS first (so its pixels can be cut); without it the picture still shows and
271
+ // cropImagePixels reports why it cannot be cut.
272
+ const hosted = !/^(?:data|blob):/.test(src);
273
+ const image = hosted ? await attempt(true).catch(() => attempt(false)) : await attempt(false);
274
+ if (!image.naturalWidth || !image.naturalHeight) throw fail("image-unreadable", "The picture has no size.", {});
275
+ return { image, width: image.naturalWidth, height: image.naturalHeight };
276
+ }
277
+
278
+ const toBlob = (canvas, type, quality) => new Promise((resolve) => canvas.toBlob(resolve, type, quality));
279
+
280
+ function hasTransparency(context, width, height) {
281
+ const data = context.getImageData(0, 0, width, height).data;
282
+ for (let index = 3; index < data.length; index += 4) if (data[index] < 255) return true;
283
+ return false;
284
+ }
285
+
286
+ /**
287
+ * Cut a rectangle (source pixels) out of a loaded picture at its own resolution. JPEG stays JPEG (quality 0.92); every
288
+ * other type becomes PNG so transparency survives. A PNG over `maxBytes` with no transparency is saved as JPEG instead.
289
+ * Returns `{ dataUri, mediaType, width, height, bytes }` (bytes is the byte count) or throws `image-too-large` or
290
+ * `image-unreadable` (a cross-origin picture the browser will not let a script read).
291
+ */
292
+ export async function cropImagePixels(loaded, rect, { mediaType, maxBytes = DEFAULT_MAX_IMAGE_BYTES, document: doc = globalThis.document } = {}) {
293
+ const bounds = { width: loaded.width, height: loaded.height };
294
+ const box = roundRect(rect, bounds);
295
+ const canvas = doc.createElement("canvas");
296
+ canvas.width = box.width;
297
+ canvas.height = box.height;
298
+ const context = canvas.getContext("2d", { willReadFrequently: true });
299
+ context.drawImage(loaded.image, box.x, box.y, box.width, box.height, 0, 0, box.width, box.height);
300
+ const wantJpeg = mediaType === "image/jpeg";
301
+ let blob;
302
+ let outType = wantJpeg ? "image/jpeg" : "image/png";
303
+ try {
304
+ blob = await toBlob(canvas, outType, wantJpeg ? 0.92 : undefined);
305
+ if (blob && !wantJpeg && blob.size > maxBytes) {
306
+ // A photo saved as PNG can be several times the size of its JPEG; use JPEG when nothing is transparent.
307
+ if (!hasTransparency(context, box.width, box.height)) {
308
+ const alternative = await toBlob(canvas, "image/jpeg", 0.92);
309
+ if (alternative && alternative.size < blob.size) { blob = alternative; outType = "image/jpeg"; }
310
+ }
311
+ }
312
+ } catch (error) {
313
+ throw fail("image-unreadable", "The browser does not let the editor read this picture's pixels (it is hosted on another site). Upload it to the presentation, then crop it.", { cause: String(error?.message ?? error) });
314
+ }
315
+ if (!blob) throw fail("image-unreadable", "The cropped picture could not be encoded.", {});
316
+ if (blob.size > maxBytes) throw fail("image-too-large", `The cropped picture is ${(blob.size / 1048576).toFixed(1)} MB, over the ${(maxBytes / 1048576).toFixed(1)} MB limit. Crop a smaller area.`, { size: blob.size, maxBytes });
317
+ const bytes = new Uint8Array(await blob.arrayBuffer());
318
+ const sniffed = sniffImageType(bytes) ?? outType;
319
+ return { dataUri: imageDataUri(bytes, sniffed), mediaType: sniffed, width: box.width, height: box.height, bytes: bytes.length };
320
+ }
321
+
322
+ /**
323
+ * Crop a picture of the document and apply it as one undoable change. `rect` is in source pixels of the picture as it is
324
+ * now. Options: `loaded` (an already loaded `loadImagePixels` result), `maxBytes`, `meta`. Returns the session's change plus
325
+ * `{ assetId, reference, width, height }`.
326
+ */
327
+ export async function applyCrop(editor, path, rect, options = {}) {
328
+ const image = describeImage(editor.document, path);
329
+ if (image.error) throw fail("not-croppable", image.error, { path });
330
+ const loaded = options.loaded ?? await loadImagePixels(image.assetSrc);
331
+ const pixels = await cropImagePixels(loaded, rect, { mediaType: image.mediaType, maxBytes: options.maxBytes });
332
+ // The document may have changed while the picture was encoding.
333
+ const prepared = prepareCrop(editor.document, path, pixels);
334
+ const change = editor.applyPatch(prepared.patches, { ...options.meta, source: options.meta?.source ?? "image-crop", path, assetId: prepared.assetId });
335
+ return { ...change, assetId: prepared.assetId, reference: prepared.reference, width: pixels.width, height: pixels.height };
336
+ }
@@ -0,0 +1,29 @@
1
+ import type { EditorSession } from "./index.js";
2
+
3
+ export interface ImageCropperOptions {
4
+ editor: EditorSession;
5
+ /** The traced `<image>` of the picture at a path (its width and height give the frame's shape; its position places the button). */
6
+ getImageElement?: (path: string) => Element | null;
7
+ /** Finish an inline edit first; return false to stop. */
8
+ beforeOpen?: () => boolean;
9
+ onCommit?: (event: { path: string; assetId?: string; restored?: boolean }) => void;
10
+ onCancel?: () => void;
11
+ report?: (error: Error) => void;
12
+ /** Force the full-screen layer (true) or the in-canvas one (false). Default: full screen on a screen at most 900px wide or a canvas shorter than 460px. */
13
+ fullscreen?: boolean;
14
+ }
15
+ export interface ImageCropper {
16
+ /** Open the crop layer for the picture at `path` (`{ tool: "focus" }` starts with the focal point). Resolves to whether it opened. */
17
+ open(path: string, options?: { tool?: "crop" | "focus" }): Promise<boolean>;
18
+ readonly isOpen: boolean;
19
+ readonly path: string | null;
20
+ cancel(): void;
21
+ /** The canvas selected `path`: show the Crop picture button when it is a picture. */
22
+ sync(path: string | null): void;
23
+ /** The canvas redrew: put the button back on its picture. */
24
+ update(): void;
25
+ readonly pill: HTMLButtonElement;
26
+ destroy(): void;
27
+ }
28
+ /** The in-canvas crop tool: aspect lock, handles, focal point, exact numbers, keyboard and touch. Apply is one undoable change (see `image-crop`). */
29
+ export declare function createImageCropper(root: HTMLElement, overlay: HTMLElement, options: ImageCropperOptions): ImageCropper;