A bunch of fixes

This commit is contained in:
Gregory Schier
2025-11-04 08:44:08 -08:00
parent 81ceb981e8
commit 0cb633e479
11 changed files with 301 additions and 401 deletions

View File

@@ -1,8 +1,4 @@
import type {
DragEndEvent,
DragMoveEvent,
DragStartEvent,
} from "@dnd-kit/core";
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
MeasuringStrategy,
@@ -11,16 +7,10 @@ import {
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";
} 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,
@@ -30,14 +20,14 @@ import {
useMemo,
useRef,
useState,
} from "react";
import { useKey, useKeyPressEvent } from "react-use";
import type { HotkeyAction, HotKeyOptions } 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";
} from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import type { HotkeyAction, HotKeyOptions } 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,
@@ -45,23 +35,14 @@ import {
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";
} 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 } };
@@ -70,21 +51,15 @@ export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
getItemKey: (item: T) => string;
getContextMenu?: (
items: T[],
) => ContextMenuProps["items"] | Promise<ContextMenuProps["items"]>;
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;
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
hotkeys?: {
actions: Partial<
Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>
>;
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>;
};
getEditOptions?: (item: T) => {
defaultValue: string;
@@ -121,24 +96,19 @@ function TreeInner<T extends { id: string }>(
) {
const treeRef = useRef<HTMLDivElement>(null);
const selectableItems = useSelectableItems(root);
const [showContextMenu, setShowContextMenu] = useState<
{
items: DropdownItem[];
x: number;
y: number;
} | null
>(null);
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;
}
},
[],
);
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
useEffect(() => {
@@ -176,8 +146,8 @@ function TreeInner<T extends { id: string }>(
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) =>
i.node.item.id === lastSelectedId && !i.node.hidden
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);
@@ -224,8 +194,7 @@ function TreeInner<T extends { id: string }>(
() => ({
treeId,
focus: tryFocus,
hasFocus: () =>
treeRef.current?.contains(document.activeElement) ?? false,
hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false,
renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id) => {
if (jotaiStore.get(selectedIdsFamily(treeId)).includes(id)) {
@@ -240,9 +209,7 @@ function TreeInner<T extends { id: string }>(
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;
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
if (rect == null) return;
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
},
@@ -264,66 +231,43 @@ function TreeInner<T extends { id: string }>(
// 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 }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]);
}
};
}, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
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 }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
if (shiftKey) {
const anchorIndex = selectableItems.findIndex((i) =>
i.node.item.id === anchorSelectedId
);
const currIndex = selectableItems.findIndex((v) =>
v.node.item.id === item.id
);
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
) {
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
setSelected([item.id], true);
jotaiStore.set(
focusIdsFamily(treeId),
(prev) => ({ ...prev, anchorId: item.id }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
return;
}
const validSelectableItems = getValidSelectableItems(
treeId,
selectableItems,
);
if (currIndex > anchorIndex) {
// Selecting down
const itemsToSelect = validSelectableItems.slice(
anchorIndex,
currIndex + 1,
);
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,
);
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
@@ -331,7 +275,7 @@ function TreeInner<T extends { id: string }>(
} else {
setSelected([item.id], true);
}
} else if (type() === "macos" ? metaKey : ctrlKey) {
} 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
@@ -343,16 +287,13 @@ function TreeInner<T extends { id: string }>(
} else {
// Select single
setSelected([item.id], true);
jotaiStore.set(
focusIdsFamily(treeId),
(prev) => ({ ...prev, anchorId: item.id }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
}
},
[selectableItems, setSelected, treeId],
);
const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
handleSelect(item, e);
@@ -367,13 +308,8 @@ function TreeInner<T extends { id: string }>(
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 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);
@@ -385,13 +321,8 @@ function TreeInner<T extends { id: string }>(
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 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);
@@ -404,8 +335,7 @@ function TreeInner<T extends { id: string }>(
(e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem =
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ??
null;
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
if (lastSelectedItem?.parent != null) {
handleSelect(lastSelectedItem.parent.item, e);
}
@@ -414,7 +344,7 @@ function TreeInner<T extends { id: string }>(
);
useKey(
(e) => e.key === "ArrowUp" || e.key.toLowerCase() === "k",
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -425,7 +355,7 @@ function TreeInner<T extends { id: string }>(
);
useKey(
(e) => e.key === "ArrowDown" || e.key.toLowerCase() === "j",
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -437,26 +367,21 @@ function TreeInner<T extends { id: string }>(
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey(
(e) => e.key === "ArrowRight" || e.key === "l",
(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
);
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,
);
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false);
} else {
selectNextItem(e);
}
@@ -468,26 +393,21 @@ function TreeInner<T extends { id: string }>(
// 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) => 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
);
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,
);
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true);
} else {
selectParentItem(e);
}
@@ -496,7 +416,7 @@ function TreeInner<T extends { id: string }>(
[selectableItems, handleSelect],
);
useKeyPressEvent("Escape", async () => {
useKeyPressEvent('Escape', async () => {
if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
@@ -535,22 +455,19 @@ function TreeInner<T extends { id: string }>(
return;
}
const overSelectableItem =
selectableItems.find((i) => i.node.item.id === over.id) ?? null;
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;
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;
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
if (item.localDrag && !isSameParent) {
return;
}
@@ -561,15 +478,13 @@ function TreeInner<T extends { id: string }>(
const item = node.item;
let hoveredParent = node.parent;
const dragIndex =
selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "above" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index +
(side === "above" ? 0 : 1);
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === 'above' ? 0 : 1);
// Move into the folder if it's open and we're moving below it
if (hovered?.children != null && side === "below") {
if (hovered?.children != null && side === 'below') {
hoveredParent = hovered;
hoveredChildIndex = 0;
}
@@ -601,9 +516,7 @@ function TreeInner<T extends { id: string }>(
const handleDragStart = useCallback(
function handleDragStart(e: DragStartEvent) {
const selectedItems = getSelectedItems(treeId, selectableItems);
const isDraggingSelectedItem = selectedItems.find((i) =>
i.id === e.active.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) {
@@ -613,9 +526,7 @@ function TreeInner<T extends { id: string }>(
);
} 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;
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
@@ -656,30 +567,25 @@ function TreeInner<T extends { id: string }>(
return;
}
const hoveredParentS = hoveredParentId === root.item.id
? { node: root, depth: 0, index: 0 }
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ??
null);
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
) {
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;
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),
(n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),
);
// Work on a local copy of target children
@@ -708,7 +614,7 @@ function TreeInner<T extends { id: string }>(
const treeItemListProps: Omit<
TreeItemListProps<T>,
"nodes" | "treeId" | "activeIdAtom" | "hoveredParent" | "hoveredIndex"
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
> = {
getItemKey,
getContextMenu: handleGetContextMenu,
@@ -731,17 +637,11 @@ function TreeInner<T extends { id: string }>(
[getContextMenu],
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return (
<>
<TreeHotKeys
treeId={treeId}
hotkeys={hotkeys}
selectableItems={selectableItems}
/>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
{showContextMenu && (
<ContextMenu
items={showContextMenu.items}
@@ -764,23 +664,23 @@ function TreeInner<T extends { id: string }>(
ref={treeRef}
className={classNames(
className,
"outline-none h-full",
"overflow-y-auto overflow-x-hidden",
"grid grid-rows-[auto_1fr]",
'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]:bg-surface-highlight",
'[&_.tree-item.selected_.tree-item-inner]:text-text',
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight',
// 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",
'[&_.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
@@ -791,10 +691,7 @@ function TreeInner<T extends { id: string }>(
/>
</div>
{/* Assign root ID so we can reuse our same move/end logic */}
<DropRegionAfterList
id={root.item.id}
onContextMenu={handleContextMenu}
/>
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
</div>
<TreeDragOverlay
treeId={treeId}
@@ -816,10 +713,7 @@ 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]
) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
return false;
}
}
@@ -864,7 +758,7 @@ function TreeHotKey<T extends { id: string }>({
...options,
enable: () => {
if (enable == null) return true;
if (typeof enable === "function") return enable();
if (typeof enable === 'function') return enable();
else return enable;
},
},
@@ -878,7 +772,7 @@ function TreeHotKeys<T extends { id: string }>({
selectableItems,
}: {
treeId: string;
hotkeys: TreeProps<T>["hotkeys"];
hotkeys: TreeProps<T>['hotkeys'];
selectableItems: SelectableTreeNode<T>[];
}) {
if (hotkeys == null) return null;