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.
102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
import { useUIStore } from '../stores/uiStore';
|
|
|
|
/**
|
|
* Downloads an image by fetching it as a blob and triggering a download.
|
|
* Falls back to opening in a new tab if CORS blocks the fetch.
|
|
*/
|
|
export async function saveImage(url: string, filename?: string): Promise<void> {
|
|
const derivedFilename = filename ?? url.split('/').pop()?.split('?')[0] ?? 'image';
|
|
|
|
try {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const blob = await res.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = derivedFilename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(blobUrl);
|
|
} catch {
|
|
window.open(url, '_blank', 'noopener');
|
|
useUIStore.getState().addToast('Opened in new tab', 'info', 3000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<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 {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const blob = await res.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 }),
|
|
]);
|
|
} catch {
|
|
await navigator.clipboard.writeText(url);
|
|
useUIStore.getState().addToast('Copied image link', 'info', 3000);
|
|
}
|
|
}
|
|
|
|
/** 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<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.naturalWidth;
|
|
canvas.height = img.naturalHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
URL.revokeObjectURL(blobUrl);
|
|
reject(new Error('Canvas 2D context unavailable'));
|
|
return;
|
|
}
|
|
ctx.drawImage(img, 0, 0);
|
|
URL.revokeObjectURL(blobUrl);
|
|
canvas.toBlob((pngBlob) => {
|
|
if (pngBlob) resolve(pngBlob);
|
|
else reject(new Error('Canvas toBlob returned null'));
|
|
}, 'image/png');
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(blobUrl);
|
|
reject(new Error('Failed to load image for conversion'));
|
|
};
|
|
img.src = blobUrl;
|
|
});
|
|
}
|