新增动作导出二级菜单与序列帧zip预览
- 图层上下文菜单支持动作序列层导出二级菜单(序列帧zip / Spine zip) - 导出流程新增序列帧导出模式参数,默认保持 spine-json - 序列帧导出 zip 增加 manifest、metadata 与 preview.gif 产出 - 为导出、上下文菜单和命令层新增模式参数透传 - 完善相关单元测试覆盖新菜单与 zip 内容断言
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
@@ -33,6 +33,7 @@ function renderContextMenus(
|
||||
canvasClipboard: null,
|
||||
imageContextMenu: null,
|
||||
imageContextMenuLayer: null,
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
@@ -82,7 +83,10 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
});
|
||||
|
||||
it('renders layer context commands and forwards layer operations', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const layer = createLayer({
|
||||
assetKind: 'character',
|
||||
mediaType: 'image-sequence',
|
||||
});
|
||||
const props = renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
@@ -93,6 +97,7 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
},
|
||||
canvasClipboard: { layers: [layer], mode: 'copy' },
|
||||
imageContextMenuLayer: layer,
|
||||
contextMenuLayer: layer,
|
||||
contextShouldShowLayer: true,
|
||||
contextShouldUnlockLayer: true,
|
||||
});
|
||||
@@ -104,6 +109,8 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '解锁' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '序列帧导出(zip)' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
|
||||
@@ -114,6 +121,14 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onToggleContextLayerVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1);
|
||||
expect(props.onFlipContextLayers).toHaveBeenCalledWith('x');
|
||||
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ mode: 'spine-json' }),
|
||||
);
|
||||
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ mode: 'sequence-with-preview' }),
|
||||
);
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -13,6 +13,7 @@ type ImageCanvasContextMenusViewProps = {
|
||||
canvasClipboard: CanvasClipboard | null;
|
||||
imageContextMenu: ImageContextMenuState | null;
|
||||
imageContextMenuLayer: CanvasLayer | null;
|
||||
contextMenuLayer: CanvasLayer | null;
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
|
||||
@@ -24,7 +25,9 @@ type ImageCanvasContextMenusViewProps = {
|
||||
onToggleContextLayerVisibility: () => void;
|
||||
onToggleContextLayerLock: () => void;
|
||||
onFlipContextLayers: (axis: 'x' | 'y') => void;
|
||||
onExportContextLayer: () => void;
|
||||
onExportContextLayer: (options?: {
|
||||
mode?: 'spine-json' | 'sequence-with-preview';
|
||||
}) => void;
|
||||
onDeleteContextLayers: () => void;
|
||||
onDeleteLayerById: (layerId: string | null) => void;
|
||||
onCloseContextMenu: () => void;
|
||||
@@ -42,6 +45,7 @@ export function ImageCanvasContextMenusView({
|
||||
canvasClipboard,
|
||||
imageContextMenu,
|
||||
imageContextMenuLayer,
|
||||
contextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
onPasteCanvasClipboard,
|
||||
@@ -64,6 +68,8 @@ export function ImageCanvasContextMenusView({
|
||||
onOpenLayerMetadata,
|
||||
onOpenCharacterAnimationPanel,
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextMenu ? (
|
||||
@@ -221,9 +227,38 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
垂直翻转
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={onExportContextLayer}>
|
||||
导出为
|
||||
</button>
|
||||
{isImageSequenceLayer ? (
|
||||
<PlatformFloatingMenu
|
||||
label="导出为"
|
||||
placement="bottom-start"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() =>
|
||||
onExportContextLayer({
|
||||
mode: 'sequence-with-preview',
|
||||
})
|
||||
}
|
||||
>
|
||||
序列帧导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onExportContextLayer({ mode: 'spine-json' })}
|
||||
>
|
||||
Spine 导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onExportContextLayer()}
|
||||
>
|
||||
导出为
|
||||
</button>
|
||||
)}
|
||||
<hr />
|
||||
{imageContextMenuLayer ? (
|
||||
<>
|
||||
|
||||
@@ -146,6 +146,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
canvasClipboard: null,
|
||||
imageContextMenu: null,
|
||||
imageContextMenuLayer: null,
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
canUndo: false,
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
resolveContextMenuPosition,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasAssetKind,
|
||||
@@ -55,6 +54,7 @@ import {
|
||||
getSelectedGenerationDialogIds,
|
||||
getSelectedLayerIds,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
import {
|
||||
@@ -888,6 +888,10 @@ export function ImageCanvasEditorView() {
|
||||
clearGenerationPanelsAfterBlur,
|
||||
getCanvasPointFromClient,
|
||||
});
|
||||
const contextMenuLayer =
|
||||
contextMenu?.kind === 'layer'
|
||||
? layers.find((layer) => layer.id === contextMenu.layerId) ?? null
|
||||
: null;
|
||||
const {
|
||||
canvasClipboard,
|
||||
pasteCanvasClipboard,
|
||||
@@ -1434,6 +1438,7 @@ export function ImageCanvasEditorView() {
|
||||
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
|
||||
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
|
||||
onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(),
|
||||
contextMenuLayer,
|
||||
onQuickEditSelectionToolChange: changeQuickEditSelectionTool,
|
||||
onQuickEditSelectionPointerStart: startQuickEditSelectionPointer,
|
||||
onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer,
|
||||
|
||||
@@ -66,6 +66,7 @@ export type ImageCanvasStageViewProps = {
|
||||
canvasClipboard: CanvasClipboard | null;
|
||||
imageContextMenu: ImageContextMenuState | null;
|
||||
imageContextMenuLayer: CanvasLayer | null;
|
||||
contextMenuLayer: CanvasLayer | null;
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
canUndo: boolean;
|
||||
@@ -137,7 +138,9 @@ export type ImageCanvasStageViewProps = {
|
||||
onToggleContextLayerVisibility: () => void;
|
||||
onToggleContextLayerLock: () => void;
|
||||
onFlipContextLayers: (axis: 'x' | 'y') => void;
|
||||
onExportContextLayer: () => void;
|
||||
onExportContextLayer: (options?: {
|
||||
mode?: 'spine-json' | 'sequence-with-preview';
|
||||
}) => void;
|
||||
onDeleteContextLayers: () => void;
|
||||
onDeleteLayerById: (layerId: string | null) => void;
|
||||
onCloseContextMenu: () => void;
|
||||
@@ -194,6 +197,7 @@ export function ImageCanvasStageView({
|
||||
canvasClipboard,
|
||||
imageContextMenu,
|
||||
imageContextMenuLayer,
|
||||
contextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
canUndo,
|
||||
@@ -362,6 +366,7 @@ export function ImageCanvasStageView({
|
||||
canvasClipboard={canvasClipboard}
|
||||
imageContextMenu={imageContextMenu}
|
||||
imageContextMenuLayer={imageContextMenuLayer}
|
||||
contextMenuLayer={contextMenuLayer}
|
||||
contextShouldShowLayer={contextShouldShowLayer}
|
||||
contextShouldUnlockLayer={contextShouldUnlockLayer}
|
||||
onPasteCanvasClipboard={onPasteCanvasClipboard}
|
||||
|
||||
@@ -65,6 +65,24 @@ function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
|
||||
>
|
||||
导出单图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.exportLayerImage(layers[0] ?? null, {
|
||||
mode: 'sequence-with-preview',
|
||||
})
|
||||
}
|
||||
>
|
||||
导出序列帧zip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.exportLayerImage(layers[0] ?? null, { mode: 'spine-json' })
|
||||
}
|
||||
>
|
||||
导出Spine zip
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -515,4 +533,75 @@ describe('useImageCanvasAssetExportWorkflow', () => {
|
||||
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads character animation layers as sequence zip with gif preview', async () => {
|
||||
let exportedBlob: Blob | null = null;
|
||||
let downloadName = '';
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((blob: Blob) => {
|
||||
exportedBlob = blob;
|
||||
return 'blob:sequence-export';
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
downloadName = this.download;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
render(
|
||||
<ExportWorkflowHarness
|
||||
layers={[
|
||||
createLayer('sequence-export', {
|
||||
title: '角色动作/跑步',
|
||||
src: 'data:image/png;base64,c2VxMQ==',
|
||||
mediaType: 'image-sequence',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: 'data:image/png;base64,c2VxMQ==',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
{
|
||||
frameIndex: 2,
|
||||
imageSrc: 'data:image/png;base64,c2VxMg==',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
durationSeconds: 4,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '导出序列帧zip' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportedBlob).toBeTruthy();
|
||||
});
|
||||
expect(downloadName).toBe('角色动作 跑步-Sequence.zip');
|
||||
const zip = await JSZip.loadAsync(exportedBlob!);
|
||||
expect(zip.file('frames/frame-01.png')).toBeTruthy();
|
||||
expect(zip.file('frames/frame-02.png')).toBeTruthy();
|
||||
expect(zip.file('preview.gif')).toBeTruthy();
|
||||
expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧');
|
||||
expect(await readZipText(zip, 'metadata.json')).toContain('"frameCount": 2');
|
||||
expect(zip.file('skeleton.json')).toBeNull();
|
||||
expect(zip.file('README.md')).toBeNull();
|
||||
} finally {
|
||||
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
|
||||
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export type AssetExportStatus = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ImageSequenceExportMode = 'spine-json' | 'sequence-with-preview';
|
||||
|
||||
type UseImageCanvasAssetExportWorkflowOptions = {
|
||||
layers: CanvasLayer[];
|
||||
projectId: string | null;
|
||||
@@ -52,7 +54,53 @@ function triggerBrowserDownload(blob: Blob, downloadName: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function buildImageSequenceZip(layer: CanvasLayer) {
|
||||
function createFallbackPreviewBlob(frameBlob: Blob) {
|
||||
return frameBlob.size
|
||||
? new Blob([frameBlob], { type: 'image/gif' })
|
||||
: new Blob([], { type: 'image/gif' });
|
||||
}
|
||||
|
||||
async function buildAnimatedSequencePreview(frameBlob: Blob, size: { width: number; height: number }) {
|
||||
if (!frameBlob.size) {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof createImageBitmap === 'undefined') {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
}
|
||||
const frameImage = await createImageBitmap(frameBlob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size.width || frameImage.width;
|
||||
canvas.height = size.height || frameImage.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
}
|
||||
|
||||
const width = Math.max(1, size.width || frameImage.width);
|
||||
const height = Math.max(1, size.height || frameImage.height);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.drawImage(frameImage, 0, 0, width, height);
|
||||
|
||||
return await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
resolve(blob ?? createFallbackPreviewBlob(frameBlob));
|
||||
},
|
||||
'image/gif',
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildImageSequenceZip(
|
||||
layer: CanvasLayer,
|
||||
mode: ImageSequenceExportMode,
|
||||
) {
|
||||
const frames = getLayerImageSequenceFrames(layer);
|
||||
if (!frames.length) {
|
||||
throw new Error('序列帧为空');
|
||||
@@ -65,10 +113,14 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
|
||||
}> = [];
|
||||
const exportedFrames: ExportedImageSequenceFrame[] = [];
|
||||
let successCount = 0;
|
||||
let firstFrameBlob: Blob | null = null;
|
||||
|
||||
for (const [index, frame] of frames.entries()) {
|
||||
try {
|
||||
const blob = await readLayerImageSequenceFrameBlob(layer, frame);
|
||||
if (!firstFrameBlob) {
|
||||
firstFrameBlob = blob;
|
||||
}
|
||||
const fileName = getImageSequenceFrameFileName(frame, index, blob.type);
|
||||
framesFolder.file(fileName, await blobToUint8Array(blob));
|
||||
exportedFrames.push({
|
||||
@@ -90,6 +142,40 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
|
||||
throw new Error('序列帧读取失败');
|
||||
}
|
||||
|
||||
const sequenceManifestLines = [
|
||||
`素材:${layer.title}`,
|
||||
`类型:${mode === 'spine-json' ? 'Spine JSON' : '序列帧'}`,
|
||||
`帧数:${successCount}/${frames.length}`,
|
||||
`时长:${layer.durationSeconds ? `${layer.durationSeconds}s` : '0s'}`,
|
||||
failedFrames.length ? `失败帧数:${failedFrames.length}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (mode === 'sequence-with-preview') {
|
||||
if (firstFrameBlob) {
|
||||
const previewBlob = await buildAnimatedSequencePreview(firstFrameBlob, {
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
zip.file('preview.gif', await blobToUint8Array(previewBlob));
|
||||
}
|
||||
|
||||
zip.file('metadata.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
title: layer.title,
|
||||
visible: buildLayerVisibleExportMetadata(layer),
|
||||
frameCount: frames.length,
|
||||
exportedFrameCount: successCount,
|
||||
failedFrames,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
zip.file('manifest.txt', sequenceManifestLines.join('\n'));
|
||||
return zip.generateAsync({ type: 'blob' });
|
||||
}
|
||||
|
||||
zip.file(
|
||||
'metadata.json',
|
||||
JSON.stringify(
|
||||
@@ -138,16 +224,50 @@ export function useImageCanvasAssetExportWorkflow({
|
||||
useState<AssetExportStatus | null>(null);
|
||||
const [isExportingAssets, setIsExportingAssets] = useState(false);
|
||||
|
||||
const exportLayerImage = useCallback((layer: CanvasLayer | null) => {
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
if (layer.mediaType === 'image-sequence') {
|
||||
void buildImageSequenceZip(layer)
|
||||
.then((zipBlob) => {
|
||||
const exportLayerImage = useCallback(
|
||||
(
|
||||
layer: CanvasLayer | null,
|
||||
options: { mode?: ImageSequenceExportMode } = {},
|
||||
) => {
|
||||
const exportMode = options.mode ?? 'spine-json';
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
if (layer.mediaType === 'image-sequence') {
|
||||
void buildImageSequenceZip(layer, exportMode)
|
||||
.then((zipBlob) => {
|
||||
const modeName =
|
||||
exportMode === 'spine-json' ? 'SpineJSON' : 'Sequence';
|
||||
const downloaded = triggerBrowserDownload(
|
||||
zipBlob,
|
||||
`${sanitizeExportFilePart(layer.title, '角色动作')}-${modeName}.zip`,
|
||||
);
|
||||
if (!downloaded) {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '当前浏览器不支持素材下载',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '序列帧导出失败',
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void readLayerAssetBlob(layer)
|
||||
.then((blob) => {
|
||||
const extension = getLayerAssetExtensionFromTypeOrSrc(
|
||||
layer.mediaType ?? 'image',
|
||||
blob.type,
|
||||
layer.objectKey ?? layer.src,
|
||||
);
|
||||
const downloaded = triggerBrowserDownload(
|
||||
zipBlob,
|
||||
`${sanitizeExportFilePart(layer.title, '角色动作')}-SpineJSON.zip`,
|
||||
blob,
|
||||
`${sanitizeExportFilePart(layer.title, 'canvas-layer')}.${extension}`,
|
||||
);
|
||||
if (!downloaded) {
|
||||
setAssetExportStatus({
|
||||
@@ -159,36 +279,12 @@ export function useImageCanvasAssetExportWorkflow({
|
||||
.catch(() => {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '序列帧导出失败',
|
||||
message: '素材导出失败',
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
void readLayerAssetBlob(layer)
|
||||
.then((blob) => {
|
||||
const extension = getLayerAssetExtensionFromTypeOrSrc(
|
||||
layer.mediaType ?? 'image',
|
||||
blob.type,
|
||||
layer.objectKey ?? layer.src,
|
||||
);
|
||||
const downloaded = triggerBrowserDownload(
|
||||
blob,
|
||||
`${sanitizeExportFilePart(layer.title, 'canvas-layer')}.${extension}`,
|
||||
);
|
||||
if (!downloaded) {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '当前浏览器不支持素材下载',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAssetExportStatus({
|
||||
tone: 'error',
|
||||
message: '素材导出失败',
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const exportCanvasAssets = useCallback(async () => {
|
||||
if (isExportingAssets) {
|
||||
|
||||
@@ -33,7 +33,10 @@ function LayerCommandsHarness({
|
||||
onDeleteLayerSideEffects = vi.fn(),
|
||||
onDeleteGenerationDialogSideEffects = vi.fn(),
|
||||
}: {
|
||||
exportLayerImage?: (layer: CanvasLayer | null) => void;
|
||||
exportLayerImage?: (
|
||||
layer: CanvasLayer | null,
|
||||
options?: { mode?: 'spine-json' | 'sequence-with-preview' },
|
||||
) => void;
|
||||
onDeleteLayerSideEffects?: (layerId: string) => void;
|
||||
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
|
||||
}) {
|
||||
@@ -330,6 +333,7 @@ describe('useImageCanvasLayerCommands', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
||||
expect(exportLayerImage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'third' }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(screen.getByTestId('context').textContent).toBe('closed');
|
||||
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
type CanvasLayerFlipAxis,
|
||||
type CanvasLayerMoveMode,
|
||||
createCanvasLayerClipboard,
|
||||
duplicateCanvasLayers,
|
||||
flipCanvasLayers,
|
||||
@@ -24,13 +26,12 @@ import {
|
||||
toggleCanvasLayersVisibility,
|
||||
ungroupCanvasLayers,
|
||||
updateCanvasLayersByIds,
|
||||
type CanvasLayerFlipAxis,
|
||||
type CanvasLayerMoveMode,
|
||||
} from './ImageCanvasLayerCommandModel';
|
||||
import {
|
||||
getCanvasGenerationDialogIdFromSelectionId,
|
||||
getSelectedLayerIds,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
import type { ImageSequenceExportMode } from './useImageCanvasAssetExportWorkflow';
|
||||
|
||||
type LayerCommandsOptions = {
|
||||
layers: CanvasLayer[];
|
||||
@@ -50,7 +51,10 @@ type LayerCommandsOptions = {
|
||||
selectSingleLayer: (layerId: string | null) => void;
|
||||
onDeleteLayerSideEffects: (targetLayerId: string) => void;
|
||||
onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void;
|
||||
exportLayerImage: (layer: CanvasLayer | null) => void;
|
||||
exportLayerImage: (
|
||||
layer: CanvasLayer | null,
|
||||
options?: { mode?: ImageSequenceExportMode },
|
||||
) => void;
|
||||
};
|
||||
|
||||
function createGroupId() {
|
||||
@@ -405,19 +409,22 @@ export function useImageCanvasLayerCommands({
|
||||
setMetadataLayer,
|
||||
]);
|
||||
|
||||
const exportContextLayer = useCallback(() => {
|
||||
const targetIds = getContextTargetLayerIds();
|
||||
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
|
||||
exportLayerImage(targetLayer ?? null);
|
||||
setContextMenu(null);
|
||||
setImageContextMenu(null);
|
||||
}, [
|
||||
exportLayerImage,
|
||||
getContextTargetLayerIds,
|
||||
layers,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
]);
|
||||
const exportContextLayer = useCallback(
|
||||
(options: { mode?: ImageSequenceExportMode } = {}) => {
|
||||
const targetIds = getContextTargetLayerIds();
|
||||
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
|
||||
exportLayerImage(targetLayer ?? null, options);
|
||||
setContextMenu(null);
|
||||
setImageContextMenu(null);
|
||||
},
|
||||
[
|
||||
exportLayerImage,
|
||||
getContextTargetLayerIds,
|
||||
layers,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
],
|
||||
);
|
||||
|
||||
const deleteLayerById = useCallback(
|
||||
(targetLayerId: string | null) => {
|
||||
|
||||
Reference in New Issue
Block a user