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, submitEditorAssetShowcase, updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; import { EDITOR_ASSET_FOLDERS, escapeCssIdentifier, normalizeAssetLibrary, } from './ImageCanvasEditorModel'; import { areAllSelectableAssetsSelected, createAssetMarqueeFromPointer, createAssetMarqueeSelectionRect, createLocalAssetFolder, deleteAssetFolderLocally, getSelectableAssets, groupAssetsByFolder, moveAssetToFolderLocally, moveAssetMarqueeToPointer, removeAssetById, removeSelectedAssets, renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, resolveAssetShowcaseSubmissionState, resolveAllAssetSelection, resolveDefaultAssetFolder, resolveAssetFolderIdFromPoint, resolvePinnedAssetMoveFolderId, selectUploadedAssetsInRect, shouldStartAssetMarquee, toggleAssetFolderCollapsed, toggleAssetSelection, } from './ImageCanvasAssetLibraryModel'; 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) ); } 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]'; const ASSET_ITEM_SELECTOR = '[data-asset-id]'; const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR = 'button, input, textarea, select, [data-asset-id]'; function readAssetFolderHitTargets(listElement: ParentNode | null) { return [ ...(listElement?.querySelectorAll(ASSET_FOLDER_SELECTOR) ?? []), ] .map((element) => { const folderId = element.dataset.assetFolderId; return folderId ? { folderId, rect: element.getBoundingClientRect(), } : null; }) .filter((target): target is NonNullable => Boolean(target)); } function readAssetHitTargets(listElement: ParentNode | null) { return [ ...(listElement?.querySelectorAll(ASSET_ITEM_SELECTOR) ?? []), ] .map((element) => { const assetId = element.dataset.assetId; return assetId ? { assetId, rect: element.getBoundingClientRect(), } : null; }) .filter((target): target is NonNullable => Boolean(target)); } export function useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData, 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( () => groupAssetsByFolder(assetFolders, assets), [assetFolders, assets], ); const selectableAssets = useMemo( () => getSelectableAssets(assets), [assets], ); const allSelectableAssetsSelected = areAllSelectableAssetsSelected( selectableAssets, selectedAssetIds, ); 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; } setAssets((currentAssets) => removeAssetById(currentAssets, asset.id)); onDeleteAssets?.([asset]); 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 toggleAssetSelected = useCallback((assetId: string) => { setSelectedAssetIds((currentIds) => toggleAssetSelection(currentIds, assetId), ); }, []); const toggleAllAssetsSelected = useCallback(() => { setSelectedAssetIds( resolveAllAssetSelection({ allSelectableAssetsSelected, selectableAssets, }), ); }, [allSelectableAssetsSelected, selectableAssets]); const deleteSelectedAssets = useCallback(() => { const ids = [...selectedAssetIds]; const deletedAssets = removeSelectedAssets(assets, selectedAssetIds) .deletedAssets; setAssets( (currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets, ); 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) => moveAssetToFolderLocally(currentAssets, assetId, folderId), ); 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; }) => { setSelectedAssetIds( selectUploadedAssetsInRect({ assets, assetTargets: readAssetHitTargets(assetListRef.current), selectionRect, }), ); }, [assetListRef, assets], ); const handleAssetMarqueePointerDown = useCallback( (event: ReactPointerEvent) => { const target = event.target as HTMLElement; if ( !shouldStartAssetMarquee({ isAssetSelectionMode, button: event.button, isBlockedTarget: Boolean( target.closest(ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR), ), }) ) { return; } event.preventDefault(); assetListRef.current?.setPointerCapture?.(event.pointerId); setAssetMarquee( createAssetMarqueeFromPointer({ pointerId: event.pointerId, point: event, containerRect: assetListRef.current?.getBoundingClientRect(), }), ); setSelectedAssetIds(new Set()); }, [assetListRef, isAssetSelectionMode], ); const handleAssetMarqueePointerMove = useCallback( (event: ReactPointerEvent) => { if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) { return; } event.preventDefault(); const containerRect = assetListRef.current?.getBoundingClientRect(); setAssetMarquee((currentMarquee) => currentMarquee ? moveAssetMarqueeToPointer({ marquee: currentMarquee, point: event, containerRect, }) : null, ); updateAssetSelectionFromMarquee( createAssetMarqueeSelectionRect({ marquee: assetMarquee, point: event, containerRect, }), ); }, [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, submitAssetToShowcase, startRenamingFolder, commitFolderRename, deleteAssetFolder, toggleAssetSelected, toggleAllAssetsSelected, deleteSelectedAssets, moveAssetToFolder, closeAssetSelectionMode, handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, refreshAssetLibrary, }; } export type ImageCanvasAssetLibraryState = ReturnType< typeof useImageCanvasAssetLibrary >; export type ImageCanvasAssetLibrarySetter = Dispatch>;