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

将图片剪切板写入提前到用户点击调用栈内发起

固定系统剪切板输出为 image/png

补充异步图片读取前已启动系统剪切板写入的回归测试
This commit is contained in:
2026-07-02 21:14:54 +08:00
parent 6d4033f044
commit 7d954e0985
2 changed files with 58 additions and 18 deletions
@@ -43,17 +43,18 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer {
const originalClipboard = navigator.clipboard;
const originalClipboardItem = globalThis.ClipboardItem;
const originalCreateImageBitmap = globalThis.createImageBitmap;
type ClipboardImageItems = Record<string, Blob | Promise<Blob>>;
function installImageClipboardMock() {
class ClipboardItemMock {
readonly items: Record<string, Blob>;
readonly items: ClipboardImageItems;
constructor(items: Record<string, Blob>) {
constructor(items: ClipboardImageItems) {
this.items = items;
}
}
const write = vi.fn(
async (_items: Array<{ items: Record<string, Blob> }>) => undefined,
async (_items: Array<{ items: ClipboardImageItems }>) => undefined,
);
Object.defineProperty(globalThis, 'ClipboardItem', {
configurable: true,
@@ -311,15 +312,41 @@ describe('useImageCanvasLayerCommands', () => {
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
const clipboardItems = write.mock.calls[0]?.[0] as
| Array<{ items: Record<string, Blob> }>
| Array<{ items: ClipboardImageItems }>
| undefined;
expect(clipboardItems?.[0]?.items['image/png']).toBeInstanceOf(Blob);
await expect(
clipboardItems?.[0]?.items['image/png'],
).resolves.toBeInstanceOf(Blob);
expect(readLayerImageBlob).toHaveBeenCalledWith(
expect.objectContaining({ id: 'second' }),
);
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
});
it('starts system clipboard write before image bytes finish loading', async () => {
const write = installImageClipboardMock();
let resolveBlob: (blob: Blob) => void = () => undefined;
vi.mocked(readLayerImageBlob).mockReturnValueOnce(
new Promise<Blob>((resolve) => {
resolveBlob = resolve;
}),
);
render(<LayerCommandsHarness />);
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
const clipboardItems = write.mock.calls[0]?.[0] as
| Array<{ items: ClipboardImageItems }>
| undefined;
const pngBlobPromise = clipboardItems?.[0]?.items['image/png'];
expect(pngBlobPromise).toBeInstanceOf(Promise);
resolveBlob(new Blob(['late-png'], { type: 'image/png' }));
await expect(pngBlobPromise).resolves.toBeInstanceOf(Blob);
});
it('normalizes copied images to png for the system clipboard', async () => {
const write = installImageClipboardMock();
const drawImage = vi.fn();
@@ -351,9 +378,13 @@ describe('useImageCanvasLayerCommands', () => {
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
const clipboardItems = write.mock.calls[0]?.[0] as
| Array<{ items: Record<string, Blob> }>
| Array<{ items: ClipboardImageItems }>
| undefined;
expect(clipboardItems?.[0]?.items['image/png']?.type).toBe('image/png');
await expect(
Promise.resolve(clipboardItems?.[0]?.items['image/png']).then(
(blob) => blob?.type,
),
).resolves.toBe('image/png');
expect(drawImage).toHaveBeenCalledTimes(1);
});
@@ -99,18 +99,27 @@ async function normalizeClipboardImageBlob(blob: Blob) {
return blob;
}
async function readLayerClipboardPngBlob(layer: CanvasLayer) {
const blob = await normalizeClipboardImageBlob(
await readLayerImageBlob(layer),
);
return blob.type === 'image/png'
? blob
: blob.slice(0, blob.size, 'image/png');
}
function copyLayerImageToSystemClipboard(layers: CanvasLayer[]) {
void (async () => {
const layer = layers.slice().reverse().find(isClipboardImageLayer);
const clipboard = globalThis.navigator?.clipboard;
if (!layer || !clipboard?.write || typeof ClipboardItem !== 'function') {
return;
}
const blob = await normalizeClipboardImageBlob(
await readLayerImageBlob(layer),
);
await clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
})().catch(() => {
const layer = layers.slice().reverse().find(isClipboardImageLayer);
const clipboard = globalThis.navigator?.clipboard;
const ClipboardItemCtor = globalThis.ClipboardItem;
if (!layer || !clipboard?.write || typeof ClipboardItemCtor !== 'function') {
return;
}
void clipboard.write([
new ClipboardItemCtor({
'image/png': readLayerClipboardPngBlob(layer),
}),
]).catch(() => {
// 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。
});
}