import * as React from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { DragAndDropContextProps, DraggingPosition, TreeItem, TreeItemIndex } from "../types"; import { useTreeEnvironment } from "./ControlledTreeEnvironment"; import { useOnDragEnterTreeItemHandler, useOnDragLeaveTreeHandler } from "./useOnDragOverTreeHandler"; import { useCanDropAt } from "./useCanDropAt"; import { useGetViableDragPositions } from "./useGetViableDragPositions"; import { useSideEffect } from "../useSideEffect"; import { buildMapForTrees } from "../utils"; import { useCallSoon } from "../useCallSoon"; import { computeItemHeight } from "./layoutUtils"; import { useStableHandler } from "../use-stable-handler"; const DragAndDropContext = React.createContext(null as any); export const useDragAndDrop = () => React.useContext(DragAndDropContext); let currentDragOverEl: Element | null = null; // TODO tidy up export const DragAndDropProvider: React.FC = ({ children }) => { const environment = useTreeEnvironment(); const [isProgrammaticallyDragging, setIsProgrammaticallyDragging] = useState(false); const [itemHeight, setItemHeight] = useState(39); const [viableDragPositions, setViableDragPositions] = useState<{ [treeId: string]: DraggingPosition[]; }>({}); const [programmaticDragIndex, setProgrammaticDragIndex] = useState(0); const [draggingItems, setDraggingItems] = useState(); const draggingPositionRef = useRef(); const getViableDragPositions = useGetViableDragPositions(); const callSoon = useCallSoon(); const dragCodeRef = useRef("_nodrag"); const dragCountRef = useRef(0); const setDragCode = useCallback((value: string): boolean => { const hasChanged = dragCodeRef.current !== value; dragCodeRef.current = value; return hasChanged; }, []); const setDraggingPosition = useCallback((value: DraggingPosition | undefined, target?: HTMLElement) => { draggingPositionRef.current = value; const itemId = value?.targetItem; if (!itemId && currentDragOverEl) { currentDragOverEl.classList.remove("rct-tree-item-li-dragging-over"); currentDragOverEl = null; } if (itemId && target) { if (currentDragOverEl) { currentDragOverEl.classList.remove("rct-tree-item-li-dragging-over"); } if (target.dataset.rctItemId === itemId) { target.classList.add("rct-tree-item-li-dragging-over"); currentDragOverEl = target; } else { const el = target.closest(`li[data-rct-item-id="${itemId}"]`); if (el) { el.classList.add("rct-tree-item-li-dragging-over"); currentDragOverEl = el; } } } }, []); const incrementDragCount = useCallback( (decrement?: boolean) => { const newValue = dragCountRef.current + (decrement ? -1 : 1); dragCountRef.current = newValue; if (newValue === 0) { setDraggingPosition(undefined); dragCodeRef.current = "outside"; } }, [setDraggingPosition] ); const resetProgrammaticDragIndexForCurrentTree = useCallback( (draggingItems: TreeItem[] | undefined) => { if ( environment.activeTreeId && environment.viewState[environment.activeTreeId]?.focusedItem && environment.linearItems && draggingItems ) { const focusItem = environment.viewState[environment.activeTreeId]!.focusedItem; const treeDragPositions = getViableDragPositions(environment.activeTreeId, draggingItems); const newPos = treeDragPositions.findIndex((pos) => { if (pos.targetType === "item") { return pos.targetItem === focusItem; } return false; }); if (newPos) { setProgrammaticDragIndex(Math.min(newPos + 1, treeDragPositions.length - 1)); } else { setProgrammaticDragIndex(0); } } else { setProgrammaticDragIndex(0); } }, [environment.activeTreeId, environment.linearItems, environment.viewState, getViableDragPositions] ); const resetState = useCallback(() => { dragCountRef.current = 0; dragCodeRef.current = "_nodrag"; setDraggingPosition(undefined); setIsProgrammaticallyDragging(false); setViableDragPositions({}); setProgrammaticDragIndex(0); setDraggingItems(undefined); }, [setDraggingPosition]); useSideEffect( () => { if ( environment.activeTreeId && environment.linearItems[environment.activeTreeId] && viableDragPositions[environment.activeTreeId] ) { resetProgrammaticDragIndexForCurrentTree(draggingItems); } }, [ draggingItems, environment.activeTreeId, environment.linearItems, resetProgrammaticDragIndexForCurrentTree, viableDragPositions, ], [environment.activeTreeId] ); useSideEffect( () => { if (isProgrammaticallyDragging && environment.activeTreeId) { setDraggingPosition(viableDragPositions[environment.activeTreeId][programmaticDragIndex]); } }, [programmaticDragIndex, environment.activeTreeId, isProgrammaticallyDragging, viableDragPositions], [programmaticDragIndex, environment.activeTreeId] ); const canDropAt = useCanDropAt(); const performDrag = (draggingPosition: DraggingPosition, target?: HTMLElement) => { if (draggingItems && !canDropAt(draggingPosition, draggingItems)) { return; } setDraggingPosition(draggingPosition, target); environment.setActiveTree(draggingPosition.treeId); if (draggingItems && environment.activeTreeId !== draggingPosition.treeId) { // TODO maybe do only if draggingItems are different to selectedItems environment.onSelectItems?.( draggingItems.map((item) => item.index), draggingPosition.treeId ); } }; const onDragEnterTreeItemHandler = useOnDragEnterTreeItemHandler( setDragCode, draggingItems, itemHeight, setDraggingPosition, performDrag, environment.activeTreeId, incrementDragCount ); const onDragLeaveTreeHandler = useOnDragLeaveTreeHandler(incrementDragCount); const onDropHandler = useStableHandler(() => { if (draggingItems && draggingPositionRef.current && environment.onDrop) { environment.onDrop(draggingItems, draggingPositionRef.current); const treeId = draggingPositionRef.current.treeId; callSoon(() => { environment.onFocusItem?.(draggingItems[0], treeId); resetState(); }); } }); const onStartDraggingItems = useCallback( (items, treeId) => { dragCountRef.current = 0; const treeViableDragPositions = buildMapForTrees(environment.treeIds, (treeId) => getViableDragPositions(treeId, items) ); // TODO what if trees have different heights and drag target changes? const height = computeItemHeight(treeId); setItemHeight(height); setDraggingItems(items); setViableDragPositions(treeViableDragPositions); if (environment.activeTreeId) { resetProgrammaticDragIndexForCurrentTree(items); } }, [environment.activeTreeId, environment.treeIds, getViableDragPositions, resetProgrammaticDragIndexForCurrentTree] ); const startProgrammaticDrag = useCallback(() => { if (!environment.canDragAndDrop) { return; } if (environment.activeTreeId) { const draggingItems = environment.viewState[environment.activeTreeId]?.selectedItems ?? ([environment.viewState[environment.activeTreeId]?.focusedItem] as TreeItemIndex[]); if (draggingItems.length === 0 || draggingItems[0] === undefined) { return; } const resolvedDraggingItems = draggingItems.map((id) => environment.items[id]); if (environment.canDrag && !environment.canDrag(resolvedDraggingItems)) { return; } onStartDraggingItems(resolvedDraggingItems, environment.activeTreeId); setTimeout(() => { setIsProgrammaticallyDragging(true); // Needs to be done after onStartDraggingItems was called, so that viableDragPositions is populated }); } }, [onStartDraggingItems, environment]); const abortProgrammaticDrag = useCallback(() => { resetState(); }, [resetState]); const completeProgrammaticDrag = useCallback(() => { onDropHandler(); resetState(); }, [onDropHandler, resetState]); const programmaticDragUp = useCallback(() => { setProgrammaticDragIndex((oldIndex) => Math.max(0, oldIndex - 1)); }, []); const programmaticDragDown = useCallback(() => { if (environment.activeTreeId) { setProgrammaticDragIndex((oldIndex) => Math.min(viableDragPositions[environment.activeTreeId!].length, oldIndex + 1) ); } }, [environment.activeTreeId, viableDragPositions]); const dnd = useMemo( () => ({ onStartDraggingItems, startProgrammaticDrag, abortProgrammaticDrag, completeProgrammaticDrag, programmaticDragUp, programmaticDragDown, draggingItems, itemHeight, isProgrammaticallyDragging, onDragEnterTreeItemHandler, onDragLeaveTreeHandler, viableDragPositions, }), [ onStartDraggingItems, startProgrammaticDrag, abortProgrammaticDrag, completeProgrammaticDrag, programmaticDragUp, programmaticDragDown, draggingItems, itemHeight, isProgrammaticallyDragging, onDragEnterTreeItemHandler, onDragLeaveTreeHandler, viableDragPositions, ] ); useEffect(() => { window.addEventListener("dragend", resetState); window.addEventListener("drop", onDropHandler); return () => { window.removeEventListener("dragend", resetState); window.removeEventListener("drop", onDropHandler); }; }, [onDropHandler, resetState]); return {children}; };