diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewRightPanGesture.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewRightPanGesture.ts new file mode 100644 index 000000000..e8875f38f --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewRightPanGesture.ts @@ -0,0 +1,91 @@ +import { + type CanvasViewport, + createPanDragState, + type DragState, + moveViewportFromPan, +} from '@genarrative/image-canvas-core'; + +import { + passedDragThresholdScreen, + type ScreenPoint, +} from './previewDragThreshold'; + +/** + * 右键按下到抬起之间的手势状态。右键同时承载节点菜单与视图平移: + * 只有越过拖动阈值的移动才判定为平移,且一旦越阈值就不再回到"点击开菜单"。 + */ +export type PreviewRightPanGesture = { + pointerId: number; + /** 按下点(屏幕坐标),平移按"按下点全量 delta"计算。 */ + start: ScreenPoint; + panning: boolean; + pan: Extract; +}; + +export function createPreviewRightPanGesture({ + pointerId, + pointer, + viewport, +}: { + pointerId: number; + pointer: ScreenPoint; + viewport: CanvasViewport; +}): PreviewRightPanGesture { + return { + pointerId, + start: pointer, + panning: false, + pan: createPanDragState({ pointerId, pointer, viewport }), + }; +} + +/** 未越阈值时原样返回同一个手势,避免无意义的重复状态写入。 */ +export function movePreviewRightPanGesture( + gesture: PreviewRightPanGesture, + pointer: ScreenPoint, +): PreviewRightPanGesture { + if (gesture.panning || !passedDragThresholdScreen(gesture.start, pointer)) { + return gesture; + } + return { ...gesture, panning: true }; +} + +export type PreviewRightPanRelease = { + /** 平移后的视口;未判定为平移时为 null,调用方不要改视口。 */ + viewport: CanvasViewport | null; + /** 是否按"节点右键菜单"处理。 */ + openNodeMenu: boolean; +}; + +export function resolvePreviewRightPanRelease({ + gesture, + pointer, + isInsidePreview, +}: { + gesture: PreviewRightPanGesture; + pointer: ScreenPoint; + isInsidePreview: boolean; +}): PreviewRightPanRelease { + return { + viewport: gesture.panning + ? moveViewportFromPan(gesture.pan, pointer) + : null, + openNodeMenu: !gesture.panning && isInsidePreview, + }; +} + +/** + * 预览是否接管这一次 contextmenu。 + * 平台差异:Windows / Linux 的 Chromium 与 Firefox 在**按下**时触发 contextmenu, + * macOS 在指针抬起之后触发;而原生菜单一旦弹出,页面之后收不到 pointermove / pointerup, + * 所以必须按下时就拦截,并把拦截窗口保留到下一次指针按下之前。 + */ +export function shouldInterceptRightContextMenu({ + button, + withinRightPressSequence, +}: { + button: number; + withinRightPressSequence: boolean; +}): boolean { + return button === 2 && withinRightPressSequence; +} diff --git a/apps/ai-game-creator-shell/tests/previewRightPanGesture.test.ts b/apps/ai-game-creator-shell/tests/previewRightPanGesture.test.ts new file mode 100644 index 000000000..525e429be --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewRightPanGesture.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { + createPreviewRightPanGesture, + movePreviewRightPanGesture, + resolvePreviewRightPanRelease, + shouldInterceptRightContextMenu, +} from '../src/view/ui-editor/components/preview/previewRightPanGesture'; + +const startViewport = { x: 10, y: -20, scale: 0.75 }; + +function pressAt(x: number, y: number) { + return createPreviewRightPanGesture({ + pointerId: 7, + pointer: { x, y }, + viewport: startViewport, + }); +} + +describe('preview right-button pan gesture', () => { + it('starts as a click until the pointer passes the drag threshold', () => { + const gesture = pressAt(100, 100); + + expect(gesture.panning).toBe(false); + expect(movePreviewRightPanGesture(gesture, { x: 101, y: 100 })).toBe( + gesture, + ); + expect( + movePreviewRightPanGesture(gesture, { x: 102, y: 100 }).panning, + ).toBe(true); + }); + + it('keeps panning once engaged, even when the pointer returns to the start', () => { + const engaged = movePreviewRightPanGesture(pressAt(100, 100), { + x: 130, + y: 140, + }); + + expect(engaged.panning).toBe(true); + expect( + movePreviewRightPanGesture(engaged, { x: 100, y: 100 }).panning, + ).toBe(true); + }); + + it('pans by the full delta from the press point without touching the scale', () => { + const gesture = movePreviewRightPanGesture(pressAt(100, 100), { + x: 140, + y: 70, + }); + const release = resolvePreviewRightPanRelease({ + gesture, + pointer: { x: 140, y: 70 }, + isInsidePreview: true, + }); + + expect(release.viewport).toEqual({ x: 50, y: -50, scale: 0.75 }); + expect(release.openNodeMenu).toBe(false); + }); + + it('opens the node menu only for a clean release inside the preview', () => { + const gesture = pressAt(100, 100); + + expect( + resolvePreviewRightPanRelease({ + gesture, + pointer: { x: 101, y: 100 }, + isInsidePreview: true, + }), + ).toEqual({ viewport: null, openNodeMenu: true }); + + expect( + resolvePreviewRightPanRelease({ + gesture, + pointer: { x: 101, y: 100 }, + isInsidePreview: false, + }), + ).toEqual({ viewport: null, openNodeMenu: false }); + }); + + it('intercepts only the right button of the preview right-press sequence', () => { + expect( + shouldInterceptRightContextMenu({ + button: 2, + withinRightPressSequence: true, + }), + ).toBe(true); + expect( + shouldInterceptRightContextMenu({ + button: 2, + withinRightPressSequence: false, + }), + ).toBe(false); + expect( + shouldInterceptRightContextMenu({ + button: 0, + withinRightPressSequence: true, + }), + ).toBe(false); + }); +});