import { type Dispatch, type RefObject, type SetStateAction, useCallback, useEffect, useMemo, useState, } from 'react'; import { ApiClientError } from '../../services/apiClient'; import { createEditorAssetFolder, deleteEditorAsset, deleteEditorAssetFolder, loadEditorAssetLibrary, submitEditorAssetShowcase, updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; import { createLocalAssetFolder, deleteAssetFolderLocally, groupAssetsByFolder, moveAssetToFolderLocally, removeAssetById, removeSelectedAssets, renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, resolveAssetFolderIdFromPoint, resolveAssetShowcaseSubmissionState, resolveDefaultAssetFolder, resolvePinnedAssetMoveFolderId, toggleAssetFolderCollapsed, } from './ImageCanvasAssetLibraryModel'; import { EDITOR_ASSET_FOLDERS, escapeCssIdentifier, normalizeAssetLibrary, } from './ImageCanvasEditorModel'; import type { AssetPointerDragState, EditorAsset, EditorAssetFolder, } from './ImageCanvasEditorTypes'; import { useImageCanvasAssetSelection } from './useImageCanvasAssetSelection'; export { readImageFileAsDataUrl } from './ImageCanvasFileModel'; function isEditorAuthError(error: unknown) { return ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ); } function formatShowcaseSubmitError(error: unknown) { if (error instanceof ApiClientError && error.message.trim()) { return error.message.trim(); } if (error instanceof Error && error.message.trim()) { return error.message.trim(); } return '提交精选审核失败,请稍后重试'; } const ASSET_FOLDER_SELECTOR = '[data-asset-folder-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)); } export function useImageCanvasAssetLibrary({ assetListRef, assetPointerDragRef, canAccessProtectedData, openEditorLoginModal, onDeleteAssets, onOpenSelectionMode, }: { assetListRef: RefObject; assetPointerDragRef: RefObject; canAccessProtectedData: boolean; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; onDeleteAssets?: (assets: EditorAsset[]) => boolean | void; onOpenSelectionMode?: () => 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 [assetPointerDrag, setAssetPointerDrag] = useState(null); const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState< string | null >(null); const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState< string | null >(null); const groupedAssets = useMemo( () => groupAssetsByFolder(assetFolders, assets), [assetFolders, assets], ); const clearAssetRenameState = useCallback(() => { setRenamingAsset(null); setRenamingFolder(null); }, []); const prepareAssetSelectionMode = useCallback(() => { clearAssetRenameState(); assetPointerDragRef.current = null; setAssetPointerDrag(null); setAssetMoveDropFolderId(null); setPinnedAssetMoveFolderId(null); onOpenSelectionMode?.(); }, [assetPointerDragRef, clearAssetRenameState, onOpenSelectionMode]); const { selectableAssets, selectedAssets, selectedAssetIds, isAssetSelectionMode, assetMarquee, clearAssetSelection, openAssetSelectionMode, closeAssetSelectionMode, toggleAssetSelected, toggleAllAssetsSelected, handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, handleAssetMarqueePointerCancel, handleAssetMarqueeLostPointerCapture, } = useImageCanvasAssetSelection({ assetListRef, assets, onOpenSelectionMode: prepareAssetSelectionMode, }); const applyAssetLibrarySnapshot = useCallback( ( library: Awaited>, options: { preferDefaultUploadFolder?: boolean } = {}, ) => { const nextLibrary = normalizeAssetLibrary(library); setAssetFolders(nextLibrary.folders); setAssets(nextLibrary.assets); const defaultFolder = nextLibrary.folders.find((folder) => folder.systemDefault) ?? nextLibrary.folders[0]; setActiveUploadFolderId((currentFolderId) => { if ( !options.preferDefaultUploadFolder && nextLibrary.folders.some((folder) => folder.id === currentFolderId) ) { return currentFolderId; } return defaultFolder?.id ?? 'project'; }); }, [], ); const refreshAssetLibrary = useCallback(async () => { if (!canAccessProtectedData) { return; } try { applyAssetLibrarySnapshot(await loadEditorAssetLibrary()); } catch (error: unknown) { if (isEditorAuthError(error)) { openEditorLoginModal(); } } }, [applyAssetLibrarySnapshot, canAccessProtectedData, openEditorLoginModal]); useEffect(() => { if (!canAccessProtectedData) { return undefined; } let cancelled = false; loadEditorAssetLibrary() .then((library) => { if (cancelled) { return; } applyAssetLibrarySnapshot(library, { preferDefaultUploadFolder: true }); }) .catch((error: unknown) => { if (!cancelled && isEditorAuthError(error)) { openEditorLoginModal(); } }); return () => { cancelled = true; }; }, [applyAssetLibrarySnapshot, canAccessProtectedData, openEditorLoginModal]); const resolveAssetFolderId = useCallback( (clientX: number, clientY: number) => { const listElement = assetListRef.current; if (!listElement) { return null; } return resolveAssetFolderIdFromPoint({ point: { clientX, clientY }, listRect: listElement.getBoundingClientRect(), folders: readAssetFolderHitTargets(listElement), }); }, [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)}"]`, ); setPinnedAssetMoveFolderId( resolvePinnedAssetMoveFolderId({ folderId, listRect: listElement?.getBoundingClientRect(), headerRect: header?.getBoundingClientRect(), }), ); }, [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) => renameAssetById(currentAssets, asset.id, nextLabel), ); 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) => toggleAssetFolderCollapsed(currentFolders, folderId), ); 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, createLocalAssetFolder({ folderId, label }), ]); setActiveUploadFolderId(folderId); setCreatingFolder(false); setNewFolderName(''); try { const folder = await createEditorAssetFolder( label, assetFolders.length + 100, ); setAssetFolders( (currentFolders) => replaceLocalAssetFolder({ folders: currentFolders, assets: [], localFolderId: folderId, persistedFolder: folder, }).folders, ); setAssets( (currentAssets) => replaceLocalAssetFolder({ folders: [], assets: currentAssets, localFolderId: folderId, persistedFolder: folder, }).assets, ); setActiveUploadFolderId(folder.folderId); } catch { // 本地临时文件夹仍可继续使用;下次刷新以后端为准。 } }, [assetFolders.length, newFolderName]); const deleteUploadedAsset = useCallback( (asset: EditorAsset) => { if (asset.sourceKind !== 'uploaded') { return; } if (onDeleteAssets?.([asset]) === false) { return; } setAssets((currentAssets) => removeAssetById(currentAssets, asset.id)); setRenamingAsset((currentRename) => currentRename?.assetId === asset.id ? null : currentRename, ); if (asset.persisted) { deleteEditorAsset(asset.id).catch(() => {}); } }, [onDeleteAssets], ); const submitAssetToShowcase = useCallback((asset: EditorAsset) => { if (!resolveAssetShowcaseSubmissionState(asset).canSubmit) { return; } const previousAsset = asset; setAssets((currentAssets) => currentAssets.map((currentAsset) => currentAsset.id === asset.id ? { ...currentAsset, publicShowcaseEnabled: false, showcaseReviewStatus: 'pending', showcaseDisplayEnabled: false, showcaseSubmitError: undefined, } : currentAsset, ), ); submitEditorAssetShowcase(asset.id) .then((showcaseAsset) => { setAssets((currentAssets) => currentAssets.map((currentAsset) => currentAsset.id === asset.id ? { ...currentAsset, publicShowcaseEnabled: false, showcaseId: showcaseAsset.showcaseId ?? showcaseAsset.resourceId, showcaseReviewStatus: showcaseAsset.reviewStatus ?? 'pending', showcaseDisplayEnabled: showcaseAsset.displayEnabled ?? false, showcaseLikeCount: showcaseAsset.likeCount ?? 0, generationCostMudPoints: showcaseAsset.generationCostMudPoints ?? currentAsset.generationCostMudPoints, showcaseSubmitError: undefined, } : currentAsset, ), ); }) .catch((error: unknown) => { const showcaseSubmitError = formatShowcaseSubmitError(error); setAssets((currentAssets) => currentAssets.map((currentAsset) => currentAsset.id === asset.id ? { ...previousAsset, showcaseSubmitError, } : currentAsset, ), ); }); }, []); 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) => renameAssetFolderById(currentFolders, folder.id, nextLabel), ); if (folder.persisted) { updateEditorAssetFolder(folder.id, { label: nextLabel }).catch( () => {}, ); } setRenamingFolder(null); }, [renamingFolder], ); const deleteAssetFolder = useCallback( (folder: EditorAssetFolder) => { if (folder.systemDefault) { return; } const defaultFolder = resolveDefaultAssetFolder(assetFolders); if (!defaultFolder) { return; } setAssetFolders( (currentFolders) => deleteAssetFolderLocally({ folders: currentFolders, assets: [], folderId: folder.id, defaultFolderId: defaultFolder.id, }).folders, ); setAssets( (currentAssets) => deleteAssetFolderLocally({ folders: [], assets: currentAssets, folderId: folder.id, defaultFolderId: defaultFolder.id, }).assets, ); if (folder.persisted) { deleteEditorAssetFolder(folder.id) .then((library) => { const nextLibrary = normalizeAssetLibrary(library); setAssetFolders(nextLibrary.folders); setAssets(nextLibrary.assets); }) .catch(() => {}); } }, [assetFolders], ); const deleteSelectedAssets = useCallback( (requestedAssetIds?: ReadonlySet) => { const targetAssetIds = new Set(requestedAssetIds ?? selectedAssetIds); const targetAssets = assets.filter((asset) => targetAssetIds.has(asset.id), ); const ids = targetAssets.map((asset) => asset.id); if (onDeleteAssets?.(targetAssets) === false) { return; } setAssets( (currentAssets) => removeSelectedAssets(currentAssets, targetAssetIds).assets, ); clearAssetSelection(); ids.forEach((assetId) => { void deleteEditorAsset(assetId); }); }, [assets, clearAssetSelection, 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) => moveAssetToFolderLocally(currentAssets, assetId, folderId), ); if (asset.persisted) { updateEditorAsset(asset.id, { folderId }).catch(() => {}); } }, [assets], ); return { assetFolders, setAssetFolders, assets, setAssets, groupedAssets, selectableAssets, selectedAssets, renamingAsset, setRenamingAsset, renamingFolder, setRenamingFolder, creatingFolder, setCreatingFolder, newFolderName, setNewFolderName, activeUploadFolderId, setActiveUploadFolderId, isAssetSelectionMode, selectedAssetIds, assetMarquee, assetPointerDrag, setAssetPointerDrag, assetMoveDropFolderId, pinnedAssetMoveFolderId, resolveAssetFolderId, updateAssetMoveDropFolder, startRenamingAsset, commitAssetRename, toggleAssetFolder, commitNewAssetFolder, deleteUploadedAsset, submitAssetToShowcase, startRenamingFolder, commitFolderRename, deleteAssetFolder, toggleAssetSelected, toggleAllAssetsSelected, deleteSelectedAssets, moveAssetToFolder, closeAssetSelectionMode, openAssetSelectionMode, handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, handleAssetMarqueePointerCancel, handleAssetMarqueeLostPointerCapture, refreshAssetLibrary, }; } export type ImageCanvasAssetLibraryState = ReturnType< typeof useImageCanvasAssetLibrary >; export type ImageCanvasAssetLibrarySetter = Dispatch>;