@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/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/index.cjs +1183 -0
- package/dist/index.d.cts +424 -0
- package/dist/index.d.mts +424 -0
- package/dist/index.d.ts +424 -0
- package/dist/index.mjs +1138 -0
- package/package.json +43 -0
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;
|