src/components/Tree/SortableTree.tsx
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
Announcements,
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragOverlay,
DragMoveEvent,
DragEndEvent,
DragOverEvent,
MeasuringStrategy,
DropAnimation,
Modifier,
defaultDropAnimation,
UniqueIdentifier,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import {
buildTree,
flattenTree,
getProjection,
getChildCount,
removeItem,
removeChildrenOf,
setProperty,
} from './utilities'
import type { FlattenedItem, SensorContext, TreeItems } from './types'
import { sortableTreeKeyboardCoordinates } from './keyboardCoordinates'
import { SortableTreeItem } from './components'
import { CSS } from '@dnd-kit/utilities'
import { useTaskManager } from '@/hooks/useTaskManager'
import { useTrackingMove } from '@/hooks/useTrackingState'
import { useEventAlarm } from '@/hooks/useEventAlarm'
import { treeItemsToNode, updateLines } from '@/services/util'
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
indentationWidth?: number
indicator?: boolean
removable?: boolean
}
export function SortableTree({
collapsible,
indicator = false,
indentationWidth = 20,
removable,
}: Props) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const [overId, setOverId] = useState<UniqueIdentifier | null>(null)
const [offsetLeft, setOffsetLeft] = useState(0)
const [currentPosition, setCurrentPosition] = useState<{
parentId: UniqueIdentifier | null
overId: UniqueIdentifier
} | null>(null)
const manager = useTaskManager()
const root = manager.getRoot()
const items = root.children
const { moveTracking } = useTrackingMove()
const { moveEventLine } = useEventAlarm()
const flattenedItems = useMemo(() => {
const flattenedTree = flattenTree(items)
const collapsedItems = flattenedTree.reduce<UniqueIdentifier[]>(
(acc, { children, collapsed, id }) =>
collapsed && children.length ? [...acc, id] : acc,
[],
)
return removeChildrenOf(
flattenedTree,
activeId ? [activeId, ...collapsedItems] : collapsedItems,
)
}, [activeId, items])
const projected =
activeId && overId
? getProjection(
flattenedItems,
activeId,
overId,
offsetLeft,
indentationWidth,
)
: null
const sensorContext: SensorContext = useRef({
items: flattenedItems,
offset: offsetLeft,
})
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 2, // to enables editting by double click.
},
}),
)
const sortedIds = useMemo(
() => flattenedItems.map(({ id }) => id),
[flattenedItems],
)
const activeItem = activeId
? flattenedItems.find(({ id }) => id === activeId)
: null
const isDropTarget = (id, parentId, currentPosition) => {
return (
id === currentPosition?.parentId ||
(parentId != null && parentId === currentPosition?.parentId)
)
}
const dropTargetBottom = useMemo(() => {
return flattenedItems
.filter(({ id, parentId }) => isDropTarget(id, parentId, currentPosition))
.pop()
}, [flattenedItems, currentPosition])
useEffect(() => {
sensorContext.current = {
items: flattenedItems,
offset: offsetLeft,
}
}, [flattenedItems, offsetLeft])
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 (
<DndContext
accessibility={{ announcements }}
sensors={sensors}
collisionDetection={closestCenter}
measuring={measuring}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{flattenedItems.map(({ id, children, collapsed, depth, parentId }) => (
<SortableTreeItem
key={id}
id={id}
value={`${id}`}
depth={id === activeId && projected ? projected.depth : depth}
indentationWidth={indentationWidth}
indicator={indicator}
collapsed={Boolean(collapsed && children.length)}
onCollapse={
collapsible && children.length
? () => handleCollapse(id)
: undefined
}
onRemove={removable ? () => handleRemove(id) : undefined}
dropTarget={isDropTarget(id, parentId, currentPosition)}
dropTargetParent={id === currentPosition?.parentId}
dropTargetBottom={id === dropTargetBottom?.id}
/>
))}
{createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={indicator ? [adjustTranslate] : undefined}
>
{activeId && activeItem ? (
<SortableTreeItem
id={activeId}
depth={activeItem.depth}
clone
childCount={getChildCount(items, activeId) + 1}
value={activeId.toString()}
indentationWidth={indentationWidth}
/>
) : null}
</DragOverlay>,
document.body,
)}
</SortableContext>
</DndContext>
)
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[] = flattenTree(items)
let overIndex = clonedItems.findIndex(({ id }) => id === over.id)
const activeIndex = clonedItems.findIndex(({ id }) => id === active.id)
const activeTreeItem = clonedItems[activeIndex]
const activeNode = root.find((n) => n.id === active.id)
const overNode = root.find((n) => n.id === over.id)
clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId }
if (activeIndex < overIndex && overNode.collapsed) {
overIndex += overNode.size() - 1
}
let sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
const newItems = buildTree(sortedItems)
moveTracking(activeIndex + 1, overIndex + 1, activeNode.size())
moveEventLine(activeIndex + 1, overIndex + 1, activeNode.size())
setTreeItems(newItems)
}
}
function setTreeItems(newItems: TreeItems) {
// update persistent data
let newRoot = treeItemsToNode(newItems)
newRoot = updateLines(newRoot)
manager.setRoot(newRoot)
}
function handleDragCancel() {
resetState()
}
function resetState() {
setOverId(null)
setActiveId(null)
setOffsetLeft(0)
setCurrentPosition(null)
document.body.style.setProperty('cursor', '')
}
function handleRemove(id: UniqueIdentifier) {
setTreeItems(removeItem(items, id))
}
function handleCollapse(id: UniqueIdentifier) {
const newItems = setProperty(items, id, 'collapsed', (value) => {
return !value
})
setTreeItems(newItems)
}
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,
})
}
}
// TODO: Implementing deep clone
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: string
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,
}
}