Some sidebar fixes

This commit is contained in:
Gregory Schier
2025-11-03 14:17:11 -08:00
parent 749ca968ec
commit bf97ea1659
3 changed files with 267 additions and 142 deletions

View File

@@ -1,18 +1,23 @@
import { type } from '@tauri-apps/plugin-os'; import { type } from "@tauri-apps/plugin-os";
import { settingsAtom } from '@yaakapp-internal/models'; import { settingsAtom } from "@yaakapp-internal/models";
import classNames from 'classnames'; import classNames from "classnames";
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import React, { useMemo } from 'react'; import React, { useMemo } from "react";
import { useIsFullscreen } from '../hooks/useIsFullscreen'; import { useIsFullscreen } from "../hooks/useIsFullscreen";
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants'; import {
import { WindowControls } from './WindowControls'; HEADER_SIZE_LG,
HEADER_SIZE_MD,
WINDOW_CONTROLS_WIDTH,
} from "../lib/constants";
import { WindowControls } from "./WindowControls";
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> { interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode; children?: ReactNode;
size: 'md' | 'lg'; size: "md" | "lg";
ignoreControlsSpacing?: boolean; ignoreControlsSpacing?: boolean;
onlyXWindowControl?: boolean; onlyXWindowControl?: boolean;
hideControls?: boolean;
} }
export function HeaderSize({ export function HeaderSize({
@@ -22,6 +27,7 @@ export function HeaderSize({
ignoreControlsSpacing, ignoreControlsSpacing,
onlyXWindowControl, onlyXWindowControl,
children, children,
hideControls,
}: HeaderSizeProps) { }: HeaderSizeProps) {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const isFullscreen = useIsFullscreen(); const isFullscreen = useIsFullscreen();
@@ -29,10 +35,10 @@ export function HeaderSize({
const s = { ...style }; const s = { ...style };
// Set the height (use min-height because scaling font size may make it larger // Set the height (use min-height because scaling font size may make it larger
if (size === 'md') s.minHeight = HEADER_SIZE_MD; if (size === "md") s.minHeight = HEADER_SIZE_MD;
if (size === 'lg') s.minHeight = HEADER_SIZE_LG; if (size === "lg") s.minHeight = HEADER_SIZE_LG;
if (type() === 'macos') { if (type() === "macos") {
if (!isFullscreen) { if (!isFullscreen) {
// Add large padding for window controls // Add large padding for window controls
s.paddingLeft = 72 / settings.interfaceScale; s.paddingLeft = 72 / settings.interfaceScale;
@@ -57,21 +63,21 @@ export function HeaderSize({
style={finalStyle} style={finalStyle}
className={classNames( className={classNames(
className, className,
'pt-[1px]', // Make up for bottom border "pt-[1px]", // Make up for bottom border
'select-none relative', "select-none relative",
'w-full border-b border-border-subtle min-w-0', "w-full border-b border-border-subtle min-w-0",
)} )}
> >
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */} {/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
<div <div
className={classNames( className={classNames(
'pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid', "pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid",
'px-1', // Give it some space on either end for focus outlines "px-1", // Give it some space on either end for focus outlines
)} )}
> >
{children} {children}
</div> </div>
<WindowControls onlyX={onlyXWindowControl} /> {!hideControls && <WindowControls onlyX={onlyXWindowControl} />}
</div> </div>
); );
} }

View File

@@ -141,11 +141,11 @@ export function Workspace() {
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
className={classNames( className={classNames(
'x-theme-sidebar', 'x-theme-sidebar',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[14rem]', 'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[20rem]',
'grid grid-rows-[auto_1fr]', 'grid grid-rows-[auto_1fr]',
)} )}
> >
<HeaderSize size="lg" className="border-transparent"> <HeaderSize hideControls size="lg" className="border-transparent flex items-center">
<SidebarActions /> <SidebarActions />
</HeaderSize> </HeaderSize>
<ErrorBoundary name="Sidebar (Floating)"> <ErrorBoundary name="Sidebar (Floating)">

View File

@@ -1,4 +1,8 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; import type {
DragEndEvent,
DragMoveEvent,
DragStartEvent,
} from "@dnd-kit/core";
import { import {
DndContext, DndContext,
MeasuringStrategy, MeasuringStrategy,
@@ -7,11 +11,17 @@ import {
useDroppable, useDroppable,
useSensor, useSensor,
useSensors, useSensors,
} from '@dnd-kit/core'; } from "@dnd-kit/core";
import { type } from '@tauri-apps/plugin-os'; import { type } from "@tauri-apps/plugin-os";
import classNames from 'classnames'; import classNames from "classnames";
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react'; import type {
import React, { ComponentType,
MouseEvent,
ReactElement,
Ref,
RefAttributes,
} from "react";
import {
forwardRef, forwardRef,
memo, memo,
useCallback, useCallback,
@@ -20,14 +30,14 @@ import React, {
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from "react";
import { useKey, useKeyPressEvent } from 'react-use'; import { useKey, useKeyPressEvent } from "react-use";
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey'; import type { HotkeyAction, HotKeyOptions } from "../../../hooks/useHotKey";
import { useHotKey } from '../../../hooks/useHotKey'; import { useHotKey } from "../../../hooks/useHotKey";
import { computeSideForDragMove } from '../../../lib/dnd'; import { computeSideForDragMove } from "../../../lib/dnd";
import { jotaiStore } from '../../../lib/jotai'; import { jotaiStore } from "../../../lib/jotai";
import type { ContextMenuProps, DropdownItem } from '../Dropdown'; import type { ContextMenuProps, DropdownItem } from "../Dropdown";
import { ContextMenu } from '../Dropdown'; import { ContextMenu } from "../Dropdown";
import { import {
collapsedFamily, collapsedFamily,
draggingIdsFamily, draggingIdsFamily,
@@ -35,14 +45,23 @@ import {
hoveredParentFamily, hoveredParentFamily,
isCollapsedFamily, isCollapsedFamily,
selectedIdsFamily, selectedIdsFamily,
} from './atoms'; } from "./atoms";
import type { SelectableTreeNode, TreeNode } from './common'; import type { SelectableTreeNode, TreeNode } from "./common";
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common'; import {
import { TreeDragOverlay } from './TreeDragOverlay'; closestVisibleNode,
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem'; equalSubtree,
import type { TreeItemListProps } from './TreeItemList'; getSelectedItems,
import { TreeItemList } from './TreeItemList'; hasAncestor,
import { useSelectableItems } from './useSelectableItems'; } 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 */ /** So we re-calculate after expanding a folder during drag */
const measuring = { droppable: { strategy: MeasuringStrategy.Always } }; const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
@@ -51,15 +70,21 @@ export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>; root: TreeNode<T>;
treeId: string; treeId: string;
getItemKey: (item: T) => 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 }>; ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>; ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>; ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
className?: string; className?: string;
onActivate?: (item: T) => void; 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?: { hotkeys?: {
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>; actions: Partial<
Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>
>;
}; };
getEditOptions?: (item: T) => { getEditOptions?: (item: T) => {
defaultValue: string; defaultValue: string;
@@ -96,19 +121,24 @@ function TreeInner<T extends { id: string }>(
) { ) {
const treeRef = useRef<HTMLDivElement>(null); const treeRef = useRef<HTMLDivElement>(null);
const selectableItems = useSelectableItems(root); const selectableItems = useSelectableItems(root);
const [showContextMenu, setShowContextMenu] = useState<{ const [showContextMenu, setShowContextMenu] = useState<
items: DropdownItem[]; {
x: number; items: DropdownItem[];
y: number; x: number;
} | null>(null); y: number;
} | null
>(null);
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({}); const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => { const handleAddTreeItemRef = useCallback(
if (r == null) { (item: T, r: TreeItemHandle | null) => {
delete treeItemRefs.current[item.id]; if (r == null) {
} else { delete treeItemRefs.current[item.id];
treeItemRefs.current[item.id] = r; } else {
} treeItemRefs.current[item.id] = r;
}, []); }
},
[],
);
// Select the first item on first render // Select the first item on first render
useEffect(() => { useEffect(() => {
@@ -146,7 +176,9 @@ function TreeInner<T extends { id: string }>(
const ensureTabbableItem = useCallback(() => { const ensureTabbableItem = useCallback(() => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; 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 (lastSelectedItem == null) { if (lastSelectedItem == null) {
return false; return false;
} }
@@ -184,7 +216,8 @@ function TreeInner<T extends { id: string }>(
() => ({ () => ({
treeId, treeId,
focus: tryFocus, focus: tryFocus,
hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false, hasFocus: () =>
treeRef.current?.contains(document.activeElement) ?? false,
renameItem: (id) => treeItemRefs.current[id]?.rename(), renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id) => { selectItem: (id) => {
setSelected([id], false); setSelected([id], false);
@@ -195,7 +228,9 @@ function TreeInner<T extends { id: string }>(
const items = getSelectedItems(treeId, selectableItems); const items = getSelectedItems(treeId, selectableItems);
const menuItems = await getContextMenu(items); const menuItems = await getContextMenu(items);
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; 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; if (rect == null) return;
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y }); setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
}, },
@@ -217,42 +252,66 @@ function TreeInner<T extends { id: string }>(
// If right-clicked an item that was NOT in the multiple-selection, just use that one // If right-clicked an item that was NOT in the multiple-selection, just use that one
// Also update the selection with it // Also update the selection with it
setSelected([item.id], false); 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]); return getContextMenu([item]);
} }
}; };
}, [getContextMenu, selectableItems, setSelected, treeId]); }, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>( const handleSelect = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
(item, { shiftKey, metaKey, ctrlKey }) => { (item, { shiftKey, metaKey, ctrlKey }) => {
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId; const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
const selectedIdsAtom = selectedIdsFamily(treeId); const selectedIdsAtom = selectedIdsFamily(treeId);
const selectedIds = jotaiStore.get(selectedIdsAtom); const selectedIds = jotaiStore.get(selectedIdsAtom);
// Mark the item as the last one selected // 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) { if (shiftKey) {
const anchorIndex = selectableItems.findIndex((i) => i.node.item.id === anchorSelectedId); const anchorIndex = selectableItems.findIndex((i) =>
const currIndex = selectableItems.findIndex((v) => v.node.item.id === item.id); i.node.item.id === anchorSelectedId
);
const currIndex = selectableItems.findIndex((v) =>
v.node.item.id === item.id
);
// Nothing was selected yet, so just select this item // 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); setSelected([item.id], true);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id })); jotaiStore.set(
focusIdsFamily(treeId),
(prev) => ({ ...prev, anchorId: item.id }),
);
return; return;
} }
const validSelectableItems = getValidSelectableItems(treeId, selectableItems); const validSelectableItems = getValidSelectableItems(
treeId,
selectableItems,
);
if (currIndex > anchorIndex) { if (currIndex > anchorIndex) {
// Selecting down // Selecting down
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1); const itemsToSelect = validSelectableItems.slice(
anchorIndex,
currIndex + 1,
);
setSelected( setSelected(
itemsToSelect.map((v) => v.node.item.id), itemsToSelect.map((v) => v.node.item.id),
true, true,
); );
} else if (currIndex < anchorIndex) { } else if (currIndex < anchorIndex) {
// Selecting up // Selecting up
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1); const itemsToSelect = validSelectableItems.slice(
currIndex,
anchorIndex + 1,
);
setSelected( setSelected(
itemsToSelect.map((v) => v.node.item.id), itemsToSelect.map((v) => v.node.item.id),
true, true,
@@ -260,7 +319,7 @@ function TreeInner<T extends { id: string }>(
} else { } else {
setSelected([item.id], true); setSelected([item.id], true);
} }
} else if (type() === 'macos' ? metaKey : ctrlKey) { } else if (type() === "macos" ? metaKey : ctrlKey) {
const withoutCurr = selectedIds.filter((id) => id !== item.id); const withoutCurr = selectedIds.filter((id) => id !== item.id);
if (withoutCurr.length === selectedIds.length) { if (withoutCurr.length === selectedIds.length) {
// It wasn't in there, so add it // It wasn't in there, so add it
@@ -272,13 +331,16 @@ function TreeInner<T extends { id: string }>(
} else { } else {
// Select single // Select single
setSelected([item.id], true); 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], [selectableItems, setSelected, treeId],
); );
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>( const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
(item, e) => { (item, e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) { if (e.shiftKey || e.ctrlKey || e.metaKey) {
handleSelect(item, e); handleSelect(item, e);
@@ -293,8 +355,13 @@ function TreeInner<T extends { id: string }>(
const selectPrevItem = useCallback( const selectPrevItem = useCallback(
(e: TreeItemClickEvent) => { (e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(treeId, selectableItems); const validSelectableItems = getValidSelectableItems(
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); treeId,
selectableItems,
);
const index = validSelectableItems.findIndex((i) =>
i.node.item.id === lastSelectedId
);
const item = validSelectableItems[index - 1]; const item = validSelectableItems[index - 1];
if (item != null) { if (item != null) {
handleSelect(item.node.item, e); handleSelect(item.node.item, e);
@@ -306,8 +373,13 @@ function TreeInner<T extends { id: string }>(
const selectNextItem = useCallback( const selectNextItem = useCallback(
(e: TreeItemClickEvent) => { (e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(treeId, selectableItems); const validSelectableItems = getValidSelectableItems(
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); treeId,
selectableItems,
);
const index = validSelectableItems.findIndex((i) =>
i.node.item.id === lastSelectedId
);
const item = validSelectableItems[index + 1]; const item = validSelectableItems[index + 1];
if (item != null) { if (item != null) {
handleSelect(item.node.item, e); handleSelect(item.node.item, e);
@@ -320,7 +392,8 @@ function TreeInner<T extends { id: string }>(
(e: TreeItemClickEvent) => { (e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = 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) { if (lastSelectedItem?.parent != null) {
handleSelect(lastSelectedItem.parent.item, e); handleSelect(lastSelectedItem.parent.item, e);
} }
@@ -329,7 +402,7 @@ function TreeInner<T extends { id: string }>(
); );
useKey( useKey(
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k', (e) => e.key === "ArrowUp" || e.key.toLowerCase() === "k",
(e) => { (e) => {
if (!isTreeFocused()) return; if (!isTreeFocused()) return;
e.preventDefault(); e.preventDefault();
@@ -340,7 +413,7 @@ function TreeInner<T extends { id: string }>(
); );
useKey( useKey(
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j', (e) => e.key === "ArrowDown" || e.key.toLowerCase() === "j",
(e) => { (e) => {
if (!isTreeFocused()) return; if (!isTreeFocused()) return;
e.preventDefault(); e.preventDefault();
@@ -352,21 +425,26 @@ function TreeInner<T extends { id: string }>(
// If the selected item is a collapsed folder, expand it. Otherwise, select next item // If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey( useKey(
(e) => e.key === 'ArrowRight' || e.key === 'l', (e) => e.key === "ArrowRight" || e.key === "l",
(e) => { (e) => {
if (!isTreeFocused()) return; if (!isTreeFocused()) return;
e.preventDefault(); e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId)); const collapsed = jotaiStore.get(collapsedFamily(treeId));
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; 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 ( if (
lastSelectedId && lastSelectedId &&
lastSelectedItem?.node.children != null && lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] === true collapsed[lastSelectedItem.node.item.id] === true
) { ) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false); jotaiStore.set(
isCollapsedFamily({ treeId, itemId: lastSelectedId }),
false,
);
} else { } else {
selectNextItem(e); selectNextItem(e);
} }
@@ -378,21 +456,26 @@ function TreeInner<T extends { id: string }>(
// If the selected item is in a folder, select its parent. // If the selected item is in a folder, select its parent.
// If the selected item is an expanded folder, collapse it. // If the selected item is an expanded folder, collapse it.
useKey( useKey(
(e) => e.key === 'ArrowLeft' || e.key === 'h', (e) => e.key === "ArrowLeft" || e.key === "h",
(e) => { (e) => {
if (!isTreeFocused()) return; if (!isTreeFocused()) return;
e.preventDefault(); e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId)); const collapsed = jotaiStore.get(collapsedFamily(treeId));
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; 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 ( if (
lastSelectedId && lastSelectedId &&
lastSelectedItem?.node.children != null && lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] !== true collapsed[lastSelectedItem.node.item.id] !== true
) { ) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true); jotaiStore.set(
isCollapsedFamily({ treeId, itemId: lastSelectedId }),
true,
);
} else { } else {
selectParentItem(e); selectParentItem(e);
} }
@@ -401,7 +484,7 @@ function TreeInner<T extends { id: string }>(
[selectableItems, handleSelect], [selectableItems, handleSelect],
); );
useKeyPressEvent('Escape', async () => { useKeyPressEvent("Escape", async () => {
if (!treeRef.current?.contains(document.activeElement)) return; if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState(); clearDragState();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
@@ -428,24 +511,6 @@ function TreeInner<T extends { id: string }>(
return; 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;
}
}
// Root is anything past the end of the list, so set it to the end // Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id; const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) { if (hoveringRoot) {
@@ -458,18 +523,41 @@ function TreeInner<T extends { id: string }>(
return; 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 node = overSelectableItem.node;
const side = computeSideForDragMove(node.item.id, e); const side = computeSideForDragMove(node.item.id, e);
const item = node.item; const item = node.item;
let hoveredParent = node.parent; 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 hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); const hoveredIndex = dragIndex + (side === "above" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (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 // 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; hoveredParent = hovered;
hoveredChildIndex = 0; hoveredChildIndex = 0;
} }
@@ -487,7 +575,12 @@ function TreeInner<T extends { id: string }>(
childIndex === existing.childIndex childIndex === existing.childIndex
) )
) { ) {
jotaiStore.set(hoveredParentFamily(treeId), { parentId, parentDepth, index, childIndex }); jotaiStore.set(hoveredParentFamily(treeId), {
parentId,
parentDepth,
index,
childIndex,
});
} }
}, },
[root.depth, root.item.id, selectableItems, treeId], [root.depth, root.item.id, selectableItems, treeId],
@@ -496,7 +589,9 @@ function TreeInner<T extends { id: string }>(
const handleDragStart = useCallback( const handleDragStart = useCallback(
function handleDragStart(e: DragStartEvent) { function handleDragStart(e: DragStartEvent) {
const selectedItems = getSelectedItems(treeId, selectableItems); 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 we started dragging an already-selected item, we'll use that
if (isDraggingSelectedItem) { if (isDraggingSelectedItem) {
@@ -506,11 +601,17 @@ function TreeInner<T extends { id: string }>(
); );
} else { } else {
// If we started dragging a non-selected item, only drag that item // 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) { if (activeItem != null) {
jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]); jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]);
// Also update selection to just be this one // Also update selection to just be this one
handleSelect(activeItem, { shiftKey: false, metaKey: false, ctrlKey: false }); handleSelect(activeItem, {
shiftKey: false,
metaKey: false,
ctrlKey: false,
});
} }
} }
}, },
@@ -543,25 +644,30 @@ function TreeInner<T extends { id: string }>(
return; return;
} }
const hoveredParentS = const hoveredParentS = hoveredParentId === root.item.id
hoveredParentId === root.item.id ? { node: root, depth: 0, index: 0 }
? { node: root, depth: 0, index: 0 } : (selectableItems.find((i) => i.node.item.id === hoveredParentId) ??
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null); null);
const hoveredParent = hoveredParentS?.node ?? null; const hoveredParent = hoveredParentS?.node ?? null;
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) { if (
hoveredParent == null || hoveredIndex == null || !draggingItems?.length
) {
return; return;
} }
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems) // Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
const draggedNodes: TreeNode<T>[] = draggingItems const draggedNodes: TreeNode<T>[] = draggingItems
.map((id) => { .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((n) => n != null)
// Filter out invalid drags (dragging into descendant) // Filter out invalid drags (dragging into descendant)
.filter( .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 // Work on a local copy of target children
@@ -590,7 +696,7 @@ function TreeInner<T extends { id: string }>(
const treeItemListProps: Omit< const treeItemListProps: Omit<
TreeItemListProps<T>, TreeItemListProps<T>,
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex' "nodes" | "treeId" | "activeIdAtom" | "hoveredParent" | "hoveredIndex"
> = { > = {
getItemKey, getItemKey,
getContextMenu: handleGetContextMenu, getContextMenu: handleGetContextMenu,
@@ -613,11 +719,17 @@ function TreeInner<T extends { id: string }>(
[getContextMenu], [getContextMenu],
); );
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
return ( return (
<> <>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} /> <TreeHotKeys
treeId={treeId}
hotkeys={hotkeys}
selectableItems={selectableItems}
/>
{showContextMenu && ( {showContextMenu && (
<ContextMenu <ContextMenu
items={showContextMenu.items} items={showContextMenu.items}
@@ -632,32 +744,31 @@ function TreeInner<T extends { id: string }>(
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={clearDragState} onDragCancel={clearDragState}
onDragAbort={clearDragState} onDragAbort={clearDragState}
measuring={measuring}
onDragMove={handleDragMove} onDragMove={handleDragMove}
measuring={measuring}
autoScroll autoScroll
> >
<div <div
ref={treeRef} ref={treeRef}
className={classNames( className={classNames(
className, className,
'outline-none h-full', "outline-none h-full",
'overflow-y-auto overflow-x-hidden', "overflow-y-auto overflow-x-hidden",
'grid grid-rows-[auto_1fr]', "grid grid-rows-[auto_1fr]",
)} )}
> >
<div <div
className={classNames( className={classNames(
'[&_.tree-item.selected_.tree-item-inner]:text-text', "[&_.tree-item.selected_.tree-item-inner]:text-text",
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active', "[&:focus-within]:[&_.tree-item.selected]:bg-surface-active",
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight', "[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight",
// Round the items, but only if the ends of the selection. // Round the items, but only if the ends of the selection.
// Also account for the drop marker being in between items // Also account for the drop marker being in between items
'[&_.tree-item]:rounded-md', "[&_.tree-item]:rounded-md",
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none', "[&_.tree-item.selected+.tree-item.selected]:rounded-t-none",
'[&_.tree-item.selected+.drop-marker+.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(+.tree-item.selected)]:rounded-b-none",
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none', "[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
)} )}
> >
<TreeItemList <TreeItemList
@@ -668,7 +779,10 @@ function TreeInner<T extends { id: string }>(
/> />
</div> </div>
{/* Assign root ID so we can reuse our same move/end logic */} {/* 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> </div>
<TreeDragOverlay <TreeDragOverlay
treeId={treeId} treeId={treeId}
@@ -690,7 +804,10 @@ export const Tree = memo(
Tree_, Tree_,
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => { ({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
for (const key of Object.keys(prevProps)) { 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; return false;
} }
} }
@@ -706,7 +823,9 @@ function DropRegionAfterList({
onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void; onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void;
}) { }) {
const { setNodeRef } = useDroppable({ id }); const { setNodeRef } = useDroppable({ id });
return <div ref={setNodeRef} onContextMenu={onContextMenu} />; return (
<div ref={setNodeRef} onContextMenu={onContextMenu} />
);
} }
interface TreeHotKeyProps<T extends { id: string }> { interface TreeHotKeyProps<T extends { id: string }> {
@@ -735,7 +854,7 @@ function TreeHotKey<T extends { id: string }>({
...options, ...options,
enable: () => { enable: () => {
if (enable == null) return true; if (enable == null) return true;
if (typeof enable === 'function') return enable(); if (typeof enable === "function") return enable();
else return enable; else return enable;
}, },
}, },
@@ -749,7 +868,7 @@ function TreeHotKeys<T extends { id: string }>({
selectableItems, selectableItems,
}: { }: {
treeId: string; treeId: string;
hotkeys: TreeProps<T>['hotkeys']; hotkeys: TreeProps<T>["hotkeys"];
selectableItems: SelectableTreeNode<T>[]; selectableItems: SelectableTreeNode<T>[];
}) { }) {
if (hotkeys == null) return null; if (hotkeys == null) return null;