From f10fc2f03231fd7a23d47e8625e37663463a7be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 14:23:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A2=84=E8=A7=88=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E4=BD=BF=E7=94=A8=E5=BD=93=E5=89=8D=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拖动手势优先更新按下前已选中的节点 补充跨节点拖动回归测试并同步交互约定 --- .../components/preview/PreviewWorkspace.tsx | 1 + .../preview/useNodeTransformInteraction.ts | 22 +++++++++++-- .../useNodeTransformInteraction.test.tsx | 33 ++++++++++++++++++- ...方案】UI编辑器Godot容器布局模型-2026-08-18.md | 2 +- 4 files changed, 53 insertions(+), 5 deletions(-) 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 仍按实际命中节点选择。