fix: canvas interaction supports two-axis viewport panning via mouse wheel; updated related tests, hooks, and documentation for new behavior.
This commit is contained in:
@@ -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 状态机单测覆盖中键水平、垂直同时移动。
|
||||
|
||||
@@ -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=<projectId>` 并按 query 加载该工程。
|
||||
|
||||
|
||||
@@ -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` 预留更清晰的视口接口。
|
||||
|
||||
|
||||
@@ -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,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' },
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user