diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx index 86819e94a..3de66f8e8 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx @@ -1,16 +1,18 @@ +import type { PointerEvent } from 'react'; + import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; type ExclusiveChildrenTabsProps = { parent: UiNode; isChildVisible: (nodeId: NodeId) => boolean; - onToggleChild: (nodeId: NodeId) => void; + onSelectChild: (nodeId: NodeId) => void; }; export function ExclusiveChildrenTabs({ parent, isChildVisible, - onToggleChild, + onSelectChild, }: ExclusiveChildrenTabsProps) { if ( parent.children_display_mode !== 'Exclusive' || @@ -19,12 +21,20 @@ export function ExclusiveChildrenTabs({ return null; } + const stopNodeGesture = (event: PointerEvent) => { + event.stopPropagation(); + }; + return (
{parent.children.map((child, index) => { const label = child.metadata.name || `未命名子节点 ${index + 1}`; @@ -40,7 +50,10 @@ export function ExclusiveChildrenTabs({ ? 'bg-orange-500 text-white shadow-sm' : 'text-(--platform-text-soft) hover:bg-black/5 hover:text-(--platform-text-strong)' }`} - onClick={() => onToggleChild(child.id)} + onClick={(event) => { + event.stopPropagation(); + onSelectChild(child.id); + }} > {label} 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 b1d80a718..f1b50bb18 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 @@ -27,7 +27,6 @@ import { findNodePageContext } from '../../../../features/ui-editor/nodeTransfor import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; -import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; @@ -343,15 +342,6 @@ export function PreviewWorkspace({
- {renderMode === 'final-preview' && canvas.selectedNode ? ( - canvas.isNodePreviewVisible(nodeId)} - onToggleChild={(nodeId) => - canvas.toggleNodePreviewVisibility(nodeId) - } - /> - ) : null} 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 b822c853b..17db6cc6d 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 @@ -15,6 +15,7 @@ import { } from '../../../../features/ui-editor/utils/layout/controlLayoutToCss'; import { ComponentView } from './components/ComponentView'; import type { PreviewComponentResources } from './components/types'; +import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs'; export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry'; @@ -48,6 +49,7 @@ type UiTreeRendererProps = { ) => void; onNodeResizePointerMove: (event: ReactPointerEvent) => void; onNodeResizePointerUp: (event: ReactPointerEvent) => void; + onSelectExclusiveChild: (nodeId: NodeId) => void; viewportScale: number; }; @@ -84,6 +86,7 @@ function RenderNode({ onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onSelectExclusiveChild, viewportScale, }: Omit & { node: UiNode; @@ -165,6 +168,13 @@ function RenderNode({ resources={resources} /> ))} + {renderMode === 'final-preview' && selectedNodeId === node.id ? ( + nodeId === exclusiveVisibleChildId} + onSelectChild={onSelectExclusiveChild} + /> + ) : null} {node.children.map((child) => exclusiveVisibleChildId && child.id !== exclusiveVisibleChildId ? null : ( @@ -189,6 +199,7 @@ function RenderNode({ onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onSelectExclusiveChild={onSelectExclusiveChild} viewportScale={viewportScale} /> ), diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts index f318c1b1d..4e5796291 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts @@ -23,20 +23,29 @@ export function useBestFitFontSize({ measurementKey: string; }): number { const initialSize = initialFontSize(sizing); + const fixedSize = 'Fixed' in sizing ? sizing.Fixed : null; + const bestFitMin = 'BestFit' in sizing ? sizing.BestFit.min : null; + const bestFitMax = 'BestFit' in sizing ? sizing.BestFit.max : null; const [fontSize, setFontSize] = useState(initialSize); useLayoutEffect(() => { - if ('Fixed' in sizing) { - setFontSize((current) => - current === sizing.Fixed ? current : sizing.Fixed, - ); + if (fixedSize !== null) { + setFontSize((current) => (current === fixedSize ? current : fixedSize)); return; } const container = containerRef.current; const measurement = measurementRef.current; - if (!container || !measurement) return; - const { min, max } = sizing.BestFit; + if ( + !container || + !measurement || + bestFitMin === null || + bestFitMax === null + ) { + return; + } + const min = bestFitMin; + const max = bestFitMax; const measure = () => { const width = container.clientWidth; const height = container.clientHeight; @@ -61,7 +70,14 @@ export function useBestFitFontSize({ const observer = new ResizeObserver(measure); observer.observe(container); return () => observer.disconnect(); - }, [containerRef, initialSize, measurementKey, measurementRef, sizing]); + }, [ + bestFitMax, + bestFitMin, + containerRef, + fixedSize, + measurementKey, + measurementRef, + ]); return fontSize; } 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 73457e057..60edee2c5 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 @@ -428,12 +428,9 @@ export function useUiEditorSession( parent?.children_display_mode === 'Exclusive' && parent.children.some((child) => child.id === nodeId); const currentlyVisible = isExclusiveChild - ? parent.children.some( - (child) => - child.id === nodeId && - !current.has(child.id) && - !current.has(parent.id), - ) + ? !current.has(parent.id) && + parent.children.find((child) => !current.has(child.id))?.id === + nodeId : !current.has(nodeId); if (currentlyVisible === visible) return current; const next = new Set(current); @@ -482,6 +479,10 @@ export function useUiEditorSession( setNodePreviewVisible, ], ); + const selectExclusiveChild = useCallback( + (nodeId: NodeId) => setNodePreviewVisible(nodeId, true), + [setNodePreviewVisible], + ); const selectedNodeContext = useMemo(() => { if (!activeImage || !treeForActiveImage || !selectedNodeId) return null; const ppu = activeImage.pixels_per_unit; @@ -1091,6 +1092,7 @@ export function useUiEditorSession( status, isNodePreviewVisible, toggleNodePreviewVisibility, + selectExclusiveChild, selectNode, clearNodeSelection, updateNodeTransform, diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 7b62795ac..6395ad68f 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -508,6 +508,14 @@ describe('UiEditorPage', () => { result.current.canvas.isNodePreviewVisible(childBDescendantId!), ).toBe(false); + act(() => result.current.canvas.selectExclusiveChild(childBId!)); + expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(false); + expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(true); + expect( + result.current.canvas.isNodePreviewVisible(childBDescendantId!), + ).toBe(true); + + act(() => result.current.canvas.selectExclusiveChild(childAId!)); act(() => result.current.canvas.toggleNodePreviewVisibility(childAId!)); expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(false); expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(false); @@ -518,6 +526,10 @@ describe('UiEditorPage', () => { expect( result.current.canvas.isNodePreviewVisible(childBDescendantId!), ).toBe(true); + + act(() => result.current.canvas.selectExclusiveChild(childAId!)); + expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(true); + expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(false); }); it('shows a page-level save failure and allows a retry', async () => { diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index f31090c98..5b47a87c3 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -259,12 +259,12 @@ describe('UI tree preview visibility', () => { it('uses a highlighted tab bar for exclusive child switching', () => { const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]); - const onToggleChild = vi.fn(); + const onSelectChild = vi.fn(); render( nodeId === 'child-a'} - onToggleChild={onToggleChild} + onSelectChild={onSelectChild} />, ); @@ -279,7 +279,40 @@ describe('UI tree preview visibility', () => { .getAttribute('aria-selected'), ).toBe('false'); fireEvent.click(screen.getByRole('tab', { name: 'child-b' })); - expect(onToggleChild).toHaveBeenCalledWith('child-b'); + expect(onSelectChild).toHaveBeenCalledWith('child-b'); + }); + + it('keeps exclusive tab pointer gestures out of the parent node', () => { + const onSelectChild = vi.fn(); + const onNodePointerDown = vi.fn(); + const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]); + render( + , + ); + + const tab = screen.getByRole('tab', { name: 'child-b' }); + fireEvent.pointerDown(tab, { button: 0, pointerId: 1 }); + fireEvent.click(tab); + + expect(onNodePointerDown).not.toHaveBeenCalled(); + expect(onSelectChild).toHaveBeenCalledWith('child-b'); }); it('opens a context-menu target only from editor overlay nodes', () => { diff --git a/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx index f66f44377..cdf4bd25a 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx @@ -147,4 +147,32 @@ describe('TextComponentView', () => { act(() => observerCallback?.([], {} as ResizeObserver)); expect(view.style.fontSize).toBe('9px'); }); + + it('does not restart BestFit measurement when an equivalent component is cloned', () => { + const observers: Array<{ disconnect: ReturnType }> = []; + class TestResizeObserver { + readonly disconnect = vi.fn(); + + constructor(_callback: ResizeObserverCallback) { + observers.push(this); + } + + observe = vi.fn(); + } + vi.stubGlobal('ResizeObserver', TestResizeObserver); + const original = text({ font_sizing: { BestFit: { min: 8, max: 20 } } }); + const view = render( + , + ); + + view.rerender( + , + ); + + expect(observers).toHaveLength(1); + expect(observers[0]!.disconnect).not.toHaveBeenCalled(); + }); }); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index fa191a68d..f5b84297d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -2,6 +2,12 @@ ## 2026-08-19 UI Editor 节点右键菜单 +## 2026-08-20 UI Editor 最终预览互斥子节点 + +最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 + +`BestFit` 文本测量的 effect 只依赖字号模式的标量值、文本测量键和实际容器,不能依赖整个 `FontSizing` 对象引用。UI State 对任意节点(包括切换 `children_display_mode`)的不可变更新会 clone 整棵树;等值 clone 不得重新创建 `ResizeObserver` 或同步重复二分测量,以避免最终预览已显示时出现明显的“重新加载”。 + ## 2026-08-19 UI Editor 节点几何唯一 seam `features/ui-editor/nodeTransformGeometry.ts` 是 UI Editor 节点几何的唯一 seam:它统一解析页面 rect 和节点 context、按 anchor / offset 反算 transform、同树 reparent 意图、父节点变换时的直接子节点页面位置保持,以及预览 resize 的等比约束。页面 Inspector、预览 pointer interaction 与状态迁移分别作为该 Module 的 adapter;它们不得再递归计算节点尺寸、各自反算 offset 或复制子节点保持逻辑。跨界面移动仍只保留原局部 transform,不进入 page rect 换算。