498b905bd8
<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>
578 lines
16 KiB
TypeScript
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>>;
|