mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-30 22:31:52 +02:00
Move Tree component to @yaakapp-internal/ui package
Decouple Tree from client app's hotkey system by adding getSelectedItems() to TreeHandle and having callers register hotkeys externally. Extract shared action callbacks to eliminate duplication between hotkey and context menu handlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
755
packages/ui/src/components/tree/Tree.tsx
Normal file
755
packages/ui/src/components/tree/Tree.tsx
Normal file
@@ -0,0 +1,755 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import classNames from 'classnames';
|
||||
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import { computeSideForDragMove } from '../../lib/dnd';
|
||||
import { useStore } from 'jotai';
|
||||
import {
|
||||
draggingIdsFamily,
|
||||
focusIdsFamily,
|
||||
hoveredParentFamily,
|
||||
selectedIdsFamily,
|
||||
} from './atoms';
|
||||
import { type CollapsedAtom, CollapsedAtomContext } from './context';
|
||||
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from './common';
|
||||
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
import { useSelectableItems } from './useSelectableItems';
|
||||
|
||||
/** So we re-calculate after expanding a folder during drag */
|
||||
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
|
||||
|
||||
export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
collapsedAtom: CollapsedAtom;
|
||||
getItemKey: (item: T) => string;
|
||||
getContextMenu?: (items: T[]) => unknown[] | Promise<unknown[]>;
|
||||
renderContextMenu?: ContextMenuRenderer;
|
||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||
className?: string;
|
||||
onActivate?: (item: T) => void;
|
||||
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||
getEditOptions?: (item: T) => {
|
||||
defaultValue: string;
|
||||
placeholder?: string;
|
||||
onChange: (item: T, text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TreeHandle {
|
||||
treeId: string;
|
||||
focus: () => boolean;
|
||||
hasFocus: () => boolean;
|
||||
getSelectedItems: () => { id: string }[];
|
||||
selectItem: (id: string, focus?: boolean) => void;
|
||||
renameItem: (id: string) => void;
|
||||
showContextMenu: () => void;
|
||||
}
|
||||
|
||||
function TreeInner<T extends { id: string }>(
|
||||
{
|
||||
className,
|
||||
collapsedAtom,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
onActivate,
|
||||
onDragEnd,
|
||||
renderContextMenu,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
root,
|
||||
treeId,
|
||||
}: TreeProps<T>,
|
||||
ref: Ref<TreeHandle>,
|
||||
) {
|
||||
const store = useStore();
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const selectableItems = useSelectableItems(root);
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: unknown[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
|
||||
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
|
||||
if (r == null) {
|
||||
delete treeItemRefs.current[item.id];
|
||||
} else {
|
||||
treeItemRefs.current[item.id] = r;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Select the first item on first render
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only used for initial render
|
||||
useEffect(() => {
|
||||
const ids = store.get(selectedIdsFamily(treeId));
|
||||
const fallback = selectableItems[0];
|
||||
if (ids.length === 0 && fallback != null) {
|
||||
store.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
|
||||
store.set(focusIdsFamily(treeId), {
|
||||
anchorId: fallback.node.item.id,
|
||||
lastId: fallback.node.item.id,
|
||||
});
|
||||
}
|
||||
}, [treeId]);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
setShowContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const isTreeFocused = useCallback(() => {
|
||||
return treeRef.current?.contains(document.activeElement);
|
||||
}, []);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
|
||||
'.tree-item button[tabindex="0"]',
|
||||
);
|
||||
if ($el == null) {
|
||||
return false;
|
||||
}
|
||||
$el.focus();
|
||||
$el.scrollIntoView({ block: 'nearest' });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const ensureTabbableItem = useCallback(() => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find(
|
||||
(i) => i.node.item.id === lastSelectedId && !i.node.hidden,
|
||||
);
|
||||
|
||||
// If no item found, default to selecting the first item (prefer leaf node);
|
||||
if (lastSelectedItem == null) {
|
||||
const firstLeafItem = selectableItems.find((i) => !i.node.hidden && i.node.children == null);
|
||||
const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden);
|
||||
if (firstItem != null) {
|
||||
const id = firstItem.node.item.id;
|
||||
store.set(selectedIdsFamily(treeId), [id]);
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
|
||||
if (closest != null) {
|
||||
const id = closest.item.id;
|
||||
store.set(selectedIdsFamily(treeId), [id]);
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
}
|
||||
}, [selectableItems, treeId]);
|
||||
|
||||
// Ensure there's always a tabbable item after collapsed state changes
|
||||
useEffect(() => {
|
||||
const unsub = store.sub(collapsedAtom, ensureTabbableItem);
|
||||
return unsub;
|
||||
}, [ensureTabbableItem, treeId]);
|
||||
|
||||
// Ensure there's always a tabbable item after render
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(ensureTabbableItem);
|
||||
});
|
||||
|
||||
const hasFocus = useCallback(() => {
|
||||
return treeRef.current?.contains(document.activeElement) ?? false;
|
||||
}, []);
|
||||
|
||||
const setSelected = useCallback(
|
||||
(ids: string[], focus: boolean) => {
|
||||
store.set(selectedIdsFamily(treeId), ids);
|
||||
// TODO: Figure out a better way than timeout
|
||||
if (!focus) return;
|
||||
setTimeout(tryFocus, 50);
|
||||
},
|
||||
[treeId, tryFocus],
|
||||
);
|
||||
|
||||
const treeHandle = useMemo<TreeHandle>(
|
||||
() => ({
|
||||
treeId,
|
||||
focus: tryFocus,
|
||||
hasFocus: hasFocus,
|
||||
getSelectedItems: () => getSelectedItems(store, treeId, selectableItems),
|
||||
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
||||
selectItem: (id, focus) => {
|
||||
if (store.get(selectedIdsFamily(treeId)).includes(id)) {
|
||||
// Already selected
|
||||
return;
|
||||
}
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
setSelected([id], focus === true);
|
||||
},
|
||||
showContextMenu: async () => {
|
||||
if (getContextMenu == null) return;
|
||||
const items = getSelectedItems(store, treeId, selectableItems);
|
||||
const menuItems = await getContextMenu(items);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
|
||||
if (rect == null) return;
|
||||
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
|
||||
},
|
||||
}),
|
||||
[getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
|
||||
|
||||
const handleGetContextMenu = useMemo(() => {
|
||||
if (getContextMenu == null) return;
|
||||
return (item: T) => {
|
||||
const items = getSelectedItems(store, treeId, selectableItems);
|
||||
const isSelected = items.find((i) => i.id === item.id);
|
||||
if (isSelected) {
|
||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||
return getContextMenu(items);
|
||||
}
|
||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||
// Also update the selection with it
|
||||
setSelected([item.id], false);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
return getContextMenu([item]);
|
||||
};
|
||||
}, [getContextMenu, selectableItems, setSelected, treeId]);
|
||||
|
||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||
const anchorSelectedId = store.get(focusIdsFamily(treeId)).anchorId;
|
||||
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||
const selectedIds = store.get(selectedIdsAtom);
|
||||
|
||||
// Mark the item as the last one selected
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
|
||||
if (shiftKey) {
|
||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
||||
const anchorIndex = validSelectableItems.findIndex(
|
||||
(i) => i.node.item.id === anchorSelectedId,
|
||||
);
|
||||
const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id);
|
||||
|
||||
// Nothing was selected yet, so just select this item
|
||||
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
|
||||
setSelected([item.id], true);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currIndex > anchorIndex) {
|
||||
// Selecting down
|
||||
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else if (currIndex < anchorIndex) {
|
||||
// Selecting up
|
||||
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
setSelected([item.id], true);
|
||||
}
|
||||
} else if (type() === 'macos' ? metaKey : ctrlKey) {
|
||||
const withoutCurr = selectedIds.filter((id) => id !== item.id);
|
||||
if (withoutCurr.length === selectedIds.length) {
|
||||
// It wasn't in there, so add it
|
||||
setSelected([...selectedIds, item.id], true);
|
||||
} else {
|
||||
// It was in there, so remove it
|
||||
setSelected(withoutCurr, true);
|
||||
}
|
||||
} else {
|
||||
// Select single
|
||||
setSelected([item.id], true);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
}
|
||||
},
|
||||
[selectableItems, setSelected, treeId],
|
||||
);
|
||||
|
||||
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, e) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
handleSelect(item, e);
|
||||
} else {
|
||||
handleSelect(item, e);
|
||||
onActivate?.(item);
|
||||
}
|
||||
},
|
||||
[handleSelect, onActivate],
|
||||
);
|
||||
|
||||
const selectPrevItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = validSelectableItems[index - 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const selectNextItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = validSelectableItems[index + 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const selectParentItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem =
|
||||
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
|
||||
if (lastSelectedItem?.parent != null) {
|
||||
handleSelect(lastSelectedItem.parent.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectPrevItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectNextItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowRight' || e.key === 'l',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] === true
|
||||
) {
|
||||
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: false });
|
||||
} else {
|
||||
selectNextItem(e);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is in a folder, select its parent.
|
||||
// If the selected item is an expanded folder, collapse it.
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowLeft' || e.key === 'h',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] !== true
|
||||
) {
|
||||
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: true });
|
||||
} else {
|
||||
selectParentItem(e);
|
||||
}
|
||||
},
|
||||
{ options: {} },
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Escape', async () => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
clearDragState();
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
if (lastSelectedId == null) return;
|
||||
setSelected([lastSelectedId], false);
|
||||
});
|
||||
|
||||
const handleDragMove = useCallback(
|
||||
function handleDragMove(e: DragMoveEvent) {
|
||||
const over = e.over;
|
||||
if (!over) {
|
||||
// Clear the drop indicator when hovering outside the tree
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
childIndex: null,
|
||||
index: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Not sure when or if this happens
|
||||
if (e.active.rect.current.initial == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Root is anything past the end of the list, so set it to the end
|
||||
const hoveringRoot = over.id === root.item.id;
|
||||
if (hoveringRoot) {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: root.item.id,
|
||||
parentDepth: root.depth,
|
||||
index: selectableItems.length,
|
||||
childIndex: selectableItems.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
|
||||
if (overSelectableItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggingItems = store.get(draggingIdsFamily(treeId));
|
||||
for (const id of draggingItems) {
|
||||
const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
|
||||
if (item.localDrag && !isSameParent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const node = overSelectableItem.node;
|
||||
const side = computeSideForDragMove(node.item.id, e);
|
||||
|
||||
const item = node.item;
|
||||
let hoveredParent = node.parent;
|
||||
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
|
||||
const hovered = selectableItems[dragIndex]?.node ?? null;
|
||||
const hoveredIndex = dragIndex + (side === 'before' ? 0 : 1);
|
||||
let hoveredChildIndex = overSelectableItem.index + (side === 'before' ? 0 : 1);
|
||||
|
||||
// Move into the folder if it's open and we're moving after it
|
||||
if (hovered?.children != null && side === 'after') {
|
||||
hoveredParent = hovered;
|
||||
hoveredChildIndex = 0;
|
||||
}
|
||||
|
||||
const parentId = hoveredParent?.item.id ?? null;
|
||||
const parentDepth = hoveredParent?.depth ?? null;
|
||||
const index = hoveredIndex;
|
||||
const childIndex = hoveredChildIndex;
|
||||
const existing = store.get(hoveredParentFamily(treeId));
|
||||
if (
|
||||
!(
|
||||
parentId === existing.parentId &&
|
||||
parentDepth === existing.parentDepth &&
|
||||
index === existing.index &&
|
||||
childIndex === existing.childIndex
|
||||
)
|
||||
) {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId,
|
||||
parentDepth,
|
||||
index,
|
||||
childIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[root.depth, root.item.id, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
function handleDragStart(e: DragStartEvent) {
|
||||
const selectedItems = getSelectedItems(store, treeId, selectableItems);
|
||||
const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);
|
||||
|
||||
// If we started dragging an already-selected item, we'll use that
|
||||
if (isDraggingSelectedItem) {
|
||||
store.set(
|
||||
draggingIdsFamily(treeId),
|
||||
selectedItems.map((i) => i.id),
|
||||
);
|
||||
} else {
|
||||
// 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) {
|
||||
store.set(draggingIdsFamily(treeId), [activeItem.id]);
|
||||
// Also update selection to just be this one
|
||||
handleSelect(activeItem, {
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
index: null,
|
||||
childIndex: null,
|
||||
});
|
||||
store.set(draggingIdsFamily(treeId), []);
|
||||
}, [treeId]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
function handleDragEnd(e: DragEndEvent) {
|
||||
// Get this from the store so our callback doesn't change all the time
|
||||
const {
|
||||
index: hoveredIndex,
|
||||
parentId: hoveredParentId,
|
||||
childIndex: hoveredChildIndex,
|
||||
} = store.get(hoveredParentFamily(treeId));
|
||||
const draggingItems = store.get(draggingIdsFamily(treeId));
|
||||
clearDragState();
|
||||
|
||||
// Dropped outside the tree?
|
||||
if (e.over == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredParentS =
|
||||
hoveredParentId === root.item.id
|
||||
? { node: root, depth: 0, index: 0 }
|
||||
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
|
||||
const hoveredParent = hoveredParentS?.node ?? null;
|
||||
|
||||
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
|
||||
const draggedNodes: TreeNode<T>[] = draggingItems
|
||||
.map((id) => {
|
||||
return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||
})
|
||||
.filter((n) => n != null)
|
||||
// Filter out invalid drags (dragging into descendant)
|
||||
.filter(
|
||||
(n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),
|
||||
);
|
||||
|
||||
// Work on a local copy of target children
|
||||
const nextChildren = [...(hoveredParent.children ?? [])];
|
||||
|
||||
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
||||
let insertAt = hoveredChildIndex ?? 0;
|
||||
for (const node of draggedNodes) {
|
||||
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
||||
if (i !== -1) {
|
||||
nextChildren.splice(i, 1);
|
||||
if (i < insertAt) insertAt -= 1; // account for removed-before
|
||||
}
|
||||
}
|
||||
|
||||
// Batch callback
|
||||
onDragEnd?.({
|
||||
items: draggedNodes.map((n) => n.item),
|
||||
parent: hoveredParent.item,
|
||||
children: nextChildren.map((c) => c.item),
|
||||
insertAt,
|
||||
});
|
||||
},
|
||||
[treeId, clearDragState, selectableItems, root, onDragEnd],
|
||||
);
|
||||
|
||||
const treeItemListProps: Omit<
|
||||
TreeItemListProps<T>,
|
||||
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||
> = {
|
||||
getItemKey,
|
||||
getContextMenu: handleGetContextMenu,
|
||||
renderContextMenu,
|
||||
onClick: handleClick,
|
||||
getEditOptions,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
};
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: MouseEvent<HTMLElement>) => {
|
||||
if (getContextMenu == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = await getContextMenu([]);
|
||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[getContextMenu],
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
<CollapsedAtomContext.Provider value={collapsedAtom}>
|
||||
{showContextMenu &&
|
||||
renderContextMenu?.({
|
||||
items: showContextMenu.items,
|
||||
position: showContextMenu,
|
||||
onClose: handleCloseContextMenu,
|
||||
})}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={clearDragState}
|
||||
onDragAbort={clearDragState}
|
||||
onDragMove={handleDragMove}
|
||||
measuring={measuring}
|
||||
autoScroll
|
||||
>
|
||||
<div
|
||||
ref={treeRef}
|
||||
className={classNames(
|
||||
className,
|
||||
'outline-none h-full',
|
||||
'overflow-y-auto overflow-x-hidden',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'[&_.tree-item.selected_.tree-item-inner]:text-text',
|
||||
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
|
||||
'[&:not(:focus-within)]:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight',
|
||||
'[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active',
|
||||
// Round the items, but only if the ends of the selection.
|
||||
// Also account for the drop marker being in between items
|
||||
'[&_.tree-item]:rounded-md',
|
||||
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none',
|
||||
'[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none',
|
||||
'[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none',
|
||||
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none',
|
||||
)}
|
||||
>
|
||||
<TreeItemList
|
||||
addTreeItemRef={handleAddTreeItemRef}
|
||||
nodes={selectableItems}
|
||||
treeId={treeId}
|
||||
{...treeItemListProps}
|
||||
/>
|
||||
</div>
|
||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
|
||||
</div>
|
||||
<TreeDragOverlay
|
||||
treeId={treeId}
|
||||
selectableItems={selectableItems}
|
||||
ItemInner={ItemInner}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</DndContext>
|
||||
</CollapsedAtomContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Preserve generics through forwardRef:
|
||||
const Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(
|
||||
props: TreeProps<T> & RefAttributes<TreeHandle>,
|
||||
) => ReactElement | null;
|
||||
|
||||
export const Tree = memo(
|
||||
Tree_,
|
||||
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||
},
|
||||
) as typeof Tree_;
|
||||
|
||||
function DropRegionAfterList({
|
||||
id,
|
||||
onContextMenu,
|
||||
}: {
|
||||
id: string;
|
||||
onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Meh
|
||||
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
|
||||
}
|
||||
|
||||
function getValidSelectableItems<T extends { id: string }>(
|
||||
store: JotaiStore,
|
||||
collapsedAtom: CollapsedAtom,
|
||||
selectableItems: SelectableTreeNode<T>[],
|
||||
) {
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
return selectableItems.filter((i) => {
|
||||
if (i.node.hidden) return false;
|
||||
let p = i.node.parent;
|
||||
while (p) {
|
||||
if (collapsed[p.item.id]) return false;
|
||||
p = p.parent;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
31
packages/ui/src/components/tree/TreeDragOverlay.tsx
Normal file
31
packages/ui/src/components/tree/TreeDragOverlay.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { DragOverlay } from '@dnd-kit/core';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { draggingIdsFamily } from './atoms';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
|
||||
export function TreeDragOverlay<T extends { id: string }>({
|
||||
treeId,
|
||||
selectableItems,
|
||||
getItemKey,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
}: {
|
||||
treeId: string;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlotInner'>) {
|
||||
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||
return (
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<TreeItemList
|
||||
treeId={`${treeId}.dragging`}
|
||||
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
forceDepth={0}
|
||||
/>
|
||||
</DragOverlay>
|
||||
);
|
||||
}
|
||||
36
packages/ui/src/components/tree/TreeDropMarker.tsx
Normal file
36
packages/ui/src/components/tree/TreeDropMarker.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
import { DropMarker } from '../DropMarker';
|
||||
import { hoveredParentDepthFamily, isIndexHoveredFamily } from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { useIsCollapsed } from './context';
|
||||
|
||||
export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
||||
className,
|
||||
treeId,
|
||||
node,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
index: number;
|
||||
node: TreeNode<T> | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
const collapsed = useIsCollapsed(node?.item.id);
|
||||
|
||||
// Only show if we're hovering over this index
|
||||
if (!isHovered) return null;
|
||||
|
||||
// Don't show if we're right under a collapsed folder, or empty folder. We have a separate
|
||||
// delayed expansion animation for that.
|
||||
if (collapsed || node?.children?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="drop-marker relative" style={{ paddingLeft: `${parentDepth}rem` }}>
|
||||
<DropMarker className={classNames(className)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
32
packages/ui/src/components/tree/TreeIndentGuide.tsx
Normal file
32
packages/ui/src/components/tree/TreeIndentGuide.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
import { hoveredParentDepthFamily, isAncestorHoveredFamily } from './atoms';
|
||||
|
||||
export const TreeIndentGuide = memo(function TreeIndentGuide({
|
||||
treeId,
|
||||
depth,
|
||||
ancestorIds,
|
||||
}: {
|
||||
treeId: string;
|
||||
depth: number;
|
||||
ancestorIds: string[];
|
||||
}) {
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
const isHovered = useAtomValue(isAncestorHoveredFamily({ treeId, ancestorIds }));
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
key={i}
|
||||
className={classNames(
|
||||
'w-[calc(1rem+0.5px)] border-r border-r-text-subtlest',
|
||||
!(parentDepth === i + 1 && isHovered) && 'opacity-30',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
388
packages/ui/src/components/tree/TreeItem.tsx
Normal file
388
packages/ui/src/components/tree/TreeItem.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
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 type { ContextMenuRenderer } from './common';
|
||||
import { Icon } from '../Icon';
|
||||
import { isLastFocusedFamily, isSelectedFamily } from './atoms';
|
||||
import { useCollapsedAtom, useIsAncestorCollapsed, useIsCollapsed, useSetCollapsed } from './context';
|
||||
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<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' | 'renderContextMenu'
|
||||
> & {
|
||||
node: TreeNode<T>;
|
||||
className?: string;
|
||||
onClick?: (item: T, e: TreeItemClickEvent) => void;
|
||||
getContextMenu?: (item: T) => unknown[] | Promise<unknown[]>;
|
||||
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_<T extends { id: string }>({
|
||||
treeId,
|
||||
node,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
getContextMenu,
|
||||
renderContextMenu,
|
||||
onClick,
|
||||
getEditOptions,
|
||||
className,
|
||||
depth,
|
||||
setRef,
|
||||
}: TreeItemProps<T>) {
|
||||
const store = useStore();
|
||||
const listItemRef = useRef<HTMLLIElement>(null);
|
||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||
const isCollapsed = useIsCollapsed(node.item.id);
|
||||
const setCollapsed = useSetCollapsed(node.item.id);
|
||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const handle = useMemo<TreeItemHandle>(
|
||||
() => ({
|
||||
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 isAncestorCollapsed = useIsAncestorCollapsed(ancestorIds);
|
||||
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: unknown[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => onClick?.(node.item, e),
|
||||
[node, onClick],
|
||||
);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, [setCollapsed]);
|
||||
|
||||
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<HTMLInputElement>) {
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
async (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
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 collapsedMap = store.get(collapsedAtom);
|
||||
const itemCollapsed = !!collapsedMap[node.item.id];
|
||||
if (itemCollapsed && isFolder && hasChildren && side === 'after') {
|
||||
setDropHover('animate');
|
||||
clearTimeout(startedHoverTimeout.current);
|
||||
startedHoverTimeout.current = setTimeout(() => {
|
||||
store.set(collapsedAtom, { ...store.get(collapsedAtom), [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<HTMLElement>) => {
|
||||
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<HTMLButtonElement>) {
|
||||
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 (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={classNames(
|
||||
className,
|
||||
'tree-item',
|
||||
'h-sm',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)]',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
dropHover != null && 'relative z-10 ring-2 ring-primary',
|
||||
dropHover === 'animate' && 'animate-blinkRing',
|
||||
isSelected && 'selected',
|
||||
)}
|
||||
>
|
||||
<TreeIndentGuide treeId={treeId} depth={depth} ancestorIds={ancestorIds} />
|
||||
<div
|
||||
className={classNames(
|
||||
'text-text-subtle',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
|
||||
)}
|
||||
>
|
||||
{showContextMenu &&
|
||||
renderContextMenu?.({
|
||||
items: showContextMenu.items,
|
||||
position: showContextMenu,
|
||||
onClose: handleCloseContextMenu,
|
||||
})}
|
||||
{node.children != null ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="h-full pl-[0.5rem] outline-none"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<Icon
|
||||
icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
|
||||
className={classNames(
|
||||
'transition-transform text-text-subtlest',
|
||||
'ml-auto',
|
||||
'w-[1rem] h-[1rem]',
|
||||
!isCollapsed && node.children.length > 0 && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden /> // Make the grid happy
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={handleSetDraggableRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
disabled={editing}
|
||||
className="cursor-default tree-item-inner pr-1 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={isLastSelected ? 0 : -1}
|
||||
>
|
||||
{ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
|
||||
{getEditOptions != null && editing ? (
|
||||
(() => {
|
||||
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||
return (
|
||||
<input
|
||||
data-disable-hotkey
|
||||
ref={handleEditFocus}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className="bg-transparent outline-none w-full cursor-text"
|
||||
onBlur={handleEditBlur}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<ItemInner treeId={treeId} item={node.item} />
|
||||
)}
|
||||
</button>
|
||||
{ItemRightSlot != null ? (
|
||||
<ItemRightSlot treeId={treeId} item={node.item} />
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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_;
|
||||
49
packages/ui/src/components/tree/TreeItemList.tsx
Normal file
49
packages/ui/src/components/tree/TreeItemList.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeDropMarker } from './TreeDropMarker';
|
||||
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import { TreeItem } from './TreeItem';
|
||||
|
||||
export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions' | 'renderContextMenu'
|
||||
> &
|
||||
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
||||
nodes: SelectableTreeNode<T>[];
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
forceDepth?: number;
|
||||
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||
};
|
||||
|
||||
export function TreeItemList<T extends { id: string }>({
|
||||
className,
|
||||
getItemKey,
|
||||
nodes,
|
||||
style,
|
||||
treeId,
|
||||
forceDepth,
|
||||
addTreeItemRef,
|
||||
...props
|
||||
}: TreeItemListProps<T>) {
|
||||
return (
|
||||
<ul style={style} className={className}>
|
||||
<TreeDropMarker node={null} treeId={treeId} index={0} />
|
||||
{nodes.map((child, i) => (
|
||||
<Fragment key={getItemKey(child.node.item)}>
|
||||
<TreeItem
|
||||
treeId={treeId}
|
||||
setRef={addTreeItemRef}
|
||||
node={child.node}
|
||||
getItemKey={getItemKey}
|
||||
depth={forceDepth == null ? child.depth : forceDepth}
|
||||
{...props}
|
||||
/>
|
||||
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
76
packages/ui/src/components/tree/atoms.ts
Normal file
76
packages/ui/src/components/tree/atoms.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomFamily, selectAtom } from 'jotai/utils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const selectedIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<string[]>([]);
|
||||
});
|
||||
|
||||
export const isSelectedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) => {
|
||||
return selectAtom(selectedIdsFamily(treeId), (ids) => ids.includes(itemId), Object.is);
|
||||
},
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const focusIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<{ lastId: string | null; anchorId: string | null }>({ lastId: null, anchorId: null });
|
||||
});
|
||||
|
||||
export const isLastFocusedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||
selectAtom(focusIdsFamily(treeId), (v) => v.lastId === itemId, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const draggingIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<string[]>([]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const hoveredParentFamily = atomFamily((_treeId: string) => {
|
||||
return atom<{
|
||||
index: number | null;
|
||||
childIndex: number | null;
|
||||
parentId: string | null;
|
||||
parentDepth: number | null;
|
||||
}>({
|
||||
index: null,
|
||||
childIndex: null,
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
});
|
||||
});
|
||||
|
||||
export const isParentHoveredFamily = atomFamily(
|
||||
({ treeId, parentId }: { treeId: string; parentId: string | null }) =>
|
||||
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
|
||||
(a, b) => 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),
|
||||
(a, b) => a.treeId === b.treeId && a.index === b.index,
|
||||
);
|
||||
|
||||
export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
|
||||
selectAtom(
|
||||
hoveredParentFamily(treeId),
|
||||
(s) => s.parentDepth,
|
||||
(a, b) => Object.is(a, b), // prevents re-render unless the value changes
|
||||
),
|
||||
);
|
||||
|
||||
96
packages/ui/src/components/tree/common.ts
Normal file
96
packages/ui/src/components/tree/common.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { createStore } from 'jotai';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CollapsedAtom } from './context';
|
||||
import { selectedIdsFamily } from './atoms';
|
||||
|
||||
export type JotaiStore = ReturnType<typeof createStore>;
|
||||
|
||||
export type ContextMenuRenderer = (props: {
|
||||
items: unknown[];
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}) => ReactNode;
|
||||
|
||||
export interface TreeNode<T extends { id: string }> {
|
||||
children?: TreeNode<T>[];
|
||||
item: T;
|
||||
hidden?: boolean;
|
||||
parent: TreeNode<T> | null;
|
||||
depth: number;
|
||||
draggable?: boolean;
|
||||
localDrag?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectableTreeNode<T extends { id: string }> {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function getSelectedItems<T extends { id: string }>(
|
||||
store: JotaiStore,
|
||||
treeId: string,
|
||||
selectableItems: SelectableTreeNode<T>[],
|
||||
) {
|
||||
const selectedItemIds = store.get(selectedIdsFamily(treeId));
|
||||
return selectableItems
|
||||
.filter((i) => selectedItemIds.includes(i.node.item.id))
|
||||
.map((i) => i.node.item);
|
||||
}
|
||||
|
||||
export function equalSubtree<T extends { id: string }>(
|
||||
a: TreeNode<T>,
|
||||
b: TreeNode<T>,
|
||||
getItemKey: (t: T) => string,
|
||||
): boolean {
|
||||
if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false;
|
||||
const ak = a.children ?? [];
|
||||
const bk = b.children ?? [];
|
||||
|
||||
if (ak.length !== bk.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ak.length; i++) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: none
|
||||
if (!equalSubtree(ak[i]!, bk[i]!, getItemKey)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getNodeKey<T extends { id: string }>(a: TreeNode<T>, getItemKey: (i: T) => string) {
|
||||
return getItemKey(a.item) + a.hidden;
|
||||
}
|
||||
|
||||
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||
if (node.parent == null) return false;
|
||||
if (node.parent.item.id === ancestorId) return true;
|
||||
|
||||
// Check parents recursively
|
||||
return hasAncestor(node.parent, ancestorId);
|
||||
}
|
||||
|
||||
export function isVisibleNode<T extends { id: string }>(store: JotaiStore, collapsedAtom: CollapsedAtom, node: TreeNode<T>) {
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
let p = node.parent;
|
||||
while (p) {
|
||||
if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node
|
||||
p = p.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closestVisibleNode<T extends { id: string }>(
|
||||
store: JotaiStore,
|
||||
collapsedAtom: CollapsedAtom,
|
||||
node: TreeNode<T>,
|
||||
): TreeNode<T> | null {
|
||||
let n: TreeNode<T> | null = node;
|
||||
while (n) {
|
||||
if (isVisibleNode(store, collapsedAtom, n) && !n.hidden) return n;
|
||||
if (n.parent == null) return null;
|
||||
n = n.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
60
packages/ui/src/components/tree/context.ts
Normal file
60
packages/ui/src/components/tree/context.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { WritableAtom } from 'jotai';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react';
|
||||
|
||||
type CollapsedMap = Record<string, boolean>;
|
||||
type SetAction = CollapsedMap | ((prev: CollapsedMap) => CollapsedMap);
|
||||
export type CollapsedAtom = WritableAtom<CollapsedMap, [SetAction], void>;
|
||||
|
||||
export const CollapsedAtomContext = createContext<CollapsedAtom | null>(null);
|
||||
|
||||
export function useCollapsedAtom(): CollapsedAtom {
|
||||
const atom = useContext(CollapsedAtomContext);
|
||||
if (!atom) throw new Error('CollapsedAtomContext not provided');
|
||||
return atom;
|
||||
}
|
||||
|
||||
export function useIsCollapsed(itemId: string | undefined) {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const derivedAtom = useMemo(
|
||||
() => selectAtom(collapsedAtom, (map) => !!map[itemId ?? 'n/a'], Object.is),
|
||||
[collapsedAtom, itemId],
|
||||
);
|
||||
return useAtomValue(derivedAtom);
|
||||
}
|
||||
|
||||
export function useSetCollapsed(itemId: string | undefined) {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const store = useStore();
|
||||
return useCallback(
|
||||
(next: boolean | ((prev: boolean) => boolean)) => {
|
||||
const key = itemId ?? 'n/a';
|
||||
const prevMap = store.get(collapsedAtom);
|
||||
const prevValue = !!prevMap[key];
|
||||
const value = typeof next === 'function' ? next(prevValue) : next;
|
||||
if (value === prevValue) return;
|
||||
store.set(collapsedAtom, { ...prevMap, [key]: value });
|
||||
},
|
||||
[collapsedAtom, itemId, store],
|
||||
);
|
||||
}
|
||||
|
||||
export function useCollapsedMap() {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
return useAtomValue(collapsedAtom);
|
||||
}
|
||||
|
||||
export function useIsAncestorCollapsed(ancestorIds: string[]) {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const derivedAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
collapsedAtom,
|
||||
(collapsed) => ancestorIds.some((id) => collapsed[id]),
|
||||
(a, b) => a === b,
|
||||
),
|
||||
[collapsedAtom, ancestorIds],
|
||||
);
|
||||
return useAtomValue(derivedAtom);
|
||||
}
|
||||
30
packages/ui/src/components/tree/useSelectableItems.ts
Normal file
30
packages/ui/src/components/tree/useSelectableItems.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
|
||||
export function useSelectableItems<T extends { id: string }>(root: TreeNode<T>) {
|
||||
return useMemo(() => {
|
||||
const selectableItems: SelectableTreeNode<T>[] = [];
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<T>, depth = 0) => {
|
||||
if (node.children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse to children
|
||||
let selectableIndex = 0;
|
||||
for (const child of node.children) {
|
||||
selectableItems.push({
|
||||
node: child,
|
||||
index: selectableIndex++,
|
||||
depth,
|
||||
});
|
||||
|
||||
next(child, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
next(root);
|
||||
return selectableItems;
|
||||
}, [root]);
|
||||
}
|
||||
@@ -11,3 +11,8 @@ export { useDebouncedState } from "./hooks/useDebouncedState";
|
||||
export { HEADER_SIZE_MD, HEADER_SIZE_LG, WINDOW_CONTROLS_WIDTH } from "./lib/constants";
|
||||
export { DropMarker } from "./components/DropMarker";
|
||||
export { computeSideForDragMove } from "./lib/dnd";
|
||||
export { Tree } from "./components/tree/Tree";
|
||||
export type { TreeHandle, TreeProps } from "./components/tree/Tree";
|
||||
export type { TreeNode } from "./components/tree/common";
|
||||
export type { TreeItemProps } from "./components/tree/TreeItem";
|
||||
export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms";
|
||||
|
||||
Reference in New Issue
Block a user