@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.
- package/dist/core/files/file-helper.d.ts +5 -4
- package/dist/core/files/file-helper.js +34 -12
- package/dist/ui/cropper/image-editor-dialog/cmp.image-editor-dialog.svelte +9 -4
- package/dist/ui/cropper/image-editor-dialog/crop-and-resize.d.ts +4 -2
- package/dist/ui/cropper/image-editor-dialog/crop-and-resize.js +4 -2
- package/dist/ui/cropper/image-editor-dialog/index.d.ts +2 -22
- package/dist/ui/cropper/image-editor-dialog/index.js +1 -26
- package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.d.ts +25 -0
- package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.js +30 -0
- package/dist/ui/cropper/image-editor-dialog/types.d.ts +15 -0
- package/dist/ui/cropper/img-cropper/cmp.img-cropper-toolbar.svelte +14 -5
- package/dist/ui/cropper/img-cropper/cmp.img-cropper-toolbar.svelte.d.ts +1 -1
- package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte +12 -10
- package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte.d.ts +4 -5
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.d.ts +42 -12
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.js +198 -97
- package/dist/ui/cropper/img-cropper/index.d.ts +1 -1
- package/dist/ui/cropper/img-cropper/picture.d.ts +16 -0
- package/dist/ui/cropper/img-cropper/picture.js +46 -0
- package/package.json +1 -1
|
@@ -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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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(
|
|
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 =
|
|
31
|
-
return type !== null && type ===
|
|
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 ?
|
|
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
|
|
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
|
|
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) ?
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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(
|
|
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.
|
|
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
|
|
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 './
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
import
|
|
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={
|
|
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={
|
|
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.
|
|
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.
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
--
|
|
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(--
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
private
|
|
57
|
-
private
|
|
58
|
-
private
|
|
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
|
|
68
|
-
get
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
93
|
-
const reachesPast = !!crop && !!
|
|
94
|
-
return this.
|
|
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
|
|
108
|
-
get
|
|
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
|
|
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
|
|
116
|
-
return
|
|
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
|
|
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 =
|
|
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
|
|
162
|
-
const
|
|
163
|
-
|
|
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.
|
|
190
|
+
this._alive = true;
|
|
172
191
|
this.failed = false;
|
|
173
192
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
185
|
-
if (!image.naturalWidth || !image.naturalHeight) {
|
|
198
|
+
if (!picture) {
|
|
186
199
|
this.failed = true;
|
|
187
200
|
return;
|
|
188
201
|
}
|
|
189
|
-
this.
|
|
190
|
-
this.
|
|
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.
|
|
204
|
-
this.
|
|
205
|
-
this.
|
|
206
|
-
this.
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
this.
|
|
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,
|
|
294
|
-
if (!this.hasSelection || !selection || !box || !
|
|
313
|
+
const { selection, box, croppedSize } = this;
|
|
314
|
+
if (!this.hasSelection || !selection || !box || !croppedSize || !this.frame) {
|
|
295
315
|
return;
|
|
296
316
|
}
|
|
297
|
-
const scale =
|
|
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
|
|
367
|
+
const turned = this._turnedSize;
|
|
348
368
|
this.rotation = toRotation(this.rotation + direction * 90);
|
|
349
|
-
if (crop &&
|
|
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:
|
|
354
|
-
: { x: crop.y, y:
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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 =
|
|
450
|
+
const nativeScale = croppedSize.w / box.w;
|
|
393
451
|
const nativeArea = frame.w * frame.h * nativeScale * nativeScale;
|
|
394
|
-
const areaLimit = Math.min(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
+
}
|