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'; export { readImageFileAsDataUrl } from './ImageCanvasFileModel'; function isEditorAuthError(error: unknown) { return ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ); } export function useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData, openEditorLoginModal, onDeleteAssets, }: { assetListRef: RefObject; canAccessProtectedData: boolean; 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(() => { if (!canAccessProtectedData) { return undefined; } 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; }; }, [canAccessProtectedData, 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>;