diff --git a/src/islands/dev/ClipboardInspector.tsx b/src/islands/dev/ClipboardInspector.tsx index 41c7b14..e776701 100644 --- a/src/islands/dev/ClipboardInspector.tsx +++ b/src/islands/dev/ClipboardInspector.tsx @@ -3,7 +3,7 @@ import { ClipboardPaste, Download, Trash2, RefreshCw, FileVideo, FileAudio, File import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { - readClipboard, parseDataTransfer, previewKindOf, formatSize, mimeToExtension, + readClipboard, parseDataTransfer, previewKindOf, formatSize, mimeToExtension, entryToBlob, type ClipboardSnapshot, type ClipboardItemEntry, type PreviewKind, } from '@/tools/dev/clipboard.lib'; import { downloadService } from '@/services/download.service'; @@ -54,19 +54,13 @@ function TypeBadge({ kind, type }: { kind: PreviewKind; type: string }) { function ItemPreview({ item }: { item: ClipboardItemEntry }) { const [showHtml, setShowHtml] = useState<'source' | 'render'>('render'); - function handleDownload() { + async function handleDownload() { const ext = item.filename ? item.filename.split('.').pop() ?? mimeToExtension(item.type) : mimeToExtension(item.type); const name = item.filename ?? `clipboard.${ext}`; - if (item.blobUrl) { - downloadService.download(item.blobUrl, name); - } else if (item.text != null) { - const blob = new Blob([item.text], { type: item.type }); - const url = URL.createObjectURL(blob); - downloadService.download(url, name); - setTimeout(() => URL.revokeObjectURL(url), 5000); - } + const blob = await entryToBlob(item); + if (blob) downloadService.download(blob, name); } return ( diff --git a/src/tools/dev/clipboard.lib.test.ts b/src/tools/dev/clipboard.lib.test.ts index edc4cd2..9ca3a2e 100644 --- a/src/tools/dev/clipboard.lib.test.ts +++ b/src/tools/dev/clipboard.lib.test.ts @@ -1,8 +1,30 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { - previewKindOf, formatSize, mimeToExtension, parseDataTransfer, + previewKindOf, formatSize, mimeToExtension, parseDataTransfer, entryToBlob, } from './clipboard.lib'; +// ─── entryToBlob (the download source — regression for the 66-byte PNG bug) ───── + +describe('entryToBlob', () => { + it('wraps a text entry in a blob of the right type', async () => { + const blob = await entryToBlob({ type: 'text/plain', kind: 'text', text: 'hello', size: 5 }); + expect(blob).toBeInstanceOf(Blob); + expect(await blob!.text()).toBe('hello'); + expect(blob!.type).toBe('text/plain'); + }); + it('fetches the real blob for a binary entry (never the URL string)', async () => { + const png = new Blob([new Uint8Array([137, 80, 78, 71])], { type: 'image/png' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ blob: async () => png } as Response); + const blob = await entryToBlob({ type: 'image/png', kind: 'image', blobUrl: 'blob:mock', size: 4 }); + expect(fetchSpy).toHaveBeenCalledWith('blob:mock'); + expect(blob).toBe(png); + fetchSpy.mockRestore(); + }); + it('returns null when there is nothing to download', async () => { + expect(await entryToBlob({ type: 'text/plain', kind: 'text', size: 0 })).toBeNull(); + }); +}); + // ─── previewKindOf ──────────────────────────────────────────────────────────── describe('previewKindOf', () => { diff --git a/src/tools/dev/clipboard.lib.ts b/src/tools/dev/clipboard.lib.ts index 77dc71d..e8ff8c4 100644 --- a/src/tools/dev/clipboard.lib.ts +++ b/src/tools/dev/clipboard.lib.ts @@ -29,6 +29,18 @@ export interface ClipboardSnapshot { items: ClipboardItemEntry[]; } +/** + * Resolve the actual downloadable Blob for an entry. Binary entries only keep a + * `blobUrl` (an object-URL string), so we fetch the bytes back — passing the URL + * string straight to a download helper writes the ~60-char URL to disk instead of + * the file (the "saved PNG is 66 bytes / invalid" bug). + */ +export async function entryToBlob(item: ClipboardItemEntry): Promise { + if (item.blobUrl) return fetch(item.blobUrl).then(r => r.blob()).catch(() => null); + if (item.text != null) return new Blob([item.text], { type: item.type }); + return null; +} + // ─── Utilities ──────────────────────────────────────────────────────────────── export function previewKindOf(mimeType: string): PreviewKind {