import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; import { Announcements, closestCenter, defaultDropAnimation, DndContext, DragEndEvent, DragMoveEvent, DragOverEvent, DragOverlay, DragStartEvent, DropAnimation, // MeasuringStrategy, // KeyboardSensor, Modifier, PointerSensor, PointerSensorOptions, UniqueIdentifier, useSensor, useSensors, } from '@dnd-kit/core'; import { CSS } from '@dnd-kit/utilities'; import { arrayMove, SortableContext, UseSortableArguments, } from '@dnd-kit/sortable'; import { buildTree, findItemDeep, flattenTree, getChildCount, getProjection, removeChildrenOf, removeItem, setProperty, } from './utilities'; import type { FlattenedItem, ItemChangedReason, SensorContext, TreeItemComponentType, TreeItems, } from './types'; // import { sortableTreeKeyboardCoordinates } from './keyboardCoordinates'; import { SortableTreeItem } from './SortableTreeItem'; import { customListSortingStrategy } from './SortingStrategy'; export type SortableTreeProps< TData extends Record, TElement extends HTMLElement > = { items: TreeItems; onItemsChanged( items: TreeItems, reason: ItemChangedReason ): void; TreeItemComponent: TreeItemComponentType; indentationWidth?: number; indicator?: boolean; pointerSensorOptions?: PointerSensorOptions; disableSorting?: boolean; dropAnimation?: DropAnimation | null; dndContextProps?: React.ComponentProps; sortableProps?: Omit; keepGhostInPlace?: boolean; canRootHaveChildren?: boolean | ((dragItem: FlattenedItem) => boolean); }; const defaultPointerSensorOptions: PointerSensorOptions = { activationConstraint: { distance: 3, }, }; export const dropAnimationDefaultConfig: DropAnimation = { keyframes({ transform }) { return [ { opacity: 1, transform: CSS.Transform.toString(transform.initial) }, { opacity: 0, transform: CSS.Transform.toString({ ...transform.final, x: transform.final.x + 5, y: transform.final.y + 5, }), }, ]; }, easing: 'ease-out', sideEffects({ active }) { active.node.animate([{ opacity: 0 }, { opacity: 1 }], { duration: defaultDropAnimation.duration, easing: defaultDropAnimation.easing, }); }, }; export function SortableTree< TreeItemData extends Record, TElement extends HTMLElement = HTMLDivElement >({ items, indicator, indentationWidth = 20, onItemsChanged, TreeItemComponent, pointerSensorOptions, disableSorting, dropAnimation, dndContextProps, sortableProps, keepGhostInPlace, canRootHaveChildren, ...rest }: SortableTreeProps) { const [activeId, setActiveId] = useState(null); const [overId, setOverId] = useState(null); const [offsetLeft, setOffsetLeft] = useState(0); const [currentPosition, setCurrentPosition] = useState<{ parentId: UniqueIdentifier | null; overId: UniqueIdentifier; } | null>(null); const flattenedItems = useMemo(() => { const flattenedTree = flattenTree(items); const collapsedItems = flattenedTree.reduce( (acc, { children, collapsed, id }) => collapsed && children?.length ? [...acc, id] : acc, [] ); const result = removeChildrenOf( flattenedTree, activeId ? [activeId, ...collapsedItems] : collapsedItems ); return result; }, [activeId, items]); const projected = getProjection( flattenedItems, activeId, overId, offsetLeft, indentationWidth, keepGhostInPlace ?? false, canRootHaveChildren ); const sensorContext: SensorContext = useRef({ items: flattenedItems, offset: offsetLeft, }); // const [coordinateGetter] = useState(() => // sortableTreeKeyboardCoordinates(sensorContext, indentationWidth) // ); const sensors = useSensors( useSensor( PointerSensor, pointerSensorOptions ?? defaultPointerSensorOptions ) // useSensor(KeyboardSensor, { // coordinateGetter, // }) ); const sortedIds = useMemo( () => flattenedItems.map(({ id }) => id), [flattenedItems] ); const activeItem = activeId ? flattenedItems.find(({ id }) => id === activeId) : null; useEffect(() => { sensorContext.current = { items: flattenedItems, offset: offsetLeft, }; }, [flattenedItems, offsetLeft]); const itemsRef = useRef(items); itemsRef.current = items; const handleRemove = useCallback( (id: string) => { const item = findItemDeep(itemsRef.current, id)!; onItemsChanged(removeItem(itemsRef.current, id), { type: 'removed', item, }); }, [onItemsChanged] ); const handleCollapse = useCallback( function handleCollapse(id: string) { const item = findItemDeep(itemsRef.current, id)!; onItemsChanged( setProperty(itemsRef.current, id, 'collapsed', ((value: boolean) => { return !value; }) as any), { type: item.collapsed ? 'collapsed' : 'expanded', item: item, } ); }, [onItemsChanged] ); const announcements: Announcements = useMemo( () => ({ onDragStart({ active }) { return `Picked up ${active.id}.`; }, onDragMove({ active, over }) { return getMovementAnnouncement('onDragMove', active.id, over?.id); }, onDragOver({ active, over }) { return getMovementAnnouncement('onDragOver', active.id, over?.id); }, onDragEnd({ active, over }) { return getMovementAnnouncement('onDragEnd', active.id, over?.id); }, onDragCancel({ active }) { return `Moving was cancelled. ${active.id} was dropped in its original position.`; }, }), [] ); const strategyCallback = useCallback(() => { return !!projected; }, [projected]); return ( {flattenedItems.map((item) => { return ( ); })} {createPortal( {activeId && activeItem ? ( ) : null} , document.body )} ); function handleDragStart({ active: { id: activeId } }: DragStartEvent) { setActiveId(activeId); setOverId(activeId); const activeItem = flattenedItems.find(({ id }) => id === activeId); if (activeItem) { setCurrentPosition({ parentId: activeItem.parentId, overId: activeId, }); } document.body.style.setProperty('cursor', 'grabbing'); } function handleDragMove({ delta }: DragMoveEvent) { setOffsetLeft(delta.x); } function handleDragOver({ over }: DragOverEvent) { setOverId(over?.id ?? null); } function handleDragEnd({ active, over }: DragEndEvent) { resetState(); if (projected && over) { const { depth, parentId } = projected; if (keepGhostInPlace && over.id === active.id) return; const clonedItems: FlattenedItem[] = flattenTree(items); const overIndex = clonedItems.findIndex(({ id }) => id === over.id); const activeIndex = clonedItems.findIndex(({ id }) => id === active.id); const activeTreeItem = clonedItems[activeIndex]; clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId }; const draggedFromParent = activeTreeItem.parent; const sortedItems = arrayMove(clonedItems, activeIndex, overIndex); const newItems = buildTree(sortedItems); const newActiveItem = sortedItems.find((x) => x.id === active.id)!; const currentParent = newActiveItem.parentId ? sortedItems.find((x) => x.id === newActiveItem.parentId)! : null; // removing setTimeout leads to an unwanted scrolling // Use case: // There are a lot of items in a tree (so that the scroll exists). // You take the node from the bottom and move it to the top // Without `setTimeout` when you drop the node the list gets scrolled to the bottom. setTimeout(() => onItemsChanged(newItems, { type: 'dropped', draggedItem: newActiveItem, draggedFromParent: draggedFromParent, droppedToParent: currentParent, }) ); } } function handleDragCancel() { resetState(); } function resetState() { setOverId(null); setActiveId(null); setOffsetLeft(0); setCurrentPosition(null); document.body.style.setProperty('cursor', ''); } function getMovementAnnouncement( eventName: string, activeId: UniqueIdentifier, overId?: UniqueIdentifier ) { if (overId && projected) { if (eventName !== 'onDragEnd') { if ( currentPosition && projected.parentId === currentPosition.parentId && overId === currentPosition.overId ) { return; } else { setCurrentPosition({ parentId: projected.parentId, overId, }); } } const clonedItems: FlattenedItem[] = flattenTree(items); const overIndex = clonedItems.findIndex(({ id }) => id === overId); const activeIndex = clonedItems.findIndex(({ id }) => id === activeId); const sortedItems = arrayMove(clonedItems, activeIndex, overIndex); const previousItem = sortedItems[overIndex - 1]; let announcement; const movedVerb = eventName === 'onDragEnd' ? 'dropped' : 'moved'; const nestedVerb = eventName === 'onDragEnd' ? 'dropped' : 'nested'; if (!previousItem) { const nextItem = sortedItems[overIndex + 1]; announcement = `${activeId} was ${movedVerb} before ${nextItem.id}.`; } else { if (projected.depth > previousItem.depth) { announcement = `${activeId} was ${nestedVerb} under ${previousItem.id}.`; } else { let previousSibling: FlattenedItem | undefined = previousItem; while (previousSibling && projected.depth < previousSibling.depth) { const parentId: UniqueIdentifier | null = previousSibling.parentId; previousSibling = sortedItems.find(({ id }) => id === parentId); } if (previousSibling) { announcement = `${activeId} was ${movedVerb} after ${previousSibling.id}.`; } } } return announcement; } return; } } const adjustTranslate: Modifier = ({ transform }) => { return { ...transform, y: transform.y - 25, }; }; const modifiersArray = [adjustTranslate];