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 48d8a0bb9..91557dced 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 @@ -118,6 +118,9 @@ export function PreviewWorkspace({ const didInitialFitRef = useRef(false); const panRef = useRef | null>(null); const rightPanRef = useRef(null); + // 右键按下时的命中元素:指针被视口捕获后,pointerup 的 target 会变成捕获元素, + // 必须在按下时把真正的节点/树目标记下来。 + const rightPanTargetRef = useRef(null); const rightPressSequenceRef = useRef(false); const treeDragRef = useRef<{ treeId: string; @@ -422,6 +425,7 @@ export function PreviewWorkspace({ event.preventDefault(); event.currentTarget.focus({ preventScroll: true }); event.currentTarget.setPointerCapture(event.pointerId); + rightPanTargetRef.current = event.target; rightPanRef.current = createPreviewRightPanGesture({ pointerId: event.pointerId, pointer: { x: event.clientX, y: event.clientY }, @@ -468,8 +472,11 @@ export function PreviewWorkspace({ ); }; - const openNodeContextMenuAt = (event: ReactPointerEvent) => { - const target = resolveRightClickMenuTarget(event.target); + const openNodeContextMenuAt = ( + event: ReactPointerEvent, + pressedTarget: EventTarget | null, + ) => { + const target = resolveRightClickMenuTarget(pressedTarget); if (!target) return; const layout = treeLayouts.find( (item) => item.tree.src_ui_design === target.treeId, @@ -501,7 +508,9 @@ export function PreviewWorkspace({ ), }); if (release.viewport) setViewport(release.viewport); - if (release.openNodeMenu) openNodeContextMenuAt(event); + const pressedTarget = rightPanTargetRef.current; + rightPanTargetRef.current = null; + if (release.openNodeMenu) openNodeContextMenuAt(event, pressedTarget); return; } if (panRef.current?.pointerId !== event.pointerId) return; @@ -516,7 +525,10 @@ export function PreviewWorkspace({ const cancelsRightPan = rightPan !== null && rightPan.pointerId === event.pointerId; const cancelsPan = pan !== null && pan.pointerId === event.pointerId; - if (cancelsRightPan) rightPanRef.current = null; + if (cancelsRightPan) { + rightPanRef.current = null; + rightPanTargetRef.current = null; + } if (cancelsPan) panRef.current = null; if (cancelsRightPan || cancelsPan) { setIsPanning(false); diff --git a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx index 7b5cb0c6d..466cd8bac 100644 --- a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx @@ -292,6 +292,28 @@ describe('PreviewWorkspace right-button pan', () => { 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();