抽出编辑器舞台交互状态模型
新增 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` 巨型集成测试,新增 `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。
|
||||
|
||||
@@ -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,
|
||||
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<HTMLDivElement | null>;
|
||||
activeTool: CanvasTool;
|
||||
@@ -70,51 +79,6 @@ type UseImageCanvasStageInteractionsOptions = {
|
||||
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({
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user