新增预览右键平移手势状态机

- 新增 previewRightPanGesture.ts:按下起手、按共用阈值判定是否进入平移、按起点全量 delta 复用 moveViewportFromPan 算视口
- 抬起裁决统一收口到 resolvePreviewRightPanRelease:平移过就只回视口,未平移且在预览内才按节点菜单处理
- shouldInterceptRightContextMenu 记录"按钮 2 且处于右键按下序列"的拦截窗口,兼容按下触发与抬起后触发的两类平台时序
- 新增 tests/previewRightPanGesture.test.ts 覆盖阈值边界、越阈值后不回弹、全量 delta、预览外抬起与拦截判据

验证:npx vitest run previewRightPanGesture.test.ts(5 passed)
This commit is contained in:
2026-09-22 15:27:30 +08:00
parent 96e7ba7808
commit f525b6dc70
2 changed files with 191 additions and 0 deletions
@@ -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<DragState, { kind: 'pan' }>;
};
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;
}
@@ -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);
});
});