Files
Genarrative/src/components/image-editor/useImageCanvasAssetLibrary.ts
T
k88936 498b905bd8
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 15m48s
优化素材库多选操作 (#114)
<video src="attachments/2377fc8b-50e4-4919-ba41-5027b957709f" title="2026-07-27 16-15-53.mp4" controls></video>

- 实现shift ctrl的范围选扩选
- 实现多选下载(为一个zip)
- 原本有搜索和文件夹折叠展开功能, 我的处理是: 只作为方便查找的用途. 最终操作会有"目前一些(比如待删除)元素被隐藏了,请再确认"的提示.

result:
```shell
root@k88936-t14p: ~/Downloads# unzip 未命名画布-选中素材-20260806-195631.zip
Archive:  未命名画布-选中素材-20260806-195631.zip
   creating: 未命名画布-选中素材/
   creating: 未命名画布-选中素材/images/
   creating: 未命名画布-选中素材/media/
 extracting: 未命名画布-选中素材/media/001-角色动作(原始视频).mp4
   creating: 未命名画布-选中素材/sequences/
   creating: 未命名画布-选中素材/sequences/002-角色动作/
   creating: 未命名画布-选中素材/sequences/002-角色动作/frames/
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-01.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-02.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-03.png
...
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-31.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-32.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/metadata.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/skeleton.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/README.md
 extracting: 未命名画布-选中素材/sequences/002-角色动作/manifest.txt
 extracting: 未命名画布-选中素材/metadata.json
 extracting: 未命名画布-选中素材/manifest.txt
 ```

~~动作的导出存在很大的问题, 在另一个pr #117 解决~~

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/114
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 11:11:25 +08:00

578 lines
16 KiB
TypeScript

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<HTMLElement>(ASSET_FOLDER_SELECTOR) ??
[]),
]
.map((element) => {
const folderId = element.dataset.assetFolderId;
return folderId
? {
folderId,
rect: element.getBoundingClientRect(),
}
: null;
})
.filter((target): target is NonNullable<typeof target> => Boolean(target));
}
export function useImageCanvasAssetLibrary({
assetListRef,
assetPointerDragRef,
canAccessProtectedData,
openEditorLoginModal,
onDeleteAssets,
onOpenSelectionMode,
}: {
assetListRef: RefObject<HTMLDivElement | null>;
assetPointerDragRef: RefObject<AssetPointerDragState | null>;
canAccessProtectedData: boolean;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
onOpenSelectionMode?: () => void;
}) {
const [assetFolders, setAssetFolders] =
useState<EditorAssetFolder[]>(EDITOR_ASSET_FOLDERS);
const [assets, setAssets] = useState<EditorAsset[]>([]);
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<AssetPointerDragState | null>(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<ReturnType<typeof loadEditorAssetLibrary>>,
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<HTMLElement>(
`[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<string>) => {
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<T> = Dispatch<SetStateAction<T>>;