From 31da3b2fa256d5bfe1c6e9f8ac6e6ecc0eb8b099 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 10:04:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E8=88=9E=E5=8F=B0=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画布舞台交互 hook,承接选择、框选、拖拽、平移和小地图 pointer 状态机 更新历史恢复清理入口,撤销重做时统一重置舞台交互状态 补充舞台交互 hook 测试并更新前端拆分文档和 TRACKING 记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 11 +- .../image-editor/ImageCanvasEditorView.tsx | 460 ++---------- .../image-editor/useCanvasHistory.test.tsx | 7 +- .../image-editor/useCanvasHistory.ts | 18 +- .../useImageCanvasStageInteractions.test.tsx | 656 ++++++++++++++++++ .../useImageCanvasStageInteractions.ts | 583 ++++++++++++++++ 7 files changed, 1307 insertions(+), 429 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasStageInteractions.test.tsx create mode 100644 src/components/image-editor/useImageCanvasStageInteractions.ts diff --git a/TRACKING.md b/TRACKING.md index d6f9b659..71ea404c 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 3d8267ed..e62661c3 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -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` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index eaa19dd5..3994790a 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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) { - 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) { - const nativeEvent = event.nativeEvent as PointerEvent; - return { - x: Number.isFinite(event.clientX) - ? event.clientX - : Number.isFinite(nativeEvent.clientX) - ? nativeEvent.clientX - : 0, - y: Number.isFinite(event.clientY) - ? event.clientY - : Number.isFinite(nativeEvent.clientY) - ? nativeEvent.clientY - : 0, - }; -} - -function getPointerId(event: ReactPointerEvent) { - const nativeId = (event.nativeEvent as PointerEvent).pointerId; - if (Number.isFinite(event.pointerId)) { - return event.pointerId; - } - return Number.isFinite(nativeId) ? nativeId : -1; -} - export function ImageCanvasEditorView() { const authUi = useAuthUi(); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); - const dragStateRef = useRef(null); const assetPointerDragRef = useRef(null); const authUiRef = useRef(authUi); - const isShiftPressedRef = useRef(false); const layerCounterRef = useRef(0); const layersRef = useRef([]); const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); const captureCanvasHistoryRef = useRef<() => void>(() => {}); + const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); @@ -157,15 +101,9 @@ export function ImageCanvasEditorView() { ); const suppressAssetClickRef = useRef(false); const [layers, setLayers] = useState([]); - const [canvasMarquee, setCanvasMarquee] = useState( - null, - ); const [selectedLayerId, setSelectedLayerId] = useState(null); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); - const [isSpacePanning, setIsSpacePanning] = useState(false); - const [isPanning, setIsPanning] = useState(false); - const [snapGuide, setSnapGuide] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); const [imageContextMenu, setImageContextMenu] = useState(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) => { - 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, - ) => { - 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) => { if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { event.preventDefault(); @@ -1049,114 +975,6 @@ export function ImageCanvasEditorView() { }); }; - const handleLayerPointerDown = ( - event: ReactPointerEvent, - layer: CanvasLayer, - ) => { - const button = getPointerButton(event); - if (button === 1 || effectiveTool === 'hand') { - event.stopPropagation(); - startPan(event as unknown as ReactPointerEvent); - 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, - 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, layer: CanvasLayer, @@ -1183,176 +1001,8 @@ export function ImageCanvasEditorView() { }); }; - const handleGenerationFramePointerDown = ( - event: ReactPointerEvent, - dialog: CanvasGenerationDialogState, - ) => { - if (!dialog.placeholder) { - return; - } - const button = getPointerButton(event); - if (button === 1 || effectiveTool === 'hand') { - event.stopPropagation(); - startPan(event as unknown as ReactPointerEvent); - 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, - ) => { - 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) => { - 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) => { - 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; diff --git a/src/components/image-editor/useCanvasHistory.test.tsx b/src/components/image-editor/useCanvasHistory.test.tsx index 0e4da5f3..5e9c502b 100644 --- a/src/components/image-editor/useCanvasHistory.test.tsx +++ b/src/components/image-editor/useCanvasHistory.test.tsx @@ -108,12 +108,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { resetters: { setHoveredLayerId: () => {}, setMetadataLayer: () => {}, - setCanvasMarquee: () => {}, - setSnapGuide: () => {}, - setImageContextMenu: () => {}, - setContextMenu: () => {}, - setIsPanning: () => {}, - clearDragState: onClearDrag, + resetCanvasInteractionState: onClearDrag, }, }); diff --git a/src/components/image-editor/useCanvasHistory.ts b/src/components/image-editor/useCanvasHistory.ts index d5298ee5..a9e1ae12 100644 --- a/src/components/image-editor/useCanvasHistory.ts +++ b/src/components/image-editor/useCanvasHistory.ts @@ -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], ); diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx new file mode 100644 index 00000000..2d89bcfc --- /dev/null +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -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 { + 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( + currentTarget: TElement, + options: { + pointerId: number; + clientX: number; + clientY: number; + button?: number; + buttons?: number; + shiftKey?: boolean; + target?: EventTarget; + }, +): ReactPointerEvent { + 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; +} + +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(null); + const worldRef = useRef(null); + const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select'); + const [layers, setLayers] = useState([ + createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }), + createLayer({ id: 'second', x: 220, y: 60, zIndex: 2 }), + ]); + const [viewport, setViewport] = useState({ + x: 0, + y: 0, + scale: 1, + }); + const [selectedLayerId, setSelectedLayerId] = useState(null); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); + const [generateDialog, setGenerateDialog] = + useState({ + 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 ( +
+
{ + canvasViewportRef.current = element; + if (element) { + setElementBox(element, { width: 640, height: 480 }); + } + }} + data-testid="viewport" + onPointerDown={interaction.handleCanvasPointerDown} + onPointerMove={interaction.handlePointerMove} + onPointerUp={interaction.finishDrag} + > +
+ {layers.map((layer) => ( + + ))} + {activeGenerationDialog?.placeholder ? ( +
+ interaction.handleGenerationFramePointerDown( + event, + activeGenerationDialog, + ) + } + /> + ) : null} + +
+ + {selectedLayerId ?? '-'}:{selectedLayerIds.join(',')} + + + {layers + .map((layer) => `${layer.id}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`) + .join('|')} + + + {viewport.x.toFixed(1)},{viewport.y.toFixed(1)},{viewport.scale} + + + {interaction.canvasMarquee + ? `${interaction.canvasMarquee.startX},${interaction.canvasMarquee.currentX}` + : '-'} + + {String(interaction.isPanning)} + {interaction.effectiveTool} + + {interaction.snapGuide?.vertical ?? interaction.snapGuide?.horizontal ?? '-'} + + + {generateDialog?.placeholder + ? `${generateDialog.placeholder.x.toFixed(1)},${generateDialog.placeholder.y.toFixed(1)}` + : '-'} + + + {String(generateDialog?.composerOpen ?? false)} + + {clearCount} + + {imageMenuCloseCount} + + + + + + + + + + + + + + + + + + +
+ ); +} + +describe('useImageCanvasStageInteractions', () => { + it('selects and drags multiple layers from stage pointer events', () => { + render(); + + 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( + , + ); + + 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( + , + ); + + 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, + ); + }); +}); diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts new file mode 100644 index 00000000..2507f19f --- /dev/null +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -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; + activeTool: CanvasTool; + layers: CanvasLayer[]; + setLayers: Dispatch>; + viewport: CanvasViewport; + setViewport: Dispatch>; + selectedLayerIds: string[]; + setSelectedLayerId: Dispatch>; + setSelectedLayerIds: Dispatch>; + generateDialog: GenerateDialogState | null; + setGenerateDialog: Dispatch>; + 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, + clientX: number, + clientY: number, + ) => void; + minimapScale: number; + onCloseImageContextMenu: () => void; +}; + +function getPointerButton(event: ReactPointerEvent) { + const nativeEvent = event.nativeEvent as PointerEvent; + const nativeButtons = Number(nativeEvent.buttons); + if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) { + return 1; + } + const syntheticButtons = Number(event.buttons); + if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) { + return 1; + } + const syntheticButton = Number(event.button); + if (Number.isFinite(syntheticButton)) { + return syntheticButton; + } + const nativeButton = Number(nativeEvent.button); + if (Number.isFinite(nativeButton)) { + return nativeButton; + } + return 0; +} + +function getPointerClient(event: ReactPointerEvent): CanvasPoint { + const nativeEvent = event.nativeEvent as PointerEvent; + return { + x: Number.isFinite(event.clientX) + ? event.clientX + : Number.isFinite(nativeEvent.clientX) + ? nativeEvent.clientX + : 0, + y: Number.isFinite(event.clientY) + ? event.clientY + : Number.isFinite(nativeEvent.clientY) + ? nativeEvent.clientY + : 0, + }; +} + +function getPointerId(event: ReactPointerEvent) { + const nativeId = (event.nativeEvent as PointerEvent).pointerId; + if (Number.isFinite(event.pointerId)) { + return event.pointerId; + } + return Number.isFinite(nativeId) ? nativeId : -1; +} + +export function useImageCanvasStageInteractions({ + canvasViewportRef, + activeTool, + layers, + setLayers, + viewport, + setViewport, + selectedLayerIds, + setSelectedLayerId, + setSelectedLayerIds, + generateDialog, + setGenerateDialog, + isPickingCharacterSpecFromCanvas, + isPickingIconSpecFromCanvas, + clearCanvasFocus, + pickCharacterSpecFromLayer, + pickIconSpecFromLayer, + activateCanvasGenerationDialog, + updateCanvasGenerationDialogById, + moveViewportFromMinimapPointer, + updateViewportFromMinimapDrag, + minimapScale, + onCloseImageContextMenu, +}: UseImageCanvasStageInteractionsOptions) { + const dragStateRef = useRef(null); + const isShiftPressedRef = useRef(false); + const [canvasMarquee, setCanvasMarquee] = useState( + null, + ); + const [isSpacePanning, setIsSpacePanning] = useState(false); + const [isPanning, setIsPanning] = useState(false); + const [snapGuide, setSnapGuide] = useState(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) => { + 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) => { + 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, 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, 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, + 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) => { + 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) => { + 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) => { + 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, + ], + ); +}