mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-06 21:05:24 +02:00
Flattened the sidebar tree
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import type { CSSProperties} from 'react';
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DropMarker = memo(
|
export const DropMarker = memo(
|
||||||
function DropMarker({ className }: Props) {
|
function DropMarker({ className, style }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
style={style}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'relative w-full h-0 overflow-visible pointer-events-none',
|
'relative w-full h-0 overflow-visible pointer-events-none',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
||||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||||
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
|
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
|
||||||
@@ -71,7 +71,13 @@ function getItemKey(item: Model) {
|
|||||||
].join('::');
|
].join('::');
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) {
|
const SidebarLeftSlot = memo(function SidebarLeftSlot({
|
||||||
|
treeId,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
treeId: string;
|
||||||
|
item: Model;
|
||||||
|
}) {
|
||||||
if (item.model === 'folder') {
|
if (item.model === 'folder') {
|
||||||
return <Icon icon="folder" />;
|
return <Icon icon="folder" />;
|
||||||
} else if (item.model === 'workspace') {
|
} else if (item.model === 'workspace') {
|
||||||
@@ -86,9 +92,9 @@ function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
|
const SidebarInnerItem = memo(function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
|
||||||
const response = useAtomValue(
|
const response = useAtomValue(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -119,7 +125,7 @@ function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function NewSidebar({ className }: { className?: string }) {
|
function NewSidebar({ className }: { className?: string }) {
|
||||||
const [hidden, setHidden] = useSidebarHidden();
|
const [hidden, setHidden] = useSidebarHidden();
|
||||||
@@ -292,7 +298,7 @@ const sidebarTreeAtom = atom((get) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Put requests and folders into a tree structure
|
// Put requests and folders into a tree structure
|
||||||
const next = (node: TreeNode<Model>): TreeNode<Model> => {
|
const next = (node: TreeNode<Model>, depth: number): TreeNode<Model> => {
|
||||||
const childItems = childrenMap[node.item.id] ?? [];
|
const childItems = childrenMap[node.item.id] ?? [];
|
||||||
|
|
||||||
// Recurse to children
|
// Recurse to children
|
||||||
@@ -301,18 +307,22 @@ const sidebarTreeAtom = atom((get) => {
|
|||||||
node.children = node.children ?? [];
|
node.children = node.children ?? [];
|
||||||
for (const item of childItems) {
|
for (const item of childItems) {
|
||||||
treeParentMap[item.id] = node;
|
treeParentMap[item.id] = node;
|
||||||
node.children.push(next({ item, parent: node }));
|
node.children.push(next({ item, parent: node, depth }, depth + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
return next({
|
return next(
|
||||||
item: activeWorkspace,
|
{
|
||||||
children: [],
|
item: activeWorkspace,
|
||||||
parent: null,
|
children: [],
|
||||||
});
|
parent: null,
|
||||||
|
depth: 0,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-intern
|
|||||||
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 { memo } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
||||||
@@ -23,7 +24,7 @@ const methodNames: Record<string, string> = {
|
|||||||
websocket: 'WS',
|
websocket: 'WS',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HttpMethodTag({ request, className, short }: Props) {
|
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const method =
|
const method =
|
||||||
request.model === 'http_request' && request.bodyType === 'graphql'
|
request.model === 'http_request' && request.bodyType === 'graphql'
|
||||||
@@ -42,9 +43,9 @@ export function HttpMethodTag({ request, className, short }: Props) {
|
|||||||
short={short}
|
short={short}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function HttpMethodTagRaw({
|
function HttpMethodTagRaw({
|
||||||
className,
|
className,
|
||||||
method,
|
method,
|
||||||
colored,
|
colored,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Color } from '@yaakapp-internal/plugins';
|
import type { Color } from '@yaakapp-internal/plugins';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as lucide from 'lucide-react';
|
import * as lucide from 'lucide-react';
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { CSSProperties, HTMLAttributes } from 'react';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
@@ -127,6 +127,7 @@ const icons = {
|
|||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
icon: keyof typeof icons;
|
icon: keyof typeof icons;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
spin?: boolean;
|
spin?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -138,12 +139,14 @@ export const Icon = memo(function Icon({
|
|||||||
color = 'default',
|
color = 'default',
|
||||||
spin,
|
spin,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
style,
|
||||||
className,
|
className,
|
||||||
title,
|
title,
|
||||||
}: IconProps) {
|
}: IconProps) {
|
||||||
const Component = icons[icon] ?? icons._unknown;
|
const Component = icons[icon] ?? icons._unknown;
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
|
style={style}
|
||||||
title={title}
|
title={title}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -9,31 +9,27 @@ import {
|
|||||||
} 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 { useAtomValue } from 'jotai';
|
|
||||||
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
|
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
|
||||||
import {
|
import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} 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 { sidebarCollapsedAtom } from '../../../hooks/useSidebarItemCollapsed';
|
|
||||||
import { jotaiStore } from '../../../lib/jotai';
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
import type { ContextMenuProps } from '../Dropdown';
|
import type { ContextMenuProps } from '../Dropdown';
|
||||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
|
import {
|
||||||
|
collapsedFamily,
|
||||||
|
draggingIdsFamily,
|
||||||
|
focusIdsFamily,
|
||||||
|
hoveredParentFamily,
|
||||||
|
selectedIdsFamily,
|
||||||
|
} from './atoms';
|
||||||
import type { SelectableTreeNode, TreeNode } from './common';
|
import type { SelectableTreeNode, TreeNode } from './common';
|
||||||
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||||
import type { TreeItemProps } from './TreeItem';
|
import type { TreeItemProps } from './TreeItem';
|
||||||
import type { TreeItemListProps } from './TreeItemList';
|
import type { TreeItemListProps } from './TreeItemList';
|
||||||
import { TreeItemList } from './TreeItemList';
|
import { TreeItemList } from './TreeItemList';
|
||||||
|
import { useSelectableItems } from './useSelectableItems';
|
||||||
|
|
||||||
export interface TreeProps<T extends { id: string }> {
|
export interface TreeProps<T extends { id: string }> {
|
||||||
root: TreeNode<T>;
|
root: TreeNode<T>;
|
||||||
@@ -75,8 +71,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
ref: Ref<TreeHandle>,
|
ref: Ref<TreeHandle>,
|
||||||
) {
|
) {
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey);
|
const selectableItems = useSelectableItems(root);
|
||||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const tryFocus = useCallback(() => {
|
const tryFocus = useCallback(() => {
|
||||||
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
||||||
@@ -228,7 +223,12 @@ function TreeInner<T extends { id: string }>(
|
|||||||
const over = e.over;
|
const over = e.over;
|
||||||
if (!over) {
|
if (!over) {
|
||||||
// Clear the drop indicator when hovering outside the tree
|
// Clear the drop indicator when hovering outside the tree
|
||||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||||
|
parentId: null,
|
||||||
|
parentDepth: null,
|
||||||
|
childIndex: null,
|
||||||
|
index: null,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,39 +242,59 @@ function TreeInner<T extends { id: string }>(
|
|||||||
if (hoveringRoot) {
|
if (hoveringRoot) {
|
||||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||||
parentId: root.item.id,
|
parentId: root.item.id,
|
||||||
index: root.children?.length ?? 0,
|
parentDepth: root.depth,
|
||||||
|
index: selectableItems.length,
|
||||||
|
childIndex: selectableItems.length,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null;
|
const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
|
||||||
if (node == null) {
|
if (selectableItem == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const node = selectableItem.node;
|
||||||
|
|
||||||
const side = computeSideForDragMove(node, e);
|
const side = computeSideForDragMove(node, e);
|
||||||
|
|
||||||
const item = node.item;
|
const item = node.item;
|
||||||
let hoveredParent = treeParentMap[item.id] ?? null;
|
let hoveredParent = node.parent;
|
||||||
const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99;
|
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
|
||||||
const hovered = hoveredParent?.children?.[dragIndex] ?? null;
|
const hovered = selectableItems[dragIndex]?.node ?? null;
|
||||||
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
||||||
|
let hoveredChildIndex = selectableItem.index + (side === 'above' ? 0 : 1);
|
||||||
|
|
||||||
const collapsedMap = jotaiStore.get(jotaiStore.get(sidebarCollapsedAtom));
|
const collapsedMap = jotaiStore.get(collapsedFamily(treeId));
|
||||||
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
|
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
|
||||||
|
|
||||||
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
|
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
|
||||||
// Move into the folder if it's open and we're moving below it
|
// Move into the folder if it's open and we're moving below it
|
||||||
hoveredParent = hoveredParent?.children?.find((n) => n.item.id === item.id) ?? null;
|
hoveredParent = hovered;
|
||||||
hoveredIndex = 0;
|
hoveredChildIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
const parentId = hoveredParent?.item.id ?? null;
|
||||||
parentId: hoveredParent?.item.id ?? null,
|
const parentDepth = hoveredParent?.depth ?? null;
|
||||||
index: hoveredIndex,
|
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: hoveredParent?.item.id ?? null,
|
||||||
|
parentDepth: hoveredParent?.depth ?? null,
|
||||||
|
index: hoveredIndex,
|
||||||
|
childIndex: hoveredChildIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[root.children?.length, root.item.id, selectableItems, treeId, treeParentMap],
|
[root.depth, root.item.id, selectableItems, treeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
@@ -299,46 +319,57 @@ function TreeInner<T extends { id: string }>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clearDragState = useCallback(() => {
|
const clearDragState = useCallback(() => {
|
||||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||||
|
parentId: null,
|
||||||
|
parentDepth: null,
|
||||||
|
index: null,
|
||||||
|
childIndex: null,
|
||||||
|
});
|
||||||
jotaiStore.set(draggingIdsFamily(treeId), []);
|
jotaiStore.set(draggingIdsFamily(treeId), []);
|
||||||
}, [treeId]);
|
}, [treeId]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
function handleDragEnd(e: DragEndEvent) {
|
function handleDragEnd(e: DragEndEvent) {
|
||||||
// Get this from the store so our callback doesn't change all the time
|
// Get this from the store so our callback doesn't change all the time
|
||||||
const hovered = jotaiStore.get(hoveredParentFamily(treeId));
|
const {
|
||||||
|
index: hoveredIndex,
|
||||||
|
parentId: hoveredParentId,
|
||||||
|
childIndex: hoveredChildIndex,
|
||||||
|
} = jotaiStore.get(hoveredParentFamily(treeId));
|
||||||
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
|
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
|
||||||
clearDragState();
|
clearDragState();
|
||||||
|
|
||||||
// Dropped outside the tree?
|
// Dropped outside the tree?
|
||||||
if (e.over == null) return;
|
if (e.over == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hoveredParent =
|
const hoveredParentS =
|
||||||
hovered.parentId == root.item.id
|
hoveredParentId === root.item.id
|
||||||
? root
|
? { node: root, depth: 0, index: 0 }
|
||||||
: selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node;
|
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
|
||||||
|
const hoveredParent = hoveredParentS?.node ?? null;
|
||||||
|
|
||||||
if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return;
|
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
|
||||||
|
return;
|
||||||
// Optional tiny guard: don't drop into itself
|
}
|
||||||
if (draggingItems.some((id) => id === hovered.parentId)) 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) => {
|
||||||
const parent = treeParentMap[id];
|
return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||||
const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1;
|
|
||||||
return idx >= 0 ? parent!.children![idx]! : null;
|
|
||||||
})
|
})
|
||||||
.filter((n) => n != null)
|
.filter((n) => n != null)
|
||||||
// Filter out invalid drags (dragging into descendant)
|
// Filter out invalid drags (dragging into descendant)
|
||||||
.filter((n) => !hasAncestor(hoveredParent, n.item.id));
|
.filter(
|
||||||
|
(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
|
||||||
const nextChildren = [...(hoveredParent.children ?? [])];
|
const nextChildren = [...(hoveredParent.children ?? [])];
|
||||||
|
|
||||||
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
||||||
let insertAt = hovered.index;
|
let insertAt = hoveredChildIndex ?? 0;
|
||||||
for (const node of draggedNodes) {
|
for (const node of draggedNodes) {
|
||||||
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
||||||
if (i !== -1) {
|
if (i !== -1) {
|
||||||
@@ -355,14 +386,13 @@ function TreeInner<T extends { id: string }>(
|
|||||||
insertAt,
|
insertAt,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[treeId, clearDragState, root, selectableItems, onDragEnd, treeParentMap],
|
[treeId, clearDragState, selectableItems, root, onDragEnd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const treeItemListProps: Omit<
|
const treeItemListProps: Omit<
|
||||||
TreeItemListProps<T>,
|
TreeItemListProps<T>,
|
||||||
'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||||
> = {
|
> = {
|
||||||
depth: 0,
|
|
||||||
getItemKey,
|
getItemKey,
|
||||||
getContextMenu: handleGetContextMenu,
|
getContextMenu: handleGetContextMenu,
|
||||||
onClick: handleClick,
|
onClick: handleClick,
|
||||||
@@ -371,14 +401,6 @@ function TreeInner<T extends { id: string }>(
|
|||||||
ItemLeftSlot,
|
ItemLeftSlot,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = useCallback(function handleFocus() {
|
|
||||||
setIsFocused(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBlur = useCallback(function handleBlur() {
|
|
||||||
setIsFocused(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -396,30 +418,37 @@ function TreeInner<T extends { id: string }>(
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={treeRef}
|
ref={treeRef}
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
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]',
|
||||||
' [&_.tree-item.selected]:text-text',
|
|
||||||
isFocused
|
|
||||||
? '[&_.tree-item.selected]:bg-surface-active'
|
|
||||||
: '[&_.tree-item.selected]:bg-surface-highlight',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TreeItemList node={root} treeId={treeId} {...treeItemListProps} />
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'[&_.tree-item-inner]:bg-surface',
|
||||||
|
'[&_.tree-item-selectable.selected]: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
|
||||||
|
'[&_.tree-item]:rounded-md',
|
||||||
|
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none',
|
||||||
|
'[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TreeItemList nodes={selectableItems} treeId={treeId} {...treeItemListProps} />
|
||||||
|
</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} />
|
<DropRegionAfterList id={root.item.id} />
|
||||||
<TreeDragOverlay
|
|
||||||
treeId={treeId}
|
|
||||||
root={root}
|
|
||||||
selectableItems={selectableItems}
|
|
||||||
ItemInner={ItemInner}
|
|
||||||
getItemKey={getItemKey}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<TreeDragOverlay
|
||||||
|
treeId={treeId}
|
||||||
|
selectableItems={selectableItems}
|
||||||
|
ItemInner={ItemInner}
|
||||||
|
getItemKey={getItemKey}
|
||||||
|
/>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -447,63 +476,6 @@ function DropRegionAfterList({ id }: { id: string }) {
|
|||||||
return <div ref={setNodeRef} />;
|
return <div ref={setNodeRef} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTreeParentMap<T extends { id: string }>(
|
|
||||||
root: TreeNode<T>,
|
|
||||||
getItemKey: (item: T) => string,
|
|
||||||
) {
|
|
||||||
const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
|
||||||
const [{ treeParentMap, selectableItems }, setData] = useState(() => {
|
|
||||||
return compute(root, collapsedMap);
|
|
||||||
});
|
|
||||||
|
|
||||||
const prevRoot = useRef<TreeNode<T> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const shouldRecompute =
|
|
||||||
root == null || prevRoot.current == null || !equalSubtree(root, prevRoot.current, getItemKey);
|
|
||||||
if (!shouldRecompute) return;
|
|
||||||
setData(compute(root, collapsedMap));
|
|
||||||
prevRoot.current = root;
|
|
||||||
}, [collapsedMap, getItemKey, root]);
|
|
||||||
|
|
||||||
return { treeParentMap, selectableItems };
|
|
||||||
}
|
|
||||||
|
|
||||||
function compute<T extends { id: string }>(
|
|
||||||
root: TreeNode<T>,
|
|
||||||
collapsedMap: Record<string, boolean>,
|
|
||||||
) {
|
|
||||||
const treeParentMap: Record<string, TreeNode<T>> = {};
|
|
||||||
const selectableItems: SelectableTreeNode<T>[] = [];
|
|
||||||
|
|
||||||
// Put requests and folders into a tree structure
|
|
||||||
const next = (node: TreeNode<T>, depth: number = 0) => {
|
|
||||||
const isCollapsed = collapsedMap[node.item.id] === true;
|
|
||||||
// console.log("IS COLLAPSED", node.item.name, isCollapsed);
|
|
||||||
if (node.children == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse to children
|
|
||||||
let selectableIndex = 0;
|
|
||||||
for (const child of node.children) {
|
|
||||||
treeParentMap[child.item.id] = node;
|
|
||||||
if (!isCollapsed) {
|
|
||||||
selectableItems.push({
|
|
||||||
node: child,
|
|
||||||
index: selectableIndex++,
|
|
||||||
depth,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next(child, depth + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
next(root);
|
|
||||||
return { treeParentMap, selectableItems };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
||||||
action: HotkeyAction;
|
action: HotkeyAction;
|
||||||
selectableItems: SelectableTreeNode<T>[];
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { DragOverlay } from '@dnd-kit/core';
|
import { DragOverlay } from '@dnd-kit/core';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { draggingIdsFamily } from './atoms';
|
import { draggingIdsFamily } from './atoms';
|
||||||
import type { SelectableTreeNode, TreeNode } from './common';
|
import type { SelectableTreeNode } from './common';
|
||||||
import type { TreeProps } from './Tree';
|
import type { TreeProps } from './Tree';
|
||||||
import { TreeItemList } from './TreeItemList';
|
import { TreeItemList } from './TreeItemList';
|
||||||
|
|
||||||
export function TreeDragOverlay<T extends { id: string }>({
|
export function TreeDragOverlay<T extends { id: string }>({
|
||||||
treeId,
|
treeId,
|
||||||
root,
|
|
||||||
selectableItems,
|
selectableItems,
|
||||||
getItemKey,
|
getItemKey,
|
||||||
ItemInner,
|
ItemInner,
|
||||||
ItemLeftSlot,
|
ItemLeftSlot,
|
||||||
}: {
|
}: {
|
||||||
treeId: string;
|
treeId: string;
|
||||||
root: TreeNode<T>;
|
|
||||||
selectableItems: SelectableTreeNode<T>[];
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
|
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
|
||||||
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||||
@@ -22,22 +20,11 @@ export function TreeDragOverlay<T extends { id: string }>({
|
|||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
<TreeItemList
|
<TreeItemList
|
||||||
treeId={treeId + '.dragging'}
|
treeId={treeId + '.dragging'}
|
||||||
node={{
|
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
|
||||||
item: { ...root.item, id: `${root.item.id}_dragging` },
|
|
||||||
parent: null,
|
|
||||||
children: draggingItems
|
|
||||||
.map((id) => {
|
|
||||||
const child = selectableItems.find((i2) => {
|
|
||||||
return i2.node.item.id === id;
|
|
||||||
})?.node;
|
|
||||||
return child == null ? null : { ...child, children: undefined };
|
|
||||||
})
|
|
||||||
.filter((c) => c != null),
|
|
||||||
}}
|
|
||||||
getItemKey={getItemKey}
|
getItemKey={getItemKey}
|
||||||
ItemInner={ItemInner}
|
ItemInner={ItemInner}
|
||||||
ItemLeftSlot={ItemLeftSlot}
|
ItemLeftSlot={ItemLeftSlot}
|
||||||
depth={0}
|
forceDepth={0}
|
||||||
/>
|
/>
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { DropMarker } from '../../DropMarker';
|
||||||
|
import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from './atoms';
|
||||||
|
|
||||||
|
export const TreeDropMarker = memo(function TreeDropMarker({
|
||||||
|
className,
|
||||||
|
treeId,
|
||||||
|
itemId,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
treeId: string;
|
||||||
|
index: number;
|
||||||
|
itemId: string | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
|
||||||
|
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||||
|
const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: itemId ?? undefined }));
|
||||||
|
|
||||||
|
// Only show if we're hovering over this index
|
||||||
|
if (!isHovered) return null;
|
||||||
|
|
||||||
|
// Don't show if we're right under a collapsed folder. We have a separate delayed expansion
|
||||||
|
// animation for that.
|
||||||
|
if (collapsed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingLeft: `${parentDepth}rem` }}>
|
||||||
|
<DropMarker className={classNames(className)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { hoveredParentDepthFamily } from './atoms';
|
||||||
|
|
||||||
|
export const TreeIndentGuide = memo(function TreeIndentGuide({
|
||||||
|
treeId,
|
||||||
|
depth,
|
||||||
|
}: {
|
||||||
|
treeId: string;
|
||||||
|
depth: number;
|
||||||
|
}) {
|
||||||
|
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
{Array.from({ length: depth }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={classNames(
|
||||||
|
'w-[1rem] border-r border-r-text-subtlest',
|
||||||
|
parentDepth !== i + 1 && 'opacity-30',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,21 +2,18 @@ import type { DragMoveEvent } from '@dnd-kit/core';
|
|||||||
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
import type { MouseEvent, PointerEvent } from 'react';
|
import type { MouseEvent, PointerEvent } from 'react';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
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 { Icon } from '../Icon';
|
import { Icon } from '../Icon';
|
||||||
import {
|
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
|
||||||
isCollapsedFamily,
|
|
||||||
isLastFocusedFamily,
|
|
||||||
isParentHoveredFamily,
|
|
||||||
isSelectedFamily,
|
|
||||||
} from './atoms';
|
|
||||||
import type { TreeNode } from './common';
|
import type { TreeNode } from './common';
|
||||||
import { computeSideForDragMove } from './common';
|
import { computeSideForDragMove } from './common';
|
||||||
import type { TreeProps } from './Tree';
|
import type { TreeProps } from './Tree';
|
||||||
|
import { TreeIndentGuide } from './TreeIndentGuide';
|
||||||
|
|
||||||
interface OnClickEvent {
|
interface OnClickEvent {
|
||||||
shiftKey: boolean;
|
shiftKey: boolean;
|
||||||
@@ -26,17 +23,18 @@ interface OnClickEvent {
|
|||||||
|
|
||||||
export type TreeItemProps<T extends { id: string }> = Pick<
|
export type TreeItemProps<T extends { id: string }> = Pick<
|
||||||
TreeProps<T>,
|
TreeProps<T>,
|
||||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions'
|
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
|
||||||
> & {
|
> & {
|
||||||
node: TreeNode<T>;
|
node: TreeNode<T>;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: (item: T, e: OnClickEvent) => void;
|
onClick?: (item: T, e: OnClickEvent) => void;
|
||||||
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||||
|
depth: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||||
|
|
||||||
export function TreeItem<T extends { id: string }>({
|
function TreeItem_<T extends { id: string }>({
|
||||||
treeId,
|
treeId,
|
||||||
node,
|
node,
|
||||||
ItemInner,
|
ItemInner,
|
||||||
@@ -45,17 +43,34 @@ export function TreeItem<T extends { id: string }>({
|
|||||||
onClick,
|
onClick,
|
||||||
getEditOptions,
|
getEditOptions,
|
||||||
className,
|
className,
|
||||||
|
depth,
|
||||||
}: TreeItemProps<T>) {
|
}: TreeItemProps<T>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||||
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||||
const isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
|
||||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||||
const [editing, setEditing] = useState<boolean>(false);
|
const [editing, setEditing] = useState<boolean>(false);
|
||||||
const [isDropHover, setIsDropHover] = useState<boolean>(false);
|
const [isDropHover, setIsDropHover] = useState<boolean>(false);
|
||||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
|
||||||
|
const isAncestorCollapsedAtom = useMemo(
|
||||||
|
() =>
|
||||||
|
selectAtom(
|
||||||
|
collapsedFamily(treeId),
|
||||||
|
(collapsed) => {
|
||||||
|
const next = (n: TreeNode<T>) => {
|
||||||
|
if (n.parent == null) return false;
|
||||||
|
if (collapsed[n.parent.item.id]) return true;
|
||||||
|
return next(n.parent);
|
||||||
|
};
|
||||||
|
return next(node);
|
||||||
|
},
|
||||||
|
(a, b) => a === b, // re-render only when boolean flips
|
||||||
|
),
|
||||||
|
[node, treeId],
|
||||||
|
);
|
||||||
|
|
||||||
const [showContextMenu, setShowContextMenu] = useState<{
|
const [showContextMenu, setShowContextMenu] = useState<{
|
||||||
items: DropdownItem[];
|
items: DropdownItem[];
|
||||||
x: number;
|
x: number;
|
||||||
@@ -160,7 +175,7 @@ export function TreeItem<T extends { id: string }>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleContextMenu = useCallback(
|
const handleContextMenu = useCallback(
|
||||||
async (e: MouseEvent<HTMLDivElement>) => {
|
async (e: MouseEvent<HTMLElement>) => {
|
||||||
if (getContextMenu == null) return;
|
if (getContextMenu == null) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -197,77 +212,107 @@ export function TreeItem<T extends { id: string }>({
|
|||||||
[setDraggableRef, setDroppableRef],
|
[setDraggableRef, setDroppableRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (useAtomValue(isAncestorCollapsedAtom)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<li
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={depth + 1}
|
||||||
|
aria-expanded={node.children == null ? undefined : !isCollapsed}
|
||||||
|
aria-selected={isSelected}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'tree-item',
|
'tree-item',
|
||||||
|
'h-sm',
|
||||||
|
'grid grid-cols-[auto_minmax(0,1fr)]',
|
||||||
isSelected && 'selected',
|
isSelected && 'selected',
|
||||||
'text-text-subtle',
|
|
||||||
'h-sm grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md px-1.5',
|
|
||||||
editing && 'ring-1 focus-within:ring-focus',
|
|
||||||
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showContextMenu && (
|
<TreeIndentGuide treeId={treeId} depth={depth} />
|
||||||
<ContextMenu
|
<div
|
||||||
items={showContextMenu.items}
|
className={classNames(
|
||||||
triggerPosition={showContextMenu}
|
'tree-item-selectable',
|
||||||
onClose={handleCloseContextMenu}
|
'text-text-subtle',
|
||||||
/>
|
isSelected && 'selected',
|
||||||
)}
|
'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
|
||||||
{node.children != null ? (
|
editing && 'ring-1 focus-within:ring-focus',
|
||||||
<button
|
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
|
||||||
tabIndex={-1}
|
|
||||||
className="h-full w-[2.8rem] pr-[0.5rem] -ml-[1rem]"
|
|
||||||
onClick={toggleCollapsed}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="chevron_right"
|
|
||||||
className={classNames(
|
|
||||||
'transition-transform text-text-subtlest',
|
|
||||||
'ml-auto !h-[1rem] !w-[1rem]',
|
|
||||||
node.children.length == 0 && 'opacity-0',
|
|
||||||
!isCollapsed && 'rotate-90',
|
|
||||||
isHoveredAsParent && '!text-text',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
ref={handleSetDraggableRef}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
disabled={editing}
|
|
||||||
className="focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
tabIndex={isLastSelected ? 0 : -1}
|
|
||||||
>
|
|
||||||
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
|
|
||||||
{getEditOptions != null && editing ? (
|
|
||||||
(() => {
|
|
||||||
const { defaultValue, placeholder } = getEditOptions(node.item);
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
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>
|
>
|
||||||
</div>
|
{showContextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
items={showContextMenu.items}
|
||||||
|
triggerPosition={showContextMenu}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.children != null ? (
|
||||||
|
<button tabIndex={-1} className="h-full pl-[0.5rem]" onClick={toggleCollapsed}>
|
||||||
|
<Icon
|
||||||
|
icon="chevron_right"
|
||||||
|
className={classNames(
|
||||||
|
'transition-transform text-text-subtlest',
|
||||||
|
'ml-auto',
|
||||||
|
'w-[1rem] h-[1rem]',
|
||||||
|
// node.children.length == 0 && 'opacity-0',
|
||||||
|
!isCollapsed && 'rotate-90',
|
||||||
|
// isHoveredAsParent && '!text-text',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden /> // Make the grid happy
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={handleSetDraggableRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
disabled={editing}
|
||||||
|
className="px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
tabIndex={isLastSelected ? 0 : -1}
|
||||||
|
>
|
||||||
|
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
|
||||||
|
{getEditOptions != null && editing ? (
|
||||||
|
(() => {
|
||||||
|
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
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>
|
||||||
|
</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 nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item);
|
||||||
|
},
|
||||||
|
) as typeof TreeItem_;
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { Fragment, memo } from 'react';
|
import { Fragment, memo } from 'react';
|
||||||
import { DropMarker } from '../../DropMarker';
|
import type { SelectableTreeNode } from './common';
|
||||||
import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms';
|
|
||||||
import type { TreeNode } from './common';
|
|
||||||
import { equalSubtree } from './common';
|
|
||||||
import type { TreeProps } from './Tree';
|
import type { TreeProps } from './Tree';
|
||||||
|
import { TreeDropMarker } from './TreeDropMarker';
|
||||||
import type { TreeItemProps } from './TreeItem';
|
import type { TreeItemProps } from './TreeItem';
|
||||||
import { TreeItem } from './TreeItem';
|
import { TreeItem } from './TreeItem';
|
||||||
|
|
||||||
@@ -15,81 +11,54 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
|
|||||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
|
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
|
||||||
> &
|
> &
|
||||||
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
||||||
node: TreeNode<T>;
|
nodes: SelectableTreeNode<T>[];
|
||||||
depth: number;
|
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
forceDepth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TreeItemList_<T extends { id: string }>({
|
function TreeItemList_<T extends { id: string }>({
|
||||||
className,
|
className,
|
||||||
depth,
|
|
||||||
getContextMenu,
|
getContextMenu,
|
||||||
getEditOptions,
|
getEditOptions,
|
||||||
getItemKey,
|
getItemKey,
|
||||||
node,
|
nodes,
|
||||||
onClick,
|
onClick,
|
||||||
ItemInner,
|
ItemInner,
|
||||||
ItemLeftSlot,
|
ItemLeftSlot,
|
||||||
style,
|
style,
|
||||||
treeId,
|
treeId,
|
||||||
|
forceDepth,
|
||||||
}: TreeItemListProps<T>) {
|
}: TreeItemListProps<T>) {
|
||||||
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
|
||||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
|
||||||
const childList = !isCollapsed && node.children != null && (
|
|
||||||
<ul
|
|
||||||
style={style}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
depth > 0 && 'ml-[calc(1.2rem+0.5px)] pl-[0.7rem] border-l',
|
|
||||||
isHovered ? 'border-l-text-subtle' : 'border-l-border-subtle',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{node.children.map(function mapChild(child, i) {
|
|
||||||
return (
|
|
||||||
<Fragment key={getItemKey(child.item)}>
|
|
||||||
<TreeDropMarker treeId={treeId} parent={node} index={i} />
|
|
||||||
<TreeItemList
|
|
||||||
treeId={treeId}
|
|
||||||
node={child}
|
|
||||||
ItemInner={ItemInner}
|
|
||||||
ItemLeftSlot={ItemLeftSlot}
|
|
||||||
onClick={onClick}
|
|
||||||
getEditOptions={getEditOptions}
|
|
||||||
depth={depth + 1}
|
|
||||||
getItemKey={getItemKey}
|
|
||||||
getContextMenu={getContextMenu}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<TreeDropMarker treeId={treeId} parent={node ?? null} index={node.children?.length ?? 0} />
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (depth === 0) {
|
|
||||||
return childList;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<ul role="tree" style={style} className={className}>
|
||||||
<TreeItem
|
<TreeDropMarker itemId={null} treeId={treeId} index={0} />
|
||||||
treeId={treeId}
|
{nodes.map((child, i) => (
|
||||||
node={node}
|
<Fragment key={getItemKey(child.node.item)}>
|
||||||
getContextMenu={getContextMenu}
|
<TreeItem
|
||||||
ItemInner={ItemInner}
|
treeId={treeId}
|
||||||
ItemLeftSlot={ItemLeftSlot}
|
node={child.node}
|
||||||
onClick={onClick}
|
ItemInner={ItemInner}
|
||||||
getEditOptions={getEditOptions}
|
ItemLeftSlot={ItemLeftSlot}
|
||||||
/>
|
onClick={onClick}
|
||||||
{childList}
|
getEditOptions={getEditOptions}
|
||||||
</li>
|
getContextMenu={getContextMenu}
|
||||||
|
getItemKey={getItemKey}
|
||||||
|
depth={forceDepth == null ? child.depth : forceDepth}
|
||||||
|
/>
|
||||||
|
<TreeDropMarker itemId={child.node.item.id} treeId={treeId} index={i+1} />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TreeItemList = memo(
|
export const TreeItemList = memo(
|
||||||
TreeItemList_,
|
TreeItemList_,
|
||||||
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
(
|
||||||
|
{ nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps },
|
||||||
|
{ nodes: nextNodes, getItemKey: nextGetItemKey, ...nextProps },
|
||||||
|
) => {
|
||||||
const nonEqualKeys = [];
|
const nonEqualKeys = [];
|
||||||
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]) {
|
||||||
@@ -100,32 +69,16 @@ export const TreeItemList = memo(
|
|||||||
// console.log('TreeItemList: ', nonEqualKeys);
|
// console.log('TreeItemList: ', nonEqualKeys);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
if (prevNodes.length !== nextNodes.length) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < prevNodes.length; i++) {
|
||||||
|
const prev = prevNodes[i]!;
|
||||||
|
const next = nextNodes[i]!;
|
||||||
|
if (prevGetItemKey(prev.node.item) !== nextGetItemKey(next.node.item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
) as typeof TreeItemList_;
|
) as typeof TreeItemList_;
|
||||||
|
|
||||||
const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
|
||||||
className,
|
|
||||||
treeId,
|
|
||||||
parent,
|
|
||||||
index,
|
|
||||||
}: {
|
|
||||||
treeId: string;
|
|
||||||
parent: TreeNode<T> | null;
|
|
||||||
index: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const isHovered = useAtomValue(isItemHoveredFamily({ treeId, parentId: parent?.item.id, index }));
|
|
||||||
const isLastItem = parent?.children?.length === index;
|
|
||||||
const isLastItemHovered = useAtomValue(
|
|
||||||
isItemHoveredFamily({
|
|
||||||
treeId,
|
|
||||||
parentId: parent?.item.id,
|
|
||||||
index: parent?.children?.length ?? 0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isHovered && !(isLastItem && isLastItemHovered)) return null;
|
|
||||||
|
|
||||||
return <DropMarker className={classNames(className)} />;
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -32,43 +32,40 @@ export const draggingIdsFamily = atomFamily((_treeId: string) => {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export const hoveredParentFamily = atomFamily((_treeId: string) => {
|
export const hoveredParentFamily = atomFamily((_treeId: string) => {
|
||||||
return atom<{ index: number | null; parentId: string | null }>({ index: null, parentId: null });
|
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(
|
export const isIndexHoveredFamily = atomFamily(
|
||||||
({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) =>
|
({ treeId, index }: { treeId: string; index: number}) =>
|
||||||
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
|
selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is),
|
||||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
|
(a, b) => a.treeId === b.treeId && a.index === b.index,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const isItemHoveredFamily = atomFamily(
|
export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
|
||||||
({
|
selectAtom(
|
||||||
treeId,
|
hoveredParentFamily(treeId),
|
||||||
parentId,
|
(s) => s.parentDepth,
|
||||||
index,
|
(a, b) => Object.is(a, b) // prevents re-render unless the value changes
|
||||||
}: {
|
)
|
||||||
treeId: string;
|
|
||||||
parentId: string | null | undefined;
|
|
||||||
index: number | null;
|
|
||||||
}) =>
|
|
||||||
selectAtom(
|
|
||||||
hoveredParentFamily(treeId),
|
|
||||||
(v) => v.parentId === parentId && v.index === index,
|
|
||||||
Object.is,
|
|
||||||
),
|
|
||||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId && a.index === b.index,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function kvKey(workspaceId: string | null) {
|
|
||||||
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const collapsedFamily = atomFamily((workspaceId: string) => {
|
export const collapsedFamily = atomFamily((workspaceId: string) => {
|
||||||
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
const key = ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||||
|
return atomWithKVStorage<Record<string, boolean>>(key, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const isCollapsedFamily = atomFamily(
|
export const isCollapsedFamily = atomFamily(
|
||||||
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
({ treeId, itemId = 'n/a' }: { treeId: string; itemId: string | undefined }) =>
|
||||||
atom(
|
atom(
|
||||||
// --- getter ---
|
// --- getter ---
|
||||||
(get) => !!get(collapsedFamily(treeId))[itemId],
|
(get) => !!get(collapsedFamily(treeId))[itemId],
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type { DragMoveEvent } from '@dnd-kit/core';
|
|||||||
import { jotaiStore } from '../../../lib/jotai';
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
import { selectedIdsFamily } from './atoms';
|
import { selectedIdsFamily } from './atoms';
|
||||||
|
|
||||||
export interface TreeNode<T extends { id: string }> {
|
export interface TreeNode<T extends { id: string } > {
|
||||||
children?: TreeNode<T>[];
|
children?: TreeNode<T>[];
|
||||||
item: T;
|
item: T;
|
||||||
parent: TreeNode<T> | null;
|
parent: TreeNode<T> | null;
|
||||||
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectableTreeNode<T extends { id: string }> {
|
export interface SelectableTreeNode<T extends { id: string }> {
|
||||||
@@ -41,9 +42,10 @@ export function equalSubtree<T extends { id: string }>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||||
// Check parents recursively
|
|
||||||
if (node.parent == null) return false;
|
if (node.parent == null) return false;
|
||||||
if (node.parent.item.id === ancestorId) return true;
|
if (node.parent.item.id === ancestorId) return true;
|
||||||
|
|
||||||
|
// Check parents recursively
|
||||||
return hasAncestor(node.parent, ancestorId);
|
return hasAncestor(node.parent, ancestorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: number = 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]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
|
||||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||||
|
|
||||||
function kvKey(workspaceId: string | null) {
|
function kvKey(workspaceId: string | null) {
|
||||||
@@ -12,18 +10,3 @@ export const sidebarCollapsedAtom = atom((get) => {
|
|||||||
const workspaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useSidebarItemCollapsed(itemId: string) {
|
|
||||||
const map = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
|
||||||
const isCollapsed = map[itemId] === true;
|
|
||||||
|
|
||||||
const toggle = useCallback(() => toggleSidebarItemCollapsed(itemId), [itemId]);
|
|
||||||
|
|
||||||
return [isCollapsed, toggle] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleSidebarItemCollapsed(itemId: string) {
|
|
||||||
jotaiStore.set(jotaiStore.get(sidebarCollapsedAtom), (prev) => {
|
|
||||||
return { ...prev, [itemId]: !prev[itemId] };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user