import type { DragMoveEvent } from '@dnd-kit/core'; import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import type { MouseEvent, PointerEvent } from 'react'; import React, { 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 type { TreeProps } from './Tree'; import { TreeIndentGuide } from './TreeIndentGuide'; interface OnClickEvent { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; } export type TreeItemProps = Pick< TreeProps, 'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' > & { node: TreeNode; className?: string; onClick?: (item: T, e: OnClickEvent) => void; getContextMenu?: (item: T) => Promise; depth: number; }; const HOVER_CLOSED_FOLDER_DELAY = 800; function TreeItem_({ treeId, node, ItemInner, ItemLeftSlot, getContextMenu, onClick, getEditOptions, className, depth, }: TreeItemProps) { const ref = 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 isAncestorCollapsedAtom = useMemo( () => selectAtom( collapsedFamily(treeId), (collapsed) => { const next = (n: TreeNode) => { if (n.parent == null) return false; if (collapsed[n.parent.item.id]) return true; return next(n.parent); }; return next(node); }, (a, b) => a === b, // re-render only when boolean flips ), [node, treeId], ); const [showContextMenu, setShowContextMenu] = useState<{ items: DropdownItem[]; x: number; y: number; } | null>(null); useEffect( function scrollIntoViewWhenSelected() { return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => { ref.current?.scrollIntoView({ block: 'nearest' }); }); }, [node.item.id, treeId], ); const handleClick = useCallback( function handleClick(e: MouseEvent) { onClick?.(node.item, e); }, [node, onClick], ); const toggleCollapsed = useCallback( function toggleCollapsed() { jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev); }, [node.item.id, treeId], ); const handleSubmitNameEdit = useCallback( async function submitNameEdit(el: HTMLInputElement) { getEditOptions?.(node.item).onChange(node.item, el.value); // Slight delay for the model to propagate to the local store setTimeout(() => setEditing(false), 200); }, [getEditOptions, node.item], ); const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) { el?.focus(); el?.select(); }, []); const handleEditBlur = useCallback( async function editBlur(e: React.FocusEvent) { await handleSubmitNameEdit(e.currentTarget); }, [handleSubmitNameEdit], ); const handleEditKeyDown = useCallback( async (e: React.KeyboardEvent) => { e.stopPropagation(); switch (e.key) { case 'Enter': e.preventDefault(); await handleSubmitNameEdit(e.currentTarget); break; case 'Escape': e.preventDefault(); setEditing(false); break; } }, [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); }; // 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 === 'below') { setDropHover('animate'); clearTimeout(startedHoverTimeout.current); startedHoverTimeout.current = setTimeout(() => { jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false); clearDropHover(); }, HOVER_CLOSED_FOLDER_DELAY); } else if (isFolder && !hasChildren && side === 'below') { setDropHover('drop'); } else { clearDropHover(); } }, }); const handleContextMenu = useCallback( async (e: MouseEvent) => { if (getContextMenu == null) return; e.preventDefault(); e.stopPropagation(); const items = await getContextMenu(node.item); setShowContextMenu({ items, x: e.clientX, y: e.clientY }); }, [getContextMenu, node.item], ); const handleCloseContextMenu = useCallback(() => { setShowContextMenu(null); }, []); const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id }); 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 (useAtomValue(isAncestorCollapsedAtom)) return null; return (
  • {showContextMenu && ( )} {node.children != null ? ( ) : ( // Make the grid happy )}
  • ); } 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 nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item); }, ) as typeof TreeItem_;