@streamscloud/kit 0.62.0 → 0.64.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/utils/color-helper.d.ts +4 -0
- package/dist/core/utils/color-helper.js +10 -0
- package/dist/ui/cropper/image-editor-dialog/cmp.image-editor-dialog.svelte +17 -51
- package/dist/ui/cropper/image-editor-dialog/crop-and-resize.d.ts +4 -16
- package/dist/ui/cropper/image-editor-dialog/crop-and-resize.js +1 -14
- package/dist/ui/cropper/image-editor-dialog/index.d.ts +8 -7
- package/dist/ui/cropper/image-editor-dialog/index.js +8 -7
- package/dist/ui/cropper/image-editor-dialog/types.d.ts +1 -1
- package/dist/ui/cropper/img-cropper/cmp.img-cropper-toolbar.svelte +149 -181
- package/dist/ui/cropper/img-cropper/cmp.img-cropper-toolbar.svelte.d.ts +4 -13
- package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte +389 -147
- package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte.d.ts +13 -39
- package/dist/ui/cropper/img-cropper/frame-geometry.d.ts +82 -0
- package/dist/ui/cropper/img-cropper/frame-geometry.js +165 -0
- package/dist/ui/cropper/img-cropper/img-cropper-localization.d.ts +2 -3
- package/dist/ui/cropper/img-cropper/img-cropper-localization.js +12 -19
- package/dist/ui/cropper/img-cropper/img-cropper-toolbar-localization.d.ts +14 -5
- package/dist/ui/cropper/img-cropper/img-cropper-toolbar-localization.js +127 -33
- package/dist/ui/cropper/img-cropper/img-cropper-utils.d.ts +4 -51
- package/dist/ui/cropper/img-cropper/img-cropper-utils.js +35 -190
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.d.ts +99 -34
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.js +405 -104
- package/dist/ui/cropper/img-cropper/index.d.ts +1 -2
- package/dist/ui/cropper/img-cropper/index.js +0 -1
- package/dist/ui/table/cmp.table-draggable.svelte +2 -2
- package/dist/ui/table/cmp.table-draggable.svelte.d.ts +5 -7
- package/dist/ui/table/cmp.table.svelte +2 -2
- package/dist/ui/table/cmp.table.svelte.d.ts +5 -7
- package/dist/ui/table/table-view.svelte.d.ts +2 -2
- package/dist/ui/table/table-view.svelte.js +11 -7
- package/package.json +1 -3
- package/dist/ui/cropper/img-cropper/cmp.img-cropper-controls.svelte +0 -68
- package/dist/ui/cropper/img-cropper/cmp.img-cropper-controls.svelte.d.ts +0 -21
- package/dist/ui/cropper/img-cropper/cropperjs-elements.d.ts +0 -35
- package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.d.ts +0 -40
- package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.js +0 -193
- package/dist/ui/cropper/img-cropper/img-cropper-contain-worker.svelte.d.ts +0 -7
- package/dist/ui/cropper/img-cropper/img-cropper-contain-worker.svelte.js +0 -51
- package/dist/ui/cropper/img-cropper/img-cropper-cover-worker.svelte.d.ts +0 -7
- package/dist/ui/cropper/img-cropper/img-cropper-cover-worker.svelte.js +0 -89
- package/dist/ui/cropper/img-cropper/img-cropper-worker.svelte.d.ts +0 -42
- package/dist/ui/cropper/img-cropper/img-cropper-worker.svelte.js +0 -1
|
@@ -1,161 +1,462 @@
|
|
|
1
1
|
import { FileWithBlobDataHelper } from '../../../core/files';
|
|
2
2
|
import { ColorHelper, Utils } from '../../../core/utils';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import { alignBox, boxAtZoom, boxTurnedWithFrame, clampBox, containBox, containZoom, coverBox, fitFrame, MAX_FRAME_ZOOM, MIN_FREE_ZOOM, moveBox, scaleBox, selectionFromDrag, shiftSelection, zoomOf } from './frame-geometry';
|
|
4
|
+
import { detectAlpha, detectMimeFromUrl, loadImage } from './img-cropper-utils';
|
|
5
|
+
const ZOOM_STOPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, MAX_FRAME_ZOOM];
|
|
6
|
+
const DEFAULT_MARGIN = { block: 64, inline: 64 };
|
|
7
|
+
const MIN_SELECTION_SIZE = 40;
|
|
8
|
+
const MIN_FRAME = { w: 200, h: 100 };
|
|
9
|
+
const MAX_OUTPUT_AREA_UPSCALE = 2;
|
|
10
|
+
// iOS Safari's canvas area limit (4096²) — raising it makes save() return null on iPhones.
|
|
11
|
+
const MAX_OUTPUT_PIXELS = 16_777_216;
|
|
12
|
+
const assertAspectRatio = (ratio) => {
|
|
13
|
+
if (!Number.isFinite(ratio) || ratio <= 0) {
|
|
14
|
+
throw new Error(`ImgCropper: aspect ratio must be a positive finite number, got ${ratio}`);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const ROTATIONS = [0, 90, 180, 270];
|
|
18
|
+
const toRotation = (degrees) => {
|
|
19
|
+
const normalized = ((degrees % 360) + 360) % 360;
|
|
20
|
+
return ROTATIONS.find((rotation) => rotation === normalized) ?? 0;
|
|
21
|
+
};
|
|
22
|
+
const toRatioLabel = (ratio) => {
|
|
23
|
+
const ldd = Utils.ldd(ratio);
|
|
24
|
+
return ldd ? `${ldd.dividend}:${ldd.divisor}` : String(ratio);
|
|
25
|
+
};
|
|
6
26
|
export class ImgCropper {
|
|
27
|
+
ratioOptions;
|
|
28
|
+
margin;
|
|
7
29
|
loading = $state(false);
|
|
8
|
-
|
|
30
|
+
failed = $state(false);
|
|
31
|
+
mode = $state('cover');
|
|
9
32
|
fillColor = $state('');
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
natural = $state.raw(null);
|
|
34
|
+
rotation = $state(0);
|
|
35
|
+
/** The part of the source (turned by `rotation`) the picture is cut to, in natural pixels; `null` for the whole source. */
|
|
36
|
+
crop = $state.raw(null);
|
|
37
|
+
source = $state('');
|
|
38
|
+
free = $state(false);
|
|
39
|
+
cropMode = $state(false);
|
|
40
|
+
selection = $state.raw(null);
|
|
41
|
+
aspectRatio = $state(null);
|
|
42
|
+
stage = $state.raw({ w: 0, h: 0 });
|
|
43
|
+
box = $state.raw(null);
|
|
44
|
+
_initialMode;
|
|
45
|
+
_initialAspectRatio;
|
|
46
|
+
_image = null;
|
|
47
|
+
_blobUrl = null;
|
|
48
|
+
_sourceMime = null;
|
|
49
|
+
_sourceHasAlpha = $state(false);
|
|
16
50
|
constructor(options) {
|
|
17
51
|
this.mode = options.mode;
|
|
52
|
+
this._initialMode = options.mode;
|
|
18
53
|
this.fillColor = options.fillColor ?? '';
|
|
19
|
-
this.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return { label: '', value: null };
|
|
23
|
-
}
|
|
24
|
-
const ldd = Utils.ldd(r);
|
|
25
|
-
return ldd ? { label: `${ldd.dividend}:${ldd.divisor}`, value: r } : { label: String(r), value: r };
|
|
54
|
+
this.margin = {
|
|
55
|
+
block: options.margin?.block ?? DEFAULT_MARGIN.block,
|
|
56
|
+
inline: options.margin?.inline ?? DEFAULT_MARGIN.inline
|
|
26
57
|
};
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
const dynamic = options.aspectRatio;
|
|
59
|
+
if (dynamic === undefined || typeof dynamic === 'number') {
|
|
60
|
+
if (dynamic !== undefined) {
|
|
61
|
+
assertAspectRatio(dynamic);
|
|
62
|
+
}
|
|
63
|
+
this.aspectRatio = dynamic ?? null;
|
|
64
|
+
this._initialAspectRatio = this.aspectRatio;
|
|
31
65
|
this.ratioOptions = null;
|
|
66
|
+
return;
|
|
32
67
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (initialRatio !== null && !supported.includes(initialRatio)) {
|
|
38
|
-
supported.unshift(initialRatio);
|
|
39
|
-
}
|
|
40
|
-
if (dynamic.allowFreeCrop) {
|
|
41
|
-
supported.unshift(0);
|
|
42
|
-
}
|
|
43
|
-
this.ratioOptions = toRatioOptions(supported.map(toOption));
|
|
68
|
+
const initial = dynamic.initial ?? (dynamic.allowFreeCrop ? null : (dynamic.supported[0] ?? null));
|
|
69
|
+
const values = [...dynamic.supported];
|
|
70
|
+
if (initial !== null && !values.includes(initial)) {
|
|
71
|
+
values.unshift(initial);
|
|
44
72
|
}
|
|
45
|
-
|
|
73
|
+
values.forEach(assertAspectRatio);
|
|
74
|
+
const items = values.map((value) => ({ label: toRatioLabel(value), value }));
|
|
75
|
+
if (dynamic.allowFreeCrop) {
|
|
76
|
+
items.unshift({ label: '', value: null });
|
|
77
|
+
}
|
|
78
|
+
this.aspectRatio = initial;
|
|
79
|
+
this._initialAspectRatio = initial;
|
|
80
|
+
this.ratioOptions = items.length >= 2 ? items : null;
|
|
46
81
|
}
|
|
47
82
|
get showFillColor() {
|
|
48
|
-
return this.mode === 'contain';
|
|
83
|
+
return this.mode === 'contain' || this.free || this.hasAlpha;
|
|
84
|
+
}
|
|
85
|
+
get hasSelection() {
|
|
86
|
+
const { selection } = this;
|
|
87
|
+
return !!selection && selection.w >= MIN_SELECTION_SIZE && selection.h >= MIN_SELECTION_SIZE;
|
|
88
|
+
}
|
|
89
|
+
/** Whether the picture has transparent pixels — the source's own, or empty space a crop took in past the source's edges. */
|
|
90
|
+
get hasAlpha() {
|
|
91
|
+
const { crop } = this;
|
|
92
|
+
const full = this._fullNatural;
|
|
93
|
+
const reachesPast = !!crop && !!full && (crop.x < -0.5 || crop.y < -0.5 || crop.x + crop.w > full.w + 0.5 || crop.y + crop.h > full.h + 0.5);
|
|
94
|
+
return this._sourceHasAlpha || reachesPast;
|
|
95
|
+
}
|
|
96
|
+
/** The current aspect ratio's label — a preset's own, or `1.78:1` for a custom one; `null` for Original. */
|
|
97
|
+
get ratioLabel() {
|
|
98
|
+
const { aspectRatio } = this;
|
|
99
|
+
if (aspectRatio === null) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return this.ratioOptions?.find((option) => option.value === aspectRatio)?.label ?? `${Math.round(aspectRatio * 100) / 100}:1`;
|
|
49
103
|
}
|
|
50
104
|
get isTransparentFill() {
|
|
51
105
|
return ColorHelper.isTransparent(this.fillColor);
|
|
52
106
|
}
|
|
53
|
-
|
|
54
|
-
|
|
107
|
+
/** The picture's natural size — the crop's, or the whole source's turned by `rotation`. */
|
|
108
|
+
get shownNatural() {
|
|
109
|
+
const full = this._fullNatural;
|
|
110
|
+
const { crop } = this;
|
|
111
|
+
return full && crop ? { w: crop.w, h: crop.h } : full;
|
|
112
|
+
}
|
|
113
|
+
/** The picture's own aspect ratio (post-rotation) — the shape `box` must always keep. */
|
|
114
|
+
get imageAspect() {
|
|
115
|
+
const shown = this.shownNatural;
|
|
116
|
+
return shown ? shown.w / shown.h : null;
|
|
117
|
+
}
|
|
118
|
+
/** The frame's own aspect ratio — the chosen ratio, or the picture's own when none is chosen. */
|
|
119
|
+
get frameAspect() {
|
|
120
|
+
return this.aspectRatio ?? this.imageAspect;
|
|
121
|
+
}
|
|
122
|
+
get frame() {
|
|
123
|
+
const frameAspect = this.frameAspect;
|
|
124
|
+
if (frameAspect === null) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const available = {
|
|
128
|
+
w: this.stage.w - this.margin.inline * 2,
|
|
129
|
+
h: this.stage.h - this.margin.block * 2
|
|
130
|
+
};
|
|
131
|
+
if (available.w <= 0 || available.h <= 0) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const fit = fitFrame(available, frameAspect);
|
|
135
|
+
const shown = this.shownNatural;
|
|
136
|
+
// 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
|
+
// tiny one stays workable (and visibly small) instead of shrinking to a few pixels.
|
|
138
|
+
const naturalWidth = shown ? containBox(shown, frameAspect).w : fit.w;
|
|
139
|
+
const w = Math.min(fit.w, Math.max(naturalWidth, coverBox(MIN_FRAME, frameAspect).w));
|
|
140
|
+
const h = w / frameAspect;
|
|
141
|
+
return { x: this.margin.inline + (available.w - w) / 2, y: this.margin.block + (available.h - h) / 2, w, h };
|
|
142
|
+
}
|
|
143
|
+
/** Zoom as shown to the user: 1 is the current mode's rest state — filling the frame in `cover`, fitting it in `contain`. */
|
|
144
|
+
get zoom() {
|
|
145
|
+
const { box, frame, imageAspect } = this;
|
|
146
|
+
return box && frame && imageAspect !== null ? zoomOf(box, frame, imageAspect) / this._zoomBase : 1;
|
|
147
|
+
}
|
|
148
|
+
get minZoom() {
|
|
149
|
+
return this.free ? MIN_FREE_ZOOM / this._zoomBase : 1;
|
|
55
150
|
}
|
|
56
|
-
get
|
|
57
|
-
return this.
|
|
151
|
+
get maxZoom() {
|
|
152
|
+
return MAX_FRAME_ZOOM / this._zoomBase;
|
|
58
153
|
}
|
|
59
|
-
|
|
60
|
-
|
|
154
|
+
/** 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. */
|
|
155
|
+
get restingMode() {
|
|
156
|
+
return !this.free && Math.abs(this.zoom - 1) < 0.005 ? this.mode : null;
|
|
61
157
|
}
|
|
62
|
-
get
|
|
63
|
-
return this.
|
|
158
|
+
get ready() {
|
|
159
|
+
return !this.failed && this.box !== null && this.frame !== null;
|
|
64
160
|
}
|
|
65
|
-
|
|
161
|
+
get _fullNatural() {
|
|
162
|
+
const { natural, rotation } = this;
|
|
163
|
+
return natural ? (rotation === 90 || rotation === 270 ? { w: natural.h, h: natural.w } : natural) : null;
|
|
164
|
+
}
|
|
165
|
+
get _zoomBase() {
|
|
166
|
+
const { frame, imageAspect } = this;
|
|
167
|
+
return this.mode === 'contain' && frame && imageAspect !== null ? containZoom(frame, imageAspect) : 1;
|
|
168
|
+
}
|
|
169
|
+
init = async (src) => {
|
|
66
170
|
this.destroy();
|
|
67
171
|
this.loading = true;
|
|
172
|
+
this.failed = false;
|
|
68
173
|
try {
|
|
69
|
-
let resolvedSrc =
|
|
70
|
-
|
|
71
|
-
const file = await FileWithBlobDataHelper.fromUrl(params.src);
|
|
174
|
+
let resolvedSrc = src;
|
|
175
|
+
const file = await FileWithBlobDataHelper.fromUrl(src);
|
|
72
176
|
if (file) {
|
|
73
177
|
resolvedSrc = file.blobUrl;
|
|
74
|
-
|
|
75
|
-
this.
|
|
178
|
+
this._blobUrl = resolvedSrc;
|
|
179
|
+
this._sourceMime = file.type || null;
|
|
180
|
+
}
|
|
181
|
+
if (!this._sourceMime) {
|
|
182
|
+
this._sourceMime = detectMimeFromUrl(src);
|
|
76
183
|
}
|
|
77
|
-
|
|
78
|
-
|
|
184
|
+
const image = await loadImage(resolvedSrc);
|
|
185
|
+
if (!image.naturalWidth || !image.naturalHeight) {
|
|
186
|
+
this.failed = true;
|
|
187
|
+
return;
|
|
79
188
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
};
|
|
89
|
-
this._worker = this.mode === 'cover' ? new ImgCropperCoverWorker(workerParams) : new ImgCropperContainWorker(workerParams);
|
|
90
|
-
await this._worker.ready();
|
|
91
|
-
this.ready = true;
|
|
189
|
+
this._image = image;
|
|
190
|
+
this.source = resolvedSrc;
|
|
191
|
+
this.natural = { w: image.naturalWidth, h: image.naturalHeight };
|
|
192
|
+
this._sourceHasAlpha = detectAlpha(image, this._sourceMime);
|
|
193
|
+
this.layout();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
this.failed = true;
|
|
92
197
|
}
|
|
93
198
|
finally {
|
|
94
199
|
this.loading = false;
|
|
95
200
|
}
|
|
96
201
|
};
|
|
97
202
|
destroy = () => {
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
-
this.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
203
|
+
this._image = null;
|
|
204
|
+
this._sourceMime = null;
|
|
205
|
+
this.natural = null;
|
|
206
|
+
this._sourceHasAlpha = false;
|
|
207
|
+
this.crop = null;
|
|
208
|
+
this.box = null;
|
|
209
|
+
if (this._blobUrl) {
|
|
210
|
+
URL.revokeObjectURL(this._blobUrl);
|
|
211
|
+
this._blobUrl = null;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
setStage = (size) => {
|
|
215
|
+
const previousFrame = this.frame;
|
|
216
|
+
const previousBox = this.box;
|
|
217
|
+
this.stage = size;
|
|
218
|
+
const frame = this.frame;
|
|
219
|
+
if (previousFrame && previousBox && frame && previousFrame.w > 0) {
|
|
220
|
+
const factor = frame.w / previousFrame.w;
|
|
221
|
+
const scaled = (rect) => ({ x: rect.x * factor, y: rect.y * factor, w: rect.w * factor, h: rect.h * factor });
|
|
222
|
+
this.box = scaled(previousBox);
|
|
223
|
+
if (this.selection) {
|
|
224
|
+
this.selection = scaled(this.selection);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
this.layout();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
layout = () => {
|
|
232
|
+
const { frame, imageAspect } = this;
|
|
233
|
+
if (!frame || imageAspect === null) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const cover = coverBox(frame, imageAspect);
|
|
237
|
+
this.box = this.mode === 'contain' ? boxAtZoom(cover, containZoom(frame, imageAspect), { frame, aspect: imageAspect, mode: 'contain' }) : cover;
|
|
238
|
+
};
|
|
239
|
+
changeAspectRatio = (ratio) => {
|
|
240
|
+
if (ratio !== null) {
|
|
241
|
+
assertAspectRatio(ratio);
|
|
242
|
+
}
|
|
243
|
+
this.aspectRatio = ratio;
|
|
244
|
+
this.selection = null;
|
|
245
|
+
this.free = false;
|
|
246
|
+
this.layout();
|
|
247
|
+
};
|
|
248
|
+
setFree = (free) => {
|
|
249
|
+
this.free = free;
|
|
250
|
+
this._update((box, ctx) => clampBox(box, ctx));
|
|
251
|
+
};
|
|
252
|
+
toggleFree = () => {
|
|
253
|
+
if (this.cropMode) {
|
|
254
|
+
this.setCropMode(false);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.setFree(!this.free);
|
|
258
|
+
};
|
|
259
|
+
setCropMode = (active) => {
|
|
260
|
+
this.cropMode = active;
|
|
261
|
+
this.selection = null;
|
|
262
|
+
};
|
|
263
|
+
updateSelection = (anchor, point) => {
|
|
264
|
+
const frame = this.frame;
|
|
265
|
+
if (!this.cropMode || !frame) {
|
|
266
|
+
return;
|
|
104
267
|
}
|
|
268
|
+
const minSize = this.hasSelection ? MIN_SELECTION_SIZE : 0;
|
|
269
|
+
this.selection = selectionFromDrag({ anchor, point, frame, aspect: this.aspectRatio, minSize });
|
|
105
270
|
};
|
|
106
|
-
|
|
107
|
-
|
|
271
|
+
/** Drops a freshly drawn selection that ended up below the minimum size. */
|
|
272
|
+
finishSelection = () => {
|
|
273
|
+
if (!this.hasSelection) {
|
|
274
|
+
this.selection = null;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
moveSelection = (delta) => {
|
|
278
|
+
const { selection, frame } = this;
|
|
279
|
+
if (!this.cropMode || !selection || !frame) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.selection = shiftSelection({ rect: selection, delta, frame });
|
|
283
|
+
};
|
|
284
|
+
clearSelection = () => {
|
|
285
|
+
this.selection = null;
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Cuts the picture to the selection — whatever it covers, empty space past the picture included (that turns
|
|
289
|
+
* transparent). The cut piece becomes the picture: the rest of it is gone until `reset()`, and it's laid out afresh,
|
|
290
|
+
* filling the frame — with the Original aspect the frame takes the piece's shape, a fixed aspect already matches it.
|
|
291
|
+
*/
|
|
292
|
+
applyCrop = () => {
|
|
293
|
+
const { selection, box, shownNatural } = this;
|
|
294
|
+
if (!this.hasSelection || !selection || !box || !shownNatural || !this.frame) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const scale = shownNatural.w / box.w;
|
|
298
|
+
const origin = this.crop ?? { x: 0, y: 0 };
|
|
299
|
+
const w = selection.w * scale;
|
|
300
|
+
// The selection's height is rounded; a fixed aspect is re-derived so the piece fills the frame without a seam.
|
|
301
|
+
const h = this.aspectRatio === null ? selection.h * scale : w / this.aspectRatio;
|
|
302
|
+
this.crop = { x: origin.x + (selection.x - box.x) * scale, y: origin.y + (selection.y - box.y) * scale, w, h };
|
|
303
|
+
this.selection = null;
|
|
304
|
+
this.cropMode = false;
|
|
305
|
+
this.free = false;
|
|
306
|
+
this.layout();
|
|
307
|
+
};
|
|
308
|
+
pan = (delta) => {
|
|
309
|
+
this._update((box, ctx) => moveBox(box, delta, ctx));
|
|
310
|
+
};
|
|
311
|
+
scaleAbout = (factor, anchor) => {
|
|
312
|
+
this._update((box, ctx) => scaleBox(box, factor, anchor, ctx));
|
|
313
|
+
};
|
|
314
|
+
/** Sets the zoom in the shown units — see `zoom`. */
|
|
315
|
+
setZoom = (zoom) => {
|
|
316
|
+
const base = this._zoomBase;
|
|
317
|
+
this._update((box, ctx) => boxAtZoom(box, zoom * base, ctx));
|
|
108
318
|
};
|
|
109
319
|
zoomIn = () => {
|
|
110
|
-
|
|
320
|
+
const next = ZOOM_STOPS.find((stop) => stop > this.zoom + 0.001);
|
|
321
|
+
this.setZoom(next ?? this.maxZoom);
|
|
111
322
|
};
|
|
112
323
|
zoomOut = () => {
|
|
113
|
-
this.
|
|
324
|
+
const floor = this.minZoom;
|
|
325
|
+
const next = [...ZOOM_STOPS].reverse().find((stop) => stop < this.zoom - 0.001);
|
|
326
|
+
this.setZoom(Math.max(floor, next ?? floor));
|
|
114
327
|
};
|
|
115
|
-
|
|
116
|
-
|
|
328
|
+
/** Switches to `cover` (leaving free position) and fills the frame. */
|
|
329
|
+
fill = () => {
|
|
330
|
+
this.mode = 'cover';
|
|
331
|
+
this.free = false;
|
|
332
|
+
this.setZoom(1);
|
|
117
333
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
this.
|
|
123
|
-
await this.reset();
|
|
334
|
+
/** Switches to `contain` (leaving free position) and fits the picture inside the frame, centered. */
|
|
335
|
+
fit = () => {
|
|
336
|
+
this.mode = 'contain';
|
|
337
|
+
this.free = false;
|
|
338
|
+
this.layout();
|
|
124
339
|
};
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
340
|
+
align = (fx, fy) => {
|
|
341
|
+
this._update((box, ctx) => alignBox(box, fx, fy, ctx));
|
|
342
|
+
};
|
|
343
|
+
/** Turns the picture a quarter clockwise (`1`) or counter-clockwise (`-1`). With the Original aspect the frame turns
|
|
344
|
+
* along and keeps what's inside it; a fixed-aspect frame can't turn, so the picture is laid out afresh instead. */
|
|
345
|
+
rotate = (direction) => {
|
|
346
|
+
const { box, frame: fromFrame, crop } = this;
|
|
347
|
+
const full = this._fullNatural;
|
|
348
|
+
this.rotation = toRotation(this.rotation + direction * 90);
|
|
349
|
+
if (crop && full) {
|
|
350
|
+
// A quarter turn maps (x, y) in the W×H source to (H - y, x) clockwise, (y, W - x) counter-clockwise.
|
|
351
|
+
this.crop =
|
|
352
|
+
direction === 1
|
|
353
|
+
? { x: full.h - (crop.y + crop.h), y: crop.x, w: crop.h, h: crop.w }
|
|
354
|
+
: { x: crop.y, y: full.w - (crop.x + crop.w), w: crop.h, h: crop.w };
|
|
128
355
|
}
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
|
|
356
|
+
this.selection = null;
|
|
357
|
+
if (this.aspectRatio !== null) {
|
|
358
|
+
this.layout();
|
|
359
|
+
return;
|
|
132
360
|
}
|
|
133
|
-
|
|
134
|
-
|
|
361
|
+
const { frame, imageAspect } = this;
|
|
362
|
+
if (!box || !fromFrame || !frame || imageAspect === null) {
|
|
363
|
+
return;
|
|
135
364
|
}
|
|
365
|
+
this.box = boxTurnedWithFrame({
|
|
366
|
+
box,
|
|
367
|
+
from: fromFrame,
|
|
368
|
+
clockwise: direction === 1,
|
|
369
|
+
ctx: { frame, aspect: imageAspect, mode: this.mode, free: this.free }
|
|
370
|
+
});
|
|
136
371
|
};
|
|
372
|
+
reset = () => {
|
|
373
|
+
this.mode = this._initialMode;
|
|
374
|
+
this.free = false;
|
|
375
|
+
this.cropMode = false;
|
|
376
|
+
this.selection = null;
|
|
377
|
+
this.rotation = 0;
|
|
378
|
+
this.crop = null;
|
|
379
|
+
this.aspectRatio = this._initialAspectRatio;
|
|
380
|
+
this.layout();
|
|
381
|
+
};
|
|
382
|
+
/** Renders the frame to an image, applying a pending selection first; `null` when there's nothing to render or the browser can't. */
|
|
137
383
|
save = async () => {
|
|
138
|
-
if (
|
|
384
|
+
if (this.hasSelection) {
|
|
385
|
+
this.applyCrop();
|
|
386
|
+
}
|
|
387
|
+
const image = this._image;
|
|
388
|
+
const { box, frame, shownNatural } = this;
|
|
389
|
+
if (this.failed || !image || !box || !frame || !shownNatural) {
|
|
139
390
|
return null;
|
|
140
391
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
392
|
+
const nativeScale = shownNatural.w / box.w;
|
|
393
|
+
const nativeArea = frame.w * frame.h * nativeScale * nativeScale;
|
|
394
|
+
const areaLimit = Math.min(shownNatural.w * shownNatural.h * MAX_OUTPUT_AREA_UPSCALE, MAX_OUTPUT_PIXELS);
|
|
395
|
+
const scale = nativeArea > areaLimit ? nativeScale * Math.sqrt(areaLimit / nativeArea) : nativeScale;
|
|
396
|
+
const width = Math.max(1, Math.floor(frame.w * scale));
|
|
397
|
+
const height = Math.max(1, Math.floor(frame.h * scale));
|
|
398
|
+
const left = Math.round(box.x * scale);
|
|
399
|
+
const top = Math.round(box.y * scale);
|
|
400
|
+
const right = Math.round((box.x + box.w) * scale);
|
|
401
|
+
const bottom = Math.round((box.y + box.h) * scale);
|
|
402
|
+
const canvas = document.createElement('canvas');
|
|
403
|
+
canvas.width = width;
|
|
404
|
+
canvas.height = height;
|
|
405
|
+
const context = canvas.getContext('2d');
|
|
406
|
+
if (!context) {
|
|
407
|
+
return null;
|
|
144
408
|
}
|
|
145
|
-
|
|
146
|
-
|
|
409
|
+
const hasGap = left > 0 || top > 0 || right < width || bottom < height;
|
|
410
|
+
if (!this.isTransparentFill) {
|
|
411
|
+
context.fillStyle = this.fillColor;
|
|
412
|
+
context.fillRect(0, 0, width, height);
|
|
147
413
|
}
|
|
414
|
+
const picture = { x: left, y: top, w: right - left, h: bottom - top };
|
|
415
|
+
const placement = this.sourcePlacement(picture);
|
|
416
|
+
if (!placement) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
const { center, size } = placement;
|
|
420
|
+
const turned = this.rotation === 90 || this.rotation === 270;
|
|
421
|
+
context.save();
|
|
422
|
+
context.beginPath();
|
|
423
|
+
context.rect(picture.x, picture.y, picture.w, picture.h);
|
|
424
|
+
context.clip();
|
|
425
|
+
context.translate(center.x, center.y);
|
|
426
|
+
context.rotate((this.rotation * Math.PI) / 180);
|
|
427
|
+
const drawWidth = turned ? size.h : size.w;
|
|
428
|
+
const drawHeight = turned ? size.w : size.h;
|
|
429
|
+
context.drawImage(image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
|
|
430
|
+
context.restore();
|
|
431
|
+
const needsAlpha = !ColorHelper.isOpaque(this.fillColor) && (hasGap || this.hasAlpha);
|
|
432
|
+
const mimeType = this._sourceMime === 'image/jpeg' && !needsAlpha ? 'image/jpeg' : 'image/png';
|
|
433
|
+
const dataUrl = canvas.toDataURL(mimeType, 0.92);
|
|
434
|
+
// A canvas past the browser's size limit encodes to an empty "data:," URL instead of throwing.
|
|
435
|
+
return dataUrl.length > 'data:,'.length ? { dataUrl, width, height, mimeType } : null;
|
|
148
436
|
};
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
437
|
+
/**
|
|
438
|
+
* Where the whole turned source lands when the picture is drawn into `picture` — its center and its size on each axis,
|
|
439
|
+
* before `rotation` swaps them. The view and `save()` both place the source through this, so they can't drift apart.
|
|
440
|
+
*/
|
|
441
|
+
sourcePlacement = (picture) => {
|
|
442
|
+
const full = this._fullNatural;
|
|
443
|
+
const shown = this.shownNatural;
|
|
444
|
+
if (!full || !shown) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
// Per axis: the picture rect is rounded on each axis separately when exported, so one shared scale leaves a seam.
|
|
448
|
+
const perX = picture.w / shown.w;
|
|
449
|
+
const perY = picture.h / shown.h;
|
|
450
|
+
const offset = this.crop ?? { x: 0, y: 0 };
|
|
451
|
+
return {
|
|
452
|
+
center: { x: picture.x + (full.w / 2 - offset.x) * perX, y: picture.y + (full.h / 2 - offset.y) * perY },
|
|
453
|
+
size: { w: full.w * perX, h: full.h * perY }
|
|
454
|
+
};
|
|
157
455
|
};
|
|
158
|
-
|
|
159
|
-
|
|
456
|
+
_update = (change) => {
|
|
457
|
+
const { box, frame, imageAspect } = this;
|
|
458
|
+
if (box && frame && imageAspect !== null) {
|
|
459
|
+
this.box = change(box, { frame, aspect: imageAspect, mode: this.mode, free: this.free });
|
|
460
|
+
}
|
|
160
461
|
};
|
|
161
462
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export { default as ImgCropperControls } from './cmp.img-cropper-controls.svelte';
|
|
2
1
|
export { default as ImgCropperToolbar } from './cmp.img-cropper-toolbar.svelte';
|
|
3
2
|
export { default as ImgCropperView } from './cmp.img-cropper.svelte';
|
|
4
|
-
export {
|
|
3
|
+
export { ImgCropper, type CropperMode, type ImgCropperDynamicAspectRatio, type ImgCropperMargin, type ImgCropperOptions, type ImgCropperRatioOption, type ImgCropperSaveResult } from './img-cropper.svelte';
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export { default as ImgCropperControls } from './cmp.img-cropper-controls.svelte';
|
|
2
1
|
export { default as ImgCropperToolbar } from './cmp.img-cropper-toolbar.svelte';
|
|
3
2
|
export { default as ImgCropperView } from './cmp.img-cropper.svelte';
|
|
4
3
|
export { ImgCropper } from './img-cropper.svelte';
|
|
@@ -7,9 +7,9 @@ import { columnWidthStyle, createTableView } from './table-view.svelte';
|
|
|
7
7
|
import IconReorderDotsHorizontal from '@fluentui/svg-icons/icons/re_order_dots_horizontal_20_regular.svg?raw';
|
|
8
8
|
import { flip } from 'svelte/animate';
|
|
9
9
|
import { dndzone } from 'svelte-dnd-action';
|
|
10
|
-
let { model, showAlternativeView = false,
|
|
10
|
+
let { model, showAlternativeView = false, noItemsPlaceholderEnabled, noItemsPlaceholderDelay, on, alternativeView, customNoItemsPlaceholder } = $props();
|
|
11
11
|
const localization = new TableLocalization();
|
|
12
|
-
const view = createTableView(() => model, () => on, () =>
|
|
12
|
+
const view = createTableView(() => model, () => on, () => noItemsPlaceholderEnabled, () => noItemsPlaceholderDelay);
|
|
13
13
|
const flipDurationMs = 200;
|
|
14
14
|
let tableRef = $state.raw(null);
|
|
15
15
|
let dndItems = $state.raw(null);
|
|
@@ -6,13 +6,11 @@ declare function $$render<T extends {
|
|
|
6
6
|
props: {
|
|
7
7
|
model: TableModel<T>;
|
|
8
8
|
showAlternativeView?: boolean;
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*/
|
|
15
|
-
noItemsPlaceholder?: number | boolean;
|
|
9
|
+
/** Whether an empty list turns into the no-items placeholder at all — `false` pairs with your own loading / empty UI. Rows always win: the placeholder
|
|
10
|
+
* is never rendered over a non-empty list. @default true */
|
|
11
|
+
noItemsPlaceholderEnabled?: boolean;
|
|
12
|
+
/** Anti-flicker delay in ms before the placeholder appears, once `noItemsPlaceholderEnabled`. `0` shows it as soon as the list is empty. @default 0 */
|
|
13
|
+
noItemsPlaceholderDelay?: number;
|
|
16
14
|
on?: {
|
|
17
15
|
selectionChanged?: (e: T[]) => void;
|
|
18
16
|
itemsSelected?: (e: T[]) => void;
|
|
@@ -3,9 +3,9 @@ import { EmptyState } from '../empty-state';
|
|
|
3
3
|
import { TableCell, TableHideableCell } from './table-cells';
|
|
4
4
|
import { TableLocalization } from './table-localization';
|
|
5
5
|
import { columnWidthStyle, createTableView } from './table-view.svelte';
|
|
6
|
-
let { model, showAlternativeView = false,
|
|
6
|
+
let { model, showAlternativeView = false, noItemsPlaceholderEnabled, noItemsPlaceholderDelay, grouping, on, alternativeView, customNoItemsPlaceholder } = $props();
|
|
7
7
|
const localization = new TableLocalization();
|
|
8
|
-
const view = createTableView(() => model, () => on, () =>
|
|
8
|
+
const view = createTableView(() => model, () => on, () => noItemsPlaceholderEnabled, () => noItemsPlaceholderDelay);
|
|
9
9
|
const displayRows = $derived.by(() => {
|
|
10
10
|
const items = model.items;
|
|
11
11
|
const grp = grouping;
|
|
@@ -6,13 +6,11 @@ declare function $$render<T extends {
|
|
|
6
6
|
props: {
|
|
7
7
|
model: TableModel<T>;
|
|
8
8
|
showAlternativeView?: boolean;
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*/
|
|
15
|
-
noItemsPlaceholder?: number | boolean;
|
|
9
|
+
/** Whether an empty list turns into the no-items placeholder at all — `false` pairs with your own loading / empty UI. Rows always win: the placeholder
|
|
10
|
+
* is never rendered over a non-empty list. @default true */
|
|
11
|
+
noItemsPlaceholderEnabled?: boolean;
|
|
12
|
+
/** Anti-flicker delay in ms before the placeholder appears, once `noItemsPlaceholderEnabled`. `0` shows it as soon as the list is empty. @default 0 */
|
|
13
|
+
noItemsPlaceholderDelay?: number;
|
|
16
14
|
/** Group consecutive (already-sorted) rows under section headers. `key` returns a group id per item (`null` = ungrouped); a header row renders wherever the key changes between neighbours. */
|
|
17
15
|
grouping?: {
|
|
18
16
|
key: (item: T) => string | null;
|