From de1b265d0aa89fa52737e634a89fa553b5a45e39 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 27 Jun 2026 17:46:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=BB=E5=B8=83=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=A4=8D=E5=88=B6=E5=88=B0=E7=B3=BB=E7=BB=9F=E5=89=AA?= =?UTF-8?q?=E5=88=87=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复制画布图层时异步写入首个可复制图片到系统剪切板 复用现有画布资源读取链路处理 data 和私有资源 补充图层复制命令的剪切板兼容测试 --- .../useImageCanvasLayerCommands.test.tsx | 105 ++++++++++++++++-- .../useImageCanvasLayerCommands.ts | 37 ++++++ 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index c54d722e0..e9bf7043c 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -1,8 +1,8 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasContextMenuState, @@ -16,7 +16,7 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer { id, resourceId: `resource-${id}`, title: id, - src: `data:image/png;base64,${id}`, + src: `data:image/png,${encodeURIComponent(id)}`, x, y: 20, width: 100, @@ -28,10 +28,36 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer { }; } +const originalClipboard = navigator.clipboard; +const originalClipboardItem = globalThis.ClipboardItem; + +function installImageClipboardMock() { + class ClipboardItemMock { + readonly items: Record; + + constructor(items: Record) { + this.items = items; + } + } + const write = vi.fn( + async (_items: Array<{ items: Record }>) => undefined, + ); + Object.defineProperty(globalThis, 'ClipboardItem', { + configurable: true, + value: ClipboardItemMock, + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { write }, + }); + return write; +} + function LayerCommandsHarness({ exportLayerImage = vi.fn(), onDeleteLayerSideEffects = vi.fn(), onDeleteGenerationDialogSideEffects = vi.fn(), + initialLayers, }: { exportLayerImage?: ( layer: CanvasLayer | null, @@ -39,12 +65,15 @@ function LayerCommandsHarness({ ) => void; onDeleteLayerSideEffects?: (layerId: string) => void; onDeleteGenerationDialogSideEffects?: (dialogId: string) => void; + initialLayers?: CanvasLayer[]; }) { - const [layers, setLayers] = useState([ - createLayer('first', 10, 1), - createLayer('second', 160, 2), - createLayer('third', 310, 3), - ]); + const [layers, setLayers] = useState( + initialLayers ?? [ + createLayer('first', 10, 1), + createLayer('second', 160, 2), + createLayer('third', 310, 3), + ], + ); const [selectedLayerId, setSelectedLayerId] = useState( 'first', ); @@ -244,7 +273,67 @@ function LayerCommandsHarness({ ); } +afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); + Object.defineProperty(globalThis, 'ClipboardItem', { + configurable: true, + value: originalClipboardItem, + }); +}); + describe('useImageCanvasLayerCommands', () => { + it('writes the first copied image layer to the system clipboard', async () => { + const write = installImageClipboardMock(); + 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']).toBeInstanceOf(Blob); + expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); + }); + + it('keeps canvas copy when system image clipboard is unavailable', () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, 'ClipboardItem', { + configurable: true, + value: undefined, + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + + expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); + }); + + it('skips non-image layers when choosing the system clipboard image', async () => { + const write = installImageClipboardMock(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + + await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); + }); + it('copies, cuts, and pastes context layers with history and selection updates', () => { render(); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index fc97dcb67..648c7a95e 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -28,6 +28,7 @@ import { ungroupCanvasLayers, updateCanvasLayersByIds, } from './ImageCanvasLayerCommandModel'; +import { readLayerImageBlob } from './ImageCanvasExportModel'; import { getCanvasGenerationDialogIdFromSelectionId, getSelectedLayerIds, @@ -62,6 +63,36 @@ function createGroupId() { return `layer-group-${Date.now()}`; } +function isClipboardImageLayer(layer: CanvasLayer) { + return ( + Boolean(layer.src.trim()) && + layer.mediaType !== 'video' && + layer.mediaType !== 'audio' && + layer.mediaType !== 'image-sequence' + ); +} + +function normalizeClipboardImageBlob(blob: Blob) { + if (!blob.type || blob.type === 'application/octet-stream') { + return blob.slice(0, blob.size, 'image/png'); + } + return blob; +} + +function copyLayerImageToSystemClipboard(layers: CanvasLayer[]) { + void (async () => { + const layer = layers.find(isClipboardImageLayer); + const clipboard = globalThis.navigator?.clipboard; + if (!layer || !clipboard?.write || typeof ClipboardItem !== 'function') { + return; + } + const blob = normalizeClipboardImageBlob(await readLayerImageBlob(layer)); + await clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + })().catch(() => { + // 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。 + }); +} + export function useImageCanvasLayerCommands({ layers, contextMenu, @@ -163,6 +194,9 @@ export function useImageCanvasLayerCommands({ } canvasClipboardRef.current = clipboard; setCanvasClipboard(clipboard); + if (!options.cut) { + copyLayerImageToSystemClipboard(clipboard.layers); + } if (options.cut) { captureCanvasHistory(); setLayers((currentLayers) => @@ -201,6 +235,9 @@ export function useImageCanvasLayerCommands({ } canvasClipboardRef.current = clipboard; setCanvasClipboard(clipboard); + if (!options.cut) { + copyLayerImageToSystemClipboard(clipboard.layers); + } if (options.cut) { captureCanvasHistory(); setLayers((currentLayers) =>