新增预览右键平移手势状态机
- 新增 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:
+91
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user