拆分图片画布素材导出工作流

新增画布素材导出 hook 和单测

主视图改为通过导出 hook 处理单图和整包下载

更新图片画布前端拆分文档和 TRACKING 回归记录
This commit is contained in:
2026-06-17 07:17:15 +08:00
parent 3c37108ef6
commit 3c933b2202
5 changed files with 434 additions and 177 deletions

View File

@@ -0,0 +1,207 @@
import JSZip from 'jszip';
import { useCallback, useState } from 'react';
import type {
CanvasAssetExportImage,
CanvasAssetExportMetadata,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
blobToUint8Array,
buildLayerExportMetadata,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerImageBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
type AssetExportStatus = {
tone: 'info' | 'success' | 'error';
message: string;
};
type UseImageCanvasAssetExportWorkflowOptions = {
layers: CanvasLayer[];
projectId: string | null;
projectTitle: string;
};
export function useImageCanvasAssetExportWorkflow({
layers,
projectId,
projectTitle,
}: UseImageCanvasAssetExportWorkflowOptions) {
const [assetExportStatus, setAssetExportStatus] =
useState<AssetExportStatus | null>(null);
const [isExportingAssets, setIsExportingAssets] = useState(false);
const exportLayerImage = useCallback((layer: CanvasLayer | null) => {
if (!layer) {
return;
}
const link = document.createElement('a');
link.href = layer.src;
link.download = `${sanitizeExportFilePart(layer.title, 'canvas-layer')}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}, []);
const exportCanvasAssets = useCallback(async () => {
if (isExportingAssets) {
return;
}
const exportableLayers = layers
.filter((layer) => layer.src.trim().length > 0)
.sort((left, right) => left.zIndex - right.zIndex);
if (!exportableLayers.length) {
setAssetExportStatus({
tone: 'info',
message: '当前画布没有可导出的素材',
});
return;
}
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
const exportedAt = new Date();
const projectName = sanitizeExportFilePart(projectTitle, '未命名画布');
const rootFolderName = `${projectName}-画布素材`;
const zip = new JSZip();
const rootFolder = zip.folder(rootFolderName) ?? zip;
const imagesFolder = rootFolder.folder('images') ?? rootFolder;
const imageByKey = new Map<string, CanvasAssetExportImage>();
const usedFileNames = new Map<string, number>();
for (const layer of exportableLayers) {
const key = getLayerExportKey(layer);
if (imageByKey.has(key)) {
continue;
}
const index = imageByKey.size + 1;
const safeTitle = sanitizeExportFilePart(layer.title, '画布素材');
const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`;
const duplicateCount = usedFileNames.get(baseFileName) ?? 0;
usedFileNames.set(baseFileName, duplicateCount + 1);
const indexedFileName =
duplicateCount > 0
? `${baseFileName}-${duplicateCount + 1}`
: baseFileName;
try {
const blob = await readLayerImageBlob(layer);
const extension = getImageExtensionFromTypeOrSrc(
blob.type,
layer.src,
);
const file = `images/${indexedFileName}.${extension}`;
imageByKey.set(key, {
key,
file,
layer,
blob,
});
imagesFolder.file(
`${indexedFileName}.${extension}`,
await blobToUint8Array(blob),
);
} catch (error) {
imageByKey.set(key, {
key,
file: `images/${indexedFileName}.png`,
layer,
error: error instanceof Error ? error.message : '图片读取失败',
});
}
}
const failedImages = [...imageByKey.values()].filter(
(image) => image.error,
);
const successfulImages = [...imageByKey.values()].filter(
(image) => image.blob,
);
if (!successfulImages.length) {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
return;
}
const metadata: CanvasAssetExportMetadata = {
projectId,
projectTitle,
exportedAt: exportedAt.toISOString(),
layers: exportableLayers.map((layer) => {
const image = imageByKey.get(getLayerExportKey(layer));
return buildLayerExportMetadata(
layer,
image?.blob ? image.file : null,
image?.error,
);
}),
failedImages: failedImages.map((image) => ({
key: image.key,
title: image.layer.title,
src: image.layer.src,
error: image.error ?? '图片读取失败',
})),
};
const manifest = [
`项目:${projectTitle}`,
`导出时间:${metadata.exportedAt}`,
`素材数量:${successfulImages.length}`,
`图层数量:${exportableLayers.length}`,
failedImages.length ? `失败素材数量:${failedImages.length}` : null,
]
.filter(Boolean)
.join('\n');
rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2));
rootFolder.file('manifest.txt', manifest);
const zipBlob = await zip.generateAsync({ type: 'blob' });
if (
typeof URL.createObjectURL !== 'function' ||
typeof URL.revokeObjectURL !== 'function'
) {
setAssetExportStatus({
tone: 'error',
message: '当前浏览器不支持素材下载',
});
return;
}
const downloadUrl = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
setAssetExportStatus({
tone: failedImages.length ? 'error' : 'success',
message: failedImages.length ? '部分素材未能导出' : '画布素材已导出',
});
} catch {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
} finally {
setIsExportingAssets(false);
}
}, [isExportingAssets, layers, projectId, projectTitle]);
return {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportLayerImage,
};
}