fix: Keep the clipboard write within user activation

This commit is contained in:
2026-07-20 17:25:52 +08:00
parent 93296a3e2f
commit f86df0c12a
2 changed files with 27 additions and 13 deletions
@@ -97,14 +97,24 @@ describe('MessageBubble', () => {
});
it('copies an attached WebP as PNG without opening the message text menu', async () => {
const clipboardWrite = vi.fn().mockResolvedValue(undefined);
let resolveAssetRead!: (response: Response) => void;
const pendingAssetRead = new Promise<Response>((resolve) => {
resolveAssetRead = resolve;
});
const clipboardWrite = vi.fn(
async (
items: Array<{ items: Record<string, Blob | PromiseLike<Blob>> }>,
) => {
await items[0]?.items['image/png'];
},
);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { write: clipboardWrite },
});
const ClipboardItemMock = vi.fn(function (
this: { items: Record<string, Blob> },
items: Record<string, Blob>,
this: { items: Record<string, Blob | PromiseLike<Blob>> },
items: Record<string, Blob | PromiseLike<Blob>>,
) {
this.items = items;
});
@@ -127,11 +137,7 @@ describe('MessageBubble', () => {
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback) => callback(new Blob(['png'], { type: 'image/png' })),
);
vi.mocked(readAssetBytes).mockResolvedValue({
blob: vi
.fn()
.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })),
} as unknown as Response);
vi.mocked(readAssetBytes).mockReturnValue(pendingAssetRead);
const { container } = renderMessage({
id: 5,
role: 'user',
@@ -158,14 +164,21 @@ describe('MessageBubble', () => {
expect(screen.queryByRole('menuitem', { name: '复制文本' })).toBeNull();
fireEvent.click(screen.getByRole('menuitem', { name: '复制图片' }));
await waitFor(() => expect(clipboardWrite).toHaveBeenCalledTimes(1));
// The clipboard write starts synchronously, before private asset loading completes.
expect(clipboardWrite).toHaveBeenCalledTimes(1);
expect(readAssetBytes).toHaveBeenCalledWith(
'/generated-editor-images/private.webp',
{ objectKey: 'editor/private.webp' },
);
expect(ClipboardItemMock).toHaveBeenCalledWith({
'image/png': expect.any(Blob),
'image/png': expect.any(Promise),
});
resolveAssetRead({
blob: vi
.fn()
.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })),
} as unknown as Response);
await screen.findByRole('menuitem', { name: '已复制' });
expect(bitmapClose).toHaveBeenCalledTimes(1);
});
@@ -93,10 +93,11 @@ async function copyAssetImage(asset: EditorAgentContextAsset) {
return false;
}
try {
const response = await readAssetBytes(asset.source, {
const pngBlob = readAssetBytes(asset.source, {
objectKey: asset.objectKey,
});
const pngBlob = await convertImageBlobToPng(await response.blob());
}).then(async (response) => convertImageBlobToPng(await response.blob()));
// Clipboard write must start in the menu click's user-activation stack.
// Safari accepts promised ClipboardItem data and resolves it after asset loading/conversion.
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob }),
]);