UNPKG

react-muntaha-uploader

Version:

A flexible, feature-rich React hook for robust file uploads with drag-and-drop, folder support, validation, progress tracking, abort, and more.

518 lines (517 loc) 22.1 kB
'use client'; var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (g && (g = 0, op[0] && (_ = 0)), _) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { if (ar || !(i in from)) { if (!ar) ar = Array.prototype.slice.call(from, 0, i); ar[i] = from[i]; } } return to.concat(ar || Array.prototype.slice.call(from)); }; import { useCallback, useEffect, useRef, useState } from 'react'; /** * A custom React hook for handling file drops and uploads with validation, progress tracking, and abort support. * @template T - A boolean type indicating whether multiple files are allowed (defaults to true) * @param {DropZoneOptions<T>} options - Configuration options for the drop zone * @returns {EnhancedDropZoneState<T>} An object containing state and utility functions for the drop zone */ var useMuntahaDrop = function (options) { if (options === void 0) { options = {}; } var inputRef = useRef(null); var rootRef = useRef(null); var _a = useState(null), error = _a[0], setError = _a[1]; var _b = useState(false), isDragActive = _b[0], setIsDragActive = _b[1]; var _c = useState(null), files = _c[0], setFiles = _c[1]; var _d = useState(null), arrayBuffer = _d[0], setArrayBuffer = _d[1]; var _e = useState({}), progress = _e[0], setProgress = _e[1]; var _f = useState('idle'), status = _f[0], setStatus = _f[1]; var abortController = useRef(new AbortController()); var activeReaders = useRef(new Set()); var _g = options.accept, accept = _g === void 0 ? ['*'] : _g, minSize = options.minSize, maxSize = options.maxSize, maxFiles = options.maxFiles, _h = options.multiple, multiple = _h === void 0 ? true : _h, _j = options.disabled, disabled = _j === void 0 ? false : _j, _k = options.isArrayBuffer, isArrayBuffer = _k === void 0 ? false : _k, _l = options.enableFolderUpload, enableFolderUpload = _l === void 0 ? false : _l, _m = options.enableKeyboard, enableKeyboard = _m === void 0 ? true : _m, onDrop = options.onDrop, onError = options.onError; // Set up folder upload attributes useEffect(function () { if (inputRef.current && enableFolderUpload) { inputRef.current.setAttribute('webkitdirectory', ''); inputRef.current.setAttribute('directory', ''); } else if (inputRef.current) { inputRef.current.removeAttribute('webkitdirectory'); inputRef.current.removeAttribute('directory'); } }, [enableFolderUpload]); // const getImageDimensions = ( // file: File // ): Promise<{ width: number; height: number }> => // new Promise((resolve) => { // const img = new Image(); // img.onload = () => { // resolve({ width: img.width, height: img.height }); // }; // img.src = URL.createObjectURL(file); // }); /** * Validates a file against the configured constraints * @param {File} file - The file to validate * @returns {boolean} True if the file is valid, false otherwise */ var validateFile = useCallback(function (file) { var fileSize = file.size; var fileType = file.type; if (minSize && fileSize < minSize) { setError("File size is below the minimum limit of ".concat((minSize / 1024 / 1024).toFixed(2), " MB.")); return false; } if (maxSize && fileSize > maxSize) { setError("File size exceeds the maximum limit of ".concat((maxSize / 1024 / 1024).toFixed(2), " MB.")); return false; } if (!accept.includes('*') && !accept.some(function (acpt) { if (acpt.endsWith('/*')) { return fileType.startsWith(acpt.split('/*')[0]); } return acpt === fileType; })) { setError("File type \"".concat(fileType, "\" is not allowed. Accepted types: ").concat(accept.join(', '))); return false; } setError(null); return true; }, [accept, maxSize, minSize]); /** * Reads files as ArrayBuffers with progress tracking and abort support * @param {File[]} files - The files to read * @returns {Promise<ArrayBuffer[]>} Promise resolving with ArrayBuffers of the files */ var readFiles = useCallback(function (files) { setStatus('reading'); abortController.current = new AbortController(); var signal = abortController.current.signal; return Promise.all(files.map(function (file, index) { return new Promise(function (resolve, reject) { if (signal.aborted) { reject(new Error('Upload aborted')); return; } var reader = new FileReader(); activeReaders.current.add(reader); var cleanup = function () { activeReaders.current.delete(reader); if (activeReaders.current.size === 0) { setStatus(function (prev) { return (prev === 'reading' ? 'idle' : prev); }); } }; signal.addEventListener('abort', function () { reader.abort(); cleanup(); reject(new Error('Upload aborted')); }); reader.onabort = function () { cleanup(); reject(new Error('Upload aborted')); }; reader.onerror = function () { cleanup(); setStatus('error'); reject(new Error("Failed to read ".concat(file.name))); }; reader.onprogress = function (event) { if (event.lengthComputable) { var percent_1 = Math.round((event.loaded / event.total) * 100); setProgress(function (prev) { var _a; return (__assign(__assign({}, prev), (_a = {}, _a[index] = percent_1, _a))); }); } }; reader.onload = function () { cleanup(); if (reader.result instanceof ArrayBuffer) { setProgress(function (prev) { var _a; return (__assign(__assign({}, prev), (_a = {}, _a[index] = 100, _a))); }); resolve(reader.result); } else { setStatus('error'); reject(new Error('Invalid file format')); } }; try { reader.readAsArrayBuffer(file); } catch (err) { cleanup(); setStatus('error'); reject(err instanceof Error ? err : new Error('Failed to read file')); } }); })); }, []); /** * Aborts all current upload operations */ var abortUpload = useCallback(function () { if (status === 'reading') { abortController.current.abort(); activeReaders.current.forEach(function (reader) { return reader.abort(); }); activeReaders.current.clear(); setStatus('aborted'); setProgress({}); if (inputRef.current) { inputRef.current.value = ''; } } }, [status]); // Clean up on unmount useEffect(function () { return function () { abortUpload(); }; }, [abortUpload]); /** * Handles file input change events * @param {React.ChangeEvent<HTMLInputElement>} e - The change event */ var handleInputChange = useCallback(function (e) { return __awaiter(void 0, void 0, void 0, function () { var fileList, validateFileList_1, currentFilesCount, result_1, result, error_1; return __generator(this, function (_a) { switch (_a.label) { case 0: if (!e.target.files) return [3 /*break*/, 8]; fileList = Array.from(e.target.files); validateFileList_1 = fileList.filter(validateFile); if (maxFiles !== undefined) { currentFilesCount = Array.isArray(files) ? files.length : files ? 1 : 0; if (currentFilesCount + validateFileList_1.length > maxFiles) { setError("You can only upload up to ".concat(maxFiles, " file(s). You tried to add ").concat(validateFileList_1.length, " file(s) to existing ").concat(currentFilesCount, " file(s).")); return [2 /*return*/]; } } _a.label = 1; case 1: _a.trys.push([1, 7, , 8]); if (!(validateFileList_1.length > 0)) return [3 /*break*/, 6]; if (!multiple) return [3 /*break*/, 4]; setFiles(function (prevFileList) { return __spreadArray(__spreadArray([], (Array.isArray(prevFileList) ? prevFileList : []), true), validateFileList_1, true); }); if (!isArrayBuffer) return [3 /*break*/, 3]; return [4 /*yield*/, readFiles(validateFileList_1)]; case 2: result_1 = _a.sent(); setArrayBuffer(function (prevBufferList) { return __spreadArray(__spreadArray([], (Array.isArray(prevBufferList) ? prevBufferList : []), true), result_1, true); }); _a.label = 3; case 3: return [3 /*break*/, 6]; case 4: setFiles(validateFileList_1[0]); if (!isArrayBuffer) return [3 /*break*/, 6]; return [4 /*yield*/, readFiles(validateFileList_1)]; case 5: result = _a.sent(); setArrayBuffer(result[0]); _a.label = 6; case 6: return [3 /*break*/, 8]; case 7: error_1 = _a.sent(); setError(error_1 instanceof Error ? error_1.message : 'Failed to read files'); return [3 /*break*/, 8]; case 8: return [2 /*return*/]; } }); }); }, [files, isArrayBuffer, maxFiles, multiple, readFiles, validateFile]); /** * Removes files from state * @param {number} [index] - Optional index of file to remove */ var onDelete = useCallback(function (index) { var _a, _b; if (inputRef.current) { inputRef.current.value = ''; } if (multiple) { if (typeof index === 'number') { setFiles(function (prev) { var _a; var prevFiles = Array.isArray(prev) ? prev : []; var newFiles = prevFiles.filter(function (_, i) { return i !== index; }); (_a = options.onDrop) === null || _a === void 0 ? void 0 : _a.call(options, newFiles); return newFiles; }); setArrayBuffer(function (prev) { var prevBuffers = Array.isArray(prev) ? prev : []; return prevBuffers.filter(function (_, i) { return i !== index; }); }); setProgress(function (prev) { var newProgress = __assign({}, prev); delete newProgress[index]; return Object.entries(newProgress).reduce(function (acc, _a) { var key = _a[0], value = _a[1]; var idx = Number(key); if (idx < index) acc[idx] = value; if (idx > index) acc[idx - 1] = value; return acc; }, {}); }); } else { setFiles(null); setArrayBuffer(null); setProgress({}); (_a = options.onDrop) === null || _a === void 0 ? void 0 : _a.call(options, null); } } else { setFiles(null); setArrayBuffer(null); setProgress({}); (_b = options.onDrop) === null || _b === void 0 ? void 0 : _b.call(options, null); } setError(null); }, [multiple, options]); /** * Resets the input field */ var resetInput = useCallback(function () { if (inputRef.current) { inputRef.current.value = ''; setFiles(null); setArrayBuffer(null); setProgress({}); setError(null); setStatus('idle'); abortController.current.abort(); activeReaders.current.forEach(function (reader) { return reader.abort(); }); activeReaders.current.clear(); } }, []); /** * Opens the file selection dialog */ var openDialog = useCallback(function () { if (!disabled && inputRef.current) { inputRef.current.click(); } }, [disabled]); var onDropRef = useRef(onDrop); useEffect(function () { onDropRef.current = onDrop; }, [onDrop]); useEffect(function () { var _a; if (files !== null && (multiple ? files.length > 0 : true)) { (_a = onDropRef.current) === null || _a === void 0 ? void 0 : _a.call(onDropRef, files); } }, [files, multiple]); useEffect(function () { if (onError) { onError(error); } }, [error, onError]); /** * Handles drag enter events * @param {React.DragEvent<HTMLDivElement>} e - The drag event */ var handleDragEnter = useCallback(function (e) { e.preventDefault(); e.stopPropagation(); setIsDragActive(true); }, []); /** * Handles drag over events * @param {React.DragEvent<HTMLDivElement>} e - The drag event */ var handleDragOver = useCallback(function (e) { e.preventDefault(); e.stopPropagation(); setIsDragActive(true); }, []); /** * Handles drag leave events * @param {React.DragEvent<HTMLDivElement>} e - The drag event */ var handleDragLeave = useCallback(function (e) { e.preventDefault(); e.stopPropagation(); setIsDragActive(false); }, []); /** * Handles drop events * @param {React.DragEvent<HTMLDivElement>} e - The drop event */ var handleDrop = useCallback(function (e) { e.preventDefault(); setIsDragActive(false); if (e.dataTransfer.files) { var fileList = Array.from(e.dataTransfer.files); handleInputChange({ target: { files: fileList }, }); } }, [handleInputChange]); /** * Returns props to spread on the root drop zone element * @returns {Object} Root element props */ var getRootProps = useCallback(function () { return ({ ref: rootRef, onClick: openDialog, onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, tabIndex: enableKeyboard ? 0 : undefined, role: enableKeyboard ? 'button' : undefined, 'aria-label': 'File upload', 'aria-disabled': disabled, 'data-dragging': isDragActive, 'data-disabled': disabled, 'data-has-error': error !== null, }); }, [ disabled, enableKeyboard, error, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDragActive, openDialog, ]); /** * Returns props to spread on the hidden file input element * @returns {Object} Input element props */ var getInputProps = useCallback(function () { return (__assign({ ref: inputRef, type: 'file', style: { display: 'none' }, accept: accept.join(','), multiple: multiple, disabled: disabled, onChange: handleInputChange }, (enableFolderUpload ? { webkitdirectory: '', directory: '' } : {}))); }, [accept, disabled, enableFolderUpload, handleInputChange, multiple]); /** * Gets file(s) by index or all files * @param {number} [index] - Optional index of file to get * @returns {FileState<T>} The requested file(s) */ var getFile = useCallback(function (index) { if (files === null) return null; if (index !== undefined) { if (multiple) { var fileArray = files; return (fileArray[index] ? [fileArray[index]] : []); } return files; } return multiple ? __spreadArray([], files, true) : files; }, [files, multiple]); /** * Gets ArrayBuffer(s) by index or all ArrayBuffers * @param {number} [index] - Optional index of ArrayBuffer to get * @returns {ArrayBufferState<T>} The requested ArrayBuffer(s) */ var getData = useCallback(function (index) { if (arrayBuffer === null) return null; if (index !== undefined) { if (multiple) { var bufferArray = arrayBuffer; return (bufferArray[index] ? [bufferArray[index]] : []); } return arrayBuffer; } return multiple ? __spreadArray([], arrayBuffer, true) : arrayBuffer; }, [arrayBuffer, multiple]); /** * Gets progress by index or all progress * @param {number} [index] - Optional index of progress to get * @returns {number | Record<number, number>} The requested progress */ var getProgress = useCallback(function (index) { if (!multiple) { return progress[0] || 0; } if (index !== undefined) { return progress[index] || 0; } return __assign({}, progress); }, [progress, multiple]); return { files: files, arrayBuffer: arrayBuffer, error: error, progress: progress, isDragActive: isDragActive, onDelete: onDelete, abortUpload: abortUpload, status: status, getRootProps: getRootProps, getInputProps: getInputProps, utils: { getFile: getFile, getData: getData, getProgress: getProgress, reset: resetInput, }, }; }; export { useMuntahaDrop };