UNPKG

chonky

Version:

A File Browser component for React

224 lines (210 loc) 7.87 kB
import { useCallback, useEffect, useMemo } from 'react'; import { DragSourceMonitor, DropTargetMonitor } from 'react-dnd'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { useDispatch, useSelector, useStore } from 'react-redux'; import { ExcludeKeys, Nullable } from 'tsdef'; import { EssentialActions } from '../action-definitions/essential'; import { ChonkyActions } from '../action-definitions/index'; import { selectCurrentFolder, selectFolderChain, selectInstanceId, selectSelectedFiles, } from '../redux/selectors'; import { thunkRequestFileAction } from '../redux/thunks/dispatchers.thunks'; import { StartDragNDropPayload } from '../types/action-payloads.types'; import { ChonkyDndDropResult, ChonkyDndFileEntryItem, ChonkyDndFileEntryType, } from '../types/dnd.types'; import { DndEntryState } from '../types/file-list.types'; import { FileData } from '../types/file.types'; import { useDragIfAvailable, useDropIfAvailable } from './dnd-fallback'; import { FileHelper } from './file-helper'; import { useInstanceVariable } from './hooks-helpers'; export const useFileDrag = (file: Nullable<FileData>) => { // Prepare the dnd payload const store = useStore(); const fileRef = useInstanceVariable(file); const getDndStartPayload = useCallback<() => StartDragNDropPayload>(() => { const reduxState = store.getState(); return { sourceInstanceId: selectInstanceId(reduxState), source: selectCurrentFolder(reduxState), // We force non-null type below because by convention, if drag & drop for // this file was possible, it must have been non-null. draggedFile: fileRef.current!, selectedFiles: selectSelectedFiles(reduxState), }; }, [store, fileRef]); // For drag source const dispatch = useDispatch(); const canDrag = useCallback(() => FileHelper.isDraggable(fileRef.current), [ fileRef, ]); const onDragStart = useCallback((): ChonkyDndFileEntryItem => { const item: ChonkyDndFileEntryItem = { type: ChonkyDndFileEntryType, payload: getDndStartPayload(), }; dispatch(thunkRequestFileAction(ChonkyActions.StartDragNDrop, item.payload)); return item; }, [dispatch, getDndStartPayload]); const onDragEnd = useCallback( (item: ChonkyDndFileEntryItem, monitor: DragSourceMonitor) => { const dropResult = monitor.getDropResult() as ChonkyDndDropResult; if ( !FileHelper.isDraggable(item.payload.draggedFile) || !dropResult || !dropResult.dropTarget ) { return; } dispatch( thunkRequestFileAction(ChonkyActions.EndDragNDrop, { ...item.payload, destination: dropResult.dropTarget, copy: dropResult.dropEffect === 'copy', }) ); }, [dispatch] ); // Create refs for react-dnd hooks const item = useMemo<ChonkyDndFileEntryItem>( () => ({ type: ChonkyDndFileEntryType, // Payload is actually added in `onDragStart` payload: {} as StartDragNDropPayload, }), [] ); const collect = useCallback(monitor => ({ isDragging: monitor.isDragging() }), []); const [{ isDragging: dndIsDragging }, drag, preview] = useDragIfAvailable({ item, canDrag, begin: onDragStart, // @ts-ignore end: onDragEnd, collect, }); useEffect(() => { // Set drag preview to an empty image because `DnDFileListDragLayer` will // provide its own preview. preview(getEmptyImage(), { captureDraggingState: true }); }, [preview]); return { dndIsDragging, drag }; }; interface UseFileDropParams { file: Nullable<FileData>; forceDisableDrop?: boolean; includeChildrenDrops?: boolean; } export const useFileDrop = ({ file, forceDisableDrop, includeChildrenDrops, }: UseFileDropParams) => { const folderChainRef = useInstanceVariable(useSelector(selectFolderChain)); const onDrop = useCallback( (_item: ChonkyDndFileEntryItem, monitor) => { if (!monitor.canDrop()) return; const customDropResult: ExcludeKeys<ChonkyDndDropResult, 'dropEffect'> = { dropTarget: file, }; return customDropResult; }, [file] ); const canDrop = useCallback( (item: ChonkyDndFileEntryItem, monitor: DropTargetMonitor) => { if ( forceDisableDrop || !FileHelper.isDroppable(file) || (!monitor.isOver({ shallow: true }) && !includeChildrenDrops) ) { return false; } const { source, draggedFile, selectedFiles } = item.payload; // We prevent folders from being dropped into themselves. We also prevent // any folder from current folder chain being moved - we can't move the // folder that we are currently in. const prohibitedFileIds = new Set<string>(); prohibitedFileIds.add(file.id); folderChainRef.current.map(folder => { if (folder) prohibitedFileIds.add(folder.id); }); const movedFiles: FileData[] = [draggedFile, ...selectedFiles]; for (const currFile of movedFiles) { if (prohibitedFileIds.has(currFile.id)) return false; } // Finally, prohibit files from being moved into their parent folder // (which is a no-op). return file.id !== source?.id; }, [forceDisableDrop, file, includeChildrenDrops, folderChainRef] ); const collect = useCallback( monitor => ({ isOver: monitor.isOver(), isOverCurrent: monitor.isOver({ shallow: true }), canDrop: monitor.canDrop(), }), [] ); const [ { isOver: dndIsOver, isOverCurrent: dndIsOverCurrent, canDrop: dndCanDrop }, drop, ] = useDropIfAvailable({ accept: ChonkyDndFileEntryType, drop: onDrop, canDrop, collect, }); return { dndIsOver, dndIsOverCurrent, dndCanDrop, drop }; }; export const useFileEntryDnD = (file: Nullable<FileData>) => { const { dndIsDragging, drag } = useFileDrag(file); const { dndIsOver, dndCanDrop, drop } = useFileDrop({ file }); const dndState = useMemo<DndEntryState>( () => ({ dndIsDragging, dndIsOver, dndCanDrop, }), [dndCanDrop, dndIsDragging, dndIsOver] ); return { drop, drag, dndState, }; }; export const useDndHoverOpen = (file: Nullable<FileData>, dndState: DndEntryState) => { const dispatch = useDispatch(); const currentFolderRef = useInstanceVariable(useSelector(selectCurrentFolder)); useEffect(() => { let timeout: Nullable<any> = null; if ( dndState.dndIsOver && FileHelper.isDndOpenable(file) && file.id !== currentFolderRef.current?.id ) { timeout = setTimeout( () => dispatch( thunkRequestFileAction(EssentialActions.OpenFiles, { targetFile: file, files: [file], }) ), // TODO: Make this timeout configurable 1500 ); } return () => { if (timeout) clearTimeout(timeout); }; }, [dispatch, file, dndState.dndIsOver, currentFolderRef]); };