import type { DragMoveEvent } from '@dnd-kit/core'; import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import type { MouseEvent, PointerEvent } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { jotaiStore } from '../../../lib/jotai'; import type { ContextMenuProps, DropdownItem } from '../Dropdown'; import { ContextMenu } from '../Dropdown'; import { Icon } from '../Icon'; import { isCollapsedFamily, isLastFocusedFamily, isParentHoveredFamily, isSelectedFamily, } from './atoms'; import type { TreeNode } from './common'; import { computeSideForDragMove } from './common'; import type { TreeProps } from './Tree'; interface OnClickEvent { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; } export type TreeItemProps = Pick< TreeProps, 'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' > & { node: TreeNode; className?: string; onClick?: (item: T, e: OnClickEvent) => void; getContextMenu?: (item: T) => Promise; }; const HOVER_CLOSED_FOLDER_DELAY = 800; export function TreeItem({ treeId, node, ItemInner, ItemLeftSlot, getContextMenu, onClick, getEditOptions, className, }: 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 isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id })); const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id })); const [editing, setEditing] = useState(false); const [isDropHover, setIsDropHover] = useState(false); const startedHoverTimeout = useRef(undefined); 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 clearHoverTimer = () => { if (startedHoverTimeout.current) { setIsDropHover(false); // NEW clearTimeout(startedHoverTimeout.current); // NEW startedHoverTimeout.current = undefined; // NEW } }; // Toggle auto-expand of folders when hovering over them useDndMonitor({ onDragMove(e: DragMoveEvent) { const side = computeSideForDragMove(node, e); const isFolderWithChildren = (node.children?.length ?? 0) > 0; const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id })); if (isCollapsed && isFolderWithChildren && side === 'below') { setIsDropHover(true); clearTimeout(startedHoverTimeout.current); startedHoverTimeout.current = setTimeout(() => { jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false); setIsDropHover(false); }, HOVER_CLOSED_FOLDER_DELAY); } else { clearHoverTimer(); } }, }); 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], ); return (
{showContextMenu && ( )} {node.children != null ? ( ) : ( )}
); }