diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9d3707cb2..01345b3cb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4373,3 +4373,9 @@ - 回退边界:明确模型不存在 / 不支持、408、非内容拒绝类 429、5xx、响应解析失败或非拒绝类缺图可以切模型;401 / 403、普通参数 / 内容安全拒绝、本地配置与参考图错误、发送 / 连接错误、request budget 耗尽和已生成图片下载失败不切模型。一次业务请求总发送上限仍为 5 次,两个模型共享同一 worker provider deadline 和 attempt 预算。 - 观测边界:审计 `image_model` 记录实际 provider attempt;首选 `gpt-image-2` 失败但兜底 `gpt-image-2-c` 恢复成功时,首选失败仍写入 `external_api_call_failure`,最终成功运行摘要记录 `recoveredFailureCount`。日志用 `fallback_from_model` / `fallback_to_model` 标识切换,不改变业务模型、扣费、素材 metadata 或终态语义。 - 脚本边界:仓库 `gpt-image-2-apimart` skill 的现役生成脚本采用同一首选 / 回退顺序;认证、请求发送不确定错误和下载失败不重新生图,避免重复上游成本。 + +## 2026-07-21 图片画布滚轮与中键平移统一为二维视口移动 + +- 背景:画布中键拖拽的平移模型已同时计算 X / Y,但普通滚轮分支只消费 `deltaY`,横向滚轮或触控板的 `deltaX` 被丢弃,且缺少中键横向拖动的状态机回归覆盖。 +- 决策:普通滚轮原样消费设备上报的 `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 59b7c2141..cca6665d0 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`)。 @@ -149,7 +149,7 @@ - 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。 - 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 - 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。 -- 鼠标滚轮默认垂直滚动画布视口;按住 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 4a633479f..68c306b07 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` 二维平移、Shift 且 `deltaX = 0` 时由输入适配层把 `deltaY` 映射为横向位移、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。 - 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。 - 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。 @@ -151,7 +151,7 @@ ## 第十七阶段模块 - `useImageCanvasViewportControls.ts` - - 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮纵向滚动、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/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..7863e599a 100644 --- a/src/components/image-editor/ImageCanvasShortcutModel.ts +++ b/src/components/image-editor/ImageCanvasShortcutModel.ts @@ -29,7 +29,8 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [ { title: '视图', items: [ - { action: '纵向滚动画布', keys: ['鼠标滚轮'], status: 'ready' }, + { 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/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..0f0ab01eb 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.test.tsx +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -158,19 +158,12 @@ describe('useImageCanvasViewportControls', () => { expect(captureCanvasHistory).toHaveBeenCalledTimes(2); }); - it('handles vertical wheel scroll, ctrl wheel zoom and minimap movement', () => { - const rafCallbacks: FrameRequestCallback[] = []; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation( - (callback: FrameRequestCallback) => { - rafCallbacks.push(callback); - return rafCallbacks.length; - }, - ); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + 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, @@ -182,6 +175,47 @@ describe('useImageCanvasViewportControls', () => { 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) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }, + ); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + const { result, viewportElement } = renderViewportControls(); + + act(() => { + fireEvent.wheel(viewportElement, { + deltaX: 40, + deltaY: 120, + clientX: 260, + clientY: 190, + }); + }); + expectViewport(result.current.viewport, { + x: -300, + y: -50, + scale: 0.5, + }); + act(() => { fireEvent.wheel(viewportElement, { ctrlKey: true, @@ -191,7 +225,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..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, + panViewportFromWheel, scaleViewportFromScreenPoint, - scrollViewportVertically, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; @@ -260,8 +260,16 @@ export function useImageCanvasViewportControls({ } if (!event.ctrlKey && !event.metaKey) { + const useShiftWheelHorizontalFallback = + event.shiftKey && event.deltaX === 0; setViewport((currentViewport) => - scrollViewportVertically(currentViewport, event.deltaY), + panViewportFromWheel({ + viewport: currentViewport, + deltaX: useShiftWheelHorizontalFallback + ? event.deltaY + : event.deltaX, + deltaY: useShiftWheelHorizontalFallback ? 0 : event.deltaY, + }), ); return; }