'use client'; import { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import type { DropTarget } from 'react-aria-components'; import { Button, Collection, DropIndicator, Tree as ReactAriaTree, TreeItem, TreeItemContent, type TreeItemContentProps, type TreeItemContentRenderProps, type TreeItemProps, useDragAndDrop, } from 'react-aria-components'; import type { Key, Selection } from 'react-stately'; import { useTreeData } from 'react-stately'; import clsx from 'clsx'; type TreeContextType = { onExpand?: (key: Key, item: TreeItemType) => void; onCollapse?: (key: Key, item: TreeItemType) => void; treeData?: ReturnType>; treeOpenIcon?: React.ReactNode; treeCloseIcon?: React.ReactNode; }; const TreeContext = createContext({}); interface MyTreeItemProps extends Partial { title: string; isLastChild?: boolean; isRoot?: boolean; } export type TreeItemType = { id: number | string; title: string; type: 'directory' | 'file'; value?: any; children?: TreeItemType[]; }; export type TreeRef = { updateItem: (item: TreeItemType) => void; addItem: (item: TreeItemType, parentKey?: Key | null) => void; removeItem: (itemId: number) => void; expandAllItems: () => void; collapseAllItems: () => void; applyReorder: (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => void; clearItemsByParentKey: (parentKey: Key) => void; clearAllTrees: () => void; }; export type TreeProps = { items: TreeItemType[]; selectionMode?: 'single' | 'multiple' | 'none'; selectedKeys?: 'all' | Set; selectedParentKey?: Key | null; onSelectionChange?: (keys: Selection) => void; enableDragAndDrop?: boolean; onReorder?: ( sourceKey: number, targetKey: number, dropPosition: 'before' | 'after', ParentKey: number | null ) => void; preventAutoReorder?: boolean; onExpand?: (key: Key, item: TreeItemType) => void; onCollapse?: (key: Key, item: TreeItemType) => void; onSelect?: (key: Key | null, item: TreeItemType | null) => void; 'aria-label'?: string; className?: string; treeOpenIcon?: React.ReactNode; treeCloseIcon?: React.ReactNode; persistSelectionOnCollapse?: boolean; }; const IconTreeBranch = () => ( ); const IconTreeBranchLast = () => ( ); const MyTreeItemContentInner = ({ level, hasChildItems, isExpanded, isSelected, lastChild, children, isRoot, onExpandChange, }: { level: number; hasChildItems: boolean; isExpanded: boolean; isSelected: boolean; lastChild?: boolean; children?: React.ReactNode; isRoot?: boolean; actualChildCount?: number; onExpandChange?: (isExpanded: boolean) => void; }) => { const { treeOpenIcon, treeCloseIcon } = useContext(TreeContext); if (hasChildItems || isRoot) { return ( ); } return (
{lastChild ? : }
{children}
); }; const MyTreeItemContent = ( props: Omit & { children?: React.ReactNode; lastChild?: boolean; isRoot?: boolean; onExpandChange?: (isExpanded: boolean) => void; } ) => { return ( {(renderProps: TreeItemContentRenderProps) => { const { hasChildItems, isExpanded, isSelected, level } = renderProps; return ( <> {props.children} ); }} ); }; const TreeDropIndicator = ({ target }: { target: DropTarget }) => { return ( clsx('w-full h-0 outline-2 outline-primary/50', isDropTarget ? 'opacity-100' : 'opacity-0')} /> ); }; const MyTreeItem = (props: MyTreeItemProps) => { const { onExpand, onCollapse, treeData } = useContext(TreeContext); const handleExpandChange = (isExpanded: boolean) => { if (!props.id || !treeData) return; const item = treeData.getItem(props.id); if (!item) return; if (isExpanded) { onExpand?.(props.id, item.value); } else { onCollapse?.(props.id, item.value); } }; return ( {props.title} {props.children} ); }; export const Tree = forwardRef( ( { 'aria-label': ariaLabel = 'Tree', items, selectionMode = 'single', selectedKeys, selectedParentKey, enableDragAndDrop = true, className, onSelectionChange, onReorder, preventAutoReorder = false, onExpand, onCollapse, onSelect, treeOpenIcon, treeCloseIcon, persistSelectionOnCollapse = false, }, ref ) => { const [expandedKeys, setExpandedKeys] = useState>(new Set()); const [treeKey, setTreeKey] = useState(0); const handleExpandedChange = (keys: Set) => { setExpandedKeys(keys); }; const tree = useTreeData({ initialItems: items, getKey: (item) => item.id, getChildren: (item) => item.children || [], }); const updateItem = (item: TreeItemType) => { tree.update(item.id, item); }; const addItem = (item: TreeItemType, parentKey?: Key | null) => { tree.getItem(-1) && tree.remove(-1); // 중복 체크: 이미 존재하는 아이템은 추가하지 않음 const targetParentKey = parentKey ?? selectedParentKey ?? null; const existingItem = tree.getItem(item.id); if ( (existingItem && existingItem.parentKey === targetParentKey) || (targetParentKey && targetParentKey === item.id) ) { return; } tree.append(targetParentKey, item); }; const removeItem = (itemId: number) => { tree.remove(itemId); const childrens = tree.getItem(selectedParentKey as Key)?.children; itemId !== -1 && childrens?.length === 1 && updateMyParentItem(); }; const expandAllItems = () => { setExpandedKeys(new Set([...expandedKeys, ...tree.items.map((item) => item.key)])); }; const collapseAllItems = () => { setExpandedKeys(new Set()); }; const clearAllTrees = () => { const keysToRemove = tree.items.map((item) => item.key); keysToRemove.forEach((key) => { tree.remove(key); }); }; const clearItemsByParentKey = (parentKey: Key) => { const removeRecursively = (key: Key) => { const item = tree.getItem(key); if (item && item.children) { item.children.forEach((child) => { removeRecursively(child.key); }); } tree.remove(key); }; const itemsToRemove: Key[] = []; tree.items.forEach((item) => { if (item.parentKey === parentKey) { itemsToRemove.push(item.key); } }); itemsToRemove.forEach((key) => { removeRecursively(key); }); }; const applyReorder = (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => { if (dropPosition === 'before') { tree.moveBefore(targetKey, new Set([sourceKey])); } else { tree.moveAfter(targetKey, new Set([sourceKey])); } }; const updateMyParentItem = () => { if (selectedParentKey) { const parentItem = tree.getItem(selectedParentKey); if (parentItem) { tree.update(selectedParentKey, { ...parentItem.value, children: [], ...(parentItem.value.value && { value: { ...parentItem.value.value, children: [] } }), }); } } }; useImperativeHandle(ref, () => ({ updateItem, addItem, removeItem, expandAllItems, collapseAllItems, applyReorder, clearItemsByParentKey, clearAllTrees, })); useEffect(() => { if (persistSelectionOnCollapse) return; if (selectedKeys) { setExpandedKeys(new Set([...expandedKeys, ...selectedKeys])); } }, [selectedKeys, persistSelectionOnCollapse]); useEffect(() => { if (items) { setTreeKey((prev) => prev + 1); } }, [items]); const handleSelectionChange = (keys: Selection) => { onSelectionChange?.(keys); if (onSelect && selectionMode === 'single' && keys !== 'all') { const selectedKey = keys instanceof Set ? Array.from(keys)[0] : null; if (selectedKey) { const item = tree.getItem(selectedKey); if (item) { onSelect(selectedKey, item ? item.value : null); } } else { onSelect(selectedKey, null); } } }; const { dragAndDropHooks } = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({ 'text/plain': tree.getItem(key)?.value.title || '', })), renderDropIndicator(target) { return ; }, onReorder(e) { const targetItem = tree.getItem(e.target.key); const sourceKey = Array.from(e.keys)[0] as number; const dropPosition = e.target.dropPosition === 'before' ? 'before' : 'after'; const targetParentKey = Number(targetItem?.parentKey) || null; if (preventAutoReorder) { onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey); } else { if (dropPosition === 'before') { tree.moveBefore(e.target.key, e.keys); } else { tree.moveAfter(e.target.key, e.keys); } onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey); } }, }); const calculateIsLastChild = (item: ReturnType) => { if (!item || !item.parentKey) return false; const parent = tree.getItem(item.parentKey); if (!parent) return false; const siblings = parent.children || []; const index = siblings.findIndex((sibling) => sibling.key === item.key); return index === siblings.length - 1; }; // MEMO: tree.items가 변경될 때 전체 트리를 다시 렌더링하기 위해 사용하는 key // 현재는 하나의 item이 변경되어도 전체 트리가 다시 렌더링됨. 변경된 item과 같은 부모 아래의 아이템들만 선택적으로 렌더링되도록 개선 필요함 const treeRenderKey = useMemo(() => { const generateTreeHash = (items: typeof tree.items): string => { return items .map((item) => { const childrenHash = item.children ? generateTreeHash(item.children) : ''; return `${item.key}-${item.children?.length || 0}-${childrenHash}`; }) .join('|'); }; return generateTreeHash(tree.items); }, [tree.items]); return ( {function renderItem(item) { const isLastChild = calculateIsLastChild(item); return ( {renderItem} ); }} ); } ); Tree.displayName = 'Tree';