diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 40fa2ceb2..141210879 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -46,6 +46,16 @@ export function InputSidebar({ return result; }, [editor.state.ui_trees]); + const hiddenNodeIdsByTree = useMemo(() => { + const result = new Map>(); + for (const [treeId, hiddenNodeIds] of Object.entries( + controller.hiddenNodeIdsByImage, + )) { + result.set(treeId, new Set(Object.keys(hiddenNodeIds))); + } + return result; + }, [controller.hiddenNodeIdsByImage]); + const superRoot = useMemo(() => { 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() + } onSelectNode={(treeId, nodeId) => { controller.selectDesignImage(treeId); controller.selectNode(nodeId); }} + onToggleNodeVisibility={(treeId, nodeId) => + controller.toggleNodePreviewVisibility(nodeId, treeId) + } onInsertNode={(treeId, parentId) => { controller.insertNode(parentId, treeId); }} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index f0a965820..0d4b670ff 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -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 (
+
(); + type UiTreePanelProps = { root: UiNode | null; selectedNodeId: NodeId | null; focusRequest: UiEditorNodeFocusRequest | null; treeIdForNode: (nodeId: NodeId) => UIDesignImageId | null; + hiddenNodeIdsForTree: (treeId: UIDesignImageId) => ReadonlySet; 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 & { onSelectNode: (id: NodeId) => void; + hiddenNodeIds: ReadonlySet; + 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 (
{ node.select(); onSelectNode(data.id); @@ -82,12 +92,23 @@ function TreeRow({ /> ) : null} - - {data.metadata.name || '未命名节点'} - + {nodeLabel} {data.components.length} +
); } @@ -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) => ( - { - 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 ( + { + 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, + }) + } + /> + ); + }} ) : (

diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 42c6a5be4..b0f5b8966 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -340,6 +340,7 @@ export function PreviewWorkspace({ ; 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 & { 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} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 7a2d0cff1..cacd7fb7d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -58,6 +58,18 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; const ASSET_BATCH_SIZE = 5; +type HiddenNodeIdsByImage = Record>; + +function collectNodeIds(root: UiNode): Set { + const ids = new Set(); + 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(null); + const [hiddenNodeIdsByImage, setHiddenNodeIdsByImage] = + useState({}); 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; + 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>( + () => + 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, diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index a9761c430..6c6168609 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -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); + }); }); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx new file mode 100644 index 000000000..7b4450777 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -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, +) { + return render( + , + ); +} + +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(); + }); +}); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index bce70fae5..28f079dec 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1092,3 +1092,9 @@ game-project/ - Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revision;Rust 按 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 中编辑,但不会重新出现在画布上,直到恢复显示。