拆分图片画布舞台交互

新增画布舞台交互 hook,承接选择、框选、拖拽、平移和小地图 pointer 状态机

更新历史恢复清理入口,撤销重做时统一重置舞台交互状态

补充舞台交互 hook 测试并更新前端拆分文档和 TRACKING 记录
This commit is contained in:
2026-06-17 10:04:32 +08:00
parent 31cc1f0473
commit 31da3b2fa2
7 changed files with 1307 additions and 429 deletions

View File

@@ -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。

View File

@@ -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`

View File

@@ -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;

View File

@@ -108,12 +108,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
resetters: {
setHoveredLayerId: () => {},
setMetadataLayer: () => {},
setCanvasMarquee: () => {},
setSnapGuide: () => {},
setImageContextMenu: () => {},
setContextMenu: () => {},
setIsPanning: () => {},
clearDragState: onClearDrag,
resetCanvasInteractionState: onClearDrag,
},
});

View File

@@ -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],
);

View File

@@ -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,
);
});
});

View 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,
],
);
}