抽出编辑器舞台交互状态模型

新增 ImageCanvasStageInteractionModel 承载 pointer 与拖拽状态规则

补充舞台交互状态模型单测

精简 useImageCanvasStageInteractions 的状态构造逻辑

更新 TRACKING.md 记录第四十二阶段验证
This commit is contained in:
2026-06-17 19:30:14 +08:00
parent 4e4edc285b
commit 7f573486bc
4 changed files with 665 additions and 151 deletions

View File

@@ -158,3 +158,4 @@
- 2026-06-17 前端拆分第三十九阶段:开始拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorView.test-utils` 共享默认工程 / 素材库 mock、认证上下文、pointer / dataTransfer / deferred helper 和生命周期 setup新增 `ImageCanvasEditorAssetsIntegration.test.tsx`,把素材库默认文件夹去重、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 拖拽移动、多文件上传、未登录续传、上传占位、401 登录、素材选择模式、删除素材同步清理画布图层、素材框选、画布 drop 上传和素材库拖入画布等集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 4817 行降至 3741 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx``npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``打开图层` 切换后侧栏显示 `图层`,点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第三十九阶段:开始拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorView.test-utils` 共享默认工程 / 素材库 mock、认证上下文、pointer / dataTransfer / deferred helper 和生命周期 setup新增 `ImageCanvasEditorAssetsIntegration.test.tsx`,把素材库默认文件夹去重、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 拖拽移动、多文件上传、未登录续传、上传占位、401 登录、素材选择模式、删除素材同步清理画布图层、素材框选、画布 drop 上传和素材库拖入画布等集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 4817 行降至 3741 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx``npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``打开图层` 切换后侧栏显示 `图层`,点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。
- 2026-06-17 前端拆分第四十阶段:继续拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorGenerationIntegration.test.tsx`,把画布生图占位 / 生成提交、生成错误与未登录、规范生成、角色形象生成、图标素材生成、角色动画、快速编辑、生成图元数据和修改结果等生成相关集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 3741 行降至 1419 行,主测试保留工程加载、导出、画布基础交互、右键菜单、侧栏、缩放、小地图、工具切换、吸附和历史。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx``npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第四十阶段:继续拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorGenerationIntegration.test.tsx`,把画布生图占位 / 生成提交、生成错误与未登录、规范生成、角色形象生成、图标素材生成、角色动画、快速编辑、生成图元数据和修改结果等生成相关集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 3741 行降至 1419 行,主测试保留工程加载、导出、画布基础交互、右键菜单、侧栏、缩放、小地图、工具切换、吸附和历史。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx``npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。
- 2026-06-17 前端拆分第四十一阶段:继续收口 `useImageCanvasGenerationWorkflow`,新增 `ImageCanvasGenerationDialogModel`,把普通生图 / 规范 / 角色形象 / 图标素材生成对话框草稿、修改图片 / 快速编辑 / 角色动画面板草稿、角色 / 图标参考选择、规范表单更新、图标描述更新、角色动画时长更新以及生成器失焦 / 关闭规则从 hook 中抽成纯模型workflow hook 保留真实 API 调用、生成结果落画布、侧栏 / 工具 / 选中态副作用和错误回写。新增模型单测覆盖各类草稿、失败态清理、引用选择、描述限制、动画时长和 composer 可见性;`useImageCanvasGenerationWorkflow.ts` 从 1075 行降至 870 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第四十一阶段:继续收口 `useImageCanvasGenerationWorkflow`,新增 `ImageCanvasGenerationDialogModel`,把普通生图 / 规范 / 角色形象 / 图标素材生成对话框草稿、修改图片 / 快速编辑 / 角色动画面板草稿、角色 / 图标参考选择、规范表单更新、图标描述更新、角色动画时长更新以及生成器失焦 / 关闭规则从 hook 中抽成纯模型workflow hook 保留真实 API 调用、生成结果落画布、侧栏 / 工具 / 选中态副作用和错误回写。新增模型单测覆盖各类草稿、失败态清理、引用选择、描述限制、动画时长和 composer 可见性;`useImageCanvasGenerationWorkflow.ts` 从 1075 行降至 870 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。
- 2026-06-17 前端拆分第四十二阶段:继续收口 `useImageCanvasStageInteractions`,新增 `ImageCanvasStageInteractionModel`,把 pointer button / client / id 归一化、画布框选初始状态、抓手平移拖拽状态、多选图层选择与图层拖拽初始状态、生成器 composer 随图层点击显隐、生成占位拖拽状态、小地图拖拽状态和拖拽阈值从 hook 中抽成纯模型stage hook 保留 DOM 事件拦截、pointer capture / release、React 状态写入、拖拽移动执行和回调副作用。新增模型单测覆盖 pointer 兜底、中键识别、框选 / 平移状态、多选 toggle、组拖拽初始层快照、生成器 composer 规则、生成占位状态和小地图 click / drag 分流;`useImageCanvasStageInteractions.ts` 从 610 行降至 521 行。只读子代理复核结论:当前没有同一轮必须顺手拆的第二个明显大块,`ImageCanvasEditorView.tsx` 已主要是组合层generation / asset / upload hooks 剩余复杂度多为异步编排或持久化副作用,后续应随具体需求再拆,避免过度碎片化。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasStageInteractions.test.tsx``npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none``打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具``Image Generator``生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。

View File

@@ -0,0 +1,294 @@
import { describe, expect, it } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasLayer,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
createCanvasMarqueeState,
createGenerationFrameDragState,
createLayerDragStart,
createMinimapDragState,
createPanDragState,
getCanvasPointFromPointer,
getPointerButton,
getPointerClient,
getPointerId,
resolveLayerPointerSelection,
updateGenerateDialogForLayerClick,
updateGenerateDialogForLayerPointerDown,
updateMinimapDragMovement,
} from './ImageCanvasStageInteractionModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
const id = overrides.id ?? 'layer-a';
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `data:image/png;base64,${id}`,
x: 100,
y: 120,
width: 240,
height: 160,
originalWidth: 240,
originalHeight: 160,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
describe('ImageCanvasStageInteractionModel', () => {
it('normalizes pointer button, client point, and pointer id', () => {
expect(
getPointerButton({
button: 0,
buttons: 1,
nativeEvent: { button: 0, buttons: 4 },
}),
).toBe(1);
expect(
getPointerButton({
button: 2,
buttons: 0,
nativeEvent: { button: 0, buttons: 0 },
}),
).toBe(2);
expect(
getPointerClient({
clientX: Number.NaN,
clientY: 42,
nativeEvent: { clientX: 120, clientY: 80 },
}),
).toEqual({ x: 120, y: 42 });
expect(
getPointerId({
pointerId: Number.NaN,
nativeEvent: { pointerId: 18 },
}),
).toBe(18);
expect(getPointerId({})).toBe(-1);
});
it('creates pan and marquee states from normalized pointer positions', () => {
expect(
getCanvasPointFromPointer({
pointer: { x: 180, y: 140 },
rect: { left: 20, top: 30 },
}),
).toEqual({ x: 160, y: 110 });
expect(
createCanvasMarqueeState({
pointerId: 8,
pointer: { x: 180, y: 140 },
rect: { left: 20, top: 30 },
}),
).toEqual({
pointerId: 8,
startX: 160,
startY: 110,
currentX: 160,
currentY: 110,
});
expect(
createPanDragState({
pointerId: 9,
pointer: { x: 320, y: 240 },
viewport: { x: 10, y: 20, scale: 1.5 },
}),
).toEqual({
kind: 'pan',
pointerId: 9,
startClientX: 320,
startClientY: 240,
startViewport: { x: 10, y: 20, scale: 1.5 },
});
});
it('resolves multi-select layer toggles without emptying the last selection', () => {
expect(
resolveLayerPointerSelection({
layerId: 'a',
selectedLayerIds: ['b'],
isMultiSelectGesture: false,
}),
).toEqual(['a']);
expect(
resolveLayerPointerSelection({
layerId: 'a',
selectedLayerIds: ['b'],
isMultiSelectGesture: true,
}),
).toEqual(['b', 'a']);
expect(
resolveLayerPointerSelection({
layerId: 'b',
selectedLayerIds: ['a', 'b'],
isMultiSelectGesture: true,
}),
).toEqual(['a']);
expect(
resolveLayerPointerSelection({
layerId: 'a',
selectedLayerIds: ['a'],
isMultiSelectGesture: true,
}),
).toEqual(['a']);
});
it('creates layer drag state for the next selected layer group', () => {
const layers = [
createLayer({ id: 'a', x: 40, y: 50 }),
createLayer({ id: 'b', x: 120, y: 150 }),
createLayer({ id: 'c', x: 220, y: 250 }),
];
expect(
createLayerDragStart({
layer: layers[1]!,
layers,
selectedLayerIds: ['a'],
isMultiSelectGesture: true,
pointerId: 4,
pointer: { x: 300, y: 260 },
viewportScale: 1.25,
}),
).toEqual({
selectedLayerIds: ['a', 'b'],
dragState: {
kind: 'layer',
pointerId: 4,
layerId: 'b',
layerIds: ['a', 'b'],
startClientX: 300,
startClientY: 260,
startLayerX: 120,
startLayerY: 150,
startLayers: [
{ id: 'a', x: 40, y: 50 },
{ id: 'b', x: 120, y: 150 },
],
startScale: 1.25,
},
});
});
it('keeps generation composers open only for the clicked generated layer', () => {
const generatedDialog: GenerateDialogState = {
id: 'dialog-1',
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: false,
generatedLayerId: 'layer-a',
};
const editDialog: GenerateDialogState = {
mode: 'edit',
prompt: '',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-a',
};
const draftDialogWithoutId: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
};
expect(
updateGenerateDialogForLayerPointerDown(generatedDialog, 'layer-a'),
).toEqual({
...generatedDialog,
composerOpen: true,
});
expect(
updateGenerateDialogForLayerPointerDown(generatedDialog, 'layer-b'),
).toEqual({
...generatedDialog,
composerOpen: false,
});
expect(updateGenerateDialogForLayerPointerDown(editDialog, 'layer-a')).toBe(
editDialog,
);
expect(updateGenerateDialogForLayerClick(generatedDialog)).toEqual({
...generatedDialog,
composerOpen: false,
});
expect(
updateGenerateDialogForLayerClick(draftDialogWithoutId),
).toMatchObject({
mode: 'generate',
composerOpen: false,
});
expect(updateGenerateDialogForLayerClick(editDialog)).toBe(editDialog);
});
it('creates generation frame and minimap drag states', () => {
const dialog: CanvasGenerationDialogState = {
id: 'dialog-1',
mode: 'generate',
prompt: '',
status: 'idle',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
};
expect(
createGenerationFrameDragState({
dialog,
pointerId: 11,
pointer: { x: 240, y: 200 },
viewportScale: 2,
}),
).toEqual({
kind: 'generation-frame',
dialogId: 'dialog-1',
pointerId: 11,
startClientX: 240,
startClientY: 200,
startFrameX: 200,
startFrameY: 160,
startScale: 2,
});
expect(
createGenerationFrameDragState({
dialog: { ...dialog, placeholder: undefined },
pointerId: 11,
pointer: { x: 240, y: 200 },
viewportScale: 2,
}),
).toBeNull();
const minimapDrag = createMinimapDragState({
pointerId: 12,
pointer: { x: 120, y: 90 },
viewport: { x: 10, y: 20, scale: 1 },
minimapScale: 0.4,
});
expect(minimapDrag).toEqual({
kind: 'minimap',
pointerId: 12,
startClientX: 120,
startClientY: 90,
startViewport: { x: 10, y: 20, scale: 1 },
minimapScale: 0.4,
moved: false,
});
expect(
updateMinimapDragMovement(minimapDrag, { x: 121, y: 90 }),
).toBe(minimapDrag);
expect(updateMinimapDragMovement(minimapDrag, { x: 123, y: 90 })).toEqual({
...minimapDrag,
moved: true,
});
});
});

View File

@@ -0,0 +1,308 @@
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasMarqueeState,
CanvasViewport,
DragState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import type { CanvasPoint } from './ImageCanvasInteractionModel';
type PointerSource = {
button?: number;
buttons?: number;
clientX?: number;
clientY?: number;
pointerId?: number;
nativeEvent?: {
button?: number;
buttons?: number;
clientX?: number;
clientY?: number;
pointerId?: number;
};
};
type CanvasRectLike = {
left?: number;
top?: number;
} | null | undefined;
const CANVAS_GENERATION_DIALOG_MODES = new Set([
'generate',
'spec',
'character',
'icon',
]);
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function hasCanvasGenerationDialogMode(
dialog: GenerateDialogState | null,
): dialog is GenerateDialogState & {
mode: CanvasGenerationDialogState['mode'];
} {
if (!dialog) {
return false;
}
return CANVAS_GENERATION_DIALOG_MODES.has(dialog.mode);
}
export function getPointerButton(event: PointerSource) {
const nativeButtons = Number(event.nativeEvent?.buttons);
if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) {
return 1;
}
const syntheticButtons = Number(event.buttons);
if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) {
return 1;
}
const syntheticButton = Number(event.button);
if (Number.isFinite(syntheticButton)) {
return syntheticButton;
}
const nativeButton = Number(event.nativeEvent?.button);
if (Number.isFinite(nativeButton)) {
return nativeButton;
}
return 0;
}
export function getPointerClient(event: PointerSource): CanvasPoint {
return {
x: isFiniteNumber(event.clientX)
? event.clientX
: isFiniteNumber(event.nativeEvent?.clientX)
? event.nativeEvent.clientX
: 0,
y: isFiniteNumber(event.clientY)
? event.clientY
: isFiniteNumber(event.nativeEvent?.clientY)
? event.nativeEvent.clientY
: 0,
};
}
export function getPointerId(event: PointerSource) {
return isFiniteNumber(event.pointerId)
? event.pointerId
: isFiniteNumber(event.nativeEvent?.pointerId)
? event.nativeEvent.pointerId
: -1;
}
export function getCanvasPointFromPointer({
pointer,
rect,
}: {
pointer: CanvasPoint;
rect: CanvasRectLike;
}): CanvasPoint {
return {
x: pointer.x - (rect?.left ?? 0),
y: pointer.y - (rect?.top ?? 0),
};
}
export function createCanvasMarqueeState({
pointerId,
pointer,
rect,
}: {
pointerId: number;
pointer: CanvasPoint;
rect: CanvasRectLike;
}): CanvasMarqueeState {
const startPoint = getCanvasPointFromPointer({ pointer, rect });
return {
pointerId,
startX: startPoint.x,
startY: startPoint.y,
currentX: startPoint.x,
currentY: startPoint.y,
};
}
export function createPanDragState({
pointerId,
pointer,
viewport,
}: {
pointerId: number;
pointer: CanvasPoint;
viewport: CanvasViewport;
}): Extract<DragState, { kind: 'pan' }> {
return {
kind: 'pan',
pointerId,
startClientX: pointer.x,
startClientY: pointer.y,
startViewport: viewport,
};
}
export function resolveLayerPointerSelection({
layerId,
selectedLayerIds,
isMultiSelectGesture,
}: {
layerId: string;
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
}) {
if (!isMultiSelectGesture) {
return [layerId];
}
if (!selectedLayerIds.includes(layerId)) {
return [...selectedLayerIds, layerId];
}
if (selectedLayerIds.length <= 1) {
return [layerId];
}
return selectedLayerIds.filter((currentLayerId) => currentLayerId !== layerId);
}
export function createLayerDragStart({
layer,
layers,
selectedLayerIds,
isMultiSelectGesture,
pointerId,
pointer,
viewportScale,
}: {
layer: CanvasLayer;
layers: CanvasLayer[];
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
pointerId: number;
pointer: CanvasPoint;
viewportScale: number;
}): {
selectedLayerIds: string[];
dragState: Extract<DragState, { kind: 'layer' }>;
} {
const nextSelectedLayerIds = resolveLayerPointerSelection({
layerId: layer.id,
selectedLayerIds,
isMultiSelectGesture,
});
const dragLayerIds = nextSelectedLayerIds.includes(layer.id)
? nextSelectedLayerIds
: [layer.id];
const startLayers = layers
.filter((currentLayer) => dragLayerIds.includes(currentLayer.id))
.map((currentLayer) => ({
id: currentLayer.id,
x: currentLayer.x,
y: currentLayer.y,
}));
return {
selectedLayerIds: nextSelectedLayerIds,
dragState: {
kind: 'layer',
pointerId,
layerId: layer.id,
layerIds: dragLayerIds,
startClientX: pointer.x,
startClientY: pointer.y,
startLayerX: layer.x,
startLayerY: layer.y,
startLayers,
startScale: viewportScale,
},
};
}
export function updateGenerateDialogForLayerPointerDown(
dialog: GenerateDialogState | null,
layerId: string,
): GenerateDialogState | null {
if (!hasCanvasGenerationDialogMode(dialog)) {
return dialog;
}
return {
...dialog,
composerOpen: dialog.generatedLayerId === layerId,
};
}
export function updateGenerateDialogForLayerClick(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
if (!hasCanvasGenerationDialogMode(dialog)) {
return dialog;
}
return {
...dialog,
composerOpen: false,
};
}
export function createGenerationFrameDragState({
dialog,
pointerId,
pointer,
viewportScale,
}: {
dialog: CanvasGenerationDialogState;
pointerId: number;
pointer: CanvasPoint;
viewportScale: number;
}): Extract<DragState, { kind: 'generation-frame' }> | null {
if (!dialog.placeholder) {
return null;
}
return {
kind: 'generation-frame',
dialogId: dialog.id,
pointerId,
startClientX: pointer.x,
startClientY: pointer.y,
startFrameX: dialog.placeholder.x,
startFrameY: dialog.placeholder.y,
startScale: viewportScale,
};
}
export function createMinimapDragState({
pointerId,
pointer,
viewport,
minimapScale,
}: {
pointerId: number;
pointer: CanvasPoint;
viewport: CanvasViewport;
minimapScale: number;
}): Extract<DragState, { kind: 'minimap' }> {
return {
kind: 'minimap',
pointerId,
startClientX: pointer.x,
startClientY: pointer.y,
startViewport: { ...viewport },
minimapScale,
moved: false,
};
}
export function updateMinimapDragMovement(
dragState: Extract<DragState, { kind: 'minimap' }>,
pointer: CanvasPoint,
): Extract<DragState, { kind: 'minimap' }> {
if (dragState.moved) {
return dragState;
}
const deltaX = pointer.x - dragState.startClientX;
const deltaY = pointer.y - dragState.startClientY;
return Math.hypot(deltaX, deltaY) >= 2
? {
...dragState,
moved: true,
}
: dragState;
}

View File

@@ -16,6 +16,20 @@ import {
moveViewportFromPan, moveViewportFromPan,
selectLayersInsideMarquee, selectLayersInsideMarquee,
} from './ImageCanvasInteractionModel'; } from './ImageCanvasInteractionModel';
import {
createCanvasMarqueeState,
createGenerationFrameDragState,
createLayerDragStart,
createMinimapDragState,
createPanDragState,
getCanvasPointFromPointer,
getPointerButton,
getPointerClient,
getPointerId,
updateGenerateDialogForLayerClick,
updateGenerateDialogForLayerPointerDown,
updateMinimapDragMovement,
} from './ImageCanvasStageInteractionModel';
import type { import type {
CanvasGenerationDialogState, CanvasGenerationDialogState,
CanvasLayer, CanvasLayer,
@@ -27,11 +41,6 @@ import type {
SnapGuide, SnapGuide,
} from './ImageCanvasEditorTypes'; } from './ImageCanvasEditorTypes';
type CanvasPoint = {
x: number;
y: number;
};
type UseImageCanvasStageInteractionsOptions = { type UseImageCanvasStageInteractionsOptions = {
canvasViewportRef: RefObject<HTMLDivElement | null>; canvasViewportRef: RefObject<HTMLDivElement | null>;
activeTool: CanvasTool; activeTool: CanvasTool;
@@ -70,51 +79,6 @@ type UseImageCanvasStageInteractionsOptions = {
onCloseImageContextMenu: () => void; onCloseImageContextMenu: () => void;
}; };
function getPointerButton(event: ReactPointerEvent<HTMLElement>) {
const nativeEvent = event.nativeEvent as PointerEvent;
const nativeButtons = Number(nativeEvent.buttons);
if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) {
return 1;
}
const syntheticButtons = Number(event.buttons);
if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) {
return 1;
}
const syntheticButton = Number(event.button);
if (Number.isFinite(syntheticButton)) {
return syntheticButton;
}
const nativeButton = Number(nativeEvent.button);
if (Number.isFinite(nativeButton)) {
return nativeButton;
}
return 0;
}
function getPointerClient(event: ReactPointerEvent<HTMLElement>): CanvasPoint {
const nativeEvent = event.nativeEvent as PointerEvent;
return {
x: Number.isFinite(event.clientX)
? event.clientX
: Number.isFinite(nativeEvent.clientX)
? nativeEvent.clientX
: 0,
y: Number.isFinite(event.clientY)
? event.clientY
: Number.isFinite(nativeEvent.clientY)
? nativeEvent.clientY
: 0,
};
}
function getPointerId(event: ReactPointerEvent<HTMLElement>) {
const nativeId = (event.nativeEvent as PointerEvent).pointerId;
if (Number.isFinite(event.pointerId)) {
return event.pointerId;
}
return Number.isFinite(nativeId) ? nativeId : -1;
}
export function useImageCanvasStageInteractions({ export function useImageCanvasStageInteractions({
canvasViewportRef, canvasViewportRef,
activeTool, activeTool,
@@ -169,13 +133,11 @@ export function useImageCanvasStageInteractions({
const pointer = getPointerClient(event); const pointer = getPointerClient(event);
canvasViewportRef.current?.setPointerCapture?.(event.pointerId); canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
setIsPanning(true); setIsPanning(true);
dragStateRef.current = { dragStateRef.current = createPanDragState({
kind: 'pan',
pointerId: getPointerId(event), pointerId: getPointerId(event),
startClientX: pointer.x, pointer,
startClientY: pointer.y, viewport,
startViewport: viewport, });
};
}, },
[canvasViewportRef, viewport], [canvasViewportRef, viewport],
); );
@@ -199,16 +161,15 @@ export function useImageCanvasStageInteractions({
) { ) {
event.preventDefault(); event.preventDefault();
const rect = canvasViewportRef.current?.getBoundingClientRect(); const rect = canvasViewportRef.current?.getBoundingClientRect();
const startX = event.clientX - (rect?.left ?? 0); const pointer = getPointerClient(event);
const startY = event.clientY - (rect?.top ?? 0);
canvasViewportRef.current?.setPointerCapture?.(event.pointerId); canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
setCanvasMarquee({ setCanvasMarquee(
pointerId: event.pointerId, createCanvasMarqueeState({
startX, pointerId: getPointerId(event),
startY, pointer,
currentX: startX, rect,
currentY: startY, }),
}); );
clearCanvasFocus(); clearCanvasFocus();
return; return;
} }
@@ -262,57 +223,21 @@ export function useImageCanvasStageInteractions({
const pointer = getPointerClient(event); const pointer = getPointerClient(event);
canvasViewportRef.current?.setPointerCapture?.(event.pointerId); canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current; const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current;
const nextSelectedIds = isMultiSelectGesture const layerDragStart = createLayerDragStart({
? selectedLayerIds.includes(layer.id) layer,
? selectedLayerIds.length > 1 layers,
? selectedLayerIds.filter((layerId) => layerId !== layer.id) selectedLayerIds,
: [layer.id] isMultiSelectGesture,
: [...selectedLayerIds, layer.id]
: [layer.id];
setSelectedLayerId(layer.id);
setSelectedLayerIds(nextSelectedIds);
setGenerateDialog((currentDialog) => {
if (
currentDialog?.mode !== 'generate' &&
currentDialog?.mode !== 'spec' &&
currentDialog?.mode !== 'character' &&
currentDialog?.mode !== 'icon'
) {
return currentDialog;
}
if (currentDialog.generatedLayerId === layer.id) {
return {
...currentDialog,
composerOpen: true,
};
}
return {
...currentDialog,
composerOpen: false,
};
});
const dragLayerIds = nextSelectedIds.includes(layer.id)
? nextSelectedIds
: [layer.id];
const startLayers = layers
.filter((currentLayer) => dragLayerIds.includes(currentLayer.id))
.map((currentLayer) => ({
id: currentLayer.id,
x: currentLayer.x,
y: currentLayer.y,
}));
dragStateRef.current = {
kind: 'layer',
pointerId: getPointerId(event), pointerId: getPointerId(event),
layerId: layer.id, pointer,
layerIds: dragLayerIds, viewportScale: viewport.scale,
startClientX: pointer.x, });
startClientY: pointer.y, setSelectedLayerId(layer.id);
startLayerX: layer.x, setSelectedLayerIds(layerDragStart.selectedLayerIds);
startLayerY: layer.y, setGenerateDialog((currentDialog) =>
startLayers, updateGenerateDialogForLayerPointerDown(currentDialog, layer.id),
startScale: viewport.scale, );
}; dragStateRef.current = layerDragStart.dragState;
}, },
[ [
canvasViewportRef, canvasViewportRef,
@@ -358,15 +283,7 @@ export function useImageCanvasStageInteractions({
setSelectedLayerId(layer.id); setSelectedLayerId(layer.id);
setSelectedLayerIds([layer.id]); setSelectedLayerIds([layer.id]);
setGenerateDialog((currentDialog) => setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate' || updateGenerateDialogForLayerClick(currentDialog),
currentDialog?.mode === 'spec' ||
currentDialog?.mode === 'character' ||
currentDialog?.mode === 'icon'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
); );
onCloseImageContextMenu(); onCloseImageContextMenu();
}, },
@@ -404,16 +321,12 @@ export function useImageCanvasStageInteractions({
const pointer = getPointerClient(event); const pointer = getPointerClient(event);
canvasViewportRef.current?.setPointerCapture?.(event.pointerId); canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
activateCanvasGenerationDialog(dialog); activateCanvasGenerationDialog(dialog);
dragStateRef.current = { dragStateRef.current = createGenerationFrameDragState({
kind: 'generation-frame', dialog,
dialogId: dialog.id,
pointerId: getPointerId(event), pointerId: getPointerId(event),
startClientX: pointer.x, pointer,
startClientY: pointer.y, viewportScale: viewport.scale,
startFrameX: dialog.placeholder.x, });
startFrameY: dialog.placeholder.y,
startScale: viewport.scale,
};
}, },
[ [
activateCanvasGenerationDialog, activateCanvasGenerationDialog,
@@ -430,15 +343,12 @@ export function useImageCanvasStageInteractions({
event.stopPropagation(); event.stopPropagation();
const pointer = getPointerClient(event); const pointer = getPointerClient(event);
canvasViewportRef.current?.setPointerCapture?.(event.pointerId); canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
dragStateRef.current = { dragStateRef.current = createMinimapDragState({
kind: 'minimap',
pointerId: getPointerId(event), pointerId: getPointerId(event),
startClientX: pointer.x, pointer,
startClientY: pointer.y, viewport,
startViewport: { ...viewport },
minimapScale, minimapScale,
moved: false, });
};
}, },
[canvasViewportRef, minimapScale, viewport], [canvasViewportRef, minimapScale, viewport],
); );
@@ -448,20 +358,22 @@ export function useImageCanvasStageInteractions({
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
event.preventDefault(); event.preventDefault();
const rect = canvasViewportRef.current?.getBoundingClientRect(); const rect = canvasViewportRef.current?.getBoundingClientRect();
const currentX = event.clientX - (rect?.left ?? 0); const currentPoint = getCanvasPointFromPointer({
const currentY = event.clientY - (rect?.top ?? 0); pointer: getPointerClient(event),
rect,
});
setCanvasMarquee((currentMarquee) => setCanvasMarquee((currentMarquee) =>
currentMarquee currentMarquee
? { ? {
...currentMarquee, ...currentMarquee,
currentX, currentX: currentPoint.x,
currentY, currentY: currentPoint.y,
} }
: null, : null,
); );
const selectedIds = selectLayersInsideMarquee({ const selectedIds = selectLayersInsideMarquee({
marquee: canvasMarquee, marquee: canvasMarquee,
currentPoint: { x: currentX, y: currentY }, currentPoint,
layers, layers,
viewport, viewport,
}); });
@@ -507,13 +419,12 @@ export function useImageCanvasStageInteractions({
if (dragState.kind === 'minimap') { if (dragState.kind === 'minimap') {
const pointer = getPointerClient(event); const pointer = getPointerClient(event);
const deltaX = pointer.x - dragState.startClientX; const nextDragState = updateMinimapDragMovement(dragState, pointer);
const deltaY = pointer.y - dragState.startClientY; if (nextDragState !== dragState) {
if (!dragState.moved && Math.hypot(deltaX, deltaY) >= 2) { dragStateRef.current = nextDragState;
dragState.moved = true;
} }
if (dragState.moved) { if (nextDragState.moved) {
updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y); updateViewportFromMinimapDrag(nextDragState, pointer.x, pointer.y);
} }
return; return;
} }