import {fromEvent} from "file-selector"; import type {FileWithPath} from "file-selector"; import type * as React from "react"; import {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef} from "react"; import { acceptPropAsAcceptAttr, canUseFileSystemAccessAPI, composeEventHandlers, ErrorCode, evaluateDragFiles, fileAccepted, fileMatchSize, flattenAccept, isAbort, isEvtWithFiles, isIeOrEdge, isNotAllowedError, isThenable, isPropagationStopped, isSecurityError, onDocumentDragOver, pickerOptionsFromAccept, TOO_MANY_FILES_REJECTION } from "./utils"; import type { Accept, AcceptGroup, DragFileRejection as UtilsDragFileRejection, FileError, ValidatorResult } from "./utils"; export type {Accept, AcceptGroup, FileError, FileWithPath, ValidatorResult}; export {ErrorCode}; export interface DropzoneProps extends DropzoneOptions { children?: (state: DropzoneState) => React.ReactElement; } export interface FileRejection { file: FileWithPath; errors: readonly FileError[]; } export type DragFileRejection = UtilsDragFileRejection; type SharedProps = "multiple" | "onDragEnter" | "onDragOver" | "onDragLeave"; export type DropzoneOptions = Pick, SharedProps> & { accept?: Accept | AcceptGroup[]; minSize?: number; maxSize?: number; maxFiles?: number; preventDropOnDocument?: boolean; noClick?: boolean; noKeyboard?: boolean; noDrag?: boolean; noDragEventsBubbling?: boolean; /** * If true, disables paste-to-upload. By default, when the dropzone (or a focused child) receives a * paste that carries files - e.g. a screenshot pasted with Ctrl/Cmd+V - those files go through the * same `accept`/size/`validator` checks and `onDrop` callbacks as a drop. Pastes with no files * (plain text, etc.) are ignored and left untouched. See * https://github.com/react-dropzone/react-dropzone/issues/1210 */ noPaste?: boolean; disabled?: boolean; onDrop?: (acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void; onDropAccepted?: (files: T[], event: DropEvent) => void; onDropRejected?: (fileRejections: FileRejection[], event: DropEvent) => void; getFilesFromEvent?: (event: DropEvent | Array) => Promise>; onFileDialogCancel?: () => void; onFileDialogOpen?: () => void; onError?: (err: Error) => void; /** * Custom validation, run once per file on drop/selection. Return `null` to accept the file, or a * {@link FileError} (or array of them) to reject it. May be `async` (return a `Promise`) to support * checks that can't run synchronously - e.g. reading image dimensions, inspecting file contents, * or calling an external service. While an async validator is pending, {@link DropzoneState.isProcessing} * is `true`, and `onDrop`/`onDropAccepted`/`onDropRejected` fire only once it settles. If the * validator throws or rejects, `onError` is called and the drop is discarded. * * Note: the validator never runs during a drag (a `DataTransferItem` has no name/size), so a * validator-configured dropzone is `isDragUnknown` until drop. */ validator?: (file: T) => ValidatorResult | Promise; /** * Override the message of any rejection error (built-in or custom). Called once per error; * receives the error and the file it belongs to and returns the message to use. Return * `error.message` for codes you don't want to change. Useful for localizing error messages. */ getErrorMessage?: (error: FileError, file: File) => string; useFsAccessApi?: boolean; autoFocus?: boolean; }; export type DropEvent = | React.DragEvent | React.ChangeEvent | React.ClipboardEvent | DragEvent | ClipboardEvent | Event; export interface DropzoneRef { open: () => void; } export type DropzoneState = DropzoneRef & { isFocused: boolean; isDragActive: boolean; isDragAccept: boolean; isDragReject: boolean; isDragUnknown: boolean; isDragGlobal: boolean; isFileDialogActive: boolean; /** * `true` while a drop/selection is being processed asynchronously - i.e. while `getFilesFromEvent` * reads the files and/or an async {@link DropzoneOptions.validator} runs. Spans the whole pipeline, * from when files start being read until validation settles. When both are synchronous (the default * `getFilesFromEvent` with no/async-free validator) the work resolves within a microtask, so it's * only observable for genuinely async work. Use it to show a spinner or disable UI while processing. */ isProcessing: boolean; acceptedFiles: readonly FileWithPath[]; fileRejections: readonly FileRejection[]; /** * Rejections that can be determined before drop. Standard drag events expose * `DataTransferItem`s with a MIME type but no name or size, so extension, size, and custom * validator failures are only available in {@link fileRejections} after drop. In practice this * state can report MIME-type and surplus-file errors during a standard browser drag. */ dragFileRejections: readonly DragFileRejection[]; rootRef: React.RefObject; inputRef: React.RefObject; getRootProps: (props?: T) => T; getInputProps: (props?: T) => T; }; export interface DropzoneRootProps extends React.HTMLAttributes { refKey?: string; [key: string]: any; } export interface DropzoneInputProps extends React.InputHTMLAttributes { refKey?: string; } /** * Convenience wrapper component for the `useDropzone` hook * * ```jsx * * {({getRootProps, getInputProps}) => ( *
* *

Drag 'n' drop some files here, or click to select files

*
* )} *
* ``` */ const Dropzone: React.ForwardRefExoticComponent> = forwardRef< DropzoneRef, DropzoneProps >(({children, ...params}, ref) => { const {open, ...props} = useDropzone(params); useImperativeHandle(ref, () => ({open}), [open]); return <>{children?.({...props, open})}; }); Dropzone.displayName = "Dropzone"; export default Dropzone; interface DropzoneInternalState { isFocused: boolean; isFileDialogActive: boolean; isDragActive: boolean; isDragAccept: boolean; isDragReject: boolean; isDragUnknown: boolean; isDragGlobal: boolean; isProcessing: boolean; acceptedFiles: FileWithPath[]; fileRejections: FileRejection[]; dragFileRejections: DragFileRejection[]; } /** * The per-file outcome of the built-in checks plus the (resolved) custom validator, assembled in * setFiles before the accepted/rejected split. */ interface PerFileResult { file: FileWithPath; accepted: boolean; acceptError: FileError | null; sizeMatch: boolean; sizeError: FileError | null; customErrors: ValidatorResult; } const initialState: DropzoneInternalState = { isFocused: false, isFileDialogActive: false, isDragActive: false, isDragAccept: false, isDragReject: false, isDragUnknown: false, isDragGlobal: false, isProcessing: false, acceptedFiles: [], fileRejections: [], dragFileRejections: [] }; /** * A React hook that creates a drag 'n' drop area. * * ```jsx * function MyDropzone(props) { * const {getRootProps, getInputProps} = useDropzone({ * onDrop: acceptedFiles => { * // do something with the File objects, e.g. upload to some server * } * }); * return ( *
* *

Drag and drop some files here, or click to select files

*
* ) * } * ``` */ export function useDropzone(props: DropzoneOptions = {}): DropzoneState { const { accept, disabled = false, getFilesFromEvent = fromEvent, maxSize = Number.POSITIVE_INFINITY, minSize = 0, multiple = true, maxFiles = 0, onDragEnter, onDragLeave, onDragOver, onDrop, onDropAccepted, onDropRejected, onFileDialogCancel, onFileDialogOpen, useFsAccessApi = false, autoFocus = false, preventDropOnDocument = true, noClick = false, noKeyboard = false, noDrag = false, noDragEventsBubbling = false, noPaste = false, onError, validator, getErrorMessage } = props; // `accept` may be a MIME->extensions map or an array of labeled groups (for the FS Access // picker). Flatten it to a single map for the native `` and the drag/drop validators, // which have no concept of groups; the picker keeps the groups via `pickerTypes` below. const flatAccept = useMemo(() => flattenAccept(accept), [accept]); // `acceptAttr` keeps wildcard MIME types (e.g. `image/*`) so the drag-time // `isDragAccept`/`isDragReject` check can react to a file's MIME type - file names // (hence extensions) aren't readable during a drag. const acceptAttr = useMemo(() => acceptPropAsAcceptAttr(flatAccept), [flatAccept]); // `inputAcceptAttr` drops a wildcard MIME type when it is paired with extensions, so the // native picker and drop-time validation enforce the extensions instead of accepting any // file of that type. See https://github.com/react-dropzone/react-dropzone/issues/1220 const inputAcceptAttr = useMemo( () => acceptPropAsAcceptAttr(flatAccept, { omitWildcardMimeTypesWithExtensions: true }), [flatAccept] ); const pickerTypes = useMemo(() => pickerOptionsFromAccept(accept), [accept]); const onFileDialogOpenCb = useMemo<(...args: any[]) => void>( () => (typeof onFileDialogOpen === "function" ? onFileDialogOpen : noop), [onFileDialogOpen] ); const onFileDialogCancelCb = useMemo<(...args: any[]) => void>( () => (typeof onFileDialogCancel === "function" ? onFileDialogCancel : noop), [onFileDialogCancel] ); const rootRef = useRef(null); const inputRef = useRef(null); const [state, dispatch] = useReducer(reducer, initialState); const {isFocused, isFileDialogActive} = state; // Mirror {isFileDialogActive} into a ref so the memoized drag handlers can read the current value // without being recreated (and churning getRootProps/getInputProps) every time the dialog toggles. const isFileDialogActiveRef = useRef(isFileDialogActive); isFileDialogActiveRef.current = isFileDialogActive; // Tracks the in-flight processing run - reading files (getFilesFromEvent) plus running an async // validator. A newer drop/selection aborts the previous run so slow async work can't resolve late // and clobber the state with stale results. const processingAbortRef = useRef(null); // Begin a processing run: supersede any run still in flight and flip {isProcessing} on. Returns // the run's AbortSignal, which downstream async steps check to bail if a newer run took over. const beginProcessing = useCallback(() => { processingAbortRef.current?.abort(); const controller = new AbortController(); processingAbortRef.current = controller; dispatch({type: "setProcessing", isProcessing: true}); return controller.signal; }, []); // End a processing run by clearing {isProcessing} - but only if this run is still the active one. // A superseded run (signal aborted) leaves the flag to the run that replaced it. const endProcessing = useCallback((signal: AbortSignal) => { if (!signal.aborted) { dispatch({type: "setProcessing", isProcessing: false}); } }, []); const fsAccessApiWorksRef = useRef( typeof window !== "undefined" && window.isSecureContext && useFsAccessApi && canUseFileSystemAccessAPI() ); // Update file dialog active state when the window is focused on const onWindowFocus = () => { // Execute the timeout only if the file dialog is opened in the browser if (!fsAccessApiWorksRef.current && isFileDialogActive) { setTimeout(() => { if (inputRef.current) { const {files} = inputRef.current; if (!files?.length) { dispatch({type: "closeDialog"}); onFileDialogCancelCb(); } } }, 300); } }; useEffect(() => { window.addEventListener("focus", onWindowFocus, false); return () => { window.removeEventListener("focus", onWindowFocus, false); }; }, [inputRef, isFileDialogActive, onFileDialogCancelCb, fsAccessApiWorksRef]); const dragTargetsRef = useRef([]); const globalDragTargetsRef = useRef([]); const onDocumentDrop = (event: DragEvent) => { // This is a document-level, bubble-phase listener, so it runs *after* the event has already // bubbled through the dropzone root. If the drop landed inside the root and the instance's own // onDrop handler already prevented the default, there's nothing left to do. // // We must NOT bail out on `contains()` alone: when the dropzone is `disabled` or has `noDrag`, // the root has no onDrop handler, so nothing prevents the browser's default action and the file // is opened in the tab. Falling through to `preventDefault()` here keeps that from happening. // See https://github.com/react-dropzone/react-dropzone/issues/1362 if (rootRef.current && event.target && rootRef.current.contains(event.target as Node) && event.defaultPrevented) { return; } event.preventDefault(); dragTargetsRef.current = []; }; useEffect(() => { if (preventDropOnDocument) { document.addEventListener("dragover", onDocumentDragOver, false); document.addEventListener("drop", onDocumentDrop, false); } return () => { if (preventDropOnDocument) { document.removeEventListener("dragover", onDocumentDragOver); document.removeEventListener("drop", onDocumentDrop); } }; }, [rootRef, preventDropOnDocument]); // Track global drag state for document-level drag events useEffect(() => { const onDocumentDragEnter = (event: DragEvent) => { if (event.target) { globalDragTargetsRef.current = [...globalDragTargetsRef.current, event.target]; } if (isEvtWithFiles(event)) { dispatch({isDragGlobal: true, type: "setDragGlobal"}); } }; const onDocumentDragLeave = (event: DragEvent) => { // Only deactivate once we've left all children globalDragTargetsRef.current = globalDragTargetsRef.current.filter(el => el !== event.target && el !== null); if (globalDragTargetsRef.current.length > 0) { return; } dispatch({isDragGlobal: false, type: "setDragGlobal"}); }; const onDocumentDragEnd = () => { globalDragTargetsRef.current = []; dispatch({isDragGlobal: false, type: "setDragGlobal"}); }; const onDocumentDropGlobal = () => { globalDragTargetsRef.current = []; dispatch({isDragGlobal: false, type: "setDragGlobal"}); }; document.addEventListener("dragenter", onDocumentDragEnter, false); document.addEventListener("dragleave", onDocumentDragLeave, false); document.addEventListener("dragend", onDocumentDragEnd, false); document.addEventListener("drop", onDocumentDropGlobal, false); return () => { document.removeEventListener("dragenter", onDocumentDragEnter); document.removeEventListener("dragleave", onDocumentDragLeave); document.removeEventListener("dragend", onDocumentDragEnd); document.removeEventListener("drop", onDocumentDropGlobal); }; }, [rootRef]); // Auto focus the root when autoFocus is true useEffect(() => { if (!disabled && autoFocus && rootRef.current) { rootRef.current.focus(); } return () => {}; }, [rootRef, autoFocus, disabled]); const onErrCb = useCallback( (e: Error) => { if (onError) { onError(e); } else { // Let the user know something's gone wrong if they haven't provided the onError cb. console.error(e); } }, [onError] ); const onDragEnterCb = useCallback( (event: any) => { event.preventDefault(); // Persist here because we need the event later after getFilesFromEvent() is done event.persist?.(); stopPropagation(event); // Ignore drags onto the dropzone while the file picker dialog is open: the page underneath a // live picker shouldn't react to (or accept) dropped files. See #1455. preventDefault() above // still runs so the browser doesn't try to open/navigate to a dropped file. if (isFileDialogActiveRef.current) { return; } dragTargetsRef.current = [...dragTargetsRef.current, event.target]; if (isEvtWithFiles(event)) { Promise.resolve(getFilesFromEvent(event)) .then(files => { if (isPropagationStopped(event) && !noDragEventsBubbling) { return; } // During a drag we only have DataTransferItems (MIME type, no name/size), so the // custom validator can't run yet - a validator-configured dropzone is "unknown" until // drop rather than a misleading accept/reject. The verdict and rejection details come // from one evaluation so the two states cannot disagree. const evaluation = files.length > 0 ? evaluateDragFiles({ files: files as Array, accept: acceptAttr, minSize, maxSize, multiple, maxFiles, validator, getErrorMessage }) : null; dispatch({ isDragAccept: evaluation?.verdict === "accept", isDragReject: evaluation?.verdict === "reject", isDragUnknown: evaluation?.verdict === "unknown", isDragActive: true, dragFileRejections: evaluation?.rejections ?? [], type: "setDraggedFiles" }); if (onDragEnter) { onDragEnter(event); } }) .catch(e => onErrCb(e)); } }, [ getFilesFromEvent, onDragEnter, onErrCb, noDragEventsBubbling, acceptAttr, minSize, maxSize, multiple, maxFiles, validator, getErrorMessage ] ); const onDragOverCb = useCallback( (event: any) => { event.preventDefault(); event.persist?.(); stopPropagation(event); // Ignore drags over the dropzone while the file picker dialog is open. See #1455. if (isFileDialogActiveRef.current) { return false; } const hasFiles = isEvtWithFiles(event); if (hasFiles && event.dataTransfer) { try { event.dataTransfer.dropEffect = "copy"; } catch { /* no-op */ } } if (hasFiles && onDragOver) { onDragOver(event); } return false; }, [onDragOver, noDragEventsBubbling] ); const onDragLeaveCb = useCallback( (event: any) => { event.preventDefault(); event.persist?.(); stopPropagation(event); // Only deactivate once the dropzone and all children have been left const targets = dragTargetsRef.current.filter(target => rootRef.current?.contains(target as Node)); // Make sure to remove a target present multiple times only once // (Firefox may fire dragenter/dragleave multiple times on the same element) const targetIdx = targets.indexOf(event.target); if (targetIdx !== -1) { targets.splice(targetIdx, 1); } dragTargetsRef.current = targets; if (targets.length > 0) { return; } dispatch({ type: "setDraggedFiles", isDragActive: false, isDragAccept: false, isDragReject: false, isDragUnknown: false, dragFileRejections: [] }); if (isEvtWithFiles(event) && onDragLeave) { onDragLeave(event); } }, [rootRef, onDragLeave, noDragEventsBubbling] ); const setFiles = useCallback( async (files: FileWithPath[], event: any, signal: AbortSignal) => { const localizeError = (error: FileError, file: File): FileError => getErrorMessage ? {...error, message: getErrorMessage(error, file)} : error; // Commit the per-file verdicts: split accepted/rejected, cap the surplus, update state and // fire the onDrop callbacks. Runs synchronously so nested-dropzone ordering is preserved on // the fast path (an onDrop handler calling stopPropagation must do so before a parent's onDrop // check - see the noDragEventsBubbling tests). const commit = (results: Array) => { const acceptedFiles: FileWithPath[] = []; const fileRejections: FileRejection[] = []; results.forEach(({file, accepted, acceptError, sizeMatch, sizeError, customErrors}) => { if (accepted && sizeMatch && !customErrors) { acceptedFiles.push(file); } else { let errors: Array = [acceptError, sizeError]; if (customErrors) { errors = errors.concat(customErrors); } fileRejections.push({ file, errors: errors.filter((e): e is FileError => e != null).map(error => localizeError(error, file)) }); } }); // Cap the accepted files at the configured limit and reject only the surplus (the files past // the limit) with a too-many-files error, instead of rejecting the whole batch. The limit is 1 // when {multiple} is false, and {maxFiles} when {multiple} is true (0 means no limit). Files // that already failed the per-file checks above are in {fileRejections} and don't count here. // See https://github.com/react-dropzone/react-dropzone/issues/1355 // and https://github.com/react-dropzone/react-dropzone/issues/1358 const acceptedFilesLimit = multiple ? (maxFiles >= 1 ? maxFiles : Number.POSITIVE_INFINITY) : 1; if (acceptedFiles.length > acceptedFilesLimit) { const surplusFiles = acceptedFiles.splice(acceptedFilesLimit); surplusFiles.forEach(file => { fileRejections.push({file, errors: [localizeError(TOO_MANY_FILES_REJECTION, file)]}); }); } // Clears isProcessing back to false (see the reducer) in the same update that sets the files. dispatch({ acceptedFiles, fileRejections, type: "setFiles" }); if (onDrop) { onDrop(acceptedFiles, fileRejections, event); } if (fileRejections.length > 0 && onDropRejected) { onDropRejected(fileRejections, event); } if (acceptedFiles.length > 0 && onDropAccepted) { onDropAccepted(acceptedFiles, event); } }; // Run the built-in checks synchronously and invoke the validator (which may return a value or // a Promise). customErrors is left as-is here so we can tell sync from async below. const pending = files.map(file => { const [accepted, acceptError] = fileAccepted(file, inputAcceptAttr); const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize); const customErrors = validator ? validator(file) : null; return {file, accepted, acceptError, sizeMatch, sizeError, customErrors}; }); // Callers check signal.aborted right before invoking setFiles (synchronously, no await in // between), so this run is guaranteed live here - the supersession guards below only matter // after we await the validator. // Fast path: no validator, or a synchronous one. Commit synchronously - no extra microtask // hop, so nested-dropzone ordering is preserved (an onDrop handler calling stopPropagation // must run before a parent's onDrop check - see the noDragEventsBubbling tests). commit's // dispatch also clears isProcessing. if (!pending.some(({customErrors}) => isThenable(customErrors))) { commit(pending as Array); return; } // Async path: at least one validator returned a Promise. isProcessing is already on (set when // the run began, before getFilesFromEvent); keep guarding against supersession while we await. let results: Array; try { results = await Promise.all( pending.map(async ({customErrors, ...rest}) => ({...rest, customErrors: await customErrors})) ); } catch (e) { // A validator threw/rejected. If a newer run already superseded this one, let it own the // state; otherwise clear the processing flag and report the error via onError. if (!signal.aborted) { endProcessing(signal); onErrCb(e as Error); } return; } // A newer drop landed while we were validating - discard these stale results. if (signal.aborted) { return; } commit(results); }, [ dispatch, multiple, inputAcceptAttr, minSize, maxSize, maxFiles, onDrop, onDropAccepted, onDropRejected, validator, getErrorMessage, onErrCb, endProcessing ] ); const onDropCb = useCallback( (event: any) => { event.preventDefault(); // Persist here because we need the event later after getFilesFromEvent() is done event.persist?.(); stopPropagation(event); dragTargetsRef.current = []; // Ignore a drop landing on the dropzone while the file picker dialog is open (see #1455). // Guard on {event.dataTransfer} so only real drag-drops are suppressed: the input's change // event (a file picked from the dialog) also runs through here while the dialog is still // flagged active, and that path must keep working. Returning before the reset below leaves // the dialog flag intact. if (isFileDialogActiveRef.current && event.dataTransfer) { return; } // Clear drag state before we begin processing so beginProcessing's isProcessing isn't reset. dispatch({type: "reset"}); if (isEvtWithFiles(event)) { // Processing spans reading the files (getFilesFromEvent) and running the validator. const signal = beginProcessing(); Promise.resolve(getFilesFromEvent(event)) .then(files => { // A newer drop superseded this one while reading files - it owns isProcessing now. if (signal.aborted) { return; } if (isPropagationStopped(event) && !noDragEventsBubbling) { endProcessing(signal); return; } // setFiles handles validator errors internally (routing them to onError); the outer // catch here only fires for a getFilesFromEvent failure. return setFiles(files as FileWithPath[], event, signal); }) .catch(e => { if (!signal.aborted) { endProcessing(signal); onErrCb(e); } }); } }, [getFilesFromEvent, setFiles, onErrCb, noDragEventsBubbling, beginProcessing, endProcessing] ); // Cb to add files pasted into the dropzone (e.g. a screenshot pasted with Ctrl/Cmd+V). Fires when // the root - or a focused descendant, so a child