mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Extract shared image clipboard copy utility (#31600)
* Extract image clipboard copying into shared utility * Await image reads before reporting clipboard copy success
This commit is contained in:
+45
-8
@@ -144,21 +144,58 @@ describe('NativeChatResolvedView image menu ownership', () => {
|
||||
expect(mocks.writeClipboardImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the original image through the visible bridge chat menu', async () => {
|
||||
it.each(['thumbnail', 'preview'])(
|
||||
'copies the original image from the %s through the visible bridge chat menu',
|
||||
async (surface) => {
|
||||
mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
|
||||
render(<RetainedBridgeChat />)
|
||||
|
||||
if (surface === 'preview') {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Image #1: View image: original.png' }))
|
||||
}
|
||||
const preview =
|
||||
surface === 'preview' ? screen.getByRole('dialog', { name: 'original.png' }) : null
|
||||
const image = preview
|
||||
? within(preview).getByRole('img')
|
||||
: screen.getByRole('img', { name: 'original.png' })
|
||||
fireEvent.contextMenu(image)
|
||||
const copy = await screen.findByRole('menuitem', { name: 'Copy image' })
|
||||
fireEvent.pointerDown(copy, { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.pointerUp(copy, { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(copy)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.writeClipboardImage).toHaveBeenCalledWith(
|
||||
`data:image/png;base64,${Buffer.from('original image').toString('base64')}`
|
||||
)
|
||||
)
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('blob:original')
|
||||
expect(mocks.success).toHaveBeenCalledWith('Image copied')
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
if (preview) {
|
||||
expect(preview).toBeInTheDocument()
|
||||
expect(preview).toHaveAttribute('data-state', 'open')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('reports a clipboard write failure without announcing success while the write is pending', async () => {
|
||||
const write = Promise.withResolvers<void>()
|
||||
mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
|
||||
mocks.writeClipboardImage.mockReturnValue(write.promise)
|
||||
render(<RetainedBridgeChat />)
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('img', { name: 'original.png' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.writeClipboardImage).toHaveBeenCalledWith(
|
||||
`data:image/png;base64,${Buffer.from('original image').toString('base64')}`
|
||||
)
|
||||
)
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('blob:original')
|
||||
expect(mocks.success).toHaveBeenCalledWith('Image copied')
|
||||
await waitFor(() => expect(mocks.writeClipboardImage).toHaveBeenCalledOnce())
|
||||
expect(mocks.success).not.toHaveBeenCalled()
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => write.reject(new Error('Clipboard unavailable')))
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("Couldn't copy image", { description: undefined })
|
||||
expect(mocks.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the portaled menu and captured image when the retained bridge owner hides', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { blobToBase64, convertImageBlobToPng } from '@/lib/image-blob-png'
|
||||
import { copyImageToClipboard } from '@/lib/clipboard-image-copy'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image'
|
||||
|
||||
@@ -60,8 +60,7 @@ export function keepPreviewOpenForChatMenu(event: {
|
||||
|
||||
export async function copyNativeChatImage(image: NativeChatCopyImage): Promise<void> {
|
||||
try {
|
||||
const png = await convertImageBlobToPng(await image())
|
||||
await window.api.ui.writeClipboardImage(`data:image/png;base64,${await blobToBase64(png)}`)
|
||||
await copyImageToClipboard(await image())
|
||||
toast.success(translate('components.native-chat.composer.imageCopied', 'Image copied'))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { copyImageToClipboard } from './clipboard-image-copy'
|
||||
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='
|
||||
const png = new Blob([Uint8Array.from(atob(PNG_BASE64), (character) => character.charCodeAt(0))], {
|
||||
type: 'image/png'
|
||||
})
|
||||
const writeClipboardImage = vi.fn<(dataUrl: string) => Promise<void>>()
|
||||
const decode = vi.fn<() => Promise<void>>()
|
||||
|
||||
beforeEach(() => {
|
||||
writeClipboardImage.mockResolvedValue(undefined)
|
||||
decode.mockResolvedValue(undefined)
|
||||
vi.stubGlobal('api', { ui: { writeClipboardImage } })
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class {
|
||||
src = ''
|
||||
naturalWidth = 1
|
||||
naturalHeight = 1
|
||||
decode = decode
|
||||
}
|
||||
)
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:decode-owned')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
writeClipboardImage.mockReset()
|
||||
decode.mockReset()
|
||||
})
|
||||
|
||||
describe('copyImageToClipboard', () => {
|
||||
it('writes the original PNG bytes and waits for the local clipboard write', async () => {
|
||||
const write = Promise.withResolvers<void>()
|
||||
writeClipboardImage.mockReturnValue(write.promise)
|
||||
let finished = false
|
||||
|
||||
const copying = copyImageToClipboard(png).then(() => {
|
||||
finished = true
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(writeClipboardImage).toHaveBeenCalledWith(`data:image/png;base64,${PNG_BASE64}`)
|
||||
})
|
||||
expect(finished).toBe(false)
|
||||
|
||||
write.resolve()
|
||||
await copying
|
||||
expect(finished).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates a decode failure without writing to the clipboard', async () => {
|
||||
const error = new Error('Invalid image')
|
||||
decode.mockRejectedValue(error)
|
||||
|
||||
await expect(copyImageToClipboard(png)).rejects.toBe(error)
|
||||
|
||||
expect(writeClipboardImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates a local clipboard write failure', async () => {
|
||||
const error = new Error('Clipboard unavailable')
|
||||
writeClipboardImage.mockRejectedValue(error)
|
||||
|
||||
await expect(copyImageToClipboard(png)).rejects.toBe(error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { blobToBase64, convertImageBlobToPng } from './image-blob-png'
|
||||
|
||||
export async function copyImageToClipboard(blob: Blob): Promise<void> {
|
||||
const png = await convertImageBlobToPng(blob)
|
||||
await window.api.ui.writeClipboardImage(`data:image/png;base64,${await blobToBase64(png)}`)
|
||||
}
|
||||
Reference in New Issue
Block a user