增加 UI 编辑器节点预览可见性

新增按界面图隔离的临时节点显示状态
接入 UI Tree 眼睛按钮与 Inspector 显示复选框
让两种预览模式按子树隐藏节点并清理失效状态
补充可见性测试与技术方案记录
This commit is contained in:
2026-08-18 12:04:43 +08:00
parent 929788f4f9
commit 2b1fc406a7
9 changed files with 363 additions and 23 deletions
@@ -46,6 +46,16 @@ export function InputSidebar({
return result;
}, [editor.state.ui_trees]);
const hiddenNodeIdsByTree = useMemo(() => {
const result = new Map<UIDesignImageId, ReadonlySet<NodeId>>();
for (const [treeId, hiddenNodeIds] of Object.entries(
controller.hiddenNodeIdsByImage,
)) {
result.set(treeId, new Set(Object.keys(hiddenNodeIds)));
}
return result;
}, [controller.hiddenNodeIdsByImage]);
const superRoot = useMemo<UiNode | null>(() => {
if (editor.state.ui_trees.length === 0) return null;
return {
@@ -261,10 +271,16 @@ export function InputSidebar({
selectedNodeId={selectedNodeId}
focusRequest={focusRequest}
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
hiddenNodeIdsForTree={(treeId) =>
hiddenNodeIdsByTree.get(treeId) ?? new Set<NodeId>()
}
onSelectNode={(treeId, nodeId) => {
controller.selectDesignImage(treeId);
controller.selectNode(nodeId);
}}
onToggleNodeVisibility={(treeId, nodeId) =>
controller.toggleNodePreviewVisibility(nodeId, treeId)
}
onInsertNode={(treeId, parentId) => {
controller.insertNode(parentId, treeId);
}}
@@ -78,6 +78,10 @@ export function InspectorSidebar({
node={view.node}
parentSize={view.parentSize}
readOnly={controller.editor.isLocked}
isNodePreviewVisible={controller.isNodePreviewVisible(view.node.id)}
onSetNodePreviewVisible={(visible) =>
controller.setNodePreviewVisible(view.node.id, visible)
}
transformReadOnly={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
@@ -222,6 +226,8 @@ function NodeInspector({
node,
parentSize,
readOnly,
isNodePreviewVisible,
onSetNodePreviewVisible,
transformReadOnly,
keepChildrenUnchanged,
onKeepChildrenUnchangedChange,
@@ -239,6 +245,8 @@ function NodeInspector({
node: SelectedNode;
parentSize: UiEditorPageController['selectedNodeParentSize'];
readOnly: boolean;
isNodePreviewVisible: boolean;
onSetNodePreviewVisible: (visible: boolean) => void;
transformReadOnly: boolean;
keepChildrenUnchanged: boolean;
onKeepChildrenUnchangedChange: (value: boolean) => void;
@@ -255,6 +263,14 @@ function NodeInspector({
}) {
return (
<div className="mt-4 space-y-4">
<label className="flex items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-2 text-xs">
<input
type="checkbox"
checked={isNodePreviewVisible}
onChange={(event) => onSetNodePreviewVisible(event.target.checked)}
/>
</label>
<div
className="flex items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 p-0.5 text-[10px]"
role="group"
@@ -1,4 +1,4 @@
import { ChevronDown, Layers3 } from 'lucide-react';
import { ChevronDown, Eye, EyeOff, Layers3 } from 'lucide-react';
import {
type MouseEvent as ReactMouseEvent,
useEffect,
@@ -18,12 +18,16 @@ import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UiEditorNodeFocusRequest } from '../model';
const EMPTY_HIDDEN_NODE_IDS = new Set<NodeId>();
type UiTreePanelProps = {
root: UiNode | null;
selectedNodeId: NodeId | null;
focusRequest: UiEditorNodeFocusRequest | null;
treeIdForNode: (nodeId: NodeId) => UIDesignImageId | null;
hiddenNodeIdsForTree: (treeId: UIDesignImageId) => ReadonlySet<NodeId>;
onSelectNode: (treeId: UIDesignImageId, id: NodeId) => void;
onToggleNodeVisibility: (treeId: UIDesignImageId, id: NodeId) => void;
onInsertNode: (treeId: UIDesignImageId, parentId: NodeId) => void;
onInsertNodeAfter: (treeId: UIDesignImageId, nodeId: NodeId) => void;
onDeleteNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
@@ -41,19 +45,25 @@ function TreeRow({
style,
dragHandle,
onSelectNode,
hiddenNodeIds,
onToggleNodeVisibility,
onOpenContextMenu,
}: NodeRendererProps<UiNode> & {
onSelectNode: (id: NodeId) => void;
hiddenNodeIds: ReadonlySet<NodeId>;
onToggleNodeVisibility: (id: NodeId) => void;
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
}) {
const data = node.data;
const nodeLabel = data.metadata.name || '未命名节点';
const isVisible = !hiddenNodeIds.has(data.id);
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) ${
node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5'
}`}
} ${isVisible ? '' : 'opacity-60'}`}
onClick={() => {
node.select();
onSelectNode(data.id);
@@ -82,12 +92,23 @@ function TreeRow({
/>
) : null}
</button>
<span className="min-w-0 flex-1 truncate">
{data.metadata.name || '未命名节点'}
</span>
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span>
<span className="text-[10px] text-(--platform-text-soft)">
{data.components.length}
</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()}
>
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
</div>
);
}
@@ -97,7 +118,9 @@ export function UiTreePanel({
selectedNodeId,
focusRequest,
treeIdForNode,
hiddenNodeIdsForTree,
onSelectNode,
onToggleNodeVisibility,
onInsertNode,
onInsertNodeAfter,
onDeleteNode,
@@ -188,24 +211,36 @@ export function UiTreePanel({
disableDrop={({ parentNode }) => parentNode.id === root.id}
aria-label="UI 节点树"
>
{(props) => (
<TreeRow
{...props}
onSelectNode={(nodeId) => {
const treeId = treeIdForNode(nodeId);
if (treeId) onSelectNode(treeId, nodeId);
}}
onOpenContextMenu={(event, nodeId) =>
nodeId === root.id
? undefined
: setContextMenu({
nodeId,
x: event.clientX,
y: event.clientY,
})
}
/>
)}
{(props) => {
const treeId = treeIdForNode(props.node.data.id);
return (
<TreeRow
{...props}
onSelectNode={(nodeId) => {
const nodeTreeId = treeIdForNode(nodeId);
if (nodeTreeId) onSelectNode(nodeTreeId, nodeId);
}}
hiddenNodeIds={
treeId
? hiddenNodeIdsForTree(treeId)
: EMPTY_HIDDEN_NODE_IDS
}
onToggleNodeVisibility={(nodeId) => {
const nodeTreeId = treeIdForNode(nodeId);
if (nodeTreeId) onToggleNodeVisibility(nodeTreeId, nodeId);
}}
onOpenContextMenu={(event, nodeId) =>
nodeId === root.id
? undefined
: setContextMenu({
nodeId,
x: event.clientX,
y: event.clientY,
})
}
/>
);
}}
</Tree>
) : (
<p className="m-0 text-xs text-(--platform-text-soft)">
@@ -340,6 +340,7 @@ export function PreviewWorkspace({
<UiTreeRenderer
tree={tree}
renderMode={renderMode}
hiddenNodeIds={controller.hiddenNodeIds}
selectedNodeId={controller.selectedNodeId}
resources={{
previewUrls,
@@ -20,6 +20,7 @@ type NodePointerDown = (
type UiTreeRendererProps = {
tree: UITree | null;
renderMode: UiEditorRenderMode;
hiddenNodeIds: ReadonlySet<NodeId>;
selectedNodeId: NodeId | null;
resources: PreviewComponentResources;
onSelectNode: (id: NodeId) => void;
@@ -56,6 +57,7 @@ function RenderNode({
node,
isRoot,
renderMode,
hiddenNodeIds,
selectedNodeId,
resources,
onSelectNode,
@@ -67,6 +69,8 @@ function RenderNode({
onNodeResizePointerUp,
viewportScale,
}: Omit<UiTreeRendererProps, 'tree'> & { node: UiNode; isRoot?: boolean }) {
if (hiddenNodeIds.has(node.id)) return null;
let geometry;
try {
geometry = tf2css(node.transform);
@@ -123,6 +127,7 @@ function RenderNode({
key={child.id}
node={child}
renderMode={renderMode}
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
@@ -58,6 +58,18 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
type HiddenNodeIdsByImage = Record<UIDesignImageId, Record<NodeId, true>>;
function collectNodeIds(root: UiNode): Set<NodeId> {
const ids = new Set<NodeId>();
const visit = (node: UiNode) => {
ids.add(node.id);
for (const child of node.children) visit(child);
};
visit(root);
return ids;
}
export type PendingWorkflowStepChange = {
from: UiEditorStepId;
to: UiEditorStepId;
@@ -90,6 +102,8 @@ export function useUiEditorPage(
null,
);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const [hiddenNodeIdsByImage, setHiddenNodeIdsByImage] =
useState<HiddenNodeIdsByImage>({});
const { focusRequest, focusNode } = useUiEditorNodeFocus({
activateImage: setActiveImageId,
clearSpriteSelection: () => setSelectedSpriteId(null),
@@ -123,6 +137,7 @@ export function useUiEditorPage(
setIsLoading(false);
setLoadError(null);
setPersistedRevision(0);
setHiddenNodeIdsByImage({});
return;
}
let cancelled = false;
@@ -141,6 +156,7 @@ export function useUiEditorPage(
setActiveImageId(ids[0] ?? null);
setSelectedNodeId(null);
setSelectedSpriteId(null);
setHiddenNodeIdsByImage({});
setPreviewUrls({});
setPersistedRevision(revision);
if (projectPath) {
@@ -260,6 +276,89 @@ export function useUiEditorPage(
? editor.state.ui_trees.find((tree) => tree.src_ui_design === activeImageId)
: null;
useEffect(() => {
setHiddenNodeIdsByImage((current) => {
let changed = false;
const next: HiddenNodeIdsByImage = {};
for (const [treeId, hiddenNodeIds] of Object.entries(current)) {
const tree = editor.state.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) {
changed = true;
continue;
}
const validNodeIds = collectNodeIds(tree.root);
const filtered = Object.fromEntries(
Object.keys(hiddenNodeIds)
.filter((nodeId) => validNodeIds.has(nodeId))
.map((nodeId) => [nodeId, true]),
) as Record<NodeId, true>;
if (
Object.keys(filtered).length !== Object.keys(hiddenNodeIds).length
) {
changed = true;
}
if (Object.keys(filtered).length > 0) {
next[treeId as UIDesignImageId] = filtered;
}
}
return changed ? next : current;
});
}, [editor.state.ui_trees]);
const hiddenNodeIds = useMemo<ReadonlySet<NodeId>>(
() =>
new Set(
activeImageId
? Object.keys(hiddenNodeIdsByImage[activeImageId] ?? {})
: [],
),
[activeImageId, hiddenNodeIdsByImage],
);
const isNodePreviewVisible = useCallback(
(nodeId: NodeId, treeId = activeImageId) =>
!Object.prototype.hasOwnProperty.call(
treeId ? hiddenNodeIdsByImage[treeId] : undefined,
nodeId,
),
[activeImageId, hiddenNodeIdsByImage],
);
const setNodePreviewVisible = useCallback(
(nodeId: NodeId, visible: boolean, treeId = activeImageId) => {
if (!treeId) return;
setHiddenNodeIdsByImage((current) => {
const currentForImage = current[treeId] ?? {};
const currentlyVisible = !currentForImage[nodeId];
if (currentlyVisible === visible) return current;
const nextForImage = { ...currentForImage };
if (visible) delete nextForImage[nodeId];
else nextForImage[nodeId] = true;
const next = { ...current };
if (Object.keys(nextForImage).length > 0) {
next[treeId] = nextForImage;
} else {
delete next[treeId];
}
return next;
});
},
[activeImageId],
);
const toggleNodePreviewVisibility = useCallback(
(nodeId: NodeId, treeId = activeImageId) => {
setNodePreviewVisible(
nodeId,
!isNodePreviewVisible(nodeId, treeId),
treeId,
);
},
[activeImageId, isNodePreviewVisible, setNodePreviewVisible],
);
const findNodeContext = useCallback(function findNodeContext(
node: UiNode,
nodeId: NodeId,
@@ -364,6 +463,12 @@ export function useUiEditorPage(
if (id === selectedSpriteId) setSelectedSpriteId(null);
if (id === selectedFontId) setSelectedFontId(null);
if (id !== activeImageId) return;
setHiddenNodeIdsByImage((current) => {
if (!(id in current)) return current;
const next = { ...current };
delete next[id as UIDesignImageId];
return next;
});
setSelectedNodeId(null);
const index = imageOrder.indexOf(id);
const remaining = imageOrder.filter((item) => item !== id);
@@ -496,6 +601,7 @@ export function useUiEditorPage(
setActiveImageId(null);
setSelectedSpriteId(null);
setSelectedNodeId(null);
setHiddenNodeIdsByImage({});
setPreviewUrls({});
setClearOpen(false);
}
@@ -825,6 +931,11 @@ export function useUiEditorPage(
activeImage,
selectedSprite,
selectedFont,
hiddenNodeIdsByImage,
hiddenNodeIds,
isNodePreviewVisible,
setNodePreviewVisible,
toggleNodePreviewVisibility,
pageOptions,
spriteReferenceCounts,
fontReferenceCounts,
@@ -97,4 +97,70 @@ describe('UiEditorPage', () => {
expect(result.current.selectedNodeId).toBeNull();
expect(result.current.selectedSpriteId).toBe('panel');
});
it('keeps node preview visibility transient and scoped to each interface image', () => {
const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor'));
act(() => {
result.current.editor.addDesignImages([
{
id: 'page-a',
image: {
metadata: {
name: 'Page A',
description: '',
role: 'Page',
slave_to: null,
},
path: 'assets/page-a.png',
pixel_size: [320, 180],
pixels_per_unit: 1,
},
},
{
id: 'page-b',
image: {
metadata: {
name: 'Page B',
description: '',
role: 'Page',
slave_to: null,
},
path: 'assets/page-b.png',
pixel_size: [320, 180],
pixels_per_unit: 1,
},
},
]);
});
act(() => result.current.selectDesignImage('page-a'));
const rootId = result.current.treeForActiveImage!.root.id;
let insertedNodeId: string | undefined;
act(() => {
const inserted = result.current.insertNode(rootId);
if (inserted?.ok) insertedNodeId = inserted.value;
});
const stateBeforeToggle = JSON.stringify(result.current.editor.state);
act(() => {
result.current.toggleNodePreviewVisibility(rootId);
});
expect(result.current.isNodePreviewVisible(rootId)).toBe(false);
expect(result.current.hiddenNodeIds.has(rootId)).toBe(true);
expect(JSON.stringify(result.current.editor.state)).toBe(stateBeforeToggle);
act(() => result.current.selectDesignImage('page-b'));
expect(result.current.hiddenNodeIds.size).toBe(0);
act(() => result.current.selectDesignImage('page-a'));
expect(result.current.hiddenNodeIds.has(rootId)).toBe(true);
expect(insertedNodeId).toBeTruthy();
act(() => result.current.toggleNodePreviewVisibility(insertedNodeId!));
expect(result.current.hiddenNodeIds.has(insertedNodeId!)).toBe(true);
act(() => result.current.deleteNode(insertedNodeId!));
expect(result.current.hiddenNodeIds.has(insertedNodeId!)).toBe(false);
act(() => result.current.clearState());
expect(result.current.hiddenNodeIds.size).toBe(0);
});
});
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UITree } from '../src/features/ui-editor/types/UITree';
import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer';
function node(id: string, children: UiNode[] = []): UiNode {
return {
id,
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
metadata: {
name: id,
description: '',
layout_status: 'Passed',
components_status: 'Passed',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components: [],
children,
};
}
const tree: UITree = {
src_ui_design: 'page',
root: node('root', [node('child', [node('grandchild')])]),
};
const resources = {
previewUrls: {},
sprites: {},
fonts: {},
fontFaces: {},
};
function renderTree(
renderMode: 'editor-overlay' | 'final-preview',
hiddenNodeIds: ReadonlySet<string>,
) {
return render(
<UiTreeRenderer
tree={tree}
renderMode={renderMode}
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={null}
resources={resources}
onSelectNode={vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
viewportScale={1}
/>,
);
}
describe('UI tree preview visibility', () => {
it('hides a node subtree in editor overlay and final preview', () => {
const editorOverlay = renderTree('editor-overlay', new Set(['child']));
expect(
editorOverlay.container.querySelector('[data-node-id="root"]'),
).toBeTruthy();
expect(
editorOverlay.container.querySelector('[data-node-id="child"]'),
).toBeNull();
expect(
editorOverlay.container.querySelector('[data-node-id="grandchild"]'),
).toBeNull();
const finalPreview = renderTree('final-preview', new Set(['root']));
expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull();
});
});
@@ -1092,3 +1092,9 @@ game-project/
- Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revisionRust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。
- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。
## 2026-08-18 UI Editor 节点预览可见性
- 节点显示 / 隐藏是编辑器页面级临时状态,不新增 Rust `Node` 字段,不进入 UI State 保存快照、运行时数据或后端契约。状态按界面图 ID 和节点 ID 维护;切换界面图保留,清空或重新加载时重置,删除节点或界面图时清理失效 ID。
- 同一可见性状态同时作用于 `编辑叠加``最终预览`。隐藏节点会截断整个子树,移除节点组件、编辑边框、名称、缩放控制点和画布命中区域;参考图和预览画布本身保持存在。父节点恢复时,子节点此前独立设置的隐藏状态保持不变,根节点不享受特殊限制,也允许隐藏。
- UI Tree 行提供 `Eye` / `EyeOff` 按钮,按钮只切换状态、不改变选择;节点 Inspector 提供“在预览中显示”复选框,两者共享页面控制器状态。隐藏节点仍可通过 UI Tree 选择并在 Inspector 中编辑,但不会重新出现在画布上,直到恢复显示。