diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 17db6cc6d..a1fb038f1 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -16,6 +16,7 @@ import { import { ComponentView } from './components/ComponentView'; import type { PreviewComponentResources } from './components/types'; import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs'; +import { resolveExclusiveVisibleChildId } from './exclusiveVisibility'; export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry'; @@ -72,6 +73,7 @@ const RESIZE_HANDLES: ReadonlyArray<{ function RenderNode({ node, isRoot, + forceVisible, parentContainer, renderMode, showFrame, @@ -91,9 +93,10 @@ function RenderNode({ }: Omit & { node: UiNode; isRoot?: boolean; + forceVisible?: boolean; parentContainer?: UiNode['layout']['container']; }) { - if (hiddenNodeIds.has(node.id)) return null; + if (!forceVisible && hiddenNodeIds.has(node.id)) return null; let geometry; try { @@ -110,8 +113,7 @@ function RenderNode({ const isFrameVisible = isEditorOverlay || showFrame; const exclusiveVisibleChildId = node.children_display_mode === 'Exclusive' - ? (node.children.find((child) => !hiddenNodeIds.has(child.id))?.id ?? - null) + ? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds) : null; return (
, + hiddenNodeIds: ReadonlySet, +): NodeId | null { + return ( + children.find((child) => !hiddenNodeIds.has(child.id))?.id ?? + children[0]?.id ?? + null + ); +} 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 a269f7cd8..a0ecf0533 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 @@ -45,6 +45,7 @@ import { createProjectResourcePreviewRequestId, createProjectResourcePreviewScopeId, } from '../../services/projectResourcePreviewTransport'; +import { resolveExclusiveVisibleChildId } from './components/preview/exclusiveVisibility'; import { activeStepPrerequisiteIssues, postCheckIssuesForSave, @@ -87,12 +88,14 @@ function isUiNodeEffectivelyVisible( targetNodeId: NodeId, hidden: ReadonlySet, ancestorsVisible = true, + forceVisible = false, ): boolean | null { - const nodeVisible = ancestorsVisible && !hidden.has(node.id); + const nodeVisible = + ancestorsVisible && (forceVisible || !hidden.has(node.id)); if (node.id === targetNodeId) return nodeVisible; const exclusiveChildId = node.children_display_mode === 'Exclusive' - ? (node.children.find((child) => !hidden.has(child.id))?.id ?? null) + ? resolveExclusiveVisibleChildId(node.children, hidden) : null; for (const child of node.children) { const visible = isUiNodeEffectivelyVisible( @@ -102,6 +105,8 @@ function isUiNodeEffectivelyVisible( nodeVisible && (node.children_display_mode !== 'Exclusive' || child.id === exclusiveChildId), + node.children_display_mode === 'Exclusive' && + child.id === exclusiveChildId, ); if (visible !== null) return visible; } @@ -429,8 +434,7 @@ export function useUiEditorSession( parent.children.some((child) => child.id === nodeId); const currentlyVisible = isExclusiveChild ? !current.has(parent.id) && - parent.children.find((child) => !current.has(child.id))?.id === - nodeId + resolveExclusiveVisibleChildId(parent.children, current) === nodeId : !current.has(nodeId); if (currentlyVisible === visible) return current; const next = new Set(current); @@ -442,6 +446,8 @@ export function useUiEditorSession( } } else { for (const child of parent.children) next.add(child.id); + const fallbackChildId = parent.children[0]?.id; + if (fallbackChildId) next.delete(fallbackChildId); } } else if (visible) { next.delete(nodeId); diff --git a/apps/ai-game-creator-shell/tests/exclusiveVisibility.test.ts b/apps/ai-game-creator-shell/tests/exclusiveVisibility.test.ts new file mode 100644 index 000000000..f4850527d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/exclusiveVisibility.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveExclusiveVisibleChildId } from '../src/view/ui-editor/components/preview/exclusiveVisibility'; + +describe('resolveExclusiveVisibleChildId', () => { + const children = [{ id: 'first' }, { id: 'second' }]; + + it('uses the first non-hidden child as the explicit selection', () => { + expect(resolveExclusiveVisibleChildId(children, new Set(['first']))).toBe( + 'second', + ); + }); + + it('falls back to the first child when every child is hidden', () => { + expect( + resolveExclusiveVisibleChildId(children, new Set(['first', 'second'])), + ).toBe('first'); + }); + + it('returns null for an empty exclusive container', () => { + expect(resolveExclusiveVisibleChildId([], new Set())).toBeNull(); + }); +}); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index bd29ec4c6..6f0f35a4e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -4,7 +4,7 @@ ## 2026-08-20 UI Editor 最终预览互斥子节点 -最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 +最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点没有显式可见子节点时,必须默认且仅显示第一个直接子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 `BestFit` 文本测量的 effect 只依赖字号模式的标量值、文本测量键和实际容器,不能依赖整个 `FontSizing` 对象引用。UI State 对任意节点(包括切换 `children_display_mode`)的不可变更新会 clone 整棵树;等值 clone 不得重新创建 `ResizeObserver` 或同步重复二分测量,以避免最终预览已显示时出现明显的“重新加载”。