@ibanzajoe/uploader 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.js ADDED
@@ -0,0 +1,1173 @@
1
+ import {
2
+ UploaderClient,
3
+ crop,
4
+ flip,
5
+ flop,
6
+ output,
7
+ quality,
8
+ resize,
9
+ rotate,
10
+ transformUrl
11
+ } from "./chunk-EM6NZZSU.js";
12
+
13
+ // src/react/PickerOverlay.tsx
14
+ import {
15
+ useRef as useRef3,
16
+ useState as useState3,
17
+ useCallback as useCallback3,
18
+ useEffect as useEffect2,
19
+ useId
20
+ } from "react";
21
+
22
+ // src/react/usePicker.ts
23
+ import { useState, useCallback, useRef } from "react";
24
+ var _idCounter = 0;
25
+ function nextId() {
26
+ return `pf-${++_idCounter}`;
27
+ }
28
+ function usePicker(opts) {
29
+ const [files, setFiles] = useState([]);
30
+ const clientRef = useRef(null);
31
+ if (!clientRef.current || clientRef.current.apikey !== opts.apikey || clientRef.current.apiUrl !== (opts.apiUrl ?? "https://api.uploaderhq.io")) {
32
+ clientRef.current = new UploaderClient({
33
+ apikey: opts.apikey,
34
+ apiUrl: opts.apiUrl,
35
+ security: opts.security
36
+ });
37
+ }
38
+ const addFiles = useCallback(
39
+ (incoming) => {
40
+ const list = Array.from(incoming);
41
+ const { maxFiles, maxSize } = opts.pickerOptions ?? {};
42
+ setFiles((prev) => {
43
+ const remaining = maxFiles != null ? maxFiles - prev.length : Infinity;
44
+ const toAdd = list.slice(0, remaining).filter((f) => {
45
+ if (maxSize != null && f.size > maxSize) return false;
46
+ return true;
47
+ });
48
+ const newEntries = toAdd.map((f) => ({
49
+ file: f,
50
+ id: nextId(),
51
+ state: "pending",
52
+ progress: null,
53
+ previewUrl: f.type.startsWith("image/") ? URL.createObjectURL(f) : void 0
54
+ }));
55
+ return [...prev, ...newEntries];
56
+ });
57
+ },
58
+ [opts.pickerOptions]
59
+ );
60
+ const removeFile = useCallback((id) => {
61
+ setFiles((prev) => {
62
+ const entry = prev.find((f) => f.id === id);
63
+ if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
64
+ return prev.filter((f) => f.id !== id);
65
+ });
66
+ }, []);
67
+ const editFile = useCallback((id, newFile) => {
68
+ setFiles(
69
+ (prev) => prev.map((f) => {
70
+ if (f.id !== id) return f;
71
+ if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
72
+ return {
73
+ ...f,
74
+ file: newFile,
75
+ state: "pending",
76
+ progress: null,
77
+ error: void 0,
78
+ result: void 0,
79
+ previewUrl: newFile.type.startsWith("image/") ? URL.createObjectURL(newFile) : void 0
80
+ };
81
+ })
82
+ );
83
+ }, []);
84
+ const retryFile = useCallback((id) => {
85
+ setFiles(
86
+ (prev) => prev.map(
87
+ (f) => f.id === id ? { ...f, state: "pending", progress: null, error: void 0 } : f
88
+ )
89
+ );
90
+ }, []);
91
+ const upload = useCallback(async () => {
92
+ const client = clientRef.current;
93
+ const snapshot = files.filter((f) => f.state === "pending");
94
+ const uploaded = [];
95
+ const failed = [];
96
+ for (const entry of snapshot) {
97
+ setFiles(
98
+ (prev) => prev.map((f) => f.id === entry.id ? { ...f, state: "uploading", progress: 0 } : f)
99
+ );
100
+ try {
101
+ const result = await client.upload(entry.file, {
102
+ onProgress: (pct) => {
103
+ setFiles(
104
+ (prev) => prev.map((f) => f.id === entry.id ? { ...f, progress: pct } : f)
105
+ );
106
+ }
107
+ });
108
+ setFiles(
109
+ (prev) => prev.map(
110
+ (f) => f.id === entry.id ? { ...f, state: "done", progress: 100, result } : f
111
+ )
112
+ );
113
+ opts.onFileUploadFinished?.(result);
114
+ uploaded.push(result);
115
+ } catch (err) {
116
+ const error = err instanceof Error ? err : new Error(String(err));
117
+ setFiles(
118
+ (prev) => prev.map(
119
+ (f) => f.id === entry.id ? { ...f, state: "error", progress: null, error: error.message } : f
120
+ )
121
+ );
122
+ opts.onFileUploadFailed?.(entry.file, error);
123
+ failed.push({
124
+ handle: "",
125
+ url: "",
126
+ filename: entry.file.name,
127
+ mimetype: entry.file.type,
128
+ size: entry.file.size,
129
+ status: "Stored"
130
+ });
131
+ }
132
+ }
133
+ if (snapshot.length > 0) {
134
+ opts.onUploadDone?.({ filesUploaded: uploaded, filesFailed: failed });
135
+ }
136
+ }, [files, opts]);
137
+ const activeFiles = files.filter((f) => f.state === "uploading" || f.state === "done");
138
+ const progress = activeFiles.length === 0 ? 0 : Math.round(
139
+ activeFiles.reduce((sum, f) => sum + (f.progress ?? 0), 0) / activeFiles.length
140
+ );
141
+ const isUploading = files.some((f) => f.state === "uploading");
142
+ const isDone = files.length > 0 && files.every((f) => f.state === "done" || f.state === "error");
143
+ return { files, addFiles, removeFile, editFile, retryFile, upload, progress, isUploading, isDone };
144
+ }
145
+
146
+ // src/react/ImageEditor.tsx
147
+ import { useCallback as useCallback2, useEffect, useRef as useRef2, useState as useState2 } from "react";
148
+
149
+ // src/react/editImage.ts
150
+ var ENCODABLE = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp"]);
151
+ function mimeToExtension(mime) {
152
+ switch (mime) {
153
+ case "image/jpeg":
154
+ return "jpg";
155
+ case "image/png":
156
+ return "png";
157
+ case "image/webp":
158
+ return "webp";
159
+ default:
160
+ return "png";
161
+ }
162
+ }
163
+ function pickOutputType(sourceType, override) {
164
+ if (override) return override;
165
+ return ENCODABLE.has(sourceType) ? sourceType : "image/png";
166
+ }
167
+ function rotatedBoundingBox(width, height, deg) {
168
+ const rad = deg * Math.PI / 180;
169
+ return {
170
+ width: Math.abs(Math.cos(rad) * width) + Math.abs(Math.sin(rad) * height),
171
+ height: Math.abs(Math.sin(rad) * width) + Math.abs(Math.cos(rad) * height)
172
+ };
173
+ }
174
+ function fitScale(width, height, maxDimension) {
175
+ if (!maxDimension) return 1;
176
+ const longest = Math.max(width, height);
177
+ return longest > maxDimension ? maxDimension / longest : 1;
178
+ }
179
+ function withExtension(name, ext) {
180
+ const dot = name.lastIndexOf(".");
181
+ const base = dot > 0 ? name.slice(0, dot) : name;
182
+ return `${base}.${ext}`;
183
+ }
184
+ function loadImage(url) {
185
+ return new Promise((resolve, reject) => {
186
+ const img = new Image();
187
+ img.onload = () => resolve(img);
188
+ img.onerror = () => reject(new Error("Failed to load image for editing"));
189
+ img.src = url;
190
+ });
191
+ }
192
+ async function editImage(file, opts = {}) {
193
+ const {
194
+ rotation = 0,
195
+ flip: flip2 = { horizontal: false, vertical: false },
196
+ crop: crop2,
197
+ maxDimension
198
+ } = opts;
199
+ const url = URL.createObjectURL(file);
200
+ try {
201
+ const image = await loadImage(url);
202
+ const iw = image.naturalWidth || image.width;
203
+ const ih = image.naturalHeight || image.height;
204
+ const box = rotatedBoundingBox(iw, ih, rotation);
205
+ const boxCanvas = document.createElement("canvas");
206
+ boxCanvas.width = Math.max(1, Math.round(box.width));
207
+ boxCanvas.height = Math.max(1, Math.round(box.height));
208
+ const boxCtx = boxCanvas.getContext("2d");
209
+ if (!boxCtx) throw new Error("Canvas 2D context unavailable");
210
+ boxCtx.translate(boxCanvas.width / 2, boxCanvas.height / 2);
211
+ boxCtx.rotate(rotation * Math.PI / 180);
212
+ boxCtx.scale(flip2.horizontal ? -1 : 1, flip2.vertical ? -1 : 1);
213
+ boxCtx.drawImage(image, -iw / 2, -ih / 2);
214
+ const region = crop2 ?? {
215
+ x: 0,
216
+ y: 0,
217
+ width: boxCanvas.width,
218
+ height: boxCanvas.height
219
+ };
220
+ const scale = fitScale(region.width, region.height, maxDimension);
221
+ const outW = Math.max(1, Math.round(region.width * scale));
222
+ const outH = Math.max(1, Math.round(region.height * scale));
223
+ const outCanvas = document.createElement("canvas");
224
+ outCanvas.width = outW;
225
+ outCanvas.height = outH;
226
+ const outCtx = outCanvas.getContext("2d");
227
+ if (!outCtx) throw new Error("Canvas 2D context unavailable");
228
+ if (opts.circle) {
229
+ outCtx.beginPath();
230
+ outCtx.ellipse(outW / 2, outH / 2, outW / 2, outH / 2, 0, 0, Math.PI * 2);
231
+ outCtx.clip();
232
+ }
233
+ outCtx.drawImage(boxCanvas, region.x, region.y, region.width, region.height, 0, 0, outW, outH);
234
+ const outputType = opts.circle ? "image/png" : pickOutputType(file.type, opts.outputType);
235
+ const quality2 = opts.quality ?? 0.92;
236
+ const blob = await new Promise((resolve, reject) => {
237
+ outCanvas.toBlob(
238
+ (b) => b ? resolve(b) : reject(new Error("Canvas toBlob returned null")),
239
+ outputType,
240
+ quality2
241
+ );
242
+ });
243
+ const name = opts.fileName ?? withExtension(file.name, mimeToExtension(outputType));
244
+ return new File([blob], name, { type: outputType, lastModified: Date.now() });
245
+ } finally {
246
+ URL.revokeObjectURL(url);
247
+ }
248
+ }
249
+
250
+ // src/react/ImageEditor.tsx
251
+ import { jsx, jsxs } from "react/jsx-runtime";
252
+ var clampNum = (n, min, max) => Math.min(max, Math.max(min, n));
253
+ var MIN_CROP = 48;
254
+ var MAX_ZOOM = 8;
255
+ var ASPECTS = [
256
+ { label: "Free", value: void 0 },
257
+ { label: "1:1", value: 1 },
258
+ { label: "4:3", value: 4 / 3 },
259
+ { label: "16:9", value: 16 / 9 },
260
+ { label: "3:2", value: 3 / 2 }
261
+ ];
262
+ var HANDLES = [
263
+ { id: "nw", pos: { left: -7, top: -7, cursor: "nwse-resize" } },
264
+ { id: "ne", pos: { right: -7, top: -7, cursor: "nesw-resize" } },
265
+ { id: "sw", pos: { left: -7, bottom: -7, cursor: "nesw-resize" } },
266
+ { id: "se", pos: { right: -7, bottom: -7, cursor: "nwse-resize" } }
267
+ ];
268
+ function ImageEditor({
269
+ file,
270
+ onApply,
271
+ onCancel,
272
+ outputType,
273
+ quality: quality2,
274
+ maxDimensionDefault = 1920,
275
+ className,
276
+ style
277
+ }) {
278
+ const [rotation, setRotation] = useState2(0);
279
+ const [flip2, setFlip] = useState2({ horizontal: false, vertical: false });
280
+ const [aspect, setAspect] = useState2(void 0);
281
+ const [cropShape, setCropShape] = useState2("rect");
282
+ const [resizeEnabled, setResizeEnabled] = useState2(false);
283
+ const [maxDimension, setMaxDimension] = useState2(maxDimensionDefault);
284
+ const [busy, setBusy] = useState2(false);
285
+ const [error, setError] = useState2(null);
286
+ const [workingFile, setWorkingFile] = useState2(file);
287
+ useEffect(() => {
288
+ let cancelled = false;
289
+ if (rotation === 0 && !flip2.horizontal && !flip2.vertical) {
290
+ setWorkingFile(file);
291
+ return;
292
+ }
293
+ editImage(file, { rotation, flip: flip2 }).then((f) => {
294
+ if (!cancelled) setWorkingFile(f);
295
+ }).catch(() => {
296
+ if (!cancelled) setError("Failed to render the rotated preview");
297
+ });
298
+ return () => {
299
+ cancelled = true;
300
+ };
301
+ }, [file, rotation, flip2]);
302
+ const [workingUrl, setWorkingUrl] = useState2("");
303
+ useEffect(() => {
304
+ const url = URL.createObjectURL(workingFile);
305
+ setWorkingUrl(url);
306
+ return () => URL.revokeObjectURL(url);
307
+ }, [workingFile]);
308
+ const viewportRef = useRef2(null);
309
+ const [vp, setVp] = useState2({ w: 0, h: 0 });
310
+ const [nat, setNat] = useState2({ w: 0, h: 0 });
311
+ const [view, setView] = useState2({ scale: 1, x: 0, y: 0 });
312
+ const [crop2, setCrop] = useState2({ w: 0, h: 0 });
313
+ const ready = nat.w > 0 && vp.w > 0;
314
+ useEffect(() => {
315
+ const el = viewportRef.current;
316
+ if (!el || typeof ResizeObserver === "undefined") return;
317
+ const measure = () => {
318
+ const r = el.getBoundingClientRect();
319
+ setVp({ w: r.width, h: r.height });
320
+ };
321
+ measure();
322
+ const ro = new ResizeObserver(measure);
323
+ ro.observe(el);
324
+ return () => ro.disconnect();
325
+ }, []);
326
+ const normalize = useCallback2(
327
+ (scaleRaw, xRaw, yRaw, cw, ch) => {
328
+ if (!nat.w || !vp.w) return { scale: scaleRaw, x: xRaw, y: yRaw };
329
+ const fitS = Math.min(vp.w / nat.w, vp.h / nat.h);
330
+ const coverMin = Math.max(cw / nat.w, ch / nat.h);
331
+ const scale = clampNum(scaleRaw, coverMin, Math.max(fitS, coverMin) * MAX_ZOOM);
332
+ const cbX2 = (vp.w - cw) / 2;
333
+ const cbY2 = (vp.h - ch) / 2;
334
+ const x = clampNum(xRaw, cbX2 + cw - scale * nat.w, cbX2);
335
+ const y = clampNum(yRaw, cbY2 + ch - scale * nat.h, cbY2);
336
+ return { scale, x, y };
337
+ },
338
+ [nat, vp]
339
+ );
340
+ useEffect(() => {
341
+ if (!nat.w || !vp.w) return;
342
+ const fitS = Math.min(vp.w / nat.w, vp.h / nat.h);
343
+ const baseline = Math.min(vp.w, vp.h) * 0.7;
344
+ let cw = baseline;
345
+ let ch = baseline;
346
+ if (cropShape === "round") {
347
+ cw = ch = baseline;
348
+ } else if (aspect) {
349
+ if (aspect >= 1) ch = baseline / aspect;
350
+ else cw = baseline * aspect;
351
+ }
352
+ cw = clampNum(cw, MIN_CROP, vp.w);
353
+ ch = clampNum(ch, MIN_CROP, vp.h);
354
+ setCrop({ w: cw, h: ch });
355
+ setView(normalize(fitS, (vp.w - fitS * nat.w) / 2, (vp.h - fitS * nat.h) / 2, cw, ch));
356
+ }, [nat, vp]);
357
+ useEffect(() => {
358
+ if (!nat.w || !vp.w) return;
359
+ let cw = crop2.w;
360
+ let ch = crop2.h;
361
+ if (cropShape === "round") {
362
+ cw = ch = Math.min(cw, ch);
363
+ } else if (aspect) {
364
+ ch = cw / aspect;
365
+ if (ch > vp.h) {
366
+ ch = vp.h;
367
+ cw = ch * aspect;
368
+ }
369
+ }
370
+ cw = clampNum(cw, MIN_CROP, vp.w);
371
+ ch = clampNum(ch, MIN_CROP, vp.h);
372
+ setCrop({ w: cw, h: ch });
373
+ setView((v) => normalize(v.scale, v.x, v.y, cw, ch));
374
+ }, [aspect, cropShape]);
375
+ const onImageLoad = useCallback2((e) => {
376
+ setNat({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight });
377
+ }, []);
378
+ useEffect(() => {
379
+ const el = viewportRef.current;
380
+ if (!el) return;
381
+ const onWheel = (e) => {
382
+ e.preventDefault();
383
+ const rect = el.getBoundingClientRect();
384
+ const fx = e.clientX - rect.left;
385
+ const fy = e.clientY - rect.top;
386
+ const factor = Math.exp(-e.deltaY * 15e-4);
387
+ setView((v) => {
388
+ const next = normalize(v.scale * factor, v.x, v.y, crop2.w, crop2.h);
389
+ const ratio = next.scale / v.scale;
390
+ return normalize(next.scale, fx - ratio * (fx - v.x), fy - ratio * (fy - v.y), crop2.w, crop2.h);
391
+ });
392
+ };
393
+ el.addEventListener("wheel", onWheel, { passive: false });
394
+ return () => el.removeEventListener("wheel", onWheel);
395
+ }, [normalize, crop2.w, crop2.h]);
396
+ const dragRef = useRef2(null);
397
+ const onPointerDown = useCallback2(
398
+ (e) => {
399
+ if (!ready) return;
400
+ const handle = e.target.dataset.handle;
401
+ e.currentTarget.setPointerCapture(e.pointerId);
402
+ if (handle) {
403
+ dragRef.current = { mode: "resize", corner: handle };
404
+ } else {
405
+ dragRef.current = { mode: "pan", downX: e.clientX, downY: e.clientY, startX: view.x, startY: view.y };
406
+ }
407
+ },
408
+ [ready, view.x, view.y]
409
+ );
410
+ const onPointerMove = useCallback2(
411
+ (e) => {
412
+ const d = dragRef.current;
413
+ if (!d) return;
414
+ const rect = e.currentTarget.getBoundingClientRect();
415
+ if (d.mode === "pan") {
416
+ setView((v) => normalize(v.scale, d.startX + (e.clientX - d.downX), d.startY + (e.clientY - d.downY), crop2.w, crop2.h));
417
+ } else {
418
+ const hx = Math.abs(e.clientX - rect.left - rect.width / 2);
419
+ const hy = Math.abs(e.clientY - rect.top - rect.height / 2);
420
+ let cw;
421
+ let ch;
422
+ if (cropShape === "round") {
423
+ cw = ch = 2 * Math.max(hx, hy);
424
+ } else if (aspect) {
425
+ cw = Math.max(2 * hx, 2 * hy * aspect);
426
+ ch = cw / aspect;
427
+ } else {
428
+ cw = 2 * hx;
429
+ ch = 2 * hy;
430
+ }
431
+ cw = clampNum(cw, MIN_CROP, rect.width);
432
+ ch = clampNum(ch, MIN_CROP, rect.height);
433
+ if (cropShape === "round") cw = ch = Math.min(cw, ch);
434
+ setCrop({ w: cw, h: ch });
435
+ setView((v) => normalize(v.scale, v.x, v.y, cw, ch));
436
+ }
437
+ },
438
+ [normalize, crop2.w, crop2.h, cropShape, aspect]
439
+ );
440
+ const onPointerUp = useCallback2((e) => {
441
+ dragRef.current = null;
442
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
443
+ }, []);
444
+ const handleApply = useCallback2(async () => {
445
+ if (!ready) {
446
+ onApply(workingFile);
447
+ return;
448
+ }
449
+ setBusy(true);
450
+ setError(null);
451
+ try {
452
+ const cbX2 = (vp.w - crop2.w) / 2;
453
+ const cbY2 = (vp.h - crop2.h) / 2;
454
+ const region = {
455
+ x: (cbX2 - view.x) / view.scale,
456
+ y: (cbY2 - view.y) / view.scale,
457
+ width: crop2.w / view.scale,
458
+ height: crop2.h / view.scale
459
+ };
460
+ const edited = await editImage(workingFile, {
461
+ crop: region,
462
+ circle: cropShape === "round",
463
+ maxDimension: resizeEnabled ? maxDimension : void 0,
464
+ outputType,
465
+ quality: quality2
466
+ });
467
+ onApply(edited);
468
+ } catch (e) {
469
+ setError(e instanceof Error ? e.message : "Failed to apply edits");
470
+ } finally {
471
+ setBusy(false);
472
+ }
473
+ }, [ready, vp, crop2, view, workingFile, cropShape, resizeEnabled, maxDimension, outputType, quality2, onApply]);
474
+ const cbX = (vp.w - crop2.w) / 2;
475
+ const cbY = (vp.h - crop2.h) / 2;
476
+ return /* @__PURE__ */ jsxs(
477
+ "div",
478
+ {
479
+ className: ["uploader-editor", className ?? ""].filter(Boolean).join(" "),
480
+ style,
481
+ "data-testid": "image-editor",
482
+ children: [
483
+ /* @__PURE__ */ jsxs(
484
+ "div",
485
+ {
486
+ ref: viewportRef,
487
+ className: "uploader-editor-canvas",
488
+ onPointerDown,
489
+ onPointerMove,
490
+ onPointerUp,
491
+ onPointerCancel: onPointerUp,
492
+ children: [
493
+ workingUrl && /* eslint-disable-next-line @next/next/no-img-element */
494
+ /* @__PURE__ */ jsx(
495
+ "img",
496
+ {
497
+ src: workingUrl,
498
+ alt: "Edit preview",
499
+ onLoad: onImageLoad,
500
+ draggable: false,
501
+ className: "uploader-editor-image",
502
+ style: {
503
+ width: nat.w || void 0,
504
+ height: nat.h || void 0,
505
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
506
+ opacity: ready ? 1 : 0
507
+ }
508
+ }
509
+ ),
510
+ ready && /* @__PURE__ */ jsx(
511
+ "div",
512
+ {
513
+ className: `uploader-editor-cropbox${cropShape === "round" ? " uploader-editor-cropbox-round" : ""}`,
514
+ style: { left: cbX, top: cbY, width: crop2.w, height: crop2.h },
515
+ children: HANDLES.map((h) => /* @__PURE__ */ jsx("span", { "data-handle": h.id, className: "uploader-editor-handle", style: h.pos }, h.id))
516
+ }
517
+ )
518
+ ]
519
+ }
520
+ ),
521
+ /* @__PURE__ */ jsx("p", { className: "uploader-editor-hint", children: "Drag the image to move it \xB7 scroll to zoom \xB7 drag a corner to resize the crop." }),
522
+ /* @__PURE__ */ jsxs("div", { className: "uploader-editor-controls", children: [
523
+ /* @__PURE__ */ jsxs("div", { className: "uploader-editor-row", children: [
524
+ /* @__PURE__ */ jsx(
525
+ "button",
526
+ {
527
+ type: "button",
528
+ className: "uploader-editor-tool",
529
+ "aria-label": "Rotate left",
530
+ onClick: () => setRotation((r) => (r - 90 + 360) % 360),
531
+ children: "\u27F2"
532
+ }
533
+ ),
534
+ /* @__PURE__ */ jsx(
535
+ "button",
536
+ {
537
+ type: "button",
538
+ className: "uploader-editor-tool",
539
+ "aria-label": "Rotate right",
540
+ onClick: () => setRotation((r) => (r + 90) % 360),
541
+ children: "\u27F3"
542
+ }
543
+ ),
544
+ /* @__PURE__ */ jsx(
545
+ "button",
546
+ {
547
+ type: "button",
548
+ className: `uploader-editor-tool${flip2.horizontal ? " uploader-editor-tool-active" : ""}`,
549
+ "aria-pressed": flip2.horizontal,
550
+ "aria-label": "Flip horizontal",
551
+ onClick: () => setFlip((f) => ({ ...f, horizontal: !f.horizontal })),
552
+ children: "\u21CB"
553
+ }
554
+ ),
555
+ /* @__PURE__ */ jsx(
556
+ "button",
557
+ {
558
+ type: "button",
559
+ className: `uploader-editor-tool${flip2.vertical ? " uploader-editor-tool-active" : ""}`,
560
+ "aria-pressed": flip2.vertical,
561
+ "aria-label": "Flip vertical",
562
+ onClick: () => setFlip((f) => ({ ...f, vertical: !f.vertical })),
563
+ children: "\u21C5"
564
+ }
565
+ )
566
+ ] }),
567
+ /* @__PURE__ */ jsxs("div", { className: "uploader-editor-row", children: [
568
+ /* @__PURE__ */ jsx("span", { className: "uploader-editor-label", children: "Shape" }),
569
+ /* @__PURE__ */ jsx(
570
+ "button",
571
+ {
572
+ type: "button",
573
+ className: `uploader-editor-chip${cropShape === "rect" ? " uploader-editor-chip-active" : ""}`,
574
+ onClick: () => setCropShape("rect"),
575
+ children: "Rectangle"
576
+ }
577
+ ),
578
+ /* @__PURE__ */ jsx(
579
+ "button",
580
+ {
581
+ type: "button",
582
+ className: `uploader-editor-chip${cropShape === "round" ? " uploader-editor-chip-active" : ""}`,
583
+ onClick: () => {
584
+ setCropShape("round");
585
+ setAspect(1);
586
+ },
587
+ children: "Circle"
588
+ }
589
+ )
590
+ ] }),
591
+ /* @__PURE__ */ jsxs("div", { className: "uploader-editor-row", children: [
592
+ /* @__PURE__ */ jsx("span", { className: "uploader-editor-label", children: "Ratio" }),
593
+ ASPECTS.map((a) => /* @__PURE__ */ jsx(
594
+ "button",
595
+ {
596
+ type: "button",
597
+ disabled: cropShape === "round",
598
+ className: `uploader-editor-chip${cropShape === "rect" && aspect === a.value ? " uploader-editor-chip-active" : ""}`,
599
+ onClick: () => setAspect(a.value),
600
+ children: a.label
601
+ },
602
+ a.label
603
+ ))
604
+ ] }),
605
+ /* @__PURE__ */ jsxs("div", { className: "uploader-editor-row", children: [
606
+ /* @__PURE__ */ jsxs("label", { className: "uploader-editor-label", children: [
607
+ /* @__PURE__ */ jsx(
608
+ "input",
609
+ {
610
+ type: "checkbox",
611
+ checked: resizeEnabled,
612
+ onChange: (e) => setResizeEnabled(e.target.checked)
613
+ }
614
+ ),
615
+ " ",
616
+ "Resize to"
617
+ ] }),
618
+ /* @__PURE__ */ jsx(
619
+ "input",
620
+ {
621
+ type: "number",
622
+ min: 16,
623
+ value: maxDimension,
624
+ disabled: !resizeEnabled,
625
+ "aria-label": "Maximum longest side in pixels",
626
+ className: "uploader-editor-number",
627
+ onChange: (e) => setMaxDimension(Number(e.target.value))
628
+ }
629
+ ),
630
+ /* @__PURE__ */ jsx("span", { className: "uploader-editor-label", children: "px (longest side)" })
631
+ ] }),
632
+ error && /* @__PURE__ */ jsx("div", { className: "uploader-editor-error", role: "alert", children: error })
633
+ ] }),
634
+ /* @__PURE__ */ jsxs("div", { className: "uploader-editor-actions", children: [
635
+ /* @__PURE__ */ jsx("button", { type: "button", className: "uploader-btn-cancel", onClick: onCancel, disabled: busy, children: "Cancel" }),
636
+ /* @__PURE__ */ jsx("button", { type: "button", className: "uploader-btn-upload", onClick: handleApply, disabled: busy, children: busy ? "Applying\u2026" : "Apply" })
637
+ ] })
638
+ ]
639
+ }
640
+ );
641
+ }
642
+
643
+ // src/react/PickerOverlay.tsx
644
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
645
+ function PickerOverlay({
646
+ open,
647
+ onClose,
648
+ apikey,
649
+ apiUrl,
650
+ security,
651
+ pickerOptions,
652
+ onUploadDone,
653
+ onFileUploadFinished,
654
+ onFileUploadFailed,
655
+ onCancel,
656
+ className,
657
+ style
658
+ }) {
659
+ const titleId = useId();
660
+ const inputRef = useRef3(null);
661
+ const panelRef = useRef3(null);
662
+ const [isDragOver, setIsDragOver] = useState3(false);
663
+ const [editingId, setEditingId] = useState3(null);
664
+ const { files, addFiles, removeFile, editFile, retryFile, upload, progress, isUploading, isDone } = usePicker({
665
+ apikey,
666
+ apiUrl,
667
+ security,
668
+ pickerOptions,
669
+ onUploadDone,
670
+ onFileUploadFinished,
671
+ onFileUploadFailed
672
+ });
673
+ useEffect2(() => {
674
+ if (!open) return;
675
+ const handler = (e) => {
676
+ if (e.key === "Escape") {
677
+ onCancel?.();
678
+ onClose();
679
+ }
680
+ };
681
+ document.addEventListener("keydown", handler);
682
+ return () => document.removeEventListener("keydown", handler);
683
+ }, [open, onClose, onCancel]);
684
+ useEffect2(() => {
685
+ if (!open || !panelRef.current) return;
686
+ const panel = panelRef.current;
687
+ const focusable = panel.querySelectorAll(
688
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
689
+ );
690
+ if (focusable.length === 0) return;
691
+ const first = focusable[0];
692
+ const last = focusable[focusable.length - 1];
693
+ first.focus();
694
+ const trap = (e) => {
695
+ if (e.key !== "Tab") return;
696
+ if (e.shiftKey) {
697
+ if (document.activeElement === first) {
698
+ e.preventDefault();
699
+ last.focus();
700
+ }
701
+ } else {
702
+ if (document.activeElement === last) {
703
+ e.preventDefault();
704
+ first.focus();
705
+ }
706
+ }
707
+ };
708
+ document.addEventListener("keydown", trap);
709
+ return () => document.removeEventListener("keydown", trap);
710
+ }, [open, files, editingId]);
711
+ useEffect2(() => {
712
+ if (!open) return;
713
+ const prev = document.body.style.overflow;
714
+ document.body.style.overflow = "hidden";
715
+ return () => {
716
+ document.body.style.overflow = prev;
717
+ };
718
+ }, [open]);
719
+ const handleDragEnter = useCallback3((e) => {
720
+ e.preventDefault();
721
+ setIsDragOver(true);
722
+ }, []);
723
+ const handleDragLeave = useCallback3((e) => {
724
+ e.preventDefault();
725
+ setIsDragOver(false);
726
+ }, []);
727
+ const handleDragOver = useCallback3((e) => {
728
+ e.preventDefault();
729
+ }, []);
730
+ const handleDrop = useCallback3(
731
+ (e) => {
732
+ e.preventDefault();
733
+ setIsDragOver(false);
734
+ if (e.dataTransfer.files.length > 0) {
735
+ addFiles(e.dataTransfer.files);
736
+ }
737
+ },
738
+ [addFiles]
739
+ );
740
+ const openFileDialog = useCallback3(() => inputRef.current?.click(), []);
741
+ const handleInputChange = useCallback3(
742
+ (e) => {
743
+ if (e.target.files && e.target.files.length > 0) {
744
+ addFiles(e.target.files);
745
+ e.target.value = "";
746
+ }
747
+ },
748
+ [addFiles]
749
+ );
750
+ const handleClose = useCallback3(() => {
751
+ onCancel?.();
752
+ onClose();
753
+ }, [onCancel, onClose]);
754
+ const hasPending = files.some((f) => f.state === "pending");
755
+ const accept = pickerOptions?.accept?.join(",");
756
+ const editingEntry = files.find((f) => f.id === editingId) ?? null;
757
+ if (!open) return null;
758
+ return (
759
+ /* Backdrop — clicking outside the panel dismisses the modal */
760
+ /* @__PURE__ */ jsx2(
761
+ "div",
762
+ {
763
+ className: "uploader-backdrop",
764
+ "data-testid": "picker-backdrop",
765
+ onClick: handleClose,
766
+ children: /* @__PURE__ */ jsxs2(
767
+ "div",
768
+ {
769
+ ref: panelRef,
770
+ role: "dialog",
771
+ "aria-modal": "true",
772
+ "aria-labelledby": titleId,
773
+ className: ["uploader-picker-panel", className ?? ""].filter(Boolean).join(" "),
774
+ style,
775
+ "data-testid": "picker-panel",
776
+ onClick: (e) => e.stopPropagation(),
777
+ children: [
778
+ /* @__PURE__ */ jsxs2("div", { className: "uploader-picker-header", children: [
779
+ /* @__PURE__ */ jsx2("h2", { id: titleId, className: "uploader-picker-title", children: editingEntry ? "Edit Image" : "Upload Files" }),
780
+ /* @__PURE__ */ jsx2(
781
+ "button",
782
+ {
783
+ type: "button",
784
+ className: "uploader-btn-close",
785
+ "aria-label": "Close picker",
786
+ onClick: handleClose,
787
+ children: "\xD7"
788
+ }
789
+ )
790
+ ] }),
791
+ editingEntry ? /* @__PURE__ */ jsx2(
792
+ ImageEditor,
793
+ {
794
+ file: editingEntry.file,
795
+ onCancel: () => setEditingId(null),
796
+ onApply: (edited) => {
797
+ editFile(editingEntry.id, edited);
798
+ setEditingId(null);
799
+ }
800
+ }
801
+ ) : /* @__PURE__ */ jsxs2(Fragment, { children: [
802
+ /* @__PURE__ */ jsxs2(
803
+ "div",
804
+ {
805
+ className: [
806
+ "uploader-dropzone",
807
+ isDragOver ? "uploader-drag-over" : ""
808
+ ].filter(Boolean).join(" "),
809
+ role: "button",
810
+ tabIndex: 0,
811
+ "aria-label": "Drop files here or click to browse",
812
+ onDragEnter: handleDragEnter,
813
+ onDragLeave: handleDragLeave,
814
+ onDragOver: handleDragOver,
815
+ onDrop: handleDrop,
816
+ onClick: openFileDialog,
817
+ onKeyDown: (e) => {
818
+ if (e.key === "Enter" || e.key === " ") {
819
+ e.preventDefault();
820
+ openFileDialog();
821
+ }
822
+ },
823
+ children: [
824
+ /* @__PURE__ */ jsx2("span", { className: "uploader-dropzone-icon", "aria-hidden": "true", children: "\u2191" }),
825
+ /* @__PURE__ */ jsx2("p", { className: "uploader-dropzone-label", children: "Drag & drop files here" }),
826
+ /* @__PURE__ */ jsx2(
827
+ "button",
828
+ {
829
+ type: "button",
830
+ className: "uploader-btn-browse",
831
+ onClick: (e) => {
832
+ e.stopPropagation();
833
+ openFileDialog();
834
+ },
835
+ children: "Browse files"
836
+ }
837
+ )
838
+ ]
839
+ }
840
+ ),
841
+ /* @__PURE__ */ jsx2(
842
+ "input",
843
+ {
844
+ ref: inputRef,
845
+ type: "file",
846
+ multiple: !pickerOptions?.maxFiles || pickerOptions.maxFiles > 1,
847
+ accept,
848
+ className: "uploader-file-input",
849
+ "aria-hidden": "true",
850
+ tabIndex: -1,
851
+ onChange: handleInputChange
852
+ }
853
+ ),
854
+ files.length > 0 && /* @__PURE__ */ jsx2("ul", { className: "uploader-file-list", role: "list", "aria-label": "Selected files", children: files.map((entry) => /* @__PURE__ */ jsxs2("li", { className: `uploader-file-item uploader-file-${entry.state}`, children: [
855
+ entry.previewUrl && /* @__PURE__ */ jsx2(
856
+ "img",
857
+ {
858
+ src: entry.previewUrl,
859
+ alt: "",
860
+ "aria-hidden": "true",
861
+ className: "uploader-file-thumb"
862
+ }
863
+ ),
864
+ /* @__PURE__ */ jsxs2("div", { className: "uploader-file-info", children: [
865
+ /* @__PURE__ */ jsx2("span", { className: "uploader-file-name", children: entry.file.name }),
866
+ entry.state === "error" && /* @__PURE__ */ jsx2("span", { className: "uploader-file-error", role: "alert", children: entry.error ?? "Upload failed" }),
867
+ entry.state === "done" && /* @__PURE__ */ jsx2("span", { className: "uploader-file-done", children: "Uploaded" })
868
+ ] }),
869
+ entry.state === "uploading" && entry.progress != null && /* @__PURE__ */ jsx2(
870
+ "progress",
871
+ {
872
+ className: "uploader-file-progress",
873
+ value: entry.progress,
874
+ max: 100,
875
+ "aria-label": `${entry.file.name} upload progress`
876
+ }
877
+ ),
878
+ /* @__PURE__ */ jsxs2("div", { className: "uploader-file-actions", children: [
879
+ entry.previewUrl && (entry.state === "pending" || entry.state === "error") && /* @__PURE__ */ jsx2(
880
+ "button",
881
+ {
882
+ type: "button",
883
+ className: "uploader-btn-edit",
884
+ "aria-label": `Edit ${entry.file.name}`,
885
+ onClick: () => setEditingId(entry.id),
886
+ children: "Edit"
887
+ }
888
+ ),
889
+ entry.state === "error" && /* @__PURE__ */ jsx2(
890
+ "button",
891
+ {
892
+ type: "button",
893
+ className: "uploader-btn-retry",
894
+ "aria-label": `Retry uploading ${entry.file.name}`,
895
+ onClick: () => retryFile(entry.id),
896
+ children: "Retry"
897
+ }
898
+ ),
899
+ entry.state !== "uploading" && /* @__PURE__ */ jsx2(
900
+ "button",
901
+ {
902
+ type: "button",
903
+ className: "uploader-btn-remove",
904
+ "aria-label": `Remove ${entry.file.name}`,
905
+ onClick: () => removeFile(entry.id),
906
+ children: "\xD7"
907
+ }
908
+ )
909
+ ] })
910
+ ] }, entry.id)) }),
911
+ isUploading && /* @__PURE__ */ jsxs2("div", { className: "uploader-aggregate-progress", role: "status", "aria-live": "polite", children: [
912
+ /* @__PURE__ */ jsx2("progress", { value: progress, max: 100, "aria-label": "Overall upload progress" }),
913
+ /* @__PURE__ */ jsxs2("span", { "aria-live": "polite", children: [
914
+ progress,
915
+ "%"
916
+ ] })
917
+ ] }),
918
+ /* @__PURE__ */ jsxs2("div", { className: "uploader-picker-footer", children: [
919
+ hasPending && !isUploading && /* @__PURE__ */ jsxs2(
920
+ "button",
921
+ {
922
+ type: "button",
923
+ className: "uploader-btn-upload",
924
+ disabled: isUploading || !hasPending,
925
+ onClick: () => void upload(),
926
+ children: [
927
+ "Upload ",
928
+ files.filter((f) => f.state === "pending").length,
929
+ " file",
930
+ files.filter((f) => f.state === "pending").length !== 1 ? "s" : ""
931
+ ]
932
+ }
933
+ ),
934
+ isDone && /* @__PURE__ */ jsx2(
935
+ "button",
936
+ {
937
+ type: "button",
938
+ className: "uploader-btn-done",
939
+ onClick: onClose,
940
+ children: "Done"
941
+ }
942
+ ),
943
+ !isDone && /* @__PURE__ */ jsx2(
944
+ "button",
945
+ {
946
+ type: "button",
947
+ className: "uploader-btn-cancel",
948
+ disabled: isUploading,
949
+ onClick: handleClose,
950
+ children: "Cancel"
951
+ }
952
+ )
953
+ ] })
954
+ ] })
955
+ ]
956
+ }
957
+ )
958
+ }
959
+ )
960
+ );
961
+ }
962
+
963
+ // src/react/DropPane.tsx
964
+ import { useRef as useRef4, useState as useState4, useCallback as useCallback4, useId as useId2 } from "react";
965
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
966
+ function DropPane({
967
+ apikey,
968
+ apiUrl,
969
+ security,
970
+ pickerOptions,
971
+ onUploadDone,
972
+ onFileUploadFinished,
973
+ onFileUploadFailed,
974
+ onCancel,
975
+ className,
976
+ style
977
+ }) {
978
+ const inputId = useId2();
979
+ const inputRef = useRef4(null);
980
+ const [isDragOver, setIsDragOver] = useState4(false);
981
+ const { files, addFiles, removeFile, retryFile, upload, progress, isUploading, isDone } = usePicker({
982
+ apikey,
983
+ apiUrl,
984
+ security,
985
+ pickerOptions,
986
+ onUploadDone,
987
+ onFileUploadFinished,
988
+ onFileUploadFailed
989
+ });
990
+ const handleDragEnter = useCallback4((e) => {
991
+ e.preventDefault();
992
+ e.stopPropagation();
993
+ setIsDragOver(true);
994
+ }, []);
995
+ const handleDragLeave = useCallback4((e) => {
996
+ e.preventDefault();
997
+ e.stopPropagation();
998
+ setIsDragOver(false);
999
+ }, []);
1000
+ const handleDragOver = useCallback4((e) => {
1001
+ e.preventDefault();
1002
+ e.stopPropagation();
1003
+ }, []);
1004
+ const handleDrop = useCallback4(
1005
+ (e) => {
1006
+ e.preventDefault();
1007
+ e.stopPropagation();
1008
+ setIsDragOver(false);
1009
+ if (e.dataTransfer.files.length > 0) {
1010
+ addFiles(e.dataTransfer.files);
1011
+ }
1012
+ },
1013
+ [addFiles]
1014
+ );
1015
+ const handleInputChange = useCallback4(
1016
+ (e) => {
1017
+ if (e.target.files && e.target.files.length > 0) {
1018
+ addFiles(e.target.files);
1019
+ e.target.value = "";
1020
+ }
1021
+ },
1022
+ [addFiles]
1023
+ );
1024
+ const openFileDialog = useCallback4(() => {
1025
+ inputRef.current?.click();
1026
+ }, []);
1027
+ const hasPending = files.some((f) => f.state === "pending");
1028
+ const accept = pickerOptions?.accept?.join(",");
1029
+ return /* @__PURE__ */ jsxs3(
1030
+ "div",
1031
+ {
1032
+ className: ["uploader-drop-pane", isDragOver ? "uploader-drag-over" : "", className ?? ""].filter(Boolean).join(" "),
1033
+ style,
1034
+ "data-testid": "drop-pane",
1035
+ children: [
1036
+ /* @__PURE__ */ jsxs3(
1037
+ "div",
1038
+ {
1039
+ className: "uploader-dropzone",
1040
+ role: "button",
1041
+ tabIndex: 0,
1042
+ "aria-label": "Drop files here or click to browse",
1043
+ onDragEnter: handleDragEnter,
1044
+ onDragLeave: handleDragLeave,
1045
+ onDragOver: handleDragOver,
1046
+ onDrop: handleDrop,
1047
+ onClick: openFileDialog,
1048
+ onKeyDown: (e) => {
1049
+ if (e.key === "Enter" || e.key === " ") {
1050
+ e.preventDefault();
1051
+ openFileDialog();
1052
+ }
1053
+ },
1054
+ children: [
1055
+ /* @__PURE__ */ jsx3("span", { className: "uploader-dropzone-icon", "aria-hidden": "true", children: "\u2191" }),
1056
+ /* @__PURE__ */ jsxs3("span", { className: "uploader-dropzone-label", children: [
1057
+ "Drag & drop files here or",
1058
+ " ",
1059
+ /* @__PURE__ */ jsx3("label", { htmlFor: inputId, className: "uploader-browse-link", children: "browse" })
1060
+ ] })
1061
+ ]
1062
+ }
1063
+ ),
1064
+ /* @__PURE__ */ jsx3(
1065
+ "input",
1066
+ {
1067
+ id: inputId,
1068
+ ref: inputRef,
1069
+ type: "file",
1070
+ multiple: !pickerOptions?.maxFiles || pickerOptions.maxFiles > 1,
1071
+ accept,
1072
+ className: "uploader-file-input",
1073
+ "aria-hidden": "true",
1074
+ tabIndex: -1,
1075
+ onChange: handleInputChange
1076
+ }
1077
+ ),
1078
+ files.length > 0 && /* @__PURE__ */ jsx3("ul", { className: "uploader-file-list", role: "list", "aria-label": "Selected files", children: files.map((entry) => /* @__PURE__ */ jsxs3("li", { className: `uploader-file-item uploader-file-${entry.state}`, children: [
1079
+ entry.previewUrl && /* @__PURE__ */ jsx3(
1080
+ "img",
1081
+ {
1082
+ src: entry.previewUrl,
1083
+ alt: "",
1084
+ "aria-hidden": "true",
1085
+ className: "uploader-file-thumb"
1086
+ }
1087
+ ),
1088
+ /* @__PURE__ */ jsxs3("div", { className: "uploader-file-info", children: [
1089
+ /* @__PURE__ */ jsx3("span", { className: "uploader-file-name", children: entry.file.name }),
1090
+ entry.state === "error" && /* @__PURE__ */ jsx3("span", { className: "uploader-file-error", role: "alert", children: entry.error ?? "Upload failed" }),
1091
+ entry.state === "done" && /* @__PURE__ */ jsx3("span", { className: "uploader-file-done", children: "Uploaded" })
1092
+ ] }),
1093
+ entry.state === "uploading" && entry.progress != null && /* @__PURE__ */ jsx3(
1094
+ "progress",
1095
+ {
1096
+ className: "uploader-file-progress",
1097
+ value: entry.progress,
1098
+ max: 100,
1099
+ "aria-label": `${entry.file.name} upload progress`
1100
+ }
1101
+ ),
1102
+ /* @__PURE__ */ jsxs3("div", { className: "uploader-file-actions", children: [
1103
+ entry.state === "error" && /* @__PURE__ */ jsx3(
1104
+ "button",
1105
+ {
1106
+ type: "button",
1107
+ className: "uploader-btn-retry",
1108
+ "aria-label": `Retry uploading ${entry.file.name}`,
1109
+ onClick: () => retryFile(entry.id),
1110
+ children: "Retry"
1111
+ }
1112
+ ),
1113
+ entry.state !== "uploading" && /* @__PURE__ */ jsx3(
1114
+ "button",
1115
+ {
1116
+ type: "button",
1117
+ className: "uploader-btn-remove",
1118
+ "aria-label": `Remove ${entry.file.name}`,
1119
+ onClick: () => removeFile(entry.id),
1120
+ children: "\xD7"
1121
+ }
1122
+ )
1123
+ ] })
1124
+ ] }, entry.id)) }),
1125
+ isUploading && /* @__PURE__ */ jsxs3("div", { className: "uploader-aggregate-progress", role: "status", "aria-live": "polite", children: [
1126
+ /* @__PURE__ */ jsx3("progress", { value: progress, max: 100, "aria-label": "Overall upload progress" }),
1127
+ /* @__PURE__ */ jsxs3("span", { children: [
1128
+ progress,
1129
+ "%"
1130
+ ] })
1131
+ ] }),
1132
+ /* @__PURE__ */ jsxs3("div", { className: "uploader-actions", children: [
1133
+ hasPending && !isUploading && /* @__PURE__ */ jsx3(
1134
+ "button",
1135
+ {
1136
+ type: "button",
1137
+ className: "uploader-btn-upload",
1138
+ disabled: isUploading,
1139
+ onClick: () => void upload(),
1140
+ children: "Upload"
1141
+ }
1142
+ ),
1143
+ !isDone && !isUploading && /* @__PURE__ */ jsx3(
1144
+ "button",
1145
+ {
1146
+ type: "button",
1147
+ className: "uploader-btn-cancel",
1148
+ onClick: onCancel,
1149
+ children: "Cancel"
1150
+ }
1151
+ )
1152
+ ] })
1153
+ ]
1154
+ }
1155
+ );
1156
+ }
1157
+ export {
1158
+ DropPane,
1159
+ ImageEditor,
1160
+ PickerOverlay,
1161
+ UploaderClient,
1162
+ crop,
1163
+ editImage,
1164
+ flip,
1165
+ flop,
1166
+ output,
1167
+ quality,
1168
+ resize,
1169
+ rotate,
1170
+ transformUrl,
1171
+ usePicker
1172
+ };
1173
+ //# sourceMappingURL=index.js.map