@streamscloud/kit 0.1.10 → 0.1.11
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/ui/cropper/image-editor-dialog/cmp.image-editor-dialog.svelte +1 -1
- package/dist/ui/cropper/image-editor-dialog/index.d.ts +3 -3
- package/dist/ui/cropper/image-editor-dialog/index.js +3 -3
- package/dist/ui/cropper/image-editor-dialog/types.d.ts +4 -12
- package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte +27 -36
- package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte.d.ts +8 -10
- package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.d.ts +43 -0
- package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.js +163 -0
- package/dist/ui/cropper/img-cropper/img-cropper-contain-worker.svelte.d.ts +5 -38
- package/dist/ui/cropper/img-cropper/img-cropper-contain-worker.svelte.js +29 -149
- package/dist/ui/cropper/img-cropper/img-cropper-cover-worker.svelte.d.ts +5 -38
- package/dist/ui/cropper/img-cropper/img-cropper-cover-worker.svelte.js +37 -135
- package/dist/ui/cropper/img-cropper/img-cropper-utils.d.ts +10 -1
- package/dist/ui/cropper/img-cropper/img-cropper-utils.js +7 -0
- package/dist/ui/cropper/img-cropper/img-cropper-worker.svelte.d.ts +8 -4
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.d.ts +11 -17
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.js +19 -23
- package/dist/ui/cropper/img-cropper/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -9,9 +9,9 @@ import type { ImageEditorDialogOptions, ImageEditorDialogResult } from './types'
|
|
|
9
9
|
*
|
|
10
10
|
* ### Aspect ratio
|
|
11
11
|
* - `number` — fixed ratio, no dropdown.
|
|
12
|
-
* - `
|
|
13
|
-
* - `
|
|
14
|
-
* - Omitted — free crop
|
|
12
|
+
* - `ImgCropperDynamicAspectRatio` — dropdown with supported ratios. `allowFreeCrop: true`
|
|
13
|
+
* adds a free-ratio option; `initial` is optional when free crop is enabled.
|
|
14
|
+
* - Omitted — free crop (no ratio constraint on the selection).
|
|
15
15
|
*
|
|
16
16
|
* ### Result
|
|
17
17
|
* - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
|
|
@@ -9,9 +9,9 @@ import { default as ImageEditorDialog } from './cmp.image-editor-dialog.svelte';
|
|
|
9
9
|
*
|
|
10
10
|
* ### Aspect ratio
|
|
11
11
|
* - `number` — fixed ratio, no dropdown.
|
|
12
|
-
* - `
|
|
13
|
-
* - `
|
|
14
|
-
* - Omitted — free crop
|
|
12
|
+
* - `ImgCropperDynamicAspectRatio` — dropdown with supported ratios. `allowFreeCrop: true`
|
|
13
|
+
* adds a free-ratio option; `initial` is optional when free crop is enabled.
|
|
14
|
+
* - Omitted — free crop (no ratio constraint on the selection).
|
|
15
15
|
*
|
|
16
16
|
* ### Result
|
|
17
17
|
* - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import type { FileWithBlobUrl } from '../../../core/files';
|
|
2
|
-
import type {
|
|
3
|
-
export type ImageEditorDialogOptions =
|
|
4
|
-
type ImageEditorDialogOptionsBase = {
|
|
2
|
+
import type { CropperMode, ImgCropperDynamicAspectRatio } from '../img-cropper';
|
|
3
|
+
export type ImageEditorDialogOptions = {
|
|
5
4
|
url: string;
|
|
6
5
|
fillColor?: string;
|
|
7
6
|
showImageShadow?: boolean;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
mode?: 'cover';
|
|
11
|
-
aspectRatio?: number | ImgCropperCoverAspectRatio;
|
|
12
|
-
};
|
|
13
|
-
type ImageEditorDialogContainOptions = ImageEditorDialogOptionsBase & {
|
|
14
|
-
mode: 'contain';
|
|
15
|
-
aspectRatio?: number | ImgCropperContainAspectRatio;
|
|
7
|
+
mode?: CropperMode;
|
|
8
|
+
aspectRatio?: number | ImgCropperDynamicAspectRatio;
|
|
16
9
|
};
|
|
17
10
|
export type CroppedFile = FileWithBlobUrl & {
|
|
18
11
|
width: number;
|
|
@@ -22,4 +15,3 @@ export type ImageEditorDialogResult = {
|
|
|
22
15
|
croppedFile: CroppedFile;
|
|
23
16
|
selectedRatio: number | null;
|
|
24
17
|
};
|
|
25
|
-
export {};
|
|
@@ -38,16 +38,24 @@ const initCanvas = (canvasEl) => {
|
|
|
38
38
|
};
|
|
39
39
|
void initCropper();
|
|
40
40
|
}
|
|
41
|
-
return {
|
|
41
|
+
return {
|
|
42
|
+
destroy: () => {
|
|
43
|
+
cropper.destroy();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const observeResize = (el) => {
|
|
48
|
+
const observer = new ResizeObserver(() => {
|
|
49
|
+
if (cropper.ready) {
|
|
50
|
+
cropper.refit();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
observer.observe(el);
|
|
54
|
+
return { destroy: () => observer.disconnect() };
|
|
42
55
|
};
|
|
43
56
|
</script>
|
|
44
57
|
|
|
45
|
-
<div
|
|
46
|
-
class="img-cropper"
|
|
47
|
-
style:--img-cropper--fill-color={cropper.fillColor || undefined}
|
|
48
|
-
style:--img-cropper--ratio={cropper.effectiveCanvasRatio ?? undefined}
|
|
49
|
-
style:--img-cropper--natural-w={cropper.naturalWidth ? `${cropper.naturalWidth}px` : undefined}
|
|
50
|
-
style:--img-cropper--natural-h={cropper.naturalHeight ? `${cropper.naturalHeight}px` : undefined}>
|
|
58
|
+
<div class="img-cropper" use:observeResize style:--img-cropper--fill-color={cropper.fillColor || undefined}>
|
|
51
59
|
{#if cropper.loading || !cropper.ready}
|
|
52
60
|
<Loading positionAbsoluteCenter={true} />
|
|
53
61
|
{/if}
|
|
@@ -56,8 +64,9 @@ const initCanvas = (canvasEl) => {
|
|
|
56
64
|
<cropper-canvas
|
|
57
65
|
class="img-cropper__canvas"
|
|
58
66
|
class:img-cropper__canvas--visible={cropper.ready}
|
|
59
|
-
class:img-cropper__canvas--fitted={cropper.effectiveCanvasRatio !== null && cropper.naturalWidth > 0}
|
|
60
67
|
class:img-cropper__canvas--shadow={cropper.showImageShadow}
|
|
68
|
+
style:width={cropper.canvasGeometry.width ? `${cropper.canvasGeometry.width}px` : undefined}
|
|
69
|
+
style:height={cropper.canvasGeometry.height ? `${cropper.canvasGeometry.height}px` : undefined}
|
|
61
70
|
use:initCanvas
|
|
62
71
|
background={(cropper.showFillColor && cropper.isTransparentFill) || undefined}>
|
|
63
72
|
<cropper-image crossorigin={cropper.corsMode === 'native' ? 'anonymous' : null} initial-center-size={cropper.mode} scalable translatable rotatable>
|
|
@@ -79,8 +88,8 @@ const initCanvas = (canvasEl) => {
|
|
|
79
88
|
</cropper-selection>
|
|
80
89
|
</cropper-canvas>
|
|
81
90
|
|
|
82
|
-
{#if cropper.showImageShadow && cropper.ready}
|
|
83
|
-
<div class="img-cropper__shadow"
|
|
91
|
+
{#if cropper.showImageShadow && cropper.ready && cropper.canvasGeometry.width > 0}
|
|
92
|
+
<div class="img-cropper__shadow" style:width="{cropper.canvasGeometry.width}px" style:height="{cropper.canvasGeometry.height}px"></div>
|
|
84
93
|
{/if}
|
|
85
94
|
{/if}
|
|
86
95
|
|
|
@@ -116,20 +125,18 @@ The behavior is determined by the `ImgCropper` instance passed via the `cropper`
|
|
|
116
125
|
Aspect ratio is configured on the `ImgCropper` instance via the `aspectRatio` option:
|
|
117
126
|
|
|
118
127
|
- **Fixed number** (e.g. `16/9`) — locks the crop selection to this ratio.
|
|
119
|
-
- **Dynamic object** — provides a list of `supported`
|
|
120
|
-
The toolbar renders a ratio dropdown when
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- In `cover` mode, `initial` is required and free crop is not available.
|
|
125
|
-
- **Omitted** — defaults to free crop in `contain` mode, or fills the canvas in `cover` mode.
|
|
128
|
+
- **Dynamic object** (`ImgCropperDynamicAspectRatio`) — provides a list of `supported`
|
|
129
|
+
ratios and an optional `initial` value. The toolbar renders a ratio dropdown when
|
|
130
|
+
multiple options are available. `allowFreeCrop: true` adds a free-ratio option
|
|
131
|
+
that lets the user draw an unconstrained selection.
|
|
132
|
+
- **Omitted** — defaults to free crop (no ratio constraint on the selection).
|
|
126
133
|
|
|
127
134
|
#### Shadow overlay
|
|
128
135
|
|
|
129
136
|
When `showImageShadow` is enabled on the cropper instance, a dark semi-transparent
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
`box-shadow` is applied directly to the `<cropper-canvas>` element, extending beyond
|
|
138
|
+
the canvas bounds so the surrounding area is dimmed. The parent element should set
|
|
139
|
+
`overflow: hidden` to clip the shadow at its boundaries.
|
|
133
140
|
|
|
134
141
|
### Props
|
|
135
142
|
| Prop | Type | Default | Description |
|
|
@@ -144,10 +151,6 @@ None — canvas background, shadow, and sizing are controlled by the `ImgCropper
|
|
|
144
151
|
|
|
145
152
|
<style>.img-cropper {
|
|
146
153
|
--_fill-color: var(--img-cropper--fill-color);
|
|
147
|
-
--_ratio: var(--img-cropper--ratio);
|
|
148
|
-
--_natural-w: var(--img-cropper--natural-w, 100cqw);
|
|
149
|
-
--_natural-h: var(--img-cropper--natural-h, 100cqh);
|
|
150
|
-
container-type: size;
|
|
151
154
|
position: relative;
|
|
152
155
|
width: 100%;
|
|
153
156
|
height: 100%;
|
|
@@ -162,11 +165,6 @@ None — canvas background, shadow, and sizing are controlled by the `ImgCropper
|
|
|
162
165
|
background-color: var(--_fill-color);
|
|
163
166
|
visibility: hidden;
|
|
164
167
|
}
|
|
165
|
-
.img-cropper__canvas--fitted {
|
|
166
|
-
width: min(100cqw, 100cqh * var(--_ratio), max(var(--_natural-w), var(--_natural-h) * var(--_ratio)));
|
|
167
|
-
height: auto;
|
|
168
|
-
aspect-ratio: var(--_ratio);
|
|
169
|
-
}
|
|
170
168
|
.img-cropper__canvas--visible {
|
|
171
169
|
visibility: visible;
|
|
172
170
|
}
|
|
@@ -178,17 +176,10 @@ None — canvas background, shadow, and sizing are controlled by the `ImgCropper
|
|
|
178
176
|
top: 50%;
|
|
179
177
|
left: 50%;
|
|
180
178
|
transform: translate(-50%, -50%);
|
|
181
|
-
width: min(100cqw, 100cqh * var(--_ratio), max(var(--_natural-w), var(--_natural-h) * var(--_ratio)));
|
|
182
|
-
aspect-ratio: var(--_ratio);
|
|
183
179
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.8);
|
|
184
180
|
pointer-events: none;
|
|
185
181
|
z-index: 2;
|
|
186
182
|
}
|
|
187
|
-
.img-cropper__shadow--full {
|
|
188
|
-
width: 100cqw;
|
|
189
|
-
height: 100cqh;
|
|
190
|
-
aspect-ratio: auto;
|
|
191
|
-
}
|
|
192
183
|
.img-cropper__controls {
|
|
193
184
|
position: absolute;
|
|
194
185
|
bottom: 0;
|
|
@@ -28,20 +28,18 @@ type Props = {
|
|
|
28
28
|
* Aspect ratio is configured on the `ImgCropper` instance via the `aspectRatio` option:
|
|
29
29
|
*
|
|
30
30
|
* - **Fixed number** (e.g. `16/9`) — locks the crop selection to this ratio.
|
|
31
|
-
* - **Dynamic object** — provides a list of `supported`
|
|
32
|
-
* The toolbar renders a ratio dropdown when
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* - In `cover` mode, `initial` is required and free crop is not available.
|
|
37
|
-
* - **Omitted** — defaults to free crop in `contain` mode, or fills the canvas in `cover` mode.
|
|
31
|
+
* - **Dynamic object** (`ImgCropperDynamicAspectRatio`) — provides a list of `supported`
|
|
32
|
+
* ratios and an optional `initial` value. The toolbar renders a ratio dropdown when
|
|
33
|
+
* multiple options are available. `allowFreeCrop: true` adds a free-ratio option
|
|
34
|
+
* that lets the user draw an unconstrained selection.
|
|
35
|
+
* - **Omitted** — defaults to free crop (no ratio constraint on the selection).
|
|
38
36
|
*
|
|
39
37
|
* #### Shadow overlay
|
|
40
38
|
*
|
|
41
39
|
* When `showImageShadow` is enabled on the cropper instance, a dark semi-transparent
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* `box-shadow` is applied directly to the `<cropper-canvas>` element, extending beyond
|
|
41
|
+
* the canvas bounds so the surrounding area is dimmed. The parent element should set
|
|
42
|
+
* `overflow: hidden` to clip the shadow at its boundaries.
|
|
45
43
|
*
|
|
46
44
|
* ### Props
|
|
47
45
|
* | Prop | Type | Default | Description |
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CanvasGeometry, DragMode, ImgCropperWorker, ImgCropperWorkerParams } from './img-cropper-worker.svelte';
|
|
2
|
+
import type { CropperCanvas, CropperImage, CropperSelection } from 'cropperjs';
|
|
3
|
+
export declare abstract class ImgCropperBaseWorker implements ImgCropperWorker {
|
|
4
|
+
cropBoxVisible: boolean;
|
|
5
|
+
dragMode: DragMode;
|
|
6
|
+
canvasGeometry: CanvasGeometry;
|
|
7
|
+
destroy: () => void;
|
|
8
|
+
protected _image: CropperImage;
|
|
9
|
+
protected _selection: CropperSelection;
|
|
10
|
+
protected _canvas: CropperCanvas;
|
|
11
|
+
protected _selectHandle: HTMLElement | null;
|
|
12
|
+
protected _originalSrc: string;
|
|
13
|
+
protected _visualWidth: number;
|
|
14
|
+
protected _visualHeight: number;
|
|
15
|
+
protected abstract readonly _centerMode: 'contain' | 'cover';
|
|
16
|
+
constructor(params: ImgCropperWorkerParams);
|
|
17
|
+
ready: () => Promise<void>;
|
|
18
|
+
rotate: () => Promise<void>;
|
|
19
|
+
zoomIn: () => void;
|
|
20
|
+
zoomOut: () => void;
|
|
21
|
+
reset: () => Promise<void>;
|
|
22
|
+
crop: (options?: {
|
|
23
|
+
fillColor?: string;
|
|
24
|
+
}) => Promise<{
|
|
25
|
+
dataUrl: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
}>;
|
|
29
|
+
save: (options?: {
|
|
30
|
+
fillColor?: string;
|
|
31
|
+
}) => Promise<{
|
|
32
|
+
dataUrl: string;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
}>;
|
|
36
|
+
clearSelection: () => void;
|
|
37
|
+
refit: () => Promise<void>;
|
|
38
|
+
enableCropMode: () => void;
|
|
39
|
+
protected _wrapTransformOp: (fn: () => void) => void;
|
|
40
|
+
protected _applyMoveMode: () => void;
|
|
41
|
+
protected _fitImage: () => Promise<void>;
|
|
42
|
+
protected abstract _computeCanvasSize(): void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ColorHelper } from '../../../core/utils';
|
|
2
|
+
import { computeImageScale, computeNaturalOutputSize, handleSelectionBoundary } from './img-cropper-utils';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
export class ImgCropperBaseWorker {
|
|
5
|
+
cropBoxVisible = $state(false);
|
|
6
|
+
dragMode = $state('move');
|
|
7
|
+
canvasGeometry = $state.raw({ aspectRatio: null, width: 0, height: 0 });
|
|
8
|
+
destroy;
|
|
9
|
+
_image;
|
|
10
|
+
_selection;
|
|
11
|
+
_canvas;
|
|
12
|
+
_selectHandle;
|
|
13
|
+
_originalSrc;
|
|
14
|
+
_visualWidth = 0;
|
|
15
|
+
_visualHeight = 0;
|
|
16
|
+
constructor(params) {
|
|
17
|
+
this._originalSrc = params.originalSrc;
|
|
18
|
+
this._image = params.image;
|
|
19
|
+
this._selection = params.selection;
|
|
20
|
+
this._canvas = params.canvas;
|
|
21
|
+
this._selectHandle = params.selectHandle;
|
|
22
|
+
this.canvasGeometry = { aspectRatio: params.aspectRatio, width: 0, height: 0 };
|
|
23
|
+
const onSelectionChange = (e) => {
|
|
24
|
+
const { width, height } = e.detail;
|
|
25
|
+
this.cropBoxVisible = width > 0 && height > 0;
|
|
26
|
+
};
|
|
27
|
+
const onBoundaryCheck = (e) => {
|
|
28
|
+
handleSelectionBoundary({ event: e, selection: this._selection, canvas: this._canvas });
|
|
29
|
+
};
|
|
30
|
+
this._selection.addEventListener('change', onSelectionChange);
|
|
31
|
+
this._selection.addEventListener('change', onBoundaryCheck);
|
|
32
|
+
this.destroy = () => {
|
|
33
|
+
this._selection.removeEventListener('change', onSelectionChange);
|
|
34
|
+
this._selection.removeEventListener('change', onBoundaryCheck);
|
|
35
|
+
};
|
|
36
|
+
this._applyMoveMode();
|
|
37
|
+
}
|
|
38
|
+
ready = async () => {
|
|
39
|
+
this._image.src = this._originalSrc;
|
|
40
|
+
await this._fitImage();
|
|
41
|
+
this._selection.$clear();
|
|
42
|
+
};
|
|
43
|
+
rotate = async () => {
|
|
44
|
+
const prevW = this._visualWidth;
|
|
45
|
+
this._visualWidth = this._visualHeight;
|
|
46
|
+
this._visualHeight = prevW;
|
|
47
|
+
this._computeCanvasSize();
|
|
48
|
+
await tick();
|
|
49
|
+
this._wrapTransformOp(() => {
|
|
50
|
+
this._image.$rotate('90deg');
|
|
51
|
+
this._image.$center(this._centerMode);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
zoomIn = () => {
|
|
55
|
+
this._image.$zoom(0.1);
|
|
56
|
+
};
|
|
57
|
+
zoomOut = () => {
|
|
58
|
+
this._image.$zoom(-0.1);
|
|
59
|
+
};
|
|
60
|
+
reset = async () => {
|
|
61
|
+
this._selection.$clear();
|
|
62
|
+
this._applyMoveMode();
|
|
63
|
+
this._image.src = this._originalSrc;
|
|
64
|
+
await this._fitImage();
|
|
65
|
+
};
|
|
66
|
+
crop = async (options) => {
|
|
67
|
+
const fillColor = options?.fillColor;
|
|
68
|
+
const imageScale = computeImageScale(this._image.$getTransform());
|
|
69
|
+
const outputSize = computeNaturalOutputSize({ displayWidth: this._selection.width, displayHeight: this._selection.height, imageScale });
|
|
70
|
+
const canvas = await this._selection.$toCanvas({
|
|
71
|
+
width: outputSize.width,
|
|
72
|
+
height: outputSize.height,
|
|
73
|
+
beforeDraw: (ctx, cvs) => {
|
|
74
|
+
if (fillColor && !ColorHelper.isTransparent(fillColor)) {
|
|
75
|
+
ctx.fillStyle = fillColor;
|
|
76
|
+
ctx.fillRect(0, 0, cvs.width, cvs.height);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const dataUrl = canvas.toDataURL('image/png', 0.9);
|
|
81
|
+
this._applyMoveMode();
|
|
82
|
+
this._image.src = dataUrl;
|
|
83
|
+
await this._fitImage();
|
|
84
|
+
this._selection.$clear();
|
|
85
|
+
return { dataUrl, width: canvas.width, height: canvas.height };
|
|
86
|
+
};
|
|
87
|
+
save = async (options) => {
|
|
88
|
+
const fillColor = options?.fillColor;
|
|
89
|
+
const beforeDraw = (ctx, cvs) => {
|
|
90
|
+
if (fillColor && !ColorHelper.isTransparent(fillColor)) {
|
|
91
|
+
ctx.fillStyle = fillColor;
|
|
92
|
+
ctx.fillRect(0, 0, cvs.width, cvs.height);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
// Compute output dimensions before micro-zoom so the result matches
|
|
96
|
+
// the source pixel density, not the display size.
|
|
97
|
+
const imageScale = computeImageScale(this._image.$getTransform());
|
|
98
|
+
const displayWidth = this.cropBoxVisible ? this._selection.width : this._canvas.offsetWidth;
|
|
99
|
+
const displayHeight = this.cropBoxVisible ? this._selection.height : this._canvas.offsetHeight;
|
|
100
|
+
const outputSize = computeNaturalOutputSize({ displayWidth, displayHeight, imageScale });
|
|
101
|
+
const toCanvasOptions = { width: outputSize.width, height: outputSize.height, beforeDraw };
|
|
102
|
+
// CropperJS $toCanvas exports only the visible portion of the canvas.
|
|
103
|
+
// Sub-pixel rounding can leave a 1px transparent edge when the image
|
|
104
|
+
// tightly fits the canvas. A micro-zoom nudges the image slightly past
|
|
105
|
+
// the boundary so the export captures the full area; the zoom is
|
|
106
|
+
// reversed immediately after.
|
|
107
|
+
const needsMicroZoom = !this.cropBoxVisible;
|
|
108
|
+
if (needsMicroZoom) {
|
|
109
|
+
// Forward zoom increases coverage — safe without suspending cover check
|
|
110
|
+
this._image.$zoom(0.003);
|
|
111
|
+
}
|
|
112
|
+
const canvas = this.cropBoxVisible ? await this._selection.$toCanvas(toCanvasOptions) : await this._canvas.$toCanvas(toCanvasOptions);
|
|
113
|
+
if (needsMicroZoom) {
|
|
114
|
+
// Reverse zoom shrinks image — must suspend cover check to avoid clamping
|
|
115
|
+
this._wrapTransformOp(() => this._image.$zoom(-0.003));
|
|
116
|
+
}
|
|
117
|
+
const dataUrl = canvas.toDataURL('image/png', 0.9);
|
|
118
|
+
return { dataUrl, width: canvas.width, height: canvas.height };
|
|
119
|
+
};
|
|
120
|
+
clearSelection = () => {
|
|
121
|
+
this._selection.$clear();
|
|
122
|
+
this._applyMoveMode();
|
|
123
|
+
};
|
|
124
|
+
refit = async () => {
|
|
125
|
+
this._selection.$clear();
|
|
126
|
+
this._applyMoveMode();
|
|
127
|
+
this._computeCanvasSize();
|
|
128
|
+
await tick();
|
|
129
|
+
this._wrapTransformOp(() => {
|
|
130
|
+
this._image.$resetTransform();
|
|
131
|
+
this._image.$center(this._centerMode);
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
enableCropMode = () => {
|
|
135
|
+
this._image.translatable = false;
|
|
136
|
+
if (this._selectHandle) {
|
|
137
|
+
this._selectHandle.setAttribute('action', 'select');
|
|
138
|
+
}
|
|
139
|
+
this.dragMode = 'crop';
|
|
140
|
+
};
|
|
141
|
+
_wrapTransformOp = (fn) => {
|
|
142
|
+
fn();
|
|
143
|
+
};
|
|
144
|
+
_applyMoveMode = () => {
|
|
145
|
+
this._image.translatable = true;
|
|
146
|
+
if (this._selectHandle) {
|
|
147
|
+
this._selectHandle.setAttribute('action', 'move');
|
|
148
|
+
}
|
|
149
|
+
this.dragMode = 'move';
|
|
150
|
+
this.cropBoxVisible = false;
|
|
151
|
+
};
|
|
152
|
+
_fitImage = async () => {
|
|
153
|
+
const img = await this._image.$ready();
|
|
154
|
+
this._visualWidth = img.naturalWidth;
|
|
155
|
+
this._visualHeight = img.naturalHeight;
|
|
156
|
+
this._computeCanvasSize();
|
|
157
|
+
await tick();
|
|
158
|
+
this._wrapTransformOp(() => {
|
|
159
|
+
this._image.$resetTransform();
|
|
160
|
+
this._image.$center(this._centerMode);
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -1,40 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
naturalWidth: number;
|
|
6
|
-
naturalHeight: number;
|
|
7
|
-
canvasRatio: number | null;
|
|
8
|
-
destroy: () => void;
|
|
9
|
-
private _image;
|
|
10
|
-
private _selection;
|
|
11
|
-
private _canvas;
|
|
12
|
-
private _selectHandle;
|
|
13
|
-
private _originalSrc;
|
|
1
|
+
import { ImgCropperBaseWorker } from './img-cropper-base-worker.svelte';
|
|
2
|
+
import type { ImgCropperWorkerParams } from './img-cropper-worker.svelte';
|
|
3
|
+
export declare class ImgCropperContainWorker extends ImgCropperBaseWorker {
|
|
4
|
+
protected readonly _centerMode: "contain";
|
|
14
5
|
constructor(params: ImgCropperWorkerParams);
|
|
15
|
-
|
|
16
|
-
rotate: () => Promise<void>;
|
|
17
|
-
zoomIn: () => void;
|
|
18
|
-
zoomOut: () => void;
|
|
19
|
-
reset: () => Promise<void>;
|
|
20
|
-
crop: (options?: {
|
|
21
|
-
fillColor?: string;
|
|
22
|
-
freeRatio?: boolean;
|
|
23
|
-
}) => Promise<{
|
|
24
|
-
dataUrl: string;
|
|
25
|
-
width: number;
|
|
26
|
-
height: number;
|
|
27
|
-
}>;
|
|
28
|
-
save: (options?: {
|
|
29
|
-
fillColor?: string;
|
|
30
|
-
}) => Promise<{
|
|
31
|
-
dataUrl: string;
|
|
32
|
-
width: number;
|
|
33
|
-
height: number;
|
|
34
|
-
}>;
|
|
35
|
-
clearSelection: () => void;
|
|
36
|
-
enableCropMode: () => void;
|
|
37
|
-
private _applyMoveMode;
|
|
38
|
-
private _fitImage;
|
|
39
|
-
private _centerForCurrentRotation;
|
|
6
|
+
protected _computeCanvasSize: () => void;
|
|
40
7
|
}
|
|
@@ -1,159 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
cropBoxVisible = $state(false);
|
|
6
|
-
dragMode = $state('move');
|
|
7
|
-
naturalWidth = $state(0);
|
|
8
|
-
naturalHeight = $state(0);
|
|
9
|
-
canvasRatio = $state(null);
|
|
10
|
-
destroy;
|
|
11
|
-
_image;
|
|
12
|
-
_selection;
|
|
13
|
-
_canvas;
|
|
14
|
-
_selectHandle;
|
|
15
|
-
_originalSrc;
|
|
1
|
+
import { ImgCropperBaseWorker } from './img-cropper-base-worker.svelte';
|
|
2
|
+
import { fitInContainer } from './img-cropper-utils';
|
|
3
|
+
export class ImgCropperContainWorker extends ImgCropperBaseWorker {
|
|
4
|
+
_centerMode = 'contain';
|
|
16
5
|
constructor(params) {
|
|
17
|
-
|
|
18
|
-
this._image = params.image;
|
|
19
|
-
this._selection = params.selection;
|
|
20
|
-
this._canvas = params.canvas;
|
|
21
|
-
this._selectHandle = params.selectHandle;
|
|
22
|
-
const onSelectionChange = (e) => {
|
|
23
|
-
const { width, height } = e.detail;
|
|
24
|
-
this.cropBoxVisible = width > 0 && height > 0;
|
|
25
|
-
};
|
|
26
|
-
const onBoundaryCheck = (e) => {
|
|
27
|
-
handleSelectionBoundary({ event: e, selection: this._selection, canvas: this._canvas });
|
|
28
|
-
};
|
|
29
|
-
this._selection.addEventListener('change', onSelectionChange);
|
|
30
|
-
this._selection.addEventListener('change', onBoundaryCheck);
|
|
31
|
-
this.destroy = () => {
|
|
32
|
-
this._selection.removeEventListener('change', onSelectionChange);
|
|
33
|
-
this._selection.removeEventListener('change', onBoundaryCheck);
|
|
34
|
-
};
|
|
35
|
-
this._applyMoveMode();
|
|
6
|
+
super(params);
|
|
36
7
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
42
|
-
rotate = async () => {
|
|
43
|
-
if (this.canvasRatio !== null) {
|
|
44
|
-
this.canvasRatio = 1 / this.canvasRatio;
|
|
45
|
-
const prevW = this.naturalWidth;
|
|
46
|
-
this.naturalWidth = this.naturalHeight;
|
|
47
|
-
this.naturalHeight = prevW;
|
|
48
|
-
await tick();
|
|
8
|
+
_computeCanvasSize = () => {
|
|
9
|
+
const container = this._canvas.parentElement;
|
|
10
|
+
if (!container) {
|
|
11
|
+
return;
|
|
49
12
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
};
|
|
59
|
-
reset = async () => {
|
|
60
|
-
this._selection.$clear();
|
|
61
|
-
this._applyMoveMode();
|
|
62
|
-
this.canvasRatio = null;
|
|
63
|
-
this._image.src = this._originalSrc;
|
|
64
|
-
await this._fitImage();
|
|
65
|
-
};
|
|
66
|
-
crop = async (options) => {
|
|
67
|
-
const fillColor = options?.fillColor;
|
|
68
|
-
const selectionRatio = this._selection.width / this._selection.height;
|
|
69
|
-
const imageScale = computeImageScale(this._image.$getTransform());
|
|
70
|
-
const outputSize = computeNaturalOutputSize({ displayWidth: this._selection.width, displayHeight: this._selection.height, imageScale });
|
|
71
|
-
const canvas = await this._selection.$toCanvas({
|
|
72
|
-
width: outputSize.width,
|
|
73
|
-
height: outputSize.height,
|
|
74
|
-
beforeDraw: (ctx, cvs) => {
|
|
75
|
-
if (fillColor && !ColorHelper.isTransparent(fillColor)) {
|
|
76
|
-
ctx.fillStyle = fillColor;
|
|
77
|
-
ctx.fillRect(0, 0, cvs.width, cvs.height);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
const dataUrl = canvas.toDataURL('image/png', 0.9);
|
|
82
|
-
this._applyMoveMode();
|
|
83
|
-
if (options?.freeRatio) {
|
|
84
|
-
this.canvasRatio = selectionRatio;
|
|
13
|
+
const containerWidth = container.clientWidth;
|
|
14
|
+
const containerHeight = container.clientHeight;
|
|
15
|
+
const visualWidth = this._visualWidth;
|
|
16
|
+
const visualHeight = this._visualHeight;
|
|
17
|
+
const aspectRatio = this.canvasGeometry.aspectRatio;
|
|
18
|
+
if (containerWidth <= 0 || containerHeight <= 0 || visualWidth <= 0 || visualHeight <= 0) {
|
|
19
|
+
this.canvasGeometry = { aspectRatio, width: 0, height: 0 };
|
|
20
|
+
return;
|
|
85
21
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const fillColor = options?.fillColor;
|
|
93
|
-
const beforeDraw = (ctx, cvs) => {
|
|
94
|
-
if (fillColor && !ColorHelper.isTransparent(fillColor)) {
|
|
95
|
-
ctx.fillStyle = fillColor;
|
|
96
|
-
ctx.fillRect(0, 0, cvs.width, cvs.height);
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
const imageScale = computeImageScale(this._image.$getTransform());
|
|
100
|
-
const displayWidth = this.cropBoxVisible ? this._selection.width : this._canvas.offsetWidth;
|
|
101
|
-
const displayHeight = this.cropBoxVisible ? this._selection.height : this._canvas.offsetHeight;
|
|
102
|
-
const outputSize = computeNaturalOutputSize({ displayWidth, displayHeight, imageScale });
|
|
103
|
-
const toCanvasOptions = { width: outputSize.width, height: outputSize.height, beforeDraw };
|
|
104
|
-
const canvas = this.cropBoxVisible ? await this._selection.$toCanvas(toCanvasOptions) : await this._canvas.$toCanvas(toCanvasOptions);
|
|
105
|
-
const dataUrl = canvas.toDataURL('image/png', 0.9);
|
|
106
|
-
return { dataUrl, width: canvas.width, height: canvas.height };
|
|
107
|
-
};
|
|
108
|
-
clearSelection = () => {
|
|
109
|
-
this._selection.$clear();
|
|
110
|
-
this._applyMoveMode();
|
|
111
|
-
};
|
|
112
|
-
enableCropMode = () => {
|
|
113
|
-
this._image.translatable = false;
|
|
114
|
-
if (this._selectHandle) {
|
|
115
|
-
this._selectHandle.setAttribute('action', 'select');
|
|
116
|
-
}
|
|
117
|
-
this.dragMode = 'crop';
|
|
118
|
-
};
|
|
119
|
-
_applyMoveMode = () => {
|
|
120
|
-
this._image.translatable = true;
|
|
121
|
-
if (this._selectHandle) {
|
|
122
|
-
this._selectHandle.setAttribute('action', 'move');
|
|
123
|
-
}
|
|
124
|
-
this.dragMode = 'move';
|
|
125
|
-
this.cropBoxVisible = false;
|
|
126
|
-
};
|
|
127
|
-
_fitImage = async () => {
|
|
128
|
-
const img = await this._image.$ready();
|
|
129
|
-
this.naturalWidth = img.naturalWidth;
|
|
130
|
-
this.naturalHeight = img.naturalHeight;
|
|
131
|
-
await tick();
|
|
132
|
-
this._image.$resetTransform();
|
|
133
|
-
this._centerForCurrentRotation();
|
|
134
|
-
};
|
|
135
|
-
// Choose $center() (natural size) or $center('contain') (scale-to-fit) based on
|
|
136
|
-
// whether the image's visual bounds fit inside the canvas at the current rotation.
|
|
137
|
-
// When canvasRatio is active, naturalWidth/Height are already swapped to match
|
|
138
|
-
// the visual orientation. Otherwise, derive visual dims from the rotation angle.
|
|
139
|
-
_centerForCurrentRotation = () => {
|
|
140
|
-
let visualW = this.naturalWidth;
|
|
141
|
-
let visualH = this.naturalHeight;
|
|
142
|
-
if (this.canvasRatio === null) {
|
|
143
|
-
const matrix = this._image.$getTransform();
|
|
144
|
-
const isSwapped = Math.abs(Math.sin(Math.atan2(matrix[1], matrix[0]))) > 0.5;
|
|
145
|
-
if (isSwapped) {
|
|
146
|
-
visualW = this.naturalHeight;
|
|
147
|
-
visualH = this.naturalWidth;
|
|
148
|
-
}
|
|
22
|
+
if (aspectRatio !== null) {
|
|
23
|
+
// Fixed ratio: fit in container, cap at natural image bounds
|
|
24
|
+
const fitted = fitInContainer({ ratio: aspectRatio, containerWidth, containerHeight });
|
|
25
|
+
const maxWidth = Math.max(visualWidth, visualHeight * aspectRatio);
|
|
26
|
+
const width = Math.min(fitted.width, maxWidth);
|
|
27
|
+
this.canvasGeometry = { aspectRatio, width, height: width / aspectRatio };
|
|
149
28
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this._image.$center();
|
|
29
|
+
else if (visualWidth <= containerWidth && visualHeight <= containerHeight) {
|
|
30
|
+
// Free ratio, image fits naturally — no upscale
|
|
31
|
+
this.canvasGeometry = { aspectRatio, width: visualWidth, height: visualHeight };
|
|
154
32
|
}
|
|
155
33
|
else {
|
|
156
|
-
|
|
34
|
+
// Free ratio, image too large — scale down to fit
|
|
35
|
+
const fitted = fitInContainer({ ratio: visualWidth / visualHeight, containerWidth, containerHeight });
|
|
36
|
+
this.canvasGeometry = { aspectRatio, width: fitted.width, height: fitted.height };
|
|
157
37
|
}
|
|
158
38
|
};
|
|
159
39
|
}
|
|
@@ -1,40 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
naturalWidth: number;
|
|
6
|
-
naturalHeight: number;
|
|
7
|
-
canvasRatio: number | null;
|
|
8
|
-
destroy: () => void;
|
|
9
|
-
private _image;
|
|
10
|
-
private _selection;
|
|
11
|
-
private _canvas;
|
|
12
|
-
private _selectHandle;
|
|
13
|
-
private _originalSrc;
|
|
14
|
-
private _withCoverCheckSuspended;
|
|
1
|
+
import { ImgCropperBaseWorker } from './img-cropper-base-worker.svelte';
|
|
2
|
+
import type { ImgCropperWorkerParams } from './img-cropper-worker.svelte';
|
|
3
|
+
export declare class ImgCropperCoverWorker extends ImgCropperBaseWorker {
|
|
4
|
+
protected readonly _centerMode: "cover";
|
|
15
5
|
constructor(params: ImgCropperWorkerParams);
|
|
16
|
-
|
|
17
|
-
rotate: () => void;
|
|
18
|
-
zoomIn: () => void;
|
|
19
|
-
zoomOut: () => void;
|
|
20
|
-
reset: () => Promise<void>;
|
|
21
|
-
crop: (options?: {
|
|
22
|
-
fillColor?: string;
|
|
23
|
-
freeRatio?: boolean;
|
|
24
|
-
}) => Promise<{
|
|
25
|
-
dataUrl: string;
|
|
26
|
-
width: number;
|
|
27
|
-
height: number;
|
|
28
|
-
}>;
|
|
29
|
-
save: (options?: {
|
|
30
|
-
fillColor?: string;
|
|
31
|
-
}) => Promise<{
|
|
32
|
-
dataUrl: string;
|
|
33
|
-
width: number;
|
|
34
|
-
height: number;
|
|
35
|
-
}>;
|
|
36
|
-
clearSelection: () => void;
|
|
37
|
-
enableCropMode: () => void;
|
|
38
|
-
private _applyMoveMode;
|
|
39
|
-
private _fitImage;
|
|
6
|
+
protected _computeCanvasSize: () => void;
|
|
40
7
|
}
|
|
@@ -1,32 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
cropBoxVisible = $state(false);
|
|
6
|
-
dragMode = $state('move');
|
|
7
|
-
naturalWidth = $state(0);
|
|
8
|
-
naturalHeight = $state(0);
|
|
9
|
-
canvasRatio = $state(null);
|
|
10
|
-
destroy;
|
|
11
|
-
_image;
|
|
12
|
-
_selection;
|
|
13
|
-
_canvas;
|
|
14
|
-
_selectHandle;
|
|
15
|
-
_originalSrc;
|
|
16
|
-
_withCoverCheckSuspended;
|
|
1
|
+
import { ImgCropperBaseWorker } from './img-cropper-base-worker.svelte';
|
|
2
|
+
import { fitInContainer, handleCoverCheck } from './img-cropper-utils';
|
|
3
|
+
export class ImgCropperCoverWorker extends ImgCropperBaseWorker {
|
|
4
|
+
_centerMode = 'cover';
|
|
17
5
|
constructor(params) {
|
|
18
|
-
|
|
19
|
-
this._image = params.image;
|
|
20
|
-
this._selection = params.selection;
|
|
21
|
-
this._canvas = params.canvas;
|
|
22
|
-
this._selectHandle = params.selectHandle;
|
|
23
|
-
const onSelectionChange = (e) => {
|
|
24
|
-
const { width, height } = e.detail;
|
|
25
|
-
this.cropBoxVisible = width > 0 && height > 0;
|
|
26
|
-
};
|
|
27
|
-
const onBoundaryCheck = (e) => {
|
|
28
|
-
handleSelectionBoundary({ event: e, selection: this._selection, canvas: this._canvas });
|
|
29
|
-
};
|
|
6
|
+
super(params);
|
|
30
7
|
// Dedup: handleCoverCheck calls $setTransform(clamped) which synchronously
|
|
31
8
|
// fires another transform event with a matrix that differs by ~0.0001px
|
|
32
9
|
// due to floating point. Without this guard CropperJS re-fires the same
|
|
@@ -41,123 +18,48 @@ export class ImgCropperCoverWorker {
|
|
|
41
18
|
handleCoverCheck({ event: e, image: this._image, canvas: this._canvas });
|
|
42
19
|
prevMatrix = null;
|
|
43
20
|
};
|
|
44
|
-
this._selection.addEventListener('change', onSelectionChange);
|
|
45
|
-
this._selection.addEventListener('change', onBoundaryCheck);
|
|
46
21
|
this._image.addEventListener('transform', onCoverCheck);
|
|
47
|
-
this.
|
|
22
|
+
this._wrapTransformOp = (fn) => {
|
|
48
23
|
this._image.removeEventListener('transform', onCoverCheck);
|
|
49
|
-
|
|
50
|
-
|
|
24
|
+
try {
|
|
25
|
+
fn();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
this._image.addEventListener('transform', onCoverCheck);
|
|
29
|
+
}
|
|
51
30
|
};
|
|
31
|
+
const baseDestroy = this.destroy;
|
|
52
32
|
this.destroy = () => {
|
|
53
|
-
|
|
54
|
-
this._selection.removeEventListener('change', onBoundaryCheck);
|
|
33
|
+
baseDestroy();
|
|
55
34
|
this._image.removeEventListener('transform', onCoverCheck);
|
|
56
35
|
};
|
|
57
|
-
this._applyMoveMode();
|
|
58
36
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
};
|
|
64
|
-
rotate = () => {
|
|
65
|
-
this._withCoverCheckSuspended(() => {
|
|
66
|
-
this._image.$rotate('90deg');
|
|
67
|
-
this._image.$center('cover');
|
|
68
|
-
});
|
|
69
|
-
};
|
|
70
|
-
zoomIn = () => {
|
|
71
|
-
this._image.$zoom(0.1);
|
|
72
|
-
};
|
|
73
|
-
zoomOut = () => {
|
|
74
|
-
this._image.$zoom(-0.1);
|
|
75
|
-
};
|
|
76
|
-
reset = async () => {
|
|
77
|
-
this._selection.$clear();
|
|
78
|
-
this._applyMoveMode();
|
|
79
|
-
this._image.src = this._originalSrc;
|
|
80
|
-
await this._fitImage();
|
|
81
|
-
};
|
|
82
|
-
crop = async (options) => {
|
|
83
|
-
const fillColor = options?.fillColor;
|
|
84
|
-
const imageScale = computeImageScale(this._image.$getTransform());
|
|
85
|
-
const outputSize = computeNaturalOutputSize({ displayWidth: this._selection.width, displayHeight: this._selection.height, imageScale });
|
|
86
|
-
const canvas = await this._selection.$toCanvas({
|
|
87
|
-
width: outputSize.width,
|
|
88
|
-
height: outputSize.height,
|
|
89
|
-
beforeDraw: (ctx, cvs) => {
|
|
90
|
-
if (fillColor && !ColorHelper.isTransparent(fillColor)) {
|
|
91
|
-
ctx.fillStyle = fillColor;
|
|
92
|
-
ctx.fillRect(0, 0, cvs.width, cvs.height);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
const dataUrl = canvas.toDataURL('image/png', 0.9);
|
|
97
|
-
this._applyMoveMode();
|
|
98
|
-
this._image.src = dataUrl;
|
|
99
|
-
await this._fitImage();
|
|
100
|
-
this._selection.$clear();
|
|
101
|
-
return { dataUrl, width: canvas.width, height: canvas.height };
|
|
102
|
-
};
|
|
103
|
-
save = async (options) => {
|
|
104
|
-
const fillColor = options?.fillColor;
|
|
105
|
-
const beforeDraw = (ctx, cvs) => {
|
|
106
|
-
if (fillColor && !ColorHelper.isTransparent(fillColor)) {
|
|
107
|
-
ctx.fillStyle = fillColor;
|
|
108
|
-
ctx.fillRect(0, 0, cvs.width, cvs.height);
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
// Compute natural output dimensions before any micro-zoom so the
|
|
112
|
-
// output matches the source pixel density, not the display size.
|
|
113
|
-
const imageScale = computeImageScale(this._image.$getTransform());
|
|
114
|
-
const displayWidth = this.cropBoxVisible ? this._selection.width : this._canvas.offsetWidth;
|
|
115
|
-
const displayHeight = this.cropBoxVisible ? this._selection.height : this._canvas.offsetHeight;
|
|
116
|
-
const outputSize = computeNaturalOutputSize({ displayWidth, displayHeight, imageScale });
|
|
117
|
-
const toCanvasOptions = { width: outputSize.width, height: outputSize.height, beforeDraw };
|
|
118
|
-
// CropperJS $toCanvas exports only the visible portion of the canvas.
|
|
119
|
-
// In cover mode the image exactly covers the canvas, so sub-pixel rounding
|
|
120
|
-
// can leave a 1px transparent edge. A micro-zoom nudges the image slightly
|
|
121
|
-
// past the canvas boundary so the export captures the full area; the zoom
|
|
122
|
-
// is reversed immediately after.
|
|
123
|
-
const needsCoverZoom = !this.cropBoxVisible;
|
|
124
|
-
if (needsCoverZoom) {
|
|
125
|
-
this._image.$zoom(0.003);
|
|
37
|
+
_computeCanvasSize = () => {
|
|
38
|
+
const container = this._canvas.parentElement;
|
|
39
|
+
if (!container) {
|
|
40
|
+
return;
|
|
126
41
|
}
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
42
|
+
const containerWidth = container.clientWidth;
|
|
43
|
+
const containerHeight = container.clientHeight;
|
|
44
|
+
const visualWidth = this._visualWidth;
|
|
45
|
+
const visualHeight = this._visualHeight;
|
|
46
|
+
const aspectRatio = this.canvasGeometry.aspectRatio;
|
|
47
|
+
if (containerWidth <= 0 || containerHeight <= 0) {
|
|
48
|
+
this.canvasGeometry = { aspectRatio, width: 0, height: 0 };
|
|
49
|
+
return;
|
|
130
50
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
};
|
|
138
|
-
enableCropMode = () => {
|
|
139
|
-
this._image.translatable = false;
|
|
140
|
-
if (this._selectHandle) {
|
|
141
|
-
this._selectHandle.setAttribute('action', 'select');
|
|
51
|
+
if (aspectRatio !== null) {
|
|
52
|
+
const fitted = fitInContainer({ ratio: aspectRatio, containerWidth, containerHeight });
|
|
53
|
+
// Cap so the image can cover the canvas without upscaling
|
|
54
|
+
const maxWidth = visualWidth > 0 && visualHeight > 0 ? Math.min(visualWidth, visualHeight * aspectRatio) : fitted.width;
|
|
55
|
+
const width = Math.min(fitted.width, maxWidth);
|
|
56
|
+
this.canvasGeometry = { aspectRatio, width, height: width / aspectRatio };
|
|
142
57
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this._selectHandle.setAttribute('action', 'move');
|
|
58
|
+
else {
|
|
59
|
+
// Cap each dimension at visual size to avoid upscaling small images
|
|
60
|
+
const width = visualWidth > 0 ? Math.min(containerWidth, visualWidth) : containerWidth;
|
|
61
|
+
const height = visualHeight > 0 ? Math.min(containerHeight, visualHeight) : containerHeight;
|
|
62
|
+
this.canvasGeometry = { aspectRatio, width, height };
|
|
149
63
|
}
|
|
150
|
-
this.dragMode = 'move';
|
|
151
|
-
this.cropBoxVisible = false;
|
|
152
|
-
};
|
|
153
|
-
_fitImage = async () => {
|
|
154
|
-
const img = await this._image.$ready();
|
|
155
|
-
this.naturalWidth = img.naturalWidth;
|
|
156
|
-
this.naturalHeight = img.naturalHeight;
|
|
157
|
-
await tick();
|
|
158
|
-
this._withCoverCheckSuspended(() => {
|
|
159
|
-
this._image.$resetTransform();
|
|
160
|
-
this._image.$center('cover');
|
|
161
|
-
});
|
|
162
64
|
};
|
|
163
65
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CropperCanvas, CropperImage, CropperSelection } from 'cropperjs';
|
|
2
|
-
|
|
2
|
+
type Rect = {
|
|
3
3
|
left: number;
|
|
4
4
|
top: number;
|
|
5
5
|
right: number;
|
|
@@ -25,8 +25,17 @@ export declare const handleCoverCheck: (params: {
|
|
|
25
25
|
image: CropperImage;
|
|
26
26
|
canvas: CropperCanvas;
|
|
27
27
|
}) => void;
|
|
28
|
+
export declare const fitInContainer: (params: {
|
|
29
|
+
ratio: number;
|
|
30
|
+
containerWidth: number;
|
|
31
|
+
containerHeight: number;
|
|
32
|
+
}) => {
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
};
|
|
28
36
|
export declare const handleSelectionBoundary: (params: {
|
|
29
37
|
event: Event;
|
|
30
38
|
selection: CropperSelection;
|
|
31
39
|
canvas: CropperCanvas;
|
|
32
40
|
}) => void;
|
|
41
|
+
export {};
|
|
@@ -91,6 +91,13 @@ export const handleCoverCheck = (params) => {
|
|
|
91
91
|
}
|
|
92
92
|
image.$setTransform(matrix[0], matrix[1], matrix[2], matrix[3], clampedTx, clampedTy);
|
|
93
93
|
};
|
|
94
|
+
export const fitInContainer = (params) => {
|
|
95
|
+
const { ratio, containerWidth, containerHeight } = params;
|
|
96
|
+
if (ratio > containerWidth / containerHeight) {
|
|
97
|
+
return { width: containerWidth, height: containerWidth / ratio };
|
|
98
|
+
}
|
|
99
|
+
return { width: containerHeight * ratio, height: containerHeight };
|
|
100
|
+
};
|
|
94
101
|
export const handleSelectionBoundary = (params) => {
|
|
95
102
|
const { event, selection, canvas } = params;
|
|
96
103
|
const { x, y, width, height } = event.detail;
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import type { CropperCanvas, CropperImage, CropperSelection } from 'cropperjs';
|
|
2
2
|
export type DragMode = 'move' | 'crop';
|
|
3
|
+
export type CanvasGeometry = {
|
|
4
|
+
aspectRatio: number | null;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
3
8
|
export type ImgCropperWorkerParams = {
|
|
4
9
|
originalSrc: string;
|
|
5
10
|
image: CropperImage;
|
|
6
11
|
selection: CropperSelection;
|
|
7
12
|
canvas: CropperCanvas;
|
|
8
13
|
selectHandle: HTMLElement | null;
|
|
14
|
+
aspectRatio: number | null;
|
|
9
15
|
};
|
|
10
16
|
export interface ImgCropperWorker {
|
|
11
17
|
cropBoxVisible: boolean;
|
|
12
18
|
dragMode: DragMode;
|
|
13
|
-
|
|
14
|
-
naturalHeight: number;
|
|
15
|
-
canvasRatio: number | null;
|
|
19
|
+
canvasGeometry: CanvasGeometry;
|
|
16
20
|
destroy: () => void;
|
|
17
21
|
ready: () => Promise<void>;
|
|
18
22
|
rotate: () => void | Promise<void>;
|
|
@@ -21,7 +25,6 @@ export interface ImgCropperWorker {
|
|
|
21
25
|
reset: () => Promise<void>;
|
|
22
26
|
crop: (options?: {
|
|
23
27
|
fillColor?: string;
|
|
24
|
-
freeRatio?: boolean;
|
|
25
28
|
}) => Promise<{
|
|
26
29
|
dataUrl: string;
|
|
27
30
|
width: number;
|
|
@@ -36,4 +39,5 @@ export interface ImgCropperWorker {
|
|
|
36
39
|
}>;
|
|
37
40
|
clearSelection: () => void;
|
|
38
41
|
enableCropMode: () => void;
|
|
42
|
+
refit: () => void | Promise<void>;
|
|
39
43
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { DragMode } from './img-cropper-worker.svelte';
|
|
1
|
+
import type { CanvasGeometry, DragMode } from './img-cropper-worker.svelte';
|
|
2
2
|
import type { CropperCanvas, CropperImage, CropperSelection } from 'cropperjs';
|
|
3
|
+
export type { CanvasGeometry };
|
|
3
4
|
export type CropperMode = 'contain' | 'cover';
|
|
4
5
|
export type CorsMode = 'native' | 'fetch';
|
|
5
6
|
export type { DragMode };
|
|
@@ -7,11 +8,7 @@ export type ImgCropperRatioOption = {
|
|
|
7
8
|
label: string;
|
|
8
9
|
value: number | null;
|
|
9
10
|
};
|
|
10
|
-
export type
|
|
11
|
-
initial: number;
|
|
12
|
-
supported: number[];
|
|
13
|
-
};
|
|
14
|
-
export type ImgCropperContainAspectRatio = {
|
|
11
|
+
export type ImgCropperDynamicAspectRatio = {
|
|
15
12
|
initial: number;
|
|
16
13
|
supported: number[];
|
|
17
14
|
allowFreeCrop?: false;
|
|
@@ -30,30 +27,26 @@ type ImgCropperOptionsBase = {
|
|
|
30
27
|
fillColor?: string;
|
|
31
28
|
showImageShadow?: boolean;
|
|
32
29
|
};
|
|
33
|
-
export type ImgCropperOptions =
|
|
34
|
-
mode:
|
|
35
|
-
aspectRatio?: number |
|
|
36
|
-
}
|
|
37
|
-
mode: 'contain';
|
|
38
|
-
aspectRatio?: number | ImgCropperContainAspectRatio;
|
|
39
|
-
});
|
|
30
|
+
export type ImgCropperOptions = ImgCropperOptionsBase & {
|
|
31
|
+
mode: CropperMode;
|
|
32
|
+
aspectRatio?: number | ImgCropperDynamicAspectRatio;
|
|
33
|
+
};
|
|
40
34
|
export declare class ImgCropper {
|
|
41
35
|
loading: boolean;
|
|
42
36
|
ready: boolean;
|
|
43
37
|
fillColor: string;
|
|
44
|
-
aspectRatio: number | null;
|
|
45
38
|
readonly mode: CropperMode;
|
|
46
39
|
readonly corsMode: CorsMode;
|
|
47
40
|
readonly ratioOptions: ImgCropperRatioOption[] | null;
|
|
48
41
|
readonly showImageShadow: boolean;
|
|
49
42
|
private _worker;
|
|
50
43
|
private _originalSrc;
|
|
44
|
+
private _defaultGeometry;
|
|
51
45
|
constructor(options: ImgCropperOptions);
|
|
52
46
|
get showFillColor(): boolean;
|
|
53
47
|
get isTransparentFill(): boolean;
|
|
54
|
-
get
|
|
55
|
-
get
|
|
56
|
-
get naturalHeight(): number;
|
|
48
|
+
get canvasGeometry(): CanvasGeometry;
|
|
49
|
+
get aspectRatio(): number | null;
|
|
57
50
|
get cropBoxVisible(): boolean;
|
|
58
51
|
get dragMode(): DragMode;
|
|
59
52
|
init: (params: {
|
|
@@ -78,4 +71,5 @@ export declare class ImgCropper {
|
|
|
78
71
|
clearSelection: () => void;
|
|
79
72
|
enableCropMode: () => void;
|
|
80
73
|
enableMoveMode: () => void;
|
|
74
|
+
refit: () => void;
|
|
81
75
|
}
|
|
@@ -6,13 +6,13 @@ export class ImgCropper {
|
|
|
6
6
|
loading = $state(false);
|
|
7
7
|
ready = $state(false);
|
|
8
8
|
fillColor = $state('');
|
|
9
|
-
aspectRatio = $state(null);
|
|
10
9
|
mode;
|
|
11
10
|
corsMode;
|
|
12
11
|
ratioOptions;
|
|
13
12
|
showImageShadow;
|
|
14
13
|
_worker = $state.raw(null);
|
|
15
14
|
_originalSrc = null;
|
|
15
|
+
_defaultGeometry;
|
|
16
16
|
constructor(options) {
|
|
17
17
|
this.mode = options.mode;
|
|
18
18
|
this.corsMode = options.corsMode ?? 'native';
|
|
@@ -26,23 +26,14 @@ export class ImgCropper {
|
|
|
26
26
|
return ldd ? { label: `${ldd.dividend}:${ldd.divisor}`, value: r } : { label: String(r), value: r };
|
|
27
27
|
};
|
|
28
28
|
const toRatioOptions = (items) => (items.length >= 2 ? items : null);
|
|
29
|
+
let initialRatio;
|
|
29
30
|
if (options.aspectRatio === undefined || typeof options.aspectRatio === 'number') {
|
|
30
|
-
|
|
31
|
+
initialRatio = options.aspectRatio ?? null;
|
|
31
32
|
this.ratioOptions = null;
|
|
32
33
|
}
|
|
33
|
-
else if (options.mode === 'cover') {
|
|
34
|
-
const dynamic = options.aspectRatio;
|
|
35
|
-
this.aspectRatio = dynamic.initial;
|
|
36
|
-
const supported = [...dynamic.supported];
|
|
37
|
-
if (!supported.includes(dynamic.initial)) {
|
|
38
|
-
supported.unshift(dynamic.initial);
|
|
39
|
-
}
|
|
40
|
-
this.ratioOptions = toRatioOptions(supported.map(toOption));
|
|
41
|
-
}
|
|
42
34
|
else {
|
|
43
35
|
const dynamic = options.aspectRatio;
|
|
44
|
-
|
|
45
|
-
this.aspectRatio = initialRatio;
|
|
36
|
+
initialRatio = dynamic.initial ?? (dynamic.allowFreeCrop ? null : (dynamic.supported[0] ?? null));
|
|
46
37
|
const supported = [...dynamic.supported];
|
|
47
38
|
if (initialRatio !== null && !supported.includes(initialRatio)) {
|
|
48
39
|
supported.unshift(initialRatio);
|
|
@@ -52,6 +43,7 @@ export class ImgCropper {
|
|
|
52
43
|
}
|
|
53
44
|
this.ratioOptions = toRatioOptions(supported.map(toOption));
|
|
54
45
|
}
|
|
46
|
+
this._defaultGeometry = { aspectRatio: initialRatio, width: 0, height: 0 };
|
|
55
47
|
}
|
|
56
48
|
get showFillColor() {
|
|
57
49
|
return this.mode === 'contain';
|
|
@@ -59,14 +51,11 @@ export class ImgCropper {
|
|
|
59
51
|
get isTransparentFill() {
|
|
60
52
|
return ColorHelper.isTransparent(this.fillColor);
|
|
61
53
|
}
|
|
62
|
-
get
|
|
63
|
-
return this.
|
|
64
|
-
}
|
|
65
|
-
get naturalWidth() {
|
|
66
|
-
return this._worker?.naturalWidth ?? 0;
|
|
54
|
+
get canvasGeometry() {
|
|
55
|
+
return this._worker?.canvasGeometry ?? this._defaultGeometry;
|
|
67
56
|
}
|
|
68
|
-
get
|
|
69
|
-
return this.
|
|
57
|
+
get aspectRatio() {
|
|
58
|
+
return this.canvasGeometry.aspectRatio;
|
|
70
59
|
}
|
|
71
60
|
get cropBoxVisible() {
|
|
72
61
|
return this._worker?.cropBoxVisible ?? false;
|
|
@@ -89,7 +78,8 @@ export class ImgCropper {
|
|
|
89
78
|
image: params.cropperImage,
|
|
90
79
|
selection: params.cropperSelection,
|
|
91
80
|
canvas: params.cropperCanvas,
|
|
92
|
-
selectHandle: params.selectHandle
|
|
81
|
+
selectHandle: params.selectHandle,
|
|
82
|
+
aspectRatio: this._defaultGeometry.aspectRatio
|
|
93
83
|
};
|
|
94
84
|
this._worker = this.mode === 'cover' ? new ImgCropperCoverWorker(workerParams) : new ImgCropperContainWorker(workerParams);
|
|
95
85
|
await this._worker.ready();
|
|
@@ -121,7 +111,10 @@ export class ImgCropper {
|
|
|
121
111
|
await this._worker?.reset();
|
|
122
112
|
};
|
|
123
113
|
changeAspectRatio = async (ratio) => {
|
|
124
|
-
this.
|
|
114
|
+
if (this._worker) {
|
|
115
|
+
this._worker.canvasGeometry = { ...this._worker.canvasGeometry, aspectRatio: ratio };
|
|
116
|
+
}
|
|
117
|
+
this._defaultGeometry = { ...this._defaultGeometry, aspectRatio: ratio };
|
|
125
118
|
await this.reset();
|
|
126
119
|
};
|
|
127
120
|
crop = async () => {
|
|
@@ -130,7 +123,7 @@ export class ImgCropper {
|
|
|
130
123
|
}
|
|
131
124
|
this.loading = true;
|
|
132
125
|
try {
|
|
133
|
-
return await this._worker.crop({ fillColor: this.fillColor
|
|
126
|
+
return await this._worker.crop({ fillColor: this.fillColor });
|
|
134
127
|
}
|
|
135
128
|
finally {
|
|
136
129
|
this.loading = false;
|
|
@@ -157,4 +150,7 @@ export class ImgCropper {
|
|
|
157
150
|
enableMoveMode = () => {
|
|
158
151
|
this._worker?.clearSelection();
|
|
159
152
|
};
|
|
153
|
+
refit = () => {
|
|
154
|
+
void this._worker?.refit();
|
|
155
|
+
};
|
|
160
156
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { default as ImgCropperControls } from './cmp.img-cropper-controls.svelte';
|
|
2
2
|
export { default as ImgCropperToolbar } from './cmp.img-cropper-toolbar.svelte';
|
|
3
3
|
export { default as ImgCropperView } from './cmp.img-cropper.svelte';
|
|
4
|
-
export { type CorsMode, type CropperMode, type DragMode, ImgCropper, type
|
|
4
|
+
export { type CanvasGeometry, type CorsMode, type CropperMode, type DragMode, ImgCropper, type ImgCropperDynamicAspectRatio, type ImgCropperOptions, type ImgCropperRatioOption, type ImgCropperSaveResult } from './img-cropper.svelte';
|