feat: add saveImage and copyImageToClipboard utilities
Implements image save/download (blob fetch + anchor trigger, fallback to window.open) and clipboard copy (PNG write via ClipboardItem, fallback to URL text copy) with toast feedback. Also adds ClipboardItem polyfill and Response.blob() content-type fix to the jsdom test setup so the clipboard test suite runs correctly.
This commit is contained in:
@@ -1 +1,43 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// Polyfill ClipboardItem for jsdom (not included in jsdom)
|
||||||
|
if (typeof ClipboardItem === 'undefined') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).ClipboardItem = class ClipboardItem {
|
||||||
|
private items: Record<string, Blob | Promise<Blob>>;
|
||||||
|
constructor(items: Record<string, Blob | Promise<Blob>>) {
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
getType(type: string): Promise<Blob> {
|
||||||
|
const item = this.items[type];
|
||||||
|
return Promise.resolve(item as Blob);
|
||||||
|
}
|
||||||
|
get types(): string[] {
|
||||||
|
return Object.keys(this.items);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch globalThis.Response to preserve Blob content-type in jsdom.
|
||||||
|
// jsdom's fetch Response.blob() drops the Blob's MIME type; this shim
|
||||||
|
// restores it so tests that construct `new Response(blob)` behave correctly.
|
||||||
|
const OriginalResponse = globalThis.Response;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).Response = class PatchedResponse extends OriginalResponse {
|
||||||
|
private _sourceBlob: Blob | null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
constructor(body?: BodyInit | null, init?: ResponseInit) {
|
||||||
|
super(body, init);
|
||||||
|
this._sourceBlob = body instanceof Blob ? body : null;
|
||||||
|
}
|
||||||
|
async blob(): Promise<Blob> {
|
||||||
|
const b = await super.blob();
|
||||||
|
if (this._sourceBlob && this._sourceBlob.type && !b.type) {
|
||||||
|
return new Blob([b], { type: this._sourceBlob.type });
|
||||||
|
}
|
||||||
|
if (this._sourceBlob && this._sourceBlob.type && b.type !== this._sourceBlob.type) {
|
||||||
|
return new Blob([b], { type: this._sourceBlob.type });
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../stores/uiStore', () => ({
|
||||||
|
useUIStore: {
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
addToast: vi.fn(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { saveImage, copyImageToClipboard } from './imageActions';
|
||||||
|
import { useUIStore } from '../stores/uiStore';
|
||||||
|
|
||||||
|
describe('saveImage', () => {
|
||||||
|
let createElementSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let mockAnchor: { href: string; download: string; click: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAnchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
||||||
|
createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any);
|
||||||
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches image as blob and triggers download for same-origin URLs', async () => {
|
||||||
|
const mockBlob = new Blob(['img'], { type: 'image/png' });
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockBlob));
|
||||||
|
|
||||||
|
await saveImage('/api/uploads/abc123_photo.png');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/uploads/abc123_photo.png');
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob);
|
||||||
|
expect(mockAnchor.download).toBe('abc123_photo.png');
|
||||||
|
expect(mockAnchor.click).toHaveBeenCalled();
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided filename when given', async () => {
|
||||||
|
const mockBlob = new Blob(['img'], { type: 'image/png' });
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockBlob));
|
||||||
|
|
||||||
|
await saveImage('/api/uploads/abc123.png', 'my-photo.png');
|
||||||
|
|
||||||
|
expect(mockAnchor.download).toBe('my-photo.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to window.open on CORS/fetch error and shows toast', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
const mockAddToast = vi.fn();
|
||||||
|
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any);
|
||||||
|
|
||||||
|
await saveImage('https://media.tenor.com/abc/tenor.gif');
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('https://media.tenor.com/abc/tenor.gif', '_blank');
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith('Opened in new tab', 'info', 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copyImageToClipboard', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches image and writes PNG blob to clipboard', async () => {
|
||||||
|
const pngBlob = new Blob(['img'], { type: 'image/png' });
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(pngBlob));
|
||||||
|
|
||||||
|
const mockWrite = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { write: mockWrite, writeText: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await copyImageToClipboard('/api/uploads/photo.png');
|
||||||
|
|
||||||
|
expect(mockWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const clipboardItem = mockWrite.mock.calls[0][0][0];
|
||||||
|
expect(clipboardItem).toBeInstanceOf(ClipboardItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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://external.com/image.jpg');
|
||||||
|
|
||||||
|
expect(mockWriteText).toHaveBeenCalledWith('https://external.com/image.jpg');
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith('Copied image link', 'info', 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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');
|
||||||
|
useUIStore.getState().addToast('Opened in new tab', 'info', 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies an image to the clipboard as PNG.
|
||||||
|
* Falls back to copying the URL as text if CORS or clipboard API blocks it.
|
||||||
|
*/
|
||||||
|
export async function copyImageToClipboard(url: string): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ 'image/png': pngBlob }),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
useUIStore.getState().addToast('Copied image link', 'info', 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user