mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-27 03:41:11 +01:00
Sidebar filtering and improvements (#285)
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useContainerSize } from '../../hooks/useContainerQuery';
|
||||
import { clamp } from '../../lib/clamp';
|
||||
import type { ResizeHandleEvent } from '../ResizeHandle';
|
||||
import { ResizeHandle } from '../ResizeHandle';
|
||||
|
||||
export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical';
|
||||
@@ -55,10 +56,6 @@ export function SplitLayout({
|
||||
);
|
||||
const width = widthRaw ?? defaultRatio;
|
||||
let height = heightRaw ?? defaultRatio;
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!secondSlot) {
|
||||
height = 0;
|
||||
@@ -86,60 +83,37 @@ export function SplitLayout({
|
||||
};
|
||||
}, [style, vertical, height, minHeightPx, width]);
|
||||
|
||||
const unsub = () => {
|
||||
if (moveState.current !== null) {
|
||||
document.documentElement.removeEventListener('pointermove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('pointerup', moveState.current.up);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (vertical) setHeight(defaultRatio);
|
||||
else setWidth(defaultRatio);
|
||||
}, [vertical, setHeight, defaultRatio, setWidth]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const handleResizeMove = useCallback(
|
||||
(e: ResizeHandleEvent) => {
|
||||
if (containerRef.current === null) return;
|
||||
unsub();
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
// const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(
|
||||
containerRef.current,
|
||||
);
|
||||
const $c = containerRef.current;
|
||||
const containerWidth = $c.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight);
|
||||
const containerHeight = $c.clientHeight - parseFloat(paddingTop) - parseFloat(paddingBottom);
|
||||
|
||||
const mouseStartX = e.clientX;
|
||||
const mouseStartY = e.clientY;
|
||||
const startWidth = containerRect.width * width;
|
||||
const startHeight = containerRect.height * height;
|
||||
const mouseStartX = e.xStart;
|
||||
const mouseStartY = e.yStart;
|
||||
const startWidth = containerWidth * width;
|
||||
const startHeight = containerHeight * height;
|
||||
|
||||
moveState.current = {
|
||||
move: (e: MouseEvent) => {
|
||||
setIsResizing(true); // Set this here so we don't block double-clicks
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerRect.height - minHeightPx;
|
||||
const newHeightPx = clamp(
|
||||
startHeight - (e.clientY - mouseStartY),
|
||||
minHeightPx,
|
||||
maxHeightPx,
|
||||
);
|
||||
setHeight(newHeightPx / containerRect.height);
|
||||
} else {
|
||||
const maxWidthPx = containerRect.width - minWidthPx;
|
||||
const newWidthPx = clamp(
|
||||
startWidth - (e.clientX - mouseStartX),
|
||||
minWidthPx,
|
||||
maxWidthPx,
|
||||
);
|
||||
setWidth(newWidthPx / containerRect.width);
|
||||
}
|
||||
},
|
||||
up: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
unsub();
|
||||
setIsResizing(false);
|
||||
},
|
||||
};
|
||||
document.documentElement.addEventListener('pointermove', moveState.current.move);
|
||||
document.documentElement.addEventListener('pointerup', moveState.current.up);
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerHeight - minHeightPx;
|
||||
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
|
||||
setHeight(newHeightPx / containerHeight);
|
||||
} else {
|
||||
const maxWidthPx = containerWidth - minWidthPx;
|
||||
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
|
||||
setWidth(newWidthPx / containerWidth);
|
||||
}
|
||||
},
|
||||
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||
);
|
||||
@@ -155,9 +129,8 @@ export function SplitLayout({
|
||||
<>
|
||||
<ResizeHandle
|
||||
style={areaD}
|
||||
isResizing={isResizing}
|
||||
className={classNames(vertical ? '-translate-y-1' : '-translate-x-1')}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResizeMove={handleResizeMove}
|
||||
onReset={handleReset}
|
||||
side={vertical ? 'top' : 'left'}
|
||||
justify="center"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
@@ -24,17 +25,28 @@ import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
|
||||
import { useHotKey } from '../../../hooks/useHotKey';
|
||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { isSidebarFocused } from '../../../lib/scopes';
|
||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||
import { ContextMenu } from '../Dropdown';
|
||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
|
||||
import {
|
||||
collapsedFamily,
|
||||
draggingIdsFamily,
|
||||
focusIdsFamily,
|
||||
hoveredParentFamily,
|
||||
isCollapsedFamily,
|
||||
selectedIdsFamily,
|
||||
} from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
import { useSelectableItems } from './useSelectableItems';
|
||||
|
||||
/** So we re-calculate after expanding a folder during drag */
|
||||
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
|
||||
|
||||
export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
@@ -93,7 +105,6 @@ function TreeInner<T extends { id: string }>(
|
||||
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];
|
||||
@@ -170,16 +181,17 @@ function TreeInner<T extends { id: string }>(
|
||||
return;
|
||||
}
|
||||
|
||||
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
|
||||
if (currIndex > anchorIndex) {
|
||||
// Selecting down
|
||||
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1);
|
||||
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else if (currIndex < anchorIndex) {
|
||||
// Selecting up
|
||||
const itemsToSelect = selectableItems.slice(currIndex, anchorIndex + 1);
|
||||
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
@@ -217,15 +229,50 @@ function TreeInner<T extends { id: string }>(
|
||||
[handleSelect, onActivate],
|
||||
);
|
||||
|
||||
const selectPrevItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
|
||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = validSelectableItems[index - 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const selectNextItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
|
||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = validSelectableItems[index + 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const selectParentItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem =
|
||||
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
|
||||
if (lastSelectedItem?.parent != null) {
|
||||
handleSelect(lastSelectedItem.parent.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
(e) => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
if (!isSidebarFocused()) return;
|
||||
e.preventDefault();
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = selectableItems[index - 1];
|
||||
if (item != null) handleSelect(item.node.item, e);
|
||||
selectPrevItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
@@ -234,12 +281,60 @@ function TreeInner<T extends { id: string }>(
|
||||
useKey(
|
||||
'ArrowDown',
|
||||
(e) => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
if (!isSidebarFocused()) return;
|
||||
e.preventDefault();
|
||||
selectNextItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
|
||||
useKey(
|
||||
'ArrowRight',
|
||||
(e) => {
|
||||
if (!isSidebarFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = selectableItems[index + 1];
|
||||
if (item != null) handleSelect(item.node.item, e);
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] === true
|
||||
) {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false);
|
||||
} else {
|
||||
selectNextItem(e);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is in a folder, select its parent.
|
||||
// If the selected item is an expanded folder, collapse it.
|
||||
useKey(
|
||||
'ArrowLeft',
|
||||
(e) => {
|
||||
if (!isSidebarFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] !== true
|
||||
) {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true);
|
||||
} else {
|
||||
selectParentItem(e);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
@@ -467,6 +562,7 @@ function TreeInner<T extends { id: string }>(
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={clearDragState}
|
||||
onDragAbort={clearDragState}
|
||||
measuring={measuring}
|
||||
onDragMove={handleDragMove}
|
||||
autoScroll
|
||||
>
|
||||
@@ -608,3 +704,19 @@ function TreeHotKeys<T extends { id: string }>({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getValidSelectableItems<T extends { id: string }>(
|
||||
treeId: string,
|
||||
selectableItems: SelectableTreeNode<T>[],
|
||||
) {
|
||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||
return selectableItems.filter((i) => {
|
||||
if (i.node.hidden) return false;
|
||||
let p = i.node.parent;
|
||||
while (p) {
|
||||
if (collapsed[p.item.id]) return false;
|
||||
p = p.parent;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const TreeIndentGuide = memo(function TreeIndentGuide({
|
||||
<div
|
||||
key={i}
|
||||
className={classNames(
|
||||
'w-[1rem] border-r border-r-text-subtlest',
|
||||
'w-[calc(1rem+0.5px)] border-r border-r-text-subtlest',
|
||||
!(parentDepth === i + 1 && isHovered) && 'opacity-30',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -12,10 +12,11 @@ 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';
|
||||
|
||||
interface OnClickEvent {
|
||||
export interface TreeItemClickEvent {
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
@@ -27,7 +28,7 @@ export type TreeItemProps<T extends { id: string }> = Pick<
|
||||
> & {
|
||||
node: TreeNode<T>;
|
||||
className?: string;
|
||||
onClick?: (item: T, e: OnClickEvent) => void;
|
||||
onClick?: (item: T, e: TreeItemClickEvent) => void;
|
||||
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||
depth: number;
|
||||
addRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||
@@ -157,8 +158,10 @@ function TreeItem_<T extends { id: string }>({
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
if (editing) {
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -253,6 +256,8 @@ function TreeItem_<T extends { id: string }>({
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
if (node.hidden || isAncestorCollapsed) return null;
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
@@ -266,7 +271,6 @@ function TreeItem_<T extends { id: string }>({
|
||||
'tree-item',
|
||||
'h-sm',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)]',
|
||||
isAncestorCollapsed && 'hidden',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
dropHover != null && 'relative z-10 ring-2 ring-primary',
|
||||
dropHover === 'animate' && 'animate-blinkRing',
|
||||
@@ -350,6 +354,9 @@ export const TreeItem = memo(
|
||||
if (nonEqualKeys.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item);
|
||||
|
||||
return (
|
||||
getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)
|
||||
);
|
||||
},
|
||||
) as typeof TreeItem_;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Fragment, memo } from 'react';
|
||||
import type { CSSProperties} from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeDropMarker } from './TreeDropMarker';
|
||||
@@ -18,7 +18,7 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||
};
|
||||
|
||||
function TreeItemList_<T extends { id: string }>({
|
||||
export function TreeItemList<T extends { id: string }>({
|
||||
className,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
@@ -55,33 +55,3 @@ function TreeItemList_<T extends { id: string }>({
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const TreeItemList = memo(
|
||||
TreeItemList_,
|
||||
(
|
||||
{ nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps },
|
||||
{ nodes: nextNodes, getItemKey: nextGetItemKey, ...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) {
|
||||
// console.log('TreeItemList: ', nonEqualKeys);
|
||||
return false;
|
||||
}
|
||||
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_;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { selectedIdsFamily } from './atoms';
|
||||
export interface TreeNode<T extends { id: string }> {
|
||||
children?: TreeNode<T>[];
|
||||
item: T;
|
||||
hidden?: boolean;
|
||||
parent: TreeNode<T> | null;
|
||||
depth: number;
|
||||
}
|
||||
@@ -27,19 +28,23 @@ export function getSelectedItems<T extends { id: string }>(
|
||||
export function equalSubtree<T extends { id: string }>(
|
||||
a: TreeNode<T>,
|
||||
b: TreeNode<T>,
|
||||
getKey: (t: T) => string,
|
||||
getItemKey: (t: T) => string,
|
||||
): boolean {
|
||||
if (getKey(a.item) !== getKey(b.item)) return false;
|
||||
if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false;
|
||||
const ak = a.children ?? [];
|
||||
const bk = b.children ?? [];
|
||||
if (ak.length !== bk.length) return false;
|
||||
for (let i = 0; i < ak.length; i++) {
|
||||
if (!equalSubtree(ak[i]!, bk[i]!, getKey)) return false;
|
||||
if (!equalSubtree(ak[i]!, bk[i]!, getItemKey)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getNodeKey<T extends { id: string }>(a: TreeNode<T>, getItemKey: (i: T) => string) {
|
||||
return getItemKey(a.item) + a.hidden;
|
||||
}
|
||||
|
||||
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||
if (node.parent == null) return false;
|
||||
if (node.parent.item.id === ancestorId) return true;
|
||||
|
||||
Reference in New Issue
Block a user