From 7dec8b7a6602e6d5d05f4fb6171c466884f00c5c Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 20:11:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E7=B4=A0=E6=9D=90=E5=BA=93?= =?UTF-8?q?=E6=A1=86=E9=80=89=E5=87=A0=E4=BD=95=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 ImageCanvasAssetLibraryModel 承载文件夹命中和框选几何规则 让素材库 hook 保留 DOM 和后端副作用并调用纯模型 补充素材库几何模型单测覆盖置顶和反向框选 更新 TRACKING.md 记录第四十四执行批次验证 --- TRACKING.md | 2 + .../ImageCanvasAssetLibraryModel.test.ts | 141 +++++++++++++++ .../ImageCanvasAssetLibraryModel.ts | 166 +++++++++++++++++ .../useImageCanvasAssetLibrary.ts | 170 +++++++++--------- 4 files changed, 397 insertions(+), 82 deletions(-) diff --git a/TRACKING.md b/TRACKING.md index b62af9e1..b671815b 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -167,3 +167,5 @@ - 2026-06-17 前端拆分第四十一执行批次:继续收口 `useImageCanvasGenerationWorkflow`,新增 `ImageCanvasGenerationDialogModel`,把普通生图 / 规范 / 角色形象 / 图标素材生成对话框草稿、修改图片 / 快速编辑 / 角色动画面板草稿、角色 / 图标参考选择、规范表单更新、图标描述更新、角色动画时长更新以及生成器失焦 / 关闭规则从 hook 中抽成纯模型;workflow hook 保留真实 API 调用、生成结果落画布、侧栏 / 工具 / 选中态副作用和错误回写。新增模型单测覆盖各类草稿、失败态清理、引用选择、描述限制、动画时长和 composer 可见性;`useImageCanvasGenerationWorkflow.ts` 从 1075 行降至 870 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第四十二执行批次:继续收口 `useImageCanvasStageInteractions`,新增 `ImageCanvasStageInteractionModel`,把 pointer button / client / id 归一化、画布框选初始状态、抓手平移拖拽状态、多选图层选择与图层拖拽初始状态、生成器 composer 随图层点击显隐、生成占位拖拽状态、小地图拖拽状态和拖拽阈值从 hook 中抽成纯模型;stage hook 保留 DOM 事件拦截、pointer capture / release、React 状态写入、拖拽移动执行和回调副作用。新增模型单测覆盖 pointer 兜底、中键识别、框选 / 平移状态、多选 toggle、组拖拽初始层快照、生成器 composer 规则、生成占位状态和小地图 click / drag 分流;`useImageCanvasStageInteractions.ts` 从 610 行降至 521 行。只读子代理复核结论:当前没有同一轮必须顺手拆的第二个明显大块,`ImageCanvasEditorView.tsx` 已主要是组合层,generation / asset / upload hooks 剩余复杂度多为异步编排或持久化副作用,后续应随具体需求再拆,避免过度碎片化。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasStageInteractions.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第四十三执行批次:继续收口 `useImageCanvasGenerationWorkflow`,新增 `useImageCanvasGenerationSubmissionWorkflow`,把普通生图 / 修改图 / 图标素材批量生成 / 快速编辑 / 角色动画的提交 API、提交中 / 失败 / 完成状态回写、生成结果落画布、选中图层、切换图层侧栏、适合视图和 last image model 记忆从入口 workflow 中抽成生成提交流水线 hook;原 workflow 保留生成入口、菜单开关、画布选取引用、表单字段更新、生成器关闭和删除图层后的状态清理,避免把 UI 选择态和提交态混在一起。新增 hook 单测覆盖 quick edit 成功 / 失败、edit 成功清理 modal、生成使用最新移动占位、icon 缺规范校验、icon 成功批量落图并记住模型、角色动画 completed / failed 状态机;同步修复新测试文件的 mock 隔离,避免 `vi.clearAllMocks()` 清空并行集成测试的调用记录;`useImageCanvasGenerationWorkflow.ts` 从 870 行降至 499 行。统一验证命令:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator` 占位、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 +- 2026-06-17 前端拆分第四十四执行批次:继续收口 `useImageCanvasAssetLibrary`,扩展 `ImageCanvasAssetLibraryModel`,把素材文件夹坐标命中、素材拖拽目标文件夹置顶判断、素材框选起点 / 移动 / 反向拖拽矩形归一化、框选只命中 uploaded 素材和框选启动规则从 hook 中抽成纯几何模型;asset library hook 保留 DOM 查询、dataset 读取、pointer capture / release、React 状态写入、后端 CRUD 和登录弹窗副作用,避免把 DOM 与服务调用塞进模型。新增模型单测覆盖文件夹边界命中、列表外返回空、文件夹 header 视野外置顶、反向框选归一化、边缘相交命中、忽略 built-in / unknown 素材;只读子代理复核同意优先抽 Rect/Point + folder hit + pinned + marquee selection,不继续拆新 hook。验证命令:`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/useImageCanvasAssetLibrary.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`。 +- 2026-06-17 前端拆分第四十四执行批次验证补充:统一编辑器回归命令 `npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` 通过 33 个文件 / 219 个测试;`npm run typecheck`、`npm run check:encoding`、`git diff --check` 均通过。浏览器回归:`http://127.0.0.1:10007/editor/canvas` 使用 Playwright CLI headless 打开成功,未登录显示 `账号入口`;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;默认素材侧栏显示 `素材` / `项目素材` / `上传到项目素材`,切换 `打开图层` 后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator` 占位、`生成图片` 对话框和 `AI画布工具栏` 均可见;网络请求仅有预期未登录 `/api/auth/refresh` 401,其余 editor smoke 相关请求正常。 diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts index 52b4978d..eb130ca2 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts @@ -6,18 +6,24 @@ import type { } from './ImageCanvasEditorTypes'; import { areAllSelectableAssetsSelected, + createAssetMarqueeFromPointer, + createAssetMarqueeSelectionRect, createLocalAssetFolder, deleteAssetFolderLocally, getSelectableAssets, groupAssetsByFolder, moveAssetToFolderLocally, + moveAssetMarqueeToPointer, removeAssetById, removeSelectedAssets, renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, resolveAllAssetSelection, + resolveAssetFolderIdFromPoint, resolveDefaultAssetFolder, + resolvePinnedAssetMoveFolderId, + selectUploadedAssetsInRect, toggleAssetFolderCollapsed, toggleAssetSelection, } from './ImageCanvasAssetLibraryModel'; @@ -184,4 +190,139 @@ describe('ImageCanvasAssetLibraryModel', () => { moveAssetToFolderLocally([createAsset()], 'asset-a', 'folder-role')[0], ).toMatchObject({ folderId: 'folder-role' }); }); + + it('resolves folder hit targets from pointer coordinates', () => { + const folders = [ + { + folderId: 'project', + rect: { left: 10, right: 110, top: 10, bottom: 80 }, + }, + { + folderId: 'folder-role', + rect: { left: 10, right: 110, top: 81, bottom: 150 }, + }, + ]; + + expect( + resolveAssetFolderIdFromPoint({ + point: { clientX: 40, clientY: 80 }, + listRect: { left: 0, right: 140, top: 0, bottom: 180 }, + folders, + }), + ).toBe('project'); + expect( + resolveAssetFolderIdFromPoint({ + point: { clientX: 5, clientY: 40 }, + listRect: { left: 10, right: 140, top: 0, bottom: 180 }, + folders, + }), + ).toBeNull(); + expect( + resolveAssetFolderIdFromPoint({ + point: { clientX: 120, clientY: 40 }, + listRect: { left: 0, right: 140, top: 0, bottom: 180 }, + folders, + }), + ).toBeNull(); + }); + + it('pins asset move folder labels only when their header is outside the list viewport', () => { + const listRect = { left: 0, right: 120, top: 100, bottom: 240 }; + + expect( + resolvePinnedAssetMoveFolderId({ + folderId: 'folder-role', + listRect, + headerRect: { left: 0, right: 120, top: 40, bottom: 80 }, + }), + ).toBe('folder-role'); + expect( + resolvePinnedAssetMoveFolderId({ + folderId: 'folder-role', + listRect, + headerRect: { left: 0, right: 120, top: 260, bottom: 300 }, + }), + ).toBe('folder-role'); + expect( + resolvePinnedAssetMoveFolderId({ + folderId: 'folder-role', + listRect, + headerRect: { left: 0, right: 120, top: 120, bottom: 150 }, + }), + ).toBeNull(); + expect( + resolvePinnedAssetMoveFolderId({ + folderId: null, + listRect, + headerRect: { left: 0, right: 120, top: 40, bottom: 80 }, + }), + ).toBeNull(); + }); + + it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => { + const marquee = createAssetMarqueeFromPointer({ + pointerId: 5, + point: { clientX: 180, clientY: 220 }, + containerRect: { left: 100, top: 200 }, + }); + + expect(marquee).toEqual({ + pointerId: 5, + startX: 80, + startY: 20, + currentX: 80, + currentY: 20, + }); + expect( + moveAssetMarqueeToPointer({ + marquee, + point: { clientX: 120, clientY: 205 }, + containerRect: { left: 100, top: 200 }, + }), + ).toMatchObject({ currentX: 20, currentY: 5 }); + expect( + createAssetMarqueeSelectionRect({ + marquee, + point: { clientX: 120, clientY: 205 }, + containerRect: { left: 100, top: 200 }, + }), + ).toEqual({ + left: 120, + right: 180, + top: 205, + bottom: 220, + }); + }); + + it('selects only uploaded assets intersecting the marquee rectangle', () => { + const assets = [ + createAsset({ id: 'asset-a' }), + createAsset({ id: 'asset-b' }), + createAsset({ id: 'built-in', sourceKind: 'built-in' }), + ]; + const selectedIds = selectUploadedAssetsInRect({ + assets, + assetTargets: [ + { + assetId: 'asset-a', + rect: { left: 10, right: 20, top: 10, bottom: 20 }, + }, + { + assetId: 'asset-b', + rect: { left: 30, right: 40, top: 30, bottom: 40 }, + }, + { + assetId: 'built-in', + rect: { left: 15, right: 25, top: 15, bottom: 25 }, + }, + { + assetId: 'missing', + rect: { left: 15, right: 25, top: 15, bottom: 25 }, + }, + ], + selectionRect: { left: 20, right: 35, top: 20, bottom: 35 }, + }); + + expect([...selectedIds]).toEqual(['asset-a', 'asset-b']); + }); }); diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts index 7cebcd3d..04a859de 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryModel.ts +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts @@ -1,12 +1,178 @@ import type { + AssetMarqueeState, EditorAsset, EditorAssetFolder, } from './ImageCanvasEditorTypes'; +type ClientRectLike = Pick; + +type ClientPoint = { + clientX: number; + clientY: number; +}; + +type AssetFolderHitTarget = { + folderId: string; + rect: ClientRectLike; +}; + +type AssetHitTarget = { + assetId: string; + rect: ClientRectLike; +}; + export type GroupedAssetFolder = EditorAssetFolder & { assets: EditorAsset[]; }; +function isPointInRect(point: ClientPoint, rect: ClientRectLike) { + return ( + point.clientX >= rect.left && + point.clientX <= rect.right && + point.clientY >= rect.top && + point.clientY <= rect.bottom + ); +} + +function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) { + return ( + rect.left <= selectionRect.right && + rect.right >= selectionRect.left && + rect.top <= selectionRect.bottom && + rect.bottom >= selectionRect.top + ); +} + +export function resolveAssetFolderIdFromPoint({ + point, + listRect, + folders, +}: { + point: ClientPoint; + listRect: ClientRectLike | null | undefined; + folders: AssetFolderHitTarget[]; +}) { + if (!listRect || !isPointInRect(point, listRect)) { + return null; + } + const matchedFolder = folders.find((folder) => + isPointInRect(point, folder.rect), + ); + return matchedFolder?.folderId ?? null; +} + +export function resolvePinnedAssetMoveFolderId({ + folderId, + listRect, + headerRect, +}: { + folderId: string | null; + listRect: ClientRectLike | null | undefined; + headerRect: ClientRectLike | null | undefined; +}) { + if ( + !folderId || + !listRect || + !headerRect || + (headerRect.bottom >= listRect.top && headerRect.top <= listRect.bottom) + ) { + return null; + } + return folderId; +} + +export function shouldStartAssetMarquee({ + isAssetSelectionMode, + button, + isBlockedTarget, +}: { + isAssetSelectionMode: boolean; + button: number; + isBlockedTarget: boolean; +}) { + return isAssetSelectionMode && button === 0 && !isBlockedTarget; +} + +export function createAssetMarqueeFromPointer({ + pointerId, + point, + containerRect, +}: { + pointerId: number; + point: ClientPoint; + containerRect: Pick | null | undefined; +}): AssetMarqueeState { + const startX = point.clientX - (containerRect?.left ?? 0); + const startY = point.clientY - (containerRect?.top ?? 0); + return { + pointerId, + startX, + startY, + currentX: startX, + currentY: startY, + }; +} + +export function moveAssetMarqueeToPointer({ + marquee, + point, + containerRect, +}: { + marquee: AssetMarqueeState; + point: ClientPoint; + containerRect: Pick | null | undefined; +}) { + return { + ...marquee, + currentX: point.clientX - (containerRect?.left ?? 0), + currentY: point.clientY - (containerRect?.top ?? 0), + }; +} + +export function createAssetMarqueeSelectionRect({ + marquee, + point, + containerRect, +}: { + marquee: AssetMarqueeState; + point: ClientPoint; + containerRect: Pick | null | undefined; +}): ClientRectLike { + const startClientX = (containerRect?.left ?? 0) + marquee.startX; + const startClientY = (containerRect?.top ?? 0) + marquee.startY; + return { + left: Math.min(startClientX, point.clientX), + right: Math.max(startClientX, point.clientX), + top: Math.min(startClientY, point.clientY), + bottom: Math.max(startClientY, point.clientY), + }; +} + +export function selectUploadedAssetsInRect({ + assets, + assetTargets, + selectionRect, +}: { + assets: EditorAsset[]; + assetTargets: AssetHitTarget[]; + selectionRect: ClientRectLike; +}) { + const uploadedAssetIds = new Set( + assets + .filter((asset) => asset.sourceKind === 'uploaded') + .map((asset) => asset.id), + ); + return new Set( + assetTargets + .filter( + (target) => + uploadedAssetIds.has(target.assetId) && + doRectsIntersect(target.rect, selectionRect), + ) + .map((target) => target.assetId), + ); +} + export function groupAssetsByFolder( assetFolders: EditorAssetFolder[], assets: EditorAsset[], diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index 8dd02e98..38eb9638 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -25,11 +25,14 @@ import { } from './ImageCanvasEditorModel'; import { areAllSelectableAssetsSelected, + createAssetMarqueeFromPointer, + createAssetMarqueeSelectionRect, createLocalAssetFolder, deleteAssetFolderLocally, getSelectableAssets, groupAssetsByFolder, moveAssetToFolderLocally, + moveAssetMarqueeToPointer, removeAssetById, removeSelectedAssets, renameAssetById, @@ -37,6 +40,10 @@ import { replaceLocalAssetFolder, resolveAllAssetSelection, resolveDefaultAssetFolder, + resolveAssetFolderIdFromPoint, + resolvePinnedAssetMoveFolderId, + selectUploadedAssetsInRect, + shouldStartAssetMarquee, toggleAssetFolderCollapsed, toggleAssetSelection, } from './ImageCanvasAssetLibraryModel'; @@ -55,6 +62,43 @@ function isEditorAuthError(error: unknown) { ); } +const ASSET_FOLDER_SELECTOR = '[data-asset-folder-id]'; +const ASSET_ITEM_SELECTOR = '[data-asset-id]'; +const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR = + 'button, input, textarea, select, [data-asset-id]'; + +function readAssetFolderHitTargets(listElement: ParentNode | null) { + return [ + ...(listElement?.querySelectorAll(ASSET_FOLDER_SELECTOR) ?? []), + ] + .map((element) => { + const folderId = element.dataset.assetFolderId; + return folderId + ? { + folderId, + rect: element.getBoundingClientRect(), + } + : null; + }) + .filter((target): target is NonNullable => Boolean(target)); +} + +function readAssetHitTargets(listElement: ParentNode | null) { + return [ + ...(listElement?.querySelectorAll(ASSET_ITEM_SELECTOR) ?? []), + ] + .map((element) => { + const assetId = element.dataset.assetId; + return assetId + ? { + assetId, + rect: element.getBoundingClientRect(), + } + : null; + }) + .filter((target): target is NonNullable => Boolean(target)); +} + export function useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData, @@ -145,28 +189,11 @@ export function useImageCanvasAssetLibrary({ if (!listElement) { return null; } - const listRect = listElement.getBoundingClientRect(); - if ( - clientX < listRect.left || - clientX > listRect.right || - clientY < listRect.top || - clientY > listRect.bottom - ) { - return null; - } - const folderElements = [ - ...listElement.querySelectorAll('[data-asset-folder-id]'), - ]; - const matchedFolder = folderElements.find((element) => { - const rect = element.getBoundingClientRect(); - return ( - clientX >= rect.left && - clientX <= rect.right && - clientY >= rect.top && - clientY <= rect.bottom - ); + return resolveAssetFolderIdFromPoint({ + point: { clientX, clientY }, + listRect: listElement.getBoundingClientRect(), + folders: readAssetFolderHitTargets(listElement), }); - return matchedFolder?.dataset.assetFolderId ?? null; }, [assetListRef], ); @@ -182,14 +209,12 @@ export function useImageCanvasAssetLibrary({ const header = listElement?.querySelector( `[data-asset-folder-header-id="${escapeCssIdentifier(folderId)}"]`, ); - const listRect = listElement?.getBoundingClientRect(); - const headerRect = header?.getBoundingClientRect(); setPinnedAssetMoveFolderId( - listRect && - headerRect && - (headerRect.bottom < listRect.top || headerRect.top > listRect.bottom) - ? folderId - : null, + resolvePinnedAssetMoveFolderId({ + folderId, + listRect: listElement?.getBoundingClientRect(), + headerRect: header?.getBoundingClientRect(), + }), ); }, [assetListRef], @@ -418,56 +443,40 @@ export function useImageCanvasAssetLibrary({ top: number; bottom: number; }) => { - const nextSelectedIds = new Set(); - assetListRef.current - ?.querySelectorAll('[data-asset-id]') - .forEach((element) => { - const assetId = element.dataset.assetId; - if (!assetId) { - return; - } - const asset = assets.find( - (currentAsset) => currentAsset.id === assetId, - ); - if (!asset || asset.sourceKind !== 'uploaded') { - return; - } - const rect = element.getBoundingClientRect(); - const intersects = - rect.left <= selectionRect.right && - rect.right >= selectionRect.left && - rect.top <= selectionRect.bottom && - rect.bottom >= selectionRect.top; - if (intersects) { - nextSelectedIds.add(assetId); - } - }); - setSelectedAssetIds(nextSelectedIds); + setSelectedAssetIds( + selectUploadedAssetsInRect({ + assets, + assetTargets: readAssetHitTargets(assetListRef.current), + selectionRect, + }), + ); }, [assetListRef, assets], ); const handleAssetMarqueePointerDown = useCallback( (event: ReactPointerEvent) => { - if (!isAssetSelectionMode || event.button !== 0) { - return; - } const target = event.target as HTMLElement; - if (target.closest('button, input, textarea, select, [data-asset-id]')) { + if ( + !shouldStartAssetMarquee({ + isAssetSelectionMode, + button: event.button, + isBlockedTarget: Boolean( + target.closest(ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR), + ), + }) + ) { return; } event.preventDefault(); assetListRef.current?.setPointerCapture?.(event.pointerId); - const rect = assetListRef.current?.getBoundingClientRect(); - const startX = event.clientX - (rect?.left ?? 0); - const startY = event.clientY - (rect?.top ?? 0); - setAssetMarquee({ - pointerId: event.pointerId, - startX, - startY, - currentX: startX, - currentY: startY, - }); + setAssetMarquee( + createAssetMarqueeFromPointer({ + pointerId: event.pointerId, + point: event, + containerRect: assetListRef.current?.getBoundingClientRect(), + }), + ); setSelectedAssetIds(new Set()); }, [assetListRef, isAssetSelectionMode], @@ -480,25 +489,22 @@ export function useImageCanvasAssetLibrary({ } event.preventDefault(); const containerRect = assetListRef.current?.getBoundingClientRect(); - const currentX = event.clientX - (containerRect?.left ?? 0); - const currentY = event.clientY - (containerRect?.top ?? 0); - const startClientX = (containerRect?.left ?? 0) + assetMarquee.startX; - const startClientY = (containerRect?.top ?? 0) + assetMarquee.startY; setAssetMarquee((currentMarquee) => currentMarquee - ? { - ...currentMarquee, - currentX, - currentY, - } + ? moveAssetMarqueeToPointer({ + marquee: currentMarquee, + point: event, + containerRect, + }) : null, ); - updateAssetSelectionFromMarquee({ - left: Math.min(startClientX, event.clientX), - right: Math.max(startClientX, event.clientX), - top: Math.min(startClientY, event.clientY), - bottom: Math.max(startClientY, event.clientY), - }); + updateAssetSelectionFromMarquee( + createAssetMarqueeSelectionRect({ + marquee: assetMarquee, + point: event, + containerRect, + }), + ); }, [assetListRef, assetMarquee, updateAssetSelectionFromMarquee], );