diff --git a/TRACKING.md b/TRACKING.md index 165fe9d8..22b29566 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -153,3 +153,4 @@ - 2026-06-17 前端拆分第三十四阶段:继续收口 `ImageCanvasStageView`,新增 `ImageCanvasWorldView`,把画布世界表面、吸附参考线、可见图层排序 / 悬浮 / 选中 / 锁定 / 生成态、元数据角标、框选矩形、生成占位框和浮动生成状态从 StageView 内联 JSX 中抽出;StageView 降至 324 行,继续保留 viewport 宿主、drop overlay、左下 dock、底部工具栏、右键菜单和选中图片工具栏装配。新增 world view 单测覆盖隐藏图层过滤、悬浮尺寸、生成态、元数据按钮、吸附线、框选矩形和生成占位框事件。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasWorldView.test.tsx`、`npm run test -- 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/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.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`,`AI画布工具栏` 保持可见;`打开图层` 切换后侧栏显示 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和底部工具栏均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第三十五阶段:继续收口 `useImageCanvasGenerationWorkflow`,新增 `ImageCanvasGenerationSubmissionModel`,把普通生图、修改图片、规范生成和角色形象生成的请求 payload、标准化 prompt、结果图层标题 / assetKind 和 generationInputs 快照构建从 workflow hook 中抽成纯模型;workflow hook 保留对话状态、真实 API 调用、图片引用解析、结果落图、选中和 fit 副作用,避免拆散生成生命周期。新增模型单测覆盖普通生图、修改图、带参考图的规范生成、带规范 / 常规参考图的角色生成和缺失源图异常;`useImageCanvasGenerationWorkflow` 从 1167 行降至 1104 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- 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/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.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 前端拆分第三十六阶段:继续收口 `useImageCanvasUploadWorkflow`,新增 `ImageCanvasUploadModel`,把上传目标文件夹解析、上传中素材占位卡、上传到画布的临时图层、无效 drop 坐标兜底和图片真实尺寸回填坐标计算从 hook 中抽成纯模型;upload workflow hook 保留登录恢复、文件读取、真实素材创建 API、上传进度状态和生成面板参考图写入副作用。新增模型单测覆盖文件夹兜底、占位素材、画布落点、非法坐标兜底和真实尺寸修正;`useImageCanvasUploadWorkflow` 从 546 行降至 510 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`、`npm run test -- 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/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.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`,把素材分组、可选择素材筛选、全选状态、素材 / 文件夹重命名、文件夹折叠、本地新建文件夹占位、持久化文件夹替换、本地删除素材、删除文件夹回默认文件夹、选择集合切换、批量删除和本地移动素材到文件夹从 hook 中抽成纯模型;asset library hook 继续保留加载账号素材库、后端 CRUD 调用、登录弹窗、DOM 框选和素材拖拽命中生命周期。新增模型单测覆盖分组 / 选择、重命名 / 折叠 / 本地文件夹、本地文件夹持久化替换、删除文件夹回默认文件夹、全选 / 批量删除和本地移动;`useImageCanvasAssetLibrary` 从 609 行降至 573 行。统一验证命令:`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/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/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。 diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts new file mode 100644 index 00000000..52b4978d --- /dev/null +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import type { + EditorAsset, + EditorAssetFolder, +} from './ImageCanvasEditorTypes'; +import { + areAllSelectableAssetsSelected, + createLocalAssetFolder, + deleteAssetFolderLocally, + getSelectableAssets, + groupAssetsByFolder, + moveAssetToFolderLocally, + removeAssetById, + removeSelectedAssets, + renameAssetById, + renameAssetFolderById, + replaceLocalAssetFolder, + resolveAllAssetSelection, + resolveDefaultAssetFolder, + toggleAssetFolderCollapsed, + toggleAssetSelection, +} from './ImageCanvasAssetLibraryModel'; + +function createFolder( + overrides: Partial = {}, +): EditorAssetFolder { + return { + id: 'project', + label: '项目素材', + collapsed: false, + systemDefault: true, + persisted: true, + ...overrides, + }; +} + +function createAsset(overrides: Partial = {}): EditorAsset { + return { + id: 'asset-a', + label: '素材A', + src: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + ...overrides, + }; +} + +describe('ImageCanvasAssetLibraryModel', () => { + it('groups assets by folder and resolves selectable uploaded assets', () => { + const folders = [ + createFolder(), + createFolder({ id: 'folder-role', label: '角色素材' }), + ]; + const assets = [ + createAsset({ id: 'asset-a', folderId: 'project' }), + createAsset({ + id: 'asset-b', + label: '素材B', + folderId: 'folder-role', + }), + createAsset({ + id: 'built-in', + label: '内置素材', + sourceKind: 'built-in', + }), + ]; + + expect(groupAssetsByFolder(folders, assets)).toMatchObject([ + { id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] }, + { id: 'folder-role', assets: [{ id: 'asset-b' }] }, + ]); + expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([ + 'asset-a', + 'asset-b', + ]); + }); + + it('renames assets, toggles folders and creates local folders', () => { + expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject( + { label: '新名字' }, + ); + expect( + renameAssetFolderById([createFolder()], 'project', '默认素材')[0], + ).toMatchObject({ label: '默认素材' }); + expect(toggleAssetFolderCollapsed([createFolder()], 'project')[0]).toMatchObject( + { collapsed: true }, + ); + expect( + createLocalAssetFolder({ + folderId: 'folder-local', + label: '角色素材', + }), + ).toEqual({ + id: 'folder-local', + label: '角色素材', + collapsed: false, + systemDefault: false, + persisted: false, + }); + }); + + it('replaces local folder ids in folders and contained assets', () => { + const result = replaceLocalAssetFolder({ + folders: [createFolder(), createFolder({ id: 'folder-local' })], + assets: [createAsset({ folderId: 'folder-local' })], + localFolderId: 'folder-local', + persistedFolder: { + folderId: 'folder-role', + label: '角色素材', + collapsed: false, + systemDefault: false, + }, + }); + + expect(result.folders[1]).toEqual({ + id: 'folder-role', + label: '角色素材', + collapsed: false, + systemDefault: false, + persisted: true, + }); + expect(result.assets[0]).toMatchObject({ folderId: 'folder-role' }); + }); + + it('deletes assets and moves deleted folder contents to the default folder', () => { + const folders = [ + createFolder(), + createFolder({ id: 'folder-role', systemDefault: false }), + ]; + const assets = [ + createAsset({ id: 'asset-a', folderId: 'folder-role' }), + createAsset({ id: 'asset-b', folderId: 'project' }), + ]; + + expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual([ + 'asset-b', + ]); + expect(resolveDefaultAssetFolder(folders)?.id).toBe('project'); + expect( + deleteAssetFolderLocally({ + folders, + assets, + folderId: 'folder-role', + defaultFolderId: 'project', + }), + ).toMatchObject({ + folders: [{ id: 'project' }], + assets: [ + { id: 'asset-a', folderId: 'project' }, + { id: 'asset-b', folderId: 'project' }, + ], + }); + }); + + it('toggles selection, selects all uploaded assets and removes selected assets', () => { + const assets = [ + createAsset({ id: 'asset-a' }), + createAsset({ id: 'asset-b' }), + ]; + const selected = toggleAssetSelection(new Set(), 'asset-a'); + + expect([...selected]).toEqual(['asset-a']); + expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]); + expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe( + false, + ); + const allSelected = resolveAllAssetSelection({ + allSelectableAssetsSelected: false, + selectableAssets: assets, + }); + expect([...allSelected]).toEqual(['asset-a', 'asset-b']); + const removal = removeSelectedAssets(assets, new Set(['asset-b'])); + expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']); + expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']); + }); + + it('moves assets between folders locally', () => { + expect( + moveAssetToFolderLocally([createAsset()], 'asset-a', 'folder-role')[0], + ).toMatchObject({ folderId: 'folder-role' }); + }); +}); diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts new file mode 100644 index 00000000..7cebcd3d --- /dev/null +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts @@ -0,0 +1,216 @@ +import type { + EditorAsset, + EditorAssetFolder, +} from './ImageCanvasEditorTypes'; + +export type GroupedAssetFolder = EditorAssetFolder & { + assets: EditorAsset[]; +}; + +export function groupAssetsByFolder( + assetFolders: EditorAssetFolder[], + assets: EditorAsset[], +): GroupedAssetFolder[] { + return assetFolders.map((folder) => ({ + ...folder, + assets: assets.filter((asset) => asset.folderId === folder.id), + })); +} + +export function getSelectableAssets(assets: EditorAsset[]) { + return assets.filter((asset) => asset.sourceKind === 'uploaded'); +} + +export function areAllSelectableAssetsSelected( + selectableAssets: EditorAsset[], + selectedAssetIds: Set, +) { + return ( + selectableAssets.length > 0 && + selectableAssets.every((asset) => selectedAssetIds.has(asset.id)) + ); +} + +export function renameAssetById( + assets: EditorAsset[], + assetId: string, + label: string, +) { + return assets.map((asset) => + asset.id === assetId + ? { + ...asset, + label, + } + : asset, + ); +} + +export function toggleAssetFolderCollapsed( + assetFolders: EditorAssetFolder[], + folderId: string, +) { + return assetFolders.map((folder) => + folder.id === folderId + ? { + ...folder, + collapsed: !folder.collapsed, + } + : folder, + ); +} + +export function createLocalAssetFolder({ + folderId, + label, +}: { + folderId: string; + label: string; +}): EditorAssetFolder { + return { + id: folderId, + label, + collapsed: false, + systemDefault: false, + persisted: false, + }; +} + +export function replaceLocalAssetFolder({ + folders, + assets, + localFolderId, + persistedFolder, +}: { + folders: EditorAssetFolder[]; + assets: EditorAsset[]; + localFolderId: string; + persistedFolder: { + folderId: string; + label: string; + collapsed: boolean; + systemDefault: boolean; + }; +}) { + return { + folders: folders.map((folder) => + folder.id === localFolderId + ? { + id: persistedFolder.folderId, + label: persistedFolder.label, + collapsed: persistedFolder.collapsed, + systemDefault: persistedFolder.systemDefault, + persisted: true, + } + : folder, + ), + assets: assets.map((asset) => + asset.folderId === localFolderId + ? { + ...asset, + folderId: persistedFolder.folderId, + } + : asset, + ), + }; +} + +export function removeAssetById(assets: EditorAsset[], assetId: string) { + return assets.filter((asset) => asset.id !== assetId); +} + +export function renameAssetFolderById( + assetFolders: EditorAssetFolder[], + folderId: string, + label: string, +) { + return assetFolders.map((folder) => + folder.id === folderId + ? { + ...folder, + label, + } + : folder, + ); +} + +export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) { + return ( + assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0] ?? null + ); +} + +export function deleteAssetFolderLocally({ + folders, + assets, + folderId, + defaultFolderId, +}: { + folders: EditorAssetFolder[]; + assets: EditorAsset[]; + folderId: string; + defaultFolderId: string; +}) { + return { + folders: folders.filter((folder) => folder.id !== folderId), + assets: assets.map((asset) => + asset.folderId === folderId + ? { + ...asset, + folderId: defaultFolderId, + } + : asset, + ), + }; +} + +export function toggleAssetSelection( + selectedAssetIds: Set, + assetId: string, +) { + const nextIds = new Set(selectedAssetIds); + if (nextIds.has(assetId)) { + nextIds.delete(assetId); + } else { + nextIds.add(assetId); + } + return nextIds; +} + +export function resolveAllAssetSelection({ + allSelectableAssetsSelected, + selectableAssets, +}: { + allSelectableAssetsSelected: boolean; + selectableAssets: EditorAsset[]; +}) { + return allSelectableAssetsSelected + ? new Set() + : new Set(selectableAssets.map((asset) => asset.id)); +} + +export function removeSelectedAssets( + assets: EditorAsset[], + selectedAssetIds: Set, +) { + const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); + return { + assets: assets.filter((asset) => !selectedAssetIds.has(asset.id)), + deletedAssets, + }; +} + +export function moveAssetToFolderLocally( + assets: EditorAsset[], + assetId: string, + folderId: string, +) { + return assets.map((asset) => + asset.id === assetId + ? { + ...asset, + folderId, + } + : asset, + ); +} diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index 4d650bfb..8dd02e98 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -23,6 +23,23 @@ import { escapeCssIdentifier, normalizeAssetLibrary, } from './ImageCanvasEditorModel'; +import { + areAllSelectableAssetsSelected, + createLocalAssetFolder, + deleteAssetFolderLocally, + getSelectableAssets, + groupAssetsByFolder, + moveAssetToFolderLocally, + removeAssetById, + removeSelectedAssets, + renameAssetById, + renameAssetFolderById, + replaceLocalAssetFolder, + resolveAllAssetSelection, + resolveDefaultAssetFolder, + toggleAssetFolderCollapsed, + toggleAssetSelection, +} from './ImageCanvasAssetLibraryModel'; import type { AssetMarqueeState, AssetPointerDragState, @@ -80,20 +97,17 @@ export function useImageCanvasAssetLibrary({ >(null); const groupedAssets = useMemo( - () => - assetFolders.map((folder) => ({ - ...folder, - assets: assets.filter((asset) => asset.folderId === folder.id), - })), + () => groupAssetsByFolder(assetFolders, assets), [assetFolders, assets], ); const selectableAssets = useMemo( - () => assets.filter((asset) => asset.sourceKind === 'uploaded'), + () => getSelectableAssets(assets), [assets], ); - const allSelectableAssetsSelected = - selectableAssets.length > 0 && - selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); + const allSelectableAssetsSelected = areAllSelectableAssetsSelected( + selectableAssets, + selectedAssetIds, + ); useEffect(() => { if (!canAccessProtectedData) { @@ -196,14 +210,7 @@ export function useImageCanvasAssetLibrary({ return; } setAssets((currentAssets) => - currentAssets.map((currentAsset) => - currentAsset.id === asset.id - ? { - ...currentAsset, - label: nextLabel, - } - : currentAsset, - ), + renameAssetById(currentAssets, asset.id, nextLabel), ); if (asset.persisted) { updateEditorAsset(asset.id, { label: nextLabel }).catch(() => {}); @@ -218,14 +225,7 @@ export function useImageCanvasAssetLibrary({ const nextFolder = assetFolders.find((folder) => folder.id === folderId); const nextCollapsed = !(nextFolder?.collapsed ?? false); setAssetFolders((currentFolders) => - currentFolders.map((folder) => - folder.id === folderId - ? { - ...folder, - collapsed: !folder.collapsed, - } - : folder, - ), + toggleAssetFolderCollapsed(currentFolders, folderId), ); if (nextFolder?.persisted) { updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch( @@ -246,13 +246,7 @@ export function useImageCanvasAssetLibrary({ const folderId = `folder-${Date.now()}`; setAssetFolders((currentFolders) => [ ...currentFolders, - { - id: folderId, - label, - collapsed: false, - systemDefault: false, - persisted: false, - }, + createLocalAssetFolder({ folderId, label }), ]); setActiveUploadFolderId(folderId); setCreatingFolder(false); @@ -263,27 +257,20 @@ export function useImageCanvasAssetLibrary({ assetFolders.length + 100, ); setAssetFolders((currentFolders) => - currentFolders.map((currentFolder) => - currentFolder.id === folderId - ? { - id: folder.folderId, - label: folder.label, - collapsed: folder.collapsed, - systemDefault: folder.systemDefault, - persisted: true, - } - : currentFolder, - ), + replaceLocalAssetFolder({ + folders: currentFolders, + assets: [], + localFolderId: folderId, + persistedFolder: folder, + }).folders, ); setAssets((currentAssets) => - currentAssets.map((asset) => - asset.folderId === folderId - ? { - ...asset, - folderId: folder.folderId, - } - : asset, - ), + replaceLocalAssetFolder({ + folders: [], + assets: currentAssets, + localFolderId: folderId, + persistedFolder: folder, + }).assets, ); setActiveUploadFolderId(folder.folderId); } catch { @@ -296,9 +283,7 @@ export function useImageCanvasAssetLibrary({ if (asset.sourceKind !== 'uploaded') { return; } - setAssets((currentAssets) => - currentAssets.filter((currentAsset) => currentAsset.id !== asset.id), - ); + setAssets((currentAssets) => removeAssetById(currentAssets, asset.id)); onDeleteAssets?.([asset]); setRenamingAsset((currentRename) => currentRename?.assetId === asset.id ? null : currentRename, @@ -325,14 +310,7 @@ export function useImageCanvasAssetLibrary({ return; } setAssetFolders((currentFolders) => - currentFolders.map((currentFolder) => - currentFolder.id === folder.id - ? { - ...currentFolder, - label: nextLabel, - } - : currentFolder, - ), + renameAssetFolderById(currentFolders, folder.id, nextLabel), ); if (folder.persisted) { updateEditorAssetFolder(folder.id, { label: nextLabel }).catch( @@ -349,26 +327,25 @@ export function useImageCanvasAssetLibrary({ if (folder.systemDefault) { return; } - const defaultFolder = - assetFolders.find((currentFolder) => currentFolder.systemDefault) ?? - assetFolders[0]; + const defaultFolder = resolveDefaultAssetFolder(assetFolders); if (!defaultFolder) { return; } setAssetFolders((currentFolders) => - currentFolders.filter( - (currentFolder) => currentFolder.id !== folder.id, - ), + deleteAssetFolderLocally({ + folders: currentFolders, + assets: [], + folderId: folder.id, + defaultFolderId: defaultFolder.id, + }).folders, ); setAssets((currentAssets) => - currentAssets.map((asset) => - asset.folderId === folder.id - ? { - ...asset, - folderId: defaultFolder.id, - } - : asset, - ), + deleteAssetFolderLocally({ + folders: [], + assets: currentAssets, + folderId: folder.id, + defaultFolderId: defaultFolder.id, + }).assets, ); if (folder.persisted) { deleteEditorAssetFolder(folder.id) @@ -384,32 +361,26 @@ export function useImageCanvasAssetLibrary({ ); const toggleAssetSelected = useCallback((assetId: string) => { - setSelectedAssetIds((currentIds) => { - const nextIds = new Set(currentIds); - if (nextIds.has(assetId)) { - nextIds.delete(assetId); - } else { - nextIds.add(assetId); - } - return nextIds; - }); + setSelectedAssetIds((currentIds) => + toggleAssetSelection(currentIds, assetId), + ); }, []); const toggleAllAssetsSelected = useCallback(() => { setSelectedAssetIds( - allSelectableAssetsSelected - ? new Set() - : new Set(selectableAssets.map((asset) => asset.id)), + resolveAllAssetSelection({ + allSelectableAssetsSelected, + selectableAssets, + }), ); }, [allSelectableAssetsSelected, selectableAssets]); const deleteSelectedAssets = useCallback(() => { const ids = [...selectedAssetIds]; - const deletedAssets = assets.filter((asset) => - selectedAssetIds.has(asset.id), - ); - setAssets((currentAssets) => - currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), + const deletedAssets = removeSelectedAssets(assets, selectedAssetIds) + .deletedAssets; + setAssets( + (currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets, ); onDeleteAssets?.(deletedAssets); setSelectedAssetIds(new Set()); @@ -425,14 +396,7 @@ export function useImageCanvasAssetLibrary({ return; } setAssets((currentAssets) => - currentAssets.map((currentAsset) => - currentAsset.id === assetId - ? { - ...currentAsset, - folderId, - } - : currentAsset, - ), + moveAssetToFolderLocally(currentAssets, assetId, folderId), ); if (asset.persisted) { updateEditorAsset(asset.id, { folderId }).catch(() => {});