diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index af52065ff..3a083b04f 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -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((resolve) => { + resolveAssetRead = resolve; + }); + const clipboardWrite = vi.fn( + async ( + items: Array<{ items: Record> }>, + ) => { + await items[0]?.items['image/png']; + }, + ); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write: clipboardWrite }, }); const ClipboardItemMock = vi.fn(function ( - this: { items: Record }, - items: Record, + this: { items: Record> }, + items: Record>, ) { 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); }); diff --git a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts index 9f3be4b24..bc4e39217 100644 --- a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts +++ b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts @@ -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 }), ]);