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 61b19bd5f..2c0c6b714 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 @@ -88,6 +88,7 @@ export function PreviewWorkspace({ spaceHeld, tree, previewRef: viewportElementRef, + selectedNodeId: canvas.selectedNodeId, viewportRef, }); const { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 978799ba6..991083160 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -73,6 +73,15 @@ function isFiniteTransform(transform: UiNode['layout']['transform']) { ); } +function findNodeById(node: UiNode, nodeId: string): UiNode | null { + if (node.id === nodeId) return node; + for (const child of node.children) { + const match = findNodeById(child, nodeId); + if (match) return match; + } + return null; +} + function releasePointer(target: HTMLDivElement, pointerId: number) { if (target.hasPointerCapture(pointerId)) { target.releasePointerCapture(pointerId); @@ -113,6 +122,7 @@ export function useNodeTransformInteraction({ tree, viewportRef, previewRef, + selectedNodeId, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -121,6 +131,7 @@ export function useNodeTransformInteraction({ tree: UITree | null; viewportRef: RefObject; previewRef?: RefObject; + selectedNodeId?: string | null; }) { const activeGestureRef = useRef(null); const suppressNextNodeClickRef = useRef(false); @@ -174,23 +185,28 @@ export function useNodeTransformInteraction({ ) { return; } + const selectedNode = + selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null; + const dragNode = + selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node; + if (!isFiniteTransform(dragNode.layout.transform)) return; event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); suppressNextNodeClickRef.current = false; activeGestureRef.current = { kind: 'drag', treeId: activeImageId, - nodeId: node.id, + nodeId: dragNode.id, pointerId: event.pointerId, target: event.currentTarget, startClientX: event.clientX, startClientY: event.clientY, - startTransform: structuredClone(node.layout.transform), + startTransform: structuredClone(dragNode.layout.transform), hitNodeId: resolveHitNodeId(event, node.id), hasMoved: false, }; }, - [activeImageId, spaceHeld, tree?.root.id], + [activeImageId, selectedNodeId, spaceHeld, tree], ); const onNodePointerMove = useCallback( diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 0b3baff76..7b794fc2c 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -79,7 +79,10 @@ function canvas() { return { selectNode: vi.fn(), updateNodeTransform: vi.fn(), - } as Pick; + selectedNodeId: null as string | null, + } as Pick & { + selectedNodeId: string | null; + }; } function renderInteraction({ @@ -87,13 +90,16 @@ function renderInteraction({ scale = 1, tree = pageTree, previewRef, + selectedNodeId = null, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; previewRef?: MutableRefObject; + selectedNodeId?: string | null; } = {}) { const canvasProjection = canvas(); + canvasProjection.selectedNodeId = selectedNodeId; const viewportRef = { current: { scale } }; const hook = renderHook( ({ imageId, currentTree }) => @@ -104,6 +110,7 @@ function renderInteraction({ spaceHeld: false, tree: currentTree, previewRef, + selectedNodeId, viewportRef, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, @@ -112,6 +119,30 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('moves the current selection when dragging another node', () => { + const selected = node('selected'); + const treeWithSelection: UITree = { + src_ui_design: 'page', + root: node('root', [selected, child]), + }; + const { result, canvasProjection } = renderInteraction({ + tree: treeWithSelection, + selectedNodeId: 'selected', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + }); + + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith( + 'page', + 'selected', + expect.anything(), + ); + }); + it('defers node selection until a clean pointerup', () => { const { result, canvasProjection } = renderInteraction(); const target = gestureTarget(); diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index be9d875a5..af2afcbf9 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -41,4 +41,4 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa 布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。 -Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。 +Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。