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 049d7735a..4595acef0 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 @@ -34,6 +34,13 @@ import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; import { resolvePreviewGridStep } from './previewGrid'; +import { + createPreviewRightPanGesture, + movePreviewRightPanGesture, + type PreviewRightPanGesture, + resolvePreviewRightPanRelease, + shouldInterceptRightContextMenu, +} from './previewRightPanGesture'; import { handlePreviewZoomKeyDown, isPreviewZoomInteractiveTarget, @@ -45,6 +52,40 @@ import { useNodeTransformInteraction } from './useNodeTransformInteraction'; /** 画布初始适配比例;调小可为浮动面板预留更多可视空间。 */ export const UI_EDITOR_FIT_DEGREE = 0.7; +function releasePointerCapture(element: Element, pointerId: number) { + if (element.hasPointerCapture(pointerId)) { + element.releasePointerCapture(pointerId); + } +} + +function isClientPointInsideElement( + element: Element | null, + clientX: number, + clientY: number, +) { + if (!element) return false; + const rect = element.getBoundingClientRect(); + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ); +} + +/** 右键抬起时命中的节点与它所属的树,用于打开节点菜单。 */ +function resolveRightClickMenuTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + const nodeId = element + ?.closest('[data-node-id]') + ?.getAttribute('data-node-id'); + const treeId = element + ?.closest('[data-tree-id]') + ?.getAttribute('data-tree-id'); + if (!nodeId || !treeId) return null; + return { nodeId, treeId }; +} + function fitEditorViewport( bounds: { x: number; y: number; width: number; height: number }, canvasSize: { width: number; height: number }, @@ -75,6 +116,8 @@ export function PreviewWorkspace({ const handledFocusRequestIdRef = useRef(null); const didInitialFitRef = useRef(false); const panRef = useRef | null>(null); + const rightPanRef = useRef(null); + const rightPressSequenceRef = useRef(false); const treeDragRef = useRef<{ treeId: string; pointerId: number; @@ -93,6 +136,7 @@ export function PreviewWorkspace({ const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const canvasSizeRef = useRef(canvasSize); const [spaceHeld, setSpaceHeld] = useState(false); + const [isPanning, setIsPanning] = useState(false); const [previewTransforms, setPreviewTransforms] = useState< ReadonlyMap >(new Map()); @@ -351,7 +395,12 @@ export function PreviewWorkspace({ const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') setSpaceHeld(false); }; - const onWindowBlur = () => setSpaceHeld(false); + const onWindowBlur = () => { + setSpaceHeld(false); + rightPressSequenceRef.current = false; + rightPanRef.current = null; + setIsPanning(false); + }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); window.addEventListener('blur', onWindowBlur); @@ -363,12 +412,29 @@ export function PreviewWorkspace({ }, [allBounds, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { + // 右键 contextmenu 的拦截窗口要覆盖"按下到下一次按下"这段: + // Windows / Linux 在按下时触发,macOS 在抬起之后触发。 + rightPressSequenceRef.current = event.button === 2; + if (event.button === 2) { + // 只接"单按右键"的干净起手,避免和左键拖动/框选互相抢指针捕获。 + if (event.buttons !== 2 || rightPanRef.current !== null) return; + event.preventDefault(); + event.currentTarget.focus({ preventScroll: true }); + event.currentTarget.setPointerCapture(event.pointerId); + rightPanRef.current = createPreviewRightPanGesture({ + pointerId: event.pointerId, + pointer: { x: event.clientX, y: event.clientY }, + viewport: viewportRef.current, + }); + return; + } if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { event.currentTarget.focus({ preventScroll: true }); } if (event.button === 1 || (event.button === 0 && spaceHeld)) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); + setIsPanning(true); panRef.current = createPanDragState({ pointerId: event.pointerId, pointer: { x: event.clientX, y: event.clientY }, @@ -382,6 +448,18 @@ export function PreviewWorkspace({ }; const handlePointerMove = (event: ReactPointerEvent) => { + const rightPan = rightPanRef.current; + if (rightPan && rightPan.pointerId === event.pointerId) { + const pointer = { x: event.clientX, y: event.clientY }; + const nextPan = movePreviewRightPanGesture(rightPan, pointer); + if (nextPan !== rightPan) { + rightPanRef.current = nextPan; + setIsPanning(true); + } + if (nextPan.panning) + setViewport(moveViewportFromPan(nextPan.pan, pointer)); + return; + } const drag = panRef.current; if (!drag || drag.pointerId !== event.pointerId) return; setViewport( @@ -389,11 +467,59 @@ export function PreviewWorkspace({ ); }; + const openNodeContextMenuAt = (event: ReactPointerEvent) => { + const target = resolveRightClickMenuTarget(event.target); + if (!target) return; + const layout = treeLayouts.find( + (item) => item.tree.src_ui_design === target.treeId, + ); + if (!layout) return; + canvas.selectNode(target.nodeId); + setContextMenu({ + nodeId: target.nodeId, + treeId: target.treeId, + x: event.clientX, + y: event.clientY, + isPageRoot: layout.tree.root.id === target.nodeId, + }); + }; + const handlePointerUp = (event: ReactPointerEvent) => { + const rightPan = rightPanRef.current; + if (rightPan && rightPan.pointerId === event.pointerId) { + rightPanRef.current = null; + setIsPanning(false); + releasePointerCapture(event.currentTarget, event.pointerId); + const release = resolvePreviewRightPanRelease({ + gesture: rightPan, + pointer: { x: event.clientX, y: event.clientY }, + isInsidePreview: isClientPointInsideElement( + viewportElementRef.current, + event.clientX, + event.clientY, + ), + }); + if (release.viewport) setViewport(release.viewport); + if (release.openNodeMenu) openNodeContextMenuAt(event); + return; + } if (panRef.current?.pointerId !== event.pointerId) return; panRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + setIsPanning(false); + releasePointerCapture(event.currentTarget, event.pointerId); + }; + + const handlePointerCancel = (event: ReactPointerEvent) => { + const rightPan = rightPanRef.current; + const pan = panRef.current; + const cancelsRightPan = + rightPan !== null && rightPan.pointerId === event.pointerId; + const cancelsPan = pan !== null && pan.pointerId === event.pointerId; + if (cancelsRightPan) rightPanRef.current = null; + if (cancelsPan) panRef.current = null; + if (cancelsRightPan || cancelsPan) { + setIsPanning(false); + releasePointerCapture(event.currentTarget, event.pointerId); } }; @@ -490,6 +616,7 @@ export function PreviewWorkspace({
{ + if ( + !shouldInterceptRightContextMenu({ + button: event.button, + withinRightPressSequence: rightPressSequenceRef.current, + }) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }} onWheel={handleWheel} onDragStart={(event) => event.preventDefault()} onClick={(event) => { @@ -537,6 +676,7 @@ export function PreviewWorkspace({ return (
diff --git a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx new file mode 100644 index 000000000..eed1c1425 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx @@ -0,0 +1,422 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; +import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; + +const PANNING_CLASS = 'genarrative-image-canvas__viewport--panning'; +const DEFAULT_BUTTONS: Record = { 0: 1, 1: 4, 2: 2 }; + +class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} + +function node( + id: string, + offset: { min: [number, number]; max: [number, number] }, + children: UiNode[] = [], +): UiNode { + return { + id, + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: id, + description: '', + layout_status: 'NoProblem', + component_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + component: null, + children_display_mode: 'Stack', + children, + offset, + }; +} + +const activeImage: UIDesignImage = { + metadata: { + name: '测试界面', + description: '', + role: null, + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [1200, 800], + pixels_per_unit: 1, +}; + +const root = node('root', { min: [0, 0], max: [1200, 800] }, [ + node('child', { min: [40, 40], max: [440, 240] }), +]); + +const PREVIEW_RECT = { + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => PREVIEW_RECT, +} as DOMRect; + +let previewRect = PREVIEW_RECT; + +function createCanvas( + overrides: Partial = {}, +): UiEditorCanvasProjection { + return { + isLocked: false, + activeImage, + activeImageId: 'page-1', + uiTrees: [{ src_ui_design: 'page-1', root }], + previewUrls: { 'page-1': 'data:image/png;base64,' }, + images: { 'page-1': activeImage }, + sprites: {}, + fontFaces: {}, + tree: { src_ui_design: 'page-1', root }, + selectedNode: null, + selectedNodeId: null, + keepChildrenUnchanged: false, + hiddenNodeIds: new Set(), + focusRequest: null, + status: null, + isNodePreviewVisible: vi.fn(() => true), + toggleNodePreviewVisibility: vi.fn(), + selectExclusiveChild: vi.fn(), + selectNode: vi.fn(), + clearNodeSelection: vi.fn(), + updateNodeTransform: vi.fn(), + insertNode: vi.fn(), + insertNodeAfter: vi.fn(), + deleteNode: vi.fn(), + openClearDialog: vi.fn(), + ...overrides, + }; +} + +function renderPreview() { + const canvas = createCanvas(); + const rendered = render( + , + ); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const child = rendered.container.querySelector( + '[data-node-id="child"]', + ) as HTMLDivElement; + return { canvas, child, preview, ...rendered }; +} + +/** 世界图层的位移,用来断言一个手势到底有没有平移视口。 */ +function worldTranslate(container: HTMLElement) { + const world = container.querySelector( + '.genarrative-image-canvas__world', + ) as HTMLElement; + const match = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec( + world.style.transform, + ); + if (!match) throw new Error('无法从 world 读取视口位移'); + return { x: Number(match[1]), y: Number(match[2]) }; +} + +/** + * jsdom 没有 PointerEvent,`fireEvent` 也不会带 pointerId; + * 这里手工派发带 pointerId 的指针事件,让"指针 id 不匹配"这类分支被真实覆盖。 + */ +function dispatchPointer( + target: Element, + type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel', + { + button = 0, + buttons, + clientX, + clientY, + pointerId, + }: { + button?: number; + buttons?: number; + clientX: number; + clientY: number; + pointerId: number; + }, +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button, + buttons: buttons ?? DEFAULT_BUTTONS[button] ?? 1, + clientX, + clientY, + }); + Object.defineProperty(event, 'pointerId', { value: pointerId }); + act(() => { + target.dispatchEvent(event); + }); + return event.defaultPrevented; +} + +beforeEach(() => { + previewRect = PREVIEW_RECT; + vi.stubGlobal('ResizeObserver', TestResizeObserver); + Object.assign(Element.prototype, { + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => true), + releasePointerCapture: vi.fn(), + }); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + () => previewRect, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +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('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('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); + }); +});