import * as React from "react"; import trieMemoize from "trie-memoize"; import type { Dir, FileTree, FileTreeNode } from "./file-tree"; import { isDir } from "./file-tree"; import type { Subject } from "./tree/subject"; import { pureSubject } from "./tree/subject"; import type { WindowRef } from "./types"; import { useObserver } from "./use-observer"; import { retryWithBackoff, shallowEqual } from "./utils"; /** * A plugin hook for adding drag and drop to the file tree. * * @param fileTree - A file tree * @param config - Configuration options * @param config.windowRef - A React ref created by useRef() or an HTML element for the * container viewport you're rendering the list inside of. * @param config.dragOverExpandTimeout - Timeout for expanding a directory when a draggable * element enters it. */ export function useDnd( fileTree: FileTree, config: UseDndConfig ): UseDndPlugin { const storedConfig = React.useRef(config); const dnd = createDnd(fileTree); const storedTimeout = React.useRef<{ id: number; timeout: ReturnType | null; }>({ id: -1, timeout: null }); const storedDir = React.useRef(null); React.useEffect(() => { storedConfig.current = config; }); useObserver(dnd, (event) => { if (!event) return; if (event.type === "enter") { storedTimeout.current.timeout && clearTimeout(storedTimeout.current.timeout); storedTimeout.current.id = event.dir.id; storedDir.current = event.dir; storedTimeout.current.timeout = setTimeout(() => { if (!event.dir.expanded) { retryWithBackoff( () => fileTree.expand(event.dir).then(() => { if (event.dir === storedDir.current) { dnd.setState({ ...event, type: "expanded" }); } }), { shouldRetry() { return ( event.dir === storedDir.current && !fileTree.isExpanded(event.dir) ); }, } ).catch(() => {}); } }, storedConfig.current.dragOverExpandTimeout ?? DEFAULT_DRAG_OVER_EXPAND_TIMEOUT); } else if ( event.type === "end" || (event.type === "leave" && storedTimeout.current.id === event.dir.id) ) { storedDir.current = null; storedTimeout.current.timeout && clearTimeout(storedTimeout.current.timeout); } else if (event.type === "drop") { storedDir.current = null; storedTimeout.current.timeout && clearTimeout(storedTimeout.current.timeout); } }); React.useEffect(() => { const { windowRef } = storedConfig.current; const windowEl = windowRef && "current" in windowRef ? windowRef.current : windowRef; if (windowEl) { const handlers = createProps(dnd, fileTree.root); const isCurrentTarget = (event: Event) => { return ( event.currentTarget instanceof HTMLElement && (event.currentTarget === event.target || event.currentTarget.firstChild === event.target) ); }; const handleDragEnter = (event: Event) => { if (isCurrentTarget(event)) { // @ts-expect-error: technically incompatible types but that is ok handlers.onDragEnter(event); } }; const handleDragOver = (event: Event) => { if (isCurrentTarget(event)) { // @ts-expect-error: technically incompatible types but that is ok handlers.onDragOver(event); } }; const handleDragLeave = (event: Event) => { if (isCurrentTarget(event)) { // @ts-expect-error: technically incompatible types but that is ok handlers.onDragLeave(event); } }; const handleDrop = (event: Event) => { if (isCurrentTarget(event)) { // @ts-expect-error: technically incompatible types but that is ok handlers.onDrop(event); } }; windowEl.addEventListener("dragenter", handleDragEnter); windowEl.addEventListener("dragover", handleDragOver); windowEl.addEventListener("dragleave", handleDragLeave); windowEl.addEventListener("drop", handleDrop); return () => { windowEl.removeEventListener("dragenter", handleDragEnter); windowEl.removeEventListener("dragover", handleDragOver); windowEl.removeEventListener("dragleave", handleDragLeave); windowEl.removeEventListener("drop", handleDrop); }; } }, [dnd, fileTree.root]); return { didChange: dnd, getProps(nodeId) { const node = fileTree.getById(nodeId); if (!node) return empty; return createProps(dnd, node); }, }; } const DEFAULT_DRAG_OVER_EXPAND_TIMEOUT = 400; const empty = {}; const createProps = trieMemoize( [WeakMap, WeakMap], ( dnd: Subject | null>, node: FileTreeNode ): DndProps => ({ draggable: true, onDragStart() { dnd.setState({ type: "start", node }); }, onDragEnd() { dnd.setState({ type: "end", node }); }, onDragEnter() { const dir = isDir(node) ? node : node.parent; const state = dnd.getState(); if (dir && state?.node) { dnd.setState({ type: "enter", node: state.node, dir }); } }, onDragOver(event) { event.preventDefault(); }, onDragLeave() { const dir = isDir(node) ? node : node.parent; const state = dnd.getState(); if (dir && state && state.type === "enter" && state.node) { dnd.setState({ type: "leave", node: state.node, dir }); } }, onDrop() { const dir = isDir(node) ? node : node.parent; const state = dnd.getState(); if (dir && state?.node) { dnd.setState({ type: "drop", node: state.node, dir }); } }, }) ); const createDnd = trieMemoize([WeakMap], (fileTree: FileTree) => { return pureSubject | null>(null, shallowEqual); }); export type DndEvent = | { type: "start"; /** * The node that is being dragged */ node: FileTreeNode; } | { type: "end"; /** * The node that is being dragged */ node: FileTreeNode; } | { type: "enter"; /** * The node that is being dragged */ node: FileTreeNode; /** * The directory that the node is being dragged over */ dir: Dir; } | { type: "expanded"; /** * The node that is being dragged */ node: FileTreeNode; /** * The directory that the node is being dragged over */ dir: Dir; } | { type: "leave"; /** * The node that is being dragged */ node: FileTreeNode; /** * The directory that the node was being dragged over */ dir: Dir; } | { type: "drop"; /** * The node that is being dragged */ node: FileTreeNode; /** * The directory that the node is being dragged over */ dir: Dir; }; export interface DndProps { draggable: true; onDragStart: React.MouseEventHandler; onDragEnd: React.MouseEventHandler; onDragOver: React.MouseEventHandler; onDragEnter: React.MouseEventHandler; onDragLeave: React.MouseEventHandler; onDrop: React.MouseEventHandler; } export interface UseDndConfig { /** * Timeout for expanding a directory when a draggable element enters it. */ dragOverExpandTimeout?: number; /** * A React ref created by useRef() or an HTML element for the container viewport * you're rendering the list inside of. */ windowRef: WindowRef; } export interface UseDndPlugin { /** * A subject that emits drag 'n drop events. */ didChange: Subject | null>; /** * Get the drag 'n drop props for a given node ID. */ getProps: (nodeId: number) => DndProps | React.HTMLAttributes; }