add shift + scroll fallback for normal mouse

This commit is contained in:
2026-07-21 19:06:00 +08:00
parent cc3629e389
commit 0de641dd31
6 changed files with 46 additions and 8 deletions
@@ -4370,5 +4370,5 @@
## 2026-07-21 图片画布滚轮与中键平移统一为二维视口移动
- 背景:画布中键拖拽的平移模型已同时计算 X / Y,但普通滚轮分支只消费 `deltaY`,横向滚轮或触控板的 `deltaX` 被丢弃,且缺少中键横向拖动的状态机回归覆盖。
- 决策:普通滚轮原样消费设备上报的 `deltaX / deltaY` 二维平移 viewport,不在核心平移模型内根据修饰键重映射轴向`Ctrl / Cmd + 滚轮` 继续只负责围绕指针缩放;中键和抓手拖拽继续同时更新 X / Y。
- 验证:交互模型单测覆盖原始 `deltaX / deltaY` 和缩放边界;viewport hook 单测覆盖二维滚轮与 Ctrl 缩放;stage 状态机单测覆盖中键水平、垂直同时移动。
- 决策:普通滚轮原样消费设备上报的 `deltaX / deltaY` 二维平移 viewport;当按住 Shift 且设备上报 `deltaX = 0` 时,输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型不感知修饰键`Ctrl / Cmd + 滚轮` 继续只负责围绕指针缩放;中键和抓手拖拽继续同时更新 X / Y。
- 验证:交互模型单测覆盖原始 `deltaX / deltaY` 和缩放边界;viewport hook 单测覆盖二维滚轮、Shift 横向适配与 Ctrl 缩放;stage 状态机单测覆盖中键水平、垂直同时移动。
@@ -149,7 +149,7 @@
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口,不在画布交互模型内做轴向重映射。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`
- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
- “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=<projectId>` 并按 query 加载该工程。
@@ -68,7 +68,7 @@
## 第六阶段模块
- `ImageCanvasInteractionModel.ts`
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时由输入适配层把 `deltaY` 映射为横向位移、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`
- 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。
@@ -151,7 +151,7 @@
## 第十七阶段模块
- `useImageCanvasViewportControls.ts`
- 承载画布视口控制:`viewport``canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
- 承载画布视口控制:`viewport``canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时的横向位移适配、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
- 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
@@ -30,6 +30,7 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [
title: '视图',
items: [
{ action: '平移画布', keys: ['鼠标滚轮 / 触控板'], status: 'ready' },
{ action: '横向平移画布', keys: ['Shift', '鼠标滚轮'], status: 'ready' },
{ action: '以鼠标位置缩放', keys: ['Ctrl', '鼠标滚轮'], status: 'ready' },
{ action: '显示全部元素', keys: ['Ctrl', '0'], status: 'new' },
{ action: '缩放至 100%', keys: ['Ctrl', '1'], status: 'new' },
@@ -158,7 +158,40 @@ describe('useImageCanvasViewportControls', () => {
expect(captureCanvasHistory).toHaveBeenCalledTimes(2);
});
it('handles two-axis wheel pan, ctrl wheel zoom and minimap movement', () => {
it('keeps ordinary mouse wheel vertical and maps Shift wheel to horizontal', () => {
const { result, viewportElement } = renderViewportControls();
act(() => {
fireEvent.wheel(viewportElement, {
deltaX: 0,
deltaY: 120,
clientX: 260,
clientY: 190,
});
});
expectViewport(result.current.viewport, {
x: -260,
y: -50,
scale: 0.5,
});
act(() => {
fireEvent.wheel(viewportElement, {
shiftKey: true,
deltaX: 0,
deltaY: 80,
clientX: 260,
clientY: 190,
});
});
expectViewport(result.current.viewport, {
x: -340,
y: -50,
scale: 0.5,
});
});
it('handles native two-axis wheel pan, ctrl wheel zoom and minimap movement', () => {
const rafCallbacks: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback: FrameRequestCallback) => {
@@ -260,11 +260,15 @@ export function useImageCanvasViewportControls({
}
if (!event.ctrlKey && !event.metaKey) {
const useShiftWheelHorizontalFallback =
event.shiftKey && event.deltaX === 0;
setViewport((currentViewport) =>
panViewportFromWheel({
viewport: currentViewport,
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaX: useShiftWheelHorizontalFallback
? event.deltaY
: event.deltaX,
deltaY: useShiftWheelHorizontalFallback ? 0 : event.deltaY,
}),
);
return;