fix: copy remote preview images without download fallback

This commit is contained in:
CodingOnStar committed 2026-09-17 16:27:04 +08:00
1 parent 485c6b30dc
commit 2c419048a2
26 files changed
+198 -66

No files matched your search

@@ -49,6 +49,12 @@ describe('ImagePreview', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal(
'fetch',
vi
.fn()
.mockImplementation(async () => new Response(new Blob(['image'], { type: 'image/png' }))),
)
if (!navigator.clipboard) {
Object.defineProperty(globalThis.navigator, 'clipboard', {
@@ -75,7 +81,7 @@ describe('ImagePreview', () => {
})
globalThis.ClipboardItem = class {
constructor(public readonly data: Record<string, Blob>) {}
constructor(public readonly data: Record<string, Blob | Promise<Blob>>) {}
} as unknown as typeof ClipboardItem
vi.spyOn(window, 'open').mockImplementation((...args: Parameters<Window['open']>) => {
return mocks.windowOpen(...args)
@@ -85,6 +91,7 @@ describe('ImagePreview', () => {
afterEach(() => {
globalThis.ClipboardItem = originalClipboardItem
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('Rendering', () => {
@@ -289,21 +296,18 @@ describe('ImagePreview', () => {
})
describe('Action Buttons', () => {
it('should open valid url in new tab', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
it.each(['https://example.com/image.png', '/image-proxy/github/example'])(
'should open valid url %s in new tab',
async (url) => {
const user = userEvent.setup()
render(<ImagePreview url={url} title="Preview Image" onCancel={vi.fn()} />)
const openInTabButton = getOpenInTabButton()
await user.click(openInTabButton)
const openInTabButton = getOpenInTabButton()
await user.click(openInTabButton)
expect(mocks.windowOpen).toHaveBeenCalledWith('https://example.com/image.png', '_blank')
})
expect(mocks.windowOpen).toHaveBeenCalledWith(url, '_blank')
},
)
it('should open data image by writing to popup window document', async () => {
const user = userEvent.setup()
@@ -336,7 +340,7 @@ describe('ImagePreview', () => {
})
})
it('should fall back to download and show info toast when clipboard copy fails', async () => {
it('keeps the preview open and reports clipboard denial without downloading or navigating', async () => {
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mocks.clipboardWrite.mockRejectedValue(new Error('copy failed'))
@@ -346,21 +350,116 @@ describe('ImagePreview', () => {
const copyButton = getCopyButton()
await user.click(copyButton)
await waitFor(() => {
expect(mocks.downloadUrl).toHaveBeenCalledWith({
url: dataImage,
fileName: 'Preview Image.png',
})
})
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({
type: 'error',
message: 'common.operation.imageCopyFailed',
}),
)
expect(mocks.downloadUrl).not.toHaveBeenCalled()
expect(mocks.windowOpen).not.toHaveBeenCalled()
expect(getOverlay()).toBeInTheDocument()
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('copies a remote PNG and starts the clipboard write before the image fetch resolves', async () => {
const user = userEvent.setup()
const png = new Blob(['png bytes'], { type: 'image/png' })
let resolveFetch!: (response: Response) => void
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve
}),
)
vi.stubGlobal('fetch', fetchMock)
let copiedBlob: Blob | undefined
mocks.clipboardWrite.mockImplementation(async ([item]) => {
copiedBlob = await (item as unknown as { data: Record<string, Promise<Blob>> }).data[
'image/png'
]
})
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
await user.click(getCopyButton())
expect(mocks.clipboardWrite).toHaveBeenCalledTimes(1)
expect(copiedBlob).toBeUndefined()
resolveFetch(new Response(png))
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })),
)
expect(copiedBlob?.type).toBe('image/png')
expect(await copiedBlob?.text()).toBe('png bytes')
expect(mocks.downloadUrl).not.toHaveBeenCalled()
})
it('converts a JPEG to PNG instead of labelling JPEG bytes as PNG', async () => {
const user = userEvent.setup()
const jpeg = new Blob(['jpeg bytes'], { type: 'image/jpeg' })
const png = new Blob(['encoded png'], { type: 'image/png' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(jpeg)))
const bitmap = { width: 12, height: 8, close: vi.fn() }
vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) =>
callback(png),
)
let copiedBlob: Blob | undefined
mocks.clipboardWrite.mockImplementation(async ([item]) => {
copiedBlob = await (item as unknown as { data: Record<string, Promise<Blob>> }).data[
'image/png'
]
})
render(
<ImagePreview
url="https://example.com/image.jpg"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
await user.click(getCopyButton())
await waitFor(() => expect(copiedBlob).toBe(png))
expect(bitmap.close).toHaveBeenCalled()
expect(mocks.downloadUrl).not.toHaveBeenCalled()
})
it('reports an image fetch failure without leaving the preview', async () => {
const user = userEvent.setup()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
mocks.clipboardWrite.mockImplementation(async ([item]) => {
await (item as unknown as { data: Record<string, Promise<Blob>> }).data['image/png']
})
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
await user.click(getCopyButton())
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })),
)
expect(mocks.downloadUrl).not.toHaveBeenCalled()
expect(mocks.windowOpen).not.toHaveBeenCalled()
expect(getOverlay()).toBeInTheDocument()
})
it('should copy image and show success toast', async () => {
const user = userEvent.setup()
mocks.clipboardWrite.mockResolvedValue()
@@ -379,24 +478,21 @@ describe('ImagePreview', () => {
)
})
it('should call download action for valid url', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const downloadButton = getDownloadButton()
await user.click(downloadButton)
it.each(['https://example.com/image.png', '/image-proxy/github/example'])(
'should download valid url %s',
async (url) => {
const user = userEvent.setup()
render(<ImagePreview url={url} title="Preview Image" onCancel={vi.fn()} />)
const downloadButton = getDownloadButton()
await user.click(downloadButton)
expect(mocks.downloadUrl).toHaveBeenCalledWith({
url: 'https://example.com/image.png',
fileName: 'Preview Image',
target: '_blank',
})
})
expect(mocks.downloadUrl).toHaveBeenCalledWith({
url,
fileName: 'Preview Image',
target: '_blank',
})
},
)
it('should show error toast for invalid download url', async () => {
const user = userEvent.setup()
@@ -25,6 +25,31 @@ const isBase64 = (str: string): boolean => {
}
}
const fetchImageAsPng = async (url: string): Promise<Blob> => {
const response = await fetch(url)
if (!response.ok) throw new Error('Unable to load image')
const blob = await response.blob()
if (blob.type === 'image/png') return blob
const bitmap = await createImageBitmap(blob)
try {
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to convert image')
context.drawImage(bitmap, 0, 0)
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((png) => {
if (png) resolve(png)
else reject(new Error('Unable to encode image'))
}, 'image/png')
})
} finally {
bitmap.close()
}
}
const ImagePreview: FC<ImagePreviewProps> = ({ url, title, onCancel, onPrev, onNext }) => {
const { t } = useTranslation()
const [scale, setScale] = useState(1)
@@ -36,7 +61,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ url, title, onCancel, onPrev, onN
const openInNewTab = () => {
// Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http') || url.startsWith('https')) {
if (url.startsWith('http') || url.startsWith('/')) {
window.open(url, '_blank')
} else if (url.startsWith('data:image')) {
// Base64 image
@@ -49,7 +74,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ url, title, onCancel, onPrev, onN
const downloadImage = () => {
// Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http') || url.startsWith('https') || url.startsWith('data:image')) {
if (url.startsWith('http') || url.startsWith('/') || url.startsWith('data:image')) {
downloadUrl({ url, fileName: title, target: '_blank' })
return
}
@@ -69,31 +94,19 @@ const ImagePreview: FC<ImagePreviewProps> = ({ url, title, onCancel, onPrev, onN
})
}
const imageBase64ToBlob = (base64: string, type = 'image/png'): Blob => {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = Array.from({ length: slice.length })
for (let i = 0; i < slice.length; i++) byteNumbers[i] = slice.charCodeAt(i)
const byteArray = new Uint8Array(byteNumbers as any)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, { type })
}
const imageCopy = useCallback(() => {
const shareImage = async () => {
try {
const base64Data = url.split(',')[1]
const blob = imageBase64ToBlob(base64Data!, 'image/png')
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined')
throw new Error('Image clipboard is unavailable')
const png = fetchImageAsPng(isBase64(url) ? `data:image/png;base64,${url}` : url)
// Handle a rejected fetch even if clipboard permission is denied before
// the browser consumes the item. Keep write in the click gesture for Safari.
void png.catch(noop)
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
'image/png': png,
}),
])
setIsCopied(true)
@@ -102,13 +115,12 @@ const ImagePreview: FC<ImagePreviewProps> = ({ url, title, onCancel, onPrev, onN
} catch (err) {
console.error('Failed to copy image:', err)
downloadUrl({ url, fileName: `${title}.png` })
toast.info(t(($) => $['operation.imageDownloaded'], { ns: 'common' }))
setIsCopied(false)
toast.error(t(($) => $['operation.imageCopyFailed'], { ns: 'common' }))
}
}
shareImage()
}, [t, title, url])
}, [t, url])
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
if (e.deltaY < 0) zoomIn()
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "ملء تلقائي",
"operation.format": "تنسيق",
"operation.imageCopied": "تم نسخ الصورة",
"operation.imageCopyFailed": "تعذّر نسخ الصورة. يرجى تنزيلها بدلاً من ذلك.",
"operation.imageDownloaded": "تم تنزيل الصورة",
"operation.in": "في",
"operation.learnMore": "تعرف على المزيد",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Automatisch ausfüllen",
"operation.format": "Format",
"operation.imageCopied": "Kopiertes Bild",
"operation.imageCopyFailed": "Das Bild konnte nicht kopiert werden. Bitte laden Sie es stattdessen herunter.",
"operation.imageDownloaded": "Bild heruntergeladen",
"operation.in": "in",
"operation.learnMore": "Mehr erfahren",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Autofill",
"operation.format": "Format",
"operation.imageCopied": "Image copied",
"operation.imageCopyFailed": "Could not copy the image. Please download it instead.",
"operation.imageDownloaded": "Image downloaded",
"operation.in": "in",
"operation.learnMore": "Learn more",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Autocompletar",
"operation.format": "Formato",
"operation.imageCopied": "Imagen copiada",
"operation.imageCopyFailed": "No se pudo copiar la imagen. Descárgala en su lugar.",
"operation.imageDownloaded": "Imagen descargada",
"operation.in": "en",
"operation.learnMore": "Aprender más",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "پر کردن خودکار",
"operation.format": "قالب",
"operation.imageCopied": "تصویر کپی شده",
"operation.imageCopyFailed": "تصویر کپی نشد. لطفاً به‌جای آن تصویر را دانلود کنید.",
"operation.imageDownloaded": "تصویر دانلود شد",
"operation.in": "در",
"operation.learnMore": "اطلاعات بیشتر",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Remplissage auto",
"operation.format": "Format",
"operation.imageCopied": "Image copied",
"operation.imageCopyFailed": "Impossible de copier l’image. Veuillez la télécharger à la place.",
"operation.imageDownloaded": "Image téléchargée",
"operation.in": "dans",
"operation.learnMore": "En savoir plus",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "ऑटोफिल",
"operation.format": "फॉर्मेट",
"operation.imageCopied": "कॉपी की गई छवि",
"operation.imageCopyFailed": "इमेज कॉपी नहीं हो सकी। कृपया इसे डाउनलोड करें।",
"operation.imageDownloaded": "छवि डाउनलोड की गई",
"operation.in": "में",
"operation.learnMore": "अधिक जानें",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Isi otomatis",
"operation.format": "Format",
"operation.imageCopied": "Gambar yang disalin",
"operation.imageCopyFailed": "Gambar tidak dapat disalin. Silakan unduh sebagai gantinya.",
"operation.imageDownloaded": "Gambar diunduh",
"operation.in": "di",
"operation.learnMore": "Pelajari lebih lanjut",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Compilazione automatica",
"operation.format": "Formato",
"operation.imageCopied": "Immagine copiata",
"operation.imageCopyFailed": "Impossibile copiare l’immagine. Scaricala invece.",
"operation.imageDownloaded": "Immagine scaricata",
"operation.in": "in",
"operation.learnMore": "Scopri di più",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "自動入力",
"operation.format": "フォーマット",
"operation.imageCopied": "コピーした画像",
"operation.imageCopyFailed": "画像をコピーできませんでした。代わりにダウンロードしてください。",
"operation.imageDownloaded": "画像がダウンロードされました",
"operation.in": "中",
"operation.learnMore": "詳細はこちら",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "자동 입력",
"operation.format": "형식",
"operation.imageCopied": "복사된 이미지",
"operation.imageCopyFailed": "이미지를 복사할 수 없습니다. 대신 다운로드해 주세요.",
"operation.imageDownloaded": "이미지 다운로드됨",
"operation.in": "안으로",
"operation.learnMore": "자세히 알아보기",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "ຕື່ມຂໍ້ມູນອັດຕະໂນມັດ",
"operation.format": "ຈັດຮູບແບບ",
"operation.imageCopied": "ຄັດລອກຮູບແລ້ວ",
"operation.imageCopyFailed": "ບໍ່ສາມາດສຳເນົາຮູບພາບໄດ້. ກະລຸນາດາວໂຫຼດຮູບພາບແທນ.",
"operation.imageDownloaded": "ດາວໂຫຼດຮູບແລ້ວ",
"operation.in": "ໃນ",
"operation.learnMore": "ຮຽນຮູ້ເພີ່ມເຕີມ",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Automatisch invullen",
"operation.format": "Format",
"operation.imageCopied": "Image copied",
"operation.imageCopyFailed": "De afbeelding kon niet worden gekopieerd. Download deze in plaats daarvan.",
"operation.imageDownloaded": "Image downloaded",
"operation.in": "in",
"operation.learnMore": "Meer informatie",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Autouzupełnianie",
"operation.format": "Format",
"operation.imageCopied": "Skopiowany obraz",
"operation.imageCopyFailed": "Nie można skopiować obrazu. Zamiast tego pobierz go.",
"operation.imageDownloaded": "Obraz pobrany",
"operation.in": "w",
"operation.learnMore": "Dowiedz się więcej",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Preenchimento automático",
"operation.format": "Formato",
"operation.imageCopied": "Imagem copiada",
"operation.imageCopyFailed": "Não foi possível copiar a imagem. Baixe-a em vez disso.",
"operation.imageDownloaded": "Imagem baixada",
"operation.in": "em",
"operation.learnMore": "Saiba mais",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Completare automată",
"operation.format": "Format",
"operation.imageCopied": "Imagine copiată",
"operation.imageCopyFailed": "Imaginea nu a putut fi copiată. Descărcați-o în schimb.",
"operation.imageDownloaded": "Imagine descărcată",
"operation.in": "în",
"operation.learnMore": "Află mai multe",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Автозаполнение",
"operation.format": "Формат",
"operation.imageCopied": "Скопированное изображение",
"operation.imageCopyFailed": "Не удалось скопировать изображение. Попробуйте скачать его.",
"operation.imageDownloaded": "Изображение загружено",
"operation.in": "в",
"operation.learnMore": "Узнать больше",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Samodejno izpolni",
"operation.format": "Format",
"operation.imageCopied": "Kopirana slika",
"operation.imageCopyFailed": "Slike ni bilo mogoče kopirati. Namesto tega jo prenesite.",
"operation.imageDownloaded": "Slika prenesena",
"operation.in": "v",
"operation.learnMore": "Izvedi več",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "กรอกอัตโนมัติ",
"operation.format": "รูปแบบ",
"operation.imageCopied": "ภาพที่คัดลอก",
"operation.imageCopyFailed": "ไม่สามารถคัดลอกรูปภาพได้ โปรดดาวน์โหลดรูปภาพแทน",
"operation.imageDownloaded": "ดาวน์โหลดรูปภาพแล้ว",
"operation.in": "ใน",
"operation.learnMore": "ศึกษาเพิ่มเติม",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Otomatik doldur",
"operation.format": "Format",
"operation.imageCopied": "Kopyalanan görüntü",
"operation.imageCopyFailed": "Görüntü kopyalanamadı. Lütfen bunun yerine indirin.",
"operation.imageDownloaded": "Resim indirildi",
"operation.in": "içinde",
"operation.learnMore": "Daha fazla bilgi",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Автозаповнення",
"operation.format": "Формат",
"operation.imageCopied": "Скопійоване зображення",
"operation.imageCopyFailed": "Не вдалося скопіювати зображення. Спробуйте завантажити його.",
"operation.imageDownloaded": "Зображення завантажено",
"operation.in": "В",
"operation.learnMore": "Дізнатися більше",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "Tự động điền",
"operation.format": "Định dạng",
"operation.imageCopied": "Hình ảnh sao chép",
"operation.imageCopyFailed": "Không thể sao chép hình ảnh. Vui lòng tải ảnh xuống.",
"operation.imageDownloaded": "Hình ảnh đã được tải xuống",
"operation.in": "trong",
"operation.learnMore": "Tìm hiểu thêm",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "一键填入",
"operation.format": "格式化",
"operation.imageCopied": "图片已复制",
"operation.imageCopyFailed": "无法复制图片,请尝试下载图片。",
"operation.imageDownloaded": "图片已下载",
"operation.in": "在",
"operation.learnMore": "了解更多",
+1
View File
@@ -474,6 +474,7 @@
"operation.fill": "一鍵填入",
"operation.format": "格式",
"operation.imageCopied": "複製的圖片",
"operation.imageCopyFailed": "無法複製圖片,請嘗試下載圖片。",
"operation.imageDownloaded": "圖片已下載",
"operation.in": "在",
"operation.learnMore": "瞭解更多",