fix: 画布鼠标/触摸板可以上下滚动,无法左右滚动 #100
@@ -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 状态机单测覆盖中键水平、垂直同时移动。
|
||||
|
||||
@@ -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=<projectId>` 并按 query 加载该工程。
|
||||
|
||||
|
||||
@@ -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` 预留更清晰的视口接口。
|
||||
|
||||
|
||||
@@ -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(<ImageCanvasEditorView />);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user