拆分编辑器素材库模型

抽出素材库分组和本地状态变更规则

补充素材库模型单测

更新 TRACKING.md 记录第三十七阶段验证
This commit is contained in:
2026-06-17 18:15:34 +08:00
parent b5707ac2b9
commit 6e8089c297
4 changed files with 469 additions and 101 deletions

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import type {
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
import {
areAllSelectableAssetsSelected,
createLocalAssetFolder,
deleteAssetFolderLocally,
getSelectableAssets,
groupAssetsByFolder,
moveAssetToFolderLocally,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAllAssetSelection,
resolveDefaultAssetFolder,
toggleAssetFolderCollapsed,
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
function createFolder(
overrides: Partial<EditorAssetFolder> = {},
): EditorAssetFolder {
return {
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
...overrides,
};
}
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
return {
id: 'asset-a',
label: '素材A',
src: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
...overrides,
};
}
describe('ImageCanvasAssetLibraryModel', () => {
it('groups assets by folder and resolves selectable uploaded assets', () => {
const folders = [
createFolder(),
createFolder({ id: 'folder-role', label: '角色素材' }),
];
const assets = [
createAsset({ id: 'asset-a', folderId: 'project' }),
createAsset({
id: 'asset-b',
label: '素材B',
folderId: 'folder-role',
}),
createAsset({
id: 'built-in',
label: '内置素材',
sourceKind: 'built-in',
}),
];
expect(groupAssetsByFolder(folders, assets)).toMatchObject([
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
]);
expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([
'asset-a',
'asset-b',
]);
});
it('renames assets, toggles folders and creates local folders', () => {
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
{ label: '新名字' },
);
expect(
renameAssetFolderById([createFolder()], 'project', '默认素材')[0],
).toMatchObject({ label: '默认素材' });
expect(toggleAssetFolderCollapsed([createFolder()], 'project')[0]).toMatchObject(
{ collapsed: true },
);
expect(
createLocalAssetFolder({
folderId: 'folder-local',
label: '角色素材',
}),
).toEqual({
id: 'folder-local',
label: '角色素材',
collapsed: false,
systemDefault: false,
persisted: false,
});
});
it('replaces local folder ids in folders and contained assets', () => {
const result = replaceLocalAssetFolder({
folders: [createFolder(), createFolder({ id: 'folder-local' })],
assets: [createAsset({ folderId: 'folder-local' })],
localFolderId: 'folder-local',
persistedFolder: {
folderId: 'folder-role',
label: '角色素材',
collapsed: false,
systemDefault: false,
},
});
expect(result.folders[1]).toEqual({
id: 'folder-role',
label: '角色素材',
collapsed: false,
systemDefault: false,
persisted: true,
});
expect(result.assets[0]).toMatchObject({ folderId: 'folder-role' });
});
it('deletes assets and moves deleted folder contents to the default folder', () => {
const folders = [
createFolder(),
createFolder({ id: 'folder-role', systemDefault: false }),
];
const assets = [
createAsset({ id: 'asset-a', folderId: 'folder-role' }),
createAsset({ id: 'asset-b', folderId: 'project' }),
];
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual([
'asset-b',
]);
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
expect(
deleteAssetFolderLocally({
folders,
assets,
folderId: 'folder-role',
defaultFolderId: 'project',
}),
).toMatchObject({
folders: [{ id: 'project' }],
assets: [
{ id: 'asset-a', folderId: 'project' },
{ id: 'asset-b', folderId: 'project' },
],
});
});
it('toggles selection, selects all uploaded assets and removes selected assets', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
];
const selected = toggleAssetSelection(new Set<string>(), 'asset-a');
expect([...selected]).toEqual(['asset-a']);
expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]);
expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe(
false,
);
const allSelected = resolveAllAssetSelection({
allSelectableAssetsSelected: false,
selectableAssets: assets,
});
expect([...allSelected]).toEqual(['asset-a', 'asset-b']);
const removal = removeSelectedAssets(assets, new Set(['asset-b']));
expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']);
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
});
it('moves assets between folders locally', () => {
expect(
moveAssetToFolderLocally([createAsset()], 'asset-a', 'folder-role')[0],
).toMatchObject({ folderId: 'folder-role' });
});
});

View File

@@ -0,0 +1,216 @@
import type {
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
export type GroupedAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
};
export function groupAssetsByFolder(
assetFolders: EditorAssetFolder[],
assets: EditorAsset[],
): GroupedAssetFolder[] {
return assetFolders.map((folder) => ({
...folder,
assets: assets.filter((asset) => asset.folderId === folder.id),
}));
}
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter((asset) => asset.sourceKind === 'uploaded');
}
export function areAllSelectableAssetsSelected(
selectableAssets: EditorAsset[],
selectedAssetIds: Set<string>,
) {
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 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<string>,
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<string>()
: new Set(selectableAssets.map((asset) => asset.id));
}
export function removeSelectedAssets(
assets: EditorAsset[],
selectedAssetIds: Set<string>,
) {
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,
);
}

View File

@@ -23,6 +23,23 @@ import {
escapeCssIdentifier,
normalizeAssetLibrary,
} from './ImageCanvasEditorModel';
import {
areAllSelectableAssetsSelected,
createLocalAssetFolder,
deleteAssetFolderLocally,
getSelectableAssets,
groupAssetsByFolder,
moveAssetToFolderLocally,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAllAssetSelection,
resolveDefaultAssetFolder,
toggleAssetFolderCollapsed,
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import type {
AssetMarqueeState,
AssetPointerDragState,
@@ -80,20 +97,17 @@ export function useImageCanvasAssetLibrary({
>(null);
const groupedAssets = useMemo(
() =>
assetFolders.map((folder) => ({
...folder,
assets: assets.filter((asset) => asset.folderId === folder.id),
})),
() => groupAssetsByFolder(assetFolders, assets),
[assetFolders, assets],
);
const selectableAssets = useMemo(
() => assets.filter((asset) => asset.sourceKind === 'uploaded'),
() => getSelectableAssets(assets),
[assets],
);
const allSelectableAssetsSelected =
selectableAssets.length > 0 &&
selectableAssets.every((asset) => selectedAssetIds.has(asset.id));
const allSelectableAssetsSelected = areAllSelectableAssetsSelected(
selectableAssets,
selectedAssetIds,
);
useEffect(() => {
if (!canAccessProtectedData) {
@@ -196,14 +210,7 @@ export function useImageCanvasAssetLibrary({
return;
}
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === asset.id
? {
...currentAsset,
label: nextLabel,
}
: currentAsset,
),
renameAssetById(currentAssets, asset.id, nextLabel),
);
if (asset.persisted) {
updateEditorAsset(asset.id, { label: nextLabel }).catch(() => {});
@@ -218,14 +225,7 @@ export function useImageCanvasAssetLibrary({
const nextFolder = assetFolders.find((folder) => folder.id === folderId);
const nextCollapsed = !(nextFolder?.collapsed ?? false);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === folderId
? {
...folder,
collapsed: !folder.collapsed,
}
: folder,
),
toggleAssetFolderCollapsed(currentFolders, folderId),
);
if (nextFolder?.persisted) {
updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch(
@@ -246,13 +246,7 @@ export function useImageCanvasAssetLibrary({
const folderId = `folder-${Date.now()}`;
setAssetFolders((currentFolders) => [
...currentFolders,
{
id: folderId,
label,
collapsed: false,
systemDefault: false,
persisted: false,
},
createLocalAssetFolder({ folderId, label }),
]);
setActiveUploadFolderId(folderId);
setCreatingFolder(false);
@@ -263,27 +257,20 @@ export function useImageCanvasAssetLibrary({
assetFolders.length + 100,
);
setAssetFolders((currentFolders) =>
currentFolders.map((currentFolder) =>
currentFolder.id === folderId
? {
id: folder.folderId,
label: folder.label,
collapsed: folder.collapsed,
systemDefault: folder.systemDefault,
persisted: true,
}
: currentFolder,
),
replaceLocalAssetFolder({
folders: currentFolders,
assets: [],
localFolderId: folderId,
persistedFolder: folder,
}).folders,
);
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.folderId === folderId
? {
...asset,
folderId: folder.folderId,
}
: asset,
),
replaceLocalAssetFolder({
folders: [],
assets: currentAssets,
localFolderId: folderId,
persistedFolder: folder,
}).assets,
);
setActiveUploadFolderId(folder.folderId);
} catch {
@@ -296,9 +283,7 @@ export function useImageCanvasAssetLibrary({
if (asset.sourceKind !== 'uploaded') {
return;
}
setAssets((currentAssets) =>
currentAssets.filter((currentAsset) => currentAsset.id !== asset.id),
);
setAssets((currentAssets) => removeAssetById(currentAssets, asset.id));
onDeleteAssets?.([asset]);
setRenamingAsset((currentRename) =>
currentRename?.assetId === asset.id ? null : currentRename,
@@ -325,14 +310,7 @@ export function useImageCanvasAssetLibrary({
return;
}
setAssetFolders((currentFolders) =>
currentFolders.map((currentFolder) =>
currentFolder.id === folder.id
? {
...currentFolder,
label: nextLabel,
}
: currentFolder,
),
renameAssetFolderById(currentFolders, folder.id, nextLabel),
);
if (folder.persisted) {
updateEditorAssetFolder(folder.id, { label: nextLabel }).catch(
@@ -349,26 +327,25 @@ export function useImageCanvasAssetLibrary({
if (folder.systemDefault) {
return;
}
const defaultFolder =
assetFolders.find((currentFolder) => currentFolder.systemDefault) ??
assetFolders[0];
const defaultFolder = resolveDefaultAssetFolder(assetFolders);
if (!defaultFolder) {
return;
}
setAssetFolders((currentFolders) =>
currentFolders.filter(
(currentFolder) => currentFolder.id !== folder.id,
),
deleteAssetFolderLocally({
folders: currentFolders,
assets: [],
folderId: folder.id,
defaultFolderId: defaultFolder.id,
}).folders,
);
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.folderId === folder.id
? {
...asset,
folderId: defaultFolder.id,
}
: asset,
),
deleteAssetFolderLocally({
folders: [],
assets: currentAssets,
folderId: folder.id,
defaultFolderId: defaultFolder.id,
}).assets,
);
if (folder.persisted) {
deleteEditorAssetFolder(folder.id)
@@ -384,32 +361,26 @@ export function useImageCanvasAssetLibrary({
);
const toggleAssetSelected = useCallback((assetId: string) => {
setSelectedAssetIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(assetId)) {
nextIds.delete(assetId);
} else {
nextIds.add(assetId);
}
return nextIds;
});
setSelectedAssetIds((currentIds) =>
toggleAssetSelection(currentIds, assetId),
);
}, []);
const toggleAllAssetsSelected = useCallback(() => {
setSelectedAssetIds(
allSelectableAssetsSelected
? new Set()
: new Set(selectableAssets.map((asset) => asset.id)),
resolveAllAssetSelection({
allSelectableAssetsSelected,
selectableAssets,
}),
);
}, [allSelectableAssetsSelected, selectableAssets]);
const deleteSelectedAssets = useCallback(() => {
const ids = [...selectedAssetIds];
const deletedAssets = assets.filter((asset) =>
selectedAssetIds.has(asset.id),
);
setAssets((currentAssets) =>
currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)),
const deletedAssets = removeSelectedAssets(assets, selectedAssetIds)
.deletedAssets;
setAssets(
(currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets,
);
onDeleteAssets?.(deletedAssets);
setSelectedAssetIds(new Set());
@@ -425,14 +396,7 @@ export function useImageCanvasAssetLibrary({
return;
}
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === assetId
? {
...currentAsset,
folderId,
}
: currentAsset,
),
moveAssetToFolderLocally(currentAssets, assetId, folderId),
);
if (asset.persisted) {
updateEditorAsset(asset.id, { folderId }).catch(() => {});