From eb583107f4ed439959057854302334005d210f38 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 06:17:34 +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=E7=B4=A0=E6=9D=90=E5=BA=93=E7=8A=B6=E6=80=81=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增图片画布素材库状态 hook 补充素材库状态 hook 单测 收口主视图素材库文件夹与选择逻辑 更新图片画布前端拆分跟踪文档 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 11 +- .../image-editor/ImageCanvasEditorView.tsx | 605 +++-------------- .../useImageCanvasAssetLibrary.test.tsx | 392 +++++++++++ .../useImageCanvasAssetLibrary.ts | 618 ++++++++++++++++++ 5 files changed, 1109 insertions(+), 518 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasAssetLibrary.test.tsx create mode 100644 src/components/image-editor/useImageCanvasAssetLibrary.ts diff --git a/TRACKING.md b/TRACKING.md index 845181af..66848a34 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -124,3 +124,4 @@ - 2026-06-17 前端拆分第七阶段:新增 `useCanvasHistory`,把画布历史快照、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态从主视图抽出;主视图只在具体动作前捕获历史,并注入恢复快照后的菜单 / hover / 框选 / 拖拽清理。验证命令:`npm run test -- src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。 - 2026-06-17 前端拆分第八阶段:新增 `useImageCanvasProjectPersistence`,把项目加载、`projectId` 状态、未就绪资源队列、工程资源创建、资源创建后即时保存和 450ms 自动保存从主视图抽出;新增 hook 单测锁定新增图层资源创建后保存真实 `resourceId` 的 layout。验证命令:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。 - 2026-06-17 前端拆分第九阶段:新增 `useCanvasGenerationDialogs`,把画布生成对象的 active / inactive 注册表、归档、激活、按 id 更新 / 删除、按图层清理和生成中最新占位框查询从主视图抽出;主视图继续保留生成提交、结果落图、quick edit 和跨图层副作用。同步把 `画布背景设置` 调整为 Lovart 式紧凑色板弹层。验证命令:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.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` 清空会话后未登录弹出 `账号入口`,关闭后点击 `画布背景色` 显示色域、色相条、圆形预设和 HEX 输入,点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 对话框,`AI画布工具栏` 保持可见。 +- 2026-06-17 前端拆分第十阶段:新增 `useImageCanvasAssetLibrary`,把账号级素材库加载、文件夹新建 / 折叠 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和素材库 401 登录弹窗从主视图抽出;主视图继续保留上传读取、上传进度、拖到画布坐标、画布图层创建和工程资源持久化。新增 hook 单测覆盖素材库归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.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`,点击 `生成工具` 后生成占位和 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见;登录临时开发账号后上传图片成功进入 `项目素材`,点击素材加入画布,切换 `图层` 可看到对应图层,控制台无前端 error。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index ad240cf7..7eacca13 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -92,14 +92,21 @@ - 该 hook 只处理 `generate/spec/character/icon` 这类画布生成对象,继续保留 `GenerateDialogState.mode === 'edit'` 的直接 `setGenerateDialog` 能力用于低风险迁移;真实生成请求、quick edit、角色动画、结果落图和资源持久化仍留在主视图。 - 主视图通过 `onActivate` 处理激活生成对象后的清空图层选择和关闭图片菜单等跨状态副作用,避免 dialog hook 反向依赖画布图层状态。 +## 第十阶段模块 + +- `useImageCanvasAssetLibrary.ts` + - 承载账号级素材库状态模型:素材文件夹、素材列表、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和鉴权失败登录弹窗。 + - 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。 + - 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。 + ## 后续阶段 - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 -- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。 +- 上传状态模型:上传占位卡片、读取图片、登录后续传、素材落库和拖到画布创建图层仍在主视图与侧栏之间协作,后续需要等上传错误恢复规则进一步稳定后再收口。 ## 验证计划 -- `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx` +- `npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.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 f687ba94..91545d7a 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -16,9 +16,6 @@ import { ApiClientError } from '../../services/apiClient'; import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; import { createEditorAsset, - createEditorAssetFolder, - deleteEditorAsset, - deleteEditorAssetFolder, editEditorImage, type EditorIconSpritesheetGenerationResult, type EditorIconSpritesheetIconResult, @@ -26,10 +23,7 @@ import { generateEditorCharacterAnimation, generateEditorIconSpritesheet, generateEditorImage, - loadEditorAssetLibrary, renameEditorProject, - updateEditorAsset, - updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; @@ -75,17 +69,14 @@ import { ASSET_DRAG_MIME_TYPE, DEFAULT_CANVAS_BACKGROUND_COLOR, DEFAULT_CANVAS_SIZE, - EDITOR_ASSET_FOLDERS, TOOLBAR_HALF_WIDTH, clamp, createLayerFromAsset, - escapeCssIdentifier, formatImageSizeValue, getDraggedAssetId, hasDataTransferType, isGeneratedLayer, isLayerLinkedToAsset, - normalizeAssetLibrary, normalizeCanvasBackgroundHex, resolveContextMenuPosition, resolveLayerResolutionSize, @@ -137,7 +128,6 @@ import { resolveImageGenerationErrorMessage, } from './ImageCanvasGenerationModel'; import type { - AssetMarqueeState, AssetPointerDragState, CanvasAssetExportImage, CanvasAssetExportMetadata, @@ -152,7 +142,6 @@ import type { CharacterAnimationPanelState, DragState, EditorAsset, - EditorAssetFolder, GenerateDialogState, ImageContextMenuState, QuickEditPanelState, @@ -164,6 +153,10 @@ import type { } from './ImageCanvasEditorTypes'; import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; +import { + readImageFileAsDataUrl, + useImageCanvasAssetLibrary, +} from './useImageCanvasAssetLibrary'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; function isImageFile(file: File) { @@ -280,36 +273,7 @@ export function ImageCanvasEditorView() { scale: 0.82, }); const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); - const [assetFolders, setAssetFolders] = - useState(EDITOR_ASSET_FOLDERS); - const [assets, setAssets] = useState([]); const [layers, setLayers] = useState([]); - const [renamingAsset, setRenamingAsset] = useState<{ - assetId: string; - value: string; - } | null>(null); - const [renamingFolder, setRenamingFolder] = useState<{ - folderId: string; - value: string; - } | null>(null); - const [creatingFolder, setCreatingFolder] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); - const [activeUploadFolderId, setActiveUploadFolderId] = useState('project'); - const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false); - const [selectedAssetIds, setSelectedAssetIds] = useState>( - () => new Set(), - ); - const [assetMarquee, setAssetMarquee] = useState( - null, - ); - const [assetPointerDrag, setAssetPointerDrag] = - useState(null); - const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState< - string | null - >(null); - const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState< - string | null - >(null); const [canvasMarquee, setCanvasMarquee] = useState( null, ); @@ -360,7 +324,7 @@ export function ImageCanvasEditorView() { selectedLayerIdsRef.current = selectedLayerIds; layersRef.current = layers; viewportRef.current = viewport; - const assetsRef = useRef(assets); + const assetsRef = useRef([]); const addAssetLayerRef = useRef< (asset: EditorAsset, screenCenter?: { x: number; y: number }) => void >(() => {}); @@ -383,6 +347,90 @@ export function ImageCanvasEditorView() { setCanvasBackgroundHexValue(normalizedColor); return true; }, []); + const removeCanvasLayersLinkedToAssets = useCallback( + (deletedAssets: EditorAsset[]) => { + if (!deletedAssets.length) { + return; + } + setLayers((currentLayers) => + currentLayers.filter( + (layer) => + !deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), + ), + ); + setSelectedLayerIds((currentIds) => + currentIds.filter((layerId) => + layers.every( + (layer) => + layer.id !== layerId || + !deletedAssets.some((asset) => + isLayerLinkedToAsset(layer, asset), + ), + ), + ), + ); + setSelectedLayerId((currentId) => { + if (!currentId) { + return currentId; + } + const currentLayer = layers.find((layer) => layer.id === currentId); + return currentLayer && + deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) + ? null + : currentId; + }); + }, + [layers], + ); + const { + assetFolders, + setAssetFolders, + assets, + setAssets, + groupedAssets, + allSelectableAssetsSelected, + renamingAsset, + setRenamingAsset, + renamingFolder, + setRenamingFolder, + creatingFolder, + setCreatingFolder, + newFolderName, + setNewFolderName, + activeUploadFolderId, + setActiveUploadFolderId, + isAssetSelectionMode, + setIsAssetSelectionMode, + selectedAssetIds, + setSelectedAssetIds, + assetMarquee, + assetPointerDrag, + setAssetPointerDrag, + assetMoveDropFolderId, + pinnedAssetMoveFolderId, + resolveAssetFolderId, + updateAssetMoveDropFolder, + startRenamingAsset, + commitAssetRename, + toggleAssetFolder, + commitNewAssetFolder, + deleteUploadedAsset, + startRenamingFolder, + commitFolderRename, + deleteAssetFolder, + toggleAssetSelected, + toggleAllAssetsSelected, + deleteSelectedAssets, + moveAssetToFolder, + closeAssetSelectionMode, + handleAssetMarqueePointerDown, + handleAssetMarqueePointerMove, + handleAssetMarqueePointerUp, + } = useImageCanvasAssetLibrary({ + assetListRef, + openEditorLoginModal, + onDeleteAssets: removeCanvasLayersLinkedToAssets, + }); useEffect(() => { assetsRef.current = assets; @@ -600,22 +648,6 @@ export function ImageCanvasEditorView() { setters: canvasHistorySetters, resetters: canvasHistoryResetters, }); - const groupedAssets = useMemo( - () => - assetFolders.map((folder) => ({ - ...folder, - assets: assets.filter((asset) => asset.folderId === folder.id), - })), - [assetFolders, assets], - ); - const selectableAssets = useMemo( - () => assets.filter((asset) => asset.sourceKind === 'uploaded'), - [assets], - ); - const allSelectableAssetsSelected = - selectableAssets.length > 0 && - selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); - const selectSingleLayer = useCallback((layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); @@ -689,33 +721,6 @@ export function ImageCanvasEditorView() { [canvasSize, layers, viewport], ); - useEffect(() => { - let cancelled = false; - loadEditorAssetLibrary() - .then((library) => { - if (cancelled) { - return; - } - const nextLibrary = normalizeAssetLibrary(library); - setAssetFolders(nextLibrary.folders); - setAssets(nextLibrary.assets); - const defaultFolder = nextLibrary.folders.find( - (folder) => folder.systemDefault, - ); - setActiveUploadFolderId( - defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project', - ); - }) - .catch((error: unknown) => { - if (!cancelled && isEditorAuthError(error)) { - openEditorLoginModal(); - } - }); - return () => { - cancelled = true; - }; - }, [openEditorLoginModal]); - useEffect(() => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { @@ -1194,56 +1199,6 @@ export function ImageCanvasEditorView() { setContextMenu(null); }; - const resolveAssetFolderId = (clientX: number, clientY: number) => { - const listElement = assetListRef.current; - 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 matchedFolder?.dataset.assetFolderId ?? null; - }; - - const updateAssetMoveDropFolder = (folderId: string | null) => { - setAssetMoveDropFolderId(folderId); - if (!folderId) { - setPinnedAssetMoveFolderId(null); - return; - } - const listElement = assetListRef.current; - 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, - ); - }; - const addAssetLayer = ( asset: EditorAsset, position?: { x: number; y: number }, @@ -1460,390 +1415,8 @@ export function ImageCanvasEditorView() { .finally(() => setIsProjectRenameSaving(false)); }; - const startRenamingAsset = (asset: EditorAsset) => { - setRenamingAsset({ - assetId: asset.id, - value: asset.label, - }); - }; - - const commitAssetRename = (asset: EditorAsset) => { - const nextLabel = renamingAsset?.value.trim(); - if (!nextLabel) { - setRenamingAsset(null); - return; - } - setAssets((currentAssets) => - currentAssets.map((currentAsset) => - currentAsset.id === asset.id - ? { - ...currentAsset, - label: nextLabel, - } - : currentAsset, - ), - ); - if (asset.persisted) { - updateEditorAsset(asset.id, { label: nextLabel }).catch(() => {}); - } - setRenamingAsset(null); - }; - - const toggleAssetFolder = (folderId: string) => { - 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, - ), - ); - if (nextFolder?.persisted) { - updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch( - () => {}, - ); - } - }; - - const commitNewAssetFolder = async () => { - const label = newFolderName.trim(); - if (!label) { - setCreatingFolder(false); - setNewFolderName(''); - return; - } - const folderId = `folder-${Date.now()}`; - setAssetFolders((currentFolders) => [ - ...currentFolders, - { - id: folderId, - label, - collapsed: false, - systemDefault: false, - persisted: false, - }, - ]); - setActiveUploadFolderId(folderId); - setCreatingFolder(false); - setNewFolderName(''); - try { - const folder = await createEditorAssetFolder( - label, - 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, - ), - ); - setAssets((currentAssets) => - currentAssets.map((asset) => - asset.folderId === folderId - ? { - ...asset, - folderId: folder.folderId, - } - : asset, - ), - ); - setActiveUploadFolderId(folder.folderId); - } catch { - // 本地临时文件夹仍可继续使用;下次刷新以后端为准。 - } - }; - - const deleteUploadedAsset = (asset: EditorAsset) => { - if (asset.sourceKind !== 'uploaded') { - return; - } - setAssets((currentAssets) => - currentAssets.filter((currentAsset) => currentAsset.id !== asset.id), - ); - setLayers((currentLayers) => - currentLayers.filter((layer) => !isLayerLinkedToAsset(layer, asset)), - ); - setSelectedLayerIds((currentIds) => - currentIds.filter((layerId) => - layers.every( - (layer) => - layer.id !== layerId || !isLayerLinkedToAsset(layer, asset), - ), - ), - ); - setSelectedLayerId((currentId) => { - if (!currentId) { - return currentId; - } - const currentLayer = layers.find((layer) => layer.id === currentId); - return currentLayer && isLayerLinkedToAsset(currentLayer, asset) - ? null - : currentId; - }); - setRenamingAsset((currentRename) => - currentRename?.assetId === asset.id ? null : currentRename, - ); - if (asset.persisted) { - deleteEditorAsset(asset.id).catch(() => {}); - } - }; - - const startRenamingFolder = (folder: EditorAssetFolder) => { - setRenamingFolder({ - folderId: folder.id, - value: folder.label, - }); - }; - - const commitFolderRename = (folder: EditorAssetFolder) => { - const nextLabel = renamingFolder?.value.trim(); - if (!nextLabel) { - setRenamingFolder(null); - return; - } - setAssetFolders((currentFolders) => - currentFolders.map((currentFolder) => - currentFolder.id === folder.id - ? { - ...currentFolder, - label: nextLabel, - } - : currentFolder, - ), - ); - if (folder.persisted) { - updateEditorAssetFolder(folder.id, { label: nextLabel }).catch(() => {}); - } - setRenamingFolder(null); - }; - - const deleteAssetFolder = (folder: EditorAssetFolder) => { - if (folder.systemDefault) { - return; - } - const defaultFolder = - assetFolders.find((currentFolder) => currentFolder.systemDefault) ?? - assetFolders[0]; - if (!defaultFolder) { - return; - } - setAssetFolders((currentFolders) => - currentFolders.filter((currentFolder) => currentFolder.id !== folder.id), - ); - setAssets((currentAssets) => - currentAssets.map((asset) => - asset.folderId === folder.id - ? { - ...asset, - folderId: defaultFolder.id, - } - : asset, - ), - ); - if (folder.persisted) { - deleteEditorAssetFolder(folder.id) - .then((library) => { - const nextLibrary = normalizeAssetLibrary(library); - setAssetFolders(nextLibrary.folders); - setAssets(nextLibrary.assets); - }) - .catch(() => {}); - } - }; - - const toggleAssetSelected = (assetId: string) => { - setSelectedAssetIds((currentIds) => { - const nextIds = new Set(currentIds); - if (nextIds.has(assetId)) { - nextIds.delete(assetId); - } else { - nextIds.add(assetId); - } - return nextIds; - }); - }; - - const toggleAllAssetsSelected = () => { - setSelectedAssetIds( - allSelectableAssetsSelected - ? new Set() - : new Set(selectableAssets.map((asset) => asset.id)), - ); - }; - - const deleteSelectedAssets = () => { - const ids = [...selectedAssetIds]; - const deletedAssets = assets.filter((asset) => - selectedAssetIds.has(asset.id), - ); - setAssets((currentAssets) => - currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), - ); - setLayers((currentLayers) => - currentLayers.filter( - (layer) => - !deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), - ), - ); - setSelectedAssetIds(new Set()); - ids.forEach((assetId) => { - void deleteEditorAsset(assetId); - }); - }; - - const moveAssetToFolder = (assetId: string, folderId: string) => { - const asset = assets.find((currentAsset) => currentAsset.id === assetId); - if (!asset || asset.folderId === folderId) { - return; - } - setAssets((currentAssets) => - currentAssets.map((currentAsset) => - currentAsset.id === assetId - ? { - ...currentAsset, - folderId, - } - : currentAsset, - ), - ); - if (asset.persisted) { - updateEditorAsset(asset.id, { folderId }).catch(() => {}); - } - }; - moveAssetToFolderRef.current = moveAssetToFolder; - const closeAssetSelectionMode = () => { - setIsAssetSelectionMode(false); - setSelectedAssetIds(new Set()); - setAssetMarquee(null); - }; - - const updateAssetSelectionFromMarquee = (selectionRect: { - left: number; - right: number; - 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); - }; - - const handleAssetMarqueePointerDown = ( - event: ReactPointerEvent, - ) => { - if (!isAssetSelectionMode || event.button !== 0) { - return; - } - const target = event.target as HTMLElement; - if (target.closest('button, input, textarea, select, [data-asset-id]')) { - 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, - }); - setSelectedAssetIds(new Set()); - }; - - const handleAssetMarqueePointerMove = ( - event: ReactPointerEvent, - ) => { - if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) { - return; - } - 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, - } - : 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), - }); - }; - - const handleAssetMarqueePointerUp = ( - event: ReactPointerEvent, - ) => { - if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) { - return; - } - event.preventDefault(); - if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) { - assetListRef.current.releasePointerCapture?.(event.pointerId); - } - setAssetMarquee(null); - }; - - const readImageFileAsDataUrl = (file: File) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - return; - } - reject(new Error('图片读取失败')); - }; - reader.onerror = () => reject(reader.error ?? new Error('图片读取失败')); - reader.readAsDataURL(file); - }); - const setCharacterGenerationIdle = (dialog: GenerateDialogState) => ({ ...dialog, status: dialog.status === 'failed' ? 'idle' : dialog.status, diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx new file mode 100644 index 00000000..2890705d --- /dev/null +++ b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx @@ -0,0 +1,392 @@ +/* @vitest-environment jsdom */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import { useRef } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiClientError } from '../../services/apiClient'; +import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient'; +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; + +const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); +const deleteEditorAssetMock = vi.hoisted(() => vi.fn()); +const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn()); +const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); +const updateEditorAssetMock = vi.hoisted(() => vi.fn()); +const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); + +const defaultOpenEditorLoginModal = () => {}; +const defaultOnDeleteAssets = () => {}; + +vi.mock('../../services/image-editor/editorProjectClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/image-editor/editorProjectClient') + >('../../services/image-editor/editorProjectClient'); + return { + ...actual, + createEditorAssetFolder: createEditorAssetFolderMock, + deleteEditorAsset: deleteEditorAssetMock, + deleteEditorAssetFolder: deleteEditorAssetFolderMock, + loadEditorAssetLibrary: loadEditorAssetLibraryMock, + updateEditorAsset: updateEditorAssetMock, + updateEditorAssetFolder: updateEditorAssetFolderMock, + }; +}); + +function createUploadedAsset(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, + }; +} + +function createAssetSnapshot( + overrides: Partial = {}, +): EditorAssetSnapshot { + return { + assetId: 'asset-a', + label: '素材A', + imageSrc: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + folderId: 'project', + sourceType: 'uploaded', + ...overrides, + }; +} + +function AssetLibraryHarness({ + openEditorLoginModal = defaultOpenEditorLoginModal, + onDeleteAssets = defaultOnDeleteAssets, +}: { + openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void; + onDeleteAssets?: (assets: EditorAsset[]) => void; +}) { + const assetListRef = useRef(null); + const assetLibrary = useImageCanvasAssetLibrary({ + assetListRef, + openEditorLoginModal, + onDeleteAssets, + }); + + return ( +
+
+ {assetLibrary.groupedAssets.map((folder) => ( +
+

{folder.label}

+ {folder.assets.map((asset, index) => ( + + ))} +
+ ))} +
+ + {assetLibrary.assetFolders + .map((folder) => `${folder.id}:${folder.label}:${folder.persisted}`) + .join('|')} + + + {assetLibrary.assets + .map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`) + .join('|')} + + + {assetLibrary.activeUploadFolderId} + + + {String(assetLibrary.allSelectableAssetsSelected)} + + + {[...assetLibrary.selectedAssetIds].join('|')} + + {assetLibrary.newFolderName} + + + + + + +
+ ); +} + +describe('useImageCanvasAssetLibrary', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadEditorAssetLibraryMock.mockResolvedValue({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [createAssetSnapshot()], + }); + createEditorAssetFolderMock.mockResolvedValue({ + folderId: 'folder-role', + label: '角色素材', + collapsed: false, + systemDefault: false, + }); + updateEditorAssetMock.mockResolvedValue(createAssetSnapshot()); + deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot()); + deleteEditorAssetFolderMock.mockResolvedValue({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }); + updateEditorAssetFolderMock.mockResolvedValue({ + folderId: 'project', + label: '项目素材', + collapsed: false, + systemDefault: true, + }); + }); + + it('loads and normalizes the account asset library', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + { + folderId: 'project-duplicate', + label: '重复默认', + sortOrder: 1, + collapsed: false, + systemDefault: true, + }, + ], + assets: [createAssetSnapshot()], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('folders').textContent).toBe( + 'project:项目素材:true', + ); + }); + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-a:素材A:project', + ); + expect(screen.getByTestId('active-upload-folder').textContent).toBe( + 'project', + ); + }); + + it('opens login when loading the asset library is unauthorized', async () => { + const openEditorLoginModal = vi.fn(); + loadEditorAssetLibraryMock.mockRejectedValueOnce( + new ApiClientError({ + message: '未授权访问', + status: 401, + code: 'UNAUTHORIZED', + }), + ); + + render( + , + ); + + await waitFor(() => { + expect(openEditorLoginModal).toHaveBeenCalledTimes(1); + }); + }); + + it('creates a local folder and replaces it with the persisted folder id', async () => { + let resolveCreateFolder: ( + folder: Awaited>, + ) => void = () => {}; + createEditorAssetFolderMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreateFolder = resolve; + }), + ); + render(); + + await screen.findByText('素材A'); + act(() => screen.getByRole('button', { name: 'prepare folder' }).click()); + await waitFor(() => { + expect(screen.getByTestId('new-folder-name').textContent).toBe( + '角色素材', + ); + }); + act(() => screen.getByRole('button', { name: 'commit folder' }).click()); + await waitFor(() => { + expect(screen.getByTestId('folders').textContent).toContain( + 'folder-', + ); + }); + act(() => { + resolveCreateFolder({ + folderId: 'folder-role', + label: '角色素材', + collapsed: false, + systemDefault: false, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('folders').textContent).toContain( + 'folder-role:角色素材:true', + ); + }); + expect(createEditorAssetFolderMock).toHaveBeenCalledWith('角色素材', 101); + expect(screen.getByTestId('active-upload-folder').textContent).toBe( + 'folder-role', + ); + }); + + it('moves a persisted asset to another folder', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + { + folderId: 'folder-role', + label: '角色素材', + sortOrder: 1, + collapsed: false, + systemDefault: false, + }, + ], + assets: [createAssetSnapshot()], + }); + + render(); + + await screen.findByText('素材A'); + act(() => screen.getByRole('button', { name: 'move asset' }).click()); + + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-a:素材A:folder-role', + ); + expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', { + folderId: 'folder-role', + }); + }); + + it('deletes uploaded assets and reports them to the canvas cleanup callback', async () => { + const onDeleteAssets = vi.fn(); + render(); + + await screen.findByText('素材A'); + act(() => screen.getByRole('button', { name: 'delete asset' }).click()); + + expect(screen.getByTestId('assets').textContent).toBe(''); + expect(onDeleteAssets).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'asset-a' }), + ]); + expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); + }); + + it('selects and deletes selected uploaded assets', async () => { + const onDeleteAssets = vi.fn(); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ assetId: 'asset-a', label: '素材A' }), + createAssetSnapshot({ assetId: 'asset-b', label: '素材B' }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => screen.getByRole('button', { name: 'toggle all' }).click()); + expect(screen.getByTestId('all-selected').textContent).toBe('true'); + act(() => screen.getByRole('button', { name: '素材B' }).click()); + await waitFor(() => { + expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a'); + }); + act(() => screen.getByRole('button', { name: 'delete selected' }).click()); + + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-b:素材B:project', + ); + expect(onDeleteAssets).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'asset-a' }), + ]); + expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); + expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b'); + }); +}); diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts new file mode 100644 index 00000000..13f7e870 --- /dev/null +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -0,0 +1,618 @@ +import { + type Dispatch, + type PointerEvent as ReactPointerEvent, + type RefObject, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +import { ApiClientError } from '../../services/apiClient'; +import { + createEditorAssetFolder, + deleteEditorAsset, + deleteEditorAssetFolder, + loadEditorAssetLibrary, + updateEditorAsset, + updateEditorAssetFolder, +} from '../../services/image-editor/editorProjectClient'; +import { + EDITOR_ASSET_FOLDERS, + escapeCssIdentifier, + normalizeAssetLibrary, +} from './ImageCanvasEditorModel'; +import type { + AssetMarqueeState, + AssetPointerDragState, + EditorAsset, + EditorAssetFolder, +} from './ImageCanvasEditorTypes'; + +function isEditorAuthError(error: unknown) { + return ( + error instanceof ApiClientError && + (error.status === 401 || error.status === 403) + ); +} + +export function readImageFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + return; + } + reject(new Error('图片读取失败')); + }; + reader.onerror = () => reject(reader.error ?? new Error('图片读取失败')); + reader.readAsDataURL(file); + }); +} + +export function useImageCanvasAssetLibrary({ + assetListRef, + openEditorLoginModal, + onDeleteAssets, +}: { + assetListRef: RefObject; + openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; + onDeleteAssets?: (assets: EditorAsset[]) => void; +}) { + const [assetFolders, setAssetFolders] = + useState(EDITOR_ASSET_FOLDERS); + const [assets, setAssets] = useState([]); + const [renamingAsset, setRenamingAsset] = useState<{ + assetId: string; + value: string; + } | null>(null); + const [renamingFolder, setRenamingFolder] = useState<{ + folderId: string; + value: string; + } | null>(null); + const [creatingFolder, setCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [activeUploadFolderId, setActiveUploadFolderId] = useState('project'); + const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false); + const [selectedAssetIds, setSelectedAssetIds] = useState>( + () => new Set(), + ); + const [assetMarquee, setAssetMarquee] = useState( + null, + ); + const [assetPointerDrag, setAssetPointerDrag] = + useState(null); + const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState< + string | null + >(null); + const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState< + string | null + >(null); + + const groupedAssets = useMemo( + () => + assetFolders.map((folder) => ({ + ...folder, + assets: assets.filter((asset) => asset.folderId === folder.id), + })), + [assetFolders, assets], + ); + const selectableAssets = useMemo( + () => assets.filter((asset) => asset.sourceKind === 'uploaded'), + [assets], + ); + const allSelectableAssetsSelected = + selectableAssets.length > 0 && + selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); + + useEffect(() => { + let cancelled = false; + loadEditorAssetLibrary() + .then((library) => { + if (cancelled) { + return; + } + const nextLibrary = normalizeAssetLibrary(library); + setAssetFolders(nextLibrary.folders); + setAssets(nextLibrary.assets); + const defaultFolder = nextLibrary.folders.find( + (folder) => folder.systemDefault, + ); + setActiveUploadFolderId( + defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project', + ); + }) + .catch((error: unknown) => { + if (!cancelled && isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); + return () => { + cancelled = true; + }; + }, [openEditorLoginModal]); + + const resolveAssetFolderId = useCallback( + (clientX: number, clientY: number) => { + const listElement = assetListRef.current; + 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 matchedFolder?.dataset.assetFolderId ?? null; + }, + [assetListRef], + ); + + const updateAssetMoveDropFolder = useCallback( + (folderId: string | null) => { + setAssetMoveDropFolderId(folderId); + if (!folderId) { + setPinnedAssetMoveFolderId(null); + return; + } + const listElement = assetListRef.current; + 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, + ); + }, + [assetListRef], + ); + + const startRenamingAsset = useCallback((asset: EditorAsset) => { + setRenamingAsset({ + assetId: asset.id, + value: asset.label, + }); + }, []); + + const commitAssetRename = useCallback( + (asset: EditorAsset) => { + const nextLabel = renamingAsset?.value.trim(); + if (!nextLabel) { + setRenamingAsset(null); + return; + } + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === asset.id + ? { + ...currentAsset, + label: nextLabel, + } + : currentAsset, + ), + ); + if (asset.persisted) { + updateEditorAsset(asset.id, { label: nextLabel }).catch(() => {}); + } + setRenamingAsset(null); + }, + [renamingAsset], + ); + + const toggleAssetFolder = useCallback( + (folderId: string) => { + 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, + ), + ); + if (nextFolder?.persisted) { + updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch( + () => {}, + ); + } + }, + [assetFolders], + ); + + const commitNewAssetFolder = useCallback(async () => { + const label = newFolderName.trim(); + if (!label) { + setCreatingFolder(false); + setNewFolderName(''); + return; + } + const folderId = `folder-${Date.now()}`; + setAssetFolders((currentFolders) => [ + ...currentFolders, + { + id: folderId, + label, + collapsed: false, + systemDefault: false, + persisted: false, + }, + ]); + setActiveUploadFolderId(folderId); + setCreatingFolder(false); + setNewFolderName(''); + try { + const folder = await createEditorAssetFolder( + label, + 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, + ), + ); + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.folderId === folderId + ? { + ...asset, + folderId: folder.folderId, + } + : asset, + ), + ); + setActiveUploadFolderId(folder.folderId); + } catch { + // 本地临时文件夹仍可继续使用;下次刷新以后端为准。 + } + }, [assetFolders.length, newFolderName]); + + const deleteUploadedAsset = useCallback( + (asset: EditorAsset) => { + if (asset.sourceKind !== 'uploaded') { + return; + } + setAssets((currentAssets) => + currentAssets.filter((currentAsset) => currentAsset.id !== asset.id), + ); + onDeleteAssets?.([asset]); + setRenamingAsset((currentRename) => + currentRename?.assetId === asset.id ? null : currentRename, + ); + if (asset.persisted) { + deleteEditorAsset(asset.id).catch(() => {}); + } + }, + [onDeleteAssets], + ); + + const startRenamingFolder = useCallback((folder: EditorAssetFolder) => { + setRenamingFolder({ + folderId: folder.id, + value: folder.label, + }); + }, []); + + const commitFolderRename = useCallback( + (folder: EditorAssetFolder) => { + const nextLabel = renamingFolder?.value.trim(); + if (!nextLabel) { + setRenamingFolder(null); + return; + } + setAssetFolders((currentFolders) => + currentFolders.map((currentFolder) => + currentFolder.id === folder.id + ? { + ...currentFolder, + label: nextLabel, + } + : currentFolder, + ), + ); + if (folder.persisted) { + updateEditorAssetFolder(folder.id, { label: nextLabel }).catch( + () => {}, + ); + } + setRenamingFolder(null); + }, + [renamingFolder], + ); + + const deleteAssetFolder = useCallback( + (folder: EditorAssetFolder) => { + if (folder.systemDefault) { + return; + } + const defaultFolder = + assetFolders.find((currentFolder) => currentFolder.systemDefault) ?? + assetFolders[0]; + if (!defaultFolder) { + return; + } + setAssetFolders((currentFolders) => + currentFolders.filter( + (currentFolder) => currentFolder.id !== folder.id, + ), + ); + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.folderId === folder.id + ? { + ...asset, + folderId: defaultFolder.id, + } + : asset, + ), + ); + if (folder.persisted) { + deleteEditorAssetFolder(folder.id) + .then((library) => { + const nextLibrary = normalizeAssetLibrary(library); + setAssetFolders(nextLibrary.folders); + setAssets(nextLibrary.assets); + }) + .catch(() => {}); + } + }, + [assetFolders], + ); + + 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; + }); + }, []); + + const toggleAllAssetsSelected = useCallback(() => { + setSelectedAssetIds( + allSelectableAssetsSelected + ? new Set() + : new Set(selectableAssets.map((asset) => asset.id)), + ); + }, [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)), + ); + onDeleteAssets?.(deletedAssets); + setSelectedAssetIds(new Set()); + ids.forEach((assetId) => { + void deleteEditorAsset(assetId); + }); + }, [assets, onDeleteAssets, selectedAssetIds]); + + const moveAssetToFolder = useCallback( + (assetId: string, folderId: string) => { + const asset = assets.find((currentAsset) => currentAsset.id === assetId); + if (!asset || asset.folderId === folderId) { + return; + } + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === assetId + ? { + ...currentAsset, + folderId, + } + : currentAsset, + ), + ); + if (asset.persisted) { + updateEditorAsset(asset.id, { folderId }).catch(() => {}); + } + }, + [assets], + ); + + const closeAssetSelectionMode = useCallback(() => { + setIsAssetSelectionMode(false); + setSelectedAssetIds(new Set()); + setAssetMarquee(null); + }, []); + + const updateAssetSelectionFromMarquee = useCallback( + (selectionRect: { + left: number; + right: number; + 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); + }, + [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]')) { + 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, + }); + setSelectedAssetIds(new Set()); + }, + [assetListRef, isAssetSelectionMode], + ); + + const handleAssetMarqueePointerMove = useCallback( + (event: ReactPointerEvent) => { + if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) { + return; + } + 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, + } + : 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), + }); + }, + [assetListRef, assetMarquee, updateAssetSelectionFromMarquee], + ); + + const handleAssetMarqueePointerUp = useCallback( + (event: ReactPointerEvent) => { + if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) { + return; + } + event.preventDefault(); + if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) { + assetListRef.current.releasePointerCapture?.(event.pointerId); + } + setAssetMarquee(null); + }, + [assetListRef, assetMarquee], + ); + + return { + assetFolders, + setAssetFolders, + assets, + setAssets, + groupedAssets, + selectableAssets, + allSelectableAssetsSelected, + renamingAsset, + setRenamingAsset, + renamingFolder, + setRenamingFolder, + creatingFolder, + setCreatingFolder, + newFolderName, + setNewFolderName, + activeUploadFolderId, + setActiveUploadFolderId, + isAssetSelectionMode, + setIsAssetSelectionMode, + selectedAssetIds, + setSelectedAssetIds, + assetMarquee, + setAssetMarquee, + assetPointerDrag, + setAssetPointerDrag, + assetMoveDropFolderId, + pinnedAssetMoveFolderId, + resolveAssetFolderId, + updateAssetMoveDropFolder, + startRenamingAsset, + commitAssetRename, + toggleAssetFolder, + commitNewAssetFolder, + deleteUploadedAsset, + startRenamingFolder, + commitFolderRename, + deleteAssetFolder, + toggleAssetSelected, + toggleAllAssetsSelected, + deleteSelectedAssets, + moveAssetToFolder, + closeAssetSelectionMode, + handleAssetMarqueePointerDown, + handleAssetMarqueePointerMove, + handleAssetMarqueePointerUp, + }; +} + +export type ImageCanvasAssetLibraryState = ReturnType< + typeof useImageCanvasAssetLibrary +>; + +export type ImageCanvasAssetLibrarySetter = Dispatch>;