支持画布图片复制到系统剪切板

复制画布图层时异步写入首个可复制图片到系统剪切板

复用现有画布资源读取链路处理 data 和私有资源

补充图层复制命令的剪切板兼容测试
This commit is contained in:
2026-06-27 17:46:39 +08:00
parent 1c5310a0bc
commit de1b265d0a
2 changed files with 134 additions and 8 deletions
@@ -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<string, Blob>;
constructor(items: Record<string, Blob>) {
this.items = items;
}
}
const write = vi.fn(
async (_items: Array<{ items: Record<string, Blob> }>) => 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<CanvasLayer[]>([
createLayer('first', 10, 1),
createLayer('second', 160, 2),
createLayer('third', 310, 3),
]);
const [layers, setLayers] = useState<CanvasLayer[]>(
initialLayers ?? [
createLayer('first', 10, 1),
createLayer('second', 160, 2),
createLayer('third', 310, 3),
],
);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
'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(<LayerCommandsHarness />);
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
const clipboardItems = write.mock.calls[0]?.[0] as
| Array<{ items: Record<string, Blob> }>
| 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(<LayerCommandsHarness />);
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(
<LayerCommandsHarness
initialLayers={[
{ ...createLayer('first', 10, 1), mediaType: 'video' },
createLayer('second', 160, 2),
createLayer('third', 310, 3),
]}
/>,
);
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(<LayerCommandsHarness />);
@@ -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) =>