@streamscloud/kit 0.84.0 → 0.85.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.
@@ -28,10 +28,11 @@ export declare class FileHelper {
28
28
  * Checks a file's real type against its declared one. The bytes identify images (PNG, JPEG, WebP, GIF, AVIF, HEIC, SVG),
29
29
  * MP4, audio (WAV, MP3, M4A, AAC, FLAC), PDF, DOCX, XLSX, ZIP and fonts (WOFF2, WOFF, TTF, OTF); other formats are typed
30
30
  * as declared. An equivalent extension (`.jpeg` for `jpg`) and any member of the declared ISO media container family
31
- * (`.m4a` holding an `isom` MP4, `.heif` for HEIC) is a match, as is any ZIP archive named `.zip`. When the name lacks
32
- * the right extension, it is appended and the original name stays intact: `photo.jpg` holding a PNG → `photo.jpg.png`.
33
- * Binary formats read only the head; SVG is looked for only when something is declared, and a markup-looking file is
34
- * then read whole to confirm an SVG root.
31
+ * (`.m4a` holding an `isom` MP4, `.heif` for HEIC) is a match, as is a declared ZIP, PNG or font whose header carries its
32
+ * format's signature (any ZIP, APNG, Apple `true` TrueType, WOFF / WOFF2 of any flavor). When the name lacks the right
33
+ * extension, it is appended and the original name stays intact: `photo.jpg` holding a PNG → `photo.jpg.png`. Binary
34
+ * formats read only the head; SVG is looked for only when something is declared, and a markup-looking file is then read
35
+ * whole to confirm an SVG root.
35
36
  */
36
37
  static checkType(file: File): Promise<FileTypeCheck>;
37
38
  static truncateFileName(fileName: string, maxNameLength: number | null | undefined): string;
@@ -23,17 +23,19 @@ const CONTENT_DETECTABLE_EXTENSIONS = new Set([
23
23
  'otf'
24
24
  ]);
25
25
  const PREFERRED_EXTENSIONS = { 'video/quicktime': 'mov', 'audio/mpeg': 'mp3', 'audio/aac': 'aac', 'audio/flac': 'flac' };
26
+ const PREFERRED_TYPES = { flac: 'audio/flac' };
27
+ const typeOf = (extension) => PREFERRED_TYPES[extension] ?? mime.getType(extension);
26
28
  const SVG_TYPE = { mime: 'image/svg+xml', extension: 'svg' };
27
29
  const ISO_MEDIA_CONTAINERS = [new Set(['mp4', 'm4v', 'm4a', 'm4b', 'mov', '3gp', '3g2', 'f4v']), new Set(['heic', 'heif'])];
28
- const CONTENT_DETECTABLE_MIMES = new Set([...CONTENT_DETECTABLE_EXTENSIONS, 'svg', ...ISO_MEDIA_CONTAINERS.flatMap((family) => [...family])].map((extension) => mime.getType(extension)));
30
+ const CONTENT_DETECTABLE_MIMES = new Set([...CONTENT_DETECTABLE_EXTENSIONS, 'svg', ...ISO_MEDIA_CONTAINERS.flatMap((family) => [...family])].map(typeOf));
29
31
  const sameFormat = (extensionA, extensionB) => {
30
- const type = mime.getType(extensionA);
31
- return type !== null && type === mime.getType(extensionB);
32
+ const type = typeOf(extensionA);
33
+ return type !== null && type === typeOf(extensionB);
32
34
  };
33
35
  const sameContainer = (extensionA, extensionB) => ISO_MEDIA_CONTAINERS.some((family) => family.has(extensionA) && family.has(extensionB));
34
36
  const declaredType = (file) => {
35
37
  const extension = FileHelper.getFileExtension(file.name).toLowerCase();
36
- const fromName = extension ? mime.getType(extension) : null;
38
+ const fromName = extension ? typeOf(extension) : null;
37
39
  if (fromName) {
38
40
  return { mime: fromName, extension, fromName: true };
39
41
  }
@@ -47,9 +49,28 @@ const startsWithMarkup = async (blob) => {
47
49
  return head.trimStart().startsWith('<');
48
50
  };
49
51
  const isSvgDocument = async (blob) => (await startsWithMarkup(blob)) && HtmlHelper.sanitizeSvg(await blob.text()) !== null;
50
- const isZipArchive = async (blob) => (await blob.slice(0, 4).text()) === 'PK\u0003\u0004';
52
+ const DECLARED_SIGNATURES = {
53
+ zip: [[0x50, 0x4b, 0x03, 0x04]],
54
+ png: [[0x89, 0x50, 0x4e, 0x47]],
55
+ ttf: [
56
+ [0x00, 0x01, 0x00, 0x00],
57
+ [0x74, 0x72, 0x75, 0x65]
58
+ ],
59
+ otf: [[0x4f, 0x54, 0x54, 0x4f]],
60
+ woff: [[0x77, 0x4f, 0x46, 0x46]],
61
+ woff2: [[0x77, 0x4f, 0x46, 0x32]]
62
+ };
63
+ const SIGNATURE_BYTES = 4;
64
+ const hasDeclaredSignature = async (blob, extension) => {
65
+ const signatures = DECLARED_SIGNATURES[extension];
66
+ if (!signatures) {
67
+ return false;
68
+ }
69
+ const head = new Uint8Array(await blob.slice(0, SIGNATURE_BYTES).arrayBuffer());
70
+ return signatures.some((signature) => signature.every((byte, index) => head[index] === byte));
71
+ };
51
72
  const contentType = async (blob, declared) => {
52
- if (declared?.extension === 'zip' && (await isZipArchive(blob))) {
73
+ if (declared && (await hasDeclaredSignature(blob, declared.extension))) {
53
74
  return declared;
54
75
  }
55
76
  const { fileTypeFromBlob } = await import('file-type');
@@ -57,7 +78,7 @@ const contentType = async (blob, declared) => {
57
78
  if (detected && declared && sameContainer(detected.ext, declared.extension)) {
58
79
  return declared;
59
80
  }
60
- const detectedMime = detected && CONTENT_DETECTABLE_EXTENSIONS.has(detected.ext) ? mime.getType(detected.ext) : null;
81
+ const detectedMime = detected && CONTENT_DETECTABLE_EXTENSIONS.has(detected.ext) ? typeOf(detected.ext) : null;
61
82
  if (detected && detectedMime) {
62
83
  return { mime: detectedMime, extension: detected.ext };
63
84
  }
@@ -83,10 +104,11 @@ export class FileHelper {
83
104
  * Checks a file's real type against its declared one. The bytes identify images (PNG, JPEG, WebP, GIF, AVIF, HEIC, SVG),
84
105
  * MP4, audio (WAV, MP3, M4A, AAC, FLAC), PDF, DOCX, XLSX, ZIP and fonts (WOFF2, WOFF, TTF, OTF); other formats are typed
85
106
  * as declared. An equivalent extension (`.jpeg` for `jpg`) and any member of the declared ISO media container family
86
- * (`.m4a` holding an `isom` MP4, `.heif` for HEIC) is a match, as is any ZIP archive named `.zip`. When the name lacks
87
- * the right extension, it is appended and the original name stays intact: `photo.jpg` holding a PNG → `photo.jpg.png`.
88
- * Binary formats read only the head; SVG is looked for only when something is declared, and a markup-looking file is
89
- * then read whole to confirm an SVG root.
107
+ * (`.m4a` holding an `isom` MP4, `.heif` for HEIC) is a match, as is a declared ZIP, PNG or font whose header carries its
108
+ * format's signature (any ZIP, APNG, Apple `true` TrueType, WOFF / WOFF2 of any flavor). When the name lacks the right
109
+ * extension, it is appended and the original name stays intact: `photo.jpg` holding a PNG → `photo.jpg.png`. Binary
110
+ * formats read only the head; SVG is looked for only when something is declared, and a markup-looking file is then read
111
+ * whole to confirm an SVG root.
90
112
  */
91
113
  static async checkType(file) {
92
114
  const declared = declaredType(file);
@@ -101,7 +123,7 @@ export class FileHelper {
101
123
  if (!declared) {
102
124
  return { status: 'unknown' };
103
125
  }
104
- if (CONTENT_DETECTABLE_MIMES.has(mime.getType(declared.extension))) {
126
+ if (CONTENT_DETECTABLE_MIMES.has(typeOf(declared.extension))) {
105
127
  return { status: 'contradicted', declaredExtension: declared.extension };
106
128
  }
107
129
  return { status: 'unverifiable', file: retyped(file, declared.fromName ? file.name : appended(declared.extension), declared.mime) };
@@ -9,17 +9,22 @@ const loc = new ImageEditorDialogLocalization();
9
9
  const cropper = untrack(() => new ImgCropper({
10
10
  mode: data.mode ?? 'cover',
11
11
  aspectRatio: data.aspectRatio,
12
- fillColor: data.fillColor
12
+ fillColor: data.fillColor,
13
+ tools: data.tools
13
14
  }));
14
15
  const url = $derived(data.url);
15
16
  const save = async () => {
16
17
  try {
17
18
  const result = await cropper.save();
19
+ // Closed mid-save: destroy() already ran, and a file made now would leak its blob URL.
20
+ if (!cropper.ready) {
21
+ return;
22
+ }
18
23
  if (!result) {
19
24
  Toastr.error(loc.saveError);
20
25
  return;
21
26
  }
22
- const croppedFile = Object.assign(FileWithBlobDataHelper.fromBase64(result.dataUrl), {
27
+ const croppedFile = Object.assign(FileWithBlobDataHelper.fromBlob(result.blob), {
23
28
  width: result.width,
24
29
  height: result.height
25
30
  });
@@ -54,8 +59,8 @@ $effect(() => untrack(() => {
54
59
  {/snippet}
55
60
  {#snippet footer()}
56
61
  <div class="image-editor-dialog__footer">
57
- <DialogCancelButton size="sm" on={{ click: cancel }}>{loc.cancel}</DialogCancelButton>
58
- <DialogButton size="sm" on={{ click: save }}>{loc.save}</DialogButton>
62
+ <DialogCancelButton size="sm" disabled={cropper.processing} on={{ click: cancel }}>{loc.cancel}</DialogCancelButton>
63
+ <DialogButton size="sm" disabled={cropper.processing || !cropper.ready} on={{ click: save }}>{loc.save}</DialogButton>
59
64
  </div>
60
65
  {/snippet}
61
66
  </Dialog>
@@ -1,11 +1,11 @@
1
1
  import type { FileWithBlobUrl } from '../../../core/files';
2
- import type { ImageEditorDynamicAspectRatio } from './types';
2
+ import type { ImageEditorDynamicAspectRatio, ImageEditorTool } from './types';
3
3
  export type CropAndResizeOptions = {
4
4
  /** Fixed aspect ratio, or dynamic selector, or omit to follow the picture's own. */
5
5
  aspectRatio?: number | ImageEditorDynamicAspectRatio;
6
6
  /** @default 'cover' */
7
7
  mode?: 'contain' | 'cover';
8
- /** Fill color in `contain` mode (transparent by default). */
8
+ /** Fill color for whatever the picture doesn't cover — `contain` gaps, free position, its own transparency (transparent by default). */
9
9
  fillColor?: string;
10
10
  /**
11
11
  * After cropping, downscale to fit these limits. `max1` / `max2` are not tied to width /
@@ -16,6 +16,8 @@ export type CropAndResizeOptions = {
16
16
  max1: number;
17
17
  max2: number;
18
18
  } | false;
19
+ /** Actions that rework the picture itself (e.g. background removal), one toolbar button each. */
20
+ tools?: ImageEditorTool[];
19
21
  };
20
22
  /**
21
23
  * Opens the kit's image editor dialog, lets the user crop, and (optionally) downscales the result before returning.
@@ -1,5 +1,5 @@
1
1
  import { FileWithBlobDataHelper, resizeBlob } from '../../../core/files';
2
- import { openImageEditorDialog } from './index';
2
+ import { openImageEditorDialog } from './open-image-editor-dialog';
3
3
  const DEFAULT_RESIZE_LIMITS = { max1: 1600, max2: 1000 };
4
4
  /**
5
5
  * Opens the kit's image editor dialog, lets the user crop, and (optionally) downscales the result before returning.
@@ -12,7 +12,8 @@ export const cropAndResizeImage = async (file, options = {}) => {
12
12
  url: sourceUrl,
13
13
  aspectRatio: options.aspectRatio,
14
14
  mode: options.mode,
15
- fillColor: options.fillColor
15
+ fillColor: options.fillColor,
16
+ tools: options.tools
16
17
  });
17
18
  if (result.wasCanceled) {
18
19
  return null;
@@ -26,6 +27,7 @@ export const cropAndResizeImage = async (file, options = {}) => {
26
27
  if (resizedBlob === cropped) {
27
28
  return cropped;
28
29
  }
30
+ FileWithBlobDataHelper.dispose(cropped.blobUrl);
29
31
  return FileWithBlobDataHelper.fromBlob(resizedBlob, { id: cropped.id });
30
32
  }
31
33
  finally {
@@ -1,24 +1,4 @@
1
- import { type DialogResult } from '../../dialog';
2
- import type { ImageEditorDialogOptions, ImageEditorDialogResult } from './types';
3
- /**
4
- * Opens a full-screen image editor dialog: drag the picture inside a fixed frame, scale it from the corners, zoom,
5
- * rotate, align, and reset.
6
- *
7
- * ### Modes
8
- * - **`cover`** (default) — the picture always fills the frame; nothing outside the frame is exported.
9
- * - **`contain`** — the picture is fitted inside the frame with an optional fill color for the remaining space.
10
- *
11
- * ### Aspect ratio
12
- * - `number` — fixed ratio, no popover.
13
- * - `ImageEditorDynamicAspectRatio` — popover with supported ratios. `allowFreeCrop: true` adds the picture's own
14
- * ratio as a choice; `initial` is optional when it's enabled.
15
- * - Omitted — the frame follows the picture's own aspect ratio.
16
- *
17
- * ### Result
18
- * - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
19
- * - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
20
- */
21
- export declare const openImageEditorDialog: (options: ImageEditorDialogOptions) => Promise<DialogResult<ImageEditorDialogResult>>;
22
- export type { CroppedFile, ImageEditorDialogOptions, ImageEditorDialogResult, ImageEditorDynamicAspectRatio } from './types';
1
+ export { openImageEditorDialog } from './open-image-editor-dialog';
2
+ export type { CroppedFile, ImageEditorDialogOptions, ImageEditorDialogResult, ImageEditorDynamicAspectRatio, ImageEditorTool } from './types';
23
3
  export { cropAndResizeImage } from './crop-and-resize';
24
4
  export type { CropAndResizeOptions } from './crop-and-resize';
@@ -1,27 +1,2 @@
1
- import { Dialogs } from '../../dialog';
2
- import { default as ImageEditorDialog } from './cmp.image-editor-dialog.svelte';
3
- /**
4
- * Opens a full-screen image editor dialog: drag the picture inside a fixed frame, scale it from the corners, zoom,
5
- * rotate, align, and reset.
6
- *
7
- * ### Modes
8
- * - **`cover`** (default) — the picture always fills the frame; nothing outside the frame is exported.
9
- * - **`contain`** — the picture is fitted inside the frame with an optional fill color for the remaining space.
10
- *
11
- * ### Aspect ratio
12
- * - `number` — fixed ratio, no popover.
13
- * - `ImageEditorDynamicAspectRatio` — popover with supported ratios. `allowFreeCrop: true` adds the picture's own
14
- * ratio as a choice; `initial` is optional when it's enabled.
15
- * - Omitted — the frame follows the picture's own aspect ratio.
16
- *
17
- * ### Result
18
- * - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
19
- * - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
20
- */
21
- export const openImageEditorDialog = async (options) => {
22
- return await Dialogs.open({
23
- view: ImageEditorDialog,
24
- data: options
25
- });
26
- };
1
+ export { openImageEditorDialog } from './open-image-editor-dialog';
27
2
  export { cropAndResizeImage } from './crop-and-resize';
@@ -0,0 +1,25 @@
1
+ import { type DialogResult } from '../../dialog';
2
+ import type { ImageEditorDialogOptions, ImageEditorDialogResult } from './types';
3
+ /**
4
+ * Opens a full-screen image editor dialog: drag the picture inside a fixed frame, scale it from the corners, zoom,
5
+ * rotate, align, and reset.
6
+ *
7
+ * ### Modes
8
+ * - **`cover`** (default) — the picture always fills the frame; nothing outside the frame is exported.
9
+ * - **`contain`** — the picture is fitted inside the frame with an optional fill color for the remaining space.
10
+ *
11
+ * ### Aspect ratio
12
+ * - `number` — fixed ratio, no popover.
13
+ * - `ImageEditorDynamicAspectRatio` — popover with supported ratios. `allowFreeCrop: true` adds the picture's own
14
+ * ratio as a choice; `initial` is optional when it's enabled.
15
+ * - Omitted — the frame follows the picture's own aspect ratio.
16
+ *
17
+ * ### Tools
18
+ * - `tools` — one toolbar button per tool; `run` gets the picture as shown — cropped and rotated, at its natural
19
+ * resolution — and its result replaces it (`null` leaves it as it is). Reset returns to the source.
20
+ *
21
+ * ### Result
22
+ * - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
23
+ * - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
24
+ */
25
+ export declare const openImageEditorDialog: (options: ImageEditorDialogOptions) => Promise<DialogResult<ImageEditorDialogResult>>;
@@ -0,0 +1,30 @@
1
+ import { Dialogs } from '../../dialog';
2
+ import { default as ImageEditorDialog } from './cmp.image-editor-dialog.svelte';
3
+ /**
4
+ * Opens a full-screen image editor dialog: drag the picture inside a fixed frame, scale it from the corners, zoom,
5
+ * rotate, align, and reset.
6
+ *
7
+ * ### Modes
8
+ * - **`cover`** (default) — the picture always fills the frame; nothing outside the frame is exported.
9
+ * - **`contain`** — the picture is fitted inside the frame with an optional fill color for the remaining space.
10
+ *
11
+ * ### Aspect ratio
12
+ * - `number` — fixed ratio, no popover.
13
+ * - `ImageEditorDynamicAspectRatio` — popover with supported ratios. `allowFreeCrop: true` adds the picture's own
14
+ * ratio as a choice; `initial` is optional when it's enabled.
15
+ * - Omitted — the frame follows the picture's own aspect ratio.
16
+ *
17
+ * ### Tools
18
+ * - `tools` — one toolbar button per tool; `run` gets the picture as shown — cropped and rotated, at its natural
19
+ * resolution — and its result replaces it (`null` leaves it as it is). Reset returns to the source.
20
+ *
21
+ * ### Result
22
+ * - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
23
+ * - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
24
+ */
25
+ export const openImageEditorDialog = async (options) => {
26
+ return await Dialogs.open({
27
+ view: ImageEditorDialog,
28
+ data: options
29
+ });
30
+ };
@@ -8,12 +8,27 @@ export type ImageEditorDynamicAspectRatio = {
8
8
  supported: number[];
9
9
  allowFreeCrop: true;
10
10
  };
11
+ export type ImageEditorTool = {
12
+ id: string;
13
+ /** The button's tooltip and accessible name. */
14
+ label: string;
15
+ /** Raw SVG markup of the button's glyph. */
16
+ icon: string;
17
+ /**
18
+ * Turns the picture as shown — cropped and rotated, at its natural resolution — into a new one that replaces it;
19
+ * `null` leaves the picture as it is (the tool reports its own failure). Reset returns to the source.
20
+ */
21
+ run: (source: Blob) => Promise<Blob | null>;
22
+ };
11
23
  export type ImageEditorDialogOptions = {
12
24
  /** The picture to edit — same-origin, a `blob:` / `data:` URL, or a server that sends CORS headers; anything else fails to load. */
13
25
  url: string;
14
26
  fillColor?: string;
27
+ /** @default 'cover' */
15
28
  mode?: 'contain' | 'cover';
16
29
  aspectRatio?: number | ImageEditorDynamicAspectRatio;
30
+ /** Actions that rework the picture itself (e.g. background removal), one toolbar button each. */
31
+ tools?: ImageEditorTool[];
17
32
  };
18
33
  export type CroppedFile = FileWithBlobUrl & {
19
34
  width: number;
@@ -8,8 +8,8 @@ import { Tooltip } from '../../tooltip';
8
8
  import { ImgCropperToolbarLocalization } from './img-cropper-toolbar-localization';
9
9
  import IconArrowMove from '@fluentui/svg-icons/icons/arrow_move_20_regular.svg?raw';
10
10
  import IconArrowReset from '@fluentui/svg-icons/icons/arrow_reset_20_regular.svg?raw';
11
- import IconRotateRight from '@fluentui/svg-icons/icons/arrow_rotate_clockwise_20_regular.svg?raw';
12
- import IconRotateLeft from '@fluentui/svg-icons/icons/arrow_rotate_counterclockwise_20_regular.svg?raw';
11
+ import IconArrowRotateClockwise from '@fluentui/svg-icons/icons/arrow_rotate_clockwise_20_regular.svg?raw';
12
+ import IconArrowRotateCounterclockwise from '@fluentui/svg-icons/icons/arrow_rotate_counterclockwise_20_regular.svg?raw';
13
13
  import IconCheckmark from '@fluentui/svg-icons/icons/checkmark_20_regular.svg?raw';
14
14
  import IconCrop from '@fluentui/svg-icons/icons/crop_20_regular.svg?raw';
15
15
  import IconGridDots from '@fluentui/svg-icons/icons/grid_dots_20_regular.svg?raw';
@@ -111,14 +111,23 @@ const ratioLabel = $derived(cropper.ratioLabel ?? loc.original);
111
111
  </div>
112
112
  </Popover>
113
113
  <Tooltip text={loc.rotateLeft}>
114
- <ToolbarIconButton icon={IconRotateLeft} aria-label={loc.rotateLeft} disabled={!cropper.ready} on={{ click: () => cropper.rotate(-1) }} />
114
+ <ToolbarIconButton icon={IconArrowRotateCounterclockwise} aria-label={loc.rotateLeft} disabled={!cropper.ready} on={{ click: () => cropper.rotate(-1) }} />
115
115
  </Tooltip>
116
116
  <Tooltip text={loc.rotateRight}>
117
- <ToolbarIconButton icon={IconRotateRight} aria-label={loc.rotateRight} disabled={!cropper.ready} on={{ click: () => cropper.rotate(1) }} />
117
+ <ToolbarIconButton icon={IconArrowRotateClockwise} aria-label={loc.rotateRight} disabled={!cropper.ready} on={{ click: () => cropper.rotate(1) }} />
118
118
  </Tooltip>
119
119
 
120
120
  <Divider orientation="vertical" />
121
121
 
122
+ {#if cropper.tools.length}
123
+ {#each cropper.tools as tool (tool.id)}
124
+ <Tooltip text={tool.label}>
125
+ <ToolbarIconButton icon={tool.icon} aria-label={tool.label} disabled={!cropper.ready} on={{ click: () => void cropper.runTool(tool) }} />
126
+ </Tooltip>
127
+ {/each}
128
+ <Divider orientation="vertical" />
129
+ {/if}
130
+
122
131
  <Tooltip text={loc.reset}>
123
132
  <ToolbarIconButton icon={IconArrowReset} aria-label={loc.reset} disabled={!cropper.ready} on={{ click: cropper.reset }} />
124
133
  </Tooltip>
@@ -135,7 +144,7 @@ const ratioLabel = $derived(cropper.ratioLabel ?? loc.original);
135
144
 
136
145
  <!--
137
146
  @component
138
- Floating toolbar for `ImgCropperView` — aspect ratio, zoom, free position, alignment, rotate, reset, and the frame's
147
+ Floating toolbar for `ImgCropperView` — aspect ratio, zoom, free position, alignment, rotate, the cropper's tools, reset, and the frame's
139
148
  fill color (in `contain` mode, whenever free position is on, or when the picture has its own transparency). Pass it
140
149
  to `ImgCropperView`'s `toolbar` snippet prop.
141
150
 
@@ -5,7 +5,7 @@ type Props = {
5
5
  children?: Snippet;
6
6
  };
7
7
  /**
8
- * Floating toolbar for `ImgCropperView` — aspect ratio, zoom, free position, alignment, rotate, reset, and the frame's
8
+ * Floating toolbar for `ImgCropperView` — aspect ratio, zoom, free position, alignment, rotate, the cropper's tools, reset, and the frame's
9
9
  * fill color (in `contain` mode, whenever free position is on, or when the picture has its own transparency). Pass it
10
10
  * to `ImgCropperView`'s `toolbar` snippet prop.
11
11
  *
@@ -194,7 +194,7 @@ const pictureStyle = (box) => {
194
194
  };
195
195
  </script>
196
196
 
197
- <div class="img-cropper">
197
+ <div class="img-cropper" inert={cropper.processing}>
198
198
  <div
199
199
  class="img-cropper__stage"
200
200
  class:img-cropper__stage--dragging={drag !== null && !cropper.cropMode}
@@ -216,11 +216,11 @@ const pictureStyle = (box) => {
216
216
  {@const box = cropper.box}
217
217
  {@const frame = cropper.frame}
218
218
  <div class="img-cropper__picture-box img-cropper__picture-box--outside" style={placement({ x: frame.x + box.x, y: frame.y + box.y, w: box.w, h: box.h })}>
219
- <img class="img-cropper__picture" src={cropper.source} alt="" draggable="false" style={pictureStyle(box)} />
219
+ <img class="img-cropper__picture" src={cropper.currentUrl} alt="" draggable="false" style={pictureStyle(box)} />
220
220
  </div>
221
221
  <div class="img-cropper__frame" style={placement(frame)} style:--_--img-cropper--fill-color={cropper.fillColor || undefined}>
222
222
  <div class="img-cropper__picture-box" style={placement(box)}>
223
- <img class="img-cropper__picture" src={cropper.source} alt="" draggable="false" style={pictureStyle(box)} />
223
+ <img class="img-cropper__picture" src={cropper.currentUrl} alt="" draggable="false" style={pictureStyle(box)} />
224
224
  </div>
225
225
  </div>
226
226
  <div class="img-cropper__outline" style={placement(frame)}></div>
@@ -269,6 +269,9 @@ const pictureStyle = (box) => {
269
269
  </div>
270
270
  {/if}
271
271
  {/if}
272
+ {#if cropper.processing}
273
+ <Spinner position="absolute-center" blocking timeout={600} />
274
+ {/if}
272
275
  </div>
273
276
  {#if toolbar}
274
277
  <div class="img-cropper__toolbar" inert={drag !== null}>
@@ -279,17 +282,16 @@ const pictureStyle = (box) => {
279
282
 
280
283
  <!--
281
284
  @component
282
- Fixed-frame picture cropper — the frame stays put on the stage; drag pans the picture, the corner handles (shown while
283
- the pointer is over the picture, always on touch) scale it about the opposite corner, and the wheel zooms about the
284
- pointer. The part of the picture spilling past the frame stays visible but dimmed. Pass a `toolbar` snippet to mount
285
- `ImgCropperToolbar` underneath the picture area. `src` and `cropper` are read once, on mount — to change either, remount
286
- the view (e.g. `{#key}`) with a new `ImgCropper`.
285
+ Fixed-frame picture cropper: drag pans the picture, corner handles scale it, the wheel zooms, and the part past the frame
286
+ stays dimmed; a `toolbar` snippet mounts `ImgCropperToolbar` below. `src` and `cropper` are read once on mount — remount
287
+ the view (e.g. `{#key}`) with a new `ImgCropper` to change either.
287
288
 
288
289
  ### CSS Custom Properties
289
290
  | Property | Description | Default |
290
291
  |---|---|---|
291
292
  | `--sc-kit--img-cropper--handle-color` | Corner handle fill | `#ffffff` |
292
293
  | `--sc-kit--img-cropper--outline-color` | Frame outline color | `#4a4a4f` |
294
+ | `--sc-kit--img-cropper--background` | Stage background around the frame | `#1e1e20` |
293
295
  -->
294
296
 
295
297
  <style>.img-cropper {
@@ -367,10 +369,10 @@ the view (e.g. `{#key}`) with a new `ImgCropper`.
367
369
  }
368
370
  .img-cropper__frame {
369
371
  --_img-cropper--fill-color: var(--_--img-cropper--fill-color, transparent);
370
- --_check: rgb(255 255 255 / 10%);
372
+ --_img-cropper--check-color: rgb(255 255 255 / 10%);
371
373
  position: absolute;
372
374
  overflow: hidden;
373
- background-image: linear-gradient(var(--_img-cropper--fill-color), var(--_img-cropper--fill-color)), linear-gradient(45deg, var(--_check) 25%, transparent 25%, transparent 75%, var(--_check) 75%), linear-gradient(45deg, var(--_check) 25%, transparent 25%, transparent 75%, var(--_check) 75%);
375
+ background-image: linear-gradient(var(--_img-cropper--fill-color), var(--_img-cropper--fill-color)), linear-gradient(45deg, var(--_img-cropper--check-color) 25%, transparent 25%, transparent 75%, var(--_img-cropper--check-color) 75%), linear-gradient(45deg, var(--_img-cropper--check-color) 25%, transparent 25%, transparent 75%, var(--_img-cropper--check-color) 75%);
374
376
  background-size: 100% 100%, 1rem 1rem, 1rem 1rem;
375
377
  background-position: 0 0, 0 0, 0.5rem 0.5rem;
376
378
  }
@@ -7,17 +7,16 @@ type Props = {
7
7
  toolbar?: Snippet;
8
8
  };
9
9
  /**
10
- * Fixed-frame picture cropper — the frame stays put on the stage; drag pans the picture, the corner handles (shown while
11
- * the pointer is over the picture, always on touch) scale it about the opposite corner, and the wheel zooms about the
12
- * pointer. The part of the picture spilling past the frame stays visible but dimmed. Pass a `toolbar` snippet to mount
13
- * `ImgCropperToolbar` underneath the picture area. `src` and `cropper` are read once, on mount — to change either, remount
14
- * the view (e.g. `{#key}`) with a new `ImgCropper`.
10
+ * Fixed-frame picture cropper: drag pans the picture, corner handles scale it, the wheel zooms, and the part past the frame
11
+ * stays dimmed; a `toolbar` snippet mounts `ImgCropperToolbar` below. `src` and `cropper` are read once on mount — remount
12
+ * the view (e.g. `{#key}`) with a new `ImgCropper` to change either.
15
13
  *
16
14
  * ### CSS Custom Properties
17
15
  * | Property | Description | Default |
18
16
  * |---|---|---|
19
17
  * | `--sc-kit--img-cropper--handle-color` | Corner handle fill | `#ffffff` |
20
18
  * | `--sc-kit--img-cropper--outline-color` | Frame outline color | `#4a4a4f` |
19
+ * | `--sc-kit--img-cropper--background` | Stage background around the frame | `#1e1e20` |
21
20
  */
22
21
  declare const Cmp: import("svelte").Component<Props, {}, "">;
23
22
  type Cmp = ReturnType<typeof Cmp>;
@@ -18,6 +18,18 @@ export type ImgCropperMargin = {
18
18
  block?: number;
19
19
  inline?: number;
20
20
  };
21
+ export type ImgCropperTool = {
22
+ id: string;
23
+ /** The button's tooltip and accessible name. */
24
+ label: string;
25
+ /** Raw SVG markup of the button's glyph. */
26
+ icon: string;
27
+ /**
28
+ * Turns the picture as shown — cropped and rotated, at its natural resolution — into a new one that replaces it;
29
+ * `null` leaves the picture as it is (the tool reports its own failure). `reset()` returns to the source.
30
+ */
31
+ run: (source: Blob) => Promise<Blob | null>;
32
+ };
21
33
  export type ImgCropperOptions = {
22
34
  /** The placement rule the picture starts with and `reset()` returns to; Fill / Fit in the toolbar switch it. */
23
35
  mode: CropperMode;
@@ -25,25 +37,24 @@ export type ImgCropperOptions = {
25
37
  fillColor?: string;
26
38
  /** Breathing room between the frame and the stage's own edges. @default `{ block: 64, inline: 64 }` */
27
39
  margin?: ImgCropperMargin;
40
+ /** Actions that rework the picture itself (e.g. background removal), one toolbar button each. */
41
+ tools?: ImgCropperTool[];
28
42
  };
29
43
  export type ImgCropperSaveResult = {
30
- dataUrl: string;
44
+ blob: Blob;
31
45
  width: number;
32
46
  height: number;
33
- mimeType: string;
34
47
  };
35
48
  export declare class ImgCropper {
36
49
  readonly ratioOptions: ImgCropperRatioOption[] | null;
37
50
  readonly margin: Required<ImgCropperMargin>;
38
- loading: boolean;
51
+ readonly tools: ImgCropperTool[];
39
52
  failed: boolean;
40
53
  mode: CropperMode;
41
54
  fillColor: string;
42
- natural: Size | null;
43
55
  rotation: 0 | 90 | 180 | 270;
44
56
  /** The part of the source (turned by `rotation`) the picture is cut to, in natural pixels; `null` for the whole source. */
45
57
  crop: Rect | null;
46
- source: string;
47
58
  free: boolean;
48
59
  cropMode: boolean;
49
60
  selection: Rect | null;
@@ -52,11 +63,15 @@ export declare class ImgCropper {
52
63
  box: Rect | null;
53
64
  private readonly _initialMode;
54
65
  private readonly _initialAspectRatio;
55
- private _image;
56
- private _blobUrl;
57
- private _sourceMime;
58
- private _sourceHasAlpha;
66
+ private _processing;
67
+ private _current;
68
+ private _original;
69
+ private _alive;
59
70
  constructor(options: ImgCropperOptions);
71
+ /** A tool or `save()` is working on the picture; the view blocks the cropper until it's done. */
72
+ get processing(): boolean;
73
+ /** The current picture's URL — the source, or the latest tool result; empty until one is loaded. */
74
+ get currentUrl(): string;
60
75
  get showFillColor(): boolean;
61
76
  get hasSelection(): boolean;
62
77
  /** Whether the picture has transparent pixels — the source's own, or empty space a crop took in past the source's edges. */
@@ -64,8 +79,8 @@ export declare class ImgCropper {
64
79
  /** The current aspect ratio's label — a preset's own, or `1.78:1` for a custom one; `null` for Original. */
65
80
  get ratioLabel(): string | null;
66
81
  get isTransparentFill(): boolean;
67
- /** The picture's natural size — the crop's, or the whole source's turned by `rotation`. */
68
- get shownNatural(): Size | null;
82
+ /** The picture's size in its own pixels, turned by `rotation` — the crop's, or the whole picture's without one. */
83
+ get croppedSize(): Size | null;
69
84
  /** The picture's own aspect ratio (post-rotation) — the shape `box` must always keep. */
70
85
  get imageAspect(): number | null;
71
86
  /** The frame's own aspect ratio — the chosen ratio, or the picture's own when none is chosen. */
@@ -78,11 +93,17 @@ export declare class ImgCropper {
78
93
  /** The mode whose rest state the picture is in right now — filling the frame for `cover`, fitting it for `contain`; `null` once zoomed or free. */
79
94
  get restingMode(): CropperMode | null;
80
95
  get ready(): boolean;
81
- private get _fullNatural();
96
+ private get _turnedSize();
82
97
  private get _zoomBase();
83
98
  init: (src: string) => Promise<void>;
84
99
  destroy: () => void;
85
100
  setStage: (size: Size) => void;
101
+ /**
102
+ * Runs `tool` on the picture as shown — cropped and rotated, at its natural resolution — and makes the result the new
103
+ * picture, so tools chain. Crop mode is left and a pending selection dropped, not applied. The zoom and position carry
104
+ * over when the result keeps the sent size; otherwise the picture is laid out afresh.
105
+ */
106
+ runTool: (tool: ImgCropperTool) => Promise<void>;
86
107
  layout: () => void;
87
108
  changeAspectRatio: (ratio: number | null) => void;
88
109
  setFree: (free: boolean) => void;
@@ -124,5 +145,14 @@ export declare class ImgCropper {
124
145
  center: Point;
125
146
  size: Size;
126
147
  } | null;
148
+ private _render;
149
+ /** Draws the picture — cropped and turned — into `target`, clipped to it; `false` when there's no picture to place. */
150
+ private _drawPicture;
151
+ /** The picture cropped and turned, at its own resolution capped like `save()`'s; its blob as is when there's nothing to apply. */
152
+ private _croppedImage;
153
+ private _isCurrent;
154
+ private _showToolResult;
155
+ private _releaseToolResult;
156
+ private _refit;
127
157
  private _update;
128
158
  }
@@ -1,7 +1,6 @@
1
- import { FileWithBlobDataHelper } from '../../../core/files';
2
1
  import { ColorHelper, Utils } from '../../../core/utils';
3
2
  import { alignBox, boxAtZoom, boxTurnedWithFrame, clampBox, containBox, containZoom, coverBox, fitFrame, MAX_FRAME_ZOOM, MIN_FREE_ZOOM, moveBox, scaleBox, selectionFromDrag, shiftSelection, zoomOf } from './frame-geometry';
4
- import { detectAlpha, detectMimeFromUrl, loadImage } from './img-cropper-utils';
3
+ import { Picture } from './picture';
5
4
  const ZOOM_STOPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, MAX_FRAME_ZOOM];
6
5
  const DEFAULT_MARGIN = { block: 64, inline: 64 };
7
6
  const MIN_SELECTION_SIZE = 40;
@@ -14,6 +13,17 @@ const assertAspectRatio = (ratio) => {
14
13
  throw new Error(`ImgCropper: aspect ratio must be a positive finite number, got ${ratio}`);
15
14
  }
16
15
  };
16
+ const scaleRect = (rect, factor) => ({ x: rect.x * factor, y: rect.y * factor, w: rect.w * factor, h: rect.h * factor });
17
+ // Derived from the long side: from the short one, a portrait crop's rounding drifts past a pixel and the zoom snaps back.
18
+ const sameShape = (size, reference) => reference.w >= reference.h ? Math.abs(size.h - (size.w * reference.h) / reference.w) <= 1 : Math.abs(size.w - (size.h * reference.w) / reference.h) <= 1;
19
+ const SAVE_JPEG_QUALITY = 0.92;
20
+ const TOOL_INPUT_JPEG_QUALITY = 0.95;
21
+ /** JPEG for a JPEG source that needs no alpha, PNG otherwise; `null` when the browser can't encode the canvas. */
22
+ const encodeCanvas = (canvas, options) => {
23
+ const mimeType = options.sourceMime === 'image/jpeg' && !options.needsAlpha ? 'image/jpeg' : 'image/png';
24
+ // A canvas past the browser's size limit encodes to `null` instead of throwing.
25
+ return new Promise((resolve) => canvas.toBlob(resolve, mimeType, options.jpegQuality));
26
+ };
17
27
  const ROTATIONS = [0, 90, 180, 270];
18
28
  const toRotation = (degrees) => {
19
29
  const normalized = ((degrees % 360) + 360) % 360;
@@ -26,15 +36,13 @@ const toRatioLabel = (ratio) => {
26
36
  export class ImgCropper {
27
37
  ratioOptions;
28
38
  margin;
29
- loading = $state(false);
39
+ tools;
30
40
  failed = $state(false);
31
41
  mode = $state('cover');
32
42
  fillColor = $state('');
33
- natural = $state.raw(null);
34
43
  rotation = $state(0);
35
44
  /** The part of the source (turned by `rotation`) the picture is cut to, in natural pixels; `null` for the whole source. */
36
45
  crop = $state.raw(null);
37
- source = $state('');
38
46
  free = $state(false);
39
47
  cropMode = $state(false);
40
48
  selection = $state.raw(null);
@@ -43,11 +51,12 @@ export class ImgCropper {
43
51
  box = $state.raw(null);
44
52
  _initialMode;
45
53
  _initialAspectRatio;
46
- _image = null;
47
- _blobUrl = null;
48
- _sourceMime = null;
49
- _sourceHasAlpha = $state(false);
54
+ _processing = $state(false);
55
+ _current = $state.raw(null);
56
+ _original = null;
57
+ _alive = false;
50
58
  constructor(options) {
59
+ this.tools = options.tools ?? [];
51
60
  this.mode = options.mode;
52
61
  this._initialMode = options.mode;
53
62
  this.fillColor = options.fillColor ?? '';
@@ -79,6 +88,14 @@ export class ImgCropper {
79
88
  this._initialAspectRatio = initial;
80
89
  this.ratioOptions = items.length >= 2 ? items : null;
81
90
  }
91
+ /** A tool or `save()` is working on the picture; the view blocks the cropper until it's done. */
92
+ get processing() {
93
+ return this._processing;
94
+ }
95
+ /** The current picture's URL — the source, or the latest tool result; empty until one is loaded. */
96
+ get currentUrl() {
97
+ return this._current?.url ?? '';
98
+ }
82
99
  get showFillColor() {
83
100
  return this.mode === 'contain' || this.free || this.hasAlpha;
84
101
  }
@@ -89,9 +106,9 @@ export class ImgCropper {
89
106
  /** Whether the picture has transparent pixels — the source's own, or empty space a crop took in past the source's edges. */
90
107
  get hasAlpha() {
91
108
  const { crop } = this;
92
- const full = this._fullNatural;
93
- const reachesPast = !!crop && !!full && (crop.x < -0.5 || crop.y < -0.5 || crop.x + crop.w > full.w + 0.5 || crop.y + crop.h > full.h + 0.5);
94
- return this._sourceHasAlpha || reachesPast;
109
+ const turned = this._turnedSize;
110
+ const reachesPast = !!crop && !!turned && (crop.x < -0.5 || crop.y < -0.5 || crop.x + crop.w > turned.w + 0.5 || crop.y + crop.h > turned.h + 0.5);
111
+ return !!this._current?.hasAlpha || reachesPast;
95
112
  }
96
113
  /** The current aspect ratio's label — a preset's own, or `1.78:1` for a custom one; `null` for Original. */
97
114
  get ratioLabel() {
@@ -104,16 +121,15 @@ export class ImgCropper {
104
121
  get isTransparentFill() {
105
122
  return ColorHelper.isTransparent(this.fillColor);
106
123
  }
107
- /** The picture's natural size — the crop's, or the whole source's turned by `rotation`. */
108
- get shownNatural() {
109
- const full = this._fullNatural;
124
+ /** The picture's size in its own pixels, turned by `rotation` — the crop's, or the whole picture's without one. */
125
+ get croppedSize() {
110
126
  const { crop } = this;
111
- return full && crop ? { w: crop.w, h: crop.h } : full;
127
+ return crop ? { w: crop.w, h: crop.h } : this._turnedSize;
112
128
  }
113
129
  /** The picture's own aspect ratio (post-rotation) — the shape `box` must always keep. */
114
130
  get imageAspect() {
115
- const shown = this.shownNatural;
116
- return shown ? shown.w / shown.h : null;
131
+ const cropped = this.croppedSize;
132
+ return cropped ? cropped.w / cropped.h : null;
117
133
  }
118
134
  /** The frame's own aspect ratio — the chosen ratio, or the picture's own when none is chosen. */
119
135
  get frameAspect() {
@@ -132,10 +148,10 @@ export class ImgCropper {
132
148
  return null;
133
149
  }
134
150
  const fit = fitFrame(available, frameAspect);
135
- const shown = this.shownNatural;
151
+ const cropped = this.croppedSize;
136
152
  // Capped at the picture's natural size so a small one isn't blown up at rest — but never below MIN_FRAME, so a
137
153
  // tiny one stays workable (and visibly small) instead of shrinking to a few pixels.
138
- const naturalWidth = shown ? containBox(shown, frameAspect).w : fit.w;
154
+ const naturalWidth = cropped ? containBox(cropped, frameAspect).w : fit.w;
139
155
  const w = Math.min(fit.w, Math.max(naturalWidth, coverBox(MIN_FRAME, frameAspect).w));
140
156
  const h = w / frameAspect;
141
157
  return { x: this.margin.inline + (available.w - w) / 2, y: this.margin.block + (available.h - h) / 2, w, h };
@@ -158,9 +174,12 @@ export class ImgCropper {
158
174
  get ready() {
159
175
  return !this.failed && this.box !== null && this.frame !== null;
160
176
  }
161
- get _fullNatural() {
162
- const { natural, rotation } = this;
163
- return natural ? (rotation === 90 || rotation === 270 ? { w: natural.h, h: natural.w } : natural) : null;
177
+ get _turnedSize() {
178
+ const size = this._current?.size;
179
+ if (!size) {
180
+ return null;
181
+ }
182
+ return this.rotation === 90 || this.rotation === 270 ? { w: size.h, h: size.w } : size;
164
183
  }
165
184
  get _zoomBase() {
166
185
  const { frame, imageAspect } = this;
@@ -168,64 +187,65 @@ export class ImgCropper {
168
187
  }
169
188
  init = async (src) => {
170
189
  this.destroy();
171
- this.loading = true;
190
+ this._alive = true;
172
191
  this.failed = false;
173
192
  try {
174
- let resolvedSrc = src;
175
- const file = await FileWithBlobDataHelper.fromUrl(src);
176
- if (file) {
177
- resolvedSrc = file.blobUrl;
178
- this._blobUrl = resolvedSrc;
179
- this._sourceMime = file.type || null;
180
- }
181
- if (!this._sourceMime) {
182
- this._sourceMime = detectMimeFromUrl(src);
193
+ const picture = await Picture.fromUrl(src);
194
+ if (!this._alive) {
195
+ picture?.release();
196
+ return;
183
197
  }
184
- const image = await loadImage(resolvedSrc);
185
- if (!image.naturalWidth || !image.naturalHeight) {
198
+ if (!picture) {
186
199
  this.failed = true;
187
200
  return;
188
201
  }
189
- this._image = image;
190
- this.source = resolvedSrc;
191
- this.natural = { w: image.naturalWidth, h: image.naturalHeight };
192
- this._sourceHasAlpha = detectAlpha(image, this._sourceMime);
202
+ this._original = picture;
203
+ this._current = picture;
193
204
  this.layout();
194
205
  }
195
206
  catch {
196
207
  this.failed = true;
197
208
  }
198
- finally {
199
- this.loading = false;
200
- }
201
209
  };
202
210
  destroy = () => {
203
- this._image = null;
204
- this._sourceMime = null;
205
- this.natural = null;
206
- this._sourceHasAlpha = false;
211
+ this._alive = false;
212
+ this._releaseToolResult();
213
+ this._original?.release();
214
+ this._current = null;
215
+ this._original = null;
207
216
  this.crop = null;
208
217
  this.box = null;
209
- if (this._blobUrl) {
210
- URL.revokeObjectURL(this._blobUrl);
211
- this._blobUrl = null;
212
- }
213
218
  };
214
219
  setStage = (size) => {
215
220
  const previousFrame = this.frame;
216
221
  const previousBox = this.box;
217
222
  this.stage = size;
218
- const frame = this.frame;
219
- if (previousFrame && previousBox && frame && previousFrame.w > 0) {
220
- const factor = frame.w / previousFrame.w;
221
- const scaled = (rect) => ({ x: rect.x * factor, y: rect.y * factor, w: rect.w * factor, h: rect.h * factor });
222
- this.box = scaled(previousBox);
223
- if (this.selection) {
224
- this.selection = scaled(this.selection);
223
+ this._refit(previousFrame, previousBox);
224
+ };
225
+ /**
226
+ * Runs `tool` on the picture as shown — cropped and rotated, at its natural resolution — and makes the result the new
227
+ * picture, so tools chain. Crop mode is left and a pending selection dropped, not applied. The zoom and position carry
228
+ * over when the result keeps the sent size; otherwise the picture is laid out afresh.
229
+ */
230
+ runTool = async (tool) => {
231
+ this.setCropMode(false);
232
+ const picture = this._current;
233
+ if (!picture) {
234
+ return;
235
+ }
236
+ this._processing = true;
237
+ try {
238
+ const toolInput = await this._croppedImage(picture);
239
+ if (!toolInput || !this._isCurrent(picture)) {
240
+ return;
241
+ }
242
+ const result = await tool.run(toolInput.blob);
243
+ if (result && this._isCurrent(picture)) {
244
+ await this._showToolResult({ result, replaced: picture, toolInputSize: toolInput.size });
225
245
  }
226
246
  }
227
- else {
228
- this.layout();
247
+ finally {
248
+ this._processing = false;
229
249
  }
230
250
  };
231
251
  layout = () => {
@@ -290,11 +310,11 @@ export class ImgCropper {
290
310
  * filling the frame — with the Original aspect the frame takes the piece's shape, a fixed aspect already matches it.
291
311
  */
292
312
  applyCrop = () => {
293
- const { selection, box, shownNatural } = this;
294
- if (!this.hasSelection || !selection || !box || !shownNatural || !this.frame) {
313
+ const { selection, box, croppedSize } = this;
314
+ if (!this.hasSelection || !selection || !box || !croppedSize || !this.frame) {
295
315
  return;
296
316
  }
297
- const scale = shownNatural.w / box.w;
317
+ const scale = croppedSize.w / box.w;
298
318
  const origin = this.crop ?? { x: 0, y: 0 };
299
319
  const w = selection.w * scale;
300
320
  // The selection's height is rounded; a fixed aspect is re-derived so the piece fills the frame without a seam.
@@ -344,14 +364,14 @@ export class ImgCropper {
344
364
  * along and keeps what's inside it; a fixed-aspect frame can't turn, so the picture is laid out afresh instead. */
345
365
  rotate = (direction) => {
346
366
  const { box, frame: fromFrame, crop } = this;
347
- const full = this._fullNatural;
367
+ const turned = this._turnedSize;
348
368
  this.rotation = toRotation(this.rotation + direction * 90);
349
- if (crop && full) {
369
+ if (crop && turned) {
350
370
  // A quarter turn maps (x, y) in the W×H source to (H - y, x) clockwise, (y, W - x) counter-clockwise.
351
371
  this.crop =
352
372
  direction === 1
353
- ? { x: full.h - (crop.y + crop.h), y: crop.x, w: crop.h, h: crop.w }
354
- : { x: crop.y, y: full.w - (crop.x + crop.w), w: crop.h, h: crop.w };
373
+ ? { x: turned.h - (crop.y + crop.h), y: crop.x, w: crop.h, h: crop.w }
374
+ : { x: crop.y, y: turned.w - (crop.x + crop.w), w: crop.h, h: crop.w };
355
375
  }
356
376
  this.selection = null;
357
377
  if (this.aspectRatio !== null) {
@@ -370,6 +390,10 @@ export class ImgCropper {
370
390
  });
371
391
  };
372
392
  reset = () => {
393
+ if (this._original && this._current !== this._original) {
394
+ this._releaseToolResult();
395
+ this._current = this._original;
396
+ }
373
397
  this.mode = this._initialMode;
374
398
  this.free = false;
375
399
  this.cropMode = false;
@@ -384,14 +408,48 @@ export class ImgCropper {
384
408
  if (this.hasSelection) {
385
409
  this.applyCrop();
386
410
  }
387
- const image = this._image;
388
- const { box, frame, shownNatural } = this;
389
- if (this.failed || !image || !box || !frame || !shownNatural) {
411
+ this._processing = true;
412
+ try {
413
+ const rendered = this._render();
414
+ if (!rendered) {
415
+ return null;
416
+ }
417
+ const { canvas, needsAlpha } = rendered;
418
+ const blob = await encodeCanvas(canvas, { sourceMime: this._current?.mime ?? null, needsAlpha, jpegQuality: SAVE_JPEG_QUALITY });
419
+ return blob ? { blob, width: canvas.width, height: canvas.height } : null;
420
+ }
421
+ finally {
422
+ this._processing = false;
423
+ }
424
+ };
425
+ /**
426
+ * Where the whole turned source lands when the picture is drawn into `picture` — its center and its size on each axis,
427
+ * before `rotation` swaps them. The view and `save()` both place the source through this, so they can't drift apart.
428
+ */
429
+ sourcePlacement = (picture) => {
430
+ const turned = this._turnedSize;
431
+ const cropped = this.croppedSize;
432
+ if (!turned || !cropped) {
433
+ return null;
434
+ }
435
+ // Per axis: the picture rect is rounded on each axis separately when exported, so one shared scale leaves a seam.
436
+ const perX = picture.w / cropped.w;
437
+ const perY = picture.h / cropped.h;
438
+ const offset = this.crop ?? { x: 0, y: 0 };
439
+ return {
440
+ center: { x: picture.x + (turned.w / 2 - offset.x) * perX, y: picture.y + (turned.h / 2 - offset.y) * perY },
441
+ size: { w: turned.w * perX, h: turned.h * perY }
442
+ };
443
+ };
444
+ _render = () => {
445
+ const source = this._current;
446
+ const { box, frame, croppedSize } = this;
447
+ if (this.failed || !source || !box || !frame || !croppedSize) {
390
448
  return null;
391
449
  }
392
- const nativeScale = shownNatural.w / box.w;
450
+ const nativeScale = croppedSize.w / box.w;
393
451
  const nativeArea = frame.w * frame.h * nativeScale * nativeScale;
394
- const areaLimit = Math.min(shownNatural.w * shownNatural.h * MAX_OUTPUT_AREA_UPSCALE, MAX_OUTPUT_PIXELS);
452
+ const areaLimit = Math.min(croppedSize.w * croppedSize.h * MAX_OUTPUT_AREA_UPSCALE, MAX_OUTPUT_PIXELS);
395
453
  const scale = nativeArea > areaLimit ? nativeScale * Math.sqrt(areaLimit / nativeArea) : nativeScale;
396
454
  const width = Math.max(1, Math.floor(frame.w * scale));
397
455
  const height = Math.max(1, Math.floor(frame.h * scale));
@@ -411,47 +469,90 @@ export class ImgCropper {
411
469
  context.fillStyle = this.fillColor;
412
470
  context.fillRect(0, 0, width, height);
413
471
  }
414
- const picture = { x: left, y: top, w: right - left, h: bottom - top };
415
- const placement = this.sourcePlacement(picture);
416
- if (!placement) {
472
+ if (!this._drawPicture(context, { source, target: { x: left, y: top, w: right - left, h: bottom - top } })) {
417
473
  return null;
418
474
  }
475
+ return { canvas, needsAlpha: !ColorHelper.isOpaque(this.fillColor) && (hasGap || this.hasAlpha) };
476
+ };
477
+ /** Draws the picture — cropped and turned — into `target`, clipped to it; `false` when there's no picture to place. */
478
+ _drawPicture = (context, { source, target }) => {
479
+ const placement = this.sourcePlacement(target);
480
+ if (!placement) {
481
+ return false;
482
+ }
419
483
  const { center, size } = placement;
420
484
  const turned = this.rotation === 90 || this.rotation === 270;
421
485
  context.save();
422
486
  context.beginPath();
423
- context.rect(picture.x, picture.y, picture.w, picture.h);
487
+ context.rect(target.x, target.y, target.w, target.h);
424
488
  context.clip();
425
489
  context.translate(center.x, center.y);
426
490
  context.rotate((this.rotation * Math.PI) / 180);
427
491
  const drawWidth = turned ? size.h : size.w;
428
492
  const drawHeight = turned ? size.w : size.h;
429
- context.drawImage(image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
493
+ context.drawImage(source.image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
430
494
  context.restore();
431
- const needsAlpha = !ColorHelper.isOpaque(this.fillColor) && (hasGap || this.hasAlpha);
432
- const mimeType = this._sourceMime === 'image/jpeg' && !needsAlpha ? 'image/jpeg' : 'image/png';
433
- const dataUrl = canvas.toDataURL(mimeType, 0.92);
434
- // A canvas past the browser's size limit encodes to an empty "data:," URL instead of throwing.
435
- return dataUrl.length > 'data:,'.length ? { dataUrl, width, height, mimeType } : null;
495
+ return true;
436
496
  };
437
- /**
438
- * Where the whole turned source lands when the picture is drawn into `picture` — its center and its size on each axis,
439
- * before `rotation` swaps them. The view and `save()` both place the source through this, so they can't drift apart.
440
- */
441
- sourcePlacement = (picture) => {
442
- const full = this._fullNatural;
443
- const shown = this.shownNatural;
444
- if (!full || !shown) {
497
+ /** The picture cropped and turned, at its own resolution capped like `save()`'s; its blob as is when there's nothing to apply. */
498
+ _croppedImage = async (source) => {
499
+ const cropped = this.croppedSize;
500
+ if (!cropped) {
445
501
  return null;
446
502
  }
447
- // Per axis: the picture rect is rounded on each axis separately when exported, so one shared scale leaves a seam.
448
- const perX = picture.w / shown.w;
449
- const perY = picture.h / shown.h;
450
- const offset = this.crop ?? { x: 0, y: 0 };
451
- return {
452
- center: { x: picture.x + (full.w / 2 - offset.x) * perX, y: picture.y + (full.h / 2 - offset.y) * perY },
453
- size: { w: full.w * perX, h: full.h * perY }
454
- };
503
+ if (!this.crop && this.rotation === 0 && source.blob) {
504
+ return { blob: source.blob, size: source.size };
505
+ }
506
+ const scale = Math.min(1, Math.sqrt(MAX_OUTPUT_PIXELS / (cropped.w * cropped.h)));
507
+ const canvas = document.createElement('canvas');
508
+ canvas.width = Math.max(1, Math.floor(cropped.w * scale));
509
+ canvas.height = Math.max(1, Math.floor(cropped.h * scale));
510
+ const context = canvas.getContext('2d');
511
+ if (!context || !this._drawPicture(context, { source, target: { x: 0, y: 0, w: canvas.width, h: canvas.height } })) {
512
+ return null;
513
+ }
514
+ const blob = await encodeCanvas(canvas, { sourceMime: source.mime, needsAlpha: this.hasAlpha, jpegQuality: TOOL_INPUT_JPEG_QUALITY });
515
+ return blob ? { blob, size: { w: canvas.width, h: canvas.height } } : null;
516
+ };
517
+ _isCurrent = (picture) => this._current === picture;
518
+ _showToolResult = async ({ result, replaced, toolInputSize }) => {
519
+ const resultPicture = await Picture.fromBlob(result);
520
+ if (!resultPicture) {
521
+ console.error('ImgCropper: tool result failed to decode as an image');
522
+ return;
523
+ }
524
+ if (!this._isCurrent(replaced)) {
525
+ resultPicture.release();
526
+ return;
527
+ }
528
+ const { frame: previousFrame, box: previousBox } = this;
529
+ this._releaseToolResult();
530
+ this._current = resultPicture;
531
+ this.crop = null;
532
+ this.rotation = 0;
533
+ if (!sameShape(resultPicture.size, toolInputSize)) {
534
+ this.layout();
535
+ return;
536
+ }
537
+ this._refit(previousFrame, previousBox);
538
+ };
539
+ _releaseToolResult = () => {
540
+ if (this._current !== this._original) {
541
+ this._current?.release();
542
+ }
543
+ };
544
+ _refit = (previousFrame, previousBox) => {
545
+ const frame = this.frame;
546
+ if (previousFrame && previousBox && frame && previousFrame.w > 0) {
547
+ const factor = frame.w / previousFrame.w;
548
+ this.box = scaleRect(previousBox, factor);
549
+ if (this.selection) {
550
+ this.selection = scaleRect(this.selection, factor);
551
+ }
552
+ }
553
+ else {
554
+ this.layout();
555
+ }
455
556
  };
456
557
  _update = (change) => {
457
558
  const { box, frame, imageAspect } = this;
@@ -1,3 +1,3 @@
1
1
  export { default as ImgCropperToolbar } from './cmp.img-cropper-toolbar.svelte';
2
2
  export { default as ImgCropperView } from './cmp.img-cropper.svelte';
3
- export { ImgCropper, type CropperMode, type ImgCropperDynamicAspectRatio, type ImgCropperMargin, type ImgCropperOptions, type ImgCropperRatioOption, type ImgCropperSaveResult } from './img-cropper.svelte';
3
+ export { ImgCropper, type CropperMode, type ImgCropperDynamicAspectRatio, type ImgCropperMargin, type ImgCropperOptions, type ImgCropperRatioOption, type ImgCropperSaveResult, type ImgCropperTool } from './img-cropper.svelte';
@@ -0,0 +1,16 @@
1
+ import type { Size } from './frame-geometry';
2
+ export declare class Picture {
3
+ readonly image: HTMLImageElement;
4
+ readonly url: string;
5
+ readonly blob: Blob | null;
6
+ readonly mime: string | null;
7
+ readonly hasAlpha: boolean;
8
+ private readonly _ownsUrl;
9
+ private constructor();
10
+ get size(): Size;
11
+ /** Loads `src` as a blob when it can be fetched, falling back to the URL itself; `null` when it has no intrinsic size or fails to load. */
12
+ static fromUrl(src: string): Promise<Picture | null>;
13
+ static fromBlob(blob: Blob): Promise<Picture | null>;
14
+ private static _load;
15
+ release(): void;
16
+ }
@@ -0,0 +1,46 @@
1
+ import { FileWithBlobDataHelper } from '../../../core/files';
2
+ import { detectAlpha, detectMimeFromUrl, loadImage } from './img-cropper-utils';
3
+ export class Picture {
4
+ image;
5
+ url;
6
+ blob;
7
+ mime;
8
+ hasAlpha;
9
+ _ownsUrl;
10
+ constructor(image, source) {
11
+ this.image = image;
12
+ this.url = source.url;
13
+ this.blob = source.blob;
14
+ this.mime = source.mime;
15
+ this._ownsUrl = source.ownsUrl;
16
+ this.hasAlpha = detectAlpha(image, source.mime);
17
+ }
18
+ get size() {
19
+ return { w: this.image.naturalWidth, h: this.image.naturalHeight };
20
+ }
21
+ /** Loads `src` as a blob when it can be fetched, falling back to the URL itself; `null` when it has no intrinsic size or fails to load. */
22
+ static async fromUrl(src) {
23
+ const file = await FileWithBlobDataHelper.fromUrl(new URL(src, document.baseURI).href);
24
+ return file
25
+ ? Picture._load({ url: file.blobUrl, blob: file, mime: file.type.startsWith('image/') ? file.type : detectMimeFromUrl(src), ownsUrl: true })
26
+ : Picture._load({ url: src, blob: null, mime: detectMimeFromUrl(src), ownsUrl: false });
27
+ }
28
+ static async fromBlob(blob) {
29
+ return Picture._load({ url: URL.createObjectURL(blob), blob, mime: blob.type || null, ownsUrl: true });
30
+ }
31
+ static async _load(source) {
32
+ const image = await loadImage(source.url).catch(() => null);
33
+ if (!image?.naturalWidth || !image.naturalHeight) {
34
+ if (source.ownsUrl) {
35
+ URL.revokeObjectURL(source.url);
36
+ }
37
+ return null;
38
+ }
39
+ return new Picture(image, source);
40
+ }
41
+ release() {
42
+ if (this._ownsUrl) {
43
+ URL.revokeObjectURL(this.url);
44
+ }
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",