diff --git a/packages/web/src/utils/imageActions.test.ts b/packages/web/src/utils/imageActions.test.ts index 10d2296d..bffb7394 100644 --- a/packages/web/src/utils/imageActions.test.ts +++ b/packages/web/src/utils/imageActions.test.ts @@ -83,6 +83,20 @@ describe('copyImageToClipboard', () => { expect(clipboardItem).toBeInstanceOf(ClipboardItem); }); + it('copies GIF URLs as text to preserve animation', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { write: vi.fn(), writeText: mockWriteText }, + }); + const mockAddToast = vi.fn(); + vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any); + + await copyImageToClipboard('https://media.tenor.com/abc/tenor.gif'); + + expect(mockWriteText).toHaveBeenCalledWith('https://media.tenor.com/abc/tenor.gif'); + expect(mockAddToast).toHaveBeenCalledWith('Copied GIF link', 'success', 3000); + }); + it('falls back to copying URL as text on failure and shows toast', async () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('CORS')); const mockWriteText = vi.fn().mockResolvedValue(undefined); diff --git a/packages/web/src/utils/imageActions.ts b/packages/web/src/utils/imageActions.ts index 199179e7..b0276b16 100644 --- a/packages/web/src/utils/imageActions.ts +++ b/packages/web/src/utils/imageActions.ts @@ -27,21 +27,31 @@ export async function saveImage(url: string, filename?: string): Promise { /** * Copies an image to the clipboard as PNG. + * GIFs are copied as URL text to preserve animation (PNG conversion strips it). * Falls back to copying the URL as text if CORS or clipboard API blocks it. */ export async function copyImageToClipboard(url: string): Promise { + // GIFs lose animation when converted to PNG — copy the URL instead + if (isGifUrl(url)) { + await navigator.clipboard.writeText(url); + useUIStore.getState().addToast('Copied GIF link', 'success', 3000); + return; + } + try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); - let pngBlob: Blob; - if (blob.type === 'image/png') { - pngBlob = blob; - } else { - pngBlob = await convertToPng(blob); + // If the server returned a GIF despite the URL not ending in .gif + if (blob.type === 'image/gif') { + await navigator.clipboard.writeText(url); + useUIStore.getState().addToast('Copied GIF link', 'success', 3000); + return; } + const pngBlob = blob.type === 'image/png' ? blob : await convertToPng(blob); + await navigator.clipboard.write([ new ClipboardItem({ 'image/png': pngBlob }), ]); @@ -51,6 +61,15 @@ export async function copyImageToClipboard(url: string): Promise { } } +/** Checks if a URL points to a GIF by extension or known GIF CDN patterns. */ +function isGifUrl(url: string): boolean { + const path = url.split('?')[0]?.toLowerCase() ?? ''; + if (path.endsWith('.gif')) return true; + // Tenor and Klipy serve GIFs even without .gif extension + if (/media\.tenor\.com|static\.klipy\.com/.test(url)) return true; + return false; +} + /** Draws a blob onto an offscreen canvas and exports as PNG. */ function convertToPng(blob: Blob): Promise { return new Promise((resolve, reject) => {