From 53d128308397a4945a697a6e3ced5a10fe363857 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 10:17:07 +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=E6=8B=96=E6=8B=BD=E5=85=A5=E7=94=BB=E5=B8=83=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画布拖拽 drop workflow,承接素材库图片和本地文件拖入画布分流 补充拖拽入画布 hook 测试,覆盖遮罩、默认文件夹和无关拖拽不拦截 更新前端拆分文档和 TRACKING 浏览器回归记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 11 +- .../image-editor/ImageCanvasEditorView.tsx | 67 +---- .../useImageCanvasCanvasDropWorkflow.test.tsx | 244 ++++++++++++++++++ .../useImageCanvasCanvasDropWorkflow.ts | 131 ++++++++++ 5 files changed, 395 insertions(+), 59 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx create mode 100644 src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts diff --git a/TRACKING.md b/TRACKING.md index 71ea404c..daac9ef9 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -134,3 +134,4 @@ - 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。 +- 2026-06-17 前端拆分第十九阶段:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出,覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理;主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx 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` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index e62661c3..6a101a9d 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -160,14 +160,21 @@ - 主视图继续保留原生文件 / 素材 drop、右键菜单定位、上传工作流、生成提交、项目持久化和工具栏动作分流;舞台 hook 只接收这些能力需要的回调,不反向读取路由、API 或素材库状态。 - 该 hook 用独立单测覆盖多选拖拽、框选、临时抓手、生成占位拖拽和小地图 click / drag 分流;主视图 DOM 测试继续覆盖真实组件路径和历史上容易回退的浏览器级交互。 +## 第十九阶段模块 + +- `useImageCanvasCanvasDropWorkflow.ts` + - 承载画布区域 drag over / leave / drop 分流:识别素材库拖拽 MIME、本地文件拖拽、画布遮罩状态、默认文件夹选择、素材入画布和文件上传到画布参数组装。 + - 主视图继续提供已有能力注入:账号级素材列表、默认素材文件夹、屏幕点转画布 drop 点、素材建层、文件上传、素材移动高亮清理;drop hook 不直接创建资源、不访问 API,也不读取项目持久化状态。 + - 该 hook 用独立单测覆盖素材库图片拖入画布、文件拖入画布、无关拖拽不拦截和 drag leave 清理遮罩;主视图集成测试继续覆盖真实 DOM 中的“素材库拖到画布”和“文件拖到画布”路径。 + ## 后续阶段 - 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 -- 素材入画布、原生文件 drop、右键菜单定位、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。 +- 右键菜单定位、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。 ## 验证计划 -- `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 test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx 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 3994790a..a2b99b56 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,7 +1,6 @@ import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react'; import { type CSSProperties, - type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, useCallback, useEffect, @@ -23,12 +22,9 @@ import { import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; import { - ASSET_DRAG_MIME_TYPE, TOOLBAR_HALF_WIDTH, clamp, createLayerFromAsset, - getDraggedAssetId, - hasDataTransferType, isGeneratedLayer, isLayerLinkedToAsset, resolveContextMenuPosition, @@ -56,6 +52,7 @@ import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; +import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow'; import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; @@ -904,60 +901,16 @@ export function ImageCanvasEditorView() { deleteLayerByIdRef.current = deleteLayerById; - const handleCanvasDragOver = (event: ReactDragEvent) => { - if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { - event.preventDefault(); - setUploadDropTarget('canvas'); - event.dataTransfer.dropEffect = 'copy'; - return; - } - if (hasDataTransferType(event.dataTransfer, 'Files')) { - event.preventDefault(); - setUploadDropTarget('canvas'); - event.dataTransfer.dropEffect = 'copy'; - } - }; - - const handleCanvasDragLeave = (event: ReactDragEvent) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - setUploadDropTarget((currentTarget) => - currentTarget === 'canvas' ? null : currentTarget, - ); - } - }; - - const handleCanvasDrop = (event: ReactDragEvent) => { - const draggedAssetId = getDraggedAssetId(event.dataTransfer); - if (draggedAssetId) { - const draggedAsset = assets.find((asset) => asset.id === draggedAssetId); - if (!draggedAsset) { - return; - } - event.preventDefault(); - setUploadDropTarget(null); - updateAssetMoveDropFolder(null); - addAssetLayer( - draggedAsset, - getCanvasDropPoint(event.clientX, event.clientY), - ); - return; - } - const files = event.dataTransfer.files; - if (!files.length) { - return; - } - event.preventDefault(); - setUploadDropTarget(null); - updateAssetMoveDropFolder(null); - const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY); - const defaultFolder = - assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; - addUploadedFiles(files, { - folderId: defaultFolder?.id, - canvasPoint, - addToCanvas: true, + const { handleCanvasDragOver, handleCanvasDragLeave, handleCanvasDrop } = + useImageCanvasCanvasDropWorkflow({ + assets, + assetFolders, + setUploadDropTarget, + updateAssetMoveDropFolder, + getCanvasDropPoint, + addAssetLayer, + addUploadedFiles, }); - }; const handleCanvasContextMenu = (event: ReactMouseEvent) => { event.preventDefault(); diff --git a/src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx b/src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx new file mode 100644 index 00000000..b09eee05 --- /dev/null +++ b/src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx @@ -0,0 +1,244 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ASSET_DRAG_MIME_TYPE } from './ImageCanvasEditorModel'; +import type { EditorAsset, EditorAssetFolder } from './ImageCanvasEditorTypes'; +import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow'; + +function createDataTransferStub({ + files = [], +}: { + files?: File[]; +} = {}) { + const store = new Map(); + return { + files, + types: files.length ? ['Files'] : ([] as string[]), + dropEffect: 'none', + setData(type: string, value: string) { + store.set(type, value); + if (!this.types.includes(type)) { + this.types.push(type); + } + }, + getData(type: string) { + return store.get(type) ?? ''; + }, + }; +} + +function dispatchDragEvent( + target: Element, + type: string, + options: { + dataTransfer: ReturnType; + clientX?: number; + clientY?: number; + relatedTarget?: EventTarget | null; + }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { + configurable: true, + value: options.dataTransfer, + }); + Object.defineProperty(event, 'clientX', { + configurable: true, + value: options.clientX ?? 0, + }); + Object.defineProperty(event, 'clientY', { + configurable: true, + value: options.clientY ?? 0, + }); + Object.defineProperty(event, 'relatedTarget', { + configurable: true, + value: options.relatedTarget ?? null, + }); + fireEvent(target, event); +} + +function DropWorkflowHarness({ + assets = [ + { + id: 'asset-1', + label: '素材一', + src: 'data:image/png;base64,asset', + width: 100, + height: 80, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + }, + ], + folders = [ + { + id: 'project', + label: '项目素材', + collapsed: false, + systemDefault: true, + persisted: true, + }, + ], + addAssetLayer = vi.fn(), + addUploadedFiles = vi.fn(), + updateAssetMoveDropFolder = vi.fn(), + getCanvasDropPoint = vi.fn((clientX: number, clientY: number) => ({ + x: clientX - 10, + y: clientY - 20, + })), +}: { + assets?: EditorAsset[]; + folders?: EditorAssetFolder[]; + addAssetLayer?: (asset: EditorAsset, position?: { x: number; y: number }) => void; + addUploadedFiles?: ( + files: FileList | File[], + options: { + folderId?: string; + canvasPoint: { x: number; y: number }; + addToCanvas: true; + }, + ) => void; + updateAssetMoveDropFolder?: (folderId: string | null) => void; + getCanvasDropPoint?: (clientX: number, clientY: number) => { + x: number; + y: number; + }; +}) { + const [uploadDropTarget, setUploadDropTarget] = useState< + 'canvas' | 'assets' | null + >(null); + const workflow = useImageCanvasCanvasDropWorkflow({ + assets, + assetFolders: folders, + setUploadDropTarget, + updateAssetMoveDropFolder, + getCanvasDropPoint, + addAssetLayer, + addUploadedFiles, + }); + + return ( +
+
setUploadDropTarget('assets')} + /> +
+ canvas + {uploadDropTarget ?? '-'} + inner +
+
+ ); +} + +describe('useImageCanvasCanvasDropWorkflow', () => { + it('shows a canvas drop target for dragged asset library images', () => { + render(); + const dataTransfer = createDataTransferStub(); + dataTransfer.setData(ASSET_DRAG_MIME_TYPE, 'asset-1'); + + dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', { + dataTransfer, + }); + + expect(dataTransfer.dropEffect).toBe('copy'); + expect(screen.getByTestId('drop-target').textContent).toBe('canvas'); + }); + + it('adds an existing asset to the canvas at the drop point', () => { + const addAssetLayer = vi.fn(); + const updateAssetMoveDropFolder = vi.fn(); + render( + , + ); + const dataTransfer = createDataTransferStub(); + dataTransfer.setData(ASSET_DRAG_MIME_TYPE, 'asset-1'); + + dispatchDragEvent(screen.getByTestId('canvas'), 'drop', { + clientX: 120, + clientY: 90, + dataTransfer, + }); + + expect(addAssetLayer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'asset-1' }), + { x: 110, y: 70 }, + ); + expect(updateAssetMoveDropFolder).toHaveBeenCalledWith(null); + expect(screen.getByTestId('drop-target').textContent).toBe('-'); + }); + + it('uploads dropped files to the default folder and adds them to the canvas', () => { + const addUploadedFiles = vi.fn(); + render(); + const imageFile = new File(['image'], '画布上传.png', { + type: 'image/png', + }); + const dataTransfer = createDataTransferStub({ files: [imageFile] }); + + dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', { + dataTransfer, + }); + expect(screen.getByTestId('drop-target').textContent).toBe('canvas'); + + dispatchDragEvent(screen.getByTestId('canvas'), 'drop', { + clientX: 300, + clientY: 220, + dataTransfer, + }); + + expect(addUploadedFiles).toHaveBeenCalledWith([imageFile], { + folderId: 'project', + canvasPoint: { x: 290, y: 200 }, + addToCanvas: true, + }); + expect(screen.getByTestId('drop-target').textContent).toBe('-'); + }); + + it('keeps unrelated drags untouched and clears only canvas overlays on leave', () => { + render(); + const dataTransfer = createDataTransferStub(); + + dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', { + dataTransfer, + }); + + expect(dataTransfer.dropEffect).toBe('none'); + expect(screen.getByTestId('drop-target').textContent).toBe('-'); + + fireEvent.dragEnter(screen.getByTestId('outside')); + expect(screen.getByTestId('drop-target').textContent).toBe('assets'); + + dispatchDragEvent(screen.getByTestId('canvas'), 'dragover', { + dataTransfer: createDataTransferStub({ + files: [new File(['image'], '画布上传.png', { type: 'image/png' })], + }), + }); + expect(screen.getByTestId('drop-target').textContent).toBe('canvas'); + + dispatchDragEvent(screen.getByTestId('canvas'), 'dragleave', { + dataTransfer: createDataTransferStub(), + relatedTarget: screen.getByTestId('inner'), + }); + expect(screen.getByTestId('drop-target').textContent).toBe('canvas'); + + dispatchDragEvent(screen.getByTestId('canvas'), 'dragleave', { + dataTransfer: createDataTransferStub(), + relatedTarget: screen.getByTestId('outside'), + }); + expect(screen.getByTestId('drop-target').textContent).toBe('-'); + }); +}); diff --git a/src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts b/src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts new file mode 100644 index 00000000..48f96187 --- /dev/null +++ b/src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts @@ -0,0 +1,131 @@ +import { + type DragEvent as ReactDragEvent, + type Dispatch, + type SetStateAction, + useCallback, + useMemo, +} from 'react'; + +import { + ASSET_DRAG_MIME_TYPE, + getDraggedAssetId, + hasDataTransferType, +} from './ImageCanvasEditorModel'; +import type { EditorAsset, EditorAssetFolder } from './ImageCanvasEditorTypes'; + +type UploadFilesToCanvasOptions = { + folderId?: string; + canvasPoint: { x: number; y: number }; + addToCanvas: true; +}; + +type UseImageCanvasCanvasDropWorkflowOptions = { + assets: EditorAsset[]; + assetFolders: EditorAssetFolder[]; + setUploadDropTarget: Dispatch>; + updateAssetMoveDropFolder: (folderId: string | null) => void; + getCanvasDropPoint: (clientX: number, clientY: number) => { + x: number; + y: number; + }; + addAssetLayer: ( + asset: EditorAsset, + position?: { x: number; y: number }, + ) => void; + addUploadedFiles: ( + files: FileList | File[], + options: UploadFilesToCanvasOptions, + ) => void; +}; + +export function useImageCanvasCanvasDropWorkflow({ + assets, + assetFolders, + setUploadDropTarget, + updateAssetMoveDropFolder, + getCanvasDropPoint, + addAssetLayer, + addUploadedFiles, +}: UseImageCanvasCanvasDropWorkflowOptions) { + const handleCanvasDragOver = useCallback( + (event: ReactDragEvent) => { + if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { + event.preventDefault(); + setUploadDropTarget('canvas'); + event.dataTransfer.dropEffect = 'copy'; + return; + } + if (hasDataTransferType(event.dataTransfer, 'Files')) { + event.preventDefault(); + setUploadDropTarget('canvas'); + event.dataTransfer.dropEffect = 'copy'; + } + }, + [setUploadDropTarget], + ); + + const handleCanvasDragLeave = useCallback( + (event: ReactDragEvent) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setUploadDropTarget((currentTarget) => + currentTarget === 'canvas' ? null : currentTarget, + ); + } + }, + [setUploadDropTarget], + ); + + const handleCanvasDrop = useCallback( + (event: ReactDragEvent) => { + const draggedAssetId = getDraggedAssetId(event.dataTransfer); + if (draggedAssetId) { + const draggedAsset = assets.find((asset) => asset.id === draggedAssetId); + if (!draggedAsset) { + return; + } + event.preventDefault(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + addAssetLayer( + draggedAsset, + getCanvasDropPoint(event.clientX, event.clientY), + ); + return; + } + + const files = event.dataTransfer.files; + if (!files.length) { + return; + } + event.preventDefault(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY); + const defaultFolder = + assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; + addUploadedFiles(files, { + folderId: defaultFolder?.id, + canvasPoint, + addToCanvas: true, + }); + }, + [ + addAssetLayer, + addUploadedFiles, + assetFolders, + assets, + getCanvasDropPoint, + setUploadDropTarget, + updateAssetMoveDropFolder, + ], + ); + + return useMemo( + () => ({ + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + }), + [handleCanvasDragLeave, handleCanvasDragOver, handleCanvasDrop], + ); +}