抽出编辑器舞台交互状态模型
新增 ImageCanvasStageInteractionModel 承载 pointer 与拖拽状态规则 补充舞台交互状态模型单测 精简 useImageCanvasStageInteractions 的状态构造逻辑 更新 TRACKING.md 记录第四十二阶段验证
This commit is contained in:
@@ -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。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
308
src/components/image-editor/ImageCanvasStageInteractionModel.ts
Normal file
308
src/components/image-editor/ImageCanvasStageInteractionModel.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user