@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.
Files changed (42) hide show
  1. package/dist/core/utils/color-helper.d.ts +4 -0
  2. package/dist/core/utils/color-helper.js +10 -0
  3. package/dist/ui/cropper/image-editor-dialog/cmp.image-editor-dialog.svelte +17 -51
  4. package/dist/ui/cropper/image-editor-dialog/crop-and-resize.d.ts +4 -16
  5. package/dist/ui/cropper/image-editor-dialog/crop-and-resize.js +1 -14
  6. package/dist/ui/cropper/image-editor-dialog/index.d.ts +8 -7
  7. package/dist/ui/cropper/image-editor-dialog/index.js +8 -7
  8. package/dist/ui/cropper/image-editor-dialog/types.d.ts +1 -1
  9. package/dist/ui/cropper/img-cropper/cmp.img-cropper-toolbar.svelte +149 -181
  10. package/dist/ui/cropper/img-cropper/cmp.img-cropper-toolbar.svelte.d.ts +4 -13
  11. package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte +389 -147
  12. package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte.d.ts +13 -39
  13. package/dist/ui/cropper/img-cropper/frame-geometry.d.ts +82 -0
  14. package/dist/ui/cropper/img-cropper/frame-geometry.js +165 -0
  15. package/dist/ui/cropper/img-cropper/img-cropper-localization.d.ts +2 -3
  16. package/dist/ui/cropper/img-cropper/img-cropper-localization.js +12 -19
  17. package/dist/ui/cropper/img-cropper/img-cropper-toolbar-localization.d.ts +14 -5
  18. package/dist/ui/cropper/img-cropper/img-cropper-toolbar-localization.js +127 -33
  19. package/dist/ui/cropper/img-cropper/img-cropper-utils.d.ts +4 -51
  20. package/dist/ui/cropper/img-cropper/img-cropper-utils.js +35 -190
  21. package/dist/ui/cropper/img-cropper/img-cropper.svelte.d.ts +99 -34
  22. package/dist/ui/cropper/img-cropper/img-cropper.svelte.js +405 -104
  23. package/dist/ui/cropper/img-cropper/index.d.ts +1 -2
  24. package/dist/ui/cropper/img-cropper/index.js +0 -1
  25. package/dist/ui/table/cmp.table-draggable.svelte +2 -2
  26. package/dist/ui/table/cmp.table-draggable.svelte.d.ts +5 -7
  27. package/dist/ui/table/cmp.table.svelte +2 -2
  28. package/dist/ui/table/cmp.table.svelte.d.ts +5 -7
  29. package/dist/ui/table/table-view.svelte.d.ts +2 -2
  30. package/dist/ui/table/table-view.svelte.js +11 -7
  31. package/package.json +1 -3
  32. package/dist/ui/cropper/img-cropper/cmp.img-cropper-controls.svelte +0 -68
  33. package/dist/ui/cropper/img-cropper/cmp.img-cropper-controls.svelte.d.ts +0 -21
  34. package/dist/ui/cropper/img-cropper/cropperjs-elements.d.ts +0 -35
  35. package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.d.ts +0 -40
  36. package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.js +0 -193
  37. package/dist/ui/cropper/img-cropper/img-cropper-contain-worker.svelte.d.ts +0 -7
  38. package/dist/ui/cropper/img-cropper/img-cropper-contain-worker.svelte.js +0 -51
  39. package/dist/ui/cropper/img-cropper/img-cropper-cover-worker.svelte.d.ts +0 -7
  40. package/dist/ui/cropper/img-cropper/img-cropper-cover-worker.svelte.js +0 -89
  41. package/dist/ui/cropper/img-cropper/img-cropper-worker.svelte.d.ts +0 -42
  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 { ImgCropperContainWorker } from './img-cropper-contain-worker.svelte';
4
- import { ImgCropperCoverWorker } from './img-cropper-cover-worker.svelte';
5
- import { detectMimeFromUrl } from './img-cropper-utils';
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
- ready = $state(false);
30
+ failed = $state(false);
31
+ mode = $state('cover');
9
32
  fillColor = $state('');
10
- mode;
11
- ratioOptions;
12
- showImageShadow;
13
- _worker = $state.raw(null);
14
- _originalSrc = null;
15
- _defaultGeometry;
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.showImageShadow = options.showImageShadow ?? true;
20
- const toOption = (r) => {
21
- if (r === 0) {
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 toRatioOptions = (items) => (items.length >= 2 ? items : null);
28
- let initialRatio;
29
- if (options.aspectRatio === undefined || typeof options.aspectRatio === 'number') {
30
- initialRatio = options.aspectRatio ?? null;
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
- else {
34
- const dynamic = options.aspectRatio;
35
- initialRatio = dynamic.initial ?? (dynamic.allowFreeCrop ? null : (dynamic.supported[0] ?? null));
36
- const supported = [...dynamic.supported];
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
- this._defaultGeometry = { aspectRatio: initialRatio, width: 0, height: 0 };
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
- get canvasGeometry() {
54
- return this._worker?.canvasGeometry ?? this._defaultGeometry;
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 aspectRatio() {
57
- return this.canvasGeometry.aspectRatio;
151
+ get maxZoom() {
152
+ return MAX_FRAME_ZOOM / this._zoomBase;
58
153
  }
59
- get cropBoxVisible() {
60
- return this._worker?.cropBoxVisible ?? false;
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 dragMode() {
63
- return this._worker?.dragMode ?? 'move';
158
+ get ready() {
159
+ return !this.failed && this.box !== null && this.frame !== null;
64
160
  }
65
- init = async (params) => {
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 = params.src;
70
- let sourceMime = null;
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
- sourceMime = file.type || null;
75
- this._originalSrc = resolvedSrc;
178
+ this._blobUrl = resolvedSrc;
179
+ this._sourceMime = file.type || null;
180
+ }
181
+ if (!this._sourceMime) {
182
+ this._sourceMime = detectMimeFromUrl(src);
76
183
  }
77
- if (!sourceMime) {
78
- sourceMime = detectMimeFromUrl(params.src);
184
+ const image = await loadImage(resolvedSrc);
185
+ if (!image.naturalWidth || !image.naturalHeight) {
186
+ this.failed = true;
187
+ return;
79
188
  }
80
- const workerParams = {
81
- originalSrc: resolvedSrc,
82
- sourceMime,
83
- image: params.cropperImage,
84
- selection: params.cropperSelection,
85
- canvas: params.cropperCanvas,
86
- selectHandle: params.selectHandle,
87
- aspectRatio: this._defaultGeometry.aspectRatio
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._worker?.destroy();
99
- this._worker = null;
100
- this.ready = false;
101
- if (this._originalSrc) {
102
- URL.revokeObjectURL(this._originalSrc);
103
- this._originalSrc = null;
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
- rotate = async () => {
107
- await this._worker?.rotate();
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
- this._worker?.zoomIn();
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._worker?.zoomOut();
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
- reset = async () => {
116
- await this._worker?.reset();
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
- changeAspectRatio = async (ratio) => {
119
- if (this._worker) {
120
- this._worker.canvasGeometry = { ...this._worker.canvasGeometry, aspectRatio: ratio };
121
- }
122
- this._defaultGeometry = { ...this._defaultGeometry, aspectRatio: ratio };
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
- crop = async () => {
126
- if (!this._worker) {
127
- return null;
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.loading = true;
130
- try {
131
- return await this._worker.crop({ fillColor: this.fillColor });
356
+ this.selection = null;
357
+ if (this.aspectRatio !== null) {
358
+ this.layout();
359
+ return;
132
360
  }
133
- finally {
134
- this.loading = false;
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 (!this._worker) {
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
- this.loading = true;
142
- try {
143
- return await this._worker.save({ fillColor: this.fillColor });
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
- finally {
146
- this.loading = false;
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
- clearSelection = () => {
150
- this._worker?.clearSelection();
151
- };
152
- enableCropMode = () => {
153
- this._worker?.enableCropMode();
154
- };
155
- enableMoveMode = () => {
156
- this._worker?.clearSelection();
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
- refit = () => {
159
- void this._worker?.refit();
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 { type CanvasGeometry, type CropperMode, type DragMode, ImgCropper, type ImgCropperDynamicAspectRatio, 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 } 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, noItemsPlaceholder, on, alternativeView, customNoItemsPlaceholder } = $props();
10
+ let { model, showAlternativeView = false, noItemsPlaceholderEnabled, noItemsPlaceholderDelay, on, alternativeView, customNoItemsPlaceholder } = $props();
11
11
  const localization = new TableLocalization();
12
- const view = createTableView(() => model, () => on, () => noItemsPlaceholder);
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
- * When an empty list turns into the no-items placeholder. A number delays it by that many ms (anti-flicker while the first page is in flight); a boolean
11
- * decides outright — `true` shows it as soon as the list is empty, `false` never shows it (pair it with your own loading / empty UI). Rows always win:
12
- * the placeholder is never rendered over a non-empty list.
13
- * @default 700
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, noItemsPlaceholder, grouping, on, alternativeView, customNoItemsPlaceholder } = $props();
6
+ let { model, showAlternativeView = false, noItemsPlaceholderEnabled, noItemsPlaceholderDelay, grouping, on, alternativeView, customNoItemsPlaceholder } = $props();
7
7
  const localization = new TableLocalization();
8
- const view = createTableView(() => model, () => on, () => noItemsPlaceholder);
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
- * When an empty list turns into the no-items placeholder. A number delays it by that many ms (anti-flicker while the first page is in flight); a boolean
11
- * decides outright — `true` shows it as soon as the list is empty, `false` never shows it (pair it with your own loading / empty UI). Rows always win:
12
- * the placeholder is never rendered over a non-empty list.
13
- * @default 700
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;