mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 22:40:26 +01:00
396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
import type { DragMoveEvent } from '@dnd-kit/core';
|
|
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
|
import classNames from 'classnames';
|
|
import { useAtomValue } from 'jotai';
|
|
import { selectAtom } from 'jotai/utils';
|
|
import type {
|
|
MouseEvent,
|
|
PointerEvent,
|
|
FocusEvent as ReactFocusEvent,
|
|
KeyboardEvent as ReactKeyboardEvent,
|
|
} from 'react';
|
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { computeSideForDragMove } from '../../../lib/dnd';
|
|
import { jotaiStore } from '../../../lib/jotai';
|
|
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
|
import { ContextMenu } from '../Dropdown';
|
|
import { Icon } from '../Icon';
|
|
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
|
|
import type { TreeNode } from './common';
|
|
import { getNodeKey } from './common';
|
|
import type { TreeProps } from './Tree';
|
|
import { TreeIndentGuide } from './TreeIndentGuide';
|
|
|
|
export interface TreeItemClickEvent {
|
|
shiftKey: boolean;
|
|
ctrlKey: boolean;
|
|
metaKey: boolean;
|
|
}
|
|
|
|
export type TreeItemProps<T extends { id: string }> = Pick<
|
|
TreeProps<T>,
|
|
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
|
|
> & {
|
|
node: TreeNode<T>;
|
|
className?: string;
|
|
onClick?: (item: T, e: TreeItemClickEvent) => void;
|
|
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
|
|
depth: number;
|
|
setRef?: (item: T, n: TreeItemHandle | null) => void;
|
|
};
|
|
|
|
export interface TreeItemHandle {
|
|
rename: () => void;
|
|
isRenaming: boolean;
|
|
rect: () => DOMRect;
|
|
focus: () => void;
|
|
scrollIntoView: () => void;
|
|
}
|
|
|
|
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
|
|
|
function TreeItem_<T extends { id: string }>({
|
|
treeId,
|
|
node,
|
|
ItemInner,
|
|
ItemLeftSlotInner,
|
|
ItemRightSlot,
|
|
getContextMenu,
|
|
onClick,
|
|
getEditOptions,
|
|
className,
|
|
depth,
|
|
setRef,
|
|
}: TreeItemProps<T>) {
|
|
const listItemRef = useRef<HTMLLIElement>(null);
|
|
const draggableRef = useRef<HTMLButtonElement>(null);
|
|
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
|
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 [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
|
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
|
const handle = useMemo<TreeItemHandle>(
|
|
() => ({
|
|
focus: () => {
|
|
draggableRef.current?.focus();
|
|
},
|
|
rename: () => {
|
|
if (getEditOptions != null) {
|
|
setEditing(true);
|
|
}
|
|
},
|
|
isRenaming: editing,
|
|
rect: () => {
|
|
if (listItemRef.current == null) {
|
|
return new DOMRect(0, 0, 0, 0);
|
|
}
|
|
return listItemRef.current.getBoundingClientRect();
|
|
},
|
|
scrollIntoView: () => {
|
|
listItemRef.current?.scrollIntoView({ block: 'nearest' });
|
|
},
|
|
}),
|
|
[editing, getEditOptions],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setRef?.(node.item, handle);
|
|
}, [setRef, handle, node.item]);
|
|
|
|
const ancestorIds = useMemo(() => {
|
|
const ids: string[] = [];
|
|
let p = node.parent;
|
|
|
|
while (p) {
|
|
ids.push(p.item.id);
|
|
p = p.parent;
|
|
}
|
|
|
|
return ids;
|
|
}, [node]);
|
|
|
|
const isAncestorCollapsedAtom = useMemo(
|
|
() =>
|
|
selectAtom(
|
|
collapsedFamily(treeId),
|
|
(collapsed) => ancestorIds.some((id) => collapsed[id]),
|
|
(a, b) => a === b,
|
|
),
|
|
[ancestorIds, treeId],
|
|
);
|
|
const isAncestorCollapsed = useAtomValue(isAncestorCollapsedAtom);
|
|
|
|
const [showContextMenu, setShowContextMenu] = useState<{
|
|
items: DropdownItem[];
|
|
x: number;
|
|
y: number;
|
|
} | null>(null);
|
|
|
|
const handleClick = useCallback(
|
|
(e: MouseEvent<HTMLButtonElement>) => onClick?.(node.item, e),
|
|
[node, onClick],
|
|
);
|
|
|
|
const toggleCollapsed = useCallback(() => {
|
|
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
|
}, [node.item.id, treeId]);
|
|
|
|
const handleSubmitNameEdit = useCallback(
|
|
async (el: HTMLInputElement) => {
|
|
getEditOptions?.(node.item).onChange(node.item, el.value);
|
|
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
|
|
// Slight delay for the model to propagate to the local store
|
|
setTimeout(() => setEditing(false), 200);
|
|
},
|
|
[getEditOptions, node.item, onClick],
|
|
);
|
|
|
|
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
|
el?.focus();
|
|
el?.select();
|
|
}, []);
|
|
|
|
const handleEditBlur = useCallback(
|
|
async function editBlur(e: ReactFocusEvent<HTMLInputElement>) {
|
|
await handleSubmitNameEdit(e.currentTarget);
|
|
},
|
|
[handleSubmitNameEdit],
|
|
);
|
|
|
|
const handleEditKeyDown = useCallback(
|
|
async (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
e.stopPropagation(); // Don't trigger other tree keys (like arrows)
|
|
switch (e.key) {
|
|
case 'Enter':
|
|
if (editing) {
|
|
e.preventDefault();
|
|
await handleSubmitNameEdit(e.currentTarget);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
if (editing) {
|
|
e.preventDefault();
|
|
setEditing(false);
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
[editing, handleSubmitNameEdit],
|
|
);
|
|
|
|
const handleDoubleClick = useCallback(() => {
|
|
const isFolder = node.children != null;
|
|
if (isFolder) {
|
|
toggleCollapsed();
|
|
} else if (getEditOptions != null) {
|
|
setEditing(true);
|
|
}
|
|
}, [getEditOptions, node.children, toggleCollapsed]);
|
|
|
|
const clearDropHover = () => {
|
|
if (startedHoverTimeout.current) {
|
|
clearTimeout(startedHoverTimeout.current);
|
|
startedHoverTimeout.current = undefined;
|
|
}
|
|
setDropHover(null);
|
|
};
|
|
|
|
const dndContext = useDndContext();
|
|
|
|
// Toggle auto-expand of folders when hovering over them
|
|
useDndMonitor({
|
|
onDragEnd() {
|
|
clearDropHover();
|
|
},
|
|
onDragMove(e: DragMoveEvent) {
|
|
const side = computeSideForDragMove(node.item.id, e);
|
|
const isFolder = node.children != null;
|
|
const hasChildren = (node.children?.length ?? 0) > 0;
|
|
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
|
if (isCollapsed && isFolder && hasChildren && side === 'below') {
|
|
setDropHover('animate');
|
|
clearTimeout(startedHoverTimeout.current);
|
|
startedHoverTimeout.current = setTimeout(() => {
|
|
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
|
|
clearDropHover();
|
|
// Force re-measure everything because all containers below the folder have been pushed down
|
|
requestAnimationFrame(() => {
|
|
dndContext.measureDroppableContainers(
|
|
dndContext.droppableContainers.toArray().map((c) => c.id),
|
|
);
|
|
});
|
|
}, HOVER_CLOSED_FOLDER_DELAY);
|
|
} else if (isFolder && !hasChildren && side === 'below') {
|
|
setDropHover('drop');
|
|
} else {
|
|
clearDropHover();
|
|
}
|
|
},
|
|
});
|
|
|
|
const handleContextMenu = useCallback(
|
|
async (e: MouseEvent<HTMLElement>) => {
|
|
if (getContextMenu == null) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Set data attribute on the list item to preserve active state
|
|
if (listItemRef.current) {
|
|
listItemRef.current.setAttribute('data-context-menu-open', 'true');
|
|
}
|
|
|
|
const items = await getContextMenu(node.item);
|
|
setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 });
|
|
},
|
|
[getContextMenu, node.item],
|
|
);
|
|
|
|
const handleCloseContextMenu = useCallback(() => {
|
|
// Remove data attribute when context menu closes
|
|
if (listItemRef.current) {
|
|
listItemRef.current.removeAttribute('data-context-menu-open');
|
|
}
|
|
setShowContextMenu(null);
|
|
}, []);
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef: setDraggableRef,
|
|
} = useDraggable({ id: node.item.id, disabled: node.draggable === false || editing });
|
|
|
|
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
|
|
|
|
const handlePointerDown = useCallback(
|
|
function handlePointerDown(e: PointerEvent<HTMLButtonElement>) {
|
|
const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey;
|
|
if (!handleByTree) {
|
|
listeners?.onPointerDown?.(e);
|
|
}
|
|
},
|
|
[listeners],
|
|
);
|
|
|
|
const handleSetDraggableRef = useCallback(
|
|
(node: HTMLButtonElement | null) => {
|
|
draggableRef.current = node;
|
|
setDraggableRef(node);
|
|
setDroppableRef(node);
|
|
},
|
|
[setDraggableRef, setDroppableRef],
|
|
);
|
|
|
|
if (node.hidden || isAncestorCollapsed) return null;
|
|
|
|
return (
|
|
<li
|
|
ref={listItemRef}
|
|
onContextMenu={handleContextMenu}
|
|
className={classNames(
|
|
className,
|
|
'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',
|
|
)}
|
|
>
|
|
<TreeIndentGuide treeId={treeId} depth={depth} ancestorIds={ancestorIds} />
|
|
<div
|
|
className={classNames(
|
|
'text-text-subtle',
|
|
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
|
|
)}
|
|
>
|
|
{showContextMenu && (
|
|
<ContextMenu
|
|
items={showContextMenu.items}
|
|
triggerPosition={showContextMenu}
|
|
onClose={handleCloseContextMenu}
|
|
/>
|
|
)}
|
|
{node.children != null ? (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
className="h-full pl-[0.5rem] outline-none"
|
|
onClick={toggleCollapsed}
|
|
>
|
|
<Icon
|
|
icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
|
|
className={classNames(
|
|
'transition-transform text-text-subtlest',
|
|
'ml-auto',
|
|
'w-[1rem] h-[1rem]',
|
|
!isCollapsed && node.children.length > 0 && 'rotate-90',
|
|
)}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<span aria-hidden /> // Make the grid happy
|
|
)}
|
|
|
|
<button
|
|
ref={handleSetDraggableRef}
|
|
onPointerDown={handlePointerDown}
|
|
onClick={handleClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
disabled={editing}
|
|
className="cursor-default tree-item-inner pr-1 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
|
{...attributes}
|
|
{...listeners}
|
|
tabIndex={isLastSelected ? 0 : -1}
|
|
>
|
|
{ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
|
|
{getEditOptions != null && editing ? (
|
|
(() => {
|
|
const { defaultValue, placeholder } = getEditOptions(node.item);
|
|
return (
|
|
<input
|
|
data-disable-hotkey
|
|
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>
|
|
{ItemRightSlot != null ? (
|
|
<ItemRightSlot treeId={treeId} item={node.item} />
|
|
) : (
|
|
<span aria-hidden />
|
|
)}
|
|
</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 (
|
|
getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)
|
|
);
|
|
},
|
|
) as typeof TreeItem_;
|