diff --git a/src-web/components/core/tree/Tree.tsx b/src-web/components/core/tree/Tree.tsx index 3eeb77e2..e65bf916 100644 --- a/src-web/components/core/tree/Tree.tsx +++ b/src-web/components/core/tree/Tree.tsx @@ -26,13 +26,7 @@ import { computeSideForDragMove } from '../../../lib/dnd'; import { jotaiStore } from '../../../lib/jotai'; import type { ContextMenuProps, DropdownItem } from '../Dropdown'; import { ContextMenu } from '../Dropdown'; -import { - collapsedFamily, - draggingIdsFamily, - focusIdsFamily, - hoveredParentFamily, - selectedIdsFamily, -} from './atoms'; +import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms'; import type { SelectableTreeNode, TreeNode } from './common'; import { equalSubtree, getSelectedItems, hasAncestor } from './common'; import { TreeDragOverlay } from './TreeDragOverlay'; @@ -294,8 +288,8 @@ function TreeInner( if (selectableItem == null) { return; } - const node = selectableItem.node; + const node = selectableItem.node; const side = computeSideForDragMove(node.item.id, e); const item = node.item; @@ -305,12 +299,8 @@ function TreeInner( const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); let hoveredChildIndex = selectableItem.index + (side === 'above' ? 0 : 1); - const collapsedMap = jotaiStore.get(collapsedFamily(treeId)); - const isHoveredItemCollapsed = - hovered != null ? hovered.children?.length === 0 || collapsedMap[hovered.item.id] : false; - - if (hovered?.children != null && side === 'below' && isHoveredItemCollapsed) { - // Move into the folder if it's open and we're moving below it + // Move into the folder if it's open and we're moving below it + if (hovered?.children != null && side === 'below') { hoveredParent = hovered; hoveredChildIndex = 0; } @@ -328,12 +318,7 @@ function TreeInner( childIndex === existing.childIndex ) ) { - jotaiStore.set(hoveredParentFamily(treeId), { - parentId: hoveredParent?.item.id ?? null, - parentDepth: hoveredParent?.depth ?? null, - index: hoveredIndex, - childIndex: hoveredChildIndex, - }); + jotaiStore.set(hoveredParentFamily(treeId), { parentId, parentDepth, index, childIndex }); } }, [root.depth, root.item.id, selectableItems, treeId], @@ -341,20 +326,23 @@ function TreeInner( const handleDragStart = useCallback( function handleDragStart(e: DragStartEvent) { - const item = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item ?? null; - if (item == null) return; - const selectedItems = getSelectedItems(treeId, selectableItems); - const isDraggingSelectedItem = selectedItems.find((i) => i.id === item.id); + const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id); + + // If we started dragging an already-selected item, we'll use that if (isDraggingSelectedItem) { jotaiStore.set( draggingIdsFamily(treeId), selectedItems.map((i) => i.id), ); } else { - jotaiStore.set(draggingIdsFamily(treeId), [item.id]); - // Also update selection to just be this one - handleSelect(item, { shiftKey: false, metaKey: false, ctrlKey: false }); + // If we started dragging a non-selected item, only drag that item + const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item; + if (activeItem != null) { + jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]); + // Also update selection to just be this one + handleSelect(activeItem, { shiftKey: false, metaKey: false, ctrlKey: false }); + } } }, [handleSelect, selectableItems, treeId], diff --git a/src-web/components/core/tree/TreeIndentGuide.tsx b/src-web/components/core/tree/TreeIndentGuide.tsx index 75825325..57962af7 100644 --- a/src-web/components/core/tree/TreeIndentGuide.tsx +++ b/src-web/components/core/tree/TreeIndentGuide.tsx @@ -1,19 +1,19 @@ import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import { memo } from 'react'; -import { hoveredParentDepthFamily, isParentHoveredFamily } from './atoms'; +import { hoveredParentDepthFamily, isAncestorHoveredFamily } from './atoms'; export const TreeIndentGuide = memo(function TreeIndentGuide({ treeId, depth, - parentId, + ancestorIds, }: { treeId: string; depth: number; - parentId: string | null; + ancestorIds: string[]; }) { const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId)); - const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId })); + const isHovered = useAtomValue(isAncestorHoveredFamily({ treeId, ancestorIds })); return (
diff --git a/src-web/components/core/tree/TreeItem.tsx b/src-web/components/core/tree/TreeItem.tsx index 18235681..69186c2f 100644 --- a/src-web/components/core/tree/TreeItem.tsx +++ b/src-web/components/core/tree/TreeItem.tsx @@ -1,5 +1,5 @@ import type { DragMoveEvent } from '@dnd-kit/core'; -import { useDndMonitor, useDraggable, useDroppable } 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'; @@ -72,22 +72,28 @@ function TreeItem_({ }); }, [addRef, editing, getEditOptions, 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) => { - 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 + (collapsed) => ancestorIds.some((id) => collapsed[id]), + (a, b) => a === b, ), - [node, treeId], + [ancestorIds, treeId], ); + const isAncestorCollapsed = useAtomValue(isAncestorCollapsedAtom); const [showContextMenu, setShowContextMenu] = useState<{ items: DropdownItem[]; @@ -176,6 +182,8 @@ function TreeItem_({ setDropHover(null); }; + const dndContext = useDndContext(); + // Toggle auto-expand of folders when hovering over them useDndMonitor({ onDragEnd() { @@ -192,6 +200,12 @@ function TreeItem_({ 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 === 'below') { setDropHover('drop'); @@ -239,8 +253,6 @@ function TreeItem_({ [setDraggableRef, setDroppableRef], ); - if (useAtomValue(isAncestorCollapsedAtom)) return null; - return (
  • ({ 'tree-item', 'h-sm', 'grid grid-cols-[auto_minmax(0,1fr)]', + isAncestorCollapsed && 'hidden', editing && 'ring-1 focus-within:ring-focus', dropHover != null && 'relative z-10 ring-2 ring-primary', dropHover === 'animate' && 'animate-blinkRing', isSelected && 'selected', )} > - +
    a.treeId === b.treeId && a.parentId === b.parentId, ); +export const isAncestorHoveredFamily = atomFamily( + ({ treeId, ancestorIds }: { treeId: string; ancestorIds: string[] }) => + selectAtom( + hoveredParentFamily(treeId), + (v) => v.parentId && ancestorIds.includes(v.parentId), + Object.is, + ), + (a, b) => a.treeId === b.treeId && a.ancestorIds.join(',') === b.ancestorIds.join(','), +); + export const isIndexHoveredFamily = atomFamily( ({ treeId, index }: { treeId: string; index: number }) => selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is), diff --git a/src-web/hooks/useHotKey.ts b/src-web/hooks/useHotKey.ts index 7d0e763a..b7bb30ad 100644 --- a/src-web/hooks/useHotKey.ts +++ b/src-web/hooks/useHotKey.ts @@ -173,7 +173,8 @@ function handleKeyDown(e: KeyboardEvent) { if (e.metaKey) currentKeysWithModifiers.add('Meta'); if (e.shiftKey) currentKeysWithModifiers.add('Shift'); - outer: for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) { + const executed: string[] = []; + outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) { if ( (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) && currentKeysWithModifiers.size === 1 && @@ -184,8 +185,7 @@ function handleKeyDown(e: KeyboardEvent) { continue; } - const executed: string[] = []; - for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) { + for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) { if (hkAction !== action) { continue; } @@ -207,12 +207,12 @@ function handleKeyDown(e: KeyboardEvent) { } } } - if (executed.length > 0) { - console.log('Executed hotkey', executed.join(', ')); - jotaiStore.set(currentKeysAtom, new Set([])); - } } + if (executed.length > 0) { + console.log('Executed hotkey', executed.join(', ')); + jotaiStore.set(currentKeysAtom, new Set([])); + } clearCurrentKeysDebounced(); } @@ -272,7 +272,13 @@ const resolveHotkeyKey = (key: string) => { function compareKeys(keysA: string[], keysB: string[]) { if (keysA.length !== keysB.length) return false; - const sortedA = keysA.map((k) => k.toLowerCase()).sort().join('::'); - const sortedB = keysB.map((k) => k.toLowerCase()).sort().join('::'); + const sortedA = keysA + .map((k) => k.toLowerCase()) + .sort() + .join('::'); + const sortedB = keysB + .map((k) => k.toLowerCase()) + .sort() + .join('::'); return sortedA === sortedB; }