From fcacdca4a4c5fd2386e1193abebffa68d44e498c 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 13:37:27 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=9D=BE=E5=BC=80=E9=80=89=E6=8B=A9=E6=89=8B?= =?UTF-8?q?=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按下阶段仅记录节点手势与命中目标 在干净松开时选择并处理拖动取消失焦边界 阻止拖动后的嵌套 click 重新选择 --- .../components/preview/PreviewWorkspace.tsx | 7 ++ .../components/preview/UiTreeRenderer.tsx | 32 ++++++-- .../preview/useNodeTransformInteraction.ts | 76 +++++++++++++++++-- 3 files changed, 103 insertions(+), 12 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 ec88d9571..61b19bd5f 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 @@ -87,15 +87,19 @@ export function PreviewWorkspace({ logicalSize, spaceHeld, tree, + previewRef: viewportElementRef, viewportRef, }); const { onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodeResizePointerCancel, + consumeNodeClick, } = nodeInteractions; const setViewport = useCallback((next: CanvasViewport) => { @@ -389,13 +393,16 @@ export function PreviewWorkspace({ fontFaces: canvas.fontFaces, }} onSelectNode={canvas.selectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={handleNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={canvas.selectExclusiveChild} viewportScale={viewport.scale} /> 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 ad91c0571..006971164 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 @@ -35,6 +35,7 @@ type UiTreeRendererProps = { selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; + consumeNodeClick?: () => boolean; onNodeContextMenu: ( event: ReactMouseEvent, node: UiNode, @@ -43,6 +44,7 @@ type UiTreeRendererProps = { onNodePointerDown: NodePointerDown; onNodePointerMove: (event: ReactPointerEvent) => void; onNodePointerUp: (event: ReactPointerEvent) => void; + onNodePointerCancel?: (event: ReactPointerEvent) => void; onNodeResizePointerDown: ( event: ReactPointerEvent, node: UiNode, @@ -50,6 +52,9 @@ type UiTreeRendererProps = { ) => void; onNodeResizePointerMove: (event: ReactPointerEvent) => void; onNodeResizePointerUp: (event: ReactPointerEvent) => void; + onNodeResizePointerCancel?: ( + event: ReactPointerEvent, + ) => void; onSelectExclusiveChild: (nodeId: NodeId) => void; viewportScale: number; }; @@ -80,13 +85,16 @@ function RenderNode({ selectedNodeId, resources, onSelectNode, + consumeNodeClick = () => false, onNodeContextMenu, onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel = onNodePointerUp, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodeResizePointerCancel = onNodeResizePointerUp, onSelectExclusiveChild, viewportScale, }: Omit & { @@ -109,6 +117,7 @@ function RenderNode({ const isEditorOverlay = renderMode === 'editor-overlay'; const isFrameVisible = isEditorOverlay || showFrame; + const hasDirectPointerGesture = !isRoot && !parentContainer; const exclusiveVisibleChildId = node.children_display_mode === 'Exclusive' ? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds) @@ -137,10 +146,18 @@ function RenderNode({ } : {}), }} - onClick={(event) => { - event.stopPropagation(); - onSelectNode(node.id); - }} + onClick={ + hasDirectPointerGesture + ? (event) => { + event.stopPropagation(); + consumeNodeClick(); + } + : (event) => { + event.stopPropagation(); + if (consumeNodeClick()) return; + onSelectNode(node.id); + } + } onContextMenu={(event) => { if (!isEditorOverlay) return; event.preventDefault(); @@ -153,7 +170,7 @@ function RenderNode({ } onPointerMove={parentContainer ? undefined : onNodePointerMove} onPointerUp={parentContainer ? undefined : onNodePointerUp} - onPointerCancel={parentContainer ? undefined : onNodePointerUp} + onPointerCancel={parentContainer ? undefined : onNodePointerCancel} title={isFrameVisible ? node.metadata.name || undefined : undefined} > {isFrameVisible && node.metadata.name ? ( @@ -192,13 +209,16 @@ function RenderNode({ selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={onNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={onSelectExclusiveChild} viewportScale={viewportScale} /> @@ -228,7 +248,7 @@ function RenderNode({ } onPointerMove={onNodeResizePointerMove} onPointerUp={onNodeResizePointerUp} - onPointerCancel={onNodeResizePointerUp} + onPointerCancel={onNodeResizePointerCancel} /> )) : null} 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 1d22c3531..978799ba6 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 @@ -28,6 +28,7 @@ type GestureBase = { startClientX: number; startClientY: number; startTransform: UiNode['layout']['transform']; + hitNodeId: string; hasMoved: boolean; }; @@ -78,6 +79,32 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function resolveHitNodeId( + event: ReactPointerEvent, + fallback: string, +) { + const target = event.target; + if (!(target instanceof Element)) return fallback; + return ( + target.closest('[data-node-id]')?.dataset.nodeId ?? fallback + ); +} + +function isInsidePreview( + event: ReactPointerEvent, + previewRef: RefObject | undefined, +) { + const preview = previewRef?.current; + if (!preview) return true; + const rect = preview.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); +} + export function useNodeTransformInteraction({ activeImageId, canvas, @@ -85,6 +112,7 @@ export function useNodeTransformInteraction({ spaceHeld, tree, viewportRef, + previewRef, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -92,8 +120,10 @@ export function useNodeTransformInteraction({ spaceHeld: boolean; tree: UITree | null; viewportRef: RefObject; + previewRef?: RefObject; }) { const activeGestureRef = useRef(null); + const suppressNextNodeClickRef = useRef(false); const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; @@ -103,6 +133,12 @@ export function useNodeTransformInteraction({ useEffect(() => cancelGesture, [cancelGesture]); + useEffect(() => { + const cancelOnWindowBlur = () => cancelGesture(); + window.addEventListener('blur', cancelOnWindowBlur); + return () => window.removeEventListener('blur', cancelOnWindowBlur); + }, [cancelGesture]); + useEffect(() => { const gesture = activeGestureRef.current; if ( @@ -140,7 +176,7 @@ export function useNodeTransformInteraction({ } event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; activeGestureRef.current = { kind: 'drag', treeId: activeImageId, @@ -150,10 +186,11 @@ export function useNodeTransformInteraction({ startClientX: event.clientX, startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), + hitNodeId: resolveHitNodeId(event, node.id), hasMoved: false, }; }, - [activeImageId, canvas, spaceHeld, tree?.root.id], + [activeImageId, spaceHeld, tree?.root.id], ); const onNodePointerMove = useCallback( @@ -164,6 +201,8 @@ export function useNodeTransformInteraction({ } event.stopPropagation(); if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return; + gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; const scale = viewportRef.current?.scale; if (!Number.isFinite(scale) || scale <= 0) { cancelGesture(); @@ -175,7 +214,6 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - gesture.hasMoved = true; const nextTransform = structuredClone(gesture.startTransform); nextTransform.offset_min[0] += logicalDeltaX; nextTransform.offset_min[1] += logicalDeltaY; @@ -191,6 +229,21 @@ export function useNodeTransformInteraction({ ); const onNodePointerUp = useCallback( + (event: ReactPointerEvent) => { + const gesture = activeGestureRef.current; + if (!gesture || !acceptsGestureEvent(event)) return; + event.stopPropagation(); + if (gesture.hasMoved) suppressNextNodeClickRef.current = true; + if (!gesture.hasMoved && isInsidePreview(event, previewRef)) { + canvas.selectNode(gesture.hitNodeId); + suppressNextNodeClickRef.current = true; + } + cancelGesture(); + }, + [acceptsGestureEvent, cancelGesture, canvas, previewRef], + ); + + const onNodePointerCancel = useCallback( (event: ReactPointerEvent) => { if (!acceptsGestureEvent(event)) return; event.stopPropagation(); @@ -236,7 +289,7 @@ export function useNodeTransformInteraction({ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; activeGestureRef.current = { kind: 'resize', treeId: activeImageId, @@ -247,13 +300,14 @@ export function useNodeTransformInteraction({ startClientX: event.clientX, startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), + hitNodeId: node.id, startRect: context.rect, parentRect: context.parentRect, ratioAxis: null, hasMoved: false, }; }, - [activeImageId, canvas, logicalSize, spaceHeld, tree], + [activeImageId, logicalSize, spaceHeld, tree], ); const onNodeResizePointerMove = useCallback( @@ -268,6 +322,8 @@ export function useNodeTransformInteraction({ } event.stopPropagation(); if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return; + gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; const scale = viewportRef.current?.scale; if (!Number.isFinite(scale) || scale <= 0) { cancelGesture(); @@ -309,18 +365,26 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - gesture.hasMoved = true; canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); }, [acceptsGestureEvent, cancelGesture, canvas, viewportRef], ); + const consumeNodeClick = useCallback(() => { + if (!suppressNextNodeClickRef.current) return false; + suppressNextNodeClickRef.current = false; + return true; + }, []); + return { onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp: onNodePointerUp, + onNodeResizePointerCancel: onNodePointerCancel, + consumeNodeClick, }; } -- 2.52.0 From 0cdd3fbd7500db7851b59bc532608bc03a898065 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 13:37:53 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E9=80=89=E6=8B=A9=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖节点点击延迟到 pointerup 的选择时机 覆盖拖动嵌套节点缩放取消失焦和区域外松开 覆盖直接拖动节点不再由 click 单独选中 --- .../tests/uiEditorPreview.test.tsx | 12 +- .../useNodeTransformInteraction.test.tsx | 119 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 5b47a87c3..bd5ac2ade 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -62,6 +62,7 @@ function renderTree( options: { showFrame?: boolean; selectedNodeId?: string | null; + onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; } = {}, ) { @@ -73,7 +74,7 @@ function renderTree( hiddenNodeIds={hiddenNodeIds} selectedNodeId={options.selectedNodeId ?? null} resources={resources} - onSelectNode={vi.fn()} + onSelectNode={options.onSelectNode ?? vi.fn()} onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()} onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} @@ -87,6 +88,15 @@ function renderTree( } describe('UI tree preview visibility', () => { + it('does not use click alone to select directly draggable nodes', () => { + const onSelectNode = vi.fn(); + const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); + fireEvent.click( + rendered.container.querySelector('[data-node-id="child"]')!, + ); + expect(onSelectNode).not.toHaveBeenCalled(); + }); + it('shares node menu restrictions, disabled actions, and dismissal behavior', () => { const onClose = vi.fn(); const onInsertChild = vi.fn(); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 2b79ab689..0b3baff76 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act, renderHook } from '@testing-library/react'; +import type { MutableRefObject } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; @@ -59,12 +60,14 @@ function pointerEvent( pointerId: number, clientX: number, clientY: number, + eventTarget: EventTarget = target, ) { return { button: 0, clientX, clientY, currentTarget: target, + target: eventTarget, pointerId, preventDefault: vi.fn(), shiftKey: false, @@ -83,10 +86,12 @@ function renderInteraction({ activeImageId = 'page', scale = 1, tree = pageTree, + previewRef, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; + previewRef?: MutableRefObject; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -98,6 +103,7 @@ function renderInteraction({ logicalSize: { width: 320, height: 180 }, spaceHeld: false, tree: currentTree, + previewRef, viewportRef, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, @@ -106,6 +112,119 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('defers node selection until a clean pointerup', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + + act(() => { + result.current.onNodePointerUp(pointerEvent(target, 1, 1, 1)); + }); + expect(canvasProjection.selectNode).toHaveBeenCalledWith('child'); + }); + + it('does not select after a drag or pointer cancellation', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 3, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 3, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(true); + expect(result.current.consumeNodeClick()).toBe(false); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child); + result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + }); + + it('does not select when a clean pointerup lands outside the preview', () => { + const preview = document.createElement('div'); + vi.spyOn(preview, 'getBoundingClientRect').mockReturnValue({ + bottom: 10, + height: 10, + left: 0, + right: 10, + top: 0, + width: 10, + x: 0, + y: 0, + toJSON: () => ({}), + }); + const previewRef = { current: preview }; + const { result, canvasProjection } = renderInteraction({ previewRef }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 1, 1), child); + result.current.onNodePointerUp(pointerEvent(target, 1, 20, 20)); + }); + + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + }); + + it('cancels the active gesture when the window loses focus', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + window.dispatchEvent(new Event('blur')); + result.current.onNodePointerUp(pointerEvent(target, 1, 0, 0)); + }); + + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(target.releasePointerCapture).toHaveBeenCalledWith(1); + }); + + it('leaves nested child clicks to the child when the outer gesture did not move', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + const nestedTarget = document.createElement('span'); + nestedTarget.dataset.nodeId = 'nested'; + target.appendChild(nestedTarget); + + act(() => { + result.current.onNodePointerDown( + pointerEvent(target, 1, 0, 0, nestedTarget), + child, + ); + result.current.onNodePointerUp( + pointerEvent(target, 1, 0, 0, nestedTarget), + ); + }); + + expect(canvasProjection.selectNode).toHaveBeenCalledWith('nested'); + }); + + it('defers resize selection until a clean pointerup as well', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + + act(() => { + result.current.onNodeResizePointerDown( + pointerEvent(target, 1, 0, 0), + child, + 'se', + ); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + + act(() => { + result.current.onNodeResizePointerUp(pointerEvent(target, 1, 1, 1)); + }); + expect(canvasProjection.selectNode).toHaveBeenCalledWith('child'); + }); + it('makes drag and resize mutually exclusive, then permits the next gesture', () => { const { result, canvasProjection } = renderInteraction(); const dragTarget = gestureTarget(); -- 2.52.0 From 8d118ec1d722f1bf34580899ef877a0ed0b414c1 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 13:38:22 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E9=80=89=E6=8B=A9=E4=BA=A4=E4=BA=92=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录松开选择与二像素拖动阈值 补充嵌套节点和取消边界语义 --- .../【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index f9852c817..be9d875a5 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -1,6 +1,6 @@ # UI 编辑器 Godot 容器布局模型 -更新时间:`2026-08-18` +更新时间:`2026-09-03` ## 范围 @@ -40,3 +40,5 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa ## 编辑器交互 布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。 + +Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。 -- 2.52.0 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 04/14] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E4=BD=BF=E7=94=A8=E5=BD=93=E5=89=8D=E9=80=89?= =?UTF-8?q?=E4=B8=AD=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 仍按实际命中节点选择。 -- 2.52.0 From d225fdae015f58b6fdce7cddc46510b6cc0297bb 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:36:49 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E9=98=BB=E6=AD=A2=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E8=A7=A6=E5=8F=91=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E6=96=87=E5=AD=97=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为预览节点层增加 select-none 样式 拖动起始阶段阻止浏览器默认选择行为 补充原生选字防回归测试并同步文档 --- .../ui-editor/components/preview/UiTreeRenderer.tsx | 2 +- .../preview/useNodeTransformInteraction.ts | 1 + .../tests/uiEditorPreview.test.tsx | 10 ++++++++++ .../tests/useNodeTransformInteraction.test.tsx | 12 ++++++++++++ ...技术方案】UI编辑器Godot容器布局模型-2026-08-18.md | 2 ++ 5 files changed, 26 insertions(+), 1 deletion(-) 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 006971164..18cee0ab6 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 @@ -125,7 +125,7 @@ function RenderNode({ return (
{ + it('disables native text selection on preview node layers', () => { + const rendered = renderTree('editor-overlay', new Set()); + + expect( + rendered.container + .querySelector('[data-node-id="child"]') + ?.classList.contains('select-none'), + ).toBe(true); + }); + it('does not use click alone to select directly draggable nodes', () => { const onSelectNode = vi.fn(); const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 7b794fc2c..d6d854eb4 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -119,6 +119,18 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('prevents the browser default when starting a node drag', () => { + const { result } = renderInteraction(); + const target = gestureTarget(); + const event = pointerEvent(target, 1, 0, 0); + + act(() => { + result.current.onNodePointerDown(event, child); + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + it('moves the current selection when dragging another node', () => { const selected = node('selected'); const treeWithSelection: UITree = { diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index af2afcbf9..d6f828055 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -42,3 +42,5 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa 布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。 Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。 + +预览节点层禁止浏览器原生文字选择;拖动手势开始时阻止默认行为,避免 pointer drag 被浏览器解释为文本选取。 -- 2.52.0 From 3deebf0e90ef7fc127902b141d586c1735f7182e 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:45:58 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=89=8B=E5=8A=BF=E5=8F=96=E6=B6=88=E5=90=8E=E7=9A=84=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E6=8A=91=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取消、失焦、树切换和无效几何统一清理点击抑制状态 补充 pointercancel 后点击状态回归测试 同步 UI 编辑器预览交互技术方案 --- .../components/preview/useNodeTransformInteraction.ts | 9 ++++++--- .../tests/useNodeTransformInteraction.test.tsx | 1 + .../【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md | 2 ++ 3 files changed, 9 insertions(+), 3 deletions(-) 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 fe7edab39..5e14abb66 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 @@ -140,6 +140,7 @@ export function useNodeTransformInteraction({ const gesture = activeGestureRef.current; if (gesture) releasePointer(gesture.target, gesture.pointerId); activeGestureRef.current = null; + suppressNextNodeClickRef.current = false; }, []); useEffect(() => cancelGesture, [cancelGesture]); @@ -250,12 +251,14 @@ export function useNodeTransformInteraction({ const gesture = activeGestureRef.current; if (!gesture || !acceptsGestureEvent(event)) return; event.stopPropagation(); - if (gesture.hasMoved) suppressNextNodeClickRef.current = true; - if (!gesture.hasMoved && isInsidePreview(event, previewRef)) { + const cleanPointerUpInside = + !gesture.hasMoved && isInsidePreview(event, previewRef); + const suppressClick = gesture.hasMoved || cleanPointerUpInside; + if (cleanPointerUpInside) { canvas.selectNode(gesture.hitNodeId); - suppressNextNodeClickRef.current = true; } cancelGesture(); + if (suppressClick) suppressNextNodeClickRef.current = true; }, [acceptsGestureEvent, cancelGesture, canvas, previewRef], ); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index d6d854eb4..6098d858c 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -188,6 +188,7 @@ describe('useNodeTransformInteraction', () => { result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0)); }); expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(false); }); it('does not select when a clean pointerup lands outside the preview', () => { diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index d6f828055..ec137834f 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -43,4 +43,6 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。 +取消、失焦、树切换或无效几何导致的手势终止会同时清理下一次 click 抑制状态;只有正常 pointerup 才会根据拖动结果抑制后续 click。 + 预览节点层禁止浏览器原生文字选择;拖动手势开始时阻止默认行为,避免 pointer drag 被浏览器解释为文本选取。 -- 2.52.0 From 2e23c0b405bf6cd7798e3f5273bbb7591618ce3d 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:46:19 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=8C=87=E9=92=88=E6=89=8B=E5=8A=BF=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用同一接收条件驱动点击分支与指针处理器 --- .../ui-editor/components/preview/UiTreeRenderer.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 18cee0ab6..101fb2163 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 @@ -117,7 +117,8 @@ function RenderNode({ const isEditorOverlay = renderMode === 'editor-overlay'; const isFrameVisible = isEditorOverlay || showFrame; - const hasDirectPointerGesture = !isRoot && !parentContainer; + const receivesPointerGesture = parentContainer === undefined; + const hasDirectPointerGesture = receivesPointerGesture && !isRoot; const exclusiveVisibleChildId = node.children_display_mode === 'Exclusive' ? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds) @@ -166,11 +167,13 @@ function RenderNode({ onNodeContextMenu(event, node, Boolean(isRoot)); }} onPointerDown={ - parentContainer ? undefined : (event) => onNodePointerDown(event, node) + receivesPointerGesture + ? (event) => onNodePointerDown(event, node) + : undefined } - onPointerMove={parentContainer ? undefined : onNodePointerMove} - onPointerUp={parentContainer ? undefined : onNodePointerUp} - onPointerCancel={parentContainer ? undefined : onNodePointerCancel} + onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined} + onPointerUp={receivesPointerGesture ? onNodePointerUp : undefined} + onPointerCancel={receivesPointerGesture ? onNodePointerCancel : undefined} title={isFrameVisible ? node.metadata.name || undefined : undefined} > {isFrameVisible && node.metadata.name ? ( -- 2.52.0 From 9833c826bd37936ee9180b9e3a44c330636149f0 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:46:57 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=89=8B=E5=8A=BF=E5=9B=9E=E5=BD=92=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖越过移动阈值后 pointercancel 清理点击抑制 --- .../tests/useNodeTransformInteraction.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 6098d858c..ee6a2251c 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -185,6 +185,7 @@ describe('useNodeTransformInteraction', () => { act(() => { result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 2, 3, 0)); result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0)); }); expect(canvasProjection.selectNode).not.toHaveBeenCalled(); -- 2.52.0 From 16dedbb4b731c09d60af8c50ff3cd65315dcaebd 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:57:18 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E4=BA=8B=E4=BB=B6=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将节点与缩放手势的 cancel 回调改为必填 更新所有 UiTreeRenderer 调用点显式传入取消处理 --- .../ui-editor/components/preview/UiTreeRenderer.tsx | 10 ++++------ .../tests/uiEditorPreview.test.tsx | 10 ++++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) 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 101fb2163..35ffe84b3 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 @@ -44,7 +44,7 @@ type UiTreeRendererProps = { onNodePointerDown: NodePointerDown; onNodePointerMove: (event: ReactPointerEvent) => void; onNodePointerUp: (event: ReactPointerEvent) => void; - onNodePointerCancel?: (event: ReactPointerEvent) => void; + onNodePointerCancel: (event: ReactPointerEvent) => void; onNodeResizePointerDown: ( event: ReactPointerEvent, node: UiNode, @@ -52,9 +52,7 @@ type UiTreeRendererProps = { ) => void; onNodeResizePointerMove: (event: ReactPointerEvent) => void; onNodeResizePointerUp: (event: ReactPointerEvent) => void; - onNodeResizePointerCancel?: ( - event: ReactPointerEvent, - ) => void; + onNodeResizePointerCancel: (event: ReactPointerEvent) => void; onSelectExclusiveChild: (nodeId: NodeId) => void; viewportScale: number; }; @@ -90,11 +88,11 @@ function RenderNode({ onNodePointerDown, onNodePointerMove, onNodePointerUp, - onNodePointerCancel = onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, - onNodeResizePointerCancel = onNodeResizePointerUp, + onNodeResizePointerCancel, onSelectExclusiveChild, viewportScale, }: Omit & { diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 855bac497..4fee75539 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -79,9 +79,11 @@ function renderTree( onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -170,9 +172,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -247,9 +251,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -319,9 +325,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={onNodePointerDown} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} onSelectExclusiveChild={onSelectChild} viewportScale={1} />, @@ -351,9 +359,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); -- 2.52.0 From 3a6109dce6d27f0b2faa56d9e591771f962c03eb 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 19:51:09 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E6=B5=8B=E8=AF=95=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卸载 ExclusiveChildrenTabs 测试实例,避免后续 tab 查询命中残留 DOM。 --- apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 4fee75539..96b3b5231 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -286,7 +286,7 @@ 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 onSelectChild = vi.fn(); - render( + const rendered = render( nodeId === 'child-a'} @@ -306,6 +306,7 @@ describe('UI tree preview visibility', () => { ).toBe('false'); fireEvent.click(screen.getByRole('tab', { name: 'child-b' })); expect(onSelectChild).toHaveBeenCalledWith('child-b'); + rendered.unmount(); }); it('keeps exclusive tab pointer gestures out of the parent node', () => { -- 2.52.0 From c4ab5c69c33691a0d363a6c2fa296893bc9c628d 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 19:59:54 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E8=A1=A5=E5=BC=BA=20UI=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E6=B5=8B=E8=AF=95=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为预览测试统一注册 afterEach cleanup,避免测试间残留 DOM。 保留原有 UI 选择与拖拽交互行为。 --- .../tests/uiEditorPreview.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 96b3b5231..6f79be210 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, 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'; @@ -9,6 +9,10 @@ import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer'; import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu'; +afterEach(() => { + cleanup(); +}); + function node(id: string, children: UiNode[] = []): UiNode { return { id, @@ -286,7 +290,7 @@ 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 onSelectChild = vi.fn(); - const rendered = render( + render( nodeId === 'child-a'} @@ -306,7 +310,6 @@ describe('UI tree preview visibility', () => { ).toBe('false'); fireEvent.click(screen.getByRole('tab', { name: 'child-b' })); expect(onSelectChild).toHaveBeenCalledWith('child-b'); - rendered.unmount(); }); it('keeps exclusive tab pointer gestures out of the parent node', () => { -- 2.52.0 From 2ebc97ad71ccaac996e7025e1f3b4a59d2376111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 13:55:30 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E5=8F=96=E6=B6=88=E7=8A=B6=E6=80=81=E6=AE=8B?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按实际拖动目标清理父节点及子节点预览变换 补充保持子节点位置时的取消回归测试 恢复拖动手势阻止浏览器原生文字选择 --- .../preview/useNodeTransformInteraction.ts | 4 +- .../useNodeTransformInteraction.test.tsx | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) 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 6770eb616..86241117b 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 @@ -271,6 +271,8 @@ export function useNodeTransformInteraction({ selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null; const dragNode = selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node; + if (!isFiniteTransform(dragNode.layout.transform)) return; + event.preventDefault(); activeGestureRef.current = { kind: 'drag', treeId: activeImageId, @@ -284,7 +286,7 @@ export function useNodeTransformInteraction({ hasMoved: false, previewNodeIds: previewNodeIds( tree, - node.id, + dragNode.id, logicalSize, keepChildrenUnchanged, ), diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index fe390833a..a9b48fe39 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -70,12 +70,14 @@ function pointerEvent( pointerId: number, clientX: number, clientY: number, + eventTarget: EventTarget = target, ) { return { button: 0, clientX, clientY, currentTarget: target, + target: eventTarget, pointerId, preventDefault: vi.fn(), shiftKey: false, @@ -96,6 +98,7 @@ function renderInteraction({ tree = pageTree, keepChildrenUnchanged = false, onPreviewTransform, + selectedNodeId = null, }: { activeImageId?: string | null; scale?: number; @@ -105,6 +108,7 @@ function renderInteraction({ nodeId: string, transform: UiNode['layout']['transform'] | null, ) => void; + selectedNodeId?: string | null; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -119,6 +123,7 @@ function renderInteraction({ viewportRef, keepChildrenUnchanged, onPreviewTransform, + selectedNodeId, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -126,6 +131,61 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('prevents native text selection when starting a node drag', () => { + const { result } = renderInteraction(); + const target = gestureTarget(); + const event = pointerEvent(target, 1, 0, 0); + + act(() => { + result.current.onNodePointerDown(event, child); + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('cleans preview transforms for the selected parent when dragging from a child', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + selectedNodeId: 'parent', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown( + pointerEvent(target, 1, 0, 0), + nestedChild, + ); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + result.current.onNodePointerCancel(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenCalledWith('parent', null); + expect(onPreviewTransform).toHaveBeenCalledWith('child', null); + }); + it('previews stable child page rectangles when the parent moves', () => { const nestedChild = transformedNode('child', { anchor_min: [0, 0], -- 2.52.0 From ab2c3465b01e4062ca3eae0571f641bc7016141a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 14:17:06 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E5=BD=93=E5=89=8D=E9=80=89=E4=B8=AD=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拖动命中其它节点时保持变换提交到按下前选中节点 补充跨节点拖动不重新选择命中节点的回归测试 避免无效选中节点提前捕获指针 --- .../preview/useNodeTransformInteraction.ts | 6 ++-- .../useNodeTransformInteraction.test.tsx | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) 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 86241117b..6460ba0bf 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 @@ -264,14 +264,14 @@ export function useNodeTransformInteraction({ ) { return; } - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - suppressNextNodeClickRef.current = false; 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; event.preventDefault(); activeGestureRef.current = { kind: 'drag', diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index a9b48fe39..7434c355e 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -131,6 +131,40 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('moves the current selection when dragging another node without selecting the hit node', () => { + const selected = transformedNode('selected', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [80, 70], + }); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [selected, child]), + }; + const { result, canvasProjection } = renderInteraction({ + tree, + selectedNodeId: 'selected', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); + }); + + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith( + 'page', + 'selected', + expect.objectContaining({ + offset_min: [24, 20], + offset_max: [84, 70], + }), + ); + }); + it('prevents native text selection when starting a node drag', () => { const { result } = renderInteraction(); const target = gestureTarget(); -- 2.52.0 From f3e5849027a457163a605e8deed9ad7d526d457c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 15:26:10 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E6=B8=85=E7=90=86=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=8C=87=E9=92=88=E6=8A=AC=E8=B5=B7=E7=9A=84=E6=97=A0=E6=95=88?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 onNodePointerUp 中永远不会成立的 pointercancel 类型判断 保留移动且存在待提交变换时的正式更新逻辑 --- .../components/preview/useNodeTransformInteraction.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 6460ba0bf..9b79de2d7 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 @@ -361,11 +361,7 @@ export function useNodeTransformInteraction({ const cleanPointerUpInside = !gesture?.hasMoved && isInsidePreview(event, previewRef); if (cleanPointerUpInside) canvas.selectNode(gesture.hitNodeId); - if ( - event.type !== 'pointercancel' && - gesture?.hasMoved && - gesture.pendingTransform - ) { + if (gesture.hasMoved && gesture.pendingTransform) { canvas.updateNodeTransform( gesture.treeId, gesture.nodeId, -- 2.52.0