From 10b170e7a222761e2ddcb14e557f0ac2ac4967df Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 29 Jun 2026 19:53:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=94=BB=E5=B8=83=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E4=B8=8E=E6=8A=A0=E5=9B=BE=E4=BB=BB=E5=8A=A1=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 素材库资产支持直接下载。 画布复制图片时同步写入系统剪贴板。 手动抠图请求直接进入画布任务列表并支持聚焦。 --- .../ImageCanvasAssetFolderSectionView.tsx | 3 + .../ImageCanvasAssetLibraryPanelView.tsx | 3 + .../ImageCanvasAssetRowView.test.tsx | 15 ++++ .../image-editor/ImageCanvasAssetRowView.tsx | 10 ++- ...mageCanvasEditorAssetsIntegration.test.tsx | 41 +++++++++++ .../ImageCanvasEditorShellView.test.tsx | 3 + .../image-editor/ImageCanvasEditorView.tsx | 51 ++++++++++++++ .../ImageCanvasSidebarView.test.tsx | 4 ++ .../image-editor/ImageCanvasSidebarView.tsx | 3 + .../image-editor/ImageCanvasStageView.tsx | 7 ++ .../ImageCanvasTaskSidebarView.test.tsx | 30 ++++++++ .../ImageCanvasTaskSidebarView.tsx | 40 +++++++++-- .../useImageCanvasLayerCommands.test.tsx | 69 ++++++++++++++++++- .../useImageCanvasLayerCommands.ts | 28 +++++++- 14 files changed, 294 insertions(+), 13 deletions(-) diff --git a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx index fe371467c..5d575ae4f 100644 --- a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx +++ b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx @@ -66,6 +66,7 @@ type ImageCanvasAssetFolderSectionViewProps = { ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; + onDownloadAsset: (asset: EditorAsset) => void; }; export function ImageCanvasAssetFolderSectionView({ @@ -97,6 +98,7 @@ export function ImageCanvasAssetFolderSectionView({ setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, + onDownloadAsset, }: ImageCanvasAssetFolderSectionViewProps) { return (
))} diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx index ac4320a0c..ffdfaa990 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -96,6 +96,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = { ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; + onDownloadAsset: (asset: EditorAsset) => void; toggleAllAssetsSelected: () => void; deleteSelectedAssets: () => void; closeAssetSelectionMode: () => void; @@ -143,6 +144,7 @@ export function ImageCanvasAssetLibraryPanelView({ setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, + onDownloadAsset, toggleAllAssetsSelected, deleteSelectedAssets, closeAssetSelectionMode, @@ -234,6 +236,7 @@ export function ImageCanvasAssetLibraryPanelView({ setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} + onDownloadAsset={onDownloadAsset} /> ))} {isAssetSelectionMode ? ( diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index b85a9fdc1..927290369 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -76,6 +76,7 @@ function renderAssetRow({ commitAssetRename = vi.fn(), deleteUploadedAsset = vi.fn(), setAssetPublicShowcaseEnabled = vi.fn(), + onDownloadAsset = vi.fn(), }: { asset?: EditorAsset; isAssetSelectionMode?: boolean; @@ -90,6 +91,7 @@ function renderAssetRow({ asset: EditorAsset, publicShowcaseEnabled: boolean, ) => void; + onDownloadAsset?: (asset: EditorAsset) => void; } = {}) { const props = { asset, @@ -111,6 +113,7 @@ function renderAssetRow({ setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, + onDownloadAsset, }; render(); @@ -128,6 +131,18 @@ describe('ImageCanvasAssetRowView', () => { expect(addAssetLayer).toHaveBeenCalledWith(asset); }); + it('downloads the asset from the row actions', () => { + const asset = createAsset(); + const onDownloadAsset = vi.fn(); + renderAssetRow({ asset, onDownloadAsset }); + + fireEvent.click( + screen.getByRole('button', { name: '下载素材账号素材A' }), + ); + + expect(onDownloadAsset).toHaveBeenCalledWith(asset); + }); + it('selects the asset instead of adding it in selection mode', () => { const addAssetLayer = vi.fn(); const toggleAssetSelected = vi.fn(); diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 583c67114..71aff901d 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -1,4 +1,4 @@ -import { Check, Music2, Pencil, Video, X } from 'lucide-react'; +import { Check, Download, Music2, Pencil, Video, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; @@ -49,6 +49,7 @@ export type ImageCanvasAssetRowViewProps = { ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; + onDownloadAsset: (asset: EditorAsset) => void; }; export function ImageCanvasAssetRowView({ @@ -71,6 +72,7 @@ export function ImageCanvasAssetRowView({ setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, + onDownloadAsset, }: ImageCanvasAssetRowViewProps) { const rowRef = useRef(null); const [menuPosition, setMenuPosition] = useState<{ @@ -204,6 +206,12 @@ export function ImageCanvasAssetRowView({ ) : (
+ onDownloadAsset(asset)} + /> { expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy(); }); + it('downloads an asset directly from the asset library', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (url: string) => { + if (url === '/creation-type-references/puzzle.webp') { + return new Response(new Blob(['asset'], { type: 'image/webp' })); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + let downloadName = ''; + let downloadHref = ''; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:asset-library-download'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + downloadName = this.download; + downloadHref = this.href; + }, + ); + + try { + render(); + + fireEvent.click( + await screen.findByRole('button', { name: '下载素材拼图素材' }), + ); + + await waitFor(() => { + expect(downloadName).toBe('拼图素材.webp'); + }); + expect(downloadHref).toBe('blob:asset-library-download'); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('collapses folders, creates upload folders, and deletes uploaded materials', async () => { const user = userEvent.setup(); const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image'); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index ed8e85a31..7bfba9f4b 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -80,6 +80,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps { setAssetPublicShowcaseEnabled: vi.fn(), toggleAssetSelected: vi.fn(), addAssetLayer: vi.fn(), + onDownloadAsset: vi.fn(), toggleAllAssetsSelected: vi.fn(), deleteSelectedAssets: vi.fn(), closeAssetSelectionMode: vi.fn(), @@ -135,6 +136,7 @@ function createStageProps(): ImageCanvasStageViewProps { hoveredLayerId: null, canvasMarquee: null, canvasGenerationDialogs: [], + localTaskItems: [], isTaskSidebarOpen: false, generateDialog: null, cropExpandPanel: null, @@ -178,6 +180,7 @@ function createStageProps(): ImageCanvasStageViewProps { onGenerationFramePointerDown: vi.fn(), onActivateGenerationDialog: vi.fn(), onFocusExternalTask: vi.fn(), + onFocusLocalTask: vi.fn(), onToggleTaskSidebar: vi.fn(), onCropExpandHandlePointerDown: vi.fn(), onOpenQuickEditPanel: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 79ff38bd5..c478f11a2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -42,9 +42,11 @@ import type { CanvasContextMenuState, CanvasGenerationDialogState, CanvasLayer, + CanvasTaskListItem, CanvasTool, CanvasViewport, CharacterReferenceImage, + EditorAsset, ImageContextMenuState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; @@ -167,6 +169,38 @@ async function readSystemClipboardImageFiles() { return imageFiles; } +function createAssetActionLayer(asset: EditorAsset): CanvasLayer { + return { + id: `asset-download-${asset.id}`, + resourceId: + asset.sourceResourceId?.trim() || asset.assetObjectId?.trim() || asset.id, + title: asset.label, + src: asset.src, + mediaType: asset.mediaType, + thumbnailSrc: asset.thumbnailSrc, + x: 0, + y: 0, + width: asset.width, + height: asset.height, + originalWidth: asset.width, + originalHeight: asset.height, + zIndex: 0, + sourceType: asset.sourceType, + prompt: asset.prompt ?? null, + actualPrompt: asset.actualPrompt ?? null, + model: asset.model ?? null, + provider: asset.provider ?? null, + taskId: asset.taskId ?? null, + objectKey: asset.objectKey ?? null, + assetObjectId: asset.assetObjectId ?? null, + sourceResourceId: asset.sourceResourceId ?? null, + sourceAssetId: asset.id, + assetKind: asset.assetKind ?? null, + generationInputs: asset.generationInputs ?? null, + durationSeconds: asset.durationSeconds, + }; +} + export function ImageCanvasEditorView() { const authUi = useAuthUi(); const [, setGenerationPricingVersion] = useState(0); @@ -691,6 +725,14 @@ export function ImageCanvasEditorView() { }, [focusCanvasGenerationDialog, focusCanvasLayerById], ); + const focusLocalCanvasTask = useCallback( + (task: CanvasTaskListItem) => { + if (task.focusLayerId) { + focusCanvasLayerById(task.focusLayerId); + } + }, + [focusCanvasLayerById], + ); const persistGeneratedAsset = useCallback( (layer: CanvasLayer) => { if (authUiRef.current && !authUiRef.current.canAccessProtectedData) { @@ -928,6 +970,12 @@ export function ImageCanvasEditorView() { projectId, projectTitle, }); + const downloadAsset = useCallback( + (asset: EditorAsset) => { + exportLayerImage(createAssetActionLayer(asset)); + }, + [exportLayerImage], + ); const { uploadInputRef, uploadTarget, @@ -1631,6 +1679,7 @@ export function ImageCanvasEditorView() { setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, + onDownloadAsset: downloadAsset, toggleAllAssetsSelected, deleteSelectedAssets, closeAssetSelectionMode, @@ -1686,6 +1735,7 @@ export function ImageCanvasEditorView() { hoveredLayerId, canvasMarquee, canvasGenerationDialogs, + localTaskItems: generationSurface.backgroundRemovalTasks, isTaskSidebarOpen: generationSurface.isTaskSidebarOpen, generateDialog, cropExpandPanel: generationSurface.cropExpandPanel, @@ -1734,6 +1784,7 @@ export function ImageCanvasEditorView() { onGenerationFramePointerDown: handleGenerationFramePointerDown, onActivateGenerationDialog: activateCanvasGenerationDialog, onFocusExternalTask: focusExternalGenerationTask, + onFocusLocalTask: focusLocalCanvasTask, onToggleTaskSidebar: generationSurface.toggleTaskSidebar, onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize, onOpenQuickEditPanel: openLayerGenerationDialog, diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx index 0c348da35..88deb5d3c 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx @@ -118,6 +118,7 @@ function createSidebarProps( setAssetPublicShowcaseEnabled: vi.fn(), toggleAssetSelected: vi.fn(), addAssetLayer: vi.fn(), + onDownloadAsset: vi.fn(), toggleAllAssetsSelected: vi.fn(), deleteSelectedAssets: vi.fn(), closeAssetSelectionMode: vi.fn(), @@ -189,6 +190,9 @@ describe('ImageCanvasSidebarView', () => { expect( within(folder).getByRole('button', { name: '上传到项目素材' }), ).toBeTruthy(); + expect( + within(folder).getByRole('button', { name: '下载素材账号素材A' }), + ).toBeTruthy(); }); it('renders asset editing inputs with sidebar text-field chrome', () => { diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx index 89054f40a..d984120af 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.tsx @@ -102,6 +102,7 @@ export type ImageCanvasSidebarViewProps = { ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; + onDownloadAsset: (asset: EditorAsset) => void; toggleAllAssetsSelected: () => void; deleteSelectedAssets: () => void; closeAssetSelectionMode: () => void; @@ -167,6 +168,7 @@ export function ImageCanvasSidebarView({ setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, + onDownloadAsset, toggleAllAssetsSelected, deleteSelectedAssets, closeAssetSelectionMode, @@ -321,6 +323,7 @@ export function ImageCanvasSidebarView({ setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} + onDownloadAsset={onDownloadAsset} toggleAllAssetsSelected={toggleAllAssetsSelected} deleteSelectedAssets={deleteSelectedAssets} closeAssetSelectionMode={closeAssetSelectionMode} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index ebe47c183..ed340d0d8 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -17,6 +17,7 @@ import type { CanvasClipboard, CanvasContextMenuState, CanvasGenerationDialogState, + CanvasTaskListItem, CanvasLayer, CanvasMarqueeState, CanvasTool, @@ -57,6 +58,7 @@ export type ImageCanvasStageViewProps = { hoveredLayerId: string | null; canvasMarquee: CanvasMarqueeState | null; canvasGenerationDialogs: CanvasGenerationDialogState[]; + localTaskItems: CanvasTaskListItem[]; isTaskSidebarOpen: boolean; generateDialog: GenerateDialogState | null; cropExpandPanel: CropExpandPanelState | null; @@ -116,6 +118,7 @@ export type ImageCanvasStageViewProps = { ) => void; onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; + onFocusLocalTask: (task: CanvasTaskListItem) => void; onToggleTaskSidebar: () => void; onCropExpandHandlePointerDown: ( event: ReactPointerEvent, @@ -195,6 +198,7 @@ export function ImageCanvasStageView({ hoveredLayerId, canvasMarquee, canvasGenerationDialogs, + localTaskItems, isTaskSidebarOpen, generateDialog, cropExpandPanel, @@ -239,6 +243,7 @@ export function ImageCanvasStageView({ onGenerationFramePointerDown, onActivateGenerationDialog, onFocusExternalTask, + onFocusLocalTask, onToggleTaskSidebar, onCropExpandHandlePointerDown, onOpenQuickEditPanel, @@ -438,9 +443,11 @@ export function ImageCanvasStageView({ { expect(screen.getByText('暂无任务')).toBeTruthy(); expect(screen.queryByText(/已完成提示词/u)).toBeNull(); }); + + it('shows local background-removal tasks in the task list immediately', async () => { + const focusLocalTask = vi.fn(); + const startedAt = Date.now() - 4000; + const localTask = { + id: 'background-removal-1', + title: '去背景:源图', + detail: '正在抠图', + status: 'running' as const, + startedAt, + focusLayerId: 'layer-source', + }; + + render( + , + ); + + expect(await screen.findByText('去背景:源图')).toBeTruthy(); + expect(screen.getByText(/正在抠图 · 已用时/u)).toBeTruthy(); + + fireEvent.click(screen.getByText('去背景:源图')); + expect(focusLocalTask).toHaveBeenCalledWith(localTask); + }); }); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index 5fe705dfc..bd97ee3ef 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -17,7 +17,10 @@ import { import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { listExternalGenerationTasks } from '../../services/external-generation'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; -import type { CanvasTaskStatus } from './ImageCanvasEditorTypes'; +import type { + CanvasTaskListItem, + CanvasTaskStatus, +} from './ImageCanvasEditorTypes'; const COMPLETED_TASK_LIST_LIMIT = 20; const ACTIVE_TASK_LIST_LIMIT = 100; @@ -40,9 +43,11 @@ type TaskSidebarItem = { type ImageCanvasTaskSidebarViewProps = { projectId?: string | null; + localTasks?: CanvasTaskListItem[]; open: boolean; onToggleOpen: () => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; + onFocusLocalTask?: (task: CanvasTaskListItem) => void; }; function parseTaskTimeMs(value?: string | null) { @@ -212,9 +217,11 @@ function mergeExternalTaskRefresh( export function ImageCanvasTaskSidebarView({ projectId, + localTasks = [], open, onToggleOpen, onFocusExternalTask, + onFocusLocalTask, }: ImageCanvasTaskSidebarViewProps) { const [activeTab, setActiveTab] = useState('active'); const [now, setNow] = useState(() => Date.now()); @@ -342,9 +349,9 @@ export function ImageCanvasTaskSidebarView({ ]); useEffect(() => { - const hasRunningTask = externalTasks.some( - (task) => task.status === 'running', - ); + const hasRunningTask = + externalTasks.some((task) => task.status === 'running') || + localTasks.some((task) => task.status === 'running'); if (!hasRunningTask) { return undefined; } @@ -352,7 +359,7 @@ export function ImageCanvasTaskSidebarView({ setNow(Date.now()); }, 1000); return () => window.clearInterval(timerId); - }, [externalTasks]); + }, [externalTasks, localTasks]); const items = useMemo(() => { const externalVisibleTasks = mergeExternalTasks( @@ -401,8 +408,27 @@ export function ImageCanvasTaskSidebarView({ 0, }; }); - return externalItems; - }, [externalTasks, now, onFocusExternalTask]); + const localItems = localTasks.map((task) => ({ + id: `local-${task.id}`, + title: task.title, + detail: task.detail, + status: task.status, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + elapsedMs: + task.status === 'running' + ? now - task.startedAt + : task.elapsedMs ?? + (task.finishedAt ? task.finishedAt - task.startedAt : undefined), + errorMessage: task.errorMessage, + onClick: + task.focusLayerId && onFocusLocalTask + ? () => onFocusLocalTask(task) + : undefined, + sortTime: task.finishedAt ?? task.startedAt, + })); + return [...localItems, ...externalItems]; + }, [externalTasks, localTasks, now, onFocusExternalTask, onFocusLocalTask]); const activeItems = useMemo( () => diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index e9bf7043c..717210232 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -9,8 +9,20 @@ import type { CanvasGenerationDialogState, CanvasLayer, } from './ImageCanvasEditorTypes'; +import { readLayerImageBlob } from './ImageCanvasExportModel'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; +vi.mock('./ImageCanvasExportModel', () => ({ + readLayerImageBlob: vi.fn(async (layer: CanvasLayer) => { + const blob = new Blob([layer.id], { type: 'image/png' }); + Object.defineProperty(blob, 'layerId', { + configurable: true, + value: layer.id, + }); + return blob; + }), +})); + function createLayer(id: string, x: number, zIndex: number): CanvasLayer { return { id, @@ -30,6 +42,7 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer { const originalClipboard = navigator.clipboard; const originalClipboardItem = globalThis.ClipboardItem; +const originalCreateImageBitmap = globalThis.createImageBitmap; function installImageClipboardMock() { class ClipboardItemMock { @@ -283,10 +296,14 @@ afterEach(() => { configurable: true, value: originalClipboardItem, }); + Object.defineProperty(globalThis, 'createImageBitmap', { + configurable: true, + value: originalCreateImageBitmap, + }); }); describe('useImageCanvasLayerCommands', () => { - it('writes the first copied image layer to the system clipboard', async () => { + it('writes the last copied image layer to the system clipboard', async () => { const write = installImageClipboardMock(); render(); @@ -297,9 +314,49 @@ describe('useImageCanvasLayerCommands', () => { | Array<{ items: Record }> | undefined; expect(clipboardItems?.[0]?.items['image/png']).toBeInstanceOf(Blob); + expect(readLayerImageBlob).toHaveBeenCalledWith( + expect.objectContaining({ id: 'second' }), + ); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); }); + it('normalizes copied images to png for the system clipboard', async () => { + const write = installImageClipboardMock(); + const drawImage = vi.fn(); + const toBlob = vi.fn((resolve: (blob: Blob) => void) => + resolve(new Blob(['png'], { type: 'image/png' })), + ); + vi.mocked(readLayerImageBlob).mockResolvedValueOnce( + new Blob(['webp'], { type: 'image/webp' }), + ); + Object.defineProperty(globalThis, 'createImageBitmap', { + configurable: true, + value: vi.fn(async () => ({ width: 16, height: 12 })), + }); + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tagName) => { + if (tagName === 'canvas') { + return { + width: 0, + height: 0, + getContext: () => ({ drawImage }), + toBlob, + } as unknown as HTMLCanvasElement; + } + return originalCreateElement(tagName); + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + + await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); + const clipboardItems = write.mock.calls[0]?.[0] as + | Array<{ items: Record }> + | undefined; + expect(clipboardItems?.[0]?.items['image/png']?.type).toBe('image/png'); + expect(drawImage).toHaveBeenCalledTimes(1); + }); + it('keeps canvas copy when system image clipboard is unavailable', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -331,13 +388,21 @@ describe('useImageCanvasLayerCommands', () => { fireEvent.click(screen.getByRole('button', { name: '复制选中' })); await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); + expect(readLayerImageBlob).toHaveBeenCalledWith( + expect.objectContaining({ id: 'second' }), + ); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); }); - it('copies, cuts, and pastes context layers with history and selection updates', () => { + it('copies, cuts, and pastes context layers with history and selection updates', async () => { + const write = installImageClipboardMock(); render(); fireEvent.click(screen.getByRole('button', { name: '复制' })); + await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); + expect(readLayerImageBlob).toHaveBeenCalledWith( + expect.objectContaining({ id: 'second' }), + ); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); 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 648c7a95e..bc0b4d414 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -72,7 +72,27 @@ function isClipboardImageLayer(layer: CanvasLayer) { ); } -function normalizeClipboardImageBlob(blob: Blob) { +async function normalizeClipboardImageBlob(blob: Blob) { + if (blob.type === 'image/png') { + return blob; + } + if (typeof createImageBitmap === 'function') { + try { + const image = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + canvas.getContext('2d')?.drawImage(image, 0, 0); + const pngBlob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/png'), + ); + if (pngBlob) { + return pngBlob; + } + } catch { + // 浏览器不支持当前图片解码时,回退到原始字节。 + } + } if (!blob.type || blob.type === 'application/octet-stream') { return blob.slice(0, blob.size, 'image/png'); } @@ -81,12 +101,14 @@ function normalizeClipboardImageBlob(blob: Blob) { function copyLayerImageToSystemClipboard(layers: CanvasLayer[]) { void (async () => { - const layer = layers.find(isClipboardImageLayer); + const layer = layers.slice().reverse().find(isClipboardImageLayer); const clipboard = globalThis.navigator?.clipboard; if (!layer || !clipboard?.write || typeof ClipboardItem !== 'function') { return; } - const blob = normalizeClipboardImageBlob(await readLayerImageBlob(layer)); + const blob = await normalizeClipboardImageBlob( + await readLayerImageBlob(layer), + ); await clipboard.write([new ClipboardItem({ [blob.type]: blob })]); })().catch(() => { // 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。