mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-15 21:53:36 +01:00
819 lines
26 KiB
TypeScript
819 lines
26 KiB
TypeScript
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 type { HotKeyOptions, HotkeyAction } from '../../../hooks/useHotKey';
|
|
import { useHotKey } from '../../../hooks/useHotKey';
|
|
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,
|
|
isCollapsedFamily,
|
|
selectedIdsFamily,
|
|
} from './atoms';
|
|
import type { 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;
|
|
getItemKey: (item: T) => string;
|
|
getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
|
|
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;
|
|
hotkeys?: {
|
|
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>;
|
|
};
|
|
getEditOptions?: (item: T) => {
|
|
defaultValue: string;
|
|
placeholder?: string;
|
|
onChange: (item: T, text: string) => void;
|
|
};
|
|
}
|
|
|
|
export interface TreeHandle {
|
|
treeId: string;
|
|
focus: () => boolean;
|
|
hasFocus: () => boolean;
|
|
selectItem: (id: string, focus?: boolean) => void;
|
|
renameItem: (id: string) => void;
|
|
showContextMenu: () => void;
|
|
}
|
|
|
|
function TreeInner<T extends { id: string }>(
|
|
{
|
|
className,
|
|
getContextMenu,
|
|
getEditOptions,
|
|
getItemKey,
|
|
hotkeys,
|
|
onActivate,
|
|
onDragEnd,
|
|
ItemInner,
|
|
ItemLeftSlotInner,
|
|
ItemRightSlot,
|
|
root,
|
|
treeId,
|
|
}: TreeProps<T>,
|
|
ref: Ref<TreeHandle>,
|
|
) {
|
|
const treeRef = useRef<HTMLDivElement>(null);
|
|
const selectableItems = useSelectableItems(root);
|
|
const [showContextMenu, setShowContextMenu] = useState<{
|
|
items: DropdownItem[];
|
|
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 = jotaiStore.get(selectedIdsFamily(treeId));
|
|
const fallback = selectableItems[0];
|
|
if (ids.length === 0 && fallback != null) {
|
|
jotaiStore.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
|
|
jotaiStore.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 = jotaiStore.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;
|
|
jotaiStore.set(selectedIdsFamily(treeId), [id]);
|
|
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const closest = closestVisibleNode(treeId, lastSelectedItem.node);
|
|
if (closest != null) {
|
|
const id = closest.item.id;
|
|
jotaiStore.set(selectedIdsFamily(treeId), [id]);
|
|
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
|
}
|
|
}, [selectableItems, treeId]);
|
|
|
|
// Ensure there's always a tabbable item after collapsed state changes
|
|
useEffect(() => {
|
|
const unsub = jotaiStore.sub(collapsedFamily(treeId), 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) => {
|
|
jotaiStore.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,
|
|
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
|
selectItem: (id, focus) => {
|
|
if (jotaiStore.get(selectedIdsFamily(treeId)).includes(id)) {
|
|
// Already selected
|
|
return;
|
|
}
|
|
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
|
setSelected([id], focus === true);
|
|
},
|
|
showContextMenu: async () => {
|
|
if (getContextMenu == null) return;
|
|
const items = getSelectedItems(treeId, selectableItems);
|
|
const menuItems = await getContextMenu(items);
|
|
const lastSelectedId = jotaiStore.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(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);
|
|
jotaiStore.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 = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
|
|
const selectedIdsAtom = selectedIdsFamily(treeId);
|
|
const selectedIds = jotaiStore.get(selectedIdsAtom);
|
|
|
|
// Mark the item as the last one selected
|
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
|
|
|
if (shiftKey) {
|
|
const validSelectableItems = getValidSelectableItems(treeId, 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);
|
|
jotaiStore.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);
|
|
jotaiStore.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 = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
|
const validSelectableItems = getValidSelectableItems(treeId, 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 = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
|
const validSelectableItems = getValidSelectableItems(treeId, 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 = jotaiStore.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 = jotaiStore.get(collapsedFamily(treeId));
|
|
const lastSelectedId = jotaiStore.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
|
|
) {
|
|
jotaiStore.set(isCollapsedFamily({ treeId, itemId: 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 = jotaiStore.get(collapsedFamily(treeId));
|
|
const lastSelectedId = jotaiStore.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
|
|
) {
|
|
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true);
|
|
} else {
|
|
selectParentItem(e);
|
|
}
|
|
},
|
|
{ options: {} },
|
|
[selectableItems, handleSelect],
|
|
);
|
|
|
|
useKeyPressEvent('Escape', async () => {
|
|
if (!treeRef.current?.contains(document.activeElement)) return;
|
|
clearDragState();
|
|
const lastSelectedId = jotaiStore.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
|
|
jotaiStore.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) {
|
|
jotaiStore.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 = jotaiStore.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 = jotaiStore.get(hoveredParentFamily(treeId));
|
|
if (
|
|
!(
|
|
parentId === existing.parentId &&
|
|
parentDepth === existing.parentDepth &&
|
|
index === existing.index &&
|
|
childIndex === existing.childIndex
|
|
)
|
|
) {
|
|
jotaiStore.set(hoveredParentFamily(treeId), {
|
|
parentId,
|
|
parentDepth,
|
|
index,
|
|
childIndex,
|
|
});
|
|
}
|
|
},
|
|
[root.depth, root.item.id, selectableItems, treeId],
|
|
);
|
|
|
|
const handleDragStart = useCallback(
|
|
function handleDragStart(e: DragStartEvent) {
|
|
const selectedItems = getSelectedItems(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) {
|
|
jotaiStore.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) {
|
|
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],
|
|
);
|
|
|
|
const clearDragState = useCallback(() => {
|
|
jotaiStore.set(hoveredParentFamily(treeId), {
|
|
parentId: null,
|
|
parentDepth: null,
|
|
index: null,
|
|
childIndex: null,
|
|
});
|
|
jotaiStore.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,
|
|
} = jotaiStore.get(hoveredParentFamily(treeId));
|
|
const draggingItems = jotaiStore.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,
|
|
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 (
|
|
<>
|
|
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
|
{showContextMenu && (
|
|
<ContextMenu
|
|
items={showContextMenu.items}
|
|
triggerPosition={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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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} />;
|
|
}
|
|
|
|
interface TreeHotKeyProps<T extends { id: string }> {
|
|
action: HotkeyAction;
|
|
selectableItems: SelectableTreeNode<T>[];
|
|
treeId: string;
|
|
onDone: (items: T[]) => void;
|
|
priority?: number;
|
|
enable?: boolean | (() => boolean);
|
|
}
|
|
|
|
function TreeHotKey<T extends { id: string }>({
|
|
treeId,
|
|
action,
|
|
onDone,
|
|
selectableItems,
|
|
enable,
|
|
...options
|
|
}: TreeHotKeyProps<T>) {
|
|
useHotKey(
|
|
action,
|
|
() => {
|
|
onDone(getSelectedItems(treeId, selectableItems));
|
|
},
|
|
{
|
|
...options,
|
|
enable: () => {
|
|
if (enable == null) return true;
|
|
if (typeof enable === 'function') return enable();
|
|
return enable;
|
|
},
|
|
},
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function TreeHotKeys<T extends { id: string }>({
|
|
treeId,
|
|
hotkeys,
|
|
selectableItems,
|
|
}: {
|
|
treeId: string;
|
|
hotkeys: TreeProps<T>['hotkeys'];
|
|
selectableItems: SelectableTreeNode<T>[];
|
|
}) {
|
|
if (hotkeys == null) return null;
|
|
|
|
return (
|
|
<>
|
|
{Object.entries(hotkeys.actions).map(([hotkey, { cb, ...options }]) => (
|
|
<TreeHotKey
|
|
key={hotkey}
|
|
action={hotkey as HotkeyAction}
|
|
treeId={treeId}
|
|
onDone={cb}
|
|
selectableItems={selectableItems}
|
|
{...options}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function getValidSelectableItems<T extends { id: string }>(
|
|
treeId: string,
|
|
selectableItems: SelectableTreeNode<T>[],
|
|
) {
|
|
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
|
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;
|
|
});
|
|
}
|