@uploadcn/react 0.0.0-stage → 0.1.1
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 -2
- package/dist/index.d.ts +576 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1224 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { UploadError as UploadError$1, createAdapter, createUploader, formatBytes, formatDuration, formatSpeed, getClipboardFiles, getDraggedTypes, getDroppedFiles, getUploadSummary, isActiveStatus, isFileDrag, isPreviewableImage, matchesAccept, toAcceptAttribute, validateFileCount, validateFiles } from "@uploadcn/core";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { compressImage, transformImage } from "@uploadcn/core/image";
|
|
6
|
+
//#region src/config.tsx
|
|
7
|
+
const UploadConfigContext = React.createContext({});
|
|
8
|
+
/**
|
|
9
|
+
* Configure uploads once — usually in your root layout — and every Upload
|
|
10
|
+
* component and block uses it. Nest providers to override per section.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <UploadConfigProvider adapter={cloudinaryAdapter({ cloudName, uploadPreset })}>
|
|
14
|
+
* {children}
|
|
15
|
+
* </UploadConfigProvider>
|
|
16
|
+
*/
|
|
17
|
+
function UploadConfigProvider({ children, ...config }) {
|
|
18
|
+
const value = {
|
|
19
|
+
...React.useContext(UploadConfigContext),
|
|
20
|
+
...stripUndefined(config)
|
|
21
|
+
};
|
|
22
|
+
return /* @__PURE__ */ jsx(UploadConfigContext.Provider, {
|
|
23
|
+
value,
|
|
24
|
+
children
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** The merged config from the nearest `UploadConfigProvider`s. */
|
|
28
|
+
function useUploadConfig() {
|
|
29
|
+
return React.useContext(UploadConfigContext);
|
|
30
|
+
}
|
|
31
|
+
function stripUndefined(value) {
|
|
32
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/use-upload-selector.ts
|
|
36
|
+
/**
|
|
37
|
+
* Subscribes to a slice of uploader state. The component re-renders only
|
|
38
|
+
* when the selected value changes (by `isEqual`, default `Object.is`).
|
|
39
|
+
*/
|
|
40
|
+
function useUploadSelector(uploader, selector, isEqual = Object.is) {
|
|
41
|
+
const cache = React.useRef(null);
|
|
42
|
+
const getSnapshot = () => {
|
|
43
|
+
const state = uploader.getState();
|
|
44
|
+
const previous = cache.current;
|
|
45
|
+
if (previous && previous.state === state && previous.selector === selector) return previous.selected;
|
|
46
|
+
const selected = selector(state);
|
|
47
|
+
const stable = previous && isEqual(previous.selected, selected) ? previous.selected : selected;
|
|
48
|
+
cache.current = {
|
|
49
|
+
state,
|
|
50
|
+
selector,
|
|
51
|
+
selected: stable
|
|
52
|
+
};
|
|
53
|
+
return stable;
|
|
54
|
+
};
|
|
55
|
+
return React.useSyncExternalStore(uploader.subscribe, getSnapshot, getSnapshot);
|
|
56
|
+
}
|
|
57
|
+
/** Shallow array equality, for selectors that return lists. */
|
|
58
|
+
function shallowArrayEqual(a, b) {
|
|
59
|
+
return a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/context.ts
|
|
63
|
+
const UploadContext = React.createContext(null);
|
|
64
|
+
function useUploadContext() {
|
|
65
|
+
const context = React.useContext(UploadContext);
|
|
66
|
+
if (!context) throw new Error("Upload components must be rendered inside <Upload> (Upload.Root)");
|
|
67
|
+
return context;
|
|
68
|
+
}
|
|
69
|
+
const UploadItemContext = React.createContext(null);
|
|
70
|
+
/**
|
|
71
|
+
* The current item. Inside `<UploadItem>` it reads from context; pass an id
|
|
72
|
+
* to subscribe to any item from within `<Upload>`.
|
|
73
|
+
*/
|
|
74
|
+
function useUploadItem(id) {
|
|
75
|
+
const fromContext = React.useContext(UploadItemContext);
|
|
76
|
+
const byId = useOptionalItem(React.useContext(UploadContext)?.uploader ?? null, id);
|
|
77
|
+
const item = id ? byId : fromContext;
|
|
78
|
+
if (!item) throw new Error(id ? `No upload item with id "${id}"` : "useUploadItem() must be used inside <UploadItem> or given an id");
|
|
79
|
+
return item;
|
|
80
|
+
}
|
|
81
|
+
const emptyState = {
|
|
82
|
+
items: [],
|
|
83
|
+
online: true
|
|
84
|
+
};
|
|
85
|
+
const noop = () => () => {};
|
|
86
|
+
const nullUploader = {
|
|
87
|
+
getState: () => emptyState,
|
|
88
|
+
subscribe: noop,
|
|
89
|
+
getItem: () => void 0
|
|
90
|
+
};
|
|
91
|
+
function useOptionalItem(uploader, id) {
|
|
92
|
+
const source = uploader ?? nullUploader;
|
|
93
|
+
return useUploadSelector(source, () => id ? source.getItem(id) : void 0);
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/messages.ts
|
|
97
|
+
const defaultMessages = {
|
|
98
|
+
status: {
|
|
99
|
+
idle: "Ready",
|
|
100
|
+
validating: "Checking",
|
|
101
|
+
queued: "Queued",
|
|
102
|
+
uploading: "Uploading",
|
|
103
|
+
paused: "Paused",
|
|
104
|
+
processing: "Processing",
|
|
105
|
+
scanning: "Scanning",
|
|
106
|
+
success: "Uploaded",
|
|
107
|
+
error: "Failed",
|
|
108
|
+
cancelled: "Cancelled",
|
|
109
|
+
rejected: "Rejected"
|
|
110
|
+
},
|
|
111
|
+
retrying: "Retrying",
|
|
112
|
+
waitingForNetwork: "Waiting for network",
|
|
113
|
+
added: (count) => `${count} file${count === 1 ? "" : "s"} added`,
|
|
114
|
+
uploaded: (name) => `${name} uploaded`,
|
|
115
|
+
failed: (name, reason) => `${name} failed: ${reason}`,
|
|
116
|
+
rejected: (name, reason) => `${name} rejected: ${reason}`,
|
|
117
|
+
cancelled: (name) => `${name} cancelled`,
|
|
118
|
+
completed: (count) => `${count} upload${count === 1 ? "" : "s"} finished`,
|
|
119
|
+
remove: (name) => `Remove ${name}`,
|
|
120
|
+
cancel: (name) => `Cancel upload of ${name}`,
|
|
121
|
+
retry: (name) => `Retry upload of ${name}`,
|
|
122
|
+
pause: (name) => `Pause upload of ${name}`,
|
|
123
|
+
resume: (name) => `Resume upload of ${name}`,
|
|
124
|
+
progress: (name) => `Upload progress for ${name}`,
|
|
125
|
+
totalProgress: "Total upload progress"
|
|
126
|
+
};
|
|
127
|
+
/** A status label that also explains automatic retries and offline pauses. */
|
|
128
|
+
function getStatusLabel(item, messages = defaultMessages) {
|
|
129
|
+
if (item.status === "queued" && item.retryAt) return messages.retrying;
|
|
130
|
+
if (item.status === "paused" && item.pauseReason === "offline") return messages.waitingForNetwork;
|
|
131
|
+
return messages.status[item.status];
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/render.ts
|
|
135
|
+
function mergeRefs(...refs) {
|
|
136
|
+
const live = refs.filter(Boolean);
|
|
137
|
+
if (live.length <= 1) return live[0];
|
|
138
|
+
return (value) => {
|
|
139
|
+
const cleanups = [];
|
|
140
|
+
for (const ref of live) if (typeof ref === "function") {
|
|
141
|
+
const cleanup = ref(value);
|
|
142
|
+
if (typeof cleanup === "function") cleanups.push(cleanup);
|
|
143
|
+
} else if (ref && typeof ref === "object" && "current" in ref) ref.current = value;
|
|
144
|
+
return () => {
|
|
145
|
+
for (const cleanup of cleanups) cleanup();
|
|
146
|
+
for (const ref of live) if (ref && typeof ref === "object" && "current" in ref) ref.current = null;
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Merges two prop objects: event handlers run in sequence (unless the first
|
|
152
|
+
* calls `preventDefault`), classNames concatenate, styles shallow-merge,
|
|
153
|
+
* refs combine, and everything else from `overrides` wins.
|
|
154
|
+
*/
|
|
155
|
+
function mergeProps(base, overrides) {
|
|
156
|
+
const merged = { ...base };
|
|
157
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
158
|
+
const current = merged[key];
|
|
159
|
+
if (/^on[A-Z]/.test(key) && typeof current === "function" && typeof value === "function") merged[key] = (...args) => {
|
|
160
|
+
value(...args);
|
|
161
|
+
if (!args[0]?.defaultPrevented) current(...args);
|
|
162
|
+
};
|
|
163
|
+
else if (key === "className" && current && value) merged[key] = `${current} ${value}`;
|
|
164
|
+
else if (key === "style" && current && value) merged[key] = {
|
|
165
|
+
...current,
|
|
166
|
+
...value
|
|
167
|
+
};
|
|
168
|
+
else if (key === "ref") merged[key] = mergeRefs(current, value);
|
|
169
|
+
else if (value !== void 0) merged[key] = value;
|
|
170
|
+
}
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
/** Renders `tag`, or the user's `render` element/function with merged props. */
|
|
174
|
+
function renderElement(tag, props, render, state) {
|
|
175
|
+
if (!render) return React.createElement(tag, props);
|
|
176
|
+
if (typeof render === "function") return render(props, state);
|
|
177
|
+
return React.cloneElement(render, mergeProps(props, render.props));
|
|
178
|
+
}
|
|
179
|
+
/** Converts truthy booleans to `""` data attributes and drops falsy ones. */
|
|
180
|
+
function dataAttributes(attributes) {
|
|
181
|
+
const result = {};
|
|
182
|
+
for (const [key, value] of Object.entries(attributes)) if (value === true) result[`data-${key}`] = "";
|
|
183
|
+
else if (value !== false && value != null) result[`data-${key}`] = value;
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/use-dropzone.ts
|
|
188
|
+
/**
|
|
189
|
+
* Drag-and-drop state and handlers for any element. Uses an enter/leave
|
|
190
|
+
* counter so moving over child elements doesn't flicker the drag state.
|
|
191
|
+
*/
|
|
192
|
+
function useDropzone(options) {
|
|
193
|
+
const { onDrop, accept, multiple = true, disabled = false } = options;
|
|
194
|
+
const [state, setState] = React.useState({
|
|
195
|
+
isDragging: false,
|
|
196
|
+
isDragReject: false
|
|
197
|
+
});
|
|
198
|
+
const depth = React.useRef(0);
|
|
199
|
+
const evaluate = React.useCallback((dataTransfer) => {
|
|
200
|
+
const types = getDraggedTypes(dataTransfer);
|
|
201
|
+
const tooMany = !multiple && types.length > 1;
|
|
202
|
+
const badType = types.some((type) => type !== "" && !matchesAccept({
|
|
203
|
+
name: "",
|
|
204
|
+
type
|
|
205
|
+
}, accept));
|
|
206
|
+
return tooMany || badType;
|
|
207
|
+
}, [accept, multiple]);
|
|
208
|
+
const handlers = React.useMemo(() => ({
|
|
209
|
+
onDragEnter(event) {
|
|
210
|
+
if (disabled || !isFileDrag(event.dataTransfer)) return;
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
depth.current++;
|
|
213
|
+
setState({
|
|
214
|
+
isDragging: true,
|
|
215
|
+
isDragReject: evaluate(event.dataTransfer)
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
onDragOver(event) {
|
|
219
|
+
if (disabled || !isFileDrag(event.dataTransfer)) return;
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
event.dataTransfer.dropEffect = "copy";
|
|
222
|
+
},
|
|
223
|
+
onDragLeave(event) {
|
|
224
|
+
if (disabled || !isFileDrag(event.dataTransfer)) return;
|
|
225
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
226
|
+
if (depth.current === 0) setState({
|
|
227
|
+
isDragging: false,
|
|
228
|
+
isDragReject: false
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
onDrop(event) {
|
|
232
|
+
if (disabled || !isFileDrag(event.dataTransfer)) return;
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
depth.current = 0;
|
|
235
|
+
setState({
|
|
236
|
+
isDragging: false,
|
|
237
|
+
isDragReject: false
|
|
238
|
+
});
|
|
239
|
+
getDroppedFiles(event.dataTransfer).then((files) => {
|
|
240
|
+
if (files.length) onDrop(multiple ? files : files.slice(0, 1));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}), [
|
|
244
|
+
disabled,
|
|
245
|
+
evaluate,
|
|
246
|
+
multiple,
|
|
247
|
+
onDrop
|
|
248
|
+
]);
|
|
249
|
+
return {
|
|
250
|
+
...state,
|
|
251
|
+
handlers
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/use-file-preview.ts
|
|
256
|
+
function getPreviewKind(file) {
|
|
257
|
+
if (isPreviewableImage(file)) return "image";
|
|
258
|
+
if (file.type.startsWith("video/")) return "video";
|
|
259
|
+
if (file.type.startsWith("audio/")) return "audio";
|
|
260
|
+
if (file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf")) return "pdf";
|
|
261
|
+
return "file";
|
|
262
|
+
}
|
|
263
|
+
async function createThumbnail(file, size) {
|
|
264
|
+
if (typeof createImageBitmap !== "function" || file.type === "image/svg+xml") return null;
|
|
265
|
+
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
266
|
+
try {
|
|
267
|
+
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
|
|
268
|
+
if (scale === 1) return null;
|
|
269
|
+
const width = Math.round(bitmap.width * scale);
|
|
270
|
+
const height = Math.round(bitmap.height * scale);
|
|
271
|
+
const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(width, height) : Object.assign(document.createElement("canvas"), {
|
|
272
|
+
width,
|
|
273
|
+
height
|
|
274
|
+
});
|
|
275
|
+
const context = canvas.getContext("2d");
|
|
276
|
+
if (!context) return null;
|
|
277
|
+
context.drawImage(bitmap, 0, 0, width, height);
|
|
278
|
+
if ("convertToBlob" in canvas) return canvas.convertToBlob({
|
|
279
|
+
type: "image/webp",
|
|
280
|
+
quality: .8
|
|
281
|
+
});
|
|
282
|
+
return new Promise((resolve) => canvas.toBlob(resolve, "image/webp", .8));
|
|
283
|
+
} finally {
|
|
284
|
+
bitmap.close();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* An object URL for previewing a file, revoked automatically when the file
|
|
289
|
+
* changes or the component unmounts.
|
|
290
|
+
*/
|
|
291
|
+
function useFilePreview(file, { thumbnailSize } = {}) {
|
|
292
|
+
const [preview, setPreview] = React.useState(null);
|
|
293
|
+
React.useEffect(() => {
|
|
294
|
+
if (!file) return;
|
|
295
|
+
let cancelled = false;
|
|
296
|
+
let objectUrl = null;
|
|
297
|
+
const source = file;
|
|
298
|
+
const publish = (blob) => {
|
|
299
|
+
if (cancelled) return;
|
|
300
|
+
objectUrl = URL.createObjectURL(blob);
|
|
301
|
+
setPreview({
|
|
302
|
+
source,
|
|
303
|
+
url: objectUrl
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
(thumbnailSize && file.type.startsWith("image/") ? createThumbnail(file, thumbnailSize).catch(() => null) : Promise.resolve(null)).then((blob) => publish(blob ?? source));
|
|
307
|
+
return () => {
|
|
308
|
+
cancelled = true;
|
|
309
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
310
|
+
};
|
|
311
|
+
}, [file, thumbnailSize]);
|
|
312
|
+
return preview && preview.source === file ? preview.url : null;
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/use-uploader.ts
|
|
316
|
+
function splitOptions(options) {
|
|
317
|
+
const { onAdd, onReject, onSuccess, onError, onComplete, restore, ...engine } = options;
|
|
318
|
+
return {
|
|
319
|
+
callbacks: {
|
|
320
|
+
onAdd,
|
|
321
|
+
onReject,
|
|
322
|
+
onSuccess,
|
|
323
|
+
onError,
|
|
324
|
+
onComplete
|
|
325
|
+
},
|
|
326
|
+
restore,
|
|
327
|
+
engine
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const NO_ADAPTER = "UploadCN: no adapter. Pass `adapter` to the component or useUploader, or wrap your app in <UploadConfigProvider adapter={…}>. See https://uploadcn.dev/docs/storage";
|
|
331
|
+
/**
|
|
332
|
+
* Stands in when no adapter is configured: the component still renders
|
|
333
|
+
* (and pre-renders), and every upload fails right away with the setup hint.
|
|
334
|
+
*/
|
|
335
|
+
const missingAdapter = createAdapter(async () => {
|
|
336
|
+
throw new UploadError$1(NO_ADAPTER, {
|
|
337
|
+
code: "unknown",
|
|
338
|
+
retryable: false
|
|
339
|
+
});
|
|
340
|
+
}, { name: "missing" });
|
|
341
|
+
/**
|
|
342
|
+
* Creates an upload engine bound to the component's lifetime. The instance
|
|
343
|
+
* is stable; option changes (including a new adapter) apply on the next
|
|
344
|
+
* render without recreating it.
|
|
345
|
+
*/
|
|
346
|
+
function useUploader(options = {}) {
|
|
347
|
+
const { messages: _messages, ...defaults } = useUploadConfig();
|
|
348
|
+
const { callbacks, restore, engine: own } = splitOptions(options);
|
|
349
|
+
const engine = {
|
|
350
|
+
...defaults,
|
|
351
|
+
...stripUndefined(own)
|
|
352
|
+
};
|
|
353
|
+
const configured = engine.adapter != null;
|
|
354
|
+
if (!configured) engine.adapter = missingAdapter;
|
|
355
|
+
const [uploader] = React.useState(() => createUploader(engine));
|
|
356
|
+
React.useEffect(() => {
|
|
357
|
+
if (!configured) console.error(NO_ADAPTER);
|
|
358
|
+
}, [configured]);
|
|
359
|
+
const callbacksRef = React.useRef(callbacks);
|
|
360
|
+
React.useEffect(() => {
|
|
361
|
+
callbacksRef.current = callbacks;
|
|
362
|
+
uploader.setOptions(engine);
|
|
363
|
+
});
|
|
364
|
+
React.useEffect(() => {
|
|
365
|
+
const unsubscribers = [
|
|
366
|
+
uploader.on("add", ({ items }) => callbacksRef.current.onAdd?.(items)),
|
|
367
|
+
uploader.on("reject", ({ item }) => callbacksRef.current.onReject?.(item)),
|
|
368
|
+
uploader.on("success", ({ item }) => callbacksRef.current.onSuccess?.(item)),
|
|
369
|
+
uploader.on("error", ({ item, error }) => callbacksRef.current.onError?.(item, error)),
|
|
370
|
+
uploader.on("complete", ({ items }) => callbacksRef.current.onComplete?.(items))
|
|
371
|
+
];
|
|
372
|
+
const unmount = uploader.mount();
|
|
373
|
+
return () => {
|
|
374
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
375
|
+
unmount();
|
|
376
|
+
};
|
|
377
|
+
}, [uploader]);
|
|
378
|
+
React.useEffect(() => {
|
|
379
|
+
if (restore) uploader.restore();
|
|
380
|
+
}, [uploader, restore]);
|
|
381
|
+
return uploader;
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/primitives.tsx
|
|
385
|
+
const visuallyHidden = {
|
|
386
|
+
position: "absolute",
|
|
387
|
+
width: 1,
|
|
388
|
+
height: 1,
|
|
389
|
+
padding: 0,
|
|
390
|
+
margin: -1,
|
|
391
|
+
overflow: "hidden",
|
|
392
|
+
clip: "rect(0, 0, 0, 0)",
|
|
393
|
+
whiteSpace: "nowrap",
|
|
394
|
+
border: 0
|
|
395
|
+
};
|
|
396
|
+
const OPTION_KEYS = [
|
|
397
|
+
"adapter",
|
|
398
|
+
"autoUpload",
|
|
399
|
+
"concurrency",
|
|
400
|
+
"retry",
|
|
401
|
+
"duplicates",
|
|
402
|
+
"transform",
|
|
403
|
+
"process",
|
|
404
|
+
"persistence",
|
|
405
|
+
"network",
|
|
406
|
+
"progressInterval",
|
|
407
|
+
"accept",
|
|
408
|
+
"maxSize",
|
|
409
|
+
"minSize",
|
|
410
|
+
"maxFiles",
|
|
411
|
+
"minFiles",
|
|
412
|
+
"image",
|
|
413
|
+
"media",
|
|
414
|
+
"validate",
|
|
415
|
+
"restore",
|
|
416
|
+
"onAdd",
|
|
417
|
+
"onReject",
|
|
418
|
+
"onSuccess",
|
|
419
|
+
"onError",
|
|
420
|
+
"onComplete"
|
|
421
|
+
];
|
|
422
|
+
const SUBMITTED = /* @__PURE__ */ new Set([
|
|
423
|
+
"idle",
|
|
424
|
+
"queued",
|
|
425
|
+
"uploading",
|
|
426
|
+
"paused",
|
|
427
|
+
"success"
|
|
428
|
+
]);
|
|
429
|
+
/** A hidden file input that mirrors the queue, for native form submission. */
|
|
430
|
+
function FormFiles({ name, items }) {
|
|
431
|
+
const ref = React.useRef(null);
|
|
432
|
+
React.useEffect(() => {
|
|
433
|
+
const input = ref.current;
|
|
434
|
+
if (!input || typeof DataTransfer === "undefined") return;
|
|
435
|
+
const transfer = new DataTransfer();
|
|
436
|
+
for (const item of items) if (SUBMITTED.has(item.status)) transfer.items.add(item.file);
|
|
437
|
+
input.files = transfer.files;
|
|
438
|
+
}, [items]);
|
|
439
|
+
return /* @__PURE__ */ jsx("input", {
|
|
440
|
+
ref,
|
|
441
|
+
type: "file",
|
|
442
|
+
name,
|
|
443
|
+
multiple: true,
|
|
444
|
+
hidden: true,
|
|
445
|
+
tabIndex: -1,
|
|
446
|
+
"aria-hidden": true
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
function defaultFormValue(item) {
|
|
450
|
+
const result = item.result;
|
|
451
|
+
if (result && typeof result.key === "string") return result.key;
|
|
452
|
+
if (result && typeof result.url === "string") return result.url;
|
|
453
|
+
return item.name;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The root of an upload UI. Pass uploader options directly
|
|
457
|
+
* (`<Upload adapter={…} maxFiles={5}>`) or an `uploader` from `useUploader`
|
|
458
|
+
* to control it from outside.
|
|
459
|
+
*/
|
|
460
|
+
function UploadRoot(props) {
|
|
461
|
+
if (props.uploader) return /* @__PURE__ */ jsx(UploadRootView, { ...props });
|
|
462
|
+
return /* @__PURE__ */ jsx(UploadRootWithUploader, { ...props });
|
|
463
|
+
}
|
|
464
|
+
function UploadRootWithUploader(props) {
|
|
465
|
+
const options = {};
|
|
466
|
+
const rest = {};
|
|
467
|
+
for (const [key, value] of Object.entries(props)) if (OPTION_KEYS.includes(key)) options[key] = value;
|
|
468
|
+
else rest[key] = value;
|
|
469
|
+
const uploader = useUploader(options);
|
|
470
|
+
return /* @__PURE__ */ jsx(UploadRootView, {
|
|
471
|
+
...rest,
|
|
472
|
+
uploader
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function UploadRootView({ uploader: typedUploader, multiple: multipleProp, disabled = false, name, getFormValue = defaultFormValue, submit = "value", capture, directory, messages: messagesProp, render, children, ...props }) {
|
|
476
|
+
const uploader = typedUploader;
|
|
477
|
+
const inputRef = React.useRef(null);
|
|
478
|
+
const inputId = React.useId();
|
|
479
|
+
const { accept, maxFiles } = uploader.getOptions();
|
|
480
|
+
const multiple = multipleProp ?? maxFiles !== 1;
|
|
481
|
+
const configMessages = useUploadConfig().messages;
|
|
482
|
+
const messages = React.useMemo(() => ({
|
|
483
|
+
...defaultMessages,
|
|
484
|
+
...configMessages,
|
|
485
|
+
...messagesProp,
|
|
486
|
+
status: {
|
|
487
|
+
...defaultMessages.status,
|
|
488
|
+
...configMessages?.status,
|
|
489
|
+
...messagesProp?.status
|
|
490
|
+
}
|
|
491
|
+
}), [configMessages, messagesProp]);
|
|
492
|
+
const items = useUploadSelector(uploader, (state) => state.items);
|
|
493
|
+
const isUploading = React.useMemo(() => getUploadSummary(items).isUploading, [items]);
|
|
494
|
+
const addFiles = React.useCallback((files) => {
|
|
495
|
+
if (disabled || files.length === 0) return;
|
|
496
|
+
if (!multiple) {
|
|
497
|
+
uploader.remove();
|
|
498
|
+
uploader.add(files.slice(0, 1));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
uploader.add(files);
|
|
502
|
+
}, [
|
|
503
|
+
disabled,
|
|
504
|
+
multiple,
|
|
505
|
+
uploader
|
|
506
|
+
]);
|
|
507
|
+
const openFileDialog = React.useCallback(() => {
|
|
508
|
+
if (!disabled) inputRef.current?.click();
|
|
509
|
+
}, [disabled]);
|
|
510
|
+
const context = React.useMemo(() => ({
|
|
511
|
+
uploader,
|
|
512
|
+
openFileDialog,
|
|
513
|
+
addFiles,
|
|
514
|
+
accept,
|
|
515
|
+
multiple,
|
|
516
|
+
disabled,
|
|
517
|
+
messages,
|
|
518
|
+
inputId
|
|
519
|
+
}), [
|
|
520
|
+
uploader,
|
|
521
|
+
openFileDialog,
|
|
522
|
+
addFiles,
|
|
523
|
+
accept,
|
|
524
|
+
multiple,
|
|
525
|
+
disabled,
|
|
526
|
+
messages,
|
|
527
|
+
inputId
|
|
528
|
+
]);
|
|
529
|
+
const state = {
|
|
530
|
+
disabled,
|
|
531
|
+
isUploading
|
|
532
|
+
};
|
|
533
|
+
const rootProps = mergeProps({
|
|
534
|
+
...dataAttributes({
|
|
535
|
+
disabled,
|
|
536
|
+
uploading: isUploading
|
|
537
|
+
}),
|
|
538
|
+
"aria-busy": isUploading || void 0
|
|
539
|
+
}, props);
|
|
540
|
+
const directoryProps = directory ? {
|
|
541
|
+
webkitdirectory: "",
|
|
542
|
+
directory: ""
|
|
543
|
+
} : {};
|
|
544
|
+
return /* @__PURE__ */ jsx(UploadContext.Provider, {
|
|
545
|
+
value: context,
|
|
546
|
+
children: renderElement("div", {
|
|
547
|
+
...rootProps,
|
|
548
|
+
children: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
549
|
+
children,
|
|
550
|
+
/* @__PURE__ */ jsx("input", {
|
|
551
|
+
ref: inputRef,
|
|
552
|
+
id: inputId,
|
|
553
|
+
type: "file",
|
|
554
|
+
tabIndex: -1,
|
|
555
|
+
"aria-hidden": true,
|
|
556
|
+
style: visuallyHidden,
|
|
557
|
+
accept: toAcceptAttribute(accept),
|
|
558
|
+
multiple,
|
|
559
|
+
disabled,
|
|
560
|
+
capture,
|
|
561
|
+
...directoryProps,
|
|
562
|
+
onChange: (event) => {
|
|
563
|
+
addFiles(Array.from(event.currentTarget.files ?? []));
|
|
564
|
+
event.currentTarget.value = "";
|
|
565
|
+
}
|
|
566
|
+
}),
|
|
567
|
+
name && submit === "file" ? /* @__PURE__ */ jsx(FormFiles, {
|
|
568
|
+
name,
|
|
569
|
+
items
|
|
570
|
+
}) : null,
|
|
571
|
+
name && submit === "value" ? items.filter((item) => item.status === "success").map((item) => /* @__PURE__ */ jsx("input", {
|
|
572
|
+
type: "hidden",
|
|
573
|
+
name,
|
|
574
|
+
value: getFormValue(item)
|
|
575
|
+
}, item.id)) : null,
|
|
576
|
+
/* @__PURE__ */ jsx(UploadAnnouncer, {
|
|
577
|
+
uploader,
|
|
578
|
+
messages
|
|
579
|
+
})
|
|
580
|
+
] })
|
|
581
|
+
}, render, state)
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function UploadAnnouncer({ uploader, messages }) {
|
|
585
|
+
const [message, setMessage] = React.useState("");
|
|
586
|
+
const messagesRef = React.useRef(messages);
|
|
587
|
+
React.useEffect(() => {
|
|
588
|
+
messagesRef.current = messages;
|
|
589
|
+
});
|
|
590
|
+
React.useEffect(() => {
|
|
591
|
+
const m = () => messagesRef.current;
|
|
592
|
+
const unsubscribers = [
|
|
593
|
+
uploader.on("add", ({ items }) => setMessage(m().added(items.length))),
|
|
594
|
+
uploader.on("success", ({ item }) => setMessage(m().uploaded(item.name))),
|
|
595
|
+
uploader.on("error", ({ item, error }) => setMessage(m().failed(item.name, error.message))),
|
|
596
|
+
uploader.on("reject", ({ item }) => setMessage(m().rejected(item.name, item.issues[0]?.message ?? ""))),
|
|
597
|
+
uploader.on("cancel", ({ item }) => setMessage(m().cancelled(item.name)))
|
|
598
|
+
];
|
|
599
|
+
return () => {
|
|
600
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
601
|
+
};
|
|
602
|
+
}, [uploader]);
|
|
603
|
+
return /* @__PURE__ */ jsx("div", {
|
|
604
|
+
role: "status",
|
|
605
|
+
"aria-live": "polite",
|
|
606
|
+
"aria-atomic": true,
|
|
607
|
+
style: visuallyHidden,
|
|
608
|
+
children: message
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const INTERACTIVE = "button, a, input, select, textarea, [role=button], [role=link]";
|
|
612
|
+
function UploadDropzone({ render, clickable = true, ...props }) {
|
|
613
|
+
const { addFiles, accept, multiple, disabled, openFileDialog } = useUploadContext();
|
|
614
|
+
const { isDragging, isDragReject, handlers } = useDropzone({
|
|
615
|
+
onDrop: addFiles,
|
|
616
|
+
accept,
|
|
617
|
+
multiple,
|
|
618
|
+
disabled
|
|
619
|
+
});
|
|
620
|
+
const state = {
|
|
621
|
+
isDragging,
|
|
622
|
+
isDragReject,
|
|
623
|
+
disabled
|
|
624
|
+
};
|
|
625
|
+
const interaction = clickable ? {
|
|
626
|
+
role: "button",
|
|
627
|
+
tabIndex: disabled ? -1 : 0,
|
|
628
|
+
"aria-disabled": disabled || void 0,
|
|
629
|
+
onClick(event) {
|
|
630
|
+
const target = event.target.closest(INTERACTIVE);
|
|
631
|
+
if (target && target !== event.currentTarget) return;
|
|
632
|
+
openFileDialog();
|
|
633
|
+
},
|
|
634
|
+
onKeyDown(event) {
|
|
635
|
+
if (event.target !== event.currentTarget) return;
|
|
636
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
637
|
+
event.preventDefault();
|
|
638
|
+
openFileDialog();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
} : {};
|
|
642
|
+
return renderElement("div", mergeProps({
|
|
643
|
+
...handlers,
|
|
644
|
+
...interaction,
|
|
645
|
+
...dataAttributes({
|
|
646
|
+
dragging: isDragging,
|
|
647
|
+
"drag-reject": isDragReject,
|
|
648
|
+
disabled
|
|
649
|
+
})
|
|
650
|
+
}, props), render, state);
|
|
651
|
+
}
|
|
652
|
+
/** A button that opens the file picker. */
|
|
653
|
+
function UploadTrigger({ render, ...props }) {
|
|
654
|
+
const { openFileDialog, disabled, inputId } = useUploadContext();
|
|
655
|
+
return renderElement("button", mergeProps({
|
|
656
|
+
type: "button",
|
|
657
|
+
disabled,
|
|
658
|
+
"aria-controls": inputId,
|
|
659
|
+
onClick: openFileDialog
|
|
660
|
+
}, props), render, { disabled });
|
|
661
|
+
}
|
|
662
|
+
const ItemSlot = React.memo(function ItemSlot({ id, template }) {
|
|
663
|
+
const item = useUploadItem(id);
|
|
664
|
+
return /* @__PURE__ */ jsx(UploadItemContext.Provider, {
|
|
665
|
+
value: item,
|
|
666
|
+
children: typeof template === "function" ? template(item) : template
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
/**
|
|
670
|
+
* A list of uploads. Children are rendered once per item, with the item in
|
|
671
|
+
* context; each item re-renders on its own progress without re-rendering
|
|
672
|
+
* its siblings.
|
|
673
|
+
*/
|
|
674
|
+
function UploadList({ render, children, filter, ...props }) {
|
|
675
|
+
const { uploader } = useUploadContext();
|
|
676
|
+
const ids = useUploadSelector(uploader, (state) => (filter ? state.items.filter(filter) : state.items).map((item) => item.id), shallowArrayEqual);
|
|
677
|
+
const content = ids.map((id) => /* @__PURE__ */ jsx(ItemSlot, {
|
|
678
|
+
id,
|
|
679
|
+
template: children
|
|
680
|
+
}, id));
|
|
681
|
+
return renderElement("ul", mergeProps({
|
|
682
|
+
role: "list",
|
|
683
|
+
...dataAttributes({ empty: ids.length === 0 })
|
|
684
|
+
}, {
|
|
685
|
+
...props,
|
|
686
|
+
children: content
|
|
687
|
+
}), render, { count: ids.length });
|
|
688
|
+
}
|
|
689
|
+
/** Provides an item to the item-level primitives inside it. */
|
|
690
|
+
function UploadItemRoot({ item: itemProp, render, ...props }) {
|
|
691
|
+
const contextItem = React.useContext(UploadItemContext);
|
|
692
|
+
const item = itemProp ?? contextItem;
|
|
693
|
+
if (!item) throw new Error("UploadItem needs an `item` prop or must be rendered inside UploadList");
|
|
694
|
+
return /* @__PURE__ */ jsx(UploadItemContext.Provider, {
|
|
695
|
+
value: item,
|
|
696
|
+
children: renderElement("li", mergeProps({
|
|
697
|
+
...dataAttributes({
|
|
698
|
+
status: item.status,
|
|
699
|
+
restored: item.restored
|
|
700
|
+
}),
|
|
701
|
+
"aria-busy": isActiveStatus(item.status) || void 0
|
|
702
|
+
}, props), render, item)
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/** An image thumbnail of the item, or `fallback` for other file types. */
|
|
706
|
+
function UploadPreview({ render, fallback = null, thumbnailSize = 256, alt = "", ...props }) {
|
|
707
|
+
const item = useUploadItem();
|
|
708
|
+
const isImage = getPreviewKind(item.file) === "image";
|
|
709
|
+
const url = useFilePreview(isImage ? item.originalFile : null, { thumbnailSize });
|
|
710
|
+
if (!isImage) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
|
|
711
|
+
if (!url) return null;
|
|
712
|
+
return renderElement("img", mergeProps({
|
|
713
|
+
src: url,
|
|
714
|
+
alt,
|
|
715
|
+
decoding: "async",
|
|
716
|
+
draggable: false
|
|
717
|
+
}, props), render, item);
|
|
718
|
+
}
|
|
719
|
+
function UploadName({ render, ...props }) {
|
|
720
|
+
const item = useUploadItem();
|
|
721
|
+
return renderElement("span", mergeProps({
|
|
722
|
+
title: item.name,
|
|
723
|
+
children: item.name
|
|
724
|
+
}, props), render, item);
|
|
725
|
+
}
|
|
726
|
+
function UploadSize({ render, format = "progress", ...props }) {
|
|
727
|
+
const item = useUploadItem();
|
|
728
|
+
return renderElement("span", mergeProps({ children: format === "progress" && (item.status === "uploading" || item.status === "paused") ? `${formatBytes(item.progress.loaded)} / ${formatBytes(item.size)}` : formatBytes(item.size) }, props), render, item);
|
|
729
|
+
}
|
|
730
|
+
/** Upload speed and time remaining, while uploading. */
|
|
731
|
+
function UploadSpeed({ render, ...props }) {
|
|
732
|
+
const item = useUploadItem();
|
|
733
|
+
if (item.status !== "uploading" || item.progress.speed == null) return null;
|
|
734
|
+
const eta = formatDuration(item.progress.eta);
|
|
735
|
+
return renderElement("span", mergeProps({ children: `${formatSpeed(item.progress.speed)}${eta ? ` · ${eta} left` : ""}` }, props), render, item);
|
|
736
|
+
}
|
|
737
|
+
function UploadStatus({ render, ...props }) {
|
|
738
|
+
const item = useUploadItem();
|
|
739
|
+
const { messages } = useUploadContext();
|
|
740
|
+
return renderElement("span", mergeProps({
|
|
741
|
+
...dataAttributes({ status: item.status }),
|
|
742
|
+
children: getStatusLabel(item, messages)
|
|
743
|
+
}, props), render, item);
|
|
744
|
+
}
|
|
745
|
+
/** The item's error or validation message, when it has one. */
|
|
746
|
+
function UploadError({ render, ...props }) {
|
|
747
|
+
const item = useUploadItem();
|
|
748
|
+
if (!item.error) return null;
|
|
749
|
+
if (item.status !== "error" && item.status !== "rejected") return null;
|
|
750
|
+
const message = item.issues.length ? item.issues.map((issue) => issue.message).join(". ") : item.error.message;
|
|
751
|
+
return renderElement("p", mergeProps({
|
|
752
|
+
...dataAttributes({ status: item.status }),
|
|
753
|
+
children: message
|
|
754
|
+
}, props), render, item);
|
|
755
|
+
}
|
|
756
|
+
/** Renders its children only once the item uploaded successfully. */
|
|
757
|
+
function UploadSuccess({ children }) {
|
|
758
|
+
return useUploadItem().status === "success" ? /* @__PURE__ */ jsx(Fragment, { children }) : null;
|
|
759
|
+
}
|
|
760
|
+
const ProgressContext = React.createContext(null);
|
|
761
|
+
/**
|
|
762
|
+
* An accessible progress bar. Inside `<UploadItem>` it tracks that item;
|
|
763
|
+
* anywhere else it tracks the whole queue.
|
|
764
|
+
*/
|
|
765
|
+
function UploadProgress(props) {
|
|
766
|
+
const item = React.useContext(UploadItemContext);
|
|
767
|
+
return item ? /* @__PURE__ */ jsx(ItemProgress, {
|
|
768
|
+
item,
|
|
769
|
+
...props
|
|
770
|
+
}) : /* @__PURE__ */ jsx(QueueProgress, { ...props });
|
|
771
|
+
}
|
|
772
|
+
function ItemProgress({ item, ...props }) {
|
|
773
|
+
const { messages } = useUploadContext();
|
|
774
|
+
const done = item.status === "success" || item.status === "processing" || item.status === "scanning";
|
|
775
|
+
const indeterminate = item.status === "validating" || item.status === "processing" || item.status === "scanning" || item.status === "uploading" && item.size === 0;
|
|
776
|
+
return /* @__PURE__ */ jsx(ProgressView, {
|
|
777
|
+
...props,
|
|
778
|
+
percent: done ? 100 : item.progress.percent,
|
|
779
|
+
indeterminate,
|
|
780
|
+
complete: item.status === "success",
|
|
781
|
+
label: messages.progress(item.name),
|
|
782
|
+
status: item.status
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
function QueueProgress(props) {
|
|
786
|
+
const { uploader, messages } = useUploadContext();
|
|
787
|
+
const items = useUploadSelector(uploader, (state) => state.items);
|
|
788
|
+
const summary = React.useMemo(() => getUploadSummary(items), [items]);
|
|
789
|
+
return /* @__PURE__ */ jsx(ProgressView, {
|
|
790
|
+
...props,
|
|
791
|
+
percent: summary.percent,
|
|
792
|
+
indeterminate: false,
|
|
793
|
+
complete: summary.isComplete,
|
|
794
|
+
label: messages.totalProgress
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
function ProgressView({ render, percent, indeterminate, complete, label, status, ...props }) {
|
|
798
|
+
const state = {
|
|
799
|
+
percent,
|
|
800
|
+
indeterminate,
|
|
801
|
+
complete
|
|
802
|
+
};
|
|
803
|
+
return /* @__PURE__ */ jsx(ProgressContext.Provider, {
|
|
804
|
+
value: state,
|
|
805
|
+
children: renderElement("div", mergeProps({
|
|
806
|
+
role: "progressbar",
|
|
807
|
+
"aria-label": label,
|
|
808
|
+
"aria-valuemin": 0,
|
|
809
|
+
"aria-valuemax": 100,
|
|
810
|
+
"aria-valuenow": indeterminate ? void 0 : Math.round(percent),
|
|
811
|
+
"aria-valuetext": indeterminate ? void 0 : `${Math.round(percent)}%`,
|
|
812
|
+
style: { "--upload-progress": `${percent}%` },
|
|
813
|
+
...dataAttributes({
|
|
814
|
+
indeterminate,
|
|
815
|
+
complete,
|
|
816
|
+
status
|
|
817
|
+
})
|
|
818
|
+
}, props), render, state)
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
/** The filled part of `UploadProgress`. */
|
|
822
|
+
function UploadProgressIndicator({ render, ...props }) {
|
|
823
|
+
const state = React.useContext(ProgressContext);
|
|
824
|
+
if (!state) throw new Error("UploadProgressIndicator must be inside UploadProgress");
|
|
825
|
+
return renderElement("div", mergeProps({
|
|
826
|
+
style: state.indeterminate ? void 0 : { transform: `translateX(-${100 - state.percent}%)` },
|
|
827
|
+
...dataAttributes({
|
|
828
|
+
indeterminate: state.indeterminate,
|
|
829
|
+
complete: state.complete
|
|
830
|
+
})
|
|
831
|
+
}, props), render, state);
|
|
832
|
+
}
|
|
833
|
+
function createItemAction(name, isAvailable, perform, label) {
|
|
834
|
+
function Action({ render, forceMount = false, ...props }) {
|
|
835
|
+
const item = useUploadItem();
|
|
836
|
+
const { uploader, messages } = useUploadContext();
|
|
837
|
+
const available = isAvailable(item, uploader);
|
|
838
|
+
if (!available && !forceMount) return null;
|
|
839
|
+
return renderElement("button", mergeProps({
|
|
840
|
+
type: "button",
|
|
841
|
+
disabled: !available,
|
|
842
|
+
"aria-label": label(messages, item.name),
|
|
843
|
+
onClick: () => perform(item, uploader)
|
|
844
|
+
}, props), render, item);
|
|
845
|
+
}
|
|
846
|
+
Action.displayName = name;
|
|
847
|
+
return Action;
|
|
848
|
+
}
|
|
849
|
+
const UploadRemove = createItemAction("UploadRemove", () => true, (item, uploader) => uploader.remove(item.id), (m, name) => m.remove(name));
|
|
850
|
+
const UploadCancel = createItemAction("UploadCancel", (item) => isActiveStatus(item.status) || item.status === "paused", (item, uploader) => uploader.cancel(item.id), (m, name) => m.cancel(name));
|
|
851
|
+
const UploadRetry = createItemAction("UploadRetry", (item) => item.status === "error" || item.status === "cancelled", (item, uploader) => uploader.retry(item.id), (m, name) => m.retry(name));
|
|
852
|
+
const UploadPause = createItemAction("UploadPause", (item, uploader) => uploader.canPause(item), (item, uploader) => uploader.pause(item.id), (m, name) => m.pause(name));
|
|
853
|
+
const UploadResume = createItemAction("UploadResume", (item) => item.status === "paused", (item, uploader) => uploader.resume(item.id), (m, name) => m.resume(name));
|
|
854
|
+
/** Renders its children only while the queue is empty. */
|
|
855
|
+
function UploadEmpty({ children }) {
|
|
856
|
+
const { uploader } = useUploadContext();
|
|
857
|
+
return useUploadSelector(uploader, (state) => state.items.length === 0) ? /* @__PURE__ */ jsx(Fragment, { children }) : null;
|
|
858
|
+
}
|
|
859
|
+
/** Starts idle uploads (when `autoUpload` is off). Hidden when there are none. */
|
|
860
|
+
function UploadStart({ render, forceMount = false, ...props }) {
|
|
861
|
+
const { uploader, disabled } = useUploadContext();
|
|
862
|
+
const count = useUploadSelector(uploader, (state) => state.items.filter((item) => item.status === "idle").length);
|
|
863
|
+
if (count === 0 && !forceMount) return null;
|
|
864
|
+
return renderElement("button", mergeProps({
|
|
865
|
+
type: "button",
|
|
866
|
+
disabled: disabled || count === 0,
|
|
867
|
+
onClick: () => uploader.start()
|
|
868
|
+
}, props), render, { count });
|
|
869
|
+
}
|
|
870
|
+
/** Removes finished items (or every item with `all`). Hidden when there's nothing to clear. */
|
|
871
|
+
function UploadClear({ render, forceMount = false, all = false, ...props }) {
|
|
872
|
+
const { uploader } = useUploadContext();
|
|
873
|
+
const count = useUploadSelector(uploader, (state) => all ? state.items.length : state.items.filter((item) => [
|
|
874
|
+
"success",
|
|
875
|
+
"cancelled",
|
|
876
|
+
"rejected"
|
|
877
|
+
].includes(item.status)).length);
|
|
878
|
+
if (count === 0 && !forceMount) return null;
|
|
879
|
+
return renderElement("button", mergeProps({
|
|
880
|
+
type: "button",
|
|
881
|
+
disabled: count === 0,
|
|
882
|
+
onClick: () => all ? uploader.remove() : uploader.clearCompleted()
|
|
883
|
+
}, props), render, { count });
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/use-upload.ts
|
|
887
|
+
/**
|
|
888
|
+
* The all-in-one hook: creates an uploader and returns its items, an
|
|
889
|
+
* aggregate summary and bound actions.
|
|
890
|
+
*
|
|
891
|
+
* @example
|
|
892
|
+
* const { items, add, summary } = useUpload({ adapter: s3Adapter({ endpoint: "/api/upload" }) })
|
|
893
|
+
*/
|
|
894
|
+
function useUpload(options) {
|
|
895
|
+
return useUploadState(useUploader(options));
|
|
896
|
+
}
|
|
897
|
+
/** Like `useUpload`, for an uploader created elsewhere (e.g. from context). */
|
|
898
|
+
function useUploadState(uploader) {
|
|
899
|
+
const state = useUploadSelector(uploader, (s) => s);
|
|
900
|
+
const summary = React.useMemo(() => getUploadSummary(state.items), [state.items]);
|
|
901
|
+
return React.useMemo(() => ({
|
|
902
|
+
uploader,
|
|
903
|
+
items: state.items,
|
|
904
|
+
summary,
|
|
905
|
+
online: state.online,
|
|
906
|
+
add: uploader.add,
|
|
907
|
+
start: uploader.start,
|
|
908
|
+
pause: uploader.pause,
|
|
909
|
+
resume: uploader.resume,
|
|
910
|
+
cancel: uploader.cancel,
|
|
911
|
+
retry: uploader.retry,
|
|
912
|
+
remove: uploader.remove,
|
|
913
|
+
clearCompleted: uploader.clearCompleted,
|
|
914
|
+
update: uploader.update
|
|
915
|
+
}), [
|
|
916
|
+
uploader,
|
|
917
|
+
state,
|
|
918
|
+
summary
|
|
919
|
+
]);
|
|
920
|
+
}
|
|
921
|
+
/** Aggregate progress, speed, ETA and counts for the whole queue. */
|
|
922
|
+
function useUploadProgress(uploader) {
|
|
923
|
+
const items = useUploadSelector(uploader, (state) => state.items);
|
|
924
|
+
return React.useMemo(() => getUploadSummary(items), [items]);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Derives form values from successful uploads — e.g. object keys for a
|
|
928
|
+
* server action or react-hook-form field. Pass a stable `getValue` (defined
|
|
929
|
+
* outside the component or memoized) to keep the returned array stable.
|
|
930
|
+
*/
|
|
931
|
+
function useUploadValue(uploader, getValue) {
|
|
932
|
+
const items = useUploadSelector(uploader, (state) => state.items);
|
|
933
|
+
return React.useMemo(() => items.filter((item) => item.status === "success").map((item) => getValue(item)), [items, getValue]);
|
|
934
|
+
}
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/use-window-drop.ts
|
|
937
|
+
/**
|
|
938
|
+
* Detects files dragged over the browser window — for "drop anywhere"
|
|
939
|
+
* overlays. Drops already handled by an inner dropzone (which calls
|
|
940
|
+
* `preventDefault`) are ignored, so the two can coexist.
|
|
941
|
+
*/
|
|
942
|
+
function useWindowDrop({ onDrop, enabled = true }) {
|
|
943
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
944
|
+
const onDropRef = React.useRef(onDrop);
|
|
945
|
+
React.useEffect(() => {
|
|
946
|
+
onDropRef.current = onDrop;
|
|
947
|
+
});
|
|
948
|
+
React.useEffect(() => {
|
|
949
|
+
if (!enabled) return;
|
|
950
|
+
let depth = 0;
|
|
951
|
+
function onDragEnter(event) {
|
|
952
|
+
if (!isFileDrag(event.dataTransfer)) return;
|
|
953
|
+
depth++;
|
|
954
|
+
setIsDragging(true);
|
|
955
|
+
}
|
|
956
|
+
function onDragOver(event) {
|
|
957
|
+
if (!isFileDrag(event.dataTransfer)) return;
|
|
958
|
+
event.preventDefault();
|
|
959
|
+
}
|
|
960
|
+
function onDragLeave(event) {
|
|
961
|
+
if (!isFileDrag(event.dataTransfer)) return;
|
|
962
|
+
depth = Math.max(0, depth - 1);
|
|
963
|
+
if (depth === 0 || event.relatedTarget === null) {
|
|
964
|
+
depth = 0;
|
|
965
|
+
setIsDragging(false);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function onDropEvent(event) {
|
|
969
|
+
depth = 0;
|
|
970
|
+
setIsDragging(false);
|
|
971
|
+
if (!event.dataTransfer || !isFileDrag(event.dataTransfer)) return;
|
|
972
|
+
const handledByDropzone = event.defaultPrevented;
|
|
973
|
+
event.preventDefault();
|
|
974
|
+
if (handledByDropzone) return;
|
|
975
|
+
getDroppedFiles(event.dataTransfer).then((files) => {
|
|
976
|
+
if (files.length) onDropRef.current(files);
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
function onBlur() {
|
|
980
|
+
depth = 0;
|
|
981
|
+
setIsDragging(false);
|
|
982
|
+
}
|
|
983
|
+
window.addEventListener("dragenter", onDragEnter);
|
|
984
|
+
window.addEventListener("dragover", onDragOver);
|
|
985
|
+
window.addEventListener("dragleave", onDragLeave);
|
|
986
|
+
window.addEventListener("drop", onDropEvent);
|
|
987
|
+
window.addEventListener("blur", onBlur);
|
|
988
|
+
return () => {
|
|
989
|
+
window.removeEventListener("dragenter", onDragEnter);
|
|
990
|
+
window.removeEventListener("dragover", onDragOver);
|
|
991
|
+
window.removeEventListener("dragleave", onDragLeave);
|
|
992
|
+
window.removeEventListener("drop", onDropEvent);
|
|
993
|
+
window.removeEventListener("blur", onBlur);
|
|
994
|
+
setIsDragging(false);
|
|
995
|
+
};
|
|
996
|
+
}, [enabled]);
|
|
997
|
+
return { isDragging: enabled && isDragging };
|
|
998
|
+
}
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region src/use-paste-files.ts
|
|
1001
|
+
/**
|
|
1002
|
+
* Uploads files and screenshots pasted from the clipboard. Text pastes are
|
|
1003
|
+
* left alone so inputs keep working normally.
|
|
1004
|
+
*/
|
|
1005
|
+
function usePasteFiles({ onPaste, target, enabled = true }) {
|
|
1006
|
+
const onPasteRef = React.useRef(onPaste);
|
|
1007
|
+
React.useEffect(() => {
|
|
1008
|
+
onPasteRef.current = onPaste;
|
|
1009
|
+
});
|
|
1010
|
+
React.useEffect(() => {
|
|
1011
|
+
if (!enabled) return;
|
|
1012
|
+
const element = target?.current ?? document;
|
|
1013
|
+
function handlePaste(event) {
|
|
1014
|
+
const files = getClipboardFiles(event.clipboardData);
|
|
1015
|
+
if (files.length === 0) return;
|
|
1016
|
+
event.preventDefault();
|
|
1017
|
+
onPasteRef.current(files);
|
|
1018
|
+
}
|
|
1019
|
+
element.addEventListener("paste", handlePaste);
|
|
1020
|
+
return () => element.removeEventListener("paste", handlePaste);
|
|
1021
|
+
}, [enabled, target]);
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/use-file-validation.ts
|
|
1025
|
+
/**
|
|
1026
|
+
* Standalone validation for flows that don't upload immediately — e.g.
|
|
1027
|
+
* collecting files in a form and submitting them later. Uses the same rules
|
|
1028
|
+
* as the uploader, including async and custom validators.
|
|
1029
|
+
*/
|
|
1030
|
+
function useFileValidation(options) {
|
|
1031
|
+
const [isValidating, setIsValidating] = React.useState(false);
|
|
1032
|
+
const optionsRef = React.useRef(options);
|
|
1033
|
+
React.useEffect(() => {
|
|
1034
|
+
optionsRef.current = options;
|
|
1035
|
+
});
|
|
1036
|
+
const pending = React.useRef(null);
|
|
1037
|
+
React.useEffect(() => () => pending.current?.abort(), []);
|
|
1038
|
+
return {
|
|
1039
|
+
validate: React.useCallback(async (files, context = {}) => {
|
|
1040
|
+
pending.current?.abort();
|
|
1041
|
+
const controller = new AbortController();
|
|
1042
|
+
pending.current = controller;
|
|
1043
|
+
setIsValidating(true);
|
|
1044
|
+
try {
|
|
1045
|
+
return await validateFiles(files, optionsRef.current, {
|
|
1046
|
+
...context,
|
|
1047
|
+
signal: controller.signal
|
|
1048
|
+
});
|
|
1049
|
+
} finally {
|
|
1050
|
+
if (pending.current === controller) {
|
|
1051
|
+
pending.current = null;
|
|
1052
|
+
setIsValidating(false);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}, []),
|
|
1056
|
+
validateCount: React.useCallback((count) => validateFileCount(count, optionsRef.current), []),
|
|
1057
|
+
isValidating
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region src/use-image.ts
|
|
1062
|
+
/** Compresses images on demand and reports how much was saved. */
|
|
1063
|
+
function useImageCompression(options = {}) {
|
|
1064
|
+
const [isCompressing, setIsCompressing] = React.useState(false);
|
|
1065
|
+
const [stats, setStats] = React.useState(null);
|
|
1066
|
+
const [error, setError] = React.useState(null);
|
|
1067
|
+
const optionsRef = React.useRef(options);
|
|
1068
|
+
React.useEffect(() => {
|
|
1069
|
+
optionsRef.current = options;
|
|
1070
|
+
});
|
|
1071
|
+
return {
|
|
1072
|
+
compress: React.useCallback(async (file) => {
|
|
1073
|
+
setIsCompressing(true);
|
|
1074
|
+
setError(null);
|
|
1075
|
+
try {
|
|
1076
|
+
const result = await compressImage(file, optionsRef.current);
|
|
1077
|
+
setStats({
|
|
1078
|
+
before: file.size,
|
|
1079
|
+
after: result.size,
|
|
1080
|
+
saved: file.size === 0 ? 0 : 1 - result.size / file.size
|
|
1081
|
+
});
|
|
1082
|
+
return result;
|
|
1083
|
+
} catch (cause) {
|
|
1084
|
+
const failure = cause instanceof Error ? cause : new Error(String(cause));
|
|
1085
|
+
setError(failure);
|
|
1086
|
+
throw failure;
|
|
1087
|
+
} finally {
|
|
1088
|
+
setIsCompressing(false);
|
|
1089
|
+
}
|
|
1090
|
+
}, []),
|
|
1091
|
+
isCompressing,
|
|
1092
|
+
stats,
|
|
1093
|
+
error
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
const INITIAL_CROP = {
|
|
1097
|
+
position: {
|
|
1098
|
+
x: 0,
|
|
1099
|
+
y: 0
|
|
1100
|
+
},
|
|
1101
|
+
zoom: 1,
|
|
1102
|
+
rotation: 0,
|
|
1103
|
+
flipHorizontal: false,
|
|
1104
|
+
flipVertical: false,
|
|
1105
|
+
area: null
|
|
1106
|
+
};
|
|
1107
|
+
/**
|
|
1108
|
+
* UI-agnostic crop/rotate/zoom state with an `apply` that renders the
|
|
1109
|
+
* result. Pair it with any crop UI (react-easy-crop, a canvas, sliders).
|
|
1110
|
+
*/
|
|
1111
|
+
function useImageCrop({ minZoom = 1, maxZoom = 4 } = {}) {
|
|
1112
|
+
const [state, setState] = React.useState(INITIAL_CROP);
|
|
1113
|
+
const actions = React.useMemo(() => ({
|
|
1114
|
+
setPosition: (position) => setState((s) => ({
|
|
1115
|
+
...s,
|
|
1116
|
+
position
|
|
1117
|
+
})),
|
|
1118
|
+
setZoom: (zoom) => setState((s) => ({
|
|
1119
|
+
...s,
|
|
1120
|
+
zoom: Math.min(maxZoom, Math.max(minZoom, zoom))
|
|
1121
|
+
})),
|
|
1122
|
+
setRotation: (rotation) => setState((s) => ({
|
|
1123
|
+
...s,
|
|
1124
|
+
rotation: (rotation % 360 + 360) % 360
|
|
1125
|
+
})),
|
|
1126
|
+
rotateBy: (degrees) => setState((s) => ({
|
|
1127
|
+
...s,
|
|
1128
|
+
rotation: ((s.rotation + degrees) % 360 + 360) % 360
|
|
1129
|
+
})),
|
|
1130
|
+
flip: (axis) => setState((s) => axis === "horizontal" ? {
|
|
1131
|
+
...s,
|
|
1132
|
+
flipHorizontal: !s.flipHorizontal
|
|
1133
|
+
} : {
|
|
1134
|
+
...s,
|
|
1135
|
+
flipVertical: !s.flipVertical
|
|
1136
|
+
}),
|
|
1137
|
+
setArea: (area) => setState((s) => ({
|
|
1138
|
+
...s,
|
|
1139
|
+
area
|
|
1140
|
+
})),
|
|
1141
|
+
reset: () => setState(INITIAL_CROP)
|
|
1142
|
+
}), [minZoom, maxZoom]);
|
|
1143
|
+
const apply = React.useCallback((file, options = {}) => transformImage(file, {
|
|
1144
|
+
...options,
|
|
1145
|
+
crop: state.area ?? void 0,
|
|
1146
|
+
rotate: state.rotation,
|
|
1147
|
+
flipHorizontal: state.flipHorizontal,
|
|
1148
|
+
flipVertical: state.flipVertical
|
|
1149
|
+
}), [state]);
|
|
1150
|
+
return {
|
|
1151
|
+
...state,
|
|
1152
|
+
...actions,
|
|
1153
|
+
minZoom,
|
|
1154
|
+
maxZoom,
|
|
1155
|
+
apply
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/use-network.ts
|
|
1160
|
+
function subscribe(callback) {
|
|
1161
|
+
window.addEventListener("online", callback);
|
|
1162
|
+
window.addEventListener("offline", callback);
|
|
1163
|
+
return () => {
|
|
1164
|
+
window.removeEventListener("online", callback);
|
|
1165
|
+
window.removeEventListener("offline", callback);
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* `navigator.onLine`, kept in sync with `online`/`offline` events. `true`
|
|
1170
|
+
* on the server. Note that "online" only means a network interface is up.
|
|
1171
|
+
*/
|
|
1172
|
+
function useNetworkStatus() {
|
|
1173
|
+
return React.useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Warns before closing or reloading the tab while uploads are in progress.
|
|
1177
|
+
* Browsers show their own generic message; custom text is not supported.
|
|
1178
|
+
*/
|
|
1179
|
+
function useUploadGuard(uploader, { enabled = true } = {}) {
|
|
1180
|
+
const isUploading = useUploadSelector(uploader, (state) => getUploadSummary(state.items).isUploading);
|
|
1181
|
+
React.useEffect(() => {
|
|
1182
|
+
if (!enabled || !isUploading) return;
|
|
1183
|
+
function onBeforeUnload(event) {
|
|
1184
|
+
event.preventDefault();
|
|
1185
|
+
}
|
|
1186
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
1187
|
+
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
1188
|
+
}, [enabled, isUploading]);
|
|
1189
|
+
return isUploading;
|
|
1190
|
+
}
|
|
1191
|
+
//#endregion
|
|
1192
|
+
//#region src/index.ts
|
|
1193
|
+
/**
|
|
1194
|
+
* Headless, accessible upload primitives. Style them directly or use the
|
|
1195
|
+
* shadcn-styled versions from the UploadCN registry.
|
|
1196
|
+
*/
|
|
1197
|
+
const Upload = {
|
|
1198
|
+
Root: UploadRoot,
|
|
1199
|
+
Dropzone: UploadDropzone,
|
|
1200
|
+
Trigger: UploadTrigger,
|
|
1201
|
+
List: UploadList,
|
|
1202
|
+
Item: UploadItemRoot,
|
|
1203
|
+
Preview: UploadPreview,
|
|
1204
|
+
Name: UploadName,
|
|
1205
|
+
Size: UploadSize,
|
|
1206
|
+
Speed: UploadSpeed,
|
|
1207
|
+
Status: UploadStatus,
|
|
1208
|
+
Error: UploadError,
|
|
1209
|
+
Success: UploadSuccess,
|
|
1210
|
+
Progress: UploadProgress,
|
|
1211
|
+
ProgressIndicator: UploadProgressIndicator,
|
|
1212
|
+
Remove: UploadRemove,
|
|
1213
|
+
Cancel: UploadCancel,
|
|
1214
|
+
Retry: UploadRetry,
|
|
1215
|
+
Pause: UploadPause,
|
|
1216
|
+
Resume: UploadResume,
|
|
1217
|
+
Empty: UploadEmpty,
|
|
1218
|
+
Start: UploadStart,
|
|
1219
|
+
Clear: UploadClear
|
|
1220
|
+
};
|
|
1221
|
+
//#endregion
|
|
1222
|
+
export { Upload, UploadCancel, UploadClear, UploadConfigProvider, UploadContext, UploadDropzone, UploadEmpty, UploadError, UploadItemContext, UploadItemRoot, UploadList, UploadName, UploadPause, UploadPreview, UploadProgress, UploadProgressIndicator, UploadRemove, UploadResume, UploadRetry, UploadRoot, UploadSize, UploadSpeed, UploadStart, UploadStatus, UploadSuccess, UploadTrigger, defaultMessages, getPreviewKind, getStatusLabel, mergeProps, shallowArrayEqual, useDropzone, useFilePreview, useFileValidation, useImageCompression, useImageCrop, useNetworkStatus, usePasteFiles, useUpload, useUploadConfig, useUploadContext, useUploadGuard, useUploadItem, useUploadProgress, useUploadSelector, useUploadState, useUploadValue, useUploader, useWindowDrop };
|
|
1223
|
+
|
|
1224
|
+
//# sourceMappingURL=index.js.map
|