拆分图片画布舞台交互
新增画布舞台交互 hook,承接选择、框选、拖拽、平移和小地图 pointer 状态机 更新历史恢复清理入口,撤销重做时统一重置舞台交互状态 补充舞台交互 hook 测试并更新前端拆分文档和 TRACKING 记录
This commit is contained in:
@@ -133,3 +133,4 @@
|
|||||||
- 2026-06-17 上传鉴权回归修正:普通素材上传入口在未登录时先打开 `账号入口`,不再先弹系统文件选择器;登录后用户再次点击上传即可打开文件选择器,避免浏览器拦截登录后异步触发的系统选择器。拖拽 / 已选中文件的续传逻辑仍保留,角色 / 图标生成参考图仍作为本地引用上传,不强制登录。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,`画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;未登录点击侧栏上传直接弹登录,不出现文件选择器;登录后再次点击上传可以打开文件选择器并上传成功,素材计数从 4 增至 5,`AI画布工具栏` 保持可见。
|
- 2026-06-17 上传鉴权回归修正:普通素材上传入口在未登录时先打开 `账号入口`,不再先弹系统文件选择器;登录后用户再次点击上传即可打开文件选择器,避免浏览器拦截登录后异步触发的系统选择器。拖拽 / 已选中文件的续传逻辑仍保留,角色 / 图标生成参考图仍作为本地引用上传,不强制登录。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,`画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;未登录点击侧栏上传直接弹登录,不出现文件选择器;登录后再次点击上传可以打开文件选择器并上传成功,素材计数从 4 增至 5,`AI画布工具栏` 保持可见。
|
||||||
- 2026-06-17 前端拆分第十六阶段:新增 `useImageCanvasEditorChrome`,把项目标题 / 重命名、侧栏开关、当前工具、缩放菜单、背景设置、小地图和背景 HEX 状态从主视图抽出;主视图继续保留上传 / 生成 / 键盘 Escape 的跨工作流编排。新增 hook 单测覆盖重命名、鉴权登录、背景色输入、面板开关和工具状态;主视图从 2039 行降至 1966 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。
|
- 2026-06-17 前端拆分第十六阶段:新增 `useImageCanvasEditorChrome`,把项目标题 / 重命名、侧栏开关、当前工具、缩放菜单、背景设置、小地图和背景 HEX 状态从主视图抽出;主视图继续保留上传 / 生成 / 键盘 Escape 的跨工作流编排。新增 hook 单测覆盖重命名、鉴权登录、背景色输入、面板开关和工具状态;主视图从 2039 行降至 1966 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。
|
||||||
- 2026-06-17 前端拆分第十七阶段:新增 `useImageCanvasViewportControls`,把视口状态、画布尺寸、小地图投影、适合视图、中心缩放、滚轮语义、坐标换算和小地图移动从主视图抽出;主视图继续保留图层拖拽、框选、生成占位拖拽、上传 drop 和历史触发时机。验证命令:`npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏可见;普通滚轮不改变缩放,Ctrl 滚轮从 `100%` 到 `110%`;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,登录后控制台无前端 error。
|
- 2026-06-17 前端拆分第十七阶段:新增 `useImageCanvasViewportControls`,把视口状态、画布尺寸、小地图投影、适合视图、中心缩放、滚轮语义、坐标换算和小地图移动从主视图抽出;主视图继续保留图层拖拽、框选、生成占位拖拽、上传 drop 和历史触发时机。验证命令:`npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏可见;普通滚轮不改变缩放,Ctrl 滚轮从 `100%` 到 `110%`;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,登录后控制台无前端 error。
|
||||||
|
- 2026-06-17 前端拆分第十八阶段:新增 `useImageCanvasStageInteractions`,把画布舞台 pointer 状态机、选择 / 框选、多选拖拽、生成占位拖拽、抓手 / Space 临时抓手 / 中键平移、小地图 click / drag 分流和吸附线状态从主视图抽出;主视图继续保留上传 drop、右键菜单、生成提交、项目持久化和工具栏动作分流。新增 hook 单测覆盖多选拖拽、框选、临时抓手、生成占位和小地图分流;主视图从 1802 行降至 1452 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新和未登录上传均弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏保持可见;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;普通滚轮不改变缩放,Ctrl 滚轮从 `146%` 到 `161%`;抓手 / 文字 / 选择工具可连续切换;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,关闭对话框后占位图保留,登录后控制台无前端 error。
|
||||||
|
|||||||
@@ -153,14 +153,21 @@
|
|||||||
- 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。
|
- 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。
|
||||||
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
|
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
|
||||||
|
|
||||||
|
## 第十八阶段模块
|
||||||
|
|
||||||
|
- `useImageCanvasStageInteractions.ts`
|
||||||
|
- 承载画布舞台 pointer 状态机:选择 / 框选、多选图层拖拽、生成占位框拖拽、抓手 / Space 临时抓手 / 中键平移、小地图 click / drag 分流和吸附线状态。
|
||||||
|
- 主视图继续保留原生文件 / 素材 drop、右键菜单定位、上传工作流、生成提交、项目持久化和工具栏动作分流;舞台 hook 只接收这些能力需要的回调,不反向读取路由、API 或素材库状态。
|
||||||
|
- 该 hook 用独立单测覆盖多选拖拽、框选、临时抓手、生成占位拖拽和小地图 click / drag 分流;主视图 DOM 测试继续覆盖真实组件路径和历史上容易回退的浏览器级交互。
|
||||||
|
|
||||||
## 后续阶段
|
## 后续阶段
|
||||||
|
|
||||||
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
||||||
- 生成对象定位、图层 / 生成占位 / 框选 pointer 事件、素材入画布、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
|
- 素材入画布、原生文件 drop、右键菜单定位、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
|
||||||
|
|
||||||
## 验证计划
|
## 验证计划
|
||||||
|
|
||||||
- `npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
- `npm run test -- src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
||||||
- `npm run typecheck`
|
- `npm run typecheck`
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type DragEvent as ReactDragEvent,
|
type DragEvent as ReactDragEvent,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -17,12 +16,6 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
|||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||||
import {
|
|
||||||
moveGenerationFrameFromDrag,
|
|
||||||
moveLayersFromDrag,
|
|
||||||
moveViewportFromPan,
|
|
||||||
selectLayersInsideMarquee,
|
|
||||||
} from './ImageCanvasInteractionModel';
|
|
||||||
import {
|
import {
|
||||||
getCanvasLayersByIds,
|
getCanvasLayersByIds,
|
||||||
resolveContextTargetLayerIds,
|
resolveContextTargetLayerIds,
|
||||||
@@ -53,15 +46,11 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AssetPointerDragState,
|
AssetPointerDragState,
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
CanvasGenerationDialogState,
|
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
CanvasMarqueeState,
|
|
||||||
CanvasTool,
|
CanvasTool,
|
||||||
CanvasViewport,
|
CanvasViewport,
|
||||||
DragState,
|
|
||||||
EditorAsset,
|
EditorAsset,
|
||||||
ImageContextMenuState,
|
ImageContextMenuState,
|
||||||
SnapGuide,
|
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
import { useCanvasHistory } from './useCanvasHistory';
|
import { useCanvasHistory } from './useCanvasHistory';
|
||||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||||
@@ -71,6 +60,7 @@ import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
|||||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||||
|
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
||||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||||
import {
|
import {
|
||||||
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
||||||
@@ -89,64 +79,18 @@ function isEditableTarget(event: KeyboardEvent) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>) {
|
|
||||||
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 ImageCanvasEditorView() {
|
export function ImageCanvasEditorView() {
|
||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStateRef = useRef<DragState | null>(null);
|
|
||||||
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
||||||
const authUiRef = useRef(authUi);
|
const authUiRef = useRef(authUi);
|
||||||
const isShiftPressedRef = useRef(false);
|
|
||||||
const layerCounterRef = useRef(0);
|
const layerCounterRef = useRef(0);
|
||||||
const layersRef = useRef<CanvasLayer[]>([]);
|
const layersRef = useRef<CanvasLayer[]>([]);
|
||||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||||
|
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
@@ -157,15 +101,9 @@ export function ImageCanvasEditorView() {
|
|||||||
);
|
);
|
||||||
const suppressAssetClickRef = useRef(false);
|
const suppressAssetClickRef = useRef(false);
|
||||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||||
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
||||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
|
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
|
||||||
const [isSpacePanning, setIsSpacePanning] = useState(false);
|
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
|
||||||
const [snapGuide, setSnapGuide] = useState<SnapGuide | null>(null);
|
|
||||||
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||||
const [imageContextMenu, setImageContextMenu] =
|
const [imageContextMenu, setImageContextMenu] =
|
||||||
useState<ImageContextMenuState | null>(null);
|
useState<ImageContextMenuState | null>(null);
|
||||||
@@ -333,7 +271,6 @@ export function ImageCanvasEditorView() {
|
|||||||
assetsRef.current = assets;
|
assetsRef.current = assets;
|
||||||
}, [assets]);
|
}, [assets]);
|
||||||
|
|
||||||
const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool;
|
|
||||||
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
||||||
setSelectedLayerId(null);
|
setSelectedLayerId(null);
|
||||||
setSelectedLayerIds([]);
|
setSelectedLayerIds([]);
|
||||||
@@ -442,21 +379,14 @@ export function ImageCanvasEditorView() {
|
|||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const clearHistoryDragState = useCallback(() => {
|
|
||||||
dragStateRef.current = null;
|
|
||||||
}, []);
|
|
||||||
const canvasHistoryResetters = useMemo(
|
const canvasHistoryResetters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
setHoveredLayerId,
|
setHoveredLayerId,
|
||||||
setMetadataLayer,
|
setMetadataLayer,
|
||||||
setCanvasMarquee,
|
resetCanvasInteractionState: () =>
|
||||||
setSnapGuide,
|
resetCanvasInteractionStateRef.current(),
|
||||||
setImageContextMenu,
|
|
||||||
setContextMenu,
|
|
||||||
setIsPanning,
|
|
||||||
clearDragState: clearHistoryDragState,
|
|
||||||
}),
|
}),
|
||||||
[clearHistoryDragState],
|
[],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
canUndo,
|
canUndo,
|
||||||
@@ -704,6 +634,46 @@ export function ImageCanvasEditorView() {
|
|||||||
setImageContextMenu(null);
|
setImageContextMenu(null);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]);
|
}, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]);
|
||||||
|
const {
|
||||||
|
canvasMarquee,
|
||||||
|
isPanning,
|
||||||
|
snapGuide,
|
||||||
|
effectiveTool,
|
||||||
|
setIsSpacePanning,
|
||||||
|
setShiftPressed,
|
||||||
|
clearActiveInteraction,
|
||||||
|
handleCanvasPointerDown,
|
||||||
|
handleLayerPointerDown,
|
||||||
|
handleLayerClick,
|
||||||
|
handleGenerationFramePointerDown,
|
||||||
|
handleMinimapPointerDown,
|
||||||
|
handlePointerMove,
|
||||||
|
finishDrag,
|
||||||
|
} = useImageCanvasStageInteractions({
|
||||||
|
canvasViewportRef,
|
||||||
|
activeTool,
|
||||||
|
layers,
|
||||||
|
setLayers,
|
||||||
|
viewport,
|
||||||
|
setViewport,
|
||||||
|
selectedLayerIds,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
generateDialog,
|
||||||
|
setGenerateDialog,
|
||||||
|
isPickingCharacterSpecFromCanvas,
|
||||||
|
isPickingIconSpecFromCanvas,
|
||||||
|
clearCanvasFocus,
|
||||||
|
pickCharacterSpecFromLayer,
|
||||||
|
pickIconSpecFromLayer,
|
||||||
|
activateCanvasGenerationDialog,
|
||||||
|
updateCanvasGenerationDialogById,
|
||||||
|
moveViewportFromMinimapPointer,
|
||||||
|
updateViewportFromMinimapDrag,
|
||||||
|
minimapScale: minimapModel?.scale ?? 1,
|
||||||
|
onCloseImageContextMenu: () => setImageContextMenu(null),
|
||||||
|
});
|
||||||
|
resetCanvasInteractionStateRef.current = clearActiveInteraction;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -721,7 +691,7 @@ export function ImageCanvasEditorView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'Shift') {
|
if (event.key === 'Shift') {
|
||||||
isShiftPressedRef.current = true;
|
setShiftPressed(true);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||||
@@ -796,7 +766,7 @@ export function ImageCanvasEditorView() {
|
|||||||
};
|
};
|
||||||
const handleKeyUp = (event: KeyboardEvent) => {
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Shift') {
|
if (event.key === 'Shift') {
|
||||||
isShiftPressedRef.current = false;
|
setShiftPressed(false);
|
||||||
}
|
}
|
||||||
if (event.code !== 'Space') {
|
if (event.code !== 'Space') {
|
||||||
return;
|
return;
|
||||||
@@ -811,7 +781,13 @@ export function ImageCanvasEditorView() {
|
|||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
};
|
};
|
||||||
}, [closeEditorChromePanels, redoCanvasChange, undoCanvasChange]);
|
}, [
|
||||||
|
closeEditorChromePanels,
|
||||||
|
redoCanvasChange,
|
||||||
|
setIsSpacePanning,
|
||||||
|
setShiftPressed,
|
||||||
|
undoCanvasChange,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blockBrowserZoom = (event: WheelEvent) => {
|
const blockBrowserZoom = (event: WheelEvent) => {
|
||||||
@@ -928,56 +904,6 @@ export function ImageCanvasEditorView() {
|
|||||||
|
|
||||||
deleteLayerByIdRef.current = deleteLayerById;
|
deleteLayerByIdRef.current = deleteLayerById;
|
||||||
|
|
||||||
const startPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const pointer = getPointerClient(event);
|
|
||||||
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
||||||
setIsPanning(true);
|
|
||||||
dragStateRef.current = {
|
|
||||||
kind: 'pan',
|
|
||||||
pointerId: getPointerId(event),
|
|
||||||
startClientX: pointer.x,
|
|
||||||
startClientY: pointer.y,
|
|
||||||
startViewport: viewport,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCanvasPointerDown = (
|
|
||||||
event: ReactPointerEvent<HTMLDivElement>,
|
|
||||||
) => {
|
|
||||||
const button = getPointerButton(event);
|
|
||||||
if (button !== 0 || effectiveTool === 'hand') {
|
|
||||||
startPan(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (button !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (
|
|
||||||
effectiveTool === 'select' &&
|
|
||||||
(event.target === event.currentTarget ||
|
|
||||||
target.classList.contains('image-canvas-editor__world'))
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
|
||||||
const startX = event.clientX - (rect?.left ?? 0);
|
|
||||||
const startY = event.clientY - (rect?.top ?? 0);
|
|
||||||
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
||||||
setCanvasMarquee({
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
startX,
|
|
||||||
startY,
|
|
||||||
currentX: startX,
|
|
||||||
currentY: startY,
|
|
||||||
});
|
|
||||||
clearCanvasFocus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearCanvasFocus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCanvasDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
const handleCanvasDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||||
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
|
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1049,114 +975,6 @@ export function ImageCanvasEditorView() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLayerPointerDown = (
|
|
||||||
event: ReactPointerEvent<HTMLButtonElement>,
|
|
||||||
layer: CanvasLayer,
|
|
||||||
) => {
|
|
||||||
const button = getPointerButton(event);
|
|
||||||
if (button === 1 || effectiveTool === 'hand') {
|
|
||||||
event.stopPropagation();
|
|
||||||
startPan(event as unknown as ReactPointerEvent<HTMLDivElement>);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (button !== 0) {
|
|
||||||
event.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isPickingCharacterSpecFromCanvas &&
|
|
||||||
generateDialog?.mode === 'character'
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
pickCharacterSpecFromLayer(layer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
pickIconSpecFromLayer(layer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
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',
|
|
||||||
pointerId: getPointerId(event),
|
|
||||||
layerId: layer.id,
|
|
||||||
layerIds: dragLayerIds,
|
|
||||||
startClientX: pointer.x,
|
|
||||||
startClientY: pointer.y,
|
|
||||||
startLayerX: layer.x,
|
|
||||||
startLayerY: layer.y,
|
|
||||||
startLayers,
|
|
||||||
startScale: viewport.scale,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLayerClick = (
|
|
||||||
event: ReactMouseEvent<HTMLButtonElement>,
|
|
||||||
layer: CanvasLayer,
|
|
||||||
) => {
|
|
||||||
// 测试环境和辅助技术可能只触发 click;
|
|
||||||
// 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。
|
|
||||||
event.stopPropagation();
|
|
||||||
if (isPickingCharacterSpecFromCanvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPickingIconSpecFromCanvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.shiftKey || isShiftPressedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectSingleLayer(layer.id);
|
|
||||||
setImageContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLayerContextMenu = (
|
const handleLayerContextMenu = (
|
||||||
event: ReactMouseEvent<HTMLButtonElement>,
|
event: ReactMouseEvent<HTMLButtonElement>,
|
||||||
layer: CanvasLayer,
|
layer: CanvasLayer,
|
||||||
@@ -1183,176 +1001,8 @@ export function ImageCanvasEditorView() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerationFramePointerDown = (
|
|
||||||
event: ReactPointerEvent<HTMLDivElement>,
|
|
||||||
dialog: CanvasGenerationDialogState,
|
|
||||||
) => {
|
|
||||||
if (!dialog.placeholder) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const button = getPointerButton(event);
|
|
||||||
if (button === 1 || effectiveTool === 'hand') {
|
|
||||||
event.stopPropagation();
|
|
||||||
startPan(event as unknown as ReactPointerEvent<HTMLDivElement>);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (button !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const pointer = getPointerClient(event);
|
|
||||||
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
||||||
activateCanvasGenerationDialog(dialog);
|
|
||||||
dragStateRef.current = {
|
|
||||||
kind: 'generation-frame',
|
|
||||||
dialogId: dialog.id,
|
|
||||||
pointerId: getPointerId(event),
|
|
||||||
startClientX: pointer.x,
|
|
||||||
startClientY: pointer.y,
|
|
||||||
startFrameX: dialog.placeholder.x,
|
|
||||||
startFrameY: dialog.placeholder.y,
|
|
||||||
startScale: viewport.scale,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMinimapPointerDown = (
|
|
||||||
event: ReactPointerEvent<HTMLButtonElement>,
|
|
||||||
) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const pointer = getPointerClient(event);
|
|
||||||
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
||||||
dragStateRef.current = {
|
|
||||||
kind: 'minimap',
|
|
||||||
pointerId: getPointerId(event),
|
|
||||||
startClientX: pointer.x,
|
|
||||||
startClientY: pointer.y,
|
|
||||||
startViewport: { ...viewport },
|
|
||||||
minimapScale: minimapModel?.scale ?? 1,
|
|
||||||
moved: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
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);
|
|
||||||
setCanvasMarquee((currentMarquee) =>
|
|
||||||
currentMarquee
|
|
||||||
? {
|
|
||||||
...currentMarquee,
|
|
||||||
currentX,
|
|
||||||
currentY,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
const selectedIds = selectLayersInsideMarquee({
|
|
||||||
marquee: canvasMarquee,
|
|
||||||
currentPoint: { x: currentX, y: currentY },
|
|
||||||
layers,
|
|
||||||
viewport,
|
|
||||||
});
|
|
||||||
setSelectedLayerIds(selectedIds);
|
|
||||||
setSelectedLayerId(selectedIds[0] ?? null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragState = dragStateRef.current;
|
|
||||||
const pointerId = getPointerId(event);
|
|
||||||
if (
|
|
||||||
!dragState ||
|
|
||||||
(dragState.pointerId >= 0 &&
|
|
||||||
pointerId >= 0 &&
|
|
||||||
dragState.pointerId !== pointerId)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dragState.kind === 'pan') {
|
|
||||||
const pointer = getPointerClient(event);
|
|
||||||
setViewport(moveViewportFromPan(dragState, pointer));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dragState.kind === 'generation-frame') {
|
|
||||||
const pointer = getPointerClient(event);
|
|
||||||
const nextFramePoint = moveGenerationFrameFromDrag(dragState, pointer);
|
|
||||||
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
|
|
||||||
currentDialog.placeholder
|
|
||||||
? {
|
|
||||||
...currentDialog,
|
|
||||||
placeholder: {
|
|
||||||
...currentDialog.placeholder,
|
|
||||||
x: nextFramePoint.x,
|
|
||||||
y: nextFramePoint.y,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: currentDialog,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (dragState.moved) {
|
|
||||||
updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pointer = getPointerClient(event);
|
|
||||||
const movedLayers = moveLayersFromDrag({ dragState, layers, pointer });
|
|
||||||
if (!movedLayers) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSnapGuide(movedLayers.snapGuide);
|
|
||||||
setLayers(movedLayers.layers);
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
|
|
||||||
event.preventDefault();
|
|
||||||
setCanvasMarquee(null);
|
|
||||||
if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) {
|
|
||||||
canvasViewportRef.current.releasePointerCapture?.(event.pointerId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragState = dragStateRef.current;
|
|
||||||
const pointerId = getPointerId(event);
|
|
||||||
if (
|
|
||||||
dragState &&
|
|
||||||
(dragState.pointerId < 0 ||
|
|
||||||
pointerId < 0 ||
|
|
||||||
dragState.pointerId === pointerId)
|
|
||||||
) {
|
|
||||||
if (dragState.kind === 'minimap' && !dragState.moved) {
|
|
||||||
const pointer = getPointerClient(event);
|
|
||||||
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
|
||||||
}
|
|
||||||
dragStateRef.current = null;
|
|
||||||
setIsPanning(false);
|
|
||||||
setSnapGuide(null);
|
|
||||||
if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) {
|
|
||||||
canvasViewportRef.current.releasePointerCapture?.(event.pointerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const switchTool = (tool: CanvasTool) => {
|
const switchTool = (tool: CanvasTool) => {
|
||||||
dragStateRef.current = null;
|
clearActiveInteraction();
|
||||||
setIsPanning(false);
|
|
||||||
setSnapGuide(null);
|
|
||||||
if (tool === 'upload') {
|
if (tool === 'upload') {
|
||||||
requestUpload('asset');
|
requestUpload('asset');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -108,12 +108,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
|
|||||||
resetters: {
|
resetters: {
|
||||||
setHoveredLayerId: () => {},
|
setHoveredLayerId: () => {},
|
||||||
setMetadataLayer: () => {},
|
setMetadataLayer: () => {},
|
||||||
setCanvasMarquee: () => {},
|
resetCanvasInteractionState: onClearDrag,
|
||||||
setSnapGuide: () => {},
|
|
||||||
setImageContextMenu: () => {},
|
|
||||||
setContextMenu: () => {},
|
|
||||||
setIsPanning: () => {},
|
|
||||||
clearDragState: onClearDrag,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ import type {
|
|||||||
CanvasGenerationDialogState,
|
CanvasGenerationDialogState,
|
||||||
CanvasHistorySnapshot,
|
CanvasHistorySnapshot,
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
CanvasMarqueeState,
|
|
||||||
CanvasContextMenuState,
|
|
||||||
CanvasViewport,
|
CanvasViewport,
|
||||||
GenerateDialogState,
|
GenerateDialogState,
|
||||||
ImageContextMenuState,
|
|
||||||
SnapGuide,
|
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
type CanvasHistoryRefs = {
|
type CanvasHistoryRefs = {
|
||||||
@@ -36,12 +32,7 @@ type CanvasHistorySetters = {
|
|||||||
type CanvasHistoryResetters = {
|
type CanvasHistoryResetters = {
|
||||||
setHoveredLayerId: (layerId: string | null) => void;
|
setHoveredLayerId: (layerId: string | null) => void;
|
||||||
setMetadataLayer: (layer: CanvasLayer | null) => void;
|
setMetadataLayer: (layer: CanvasLayer | null) => void;
|
||||||
setCanvasMarquee: (marquee: CanvasMarqueeState | null) => void;
|
resetCanvasInteractionState: () => void;
|
||||||
setSnapGuide: (guide: SnapGuide | null) => void;
|
|
||||||
setImageContextMenu: (menu: ImageContextMenuState | null) => void;
|
|
||||||
setContextMenu: (menu: CanvasContextMenuState | null) => void;
|
|
||||||
setIsPanning: (isPanning: boolean) => void;
|
|
||||||
clearDragState: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
|
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
|
||||||
@@ -104,12 +95,7 @@ export function useCanvasHistory({
|
|||||||
setters.setSelectedLayerIds([...snapshot.selectedLayerIds]);
|
setters.setSelectedLayerIds([...snapshot.selectedLayerIds]);
|
||||||
resetters.setHoveredLayerId(null);
|
resetters.setHoveredLayerId(null);
|
||||||
resetters.setMetadataLayer(null);
|
resetters.setMetadataLayer(null);
|
||||||
resetters.setCanvasMarquee(null);
|
resetters.resetCanvasInteractionState();
|
||||||
resetters.setSnapGuide(null);
|
|
||||||
resetters.setImageContextMenu(null);
|
|
||||||
resetters.setContextMenu(null);
|
|
||||||
resetters.setIsPanning(false);
|
|
||||||
resetters.clearDragState();
|
|
||||||
},
|
},
|
||||||
[resetters, setters],
|
[resetters, setters],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,656 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
CanvasViewport,
|
||||||
|
GenerateDialogState,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
||||||
|
|
||||||
|
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: 100,
|
||||||
|
width: 100,
|
||||||
|
height: 80,
|
||||||
|
originalWidth: 100,
|
||||||
|
originalHeight: 80,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElementBox(
|
||||||
|
element: HTMLElement,
|
||||||
|
box: { width: number; height: number; left?: number; top?: number },
|
||||||
|
) {
|
||||||
|
Object.defineProperty(element, 'clientWidth', {
|
||||||
|
configurable: true,
|
||||||
|
value: box.width,
|
||||||
|
});
|
||||||
|
Object.defineProperty(element, 'clientHeight', {
|
||||||
|
configurable: true,
|
||||||
|
value: box.height,
|
||||||
|
});
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
x: box.left ?? 0,
|
||||||
|
y: box.top ?? 0,
|
||||||
|
left: box.left ?? 0,
|
||||||
|
top: box.top ?? 0,
|
||||||
|
right: (box.left ?? 0) + box.width,
|
||||||
|
bottom: (box.top ?? 0) + box.height,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as DOMRect;
|
||||||
|
element.setPointerCapture = vi.fn();
|
||||||
|
element.releasePointerCapture = vi.fn();
|
||||||
|
element.hasPointerCapture = vi.fn(() => true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPointerEvent<TElement extends HTMLElement>(
|
||||||
|
currentTarget: TElement,
|
||||||
|
options: {
|
||||||
|
pointerId: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
button?: number;
|
||||||
|
buttons?: number;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
target?: EventTarget;
|
||||||
|
},
|
||||||
|
): ReactPointerEvent<TElement> {
|
||||||
|
return {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
currentTarget,
|
||||||
|
target: options.target ?? currentTarget,
|
||||||
|
pointerId: options.pointerId,
|
||||||
|
clientX: options.clientX,
|
||||||
|
clientY: options.clientY,
|
||||||
|
button: options.button ?? 0,
|
||||||
|
buttons: options.buttons ?? 1,
|
||||||
|
shiftKey: options.shiftKey ?? false,
|
||||||
|
nativeEvent: {
|
||||||
|
pointerId: options.pointerId,
|
||||||
|
clientX: options.clientX,
|
||||||
|
clientY: options.clientY,
|
||||||
|
button: options.button ?? 0,
|
||||||
|
buttons: options.buttons ?? 1,
|
||||||
|
},
|
||||||
|
} as unknown as ReactPointerEvent<TElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asCanvasGenerationDialog(
|
||||||
|
dialog: GenerateDialogState | null,
|
||||||
|
): CanvasGenerationDialogState | null {
|
||||||
|
if (!dialog?.id || dialog.mode === 'edit') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return dialog as CanvasGenerationDialogState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageInteractionsHarness({
|
||||||
|
pickCharacterSpecFromLayer = vi.fn(),
|
||||||
|
pickIconSpecFromLayer = vi.fn(),
|
||||||
|
activateCanvasGenerationDialog = vi.fn(),
|
||||||
|
moveViewportFromMinimapPointer = vi.fn(),
|
||||||
|
updateViewportFromMinimapDrag = vi.fn(),
|
||||||
|
}: {
|
||||||
|
pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void;
|
||||||
|
pickIconSpecFromLayer?: (layer: CanvasLayer) => void;
|
||||||
|
activateCanvasGenerationDialog?: (
|
||||||
|
dialog: CanvasGenerationDialogState,
|
||||||
|
) => void;
|
||||||
|
moveViewportFromMinimapPointer?: (clientX: number, clientY: number) => void;
|
||||||
|
updateViewportFromMinimapDrag?: (
|
||||||
|
dragState: {
|
||||||
|
kind: 'minimap';
|
||||||
|
pointerId: number;
|
||||||
|
moved: boolean;
|
||||||
|
},
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const worldRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select');
|
||||||
|
const [layers, setLayers] = useState<CanvasLayer[]>([
|
||||||
|
createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }),
|
||||||
|
createLayer({ id: 'second', x: 220, y: 60, zIndex: 2 }),
|
||||||
|
]);
|
||||||
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
||||||
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||||
|
const [generateDialog, setGenerateDialog] =
|
||||||
|
useState<GenerateDialogState | null>({
|
||||||
|
id: 'dialog-1',
|
||||||
|
mode: 'generate',
|
||||||
|
prompt: '生成',
|
||||||
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
|
placeholder: {
|
||||||
|
x: 300,
|
||||||
|
y: 200,
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
originalWidth: 160,
|
||||||
|
originalHeight: 120,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [clearCount, setClearCount] = useState(0);
|
||||||
|
const [imageMenuCloseCount, setImageMenuCloseCount] = useState(0);
|
||||||
|
|
||||||
|
const interaction = useImageCanvasStageInteractions({
|
||||||
|
canvasViewportRef,
|
||||||
|
activeTool,
|
||||||
|
layers,
|
||||||
|
setLayers,
|
||||||
|
viewport,
|
||||||
|
setViewport,
|
||||||
|
selectedLayerIds,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
generateDialog,
|
||||||
|
setGenerateDialog,
|
||||||
|
isPickingCharacterSpecFromCanvas: false,
|
||||||
|
isPickingIconSpecFromCanvas: false,
|
||||||
|
clearCanvasFocus: () => {
|
||||||
|
setSelectedLayerId(null);
|
||||||
|
setSelectedLayerIds([]);
|
||||||
|
setClearCount((currentCount) => currentCount + 1);
|
||||||
|
},
|
||||||
|
pickCharacterSpecFromLayer,
|
||||||
|
pickIconSpecFromLayer,
|
||||||
|
activateCanvasGenerationDialog,
|
||||||
|
updateCanvasGenerationDialogById: (dialogId, updater) => {
|
||||||
|
setGenerateDialog((currentDialog) => {
|
||||||
|
if (
|
||||||
|
!currentDialog?.id ||
|
||||||
|
currentDialog?.id !== dialogId ||
|
||||||
|
currentDialog.mode === 'edit'
|
||||||
|
) {
|
||||||
|
return currentDialog;
|
||||||
|
}
|
||||||
|
return updater(currentDialog as CanvasGenerationDialogState);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
moveViewportFromMinimapPointer,
|
||||||
|
updateViewportFromMinimapDrag: (dragState, clientX, clientY) =>
|
||||||
|
updateViewportFromMinimapDrag(
|
||||||
|
{
|
||||||
|
kind: dragState.kind,
|
||||||
|
pointerId: dragState.pointerId,
|
||||||
|
moved: dragState.moved,
|
||||||
|
},
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
),
|
||||||
|
minimapScale: 0.5,
|
||||||
|
onCloseImageContextMenu: () =>
|
||||||
|
setImageMenuCloseCount((currentCount) => currentCount + 1),
|
||||||
|
});
|
||||||
|
const activeGenerationDialog = asCanvasGenerationDialog(generateDialog);
|
||||||
|
const getViewportElement = () => {
|
||||||
|
const element = canvasViewportRef.current;
|
||||||
|
if (!element) {
|
||||||
|
throw new Error('canvas viewport is not ready');
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const firstLayer = layers[0];
|
||||||
|
const secondLayer = layers[1];
|
||||||
|
if (!firstLayer || !secondLayer) {
|
||||||
|
throw new Error('stage interaction test layers are not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref={(element) => {
|
||||||
|
canvasViewportRef.current = element;
|
||||||
|
if (element) {
|
||||||
|
setElementBox(element, { width: 640, height: 480 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid="viewport"
|
||||||
|
onPointerDown={interaction.handleCanvasPointerDown}
|
||||||
|
onPointerMove={interaction.handlePointerMove}
|
||||||
|
onPointerUp={interaction.finishDrag}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={worldRef}
|
||||||
|
className="image-canvas-editor__world"
|
||||||
|
data-testid="world"
|
||||||
|
/>
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<button
|
||||||
|
key={layer.id}
|
||||||
|
type="button"
|
||||||
|
data-testid={`layer-${layer.id}`}
|
||||||
|
onClick={(event) => interaction.handleLayerClick(event, layer)}
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
interaction.handleLayerPointerDown(event, layer)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{layer.id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{activeGenerationDialog?.placeholder ? (
|
||||||
|
<div
|
||||||
|
data-testid="generation-frame"
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
interaction.handleGenerationFramePointerDown(
|
||||||
|
event,
|
||||||
|
activeGenerationDialog,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="minimap"
|
||||||
|
onPointerDown={interaction.handleMinimapPointerDown}
|
||||||
|
>
|
||||||
|
小地图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span data-testid="selection">
|
||||||
|
{selectedLayerId ?? '-'}:{selectedLayerIds.join(',')}
|
||||||
|
</span>
|
||||||
|
<span data-testid="layers">
|
||||||
|
{layers
|
||||||
|
.map((layer) => `${layer.id}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`)
|
||||||
|
.join('|')}
|
||||||
|
</span>
|
||||||
|
<span data-testid="viewport-state">
|
||||||
|
{viewport.x.toFixed(1)},{viewport.y.toFixed(1)},{viewport.scale}
|
||||||
|
</span>
|
||||||
|
<span data-testid="marquee">
|
||||||
|
{interaction.canvasMarquee
|
||||||
|
? `${interaction.canvasMarquee.startX},${interaction.canvasMarquee.currentX}`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
<span data-testid="panning">{String(interaction.isPanning)}</span>
|
||||||
|
<span data-testid="tool">{interaction.effectiveTool}</span>
|
||||||
|
<span data-testid="snap">
|
||||||
|
{interaction.snapGuide?.vertical ?? interaction.snapGuide?.horizontal ?? '-'}
|
||||||
|
</span>
|
||||||
|
<span data-testid="dialog-position">
|
||||||
|
{generateDialog?.placeholder
|
||||||
|
? `${generateDialog.placeholder.x.toFixed(1)},${generateDialog.placeholder.y.toFixed(1)}`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
<span data-testid="dialog-open">
|
||||||
|
{String(generateDialog?.composerOpen ?? false)}
|
||||||
|
</span>
|
||||||
|
<span data-testid="clear-count">{clearCount}</span>
|
||||||
|
<span data-testid="image-menu-close-count">
|
||||||
|
{imageMenuCloseCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
interaction.setShiftPressed(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
按住 Shift
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
interaction.setIsSpacePanning(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
按住空格
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
interaction.setIsSpacePanning(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
松开空格
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
interaction.clearActiveInteraction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清理交互
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setActiveTool('hand')}>
|
||||||
|
切抓手
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handleLayerPointerDown(
|
||||||
|
createPointerEvent(event.currentTarget, {
|
||||||
|
pointerId: 1,
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 40,
|
||||||
|
}),
|
||||||
|
firstLayer,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接选第一层
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.setShiftPressed(true);
|
||||||
|
interaction.handleLayerPointerDown(
|
||||||
|
createPointerEvent(event.currentTarget, {
|
||||||
|
pointerId: 2,
|
||||||
|
clientX: 220,
|
||||||
|
clientY: 60,
|
||||||
|
}),
|
||||||
|
secondLayer,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接追加第二层
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handlePointerMove(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 2,
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 90,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接移动图层
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.finishDrag(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 2,
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 90,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接结束图层拖拽
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handleCanvasPointerDown(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 3,
|
||||||
|
clientX: 20,
|
||||||
|
clientY: 20,
|
||||||
|
target: worldRef.current ?? undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接开始框选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handlePointerMove(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 3,
|
||||||
|
clientX: 190,
|
||||||
|
clientY: 150,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接移动框选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.finishDrag(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 3,
|
||||||
|
clientX: 190,
|
||||||
|
clientY: 150,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接结束框选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handleCanvasPointerDown(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 4,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接开始平移
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handlePointerMove(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 4,
|
||||||
|
clientX: 130,
|
||||||
|
clientY: 125,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接移动平移
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
const frameElement = document.createElement('div');
|
||||||
|
const dialog = asCanvasGenerationDialog(generateDialog);
|
||||||
|
if (!dialog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
interaction.handleGenerationFramePointerDown(
|
||||||
|
createPointerEvent(frameElement, {
|
||||||
|
pointerId: 5,
|
||||||
|
clientX: 300,
|
||||||
|
clientY: 200,
|
||||||
|
}),
|
||||||
|
dialog,
|
||||||
|
);
|
||||||
|
interaction.handlePointerMove(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 5,
|
||||||
|
clientX: 340,
|
||||||
|
clientY: 230,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接拖生成占位
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handleMinimapPointerDown(
|
||||||
|
createPointerEvent(event.currentTarget, {
|
||||||
|
pointerId: 6,
|
||||||
|
clientX: 120,
|
||||||
|
clientY: 90,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
interaction.finishDrag(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 6,
|
||||||
|
clientX: 120,
|
||||||
|
clientY: 90,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接点击小地图
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
interaction.handleMinimapPointerDown(
|
||||||
|
createPointerEvent(event.currentTarget, {
|
||||||
|
pointerId: 7,
|
||||||
|
clientX: 120,
|
||||||
|
clientY: 90,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
interaction.handlePointerMove(
|
||||||
|
createPointerEvent(getViewportElement(), {
|
||||||
|
pointerId: 7,
|
||||||
|
clientX: 130,
|
||||||
|
clientY: 95,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接拖小地图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useImageCanvasStageInteractions', () => {
|
||||||
|
it('selects and drags multiple layers from stage pointer events', () => {
|
||||||
|
render(<StageInteractionsHarness />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接选第一层' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('selection').textContent).toBe('first:first');
|
||||||
|
expect(screen.getByTestId('dialog-open').textContent).toBe('false');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('selection').textContent).toBe(
|
||||||
|
'second:first,second',
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接移动图层' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain(
|
||||||
|
'first:70.0,60.0',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('layers').textContent).toContain(
|
||||||
|
'second:250.0,80.0',
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('snap').textContent).toBe('-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles marquee, temporary hand mode, and generation frame dragging', () => {
|
||||||
|
const activateCanvasGenerationDialog = vi.fn();
|
||||||
|
render(
|
||||||
|
<StageInteractionsHarness
|
||||||
|
activateCanvasGenerationDialog={activateCanvasGenerationDialog}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接开始框选' }).click();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接移动框选' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('selection').textContent).toBe('first:first');
|
||||||
|
expect(screen.getByTestId('marquee').textContent).toBe('20,190');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接结束框选' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('marquee').textContent).toBe('-');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '按住空格' }).click();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接开始平移' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('tool').textContent).toBe('hand');
|
||||||
|
expect(screen.getByTestId('panning').textContent).toBe('true');
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接移动平移' }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('viewport-state').textContent).toBe('30.0,25.0,1');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '清理交互' }).click();
|
||||||
|
screen.getByRole('button', { name: '松开空格' }).click();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接拖生成占位' }).click();
|
||||||
|
});
|
||||||
|
expect(activateCanvasGenerationDialog).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByTestId('dialog-position').textContent).toBe(
|
||||||
|
'340.0,230.0',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes minimap click and drag movement', () => {
|
||||||
|
const moveViewportFromMinimapPointer = vi.fn();
|
||||||
|
const updateViewportFromMinimapDrag = vi.fn();
|
||||||
|
render(
|
||||||
|
<StageInteractionsHarness
|
||||||
|
moveViewportFromMinimapPointer={moveViewportFromMinimapPointer}
|
||||||
|
updateViewportFromMinimapDrag={updateViewportFromMinimapDrag}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接点击小地图' }).click();
|
||||||
|
});
|
||||||
|
expect(moveViewportFromMinimapPointer).toHaveBeenCalledWith(120, 90);
|
||||||
|
expect(updateViewportFromMinimapDrag).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: '直接拖小地图' }).click();
|
||||||
|
});
|
||||||
|
expect(updateViewportFromMinimapDrag).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ kind: 'minimap', pointerId: 7, moved: true }),
|
||||||
|
130,
|
||||||
|
95,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
583
src/components/image-editor/useImageCanvasStageInteractions.ts
Normal file
583
src/components/image-editor/useImageCanvasStageInteractions.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
type RefObject,
|
||||||
|
type SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
moveGenerationFrameFromDrag,
|
||||||
|
moveLayersFromDrag,
|
||||||
|
moveViewportFromPan,
|
||||||
|
selectLayersInsideMarquee,
|
||||||
|
} from './ImageCanvasInteractionModel';
|
||||||
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
CanvasMarqueeState,
|
||||||
|
CanvasTool,
|
||||||
|
CanvasViewport,
|
||||||
|
DragState,
|
||||||
|
GenerateDialogState,
|
||||||
|
SnapGuide,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
|
type CanvasPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseImageCanvasStageInteractionsOptions = {
|
||||||
|
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||||
|
activeTool: CanvasTool;
|
||||||
|
layers: CanvasLayer[];
|
||||||
|
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
|
||||||
|
selectedLayerIds: string[];
|
||||||
|
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
generateDialog: GenerateDialogState | null;
|
||||||
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||||
|
isPickingCharacterSpecFromCanvas: boolean;
|
||||||
|
isPickingIconSpecFromCanvas: boolean;
|
||||||
|
clearCanvasFocus: () => void;
|
||||||
|
pickCharacterSpecFromLayer: (layer: CanvasLayer) => void;
|
||||||
|
pickIconSpecFromLayer: (layer: CanvasLayer) => void;
|
||||||
|
activateCanvasGenerationDialog: (
|
||||||
|
dialog: CanvasGenerationDialogState,
|
||||||
|
) => void;
|
||||||
|
updateCanvasGenerationDialogById: (
|
||||||
|
dialogId: string,
|
||||||
|
updater: (
|
||||||
|
dialog: CanvasGenerationDialogState,
|
||||||
|
) => CanvasGenerationDialogState | null,
|
||||||
|
) => void;
|
||||||
|
moveViewportFromMinimapPointer: (clientX: number, clientY: number) => void;
|
||||||
|
updateViewportFromMinimapDrag: (
|
||||||
|
dragState: Extract<DragState, { kind: 'minimap' }>,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
) => void;
|
||||||
|
minimapScale: number;
|
||||||
|
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,
|
||||||
|
layers,
|
||||||
|
setLayers,
|
||||||
|
viewport,
|
||||||
|
setViewport,
|
||||||
|
selectedLayerIds,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
generateDialog,
|
||||||
|
setGenerateDialog,
|
||||||
|
isPickingCharacterSpecFromCanvas,
|
||||||
|
isPickingIconSpecFromCanvas,
|
||||||
|
clearCanvasFocus,
|
||||||
|
pickCharacterSpecFromLayer,
|
||||||
|
pickIconSpecFromLayer,
|
||||||
|
activateCanvasGenerationDialog,
|
||||||
|
updateCanvasGenerationDialogById,
|
||||||
|
moveViewportFromMinimapPointer,
|
||||||
|
updateViewportFromMinimapDrag,
|
||||||
|
minimapScale,
|
||||||
|
onCloseImageContextMenu,
|
||||||
|
}: UseImageCanvasStageInteractionsOptions) {
|
||||||
|
const dragStateRef = useRef<DragState | null>(null);
|
||||||
|
const isShiftPressedRef = useRef(false);
|
||||||
|
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isSpacePanning, setIsSpacePanning] = useState(false);
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const [snapGuide, setSnapGuide] = useState<SnapGuide | null>(null);
|
||||||
|
const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool;
|
||||||
|
|
||||||
|
const clearActiveInteraction = useCallback(() => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
setCanvasMarquee(null);
|
||||||
|
setIsPanning(false);
|
||||||
|
setSnapGuide(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setShiftPressed = useCallback((pressed: boolean) => {
|
||||||
|
isShiftPressedRef.current = pressed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startPan = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const pointer = getPointerClient(event);
|
||||||
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
|
setIsPanning(true);
|
||||||
|
dragStateRef.current = {
|
||||||
|
kind: 'pan',
|
||||||
|
pointerId: getPointerId(event),
|
||||||
|
startClientX: pointer.x,
|
||||||
|
startClientY: pointer.y,
|
||||||
|
startViewport: viewport,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[canvasViewportRef, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasPointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const button = getPointerButton(event);
|
||||||
|
if (button !== 0 || effectiveTool === 'hand') {
|
||||||
|
startPan(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
effectiveTool === 'select' &&
|
||||||
|
(event.target === event.currentTarget ||
|
||||||
|
target.classList.contains('image-canvas-editor__world'))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
||||||
|
const startX = event.clientX - (rect?.left ?? 0);
|
||||||
|
const startY = event.clientY - (rect?.top ?? 0);
|
||||||
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
|
setCanvasMarquee({
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
currentX: startX,
|
||||||
|
currentY: startY,
|
||||||
|
});
|
||||||
|
clearCanvasFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearCanvasFocus();
|
||||||
|
},
|
||||||
|
[canvasViewportRef, clearCanvasFocus, effectiveTool, startPan],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLayerPointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLButtonElement>, layer: CanvasLayer) => {
|
||||||
|
const button = getPointerButton(event);
|
||||||
|
if (button === 1 || effectiveTool === 'hand') {
|
||||||
|
event.stopPropagation();
|
||||||
|
startPan(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button !== 0) {
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isPickingCharacterSpecFromCanvas &&
|
||||||
|
generateDialog?.mode === 'character'
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
pickCharacterSpecFromLayer(layer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
pickIconSpecFromLayer(layer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
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',
|
||||||
|
pointerId: getPointerId(event),
|
||||||
|
layerId: layer.id,
|
||||||
|
layerIds: dragLayerIds,
|
||||||
|
startClientX: pointer.x,
|
||||||
|
startClientY: pointer.y,
|
||||||
|
startLayerX: layer.x,
|
||||||
|
startLayerY: layer.y,
|
||||||
|
startLayers,
|
||||||
|
startScale: viewport.scale,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[
|
||||||
|
canvasViewportRef,
|
||||||
|
effectiveTool,
|
||||||
|
generateDialog?.mode,
|
||||||
|
isPickingCharacterSpecFromCanvas,
|
||||||
|
isPickingIconSpecFromCanvas,
|
||||||
|
layers,
|
||||||
|
pickCharacterSpecFromLayer,
|
||||||
|
pickIconSpecFromLayer,
|
||||||
|
selectedLayerIds,
|
||||||
|
setGenerateDialog,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
startPan,
|
||||||
|
viewport.scale,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLayerClick = useCallback(
|
||||||
|
(event: ReactMouseEvent<HTMLButtonElement>, layer: CanvasLayer) => {
|
||||||
|
// 测试环境和辅助技术可能只触发 click;
|
||||||
|
// 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isPickingCharacterSpecFromCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPickingIconSpecFromCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.shiftKey || isShiftPressedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedLayerId(layer.id);
|
||||||
|
setSelectedLayerIds([layer.id]);
|
||||||
|
setGenerateDialog((currentDialog) =>
|
||||||
|
currentDialog?.mode === 'generate' ||
|
||||||
|
currentDialog?.mode === 'spec' ||
|
||||||
|
currentDialog?.mode === 'character' ||
|
||||||
|
currentDialog?.mode === 'icon'
|
||||||
|
? {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: false,
|
||||||
|
}
|
||||||
|
: currentDialog,
|
||||||
|
);
|
||||||
|
onCloseImageContextMenu();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isPickingCharacterSpecFromCanvas,
|
||||||
|
isPickingIconSpecFromCanvas,
|
||||||
|
onCloseImageContextMenu,
|
||||||
|
setGenerateDialog,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGenerationFramePointerDown = useCallback(
|
||||||
|
(
|
||||||
|
event: ReactPointerEvent<HTMLDivElement>,
|
||||||
|
dialog: CanvasGenerationDialogState,
|
||||||
|
) => {
|
||||||
|
if (!dialog.placeholder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = getPointerButton(event);
|
||||||
|
if (button === 1 || effectiveTool === 'hand') {
|
||||||
|
event.stopPropagation();
|
||||||
|
startPan(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const pointer = getPointerClient(event);
|
||||||
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
|
activateCanvasGenerationDialog(dialog);
|
||||||
|
dragStateRef.current = {
|
||||||
|
kind: 'generation-frame',
|
||||||
|
dialogId: dialog.id,
|
||||||
|
pointerId: getPointerId(event),
|
||||||
|
startClientX: pointer.x,
|
||||||
|
startClientY: pointer.y,
|
||||||
|
startFrameX: dialog.placeholder.x,
|
||||||
|
startFrameY: dialog.placeholder.y,
|
||||||
|
startScale: viewport.scale,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activateCanvasGenerationDialog,
|
||||||
|
canvasViewportRef,
|
||||||
|
effectiveTool,
|
||||||
|
startPan,
|
||||||
|
viewport.scale,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMinimapPointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const pointer = getPointerClient(event);
|
||||||
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
|
dragStateRef.current = {
|
||||||
|
kind: 'minimap',
|
||||||
|
pointerId: getPointerId(event),
|
||||||
|
startClientX: pointer.x,
|
||||||
|
startClientY: pointer.y,
|
||||||
|
startViewport: { ...viewport },
|
||||||
|
minimapScale,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[canvasViewportRef, minimapScale, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
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);
|
||||||
|
setCanvasMarquee((currentMarquee) =>
|
||||||
|
currentMarquee
|
||||||
|
? {
|
||||||
|
...currentMarquee,
|
||||||
|
currentX,
|
||||||
|
currentY,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const selectedIds = selectLayersInsideMarquee({
|
||||||
|
marquee: canvasMarquee,
|
||||||
|
currentPoint: { x: currentX, y: currentY },
|
||||||
|
layers,
|
||||||
|
viewport,
|
||||||
|
});
|
||||||
|
setSelectedLayerIds(selectedIds);
|
||||||
|
setSelectedLayerId(selectedIds[0] ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
const pointerId = getPointerId(event);
|
||||||
|
if (
|
||||||
|
!dragState ||
|
||||||
|
(dragState.pointerId >= 0 &&
|
||||||
|
pointerId >= 0 &&
|
||||||
|
dragState.pointerId !== pointerId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragState.kind === 'pan') {
|
||||||
|
const pointer = getPointerClient(event);
|
||||||
|
setViewport(moveViewportFromPan(dragState, pointer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragState.kind === 'generation-frame') {
|
||||||
|
const pointer = getPointerClient(event);
|
||||||
|
const nextFramePoint = moveGenerationFrameFromDrag(dragState, pointer);
|
||||||
|
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
|
||||||
|
currentDialog.placeholder
|
||||||
|
? {
|
||||||
|
...currentDialog,
|
||||||
|
placeholder: {
|
||||||
|
...currentDialog.placeholder,
|
||||||
|
x: nextFramePoint.x,
|
||||||
|
y: nextFramePoint.y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: currentDialog,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (dragState.moved) {
|
||||||
|
updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = getPointerClient(event);
|
||||||
|
const movedLayers = moveLayersFromDrag({ dragState, layers, pointer });
|
||||||
|
if (!movedLayers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSnapGuide(movedLayers.snapGuide);
|
||||||
|
setLayers(movedLayers.layers);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
canvasMarquee,
|
||||||
|
canvasViewportRef,
|
||||||
|
layers,
|
||||||
|
setLayers,
|
||||||
|
setSelectedLayerId,
|
||||||
|
setSelectedLayerIds,
|
||||||
|
setViewport,
|
||||||
|
updateCanvasGenerationDialogById,
|
||||||
|
updateViewportFromMinimapDrag,
|
||||||
|
viewport,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishDrag = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
|
||||||
|
event.preventDefault();
|
||||||
|
setCanvasMarquee(null);
|
||||||
|
if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
canvasViewportRef.current.releasePointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
const pointerId = getPointerId(event);
|
||||||
|
if (
|
||||||
|
dragState &&
|
||||||
|
(dragState.pointerId < 0 ||
|
||||||
|
pointerId < 0 ||
|
||||||
|
dragState.pointerId === pointerId)
|
||||||
|
) {
|
||||||
|
if (dragState.kind === 'minimap' && !dragState.moved) {
|
||||||
|
const pointer = getPointerClient(event);
|
||||||
|
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
||||||
|
}
|
||||||
|
dragStateRef.current = null;
|
||||||
|
setIsPanning(false);
|
||||||
|
setSnapGuide(null);
|
||||||
|
if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
canvasViewportRef.current.releasePointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canvasMarquee, canvasViewportRef, moveViewportFromMinimapPointer],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
canvasMarquee,
|
||||||
|
isPanning,
|
||||||
|
snapGuide,
|
||||||
|
effectiveTool,
|
||||||
|
setIsSpacePanning,
|
||||||
|
setShiftPressed,
|
||||||
|
clearActiveInteraction,
|
||||||
|
handleCanvasPointerDown,
|
||||||
|
handleLayerPointerDown,
|
||||||
|
handleLayerClick,
|
||||||
|
handleGenerationFramePointerDown,
|
||||||
|
handleMinimapPointerDown,
|
||||||
|
handlePointerMove,
|
||||||
|
finishDrag,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
canvasMarquee,
|
||||||
|
clearActiveInteraction,
|
||||||
|
effectiveTool,
|
||||||
|
finishDrag,
|
||||||
|
handleCanvasPointerDown,
|
||||||
|
handleGenerationFramePointerDown,
|
||||||
|
handleLayerClick,
|
||||||
|
handleLayerPointerDown,
|
||||||
|
handleMinimapPointerDown,
|
||||||
|
handlePointerMove,
|
||||||
|
isPanning,
|
||||||
|
setShiftPressed,
|
||||||
|
snapGuide,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user