Better drag for empty folders

This commit is contained in:
Gregory Schier
2025-10-18 07:41:33 -07:00
parent f8478677c5
commit 3a61ffbbb0
5 changed files with 45 additions and 32 deletions

View File

@@ -33,17 +33,19 @@ const icons = {
chevron_left: lucide.ChevronLeftIcon,
chevron_right: lucide.ChevronRightIcon,
circle_alert: lucide.CircleAlertIcon,
circle_dashed: lucide.CircleDashedIcon,
circle_dollar_sign: lucide.CircleDollarSignIcon,
circle_fading_arrow_up: lucide.CircleFadingArrowUpIcon,
clock: lucide.ClockIcon,
code: lucide.CodeIcon,
columns_2: lucide.Columns2Icon,
command: lucide.CommandIcon,
corner_right_up: lucide.CornerRightUpIcon,
credit_card: lucide.CreditCardIcon,
cookie: lucide.CookieIcon,
copy: lucide.CopyIcon,
copy_check: lucide.CopyCheck,
corner_right_up: lucide.CornerRightUpIcon,
credit_card: lucide.CreditCardIcon,
dot: lucide.DotIcon,
download: lucide.DownloadIcon,
ellipsis: lucide.EllipsisIcon,
expand: lucide.ExpandIcon,
@@ -55,8 +57,8 @@ const icons = {
flame: lucide.FlameIcon,
flask: lucide.FlaskConicalIcon,
folder: lucide.FolderIcon,
folder_cog: lucide.FolderCogIcon,
folder_code: lucide.FolderCodeIcon,
folder_cog: lucide.FolderCogIcon,
folder_git: lucide.FolderGitIcon,
folder_input: lucide.FolderInputIcon,
folder_open: lucide.FolderOpenIcon,

View File

@@ -267,10 +267,15 @@ function TreeInner<T extends { id: string }>(
const collapsedMap = jotaiStore.get(collapsedFamily(treeId));
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
if (hovered?.children != null && side === 'below') {
// Move into the folder if it's open and we're moving below it
hoveredParent = hovered;
hoveredChildIndex = 0;
if (isHoveredItemCollapsed) {
hoveredParent = hovered;
hoveredChildIndex = 0;
} else {
hoveredParent = hovered;
hoveredChildIndex = 0;
}
}
const parentId = hoveredParent?.item.id ?? null;

View File

@@ -3,28 +3,30 @@ import { useAtomValue } from 'jotai';
import { memo } from 'react';
import { DropMarker } from '../../DropMarker';
import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from './atoms';
import type { TreeNode } from './common';
export const TreeDropMarker = memo(function TreeDropMarker({
export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
className,
treeId,
itemId,
node,
index,
}: {
treeId: string;
index: number;
itemId: string | null;
node: TreeNode<T> | null;
className?: string;
}) {
const itemId = node?.item.id;
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: itemId ?? undefined }));
const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId }));
// Only show if we're hovering over this index
if (!isHovered) return null;
if (!isHovered) return null;
// Don't show if we're right under a collapsed folder. We have a separate delayed expansion
// Don't show if we're right under a collapsed folder, or empty folder. We have a separate delayed expansion
// animation for that.
if (collapsed) return null;
if (collapsed || node?.children?.length === 0) return null;
return (
<div className="drop-marker" style={{ paddingLeft: `${parentDepth}rem` }}>

View File

@@ -51,7 +51,7 @@ function TreeItem_<T extends { id: string }>({
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
const [editing, setEditing] = useState<boolean>(false);
const [isDropHover, setIsDropHover] = useState<boolean>(false);
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
const isAncestorCollapsedAtom = useMemo(
@@ -147,29 +147,35 @@ function TreeItem_<T extends { id: string }>({
}
}, [getEditOptions, node.children, toggleCollapsed]);
const clearHoverTimer = () => {
const clearDropHover = () => {
if (startedHoverTimeout.current) {
setIsDropHover(false); // NEW
clearTimeout(startedHoverTimeout.current); // NEW
startedHoverTimeout.current = undefined; // NEW
clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = undefined;
}
setDropHover(null);
};
// Toggle auto-expand of folders when hovering over them
useDndMonitor({
onDragEnd() {
clearDropHover();
},
onDragMove(e: DragMoveEvent) {
const side = computeSideForDragMove(node, e);
const isFolderWithChildren = (node.children?.length ?? 0) > 0;
const isFolder = node.children != null;
const hasChildren = (node.children?.length ?? 0) > 0;
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
if (isCollapsed && isFolderWithChildren && side === 'below') {
setIsDropHover(true);
if (isCollapsed && isFolder && hasChildren && side === 'below') {
setDropHover('animate');
clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = setTimeout(() => {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
setIsDropHover(false);
clearDropHover();
}, HOVER_CLOSED_FOLDER_DELAY);
} else if (isFolder && !hasChildren && side === 'below') {
setDropHover('drop');
} else {
clearHoverTimer();
clearDropHover();
}
},
});
@@ -227,6 +233,9 @@ function TreeItem_<T extends { id: string }>({
'tree-item',
'h-sm',
'grid grid-cols-[auto_minmax(0,1fr)]',
editing && 'ring-1 focus-within:ring-focus',
dropHover != null && 'relative z-10 ring-2 ring-primary',
dropHover === 'animate' && 'animate-blinkRing',
isSelected && 'selected',
)}
>
@@ -235,10 +244,7 @@ function TreeItem_<T extends { id: string }>({
className={classNames(
'tree-item-selectable',
'text-text-subtle',
isSelected && 'selected',
'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
editing && 'ring-1 focus-within:ring-focus',
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
)}
>
{showContextMenu && (
@@ -251,14 +257,12 @@ function TreeItem_<T extends { id: string }>({
{node.children != null ? (
<button tabIndex={-1} className="h-full pl-[0.5rem]" onClick={toggleCollapsed}>
<Icon
icon="chevron_right"
icon={node.children.length === 0 ? 'dot' : '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',
!isCollapsed && node.children.length > 0 && 'rotate-90',
)}
/>
</button>

View File

@@ -32,7 +32,7 @@ function TreeItemList_<T extends { id: string }>({
}: TreeItemListProps<T>) {
return (
<ul role="tree" style={style} className={className}>
<TreeDropMarker itemId={null} treeId={treeId} index={0} />
<TreeDropMarker node={null} treeId={treeId} index={0} />
{nodes.map((child, i) => (
<Fragment key={getItemKey(child.node.item)}>
<TreeItem
@@ -46,7 +46,7 @@ function TreeItemList_<T extends { id: string }>({
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
/>
<TreeDropMarker itemId={child.node.item.id} treeId={treeId} index={i+1} />
<TreeDropMarker node={child.node} treeId={treeId} index={i+1} />
</Fragment>
))}
</ul>