import type { AssetMarqueeState, CanvasLayer, EditorAsset, EditorAssetFolder, } from './ImageCanvasEditorTypes'; type ClientRectLike = Pick; type ClientPoint = { clientX: number; clientY: number; }; type AssetFolderHitTarget = { folderId: string; rect: ClientRectLike; }; type AssetHitTarget = { assetId: string; rect: ClientRectLike; }; export type GroupedAssetFolder = EditorAssetFolder & { assets: EditorAsset[]; }; export const EDITOR_UPLOAD_ASSET_FOLDER_LABEL = '上传素材'; function isPointInRect(point: ClientPoint, rect: ClientRectLike) { return ( point.clientX >= rect.left && point.clientX <= rect.right && point.clientY >= rect.top && point.clientY <= rect.bottom ); } function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) { return ( rect.left <= selectionRect.right && rect.right >= selectionRect.left && rect.top <= selectionRect.bottom && rect.bottom >= selectionRect.top ); } export function resolveAssetFolderIdFromPoint({ point, listRect, folders, }: { point: ClientPoint; listRect: ClientRectLike | null | undefined; folders: AssetFolderHitTarget[]; }) { if (!listRect || !isPointInRect(point, listRect)) { return null; } const matchedFolder = folders.find((folder) => isPointInRect(point, folder.rect), ); return matchedFolder?.folderId ?? null; } export function resolvePinnedAssetMoveFolderId({ folderId, listRect, headerRect, }: { folderId: string | null; listRect: ClientRectLike | null | undefined; headerRect: ClientRectLike | null | undefined; }) { if ( !folderId || !listRect || !headerRect || (headerRect.bottom >= listRect.top && headerRect.top <= listRect.bottom) ) { return null; } return folderId; } export function shouldStartAssetMarquee({ isAssetSelectionMode, button, isBlockedTarget, }: { isAssetSelectionMode: boolean; button: number; isBlockedTarget: boolean; }) { return isAssetSelectionMode && button === 0 && !isBlockedTarget; } export function createAssetMarqueeFromPointer({ pointerId, point, containerRect, }: { pointerId: number; point: ClientPoint; containerRect: Pick | null | undefined; }): AssetMarqueeState { const startX = point.clientX - (containerRect?.left ?? 0); const startY = point.clientY - (containerRect?.top ?? 0); return { pointerId, startX, startY, currentX: startX, currentY: startY, }; } export function moveAssetMarqueeToPointer({ marquee, point, containerRect, }: { marquee: AssetMarqueeState; point: ClientPoint; containerRect: Pick | null | undefined; }) { return { ...marquee, currentX: point.clientX - (containerRect?.left ?? 0), currentY: point.clientY - (containerRect?.top ?? 0), }; } export function createAssetMarqueeSelectionRect({ marquee, point, containerRect, }: { marquee: AssetMarqueeState; point: ClientPoint; containerRect: Pick | null | undefined; }): ClientRectLike { const startClientX = (containerRect?.left ?? 0) + marquee.startX; const startClientY = (containerRect?.top ?? 0) + marquee.startY; return { left: Math.min(startClientX, point.clientX), right: Math.max(startClientX, point.clientX), top: Math.min(startClientY, point.clientY), bottom: Math.max(startClientY, point.clientY), }; } export function selectUploadedAssetsInRect({ assets, assetTargets, selectionRect, }: { assets: EditorAsset[]; assetTargets: AssetHitTarget[]; selectionRect: ClientRectLike; }) { const uploadedAssetIds = new Set( assets .filter((asset) => asset.sourceKind === 'uploaded') .map((asset) => asset.id), ); return new Set( assetTargets .filter( (target) => uploadedAssetIds.has(target.assetId) && doRectsIntersect(target.rect, selectionRect), ) .map((target) => target.assetId), ); } export function groupAssetsByFolder( assetFolders: EditorAssetFolder[], assets: EditorAsset[], ): GroupedAssetFolder[] { return assetFolders.map((folder) => ({ ...folder, assets: assets.filter((asset) => asset.folderId === folder.id), })); } function normalizeSearchQuery(query: string) { return query.trim().toLowerCase(); } function matchesSearchText(query: string, values: Array) { if (!query) { return true; } return values.some((value) => value?.toLowerCase().includes(query)); } function startsWithSearchText(query: string, values: Array) { if (!query) { return true; } return values.some((value) => value?.trim().toLowerCase().startsWith(query)); } function buildAssetSearchValues(asset: EditorAsset) { return [ asset.label, asset.prompt, asset.actualPrompt, asset.model, asset.provider, asset.taskId, asset.assetKind, asset.mediaType, ]; } function buildLayerSearchValues(layer: CanvasLayer) { return [ layer.title, layer.prompt, layer.actualPrompt, layer.model, layer.provider, layer.taskId, layer.assetKind, layer.mediaType, ]; } function assetMatchesSearch(asset: EditorAsset, query: string) { return matchesSearchText(query, buildAssetSearchValues(asset)); } function assetStartsWithSearch(asset: EditorAsset, query: string) { return startsWithSearchText(query, buildAssetSearchValues(asset)); } export function layerMatchesSearch(layer: CanvasLayer, query: string) { return matchesSearchText(query, buildLayerSearchValues(layer)); } export function layerStartsWithSearch(layer: CanvasLayer, query: string) { return startsWithSearchText(query, buildLayerSearchValues(layer)); } export function filterGroupedAssetsBySearch( groupedAssets: GroupedAssetFolder[], query: string, ) { const normalizedQuery = normalizeSearchQuery(query); if (!normalizedQuery) { return groupedAssets; } return groupedAssets .map((folder) => { const folderMatches = startsWithSearchText(normalizedQuery, [folder.label]) || matchesSearchText(normalizedQuery, [folder.label]); const prefixAssets = folder.assets.filter((asset) => assetStartsWithSearch(asset, normalizedQuery), ); const matchingAssets = folder.assets.filter((asset) => assetMatchesSearch(asset, normalizedQuery), ); return { ...folder, assets: folderMatches ? folder.assets : [ ...prefixAssets, ...matchingAssets.filter( (asset) => !prefixAssets.some( (prefixAsset) => prefixAsset.id === asset.id, ), ), ], }; }) .filter((folder) => folder.assets.length > 0); } export function getSelectableAssets(assets: EditorAsset[]) { return assets.filter((asset) => asset.sourceKind === 'uploaded'); } export type AssetShowcaseSubmissionState = { canSubmit: boolean; label: string; reason: string | null; }; function normalizeAssetShowcaseReason(value: string | null | undefined) { return value?.trim() || null; } export function resolveAssetShowcaseSubmissionState( asset: EditorAsset, ): AssetShowcaseSubmissionState { const reviewStatus = asset.showcaseReviewStatus ?? null; if (reviewStatus === 'pending') { return { canSubmit: false, label: '精选审核中', reason: '素材正在审核中,请等待审核结果', }; } if (reviewStatus === 'approved') { return { canSubmit: false, label: asset.showcaseDisplayEnabled === false ? '已入选精选,暂未展示' : '已入选精选', reason: asset.showcaseDisplayEnabled === false ? '素材已通过审核,运营后台开启展示后才会出现在前台' : '素材已经入选精选', }; } if (reviewStatus === 'rejected') { return { canSubmit: false, label: '精选未通过', reason: '该素材审核未通过,不能重复提交', }; } if (asset.sourceType !== 'generated') { return { canSubmit: false, label: '提交精选审核', reason: '只有生成素材可以提交精选审核', }; } if (!asset.persisted) { return { canSubmit: false, label: '提交精选审核', reason: '素材保存完成后才能提交精选审核', }; } if (!asset.src.trim()) { return { canSubmit: false, label: '提交精选审核', reason: '没有媒体内容的素材不能提交精选审核', }; } return { canSubmit: true, label: '提交精选审核', reason: normalizeAssetShowcaseReason(asset.showcaseSubmitError), }; } 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 resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) { return ( assetFolders.find( (folder) => folder.label.trim() === EDITOR_UPLOAD_ASSET_FOLDER_LABEL, ) ?? null ); } export function resolvePreferredAssetUploadFolder( assetFolders: EditorAssetFolder[], ) { return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders); } 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, ); }