import { arrayMove } from '@dnd-kit/sortable'; import type { FlattenedItem, TreeItem, TreeItems } from './types'; import { UniqueIdentifier } from '@dnd-kit/core'; export const iOS = typeof window !== 'undefined' ? /iPad|iPhone|iPod/.test(navigator.platform) : false; function getDragDepth(offset: number, indentationWidth: number) { return Math.round(offset / indentationWidth); } let _revertLastChanges = () => {}; export function getProjection( items: FlattenedItem[], activeId: UniqueIdentifier | null, overId: UniqueIdentifier | null, dragOffset: number, indentationWidth: number, keepGhostInPlace: boolean, canRootHaveChildren?: boolean | ((dragItem: FlattenedItem) => boolean) ): { depth: number; parentId: UniqueIdentifier | null; parent: FlattenedItem | null; isLast: boolean; } | null { _revertLastChanges(); _revertLastChanges = () => {}; if (!activeId || !overId) return null; const overItemIndex = items.findIndex(({ id }) => id === overId); const activeItemIndex = items.findIndex(({ id }) => id === activeId); const activeItem = items[activeItemIndex]; if (keepGhostInPlace) { let parent: FlattenedItem | null | undefined = items[overItemIndex]; parent = findParentWhichCanHaveChildren( parent, activeItem, canRootHaveChildren ); if (parent === undefined) return null; return { depth: parent?.depth ?? 0 + 1, parentId: parent?.id ?? null, parent: parent, isLast: !!parent?.isLast, }; } const newItems = arrayMove(items, activeItemIndex, overItemIndex); const previousItem = newItems[overItemIndex - 1]; const nextItem = newItems[overItemIndex + 1]; const dragDepth = getDragDepth(dragOffset, indentationWidth); const projectedDepth = activeItem.depth + dragDepth; let depth = projectedDepth; let directParent = findParentWithDepth(depth - 1, previousItem); let parent = findParentWhichCanHaveChildren( directParent, activeItem, canRootHaveChildren ); if (parent === undefined) return null; const maxDepth = (parent?.depth ?? -1) + 1; const minDepth = nextItem?.depth ?? 0; if (minDepth > maxDepth) return null; if (depth >= maxDepth) { depth = maxDepth; } else if (depth < minDepth) { depth = minDepth; } const isLast = (nextItem?.depth ?? -1) < depth; if (parent && parent.isLast) { _revertLastChanges = () => { parent!.isLast = true; }; parent.isLast = false; } return { depth, parentId: getParentId(), parent, isLast, }; function findParentWithDepth(depth: number, previousItem: FlattenedItem) { if (!previousItem) return null; while (depth < previousItem.depth) { if (previousItem.parent === null) return null; previousItem = previousItem.parent; } return previousItem; } function findParentWhichCanHaveChildren( parent: FlattenedItem | null, dragItem: FlattenedItem, canRootHaveChildren?: boolean | ((dragItem: FlattenedItem) => boolean) ): FlattenedItem | null | undefined { if (!parent) { const rootCanHaveChildren = typeof canRootHaveChildren === 'function' ? canRootHaveChildren(dragItem) : canRootHaveChildren; if (rootCanHaveChildren === false) return undefined; return parent; } const canHaveChildren = typeof parent.canHaveChildren === 'function' ? parent.canHaveChildren(dragItem) : parent.canHaveChildren; if (canHaveChildren === false) return findParentWhichCanHaveChildren( parent.parent, activeItem, canRootHaveChildren ); return parent; } function getParentId() { if (depth === 0 || !previousItem) { return null; } if (depth === previousItem.depth) { return previousItem.parentId; } if (depth > previousItem.depth) { return previousItem.id; } const newParent = newItems .slice(0, overItemIndex) .reverse() .find((item) => item.depth === depth)?.parentId; return newParent ?? null; } } function flatten>( items: TreeItems, parentId: UniqueIdentifier | null = null, depth = 0, parent: FlattenedItem | null = null ): FlattenedItem[] { return items.reduce[]>((acc, item, index) => { const flattenedItem: FlattenedItem = { ...item, parentId, depth, index, isLast: items.length === index + 1, parent: parent, }; return [ ...acc, flattenedItem, ...flatten(item.children ?? [], item.id, depth + 1, flattenedItem), ]; }, []); } export function flattenTree>( items: TreeItems ): FlattenedItem[] { return flatten(items); } export function buildTree>( flattenedItems: FlattenedItem[] ): TreeItems { const root: TreeItem = { id: 'root', children: [] } as any; const nodes: Record> = { [root.id]: root }; const items = flattenedItems.map((item) => ({ ...item, children: [] })); for (const item of items) { const { id } = item; const parentId = item.parentId ?? root.id; const parent = nodes[parentId] ?? findItem(items, parentId); item.parent = null; nodes[id] = item; parent?.children?.push(item); } return root.children ?? []; } export function findItem(items: TreeItem[], itemId: UniqueIdentifier) { return items.find(({ id }) => id === itemId); } export function findItemDeep>( items: TreeItems, itemId: UniqueIdentifier ): TreeItem | undefined { for (const item of items) { const { id, children } = item; if (id === itemId) { return item; } if (children?.length) { const child = findItemDeep(children, itemId); if (child) { return child; } } } return undefined; } export function removeItem>( items: TreeItems, id: string ) { const newItems = []; for (const item of items) { if (item.id === id) { continue; } if (item.children?.length) { item.children = removeItem(item.children, id); } newItems.push(item); } return newItems; } export function setProperty< TData extends Record, T extends keyof TreeItem >( items: TreeItems, id: string, property: T, setter: (value: TreeItem[T]) => TreeItem[T] ) { for (const item of items) { if (item.id === id) { item[property] = setter(item[property]); continue; } if (item.children?.length) { item.children = setProperty(item.children, id, property, setter); } } return [...items]; } function countChildren(items: TreeItem[], count = 0): number { return items.reduce((acc, { children }) => { if (children?.length) { return countChildren(children, acc + 1); } return acc + 1; }, count); } export function getChildCount>( items: TreeItems, id: UniqueIdentifier ) { if (!id) { return 0; } const item = findItemDeep(items, id); return item ? countChildren(item.children ?? []) : 0; } export function removeChildrenOf( items: FlattenedItem[], ids: UniqueIdentifier[] ) { const excludeParentIds = [...ids]; return items.filter((item) => { if (item.parentId && excludeParentIds.includes(item.parentId)) { if (item.children?.length) { excludeParentIds.push(item.id); } return false; } return true; }); } export function getIsOverParent( parent: FlattenedItem | null, overId: UniqueIdentifier ): boolean { if (!parent || !overId) return false; if (parent.id === overId) return true; return getIsOverParent(parent.parent, overId); }