mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-09 18:53:38 +02:00
Add tree rename (on Enter) and global rename hotkeys (#279)
This commit is contained in:
@@ -413,7 +413,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
close: handleClose,
|
||||
prev: handlePrev,
|
||||
next: handleNext,
|
||||
async select() {
|
||||
select: async () => {
|
||||
const item = items[selectedIndexRef.current ?? -1] ?? null;
|
||||
if (!item) return;
|
||||
await handleSelect(item);
|
||||
@@ -569,10 +569,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
<div
|
||||
key={i}
|
||||
className={classNames('my-1 mx-2 max-w-xs')}
|
||||
onClick={() => {
|
||||
// Ensure the dropdown is closed when anything in the content is clicked
|
||||
onClose();
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
import { useSelectableItems } from './useSelectableItems';
|
||||
@@ -45,13 +45,23 @@ export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
getItemKey: (item: T) => string;
|
||||
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
|
||||
getContextMenu?: (t: TreeHandle, items: T[]) => Promise<ContextMenuProps['items']>;
|
||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||
className?: string;
|
||||
onActivate?: (item: T) => void;
|
||||
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||
hotkeys?: { actions: Partial<Record<HotkeyAction, (items: T[]) => void>> } & HotKeyOptions;
|
||||
hotkeys?: {
|
||||
actions: Partial<
|
||||
Record<
|
||||
HotkeyAction,
|
||||
{
|
||||
cb: (h: TreeHandle, items: T[]) => void;
|
||||
enable?: boolean | ((h: TreeHandle) => boolean);
|
||||
} & Omit<HotKeyOptions, 'enable'>
|
||||
>
|
||||
>;
|
||||
};
|
||||
getEditOptions?: (item: T) => {
|
||||
defaultValue: string;
|
||||
placeholder?: string;
|
||||
@@ -62,6 +72,7 @@ export interface TreeProps<T extends { id: string }> {
|
||||
export interface TreeHandle {
|
||||
focus: () => void;
|
||||
selectItem: (id: string) => void;
|
||||
renameItem: (id: string) => void;
|
||||
}
|
||||
|
||||
function TreeInner<T extends { id: string }>(
|
||||
@@ -87,6 +98,15 @@ function TreeInner<T extends { id: string }>(
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
|
||||
|
||||
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
|
||||
if (r == null) {
|
||||
delete treeItemRefs.current[item.id];
|
||||
} else {
|
||||
treeItemRefs.current[item.id] = r;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
setShowContextMenu(null);
|
||||
@@ -105,11 +125,11 @@ function TreeInner<T extends { id: string }>(
|
||||
[treeId, tryFocus],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TreeHandle => ({
|
||||
const treeHandle = useMemo<TreeHandle>(
|
||||
() => ({
|
||||
focus: tryFocus,
|
||||
selectItem(id) {
|
||||
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
||||
selectItem: (id) => {
|
||||
setSelected([id], false);
|
||||
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
},
|
||||
@@ -117,6 +137,8 @@ function TreeInner<T extends { id: string }>(
|
||||
[setSelected, treeId, tryFocus],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
|
||||
|
||||
const handleGetContextMenu = useMemo(() => {
|
||||
if (getContextMenu == null) return;
|
||||
return (item: T) => {
|
||||
@@ -124,16 +146,16 @@ function TreeInner<T extends { id: string }>(
|
||||
const isSelected = items.find((i) => i.id === item.id);
|
||||
if (isSelected) {
|
||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||
return getContextMenu(items);
|
||||
return getContextMenu(treeHandle, items);
|
||||
} else {
|
||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||
// Also update the selection with it
|
||||
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
return getContextMenu([item]);
|
||||
return getContextMenu(treeHandle, [item]);
|
||||
}
|
||||
};
|
||||
}, [getContextMenu, selectableItems, treeId]);
|
||||
}, [getContextMenu, selectableItems, treeHandle, treeId]);
|
||||
|
||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||
@@ -141,7 +163,7 @@ function TreeInner<T extends { id: string }>(
|
||||
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||
const selectedIds = jotaiStore.get(selectedIdsAtom);
|
||||
|
||||
// Mark item as the last one selected
|
||||
// Mark the item as the last one selected
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
|
||||
if (shiftKey) {
|
||||
@@ -427,17 +449,22 @@ function TreeInner<T extends { id: string }>(
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = await getContextMenu([]);
|
||||
const items = await getContextMenu(treeHandle, []);
|
||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[getContextMenu],
|
||||
[getContextMenu, treeHandle],
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
||||
<TreeHotKeys
|
||||
treeHandle={treeHandle}
|
||||
treeId={treeId}
|
||||
hotkeys={hotkeys}
|
||||
selectableItems={selectableItems}
|
||||
/>
|
||||
{showContextMenu && (
|
||||
<ContextMenu
|
||||
items={showContextMenu.items}
|
||||
@@ -479,7 +506,12 @@ function TreeInner<T extends { id: string }>(
|
||||
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none',
|
||||
)}
|
||||
>
|
||||
<TreeItemList nodes={selectableItems} treeId={treeId} {...treeItemListProps} />
|
||||
<TreeItemList
|
||||
addTreeItemRef={handleAddTreeItemRef}
|
||||
nodes={selectableItems}
|
||||
treeId={treeId}
|
||||
{...treeItemListProps}
|
||||
/>
|
||||
</div>
|
||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
|
||||
@@ -523,11 +555,14 @@ function DropRegionAfterList({
|
||||
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
|
||||
}
|
||||
|
||||
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
||||
interface TreeHotKeyProps<T extends { id: string }> {
|
||||
action: HotkeyAction;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
treeId: string;
|
||||
onDone: (items: T[]) => void;
|
||||
onDone: (h: TreeHandle, items: T[]) => void;
|
||||
treeHandle: TreeHandle;
|
||||
priority?: number;
|
||||
enable?: boolean | ((h: TreeHandle) => boolean);
|
||||
}
|
||||
|
||||
function TreeHotKey<T extends { id: string }>({
|
||||
@@ -535,14 +570,23 @@ function TreeHotKey<T extends { id: string }>({
|
||||
action,
|
||||
onDone,
|
||||
selectableItems,
|
||||
treeHandle,
|
||||
enable,
|
||||
...options
|
||||
}: TreeHotKeyProps<T>) {
|
||||
useHotKey(
|
||||
action,
|
||||
() => {
|
||||
onDone(getSelectedItems(treeId, selectableItems));
|
||||
onDone(treeHandle, getSelectedItems(treeId, selectableItems));
|
||||
},
|
||||
{
|
||||
...options,
|
||||
enable: () => {
|
||||
if (enable == null) return true;
|
||||
if (typeof enable === 'function') return enable(treeHandle);
|
||||
else return enable;
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -551,24 +595,26 @@ function TreeHotKeys<T extends { id: string }>({
|
||||
treeId,
|
||||
hotkeys,
|
||||
selectableItems,
|
||||
treeHandle,
|
||||
}: {
|
||||
treeId: string;
|
||||
hotkeys: TreeProps<T>['hotkeys'];
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
treeHandle: TreeHandle;
|
||||
}) {
|
||||
if (hotkeys == null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(hotkeys.actions).map(([hotkey, onDone]) => (
|
||||
{Object.entries(hotkeys.actions).map(([hotkey, { cb, ...options }]) => (
|
||||
<TreeHotKey
|
||||
key={hotkey}
|
||||
action={hotkey as HotkeyAction}
|
||||
priority={hotkeys.priority}
|
||||
enable={hotkeys.enable}
|
||||
treeId={treeId}
|
||||
onDone={onDone}
|
||||
onDone={cb}
|
||||
treeHandle={treeHandle}
|
||||
selectableItems={selectableItems}
|
||||
{...options}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import type { MouseEvent, PointerEvent } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent, PointerEvent, ReactElement, RefAttributes } from 'react';
|
||||
import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||
@@ -30,11 +30,17 @@ export type TreeItemProps<T extends { id: string }> = Pick<
|
||||
onClick?: (item: T, e: OnClickEvent) => void;
|
||||
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||
depth: number;
|
||||
addRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||
};
|
||||
|
||||
export interface TreeItemHandle {
|
||||
rename: () => void;
|
||||
isRenaming: boolean;
|
||||
}
|
||||
|
||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||
|
||||
function TreeItem_<T extends { id: string }>({
|
||||
function TreeItemInner<T extends { id: string }>({
|
||||
treeId,
|
||||
node,
|
||||
ItemInner,
|
||||
@@ -44,8 +50,9 @@ function TreeItem_<T extends { id: string }>({
|
||||
getEditOptions,
|
||||
className,
|
||||
depth,
|
||||
addRef,
|
||||
}: TreeItemProps<T>) {
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
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 }));
|
||||
@@ -54,6 +61,17 @@ function TreeItem_<T extends { id: string }>({
|
||||
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
addRef?.(node.item, {
|
||||
rename: () => {
|
||||
if (getEditOptions != null) {
|
||||
setEditing(true);
|
||||
}
|
||||
},
|
||||
isRenaming: editing,
|
||||
});
|
||||
}, [addRef, editing, getEditOptions, node.item]);
|
||||
|
||||
const isAncestorCollapsedAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
@@ -80,7 +98,7 @@ function TreeItem_<T extends { id: string }>({
|
||||
useEffect(
|
||||
function scrollIntoViewWhenSelected() {
|
||||
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||
ref.current?.scrollIntoView({ block: 'nearest' });
|
||||
listItemRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
},
|
||||
[node.item.id, treeId],
|
||||
@@ -103,10 +121,11 @@ function TreeItem_<T extends { id: string }>({
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async function submitNameEdit(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],
|
||||
[getEditOptions, node.item, onClick],
|
||||
);
|
||||
|
||||
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
||||
@@ -126,8 +145,10 @@ function TreeItem_<T extends { id: string }>({
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
if (editing) {
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
@@ -135,7 +156,7 @@ function TreeItem_<T extends { id: string }>({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
[editing, handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
@@ -222,7 +243,7 @@ function TreeItem_<T extends { id: string }>({
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
ref={listItemRef}
|
||||
role="treeitem"
|
||||
aria-level={depth + 1}
|
||||
aria-expanded={node.children == null ? undefined : !isCollapsed}
|
||||
@@ -304,6 +325,11 @@ function TreeItem_<T extends { id: string }>({
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Preserve generics through forwardRef:
|
||||
const TreeItem_ = forwardRef(TreeItemInner) as <T extends { id: string }>(
|
||||
props: TreeItemProps<T> & RefAttributes<TreeItemHandle>,
|
||||
) => ReactElement | null;
|
||||
|
||||
export const TreeItem = memo(
|
||||
TreeItem_,
|
||||
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Fragment, memo } from 'react';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeDropMarker } from './TreeDropMarker';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import { TreeItem } from './TreeItem';
|
||||
|
||||
export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
@@ -15,6 +15,7 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
forceDepth?: number;
|
||||
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||
};
|
||||
|
||||
function TreeItemList_<T extends { id: string }>({
|
||||
@@ -29,6 +30,7 @@ function TreeItemList_<T extends { id: string }>({
|
||||
style,
|
||||
treeId,
|
||||
forceDepth,
|
||||
addTreeItemRef,
|
||||
}: TreeItemListProps<T>) {
|
||||
return (
|
||||
<ul role="tree" style={style} className={className}>
|
||||
@@ -36,6 +38,7 @@ function TreeItemList_<T extends { id: string }>({
|
||||
{nodes.map((child, i) => (
|
||||
<Fragment key={getItemKey(child.node.item)}>
|
||||
<TreeItem
|
||||
addRef={addTreeItemRef}
|
||||
treeId={treeId}
|
||||
node={child.node}
|
||||
ItemInner={ItemInner}
|
||||
@@ -46,7 +49,7 @@ function TreeItemList_<T extends { id: string }>({
|
||||
getItemKey={getItemKey}
|
||||
depth={forceDepth == null ? child.depth : forceDepth}
|
||||
/>
|
||||
<TreeDropMarker node={child.node} treeId={treeId} index={i+1} />
|
||||
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user