From 7f573486bcaf95f5058abe2b6c8f21d85057816b Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 19:30:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E8=88=9E=E5=8F=B0=E4=BA=A4=E4=BA=92=E7=8A=B6=E6=80=81=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ImageCanvasStageInteractionModel 承载 pointer 与拖拽状态规则 补充舞台交互状态模型单测 精简 useImageCanvasStageInteractions 的状态构造逻辑 更新 TRACKING.md 记录第四十二阶段验证 --- TRACKING.md | 1 + .../ImageCanvasStageInteractionModel.test.ts | 294 +++++++++++++++++ .../ImageCanvasStageInteractionModel.ts | 308 ++++++++++++++++++ .../useImageCanvasStageInteractions.ts | 213 ++++-------- 4 files changed, 665 insertions(+), 151 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasStageInteractionModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasStageInteractionModel.ts diff --git a/TRACKING.md b/TRACKING.md index 194fa540..452d5464 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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` 巨型集成测试,新增 `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 前端拆分第四十二阶段:继续收口 `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。 diff --git a/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts new file mode 100644 index 00000000..4068cee0 --- /dev/null +++ b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/src/components/image-editor/ImageCanvasStageInteractionModel.ts b/src/components/image-editor/ImageCanvasStageInteractionModel.ts new file mode 100644 index 00000000..4e0834d3 --- /dev/null +++ b/src/components/image-editor/ImageCanvasStageInteractionModel.ts @@ -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 { + 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; +} { + 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 | 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 { + return { + kind: 'minimap', + pointerId, + startClientX: pointer.x, + startClientY: pointer.y, + startViewport: { ...viewport }, + minimapScale, + moved: false, + }; +} + +export function updateMinimapDragMovement( + dragState: Extract, + pointer: CanvasPoint, +): Extract { + 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; +} diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index 46dbf0fd..865a27cb 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -16,6 +16,20 @@ import { moveViewportFromPan, selectLayersInsideMarquee, } from './ImageCanvasInteractionModel'; +import { + createCanvasMarqueeState, + createGenerationFrameDragState, + createLayerDragStart, + createMinimapDragState, + createPanDragState, + getCanvasPointFromPointer, + getPointerButton, + getPointerClient, + getPointerId, + updateGenerateDialogForLayerClick, + updateGenerateDialogForLayerPointerDown, + updateMinimapDragMovement, +} from './ImageCanvasStageInteractionModel'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -27,11 +41,6 @@ import type { SnapGuide, } from './ImageCanvasEditorTypes'; -type CanvasPoint = { - x: number; - y: number; -}; - type UseImageCanvasStageInteractionsOptions = { canvasViewportRef: RefObject; activeTool: CanvasTool; @@ -70,51 +79,6 @@ type UseImageCanvasStageInteractionsOptions = { onCloseImageContextMenu: () => void; }; -function getPointerButton(event: ReactPointerEvent) { - 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): 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) { - const nativeId = (event.nativeEvent as PointerEvent).pointerId; - if (Number.isFinite(event.pointerId)) { - return event.pointerId; - } - return Number.isFinite(nativeId) ? nativeId : -1; -} - export function useImageCanvasStageInteractions({ canvasViewportRef, activeTool, @@ -169,13 +133,11 @@ export function useImageCanvasStageInteractions({ const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); setIsPanning(true); - dragStateRef.current = { - kind: 'pan', + dragStateRef.current = createPanDragState({ pointerId: getPointerId(event), - startClientX: pointer.x, - startClientY: pointer.y, - startViewport: viewport, - }; + pointer, + viewport, + }); }, [canvasViewportRef, viewport], ); @@ -199,16 +161,15 @@ export function useImageCanvasStageInteractions({ ) { event.preventDefault(); const rect = canvasViewportRef.current?.getBoundingClientRect(); - const startX = event.clientX - (rect?.left ?? 0); - const startY = event.clientY - (rect?.top ?? 0); + const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); - setCanvasMarquee({ - pointerId: event.pointerId, - startX, - startY, - currentX: startX, - currentY: startY, - }); + setCanvasMarquee( + createCanvasMarqueeState({ + pointerId: getPointerId(event), + pointer, + rect, + }), + ); clearCanvasFocus(); return; } @@ -262,57 +223,21 @@ export function useImageCanvasStageInteractions({ const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current; - const nextSelectedIds = isMultiSelectGesture - ? selectedLayerIds.includes(layer.id) - ? selectedLayerIds.length > 1 - ? selectedLayerIds.filter((layerId) => layerId !== layer.id) - : [layer.id] - : [...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', + const layerDragStart = createLayerDragStart({ + layer, + layers, + selectedLayerIds, + isMultiSelectGesture, pointerId: getPointerId(event), - layerId: layer.id, - layerIds: dragLayerIds, - startClientX: pointer.x, - startClientY: pointer.y, - startLayerX: layer.x, - startLayerY: layer.y, - startLayers, - startScale: viewport.scale, - }; + pointer, + viewportScale: viewport.scale, + }); + setSelectedLayerId(layer.id); + setSelectedLayerIds(layerDragStart.selectedLayerIds); + setGenerateDialog((currentDialog) => + updateGenerateDialogForLayerPointerDown(currentDialog, layer.id), + ); + dragStateRef.current = layerDragStart.dragState; }, [ canvasViewportRef, @@ -358,15 +283,7 @@ export function useImageCanvasStageInteractions({ setSelectedLayerId(layer.id); setSelectedLayerIds([layer.id]); setGenerateDialog((currentDialog) => - currentDialog?.mode === 'generate' || - currentDialog?.mode === 'spec' || - currentDialog?.mode === 'character' || - currentDialog?.mode === 'icon' - ? { - ...currentDialog, - composerOpen: false, - } - : currentDialog, + updateGenerateDialogForLayerClick(currentDialog), ); onCloseImageContextMenu(); }, @@ -404,16 +321,12 @@ export function useImageCanvasStageInteractions({ const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); activateCanvasGenerationDialog(dialog); - dragStateRef.current = { - kind: 'generation-frame', - dialogId: dialog.id, + dragStateRef.current = createGenerationFrameDragState({ + dialog, pointerId: getPointerId(event), - startClientX: pointer.x, - startClientY: pointer.y, - startFrameX: dialog.placeholder.x, - startFrameY: dialog.placeholder.y, - startScale: viewport.scale, - }; + pointer, + viewportScale: viewport.scale, + }); }, [ activateCanvasGenerationDialog, @@ -430,15 +343,12 @@ export function useImageCanvasStageInteractions({ event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); - dragStateRef.current = { - kind: 'minimap', + dragStateRef.current = createMinimapDragState({ pointerId: getPointerId(event), - startClientX: pointer.x, - startClientY: pointer.y, - startViewport: { ...viewport }, + pointer, + viewport, minimapScale, - moved: false, - }; + }); }, [canvasViewportRef, minimapScale, viewport], ); @@ -448,20 +358,22 @@ export function useImageCanvasStageInteractions({ if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { event.preventDefault(); const rect = canvasViewportRef.current?.getBoundingClientRect(); - const currentX = event.clientX - (rect?.left ?? 0); - const currentY = event.clientY - (rect?.top ?? 0); + const currentPoint = getCanvasPointFromPointer({ + pointer: getPointerClient(event), + rect, + }); setCanvasMarquee((currentMarquee) => currentMarquee ? { ...currentMarquee, - currentX, - currentY, + currentX: currentPoint.x, + currentY: currentPoint.y, } : null, ); const selectedIds = selectLayersInsideMarquee({ marquee: canvasMarquee, - currentPoint: { x: currentX, y: currentY }, + currentPoint, layers, viewport, }); @@ -507,13 +419,12 @@ export function useImageCanvasStageInteractions({ if (dragState.kind === 'minimap') { const pointer = getPointerClient(event); - const deltaX = pointer.x - dragState.startClientX; - const deltaY = pointer.y - dragState.startClientY; - if (!dragState.moved && Math.hypot(deltaX, deltaY) >= 2) { - dragState.moved = true; + const nextDragState = updateMinimapDragMovement(dragState, pointer); + if (nextDragState !== dragState) { + dragStateRef.current = nextDragState; } - if (dragState.moved) { - updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y); + if (nextDragState.moved) { + updateViewportFromMinimapDrag(nextDragState, pointer.x, pointer.y); } return; }