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
JavaScript
'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 };