fix: copy GIF URLs as text to preserve animation
PNG conversion strips GIF animation. GIFs are now detected by URL pattern (.gif extension or Tenor/Klipy CDN) and copied as URL text instead, so pasting back into chat re-renders the animated GIF.
This commit is contained in:
@@ -83,6 +83,20 @@ describe('copyImageToClipboard', () => {
|
|||||||
expect(clipboardItem).toBeInstanceOf(ClipboardItem);
|
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 () => {
|
it('falls back to copying URL as text on failure and shows toast', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('CORS'));
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('CORS'));
|
||||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -27,21 +27,31 @@ export async function saveImage(url: string, filename?: string): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies an image to the clipboard as PNG.
|
* 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.
|
* Falls back to copying the URL as text if CORS or clipboard API blocks it.
|
||||||
*/
|
*/
|
||||||
export async function copyImageToClipboard(url: string): Promise<void> {
|
export async function copyImageToClipboard(url: string): Promise<void> {
|
||||||
|
// 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 {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
let pngBlob: Blob;
|
|
||||||
|
|
||||||
if (blob.type === 'image/png') {
|
// If the server returned a GIF despite the URL not ending in .gif
|
||||||
pngBlob = blob;
|
if (blob.type === 'image/gif') {
|
||||||
} else {
|
await navigator.clipboard.writeText(url);
|
||||||
pngBlob = await convertToPng(blob);
|
useUIStore.getState().addToast('Copied GIF link', 'success', 3000);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pngBlob = blob.type === 'image/png' ? blob : await convertToPng(blob);
|
||||||
|
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
new ClipboardItem({ 'image/png': pngBlob }),
|
new ClipboardItem({ 'image/png': pngBlob }),
|
||||||
]);
|
]);
|
||||||
@@ -51,6 +61,15 @@ export async function copyImageToClipboard(url: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Draws a blob onto an offscreen canvas and exports as PNG. */
|
||||||
function convertToPng(blob: Blob): Promise<Blob> {
|
function convertToPng(blob: Blob): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user