支持画布图片复制到系统剪切板
复制画布图层时异步写入首个可复制图片到系统剪切板 复用现有画布资源读取链路处理 data 和私有资源 补充图层复制命令的剪切板兼容测试
This commit is contained in:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user