From 960568364edf0dc20ed8f68a92db03340fd3e538 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 25 Jun 2026 18:59:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=A8=E4=BD=9C=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=BA=8C=E7=BA=A7=E8=8F=9C=E5=8D=95=E4=B8=8E=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=B8=A7zip=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图层上下文菜单支持动作序列层导出二级菜单(序列帧zip / Spine zip) - 导出流程新增序列帧导出模式参数,默认保持 spine-json - 序列帧导出 zip 增加 manifest、metadata 与 preview.gif 产出 - 为导出、上下文菜单和命令层新增模式参数透传 - 完善相关单元测试覆盖新菜单与 zip 内容断言 --- .../ImageCanvasContextMenusView.test.tsx | 19 +- .../ImageCanvasContextMenusView.tsx | 43 ++++- .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorView.tsx | 7 +- .../image-editor/ImageCanvasStageView.tsx | 7 +- ...useImageCanvasAssetExportWorkflow.test.tsx | 89 +++++++++ .../useImageCanvasAssetExportWorkflow.ts | 172 ++++++++++++++---- .../useImageCanvasLayerCommands.test.tsx | 6 +- .../useImageCanvasLayerCommands.ts | 43 +++-- 9 files changed, 322 insertions(+), 65 deletions(-) diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx index 92bba1006..928c676e0 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -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 { 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); diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index 46cd1ad49..5853ccfe3 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -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({ > 垂直翻转 - + {isImageSequenceLayer ? ( + + + onExportContextLayer({ + mode: 'sequence-with-preview', + }) + } + > + 序列帧导出(zip) + + onExportContextLayer({ mode: 'spine-json' })} + > + Spine 导出(zip) + + + ) : ( + + )}
{imageContextMenuLayer ? ( <> diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 96e038a0b..642c22568 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -146,6 +146,7 @@ function createStageProps(): ImageCanvasStageViewProps { canvasClipboard: null, imageContextMenu: null, imageContextMenuLayer: null, + contextMenuLayer: null, contextShouldShowLayer: false, contextShouldUnlockLayer: false, canUndo: false, diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index c2150ec9e..430521b00 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 73d88f1aa..1ee98f767 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -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} diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 081f85abc..6b7a421b5 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -65,6 +65,24 @@ function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) { > 导出单图 + + ); } @@ -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( + , + ); + + 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; + } + }); }); diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index dcf9ba463..9a501ba64 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -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((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(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) { diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 131610bdc..b78863f17 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -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'); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index d16c1e741..467d648c6 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -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) => {