From 6bd2100e04ef631c39a1bd9d8e65c698b6c54cab Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Mar 2026 03:09:51 +0100 Subject: [PATCH] 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. --- packages/web/src/test/setup.ts | 42 ++++++++ packages/web/src/utils/imageActions.test.ts | 100 ++++++++++++++++++++ packages/web/src/utils/imageActions.ts | 82 ++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 packages/web/src/utils/imageActions.test.ts create mode 100644 packages/web/src/utils/imageActions.ts diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts index bb02c60c..509fa785 100644 --- a/packages/web/src/test/setup.ts +++ b/packages/web/src/test/setup.ts @@ -1 +1,43 @@ 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>; + constructor(items: Record>) { + this.items = items; + } + getType(type: string): Promise { + 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 { + 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; + } +}; diff --git a/packages/web/src/utils/imageActions.test.ts b/packages/web/src/utils/imageActions.test.ts new file mode 100644 index 00000000..505accd5 --- /dev/null +++ b/packages/web/src/utils/imageActions.test.ts @@ -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; + let mockAnchor: { href: string; download: string; click: ReturnType; remove: ReturnType }; + + 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); + }); +}); diff --git a/packages/web/src/utils/imageActions.ts b/packages/web/src/utils/imageActions.ts new file mode 100644 index 00000000..833e0fd0 --- /dev/null +++ b/packages/web/src/utils/imageActions.ts @@ -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 { + 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 { + 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 { + 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; + }); +}