优化 UI 树节点操作布局

统一节点操作列宽度并对齐可见性与删除按钮
为树内容增加横向滚动和动态宽度测量
组件数量改用实心橙色拼图图标徽标
This commit is contained in:
2026-09-05 18:46:37 +08:00
parent 98c5768ebf
commit 39736fb54f
@@ -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;