From 073a3d5cdd47a45479f311d1046933c0be19a5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 21 Jul 2026 16:11:48 +0800 Subject: [PATCH 1/3] fix: canvas interaction supports two-axis viewport panning via mouse wheel; updated related tests, hooks, and documentation for new behavior. --- .../project-memory/shared-memory/decision-log.md | 6 ++++++ ...端架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 ++-- ...架构】图片画布编辑器前端拆分计划-2026-06-17.md | 4 ++-- .../image-editor/ImageCanvasEditorView.test.tsx | 13 +++++++++++-- .../ImageCanvasInteractionModel.test.ts | 10 ++++++---- .../image-editor/ImageCanvasInteractionModel.ts | 14 ++++++++++---- .../image-editor/ImageCanvasShortcutModel.ts | 2 +- .../useImageCanvasStageInteractions.test.tsx | 16 ++++++++++++++++ .../useImageCanvasViewportControls.test.tsx | 7 ++++--- .../useImageCanvasViewportControls.ts | 8 ++++++-- 10 files changed, 64 insertions(+), 20 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 72b4e9aee..c0dec4e88 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4354,3 +4354,9 @@ ## 2026-07-20 VectorEngine 图片任务预算收口到 worker deadline - 决策:`editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation` 和 `editor_ui_design_asset_extraction` 使用默认 `1800s` long job 预算。worker 从同一起点计算绝对 job deadline,并向 provider 提前保留 `min(60s, job 预算 / 2)` 作为审计、OSS 和终态写回窗口。deadline 只经进程内 `RequestContext` 传递;VectorEngine 单 attempt 取配置 timeout 与剩余预算的较小值,退避加下一次 attempt 无法落在同一 deadline 内时停止重试,参考图和响应图片下载也受同一 deadline 限制。普通 HTTP / `inline` 保持无 deadline 行为;`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 默认仍为 `1000000`,配置加载层允许显式值更低。lease 续租 / fencing、迟到写回仲裁、attempt 耗尽和原子退款语义不变。 + +## 2026-07-21 图片画布滚轮与中键平移统一为二维视口移动 + +- 背景:画布中键拖拽的平移模型已同时计算 X / Y,但普通滚轮分支只消费 `deltaY`,横向滚轮或触控板的 `deltaX` 被丢弃,且缺少中键横向拖动的状态机回归覆盖。 +- 决策:普通滚轮原样消费设备上报的 `deltaX / deltaY` 二维平移 viewport,不在核心平移模型内根据修饰键重映射轴向。`Ctrl / Cmd + 滚轮` 继续只负责围绕指针缩放;中键和抓手拖拽继续同时更新 X / Y。 +- 验证:交互模型单测覆盖原始 `deltaX / deltaY` 和缩放边界;viewport hook 单测覆盖二维滚轮与 Ctrl 缩放;stage 状态机单测覆盖中键水平、垂直同时移动。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 043e13bf9..ce97431c1 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -11,7 +11,7 @@ - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 - 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额优先展示画板按扣费、退回、到账和页面恢复链路刷新的 `profileDashboard.walletBalance`,充值中心 `mudPointBalance` 仅用于展开面板的账户明细,不覆盖已刷新的总余额。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 -- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 +- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 - 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。 - 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 @@ -144,7 +144,7 @@ - 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。 - 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 - 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。 -- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 +- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口,不在画布交互模型内做轴向重映射。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 - 工程刷新后能从后端恢复资源、图层布局和 viewport。 - “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=` 并按 query 加载该工程。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 4a633479f..a2020440d 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -68,7 +68,7 @@ ## 第六阶段模块 - `ImageCanvasInteractionModel.ts` - - 承载画布交互纯计算:适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。 + - 承载画布交互纯计算:适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。 - 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。 - 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。 @@ -151,7 +151,7 @@ ## 第十七阶段模块 - `useImageCanvasViewportControls.ts` - - 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。 + - 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。 - 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。 - 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 6add43c9a..b4380fdb2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2320,15 +2320,24 @@ describe('ImageCanvasEditorView', () => { }).not.toThrow(); }); - it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => { + it('uses normal wheel for two-axis canvas pan and ctrl wheel for zoom', () => { render(); const viewport = screen.getByLabelText('画布工作区'); + const world = screen.getByTestId('image-canvas-world') as HTMLElement; expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); - fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 }); + fireEvent.wheel(viewport, { + deltaX: 80, + deltaY: 120, + clientX: 400, + clientY: 280, + }); + expect(world.style.transform).toBe( + 'translate(-80px, -120px) scale(0.5)', + ); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index f9579cf51..2428748af 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -19,8 +19,8 @@ import { moveViewportFromMinimapDrag, moveViewportFromMinimapPointer, moveViewportFromPan, + panViewportFromWheel, scaleViewportFromScreenPoint, - scrollViewportVertically, selectCanvasObjectsInsideMarquee, selectLayersInsideMarquee, zoomViewportFromWheel, @@ -133,11 +133,13 @@ describe('ImageCanvasInteractionModel', () => { ).toEqual({ x: 100, y: 60 }); }); - it('scrolls vertically and zooms around a screen point', () => { + it('pans from two-axis wheel input and zooms around a screen point', () => { const viewport = { x: 10, y: 20, scale: 1 }; - expect(scrollViewportVertically(viewport, 120)).toEqual({ - x: 10, + expect( + panViewportFromWheel({ viewport, deltaX: 40, deltaY: 120 }), + ).toEqual({ + x: -30, y: -100, scale: 1, }); diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts index 3780870d8..1140366cd 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -212,12 +212,18 @@ export function scaleViewportFromScreenPoint({ }; } -export function scrollViewportVertically( - viewport: CanvasViewport, - deltaY: number, -) { +export function panViewportFromWheel({ + viewport, + deltaX, + deltaY, +}: { + viewport: CanvasViewport; + deltaX: number; + deltaY: number; +}) { return { ...viewport, + x: viewport.x - deltaX, y: viewport.y - deltaY, }; } diff --git a/src/components/image-editor/ImageCanvasShortcutModel.ts b/src/components/image-editor/ImageCanvasShortcutModel.ts index 518793a36..896d869d7 100644 --- a/src/components/image-editor/ImageCanvasShortcutModel.ts +++ b/src/components/image-editor/ImageCanvasShortcutModel.ts @@ -29,7 +29,7 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [ { title: '视图', items: [ - { action: '纵向滚动画布', keys: ['鼠标滚轮'], status: 'ready' }, + { action: '平移画布', keys: ['鼠标滚轮 / 触控板'], status: 'ready' }, { action: '以鼠标位置缩放', keys: ['Ctrl', '鼠标滚轮'], status: 'ready' }, { action: '显示全部元素', keys: ['Ctrl', '0'], status: 'new' }, { action: '缩放至 100%', keys: ['Ctrl', '1'], status: 'new' }, diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index 3ec5856a0..7d62ce7f7 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -1083,6 +1083,22 @@ describe('useImageCanvasStageInteractions', () => { }); expect(screen.getByTestId('panning').textContent).toBe('true'); + act(() => { + fireEvent( + viewport, + new MouseEvent('pointermove', { + bubbles: true, + clientX: 140, + clientY: 70, + button: 1, + buttons: 4, + }), + ); + }); + expect(screen.getByTestId('viewport-state').textContent).toBe( + '40.0,-30.0,1', + ); + act(() => { fireEvent( viewport, diff --git a/src/components/image-editor/useImageCanvasViewportControls.test.tsx b/src/components/image-editor/useImageCanvasViewportControls.test.tsx index c9f3baf14..556526b69 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.test.tsx +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -158,7 +158,7 @@ describe('useImageCanvasViewportControls', () => { expect(captureCanvasHistory).toHaveBeenCalledTimes(2); }); - it('handles vertical wheel scroll, ctrl wheel zoom and minimap movement', () => { + it('handles two-axis wheel pan, ctrl wheel zoom and minimap movement', () => { const rafCallbacks: FrameRequestCallback[] = []; vi.spyOn(window, 'requestAnimationFrame').mockImplementation( (callback: FrameRequestCallback) => { @@ -171,13 +171,14 @@ describe('useImageCanvasViewportControls', () => { act(() => { fireEvent.wheel(viewportElement, { + deltaX: 40, deltaY: 120, clientX: 260, clientY: 190, }); }); expectViewport(result.current.viewport, { - x: -260, + x: -300, y: -50, scale: 0.5, }); @@ -191,7 +192,7 @@ describe('useImageCanvasViewportControls', () => { }); }); expectViewport(result.current.viewport, { - x: -312, + x: -356, y: -74, scale: 0.55, }); diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index 4e5d0ba1d..46d39a9f7 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -24,7 +24,7 @@ import { moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag, moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer, scaleViewportFromScreenPoint, - scrollViewportVertically, + panViewportFromWheel, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; @@ -261,7 +261,11 @@ export function useImageCanvasViewportControls({ if (!event.ctrlKey && !event.metaKey) { setViewport((currentViewport) => - scrollViewportVertically(currentViewport, event.deltaY), + panViewportFromWheel({ + viewport: currentViewport, + deltaX: event.deltaX, + deltaY: event.deltaY, + }), ); return; } -- 2.52.0 From 0de641dd31cb61435ed8a4751a07e64e3163c67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 21 Jul 2026 19:06:00 +0800 Subject: [PATCH 2/3] add shift + scroll fallback for normal mouse --- .../shared-memory/decision-log.md | 4 +-- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 4 +-- .../image-editor/ImageCanvasShortcutModel.ts | 1 + .../useImageCanvasViewportControls.test.tsx | 35 ++++++++++++++++++- .../useImageCanvasViewportControls.ts | 8 +++-- 6 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6135a4b7d..81a70bcaf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 状态机单测覆盖中键水平、垂直同时移动。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 7d15c9384..cca6665d0 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -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=` 并按 query 加载该工程。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index a2020440d..68c306b07 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -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` 预留更清晰的视口接口。 diff --git a/src/components/image-editor/ImageCanvasShortcutModel.ts b/src/components/image-editor/ImageCanvasShortcutModel.ts index 896d869d7..7863e599a 100644 --- a/src/components/image-editor/ImageCanvasShortcutModel.ts +++ b/src/components/image-editor/ImageCanvasShortcutModel.ts @@ -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' }, diff --git a/src/components/image-editor/useImageCanvasViewportControls.test.tsx b/src/components/image-editor/useImageCanvasViewportControls.test.tsx index 556526b69..0f0ab01eb 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.test.tsx +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -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) => { diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index 46d39a9f7..c47deb11d 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -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; -- 2.52.0 From e88a64dda56fba3b79e5489c0279fccb3d0fdd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 21 Jul 2026 19:39:26 +0800 Subject: [PATCH 3/3] fix lint --- src/components/image-editor/useImageCanvasViewportControls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index c47deb11d..2f74c1b8f 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -23,8 +23,8 @@ import { getWorldPointFromClient, moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag, moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer, - scaleViewportFromScreenPoint, panViewportFromWheel, + scaleViewportFromScreenPoint, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; -- 2.52.0