import { Announcements, DndContext, DragEndEvent, DragMoveEvent, DragOverEvent, DragOverlay, DragStartEvent, DropAnimation, KeyboardSensor, MeasuringStrategy, PointerSensor, UniqueIdentifier, closestCenter, defaultDropAnimation, useSensor, useSensors, } from "@dnd-kit/core" import { SortableContext, arrayMove, verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { ReactNode, useEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { sortableTreeKeyboardCoordinates } from "./keyboard-coordinates" import { SortableTreeItem } from "./sortable-tree-item" import type { FlattenedItem, SensorContext, TreeItem } from "./types" import { buildTree, flattenTree, getChildCount, getProjection, removeChildrenOf, } from "./utils" const measuring = { droppable: { strategy: MeasuringStrategy.Always, }, } const dropAnimationConfig: 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, }) }, } interface Props { collapsible?: boolean childrenProp?: string items: T[] indentationWidth?: number /** * Enable drag for all items or provide a function to enable drag for specific items. * @default true */ enableDrag?: boolean | ((item: T) => boolean) onChange: ( updatedItem: { id: UniqueIdentifier parentId: UniqueIdentifier | null index: number }, items: T[] ) => void renderValue: (item: T) => ReactNode } export function SortableTree({ collapsible = true, childrenProp = "children", // "children" is the default children prop name enableDrag = true, items = [], indentationWidth = 40, onChange, renderValue, }: Props) { const [collapsedState, setCollapsedState] = useState< Record >({}) 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, childrenProp) const collapsedItems = flattenedTree.reduce( (acc, item) => { const { id } = item const children = (item[childrenProp] || []) as FlattenedItem[] const collapsed = collapsedState[id] return collapsed && children.length ? [...acc, id] : acc }, [] ) return removeChildrenOf( flattenedTree, activeId ? [activeId, ...collapsedItems] : collapsedItems, childrenProp ) }, [activeId, items, childrenProp, collapsedState]) const projected = activeId && overId ? getProjection( flattenedItems, activeId, overId, offsetLeft, indentationWidth ) : null const sensorContext: SensorContext = useRef({ items: flattenedItems, offset: offsetLeft, }) const [coordinateGetter] = useState(() => sortableTreeKeyboardCoordinates(sensorContext, indentationWidth) ) const sensors = useSensors( useSensor(PointerSensor), 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]) 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 const clonedItems: FlattenedItem[] = JSON.parse( JSON.stringify(flattenTree(items, childrenProp)) ) 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 sortedItems = arrayMove(clonedItems, activeIndex, overIndex) const { items: newItems, update } = buildTree( sortedItems, overIndex, childrenProp ) onChange(update, newItems) } } function handleDragCancel() { resetState() } function resetState() { setOverId(null) setActiveId(null) setOffsetLeft(0) setCurrentPosition(null) document.body.style.setProperty("cursor", "") } function handleCollapse(id: UniqueIdentifier) { setCollapsedState((state) => ({ ...state, [id]: state[id] ? false : true, })) } 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[] = JSON.parse( JSON.stringify(flattenTree(items, childrenProp)) ) 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 announcements: Announcements = { 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.` }, } return ( {flattenedItems.map((item) => { const { id, depth } = item const children = (item[childrenProp] || []) as FlattenedItem[] const disabled = typeof enableDrag === "function" ? !enableDrag(item as unknown as T) : !enableDrag return ( handleCollapse(id) : undefined } /> ) })} {createPortal( {activeId && activeItem ? ( ) : null} , document.body )} ) }