import { bubble } from "@web3r/flowerkit/evt"; import { useEffect, useRef, useState } from "react"; import type { RefObject } from "react"; import { getElRefHelper } from "../../../utils/react/getElRefHelper"; import type { IInputRef } from "../../input"; import type { IFilesUploaderItem, IUseFilesUploaderResult, TOnFileDelete, TOnFilesChange, IOnFilesChangeArg, TDOMDragEvent, TInputChangeEvent, TMaxSizeTypes, } from "../config/types"; import { disableEvent, getConvertedFilesArrayToMap, getIfFilesValid, setEachFileIntoInput, } from "./index"; const isPromiseLike = (value: unknown): value is PromiseLike => { return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function"; }; /** * Хук компонента загрузчика файлов, инкапсулирующий основную логику взаимодействия с пользователем * @returns {IUseFilesUploaderResult} */ export const useFilesUploader = ( initialFiles: IFilesUploaderItem[] = [], isDisabled: boolean = false, fileTypes: string[] = [], maxSize: number = Infinity, maxFiles: number = Infinity, hiddenInputRef: RefObject | null = null, inputRef: RefObject | null = null, onFilesChange: TOnFilesChange | null = null, onFileDelete: TOnFileDelete | null = null, maxSizeType: TMaxSizeTypes = "all" ): IUseFilesUploaderResult => { // Loading state (async operations) const [ isLoading, setIsLoading ] = useState(false); // Error state const [ isError, setIsError ] = useState(false); // Dragging state const [ isDragging, setIsDragging ] = useState(false); // Current files state const [ filesMap, setFilesMap ] = useState | File>>(() => getConvertedFilesArrayToMap(initialFiles)); // Drag count ref const dragCounterDeepRef = useRef(0); // Prev files state const prevFilesMap = useRef | File>>(filesMap); useEffect(() => { if (prevFilesMap.current !== filesMap) { const hiddenInputEl = getElRefHelper(hiddenInputRef); setIsError(false); const currentFiles: (Required | File)[] = [ ...filesMap.values() ]; const setFiles = (files: Array) => setEachFileIntoInput(files, hiddenInputEl as HTMLInputElement); if (typeof onFilesChange === "function") { // custom user for inserting to input const arg: IOnFilesChangeArg = { ref: hiddenInputRef, data: { files: currentFiles, prevFiles: prevFilesMap.current, dragCounter: dragCounterDeepRef.current, }, stateActions: { /** Перезапись файлов в скрытое поле */ setFiles: async (files = new Map()) => { const filesArray = Array.isArray(files) ? files : [ ...filesMap.values() ]; await setFiles(filesArray); }, setIsError, setIsLoading, setIsDragging, }, }; try { const result = onFilesChange(arg); if (isPromiseLike(result)) { setIsLoading(true); Promise.resolve(result) .catch((error) => { console.error(`[FilesUploader] Something is wrong while loading files to input`, error); setIsError(true); }) .finally(() => { setIsLoading(false); prevFilesMap.current = filesMap; }); } else { prevFilesMap.current = filesMap; } } catch (error) { console.error(`[FilesUploader] Something is wrong while loading files to input`, error); setIsError(true); prevFilesMap.current = filesMap; } } else { // non-user fn setIsLoading(true); setFiles(currentFiles) .catch((error) => { // Невозможно присвоить файлы в инпут в тестовой среде if (process.env.NODE_ENV === "test") { return; } console.error(`[FilesUploader] Something is wrong while loading files to input`, error); setIsError(true); }) .finally(() => { setIsLoading(false); prevFilesMap.current = filesMap; // fix for live validation if (hiddenInputEl && (hiddenInputEl as HTMLInputElement)?.files?.length) { bubble(hiddenInputEl, "blur"); } }); } } }, [ filesMap ]); const handleDragStart = (e: TDOMDragEvent): void => { e.dataTransfer?.clearData(); }; const handleDragEnter = (e: TDOMDragEvent): void => { disableEvent(e as Event); dragCounterDeepRef.current++; const items = e.dataTransfer?.items; if (!isDisabled && !!items && items.length) { setIsDragging(true); } }; const handleDragLeave = (e: TDOMDragEvent): void => { disableEvent(e as Event); dragCounterDeepRef.current--; if (dragCounterDeepRef.current <= 0) { setIsDragging(false); } }; const handleDragOver = (e: TDOMDragEvent): void => { disableEvent(e as Event); }; const handleDrop = (e: TDOMDragEvent): void => { disableEvent(e as Event); if (isDisabled) { return; } setIsDragging(false); const files: File[] = [ ...(e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : []) ]; dragCounterDeepRef.current = 0; if (files.length) { addFilesToFilesMap(files); } }; const handleChangeFiles = (e: TInputChangeEvent): void => { const rawFiles = e.target.files; const files: File[] = rawFiles ? Array.from(rawFiles) : []; const input = getElRefHelper(inputRef) as HTMLInputElement | null; if (files.length) { addFilesToFilesMap(files); } if (input) { input.value = ""; } }; const addFilesToFilesMap = (files: File[]): void => { const filesForValidation = (() => { if (maxSizeType === "every") { return files; } const nextFilesMap = new Map(filesMap); files.forEach((file) => { nextFilesMap.set(file.name, file); }); return Array.from(nextFilesMap.values()); })(); const isValid = getIfFilesValid(filesForValidation, maxSize, fileTypes, maxSizeType); setIsError(!isValid); if (isValid) { updateFilesMap(files); } }; const updateFilesMap = (files: File[]): void => { if (!files.length) { return; } if (maxFiles === 1) { if (files.length > 1) { setIsError(true); return; } const newFiles = new Map | File>(); const [ file ] = files; newFiles.set(file.name, file); setIsError(false); setFilesMap(newFiles); } else { const nextFilesMap = new Map(filesMap); files.forEach((file) => nextFilesMap.set(file.name, file)); if (nextFilesMap.size > maxFiles) { setIsError(true); return; } setIsError(false); setFilesMap(nextFilesMap); } }; const deleteFilesFromMap = (files: Array, "size" | "name">> = []): void => { if (files.length) { const newMap = new Map(filesMap); files.forEach(({ name, size }) => { if (newMap.get(name)?.size === size) { newMap.delete(name); } }); setFilesMap(newMap); } }; const handleFileItemDeleteClick = async (file: Required | File) => { const { name, size } = file || {}; if (name && typeof size === "number") { if (typeof onFileDelete === "function") { setIsError(false); try { const result = onFileDelete({ data: { file, dragCounter: dragCounterDeepRef, }, stateActions: { setIsError, setIsDragging, setFiles: setFilesMap, }, }); if (!isPromiseLike(result)) { return Promise.resolve(); } setIsLoading(true); return Promise.resolve(result) .catch((error) => { setIsError(true); console.error(`[FilesUploader] Something is wrong while deleting files`, error); }) .finally(() => { setIsLoading(false); }); } catch (error) { setIsError(true); console.error(`[FilesUploader] Something is wrong while deleting files`, error); return Promise.resolve(); } } else { deleteFilesFromMap([ { name, size } ]); return Promise.resolve(); } } else { console.error(`[FilesUploader] Can't parse file name or size`); return Promise.reject(); } }; return { isLoading, isDragging, isError, filesMap, setFilesMap, handleDragStart, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleChangeFiles, handleFileItemDeleteClick, }; };