拆分图片画布舞台交互
新增画布舞台交互 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 前端拆分第十六阶段:新增 `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 前端拆分第十八阶段:新增 `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 状态机。
|
||||
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
|
||||
|
||||
## 第十八阶段模块
|
||||
|
||||
- `useImageCanvasStageInteractions.ts`
|
||||
- 承载画布舞台 pointer 状态机:选择 / 框选、多选图层拖拽、生成占位框拖拽、抓手 / Space 临时抓手 / 中键平移、小地图 click / drag 分流和吸附线状态。
|
||||
- 主视图继续保留原生文件 / 素材 drop、右键菜单定位、上传工作流、生成提交、项目持久化和工具栏动作分流;舞台 hook 只接收这些能力需要的回调,不反向读取路由、API 或素材库状态。
|
||||
- 该 hook 用独立单测覆盖多选拖拽、框选、临时抓手、生成占位拖拽和小地图 click / drag 分流;主视图 DOM 测试继续覆盖真实组件路径和历史上容易回退的浏览器级交互。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- 后续可继续选择更高内聚的交互 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 check:encoding`
|
||||
- `git diff --check`
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -17,12 +16,6 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||
import {
|
||||
moveGenerationFrameFromDrag,
|
||||
moveLayersFromDrag,
|
||||
moveViewportFromPan,
|
||||
selectLayersInsideMarquee,
|
||||
} from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
getCanvasLayersByIds,
|
||||
resolveContextTargetLayerIds,
|
||||
@@ -53,15 +46,11 @@ import {
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasTool,
|
||||
CanvasViewport,
|
||||
DragState,
|
||||
EditorAsset,
|
||||
ImageContextMenuState,
|
||||
SnapGuide,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
@@ -71,6 +60,7 @@ import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||
import {
|
||||
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() {
|
||||
const authUi = useAuthUi();
|
||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
||||
const authUiRef = useRef(authUi);
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const layerCounterRef = useRef(0);
|
||||
const layersRef = useRef<CanvasLayer[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -157,15 +101,9 @@ export function ImageCanvasEditorView() {
|
||||
);
|
||||
const suppressAssetClickRef = useRef(false);
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
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 [imageContextMenu, setImageContextMenu] =
|
||||
useState<ImageContextMenuState | null>(null);
|
||||
@@ -333,7 +271,6 @@ export function ImageCanvasEditorView() {
|
||||
assetsRef.current = assets;
|
||||
}, [assets]);
|
||||
|
||||
const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool;
|
||||
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
@@ -442,21 +379,14 @@ export function ImageCanvasEditorView() {
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const clearHistoryDragState = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
}, []);
|
||||
const canvasHistoryResetters = useMemo(
|
||||
() => ({
|
||||
setHoveredLayerId,
|
||||
setMetadataLayer,
|
||||
setCanvasMarquee,
|
||||
setSnapGuide,
|
||||
setImageContextMenu,
|
||||
setContextMenu,
|
||||
setIsPanning,
|
||||
clearDragState: clearHistoryDragState,
|
||||
resetCanvasInteractionState: () =>
|
||||
resetCanvasInteractionStateRef.current(),
|
||||
}),
|
||||
[clearHistoryDragState],
|
||||
[],
|
||||
);
|
||||
const {
|
||||
canUndo,
|
||||
@@ -704,6 +634,46 @@ export function ImageCanvasEditorView() {
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
}, [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(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -721,7 +691,7 @@ export function ImageCanvasEditorView() {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Shift') {
|
||||
isShiftPressedRef.current = true;
|
||||
setShiftPressed(true);
|
||||
}
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
@@ -796,7 +766,7 @@ export function ImageCanvasEditorView() {
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
isShiftPressedRef.current = false;
|
||||
setShiftPressed(false);
|
||||
}
|
||||
if (event.code !== 'Space') {
|
||||
return;
|
||||
@@ -811,7 +781,13 @@ export function ImageCanvasEditorView() {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [closeEditorChromePanels, redoCanvasChange, undoCanvasChange]);
|
||||
}, [
|
||||
closeEditorChromePanels,
|
||||
redoCanvasChange,
|
||||
setIsSpacePanning,
|
||||
setShiftPressed,
|
||||
undoCanvasChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockBrowserZoom = (event: WheelEvent) => {
|
||||
@@ -928,56 +904,6 @@ export function ImageCanvasEditorView() {
|
||||
|
||||
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>) => {
|
||||
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
|
||||
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 = (
|
||||
event: ReactMouseEvent<HTMLButtonElement>,
|
||||
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) => {
|
||||
dragStateRef.current = null;
|
||||
setIsPanning(false);
|
||||
setSnapGuide(null);
|
||||
clearActiveInteraction();
|
||||
if (tool === 'upload') {
|
||||
requestUpload('asset');
|
||||
return;
|
||||
|
||||
@@ -108,12 +108,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
|
||||
resetters: {
|
||||
setHoveredLayerId: () => {},
|
||||
setMetadataLayer: () => {},
|
||||
setCanvasMarquee: () => {},
|
||||
setSnapGuide: () => {},
|
||||
setImageContextMenu: () => {},
|
||||
setContextMenu: () => {},
|
||||
setIsPanning: () => {},
|
||||
clearDragState: onClearDrag,
|
||||
resetCanvasInteractionState: onClearDrag,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,12 +5,8 @@ import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistorySnapshot,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasContextMenuState,
|
||||
CanvasViewport,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SnapGuide,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type CanvasHistoryRefs = {
|
||||
@@ -36,12 +32,7 @@ type CanvasHistorySetters = {
|
||||
type CanvasHistoryResetters = {
|
||||
setHoveredLayerId: (layerId: string | null) => void;
|
||||
setMetadataLayer: (layer: CanvasLayer | null) => void;
|
||||
setCanvasMarquee: (marquee: CanvasMarqueeState | null) => void;
|
||||
setSnapGuide: (guide: SnapGuide | null) => void;
|
||||
setImageContextMenu: (menu: ImageContextMenuState | null) => void;
|
||||
setContextMenu: (menu: CanvasContextMenuState | null) => void;
|
||||
setIsPanning: (isPanning: boolean) => void;
|
||||
clearDragState: () => void;
|
||||
resetCanvasInteractionState: () => void;
|
||||
};
|
||||
|
||||
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
|
||||
@@ -104,12 +95,7 @@ export function useCanvasHistory({
|
||||
setters.setSelectedLayerIds([...snapshot.selectedLayerIds]);
|
||||
resetters.setHoveredLayerId(null);
|
||||
resetters.setMetadataLayer(null);
|
||||
resetters.setCanvasMarquee(null);
|
||||
resetters.setSnapGuide(null);
|
||||
resetters.setImageContextMenu(null);
|
||||
resetters.setContextMenu(null);
|
||||
resetters.setIsPanning(false);
|
||||
resetters.clearDragState();
|
||||
resetters.resetCanvasInteractionState();
|
||||
},
|
||||
[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