优化 UI 树节点操作布局
统一节点操作列宽度并对齐可见性与删除按钮 为树内容增加横向滚动和动态宽度测量 组件数量改用实心橙色拼图图标徽标
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { ChevronDown, Eye, EyeOff, Trash2 } from 'lucide-react';
|
||||
import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
adjustMoveIndex,
|
||||
type MoveHandler,
|
||||
type NodeRendererProps,
|
||||
type RowRendererProps,
|
||||
Tree,
|
||||
type TreeApi,
|
||||
} from 'react-arborist';
|
||||
@@ -44,6 +48,38 @@ type UiTreePanelProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TreeListOuter = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLAttributes<HTMLDivElement>
|
||||
>(function TreeListOuter({ style, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={{ ...style, overflowX: 'visible', overflowY: 'auto' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function TreeRowContainer<T>({
|
||||
node,
|
||||
innerRef,
|
||||
attrs,
|
||||
children,
|
||||
}: RowRendererProps<T>) {
|
||||
return (
|
||||
<div
|
||||
{...attrs}
|
||||
ref={innerRef}
|
||||
style={{ ...attrs.style, minWidth: 0 }}
|
||||
onFocus={(event) => event.stopPropagation()}
|
||||
onClick={node.handleClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
style,
|
||||
@@ -70,8 +106,8 @@ function TreeRow({
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
className={`flex min-w-0 items-center gap-1 rounded-lg px-2 text-xs text-(--platform-text-strong) ${
|
||||
style={{ ...style, width: '100%', height: '100%', minWidth: 0 }}
|
||||
className={`relative flex min-w-0 items-center gap-1 rounded-lg px-2 pr-[68px] text-xs text-(--platform-text-strong) ${
|
||||
node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5'
|
||||
} ${isVisible ? '' : 'opacity-60'}`}
|
||||
onClick={() => {
|
||||
@@ -102,38 +138,53 @@ function TreeRow({
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span>
|
||||
<span className="text-[10px] text-(--platform-text-soft)">
|
||||
{data.components.length}
|
||||
<span className="shrink-0 whitespace-nowrap" title={nodeLabel}>
|
||||
{nodeLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleNodeVisibility(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded-md bg-black/5 px-1 py-0.5 text-[10px] leading-none text-(--platform-text-soft)"
|
||||
title={`${data.components.length} 个组件`}
|
||||
aria-label={`${data.components.length} 个组件`}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除节点“${nodeLabel}”`}
|
||||
title={canDelete ? `删除节点“${nodeLabel}”` : '页面根节点不可删除'}
|
||||
disabled={!canDelete || deleteDisabled}
|
||||
aria-disabled={!canDelete || deleteDisabled}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRequestDelete(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<Puzzle
|
||||
size={11}
|
||||
className="text-orange-600"
|
||||
fill="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{data.components.length}</span>
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-2 flex w-[52px] items-center justify-end gap-1 bg-inherit">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleNodeVisibility(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除节点“${nodeLabel}”`}
|
||||
title={canDelete ? `删除节点“${nodeLabel}”` : '页面根节点不可删除'}
|
||||
disabled={!canDelete || deleteDisabled}
|
||||
aria-disabled={!canDelete || deleteDisabled}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRequestDelete(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +206,9 @@ export function UiTreePanel({
|
||||
}: UiTreePanelProps) {
|
||||
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const treeSizerRef = useRef<HTMLDivElement>(null);
|
||||
const [treeHeight, setTreeHeight] = useState(0);
|
||||
const [treeWidth, setTreeWidth] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
nodeId: NodeId;
|
||||
x: number;
|
||||
@@ -182,6 +235,33 @@ export function UiTreePanel({
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const treeRows = useMemo(() => {
|
||||
if (!root) return [];
|
||||
const rows: Array<{ node: UiNode; depth: number }> = [];
|
||||
const visit = (node: UiNode, depth: number) => {
|
||||
rows.push({ node, depth });
|
||||
node.children.forEach((child) => visit(child, depth + 1));
|
||||
};
|
||||
visit(root, 0);
|
||||
return rows;
|
||||
}, [root]);
|
||||
|
||||
useEffect(() => {
|
||||
const sizer = treeSizerRef.current;
|
||||
const container = treeContainerRef.current;
|
||||
if (!sizer || !container) return;
|
||||
const updateWidth = () => {
|
||||
setTreeWidth(
|
||||
Math.max(Math.ceil(sizer.scrollWidth), container.clientWidth),
|
||||
);
|
||||
};
|
||||
updateWidth();
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(sizer);
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [treeRows]);
|
||||
|
||||
const handleMove: MoveHandler<UiNode> = ({
|
||||
dragIds,
|
||||
parentId,
|
||||
@@ -250,16 +330,50 @@ export function UiTreePanel({
|
||||
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div ref={treeContainerRef} className="min-h-0 flex-1">
|
||||
<div
|
||||
ref={treeContainerRef}
|
||||
className="relative min-h-0 flex-1 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
<div
|
||||
ref={treeSizerRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 invisible flex w-max flex-col whitespace-nowrap px-2 text-xs"
|
||||
>
|
||||
{treeRows.map(({ node, depth }) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex h-[34px] items-center gap-1"
|
||||
style={{ paddingLeft: depth * 16 }}
|
||||
>
|
||||
<span className="size-6 shrink-0" />
|
||||
<span className="shrink-0">
|
||||
{node.metadata.name || '未命名节点'}
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-md px-1 py-0.5 text-[10px] leading-none">
|
||||
<Puzzle
|
||||
size={11}
|
||||
className="text-orange-600"
|
||||
fill="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{node.components.length}</span>
|
||||
</span>
|
||||
<span className="w-[52px] shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{root && treeHeight > 0 ? (
|
||||
<Tree<UiNode>
|
||||
ref={treeApiRef}
|
||||
data={[root]}
|
||||
width="100%"
|
||||
width={treeWidth > 0 ? treeWidth : '100%'}
|
||||
outerElementType={TreeListOuter}
|
||||
height={treeHeight}
|
||||
rowHeight={34}
|
||||
indent={16}
|
||||
openByDefault
|
||||
renderRow={TreeRowContainer}
|
||||
selection={selectedNodeId ?? undefined}
|
||||
onSelect={(nodes) => {
|
||||
const selected = nodes[0]?.data;
|
||||
|
||||
Reference in New Issue
Block a user