import type { DragMoveEvent } from '@dnd-kit/core'; import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import type { MouseEvent, PointerEvent, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { computeSideForDragMove } from '../../../lib/dnd'; import { jotaiStore } from '../../../lib/jotai'; import type { ContextMenuProps, DropdownItem } from '../Dropdown'; import { ContextMenu } from '../Dropdown'; import { Icon } from '../Icon'; import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms'; import type { TreeNode } from './common'; import { getNodeKey } from './common'; import type { TreeProps } from './Tree'; import { TreeIndentGuide } from './TreeIndentGuide'; export interface TreeItemClickEvent { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; } export type TreeItemProps = Pick< TreeProps, 'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' > & { node: TreeNode; className?: string; onClick?: (item: T, e: TreeItemClickEvent) => void; getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise; depth: number; setRef?: (item: T, n: TreeItemHandle | null) => void; }; export interface TreeItemHandle { rename: () => void; isRenaming: boolean; rect: () => DOMRect; focus: () => void; scrollIntoView: () => void; } const HOVER_CLOSED_FOLDER_DELAY = 800; function TreeItem_({ treeId, node, ItemInner, ItemLeftSlotInner, ItemRightSlot, getContextMenu, onClick, getEditOptions, className, depth, setRef, }: TreeItemProps) { const listItemRef = useRef(null); const draggableRef = useRef(null); const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id })); const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id })); const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id })); const [editing, setEditing] = useState(false); const [dropHover, setDropHover] = useState(null); const startedHoverTimeout = useRef(undefined); const handle = useMemo( () => ({ focus: () => { draggableRef.current?.focus(); }, rename: () => { if (getEditOptions != null) { setEditing(true); } }, isRenaming: editing, rect: () => { if (listItemRef.current == null) { return new DOMRect(0, 0, 0, 0); } return listItemRef.current.getBoundingClientRect(); }, scrollIntoView: () => { listItemRef.current?.scrollIntoView({ block: 'nearest' }); }, }), [editing, getEditOptions], ); useEffect(() => { setRef?.(node.item, handle); }, [setRef, handle, node.item]); const ancestorIds = useMemo(() => { const ids: string[] = []; let p = node.parent; while (p) { ids.push(p.item.id); p = p.parent; } return ids; }, [node]); const isAncestorCollapsedAtom = useMemo( () => selectAtom( collapsedFamily(treeId), (collapsed) => ancestorIds.some((id) => collapsed[id]), (a, b) => a === b, ), [ancestorIds, treeId], ); const isAncestorCollapsed = useAtomValue(isAncestorCollapsedAtom); const [showContextMenu, setShowContextMenu] = useState<{ items: DropdownItem[]; x: number; y: number; } | null>(null); const handleClick = useCallback( (e: MouseEvent) => onClick?.(node.item, e), [node, onClick], ); const toggleCollapsed = useCallback(() => { jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev); }, [node.item.id, treeId]); const handleSubmitNameEdit = useCallback( async (el: HTMLInputElement) => { getEditOptions?.(node.item).onChange(node.item, el.value); onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false }); // Slight delay for the model to propagate to the local store setTimeout(() => setEditing(false), 200); }, [getEditOptions, node.item, onClick], ); const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) { el?.focus(); el?.select(); }, []); const handleEditBlur = useCallback( async function editBlur(e: ReactFocusEvent) { await handleSubmitNameEdit(e.currentTarget); }, [handleSubmitNameEdit], ); const handleEditKeyDown = useCallback( async (e: ReactKeyboardEvent) => { e.stopPropagation(); // Don't trigger other tree keys (like arrows) switch (e.key) { case 'Enter': if (editing) { e.preventDefault(); await handleSubmitNameEdit(e.currentTarget); } break; case 'Escape': if (editing) { e.preventDefault(); setEditing(false); } break; } }, [editing, handleSubmitNameEdit], ); const handleDoubleClick = useCallback(() => { const isFolder = node.children != null; if (isFolder) { toggleCollapsed(); } else if (getEditOptions != null) { setEditing(true); } }, [getEditOptions, node.children, toggleCollapsed]); const clearDropHover = () => { if (startedHoverTimeout.current) { clearTimeout(startedHoverTimeout.current); startedHoverTimeout.current = undefined; } setDropHover(null); }; const dndContext = useDndContext(); // Toggle auto-expand of folders when hovering over them useDndMonitor({ onDragEnd() { clearDropHover(); }, onDragMove(e: DragMoveEvent) { const side = computeSideForDragMove(node.item.id, e); const isFolder = node.children != null; const hasChildren = (node.children?.length ?? 0) > 0; const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id })); if (isCollapsed && isFolder && hasChildren && side === 'after') { setDropHover('animate'); clearTimeout(startedHoverTimeout.current); startedHoverTimeout.current = setTimeout(() => { jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false); clearDropHover(); // Force re-measure everything because all containers below the folder have been pushed down requestAnimationFrame(() => { dndContext.measureDroppableContainers( dndContext.droppableContainers.toArray().map((c) => c.id), ); }); }, HOVER_CLOSED_FOLDER_DELAY); } else if (isFolder && !hasChildren && side === 'after') { setDropHover('drop'); } else { clearDropHover(); } }, }); const handleContextMenu = useCallback( async (e: MouseEvent) => { if (getContextMenu == null) return; e.preventDefault(); e.stopPropagation(); // Set data attribute on the list item to preserve active state if (listItemRef.current) { listItemRef.current.setAttribute('data-context-menu-open', 'true'); } const items = await getContextMenu(node.item); setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 }); }, [getContextMenu, node.item], ); const handleCloseContextMenu = useCallback(() => { // Remove data attribute when context menu closes if (listItemRef.current) { listItemRef.current.removeAttribute('data-context-menu-open'); } setShowContextMenu(null); }, []); const { attributes, listeners, setNodeRef: setDraggableRef, } = useDraggable({ id: node.item.id, disabled: node.draggable === false || editing }); const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id }); const handlePointerDown = useCallback( function handlePointerDown(e: PointerEvent) { const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey; if (!handleByTree) { listeners?.onPointerDown?.(e); } }, [listeners], ); const handleSetDraggableRef = useCallback( (node: HTMLButtonElement | null) => { draggableRef.current = node; setDraggableRef(node); setDroppableRef(node); }, [setDraggableRef, setDroppableRef], ); if (node.hidden || isAncestorCollapsed) return null; return (
  • {showContextMenu && ( )} {node.children != null ? ( ) : ( // Make the grid happy )} {ItemRightSlot != null ? ( ) : ( )}
  • ); } export const TreeItem = memo( TreeItem_, ({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => { const nonEqualKeys = []; for (const key of Object.keys(prevProps)) { if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) { nonEqualKeys.push(key); } } if (nonEqualKeys.length > 0) { return false; } return ( getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey) ); }, ) as typeof TreeItem_;