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] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A2=84=E8=A7=88=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E9=80=89=E6=8B=A9=E5=9B=9E=E5=BD=92=E6=B5=8B=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();