@cropvue/core 0.1.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/index.cjs ADDED
@@ -0,0 +1,1183 @@
1
+ 'use strict';
2
+
3
+ const vue = require('vue');
4
+
5
+ const MIN_SCALE = 0.1;
6
+ const MAX_SCALE = 10;
7
+ const MIN_CROP_SIZE = 32;
8
+ const SNAP_THRESHOLD_DEGREES = 3;
9
+
10
+ function createTransformState(overrides = {}) {
11
+ return {
12
+ x: 0,
13
+ y: 0,
14
+ scale: 1,
15
+ rotation: 0,
16
+ flipX: false,
17
+ flipY: false,
18
+ ...overrides
19
+ };
20
+ }
21
+ function createCropState(dimensions, overrides = {}) {
22
+ const state = {
23
+ x: 0,
24
+ y: 0,
25
+ width: dimensions.width,
26
+ height: dimensions.height,
27
+ stencil: "rectangle",
28
+ ...overrides
29
+ };
30
+ if (state.stencil === "circle") {
31
+ state.aspectRatio = 1;
32
+ const size = Math.min(state.width, state.height);
33
+ if (state.width !== state.height) {
34
+ state.x = state.x + (state.width - size) / 2;
35
+ state.y = state.y + (state.height - size) / 2;
36
+ state.width = size;
37
+ state.height = size;
38
+ }
39
+ }
40
+ return state;
41
+ }
42
+ function normalizeAngle(degrees) {
43
+ let result = degrees % 360;
44
+ if (result > 180) result -= 360;
45
+ if (result < -180) result += 360;
46
+ return result;
47
+ }
48
+ function snapRotation(degrees) {
49
+ const snaps = [0, 90, 180, 270, 360];
50
+ const normalized = (degrees % 360 + 360) % 360;
51
+ for (const snap of snaps) {
52
+ if (Math.abs(normalized - snap) <= SNAP_THRESHOLD_DEGREES) {
53
+ return snap === 360 ? 0 : snap;
54
+ }
55
+ }
56
+ return degrees;
57
+ }
58
+ function applyRotation(state, degrees) {
59
+ const raw = state.rotation + degrees;
60
+ const rotation = normalizeAngle(raw);
61
+ return { ...state, rotation };
62
+ }
63
+ function applyFlip(state, axis) {
64
+ if (axis === "x") {
65
+ return { ...state, flipX: !state.flipX };
66
+ }
67
+ return { ...state, flipY: !state.flipY };
68
+ }
69
+ function applyZoom(state, delta) {
70
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta));
71
+ return { ...state, scale: Math.round(scale * 1e3) / 1e3 };
72
+ }
73
+ function applyPan(state, dx, dy) {
74
+ return { ...state, x: state.x + dx, y: state.y + dy };
75
+ }
76
+ function clampTransform(state, bounds) {
77
+ return {
78
+ ...state,
79
+ x: Math.min(bounds.maxX, Math.max(bounds.minX, state.x)),
80
+ y: Math.min(bounds.maxY, Math.max(bounds.minY, state.y))
81
+ };
82
+ }
83
+ function resetTransform(_state) {
84
+ return createTransformState();
85
+ }
86
+
87
+ function renderCrop(canvas, image, crop, transform, options = {}) {
88
+ let outWidth = crop.width;
89
+ let outHeight = crop.height;
90
+ if (options.maxWidth || options.maxHeight) {
91
+ const maxW = options.maxWidth ?? Infinity;
92
+ const maxH = options.maxHeight ?? Infinity;
93
+ const ratio = Math.min(maxW / outWidth, maxH / outHeight, 1);
94
+ outWidth = Math.round(outWidth * ratio);
95
+ outHeight = Math.round(outHeight * ratio);
96
+ }
97
+ canvas.width = outWidth;
98
+ canvas.height = outHeight;
99
+ const ctx = canvas.getContext("2d");
100
+ if (!ctx) return;
101
+ ctx.clearRect(0, 0, outWidth, outHeight);
102
+ if (crop.stencil === "circle") {
103
+ ctx.beginPath();
104
+ ctx.arc(outWidth / 2, outHeight / 2, Math.min(outWidth, outHeight) / 2, 0, Math.PI * 2);
105
+ ctx.closePath();
106
+ ctx.clip();
107
+ }
108
+ if (crop.stencil === "freeform" && crop.points && crop.points.length >= 3) {
109
+ const scaleX2 = outWidth / crop.width;
110
+ const scaleY2 = outHeight / crop.height;
111
+ ctx.beginPath();
112
+ ctx.moveTo(
113
+ (crop.points[0].x - crop.x) * scaleX2,
114
+ (crop.points[0].y - crop.y) * scaleY2
115
+ );
116
+ for (let i = 1; i < crop.points.length; i++) {
117
+ ctx.lineTo(
118
+ (crop.points[i].x - crop.x) * scaleX2,
119
+ (crop.points[i].y - crop.y) * scaleY2
120
+ );
121
+ }
122
+ ctx.closePath();
123
+ ctx.clip();
124
+ }
125
+ const scaleX = outWidth / crop.width;
126
+ const scaleY = outHeight / crop.height;
127
+ const imgW = image.naturalWidth;
128
+ const imgH = image.naturalHeight;
129
+ ctx.save();
130
+ ctx.scale(scaleX, scaleY);
131
+ ctx.translate(-crop.x, -crop.y);
132
+ ctx.translate(imgW / 2, imgH / 2);
133
+ ctx.translate(transform.x, transform.y);
134
+ ctx.scale(
135
+ transform.flipX ? -transform.scale : transform.scale,
136
+ transform.flipY ? -transform.scale : transform.scale
137
+ );
138
+ ctx.rotate(transform.rotation * Math.PI / 180);
139
+ ctx.translate(-imgW / 2, -imgH / 2);
140
+ ctx.drawImage(image, 0, 0);
141
+ ctx.restore();
142
+ }
143
+ function exportCrop(canvas, options) {
144
+ return new Promise((resolve, reject) => {
145
+ canvas.toBlob(
146
+ (blob) => {
147
+ if (blob) {
148
+ resolve(blob);
149
+ } else {
150
+ reject(new Error(`Canvas export failed: toBlob returned null (canvas size: ${canvas.width}x${canvas.height}, format: ${options.format})`));
151
+ }
152
+ },
153
+ options.format,
154
+ options.quality
155
+ );
156
+ });
157
+ }
158
+
159
+ const EXTENSION_MAP = {
160
+ jpg: "image/jpeg",
161
+ jpeg: "image/jpeg",
162
+ png: "image/png",
163
+ webp: "image/webp",
164
+ gif: "image/gif",
165
+ bmp: "image/bmp",
166
+ avif: "image/avif"
167
+ };
168
+ function detectMimeType(file) {
169
+ if (file.type && file.type.startsWith("image/")) {
170
+ return file.type;
171
+ }
172
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
173
+ return EXTENSION_MAP[ext] ?? "image/jpeg";
174
+ }
175
+ let webpSupported = null;
176
+ function supportsWebP() {
177
+ if (webpSupported !== null) return webpSupported;
178
+ if (typeof document === "undefined") {
179
+ webpSupported = false;
180
+ return false;
181
+ }
182
+ const canvas = document.createElement("canvas");
183
+ canvas.width = 1;
184
+ canvas.height = 1;
185
+ webpSupported = canvas.toDataURL("image/webp").startsWith("data:image/webp");
186
+ canvas.width = 0;
187
+ canvas.height = 0;
188
+ return webpSupported;
189
+ }
190
+ function getMimeForFormat(format) {
191
+ switch (format) {
192
+ case "jpeg":
193
+ return "image/jpeg";
194
+ case "png":
195
+ return "image/png";
196
+ case "webp":
197
+ return "image/webp";
198
+ case "auto":
199
+ return supportsWebP() ? "image/webp" : "image/jpeg";
200
+ }
201
+ }
202
+ function hasTransparency(mime) {
203
+ return mime === "image/png" || mime === "image/webp" || mime === "image/avif";
204
+ }
205
+
206
+ function chooseOutputFormat(format, inputMime) {
207
+ if (format === "auto" && hasTransparency(inputMime) && inputMime === "image/png") {
208
+ return "image/png";
209
+ }
210
+ return getMimeForFormat(format);
211
+ }
212
+ async function compressBlob(canvas, options) {
213
+ const outputMime = chooseOutputFormat(options.format, options.inputMime);
214
+ let quality = options.quality;
215
+ let blob = await exportCrop(canvas, { format: outputMime, quality });
216
+ if (options.maxInputSize && blob.size > options.maxInputSize) {
217
+ const maxAttempts = 3;
218
+ for (let i = 0; i < maxAttempts && blob.size > options.maxInputSize; i++) {
219
+ quality = Math.max(0.1, quality - 0.15);
220
+ blob = await exportCrop(canvas, { format: outputMime, quality });
221
+ }
222
+ }
223
+ return blob;
224
+ }
225
+ function useCompressor(options = {}) {
226
+ const isCompressing = vue.ref(false);
227
+ async function compress(canvas, inputFile) {
228
+ isCompressing.value = true;
229
+ try {
230
+ const inputMime = inputFile ? detectMimeType(inputFile) : "image/jpeg";
231
+ return await compressBlob(canvas, {
232
+ format: options.format ?? "auto",
233
+ quality: options.quality ?? 0.85,
234
+ inputMime,
235
+ maxInputSize: inputFile?.size
236
+ });
237
+ } finally {
238
+ isCompressing.value = false;
239
+ }
240
+ }
241
+ return { compress, isCompressing };
242
+ }
243
+
244
+ let cachedMaxSize = null;
245
+ function getMaxCanvasSize() {
246
+ if (cachedMaxSize !== null) return cachedMaxSize;
247
+ if (typeof document === "undefined") {
248
+ cachedMaxSize = 4096;
249
+ return cachedMaxSize;
250
+ }
251
+ const canvas = document.createElement("canvas");
252
+ let low = 1024;
253
+ let high = 16384;
254
+ while (low < high) {
255
+ const mid = Math.floor((low + high + 1) / 2);
256
+ canvas.width = mid;
257
+ canvas.height = 1;
258
+ const ctx = canvas.getContext("2d");
259
+ if (ctx) {
260
+ ctx.fillRect(0, 0, 1, 1);
261
+ const pixel = ctx.getImageData(0, 0, 1, 1).data;
262
+ if (pixel[3] > 0) {
263
+ low = mid;
264
+ } else {
265
+ high = mid - 1;
266
+ }
267
+ } else {
268
+ high = mid - 1;
269
+ }
270
+ }
271
+ cachedMaxSize = low;
272
+ canvas.width = 0;
273
+ canvas.height = 0;
274
+ return cachedMaxSize;
275
+ }
276
+ function downsampleDimensions(width, height, maxDimension) {
277
+ if (width <= maxDimension && height <= maxDimension) {
278
+ return { width, height };
279
+ }
280
+ const ratio = Math.min(maxDimension / width, maxDimension / height);
281
+ return {
282
+ width: Math.round(width * ratio),
283
+ height: Math.round(height * ratio)
284
+ };
285
+ }
286
+
287
+ function loadImageFromFile(file) {
288
+ return new Promise((resolve, reject) => {
289
+ const url = URL.createObjectURL(file);
290
+ const img = new Image();
291
+ img.onload = () => {
292
+ URL.revokeObjectURL(url);
293
+ resolve({
294
+ element: img,
295
+ naturalWidth: img.naturalWidth,
296
+ naturalHeight: img.naturalHeight,
297
+ originalFile: file,
298
+ originalSize: file.size
299
+ });
300
+ };
301
+ img.onerror = () => {
302
+ URL.revokeObjectURL(url);
303
+ reject(new Error(`Failed to load image: ${file.name}`));
304
+ };
305
+ img.src = url;
306
+ });
307
+ }
308
+ async function loadImageFromUrl(url) {
309
+ const response = await fetch(url);
310
+ if (!response.ok) {
311
+ throw new Error(`Failed to fetch image: ${url} (${response.status})`);
312
+ }
313
+ const blob = await response.blob();
314
+ const blobUrl = URL.createObjectURL(blob);
315
+ return new Promise((resolve, reject) => {
316
+ const img = new Image();
317
+ img.onload = () => {
318
+ URL.revokeObjectURL(blobUrl);
319
+ resolve({
320
+ element: img,
321
+ naturalWidth: img.naturalWidth,
322
+ naturalHeight: img.naturalHeight,
323
+ originalSize: blob.size
324
+ });
325
+ };
326
+ img.onerror = () => {
327
+ URL.revokeObjectURL(blobUrl);
328
+ reject(new Error(`Failed to load image: ${url}`));
329
+ };
330
+ img.src = blobUrl;
331
+ });
332
+ }
333
+ function needsDownsample(width, height) {
334
+ const maxSize = getMaxCanvasSize();
335
+ return width > maxSize || height > maxSize;
336
+ }
337
+ function getSafeDimensions(width, height) {
338
+ const maxSize = getMaxCanvasSize();
339
+ return downsampleDimensions(width, height, maxSize);
340
+ }
341
+
342
+ function useCropper(options = {}) {
343
+ const transform = vue.ref(createTransformState());
344
+ const crop = vue.ref(
345
+ createCropState({ width: 0, height: 0 }, {
346
+ stencil: options.stencil ?? "rectangle",
347
+ aspectRatio: options.aspectRatio ?? void 0,
348
+ minWidth: options.minWidth,
349
+ minHeight: options.minHeight,
350
+ maxWidth: options.maxWidth,
351
+ maxHeight: options.maxHeight
352
+ })
353
+ );
354
+ const image = vue.ref(null);
355
+ const isReady = vue.ref(false);
356
+ const canvasRef = vue.ref(null);
357
+ function initCropForImage(img) {
358
+ const stencil = crop.value.stencil ?? options.stencil ?? "rectangle";
359
+ const aspectRatio = stencil === "circle" ? 1 : crop.value.aspectRatio ?? options.aspectRatio;
360
+ crop.value = createCropState(
361
+ { width: img.naturalWidth, height: img.naturalHeight },
362
+ {
363
+ stencil,
364
+ aspectRatio,
365
+ minWidth: options.minWidth,
366
+ minHeight: options.minHeight,
367
+ maxWidth: options.maxWidth,
368
+ maxHeight: options.maxHeight
369
+ }
370
+ );
371
+ transform.value = createTransformState();
372
+ }
373
+ async function loadFile(file) {
374
+ image.value = await loadImageFromFile(file);
375
+ initCropForImage(image.value);
376
+ isReady.value = true;
377
+ }
378
+ async function loadUrl(url) {
379
+ image.value = await loadImageFromUrl(url);
380
+ initCropForImage(image.value);
381
+ isReady.value = true;
382
+ }
383
+ function rotateLeft() {
384
+ transform.value = applyRotation(transform.value, -90);
385
+ }
386
+ function rotateRight() {
387
+ transform.value = applyRotation(transform.value, 90);
388
+ }
389
+ function rotateTo(degrees) {
390
+ transform.value = { ...transform.value, rotation: degrees };
391
+ }
392
+ function flipX() {
393
+ transform.value = applyFlip(transform.value, "x");
394
+ }
395
+ function flipY() {
396
+ transform.value = applyFlip(transform.value, "y");
397
+ }
398
+ function zoomTo(scale) {
399
+ transform.value = { ...transform.value, scale: Math.max(0.1, Math.min(10, scale)) };
400
+ }
401
+ function zoomBy(delta) {
402
+ transform.value = applyZoom(transform.value, delta);
403
+ }
404
+ function panTo(x, y) {
405
+ transform.value = { ...transform.value, x, y };
406
+ }
407
+ function reset() {
408
+ transform.value = resetTransform(transform.value);
409
+ }
410
+ function setCropArea(area) {
411
+ crop.value = { ...crop.value, ...area };
412
+ }
413
+ function setStencil(stencil) {
414
+ const prev = crop.value;
415
+ if (stencil === "circle") {
416
+ const size = Math.min(prev.width, prev.height);
417
+ const x = prev.x + (prev.width - size) / 2;
418
+ const y = prev.y + (prev.height - size) / 2;
419
+ crop.value = { ...prev, stencil, aspectRatio: 1, x, y, width: size, height: size };
420
+ } else {
421
+ const aspectRatio = prev.aspectRatio === 1 && prev.stencil === "circle" ? void 0 : prev.aspectRatio;
422
+ crop.value = { ...prev, stencil, aspectRatio };
423
+ }
424
+ }
425
+ function setAspectRatio(ratio) {
426
+ crop.value = { ...crop.value, aspectRatio: ratio ?? void 0 };
427
+ }
428
+ async function getResult(opts) {
429
+ const canvas = canvasRef.value ?? document.createElement("canvas");
430
+ const img = image.value;
431
+ if (!img) throw new Error("No image loaded");
432
+ renderCrop(canvas, img.element, crop.value, transform.value, {
433
+ maxWidth: opts?.maxWidth ?? options.outputMaxWidth,
434
+ maxHeight: opts?.maxHeight ?? options.outputMaxHeight
435
+ });
436
+ const inputMime = img.originalFile ? detectMimeType(img.originalFile) : "image/jpeg";
437
+ const blob = await compressBlob(canvas, {
438
+ format: opts?.format ?? options.outputFormat ?? "auto",
439
+ quality: opts?.quality ?? options.outputQuality ?? 0.85,
440
+ inputMime,
441
+ maxInputSize: img.originalSize
442
+ });
443
+ const url = URL.createObjectURL(blob);
444
+ const file = new File([blob], `cropped.${blob.type.split("/")[1] ?? "jpg"}`, {
445
+ type: blob.type
446
+ });
447
+ return {
448
+ blob,
449
+ file,
450
+ url,
451
+ coords: {
452
+ x: crop.value.x,
453
+ y: crop.value.y,
454
+ width: crop.value.width,
455
+ height: crop.value.height,
456
+ rotation: transform.value.rotation,
457
+ flipX: transform.value.flipX,
458
+ flipY: transform.value.flipY,
459
+ scale: transform.value.scale
460
+ },
461
+ width: canvas.width,
462
+ height: canvas.height,
463
+ originalWidth: img.naturalWidth,
464
+ originalHeight: img.naturalHeight
465
+ };
466
+ }
467
+ function getPreviewUrl() {
468
+ const canvas = canvasRef.value;
469
+ if (!canvas) return "";
470
+ return canvas.toDataURL();
471
+ }
472
+ function renderToCanvas() {
473
+ const canvas = canvasRef.value;
474
+ if (!canvas || !image.value) return;
475
+ renderCrop(canvas, image.value.element, crop.value, transform.value, {
476
+ maxWidth: options.outputMaxWidth,
477
+ maxHeight: options.outputMaxHeight
478
+ });
479
+ }
480
+ return {
481
+ // State
482
+ image,
483
+ transform,
484
+ crop,
485
+ isReady,
486
+ // Image loading
487
+ loadFile,
488
+ loadUrl,
489
+ // Manipulation
490
+ rotateLeft,
491
+ rotateRight,
492
+ rotateTo,
493
+ flipX,
494
+ flipY,
495
+ zoomTo,
496
+ zoomBy,
497
+ panTo,
498
+ reset,
499
+ // Crop area
500
+ setCropArea,
501
+ setStencil,
502
+ setAspectRatio,
503
+ // Output
504
+ getResult,
505
+ getPreviewUrl,
506
+ // Canvas
507
+ canvasRef,
508
+ renderToCanvas
509
+ };
510
+ }
511
+
512
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
513
+ ".jpg",
514
+ ".jpeg",
515
+ ".png",
516
+ ".gif",
517
+ ".webp",
518
+ ".bmp",
519
+ ".svg",
520
+ ".avif",
521
+ ".ico",
522
+ ".tiff",
523
+ ".tif"
524
+ ]);
525
+ function hasImageExtension(filename) {
526
+ const ext = filename.lastIndexOf(".") !== -1 ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
527
+ return IMAGE_EXTENSIONS.has(ext);
528
+ }
529
+ function validateFile(file, options) {
530
+ if (file.size > options.maxSize) {
531
+ return {
532
+ type: "file-too-large",
533
+ maxSize: options.maxSize,
534
+ actualSize: file.size
535
+ };
536
+ }
537
+ const accepted = options.accept.some((pattern) => {
538
+ if (pattern === "image/*") {
539
+ if (file.type) return file.type.startsWith("image/");
540
+ return hasImageExtension(file.name);
541
+ }
542
+ if (file.type) return file.type === pattern;
543
+ const ext = file.name.lastIndexOf(".") !== -1 ? file.name.slice(file.name.lastIndexOf(".") + 1).toLowerCase() : "";
544
+ return pattern.endsWith(`/${ext}`);
545
+ });
546
+ if (!accepted) {
547
+ return {
548
+ type: "invalid-type",
549
+ accepted: options.accept,
550
+ actual: file.type
551
+ };
552
+ }
553
+ return null;
554
+ }
555
+ function useDropzone(options = {}) {
556
+ const isDragging = vue.ref(false);
557
+ const files = vue.ref([]);
558
+ const dropzoneRef = vue.ref(null);
559
+ const accept = options.accept ?? ["image/*"];
560
+ const maxSize = options.maxSize ?? Infinity;
561
+ const multiple = options.multiple ?? false;
562
+ function processFiles(fileList) {
563
+ const incoming = Array.from(fileList);
564
+ const valid = [];
565
+ for (const file of incoming) {
566
+ const error = validateFile(file, { accept, maxSize });
567
+ if (error) {
568
+ options.onError?.(error);
569
+ } else {
570
+ valid.push(file);
571
+ }
572
+ }
573
+ const result = multiple ? valid : valid.slice(0, 1);
574
+ if (result.length > 0) {
575
+ files.value = result;
576
+ options.onFiles?.(result);
577
+ }
578
+ }
579
+ function open() {
580
+ const input = document.createElement("input");
581
+ input.type = "file";
582
+ input.accept = accept.join(",");
583
+ input.multiple = multiple;
584
+ input.onchange = () => {
585
+ if (input.files) processFiles(input.files);
586
+ };
587
+ input.click();
588
+ }
589
+ function onDragOver(e) {
590
+ e.preventDefault();
591
+ isDragging.value = true;
592
+ }
593
+ function onDragLeave(e) {
594
+ e.preventDefault();
595
+ isDragging.value = false;
596
+ }
597
+ function onDrop(e) {
598
+ e.preventDefault();
599
+ isDragging.value = false;
600
+ if (e.dataTransfer?.files) {
601
+ processFiles(e.dataTransfer.files);
602
+ }
603
+ }
604
+ let currentEl = null;
605
+ function attachListeners(el) {
606
+ el.addEventListener("dragover", onDragOver);
607
+ el.addEventListener("dragleave", onDragLeave);
608
+ el.addEventListener("drop", onDrop);
609
+ }
610
+ function detachListeners(el) {
611
+ el.removeEventListener("dragover", onDragOver);
612
+ el.removeEventListener("dragleave", onDragLeave);
613
+ el.removeEventListener("drop", onDrop);
614
+ }
615
+ vue.watch(dropzoneRef, (newEl, oldEl) => {
616
+ if (oldEl) detachListeners(oldEl);
617
+ if (newEl) attachListeners(newEl);
618
+ currentEl = newEl;
619
+ }, { flush: "post" });
620
+ vue.onMounted(() => {
621
+ const el = dropzoneRef.value;
622
+ if (el && el !== currentEl) {
623
+ attachListeners(el);
624
+ currentEl = el;
625
+ }
626
+ });
627
+ vue.onUnmounted(() => {
628
+ if (currentEl) {
629
+ detachListeners(currentEl);
630
+ currentEl = null;
631
+ }
632
+ });
633
+ return { isDragging, files, dropzoneRef, open };
634
+ }
635
+
636
+ let nextId = 0;
637
+ function generateId() {
638
+ return `cropvue-${++nextId}-${Date.now()}`;
639
+ }
640
+ function createQueue() {
641
+ const state = {
642
+ items: [],
643
+ currentIndex: -1
644
+ };
645
+ function add(files) {
646
+ const newItems = files.map((file) => ({
647
+ id: generateId(),
648
+ file,
649
+ thumbnail: URL.createObjectURL(file),
650
+ status: "pending"
651
+ }));
652
+ state.items.push(...newItems);
653
+ if (state.currentIndex === -1 && state.items.length > 0) {
654
+ state.currentIndex = 0;
655
+ }
656
+ }
657
+ function remove(index) {
658
+ if (index < 0 || index >= state.items.length) return;
659
+ const item = state.items[index];
660
+ URL.revokeObjectURL(item.thumbnail);
661
+ state.items.splice(index, 1);
662
+ if (state.items.length === 0) {
663
+ state.currentIndex = -1;
664
+ } else if (state.currentIndex >= state.items.length) {
665
+ state.currentIndex = state.items.length - 1;
666
+ }
667
+ }
668
+ function select(index) {
669
+ if (index >= 0 && index < state.items.length) {
670
+ state.currentIndex = index;
671
+ }
672
+ }
673
+ function next() {
674
+ if (state.currentIndex < state.items.length - 1) {
675
+ state.currentIndex++;
676
+ }
677
+ }
678
+ function previous() {
679
+ if (state.currentIndex > 0) {
680
+ state.currentIndex--;
681
+ }
682
+ }
683
+ function clear() {
684
+ for (const item of state.items) {
685
+ URL.revokeObjectURL(item.thumbnail);
686
+ }
687
+ state.items.length = 0;
688
+ state.currentIndex = -1;
689
+ }
690
+ function setResult(index, result) {
691
+ if (index >= 0 && index < state.items.length) {
692
+ state.items[index].result = result;
693
+ state.items[index].status = "done";
694
+ }
695
+ }
696
+ Object.assign(state, { add, remove, select, next, previous, clear, setResult });
697
+ return state;
698
+ }
699
+ function useImageQueue() {
700
+ const queue = createQueue();
701
+ const images = vue.ref(queue.items);
702
+ const current = vue.ref(queue.currentIndex);
703
+ function sync() {
704
+ images.value = [...queue.items];
705
+ current.value = queue.currentIndex;
706
+ }
707
+ function add(files) {
708
+ queue.add(files);
709
+ sync();
710
+ }
711
+ function remove(index) {
712
+ queue.remove(index);
713
+ sync();
714
+ }
715
+ function select(index) {
716
+ queue.select(index);
717
+ sync();
718
+ }
719
+ function next() {
720
+ queue.next();
721
+ sync();
722
+ }
723
+ function previous() {
724
+ queue.previous();
725
+ sync();
726
+ }
727
+ function clear() {
728
+ queue.clear();
729
+ sync();
730
+ }
731
+ const results = vue.computed(
732
+ () => queue.items.filter((i) => i.result).map((i) => i.result)
733
+ );
734
+ vue.onUnmounted(() => {
735
+ queue.clear();
736
+ });
737
+ return { images, current, add, remove, select, next, previous, clear, results };
738
+ }
739
+
740
+ function createUploadHandler(options) {
741
+ if (options.handler) {
742
+ return options.handler;
743
+ }
744
+ if (options.url) {
745
+ const url = options.url;
746
+ const fieldName = options.fieldName ?? "file";
747
+ const headers = options.headers ?? {};
748
+ return async (file, { onProgress, signal }) => {
749
+ const formData = new FormData();
750
+ formData.append(fieldName, file);
751
+ const xhr = new XMLHttpRequest();
752
+ return new Promise((resolve, reject) => {
753
+ xhr.upload.addEventListener("progress", (e) => {
754
+ if (e.lengthComputable) {
755
+ onProgress(Math.round(e.loaded / e.total * 100));
756
+ }
757
+ });
758
+ xhr.addEventListener("load", () => {
759
+ if (xhr.status >= 200 && xhr.status < 300) {
760
+ try {
761
+ resolve(JSON.parse(xhr.responseText));
762
+ } catch {
763
+ resolve({});
764
+ }
765
+ } else {
766
+ reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
767
+ }
768
+ });
769
+ xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
770
+ xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
771
+ signal.addEventListener("abort", () => xhr.abort());
772
+ xhr.open("POST", url);
773
+ for (const [key, value] of Object.entries(headers)) {
774
+ xhr.setRequestHeader(key, value);
775
+ }
776
+ xhr.send(formData);
777
+ });
778
+ };
779
+ }
780
+ throw new Error("useUploader requires either a handler function or a url");
781
+ }
782
+ function useUploader(options = {}) {
783
+ const isUploading = vue.ref(false);
784
+ const progress = vue.ref(0);
785
+ const error = vue.ref(null);
786
+ let abortController = null;
787
+ async function upload(file) {
788
+ const handler = createUploadHandler(options);
789
+ isUploading.value = true;
790
+ progress.value = 0;
791
+ error.value = null;
792
+ abortController = new AbortController();
793
+ const uploadFile = file instanceof File ? file : new File([file], "cropped-image", { type: file.type });
794
+ try {
795
+ const result = await handler(uploadFile, {
796
+ onProgress: (p) => {
797
+ progress.value = p;
798
+ },
799
+ signal: abortController.signal
800
+ });
801
+ progress.value = 100;
802
+ return result;
803
+ } catch (e) {
804
+ if (e instanceof Error && e.name === "AbortError") {
805
+ error.value = "Upload cancelled";
806
+ } else {
807
+ error.value = e instanceof Error ? e.message : "Upload failed";
808
+ }
809
+ throw e;
810
+ } finally {
811
+ isUploading.value = false;
812
+ abortController = null;
813
+ }
814
+ }
815
+ function abort() {
816
+ abortController?.abort();
817
+ }
818
+ return { upload, isUploading, progress, error, abort };
819
+ }
820
+
821
+ function usePointerHandler(element, options) {
822
+ let dragMode = null;
823
+ let activeHandle = null;
824
+ let lastX = 0;
825
+ let lastY = 0;
826
+ let activePointerId = null;
827
+ function onPointerDown(e) {
828
+ if (e.button !== 0) return;
829
+ if (activePointerId !== null) return;
830
+ activePointerId = e.pointerId;
831
+ lastX = e.clientX;
832
+ lastY = e.clientY;
833
+ const handle = options.getHandleAtPoint(e);
834
+ if (handle) {
835
+ dragMode = "resize";
836
+ activeHandle = handle;
837
+ } else if (options.isInsideCropArea(e)) {
838
+ dragMode = "crop-move";
839
+ activeHandle = null;
840
+ } else {
841
+ dragMode = "pan";
842
+ activeHandle = null;
843
+ }
844
+ try {
845
+ element.setPointerCapture(e.pointerId);
846
+ } catch {
847
+ }
848
+ e.preventDefault();
849
+ }
850
+ function onPointerMove(e) {
851
+ if (activePointerId !== e.pointerId) return;
852
+ if (!dragMode) return;
853
+ const scale = options.displayScale();
854
+ const dx = (e.clientX - lastX) / scale;
855
+ const dy = (e.clientY - lastY) / scale;
856
+ lastX = e.clientX;
857
+ lastY = e.clientY;
858
+ if (dragMode === "pan") {
859
+ options.onPan(dx, dy);
860
+ } else if (dragMode === "resize" && activeHandle) {
861
+ options.onCropResize(activeHandle, dx, dy);
862
+ } else if (dragMode === "crop-move") {
863
+ options.onCropMove(dx, dy);
864
+ }
865
+ }
866
+ function onPointerUp(e) {
867
+ if (activePointerId !== e.pointerId) return;
868
+ endDrag(e);
869
+ }
870
+ function onPointerCancel(e) {
871
+ if (activePointerId !== e.pointerId) return;
872
+ endDrag(e);
873
+ }
874
+ function endDrag(e) {
875
+ if (activePointerId !== null) {
876
+ try {
877
+ element.releasePointerCapture(e.pointerId);
878
+ } catch {
879
+ }
880
+ }
881
+ dragMode = null;
882
+ activeHandle = null;
883
+ activePointerId = null;
884
+ }
885
+ function onWheel(e) {
886
+ e.preventDefault();
887
+ const rect = element.getBoundingClientRect();
888
+ const centerX = (e.clientX - rect.left) / options.displayScale();
889
+ const centerY = (e.clientY - rect.top) / options.displayScale();
890
+ const delta = -e.deltaY * 1e-3;
891
+ options.onZoom(delta, centerX, centerY);
892
+ }
893
+ function onKeyDown(e) {
894
+ const handled = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "_"];
895
+ if (handled.includes(e.key)) {
896
+ e.preventDefault();
897
+ options.onKeyboard(e.key, e.shiftKey);
898
+ }
899
+ }
900
+ element.addEventListener("pointerdown", onPointerDown);
901
+ element.addEventListener("pointermove", onPointerMove);
902
+ element.addEventListener("pointerup", onPointerUp);
903
+ element.addEventListener("pointercancel", onPointerCancel);
904
+ element.addEventListener("wheel", onWheel, { passive: false });
905
+ element.addEventListener("keydown", onKeyDown);
906
+ function destroy() {
907
+ element.removeEventListener("pointerdown", onPointerDown);
908
+ element.removeEventListener("pointermove", onPointerMove);
909
+ element.removeEventListener("pointerup", onPointerUp);
910
+ element.removeEventListener("pointercancel", onPointerCancel);
911
+ element.removeEventListener("wheel", onWheel);
912
+ element.removeEventListener("keydown", onKeyDown);
913
+ }
914
+ return { destroy };
915
+ }
916
+
917
+ function constrainCropSize(crop, bounds) {
918
+ let { width, height } = crop;
919
+ const minW = crop.minWidth ?? MIN_CROP_SIZE;
920
+ const minH = crop.minHeight ?? MIN_CROP_SIZE;
921
+ const maxW = Math.min(crop.maxWidth ?? Infinity, bounds.containerWidth);
922
+ const maxH = Math.min(crop.maxHeight ?? Infinity, bounds.containerHeight);
923
+ width = Math.max(minW, Math.min(maxW, width));
924
+ height = Math.max(minH, Math.min(maxH, height));
925
+ return { ...crop, width, height };
926
+ }
927
+ function constrainAspectRatio(crop) {
928
+ if (!crop.aspectRatio) return crop;
929
+ const ratio = crop.aspectRatio;
930
+ let { width, height } = crop;
931
+ const targetHeight = width / ratio;
932
+ if (targetHeight <= height) {
933
+ height = Math.round(targetHeight);
934
+ } else {
935
+ width = Math.round(height * ratio);
936
+ }
937
+ return { ...crop, width, height };
938
+ }
939
+ function constrainCropPosition(crop, bounds) {
940
+ let { x, y } = crop;
941
+ x = Math.max(0, Math.min(bounds.containerWidth - crop.width, x));
942
+ y = Math.max(0, Math.min(bounds.containerHeight - crop.height, y));
943
+ return { ...crop, x, y };
944
+ }
945
+
946
+ function handlePan(state, dx, dy) {
947
+ return {
948
+ ...state,
949
+ x: state.x + dx,
950
+ y: state.y + dy
951
+ };
952
+ }
953
+ function handleZoom(state, delta, centerX, centerY) {
954
+ const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, state.scale + delta));
955
+ const ratio = newScale / state.scale;
956
+ const newX = centerX - (centerX - state.x) * ratio;
957
+ const newY = centerY - (centerY - state.y) * ratio;
958
+ return {
959
+ ...state,
960
+ scale: newScale,
961
+ x: newX,
962
+ y: newY
963
+ };
964
+ }
965
+ function handleCropResize(crop, handle, dx, dy, bounds) {
966
+ let { x, y, width, height } = crop;
967
+ if (crop.stencil === "circle") {
968
+ if (handle !== "ne") return crop;
969
+ const delta = (dx + -dy) / 2;
970
+ const anchorBottom = y + height;
971
+ width += delta;
972
+ height += delta;
973
+ y = anchorBottom - height;
974
+ if (width < MIN_CROP_SIZE) {
975
+ width = MIN_CROP_SIZE;
976
+ height = MIN_CROP_SIZE;
977
+ y = anchorBottom - MIN_CROP_SIZE;
978
+ }
979
+ x = Math.max(0, x);
980
+ y = Math.max(0, y);
981
+ if (x + width > bounds.width) {
982
+ const maxSize = bounds.width - x;
983
+ width = maxSize;
984
+ height = maxSize;
985
+ y = anchorBottom - height;
986
+ }
987
+ if (y < 0) {
988
+ const maxSize = anchorBottom;
989
+ width = maxSize;
990
+ height = maxSize;
991
+ y = 0;
992
+ }
993
+ if (y + height > bounds.height) {
994
+ const maxSize = bounds.height - y;
995
+ width = maxSize;
996
+ height = maxSize;
997
+ }
998
+ return { ...crop, x, y, width, height };
999
+ }
1000
+ switch (handle) {
1001
+ case "se":
1002
+ width += dx;
1003
+ height += dy;
1004
+ break;
1005
+ case "nw":
1006
+ x += dx;
1007
+ y += dy;
1008
+ width -= dx;
1009
+ height -= dy;
1010
+ break;
1011
+ case "ne":
1012
+ y += dy;
1013
+ width += dx;
1014
+ height -= dy;
1015
+ break;
1016
+ case "sw":
1017
+ x += dx;
1018
+ width -= dx;
1019
+ height += dy;
1020
+ break;
1021
+ case "n":
1022
+ y += dy;
1023
+ height -= dy;
1024
+ break;
1025
+ case "s":
1026
+ height += dy;
1027
+ break;
1028
+ case "e":
1029
+ width += dx;
1030
+ break;
1031
+ case "w":
1032
+ x += dx;
1033
+ width -= dx;
1034
+ break;
1035
+ }
1036
+ if (width < MIN_CROP_SIZE) {
1037
+ if (handle === "nw" || handle === "sw" || handle === "w") {
1038
+ x = crop.x + crop.width - MIN_CROP_SIZE;
1039
+ }
1040
+ width = MIN_CROP_SIZE;
1041
+ }
1042
+ if (height < MIN_CROP_SIZE) {
1043
+ if (handle === "nw" || handle === "ne" || handle === "n") {
1044
+ y = crop.y + crop.height - MIN_CROP_SIZE;
1045
+ }
1046
+ height = MIN_CROP_SIZE;
1047
+ }
1048
+ x = Math.max(0, x);
1049
+ y = Math.max(0, y);
1050
+ if (x + width > bounds.width) width = bounds.width - x;
1051
+ if (y + height > bounds.height) height = bounds.height - y;
1052
+ return { ...crop, x, y, width, height };
1053
+ }
1054
+ function handleCropMove(crop, dx, dy, bounds) {
1055
+ let x = crop.x + dx;
1056
+ let y = crop.y + dy;
1057
+ x = Math.max(0, Math.min(x, bounds.width - crop.width));
1058
+ y = Math.max(0, Math.min(y, bounds.height - crop.height));
1059
+ return { ...crop, x, y };
1060
+ }
1061
+ const STEP = 1;
1062
+ const SHIFT_STEP = 10;
1063
+ const ZOOM_STEP = 0.05;
1064
+ function handleKeyboard(state, key, shiftKey) {
1065
+ const step = shiftKey ? SHIFT_STEP : STEP;
1066
+ switch (key) {
1067
+ case "ArrowLeft":
1068
+ return { ...state, x: state.x - step };
1069
+ case "ArrowRight":
1070
+ return { ...state, x: state.x + step };
1071
+ case "ArrowUp":
1072
+ return { ...state, y: state.y - step };
1073
+ case "ArrowDown":
1074
+ return { ...state, y: state.y + step };
1075
+ case "+":
1076
+ case "=":
1077
+ return {
1078
+ ...state,
1079
+ scale: Math.min(MAX_SCALE, state.scale + ZOOM_STEP)
1080
+ };
1081
+ case "-":
1082
+ case "_":
1083
+ return {
1084
+ ...state,
1085
+ scale: Math.max(MIN_SCALE, state.scale - ZOOM_STEP)
1086
+ };
1087
+ default:
1088
+ return state;
1089
+ }
1090
+ }
1091
+
1092
+ function getRectangleClipPath(rect, container) {
1093
+ const top = rect.y;
1094
+ const right = container.containerWidth - (rect.x + rect.width);
1095
+ const bottom = container.containerHeight - (rect.y + rect.height);
1096
+ const left = rect.x;
1097
+ return `inset(${top}px ${right}px ${bottom}px ${left}px)`;
1098
+ }
1099
+ function getCircleClipPath(rect, _container) {
1100
+ const radius = Math.min(rect.width, rect.height) / 2;
1101
+ const cx = rect.x + rect.width / 2;
1102
+ const cy = rect.y + rect.height / 2;
1103
+ return `circle(${radius}px at ${cx}px ${cy}px)`;
1104
+ }
1105
+ function getFreeformClipPath(points) {
1106
+ if (points.length < 3) return "";
1107
+ const coords = points.map((p) => `${p.x}px ${p.y}px`).join(", ");
1108
+ return `polygon(${coords})`;
1109
+ }
1110
+ function isPointInsideStencil(px, py, crop) {
1111
+ if (crop.stencil === "rectangle") {
1112
+ return px >= crop.x && px <= crop.x + crop.width && py >= crop.y && py <= crop.y + crop.height;
1113
+ }
1114
+ if (crop.stencil === "circle") {
1115
+ const cx = crop.x + crop.width / 2;
1116
+ const cy = crop.y + crop.height / 2;
1117
+ const r = Math.min(crop.width, crop.height) / 2;
1118
+ const dx = px - cx;
1119
+ const dy = py - cy;
1120
+ return dx * dx + dy * dy <= r * r;
1121
+ }
1122
+ if (crop.stencil === "freeform" && crop.points && crop.points.length >= 3) {
1123
+ return isPointInsidePolygon(px, py, crop.points);
1124
+ }
1125
+ return false;
1126
+ }
1127
+ function isPointInsidePolygon(px, py, polygon) {
1128
+ let inside = false;
1129
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
1130
+ const xi = polygon[i].x;
1131
+ const yi = polygon[i].y;
1132
+ const xj = polygon[j].x;
1133
+ const yj = polygon[j].y;
1134
+ const intersect = yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi;
1135
+ if (intersect) inside = !inside;
1136
+ }
1137
+ return inside;
1138
+ }
1139
+
1140
+ exports.applyFlip = applyFlip;
1141
+ exports.applyPan = applyPan;
1142
+ exports.applyRotation = applyRotation;
1143
+ exports.applyZoom = applyZoom;
1144
+ exports.chooseOutputFormat = chooseOutputFormat;
1145
+ exports.clampTransform = clampTransform;
1146
+ exports.compressBlob = compressBlob;
1147
+ exports.constrainAspectRatio = constrainAspectRatio;
1148
+ exports.constrainCropPosition = constrainCropPosition;
1149
+ exports.constrainCropSize = constrainCropSize;
1150
+ exports.createCropState = createCropState;
1151
+ exports.createQueue = createQueue;
1152
+ exports.createTransformState = createTransformState;
1153
+ exports.createUploadHandler = createUploadHandler;
1154
+ exports.detectMimeType = detectMimeType;
1155
+ exports.downsampleDimensions = downsampleDimensions;
1156
+ exports.exportCrop = exportCrop;
1157
+ exports.getCircleClipPath = getCircleClipPath;
1158
+ exports.getFreeformClipPath = getFreeformClipPath;
1159
+ exports.getMaxCanvasSize = getMaxCanvasSize;
1160
+ exports.getMimeForFormat = getMimeForFormat;
1161
+ exports.getRectangleClipPath = getRectangleClipPath;
1162
+ exports.getSafeDimensions = getSafeDimensions;
1163
+ exports.handleCropMove = handleCropMove;
1164
+ exports.handleCropResize = handleCropResize;
1165
+ exports.handleKeyboard = handleKeyboard;
1166
+ exports.handlePan = handlePan;
1167
+ exports.handleZoom = handleZoom;
1168
+ exports.hasTransparency = hasTransparency;
1169
+ exports.isPointInsideStencil = isPointInsideStencil;
1170
+ exports.loadImageFromFile = loadImageFromFile;
1171
+ exports.loadImageFromUrl = loadImageFromUrl;
1172
+ exports.needsDownsample = needsDownsample;
1173
+ exports.renderCrop = renderCrop;
1174
+ exports.resetTransform = resetTransform;
1175
+ exports.snapRotation = snapRotation;
1176
+ exports.supportsWebP = supportsWebP;
1177
+ exports.useCompressor = useCompressor;
1178
+ exports.useCropper = useCropper;
1179
+ exports.useDropzone = useDropzone;
1180
+ exports.useImageQueue = useImageQueue;
1181
+ exports.usePointerHandler = usePointerHandler;
1182
+ exports.useUploader = useUploader;
1183
+ exports.validateFile = validateFile;