From 76dfd74dea84d0929f671e13517a790b8dd851f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:07:56 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=A4=B1=E7=84=A6=E6=97=B6?= =?UTF-8?q?=E4=B8=A2=E5=BC=83=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E6=A0=91?= =?UTF-8?q?=E6=8B=96=E6=8B=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreviewWorkspace.tsx 的 onWindowBlur 一并清掉 treeDragRef 与树拖拽预览偏移:失焦时浏览器不一定补 pointercancel,陈旧手势会被后续 pointerup 复用并把意外偏移写进文档 tests/previewRightPanDrag.test.tsx 补失焦后抬起不提交偏移的用例 --- .../components/preview/PreviewWorkspace.tsx | 11 + .../tests/previewRightPanDrag.test.tsx | 509 +++++++++--------- 2 files changed, 280 insertions(+), 240 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 7939a74c0..04150de7a 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 @@ -404,6 +404,17 @@ export function PreviewWorkspace({ rightPressSequenceRef.current = false; rightPanRef.current = null; setIsPanning(false); + // 失焦时浏览器不一定补 pointercancel:丢掉未提交的树拖拽,避免后续指针事件 + // 复用陈旧手势把意外偏移写进文档。 + const drag = treeDragRef.current; + if (drag) { + treeDragRef.current = null; + setPreviewTreeOffsets((current) => { + const next = new Map(current); + next.delete(drag.treeId); + return next; + }); + } }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); diff --git a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx index d37319f74..aab60e92a 100644 --- a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx @@ -263,256 +263,285 @@ describe('PreviewWorkspace tree drag', () => { }); expect(cancelled.canvas.setTreeOffset).not.toHaveBeenCalled(); }); -}); -describe('PreviewWorkspace right-button pan', () => { - it('pans the viewport on a right drag and never opens the node menu', () => { - const { canvas, child, container, preview } = renderPreview(); - const before = worldTranslate(container); - - dispatchPointer(child, 'pointerdown', { - button: 2, - clientX: 100, - clientY: 100, - pointerId: 5, - }); - expect(preview.classList.contains(PANNING_CLASS)).toBe(false); - expect( - fireEvent.contextMenu(child, { - button: 2, - buttons: 2, - clientX: 100, - clientY: 100, - }), - ).toBe(false); - - dispatchPointer(child, 'pointermove', { - clientX: 101, - clientY: 100, - pointerId: 5, - }); - expect(preview.classList.contains(PANNING_CLASS)).toBe(false); - expect(worldTranslate(container)).toEqual(before); - - dispatchPointer(child, 'pointermove', { - clientX: 500, - clientY: 500, - pointerId: 99, - }); - expect(worldTranslate(container)).toEqual(before); - - dispatchPointer(child, 'pointermove', { - clientX: 130, - clientY: 140, - pointerId: 5, - }); - expect(preview.classList.contains(PANNING_CLASS)).toBe(true); - expect(worldTranslate(container)).toEqual({ - x: before.x + 30, - y: before.y + 40, - }); - - dispatchPointer(child, 'pointerup', { - button: 2, - clientX: 130, - clientY: 140, - pointerId: 5, - }); - expect(screen.queryByRole('menu')).toBeNull(); - expect(canvas.selectNode).not.toHaveBeenCalled(); - expect(canvas.clearNodeSelection).not.toHaveBeenCalled(); - expect(preview.classList.contains(PANNING_CLASS)).toBe(false); - }); - - it('treats a right press under the drag threshold as a node menu click', () => { - const { canvas, child, preview } = renderPreview(); - - dispatchPointer(child, 'pointerdown', { - button: 2, - clientX: 100, - clientY: 100, - pointerId: 6, - }); - dispatchPointer(child, 'pointermove', { - clientX: 101, - clientY: 100, - pointerId: 6, - }); - expect(preview.classList.contains(PANNING_CLASS)).toBe(false); - dispatchPointer(child, 'pointerup', { - button: 2, - clientX: 101, - clientY: 100, - pointerId: 6, - }); - - expect(canvas.selectNode).toHaveBeenCalledWith('child'); - expect(screen.getByRole('menu').style.left).toBe('101px'); - expect(screen.getByRole('menuitem', { name: '新增同级节点' })).toBeTruthy(); - }); - - it('opens the node menu when pointerup is retargeted to the capture element', () => { - const { canvas, child, preview } = renderPreview(); - - dispatchPointer(child, 'pointerdown', { - button: 2, - clientX: 100, - clientY: 100, - pointerId: 7, - }); - // 指针被视口捕获后,浏览器把后续指针事件派发给捕获元素:抬起目标不再是节点, - // 菜单必须按按下时的命中目标裁决。 - dispatchPointer(preview, 'pointerup', { - button: 2, - clientX: 101, - clientY: 100, - pointerId: 7, - }); - - expect(canvas.selectNode).toHaveBeenCalledWith('child'); - expect(screen.getByRole('menu')).toBeTruthy(); - }); - - it('keeps an empty-grid right click silent, unlike the left button', () => { - const { canvas, preview } = renderPreview(); - - dispatchPointer(preview, 'pointerdown', { - button: 2, - clientX: 300, - clientY: 200, - pointerId: 7, - }); - dispatchPointer(preview, 'pointerup', { - button: 2, - clientX: 300, - clientY: 200, - pointerId: 7, - }); - - expect(screen.queryByRole('menu')).toBeNull(); - expect(canvas.selectNode).not.toHaveBeenCalled(); - expect(canvas.clearNodeSelection).not.toHaveBeenCalled(); - - dispatchPointer(preview, 'pointerdown', { - clientX: 300, - clientY: 200, - pointerId: 8, - }); - expect(canvas.clearNodeSelection).toHaveBeenCalledTimes(1); - }); - - it('does not open the node menu when a clean right press ends outside the preview', () => { - const { canvas, child } = renderPreview(); - previewRect = { ...PREVIEW_RECT, right: 200, bottom: 200 } as DOMRect; - - dispatchPointer(child, 'pointerdown', { - button: 2, - clientX: 300, - clientY: 200, - pointerId: 9, - }); - dispatchPointer(child, 'pointerup', { - button: 2, - clientX: 300, - clientY: 200, - pointerId: 9, - }); - - expect(screen.queryByRole('menu')).toBeNull(); - expect(canvas.selectNode).not.toHaveBeenCalled(); - }); - - it('keeps the non-right-button context menu path immediate', () => { - const { canvas, child } = renderPreview(); - - fireEvent.contextMenu(child, { button: 0, clientX: 40, clientY: 50 }); - - expect(canvas.selectNode).toHaveBeenCalledWith('child'); - expect(screen.getByRole('menu')).toBeTruthy(); - }); - - it('leaves the keyboard menu key to the existing node menu path', () => { - const { canvas, child } = renderPreview(); - - dispatchPointer(child, 'pointerdown', { - button: 2, - clientX: 100, - clientY: 100, + it('drops an in-flight tree drag when the window loses focus', () => { + const { canvas, rootNode, treeLayer } = renderTreeDrag(); + dispatchPointer(rootNode, 'pointerdown', { + button: 0, + clientX: 10, + clientY: 10, pointerId: 13, }); - // 键盘菜单键在 Chromium 上报 button: -1,不能被右键手势的拦截窗口吃掉。 - expect(fireEvent.contextMenu(child, { button: -1 })).toBe(false); - expect(canvas.selectNode).toHaveBeenCalledWith('child'); - expect(screen.getByRole('menu')).toBeTruthy(); + dispatchPointer(treeLayer, 'pointermove', { + clientX: 40, + clientY: 50, + pointerId: 13, + }); + + act(() => { + window.dispatchEvent(new Event('blur')); + }); + dispatchPointer(treeLayer, 'pointerup', { + button: 0, + clientX: 40, + clientY: 50, + pointerId: 13, + }); + + expect(canvas.setTreeOffset).not.toHaveBeenCalled(); }); - it('keeps middle-button panning and its shared grabbing cursor', () => { - const { container, preview } = renderPreview(); - const before = worldTranslate(container); + describe('PreviewWorkspace right-button pan', () => { + it('pans the viewport on a right drag and never opens the node menu', () => { + const { canvas, child, container, preview } = renderPreview(); + const before = worldTranslate(container); - dispatchPointer(preview, 'pointerdown', { - button: 1, - clientX: 100, - clientY: 100, - pointerId: 10, - }); - expect(preview.classList.contains(PANNING_CLASS)).toBe(true); - dispatchPointer(preview, 'pointermove', { - clientX: 120, - clientY: 90, - pointerId: 10, - }); - expect(worldTranslate(container)).toEqual({ - x: before.x + 20, - y: before.y - 10, - }); - dispatchPointer(preview, 'pointerup', { - button: 1, - clientX: 120, - clientY: 90, - pointerId: 10, - }); - expect(preview.classList.contains(PANNING_CLASS)).toBe(false); - }); + dispatchPointer(child, 'pointerdown', { + button: 2, + clientX: 100, + clientY: 100, + pointerId: 5, + }); + expect(preview.classList.contains(PANNING_CLASS)).toBe(false); + expect( + fireEvent.contextMenu(child, { + button: 2, + buttons: 2, + clientX: 100, + clientY: 100, + }), + ).toBe(false); - it('still drags nodes with the left button after a right-drag pan', () => { - const { canvas, child } = renderPreview(); + dispatchPointer(child, 'pointermove', { + clientX: 101, + clientY: 100, + pointerId: 5, + }); + expect(preview.classList.contains(PANNING_CLASS)).toBe(false); + expect(worldTranslate(container)).toEqual(before); - dispatchPointer(child, 'pointerdown', { - button: 2, - clientX: 100, - clientY: 100, - pointerId: 11, - }); - dispatchPointer(child, 'pointermove', { - clientX: 140, - clientY: 100, - pointerId: 11, - }); - dispatchPointer(child, 'pointerup', { - button: 2, - clientX: 140, - clientY: 100, - pointerId: 11, - }); - expect(canvas.updateNodeTransform).not.toHaveBeenCalled(); + dispatchPointer(child, 'pointermove', { + clientX: 500, + clientY: 500, + pointerId: 99, + }); + expect(worldTranslate(container)).toEqual(before); - dispatchPointer(child, 'pointerdown', { - clientX: 100, - clientY: 100, - pointerId: 12, - }); - dispatchPointer(child, 'pointermove', { - clientX: 110, - clientY: 100, - pointerId: 12, - }); - dispatchPointer(child, 'pointerup', { - clientX: 110, - clientY: 100, - pointerId: 12, + dispatchPointer(child, 'pointermove', { + clientX: 130, + clientY: 140, + pointerId: 5, + }); + expect(preview.classList.contains(PANNING_CLASS)).toBe(true); + expect(worldTranslate(container)).toEqual({ + x: before.x + 30, + y: before.y + 40, + }); + + dispatchPointer(child, 'pointerup', { + button: 2, + clientX: 130, + clientY: 140, + pointerId: 5, + }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(canvas.selectNode).not.toHaveBeenCalled(); + expect(canvas.clearNodeSelection).not.toHaveBeenCalled(); + expect(preview.classList.contains(PANNING_CLASS)).toBe(false); }); - expect(canvas.updateNodeTransform).toHaveBeenCalledTimes(1); + it('treats a right press under the drag threshold as a node menu click', () => { + const { canvas, child, preview } = renderPreview(); + + dispatchPointer(child, 'pointerdown', { + button: 2, + clientX: 100, + clientY: 100, + pointerId: 6, + }); + dispatchPointer(child, 'pointermove', { + clientX: 101, + clientY: 100, + pointerId: 6, + }); + expect(preview.classList.contains(PANNING_CLASS)).toBe(false); + dispatchPointer(child, 'pointerup', { + button: 2, + clientX: 101, + clientY: 100, + pointerId: 6, + }); + + expect(canvas.selectNode).toHaveBeenCalledWith('child'); + expect(screen.getByRole('menu').style.left).toBe('101px'); + expect( + screen.getByRole('menuitem', { name: '新增同级节点' }), + ).toBeTruthy(); + }); + + it('opens the node menu when pointerup is retargeted to the capture element', () => { + const { canvas, child, preview } = renderPreview(); + + dispatchPointer(child, 'pointerdown', { + button: 2, + clientX: 100, + clientY: 100, + pointerId: 7, + }); + // 指针被视口捕获后,浏览器把后续指针事件派发给捕获元素:抬起目标不再是节点, + // 菜单必须按按下时的命中目标裁决。 + dispatchPointer(preview, 'pointerup', { + button: 2, + clientX: 101, + clientY: 100, + pointerId: 7, + }); + + expect(canvas.selectNode).toHaveBeenCalledWith('child'); + expect(screen.getByRole('menu')).toBeTruthy(); + }); + + it('keeps an empty-grid right click silent, unlike the left button', () => { + const { canvas, preview } = renderPreview(); + + dispatchPointer(preview, 'pointerdown', { + button: 2, + clientX: 300, + clientY: 200, + pointerId: 7, + }); + dispatchPointer(preview, 'pointerup', { + button: 2, + clientX: 300, + clientY: 200, + pointerId: 7, + }); + + expect(screen.queryByRole('menu')).toBeNull(); + expect(canvas.selectNode).not.toHaveBeenCalled(); + expect(canvas.clearNodeSelection).not.toHaveBeenCalled(); + + dispatchPointer(preview, 'pointerdown', { + clientX: 300, + clientY: 200, + pointerId: 8, + }); + expect(canvas.clearNodeSelection).toHaveBeenCalledTimes(1); + }); + + it('does not open the node menu when a clean right press ends outside the preview', () => { + const { canvas, child } = renderPreview(); + previewRect = { ...PREVIEW_RECT, right: 200, bottom: 200 } as DOMRect; + + dispatchPointer(child, 'pointerdown', { + button: 2, + clientX: 300, + clientY: 200, + pointerId: 9, + }); + dispatchPointer(child, 'pointerup', { + button: 2, + clientX: 300, + clientY: 200, + pointerId: 9, + }); + + expect(screen.queryByRole('menu')).toBeNull(); + expect(canvas.selectNode).not.toHaveBeenCalled(); + }); + + it('keeps the non-right-button context menu path immediate', () => { + const { canvas, child } = renderPreview(); + + fireEvent.contextMenu(child, { button: 0, clientX: 40, clientY: 50 }); + + expect(canvas.selectNode).toHaveBeenCalledWith('child'); + expect(screen.getByRole('menu')).toBeTruthy(); + }); + + it('leaves the keyboard menu key to the existing node menu path', () => { + const { canvas, child } = renderPreview(); + + dispatchPointer(child, 'pointerdown', { + button: 2, + clientX: 100, + clientY: 100, + pointerId: 13, + }); + // 键盘菜单键在 Chromium 上报 button: -1,不能被右键手势的拦截窗口吃掉。 + expect(fireEvent.contextMenu(child, { button: -1 })).toBe(false); + expect(canvas.selectNode).toHaveBeenCalledWith('child'); + expect(screen.getByRole('menu')).toBeTruthy(); + }); + + it('keeps middle-button panning and its shared grabbing cursor', () => { + const { container, preview } = renderPreview(); + const before = worldTranslate(container); + + dispatchPointer(preview, 'pointerdown', { + button: 1, + clientX: 100, + clientY: 100, + pointerId: 10, + }); + expect(preview.classList.contains(PANNING_CLASS)).toBe(true); + dispatchPointer(preview, 'pointermove', { + clientX: 120, + clientY: 90, + pointerId: 10, + }); + expect(worldTranslate(container)).toEqual({ + x: before.x + 20, + y: before.y - 10, + }); + dispatchPointer(preview, 'pointerup', { + button: 1, + clientX: 120, + clientY: 90, + pointerId: 10, + }); + expect(preview.classList.contains(PANNING_CLASS)).toBe(false); + }); + + it('still drags nodes with the left button after a right-drag pan', () => { + const { canvas, child } = renderPreview(); + + dispatchPointer(child, 'pointerdown', { + button: 2, + clientX: 100, + clientY: 100, + pointerId: 11, + }); + dispatchPointer(child, 'pointermove', { + clientX: 140, + clientY: 100, + pointerId: 11, + }); + dispatchPointer(child, 'pointerup', { + button: 2, + clientX: 140, + clientY: 100, + pointerId: 11, + }); + expect(canvas.updateNodeTransform).not.toHaveBeenCalled(); + + dispatchPointer(child, 'pointerdown', { + clientX: 100, + clientY: 100, + pointerId: 12, + }); + dispatchPointer(child, 'pointermove', { + clientX: 110, + clientY: 100, + pointerId: 12, + }); + dispatchPointer(child, 'pointerup', { + clientX: 110, + clientY: 100, + pointerId: 12, + }); + + expect(canvas.updateNodeTransform).toHaveBeenCalledTimes(1); + }); }); });