'use client'; import { useEffect, useCallback, useRef, type RefObject } from 'react'; import { logger } from '../utils/logger'; export interface ClipboardPasteOptions { /** Whether paste is enabled */ enabled?: boolean; /** Accept only these MIME type prefixes, e.g. ['image'] */ acceptTypes?: string[]; /** Max file size in bytes (optional) */ maxBytes?: number; /** Called with resolved File objects from clipboard */ onFiles: (files: File[]) => void; /** Called when paste happened but no suitable content found */ onNoMatch?: () => void; } /** * Resolves clipboard data into File objects. * * Handles all real-world cases: * 1. Native file(s) copied from OS (e.g. Finder, Explorer) * 2. Screenshots (PrintScreen / Cmd+Shift+4) — come as image/png blob * 3. Images copied from browser ("Copy Image") — image/* blob * 4. Base64 data: URI in clipboard text (data:image/png;base64,…) * 5. Remote image URL in clipboard text (https://…/img.jpg) * 6. HTML with — extracts first src and treats as case 4/5 */ async function resolveClipboardFiles( e: ClipboardEvent, acceptTypes: string[], maxBytes: number | undefined, ): Promise { const cd = e.clipboardData; if (!cd) return []; // ── 1 & 2 & 3: native files / blobs ───────────────────────────────────── const nativeFiles: File[] = []; for (const item of Array.from(cd.items)) { if (item.kind === 'file') { const file = item.getAsFile(); if (!file) continue; if (!matchesAccept(file.type, acceptTypes)) continue; if (maxBytes && file.size > maxBytes) { logger.warn(`Clipboard file "${file.name}" exceeds size limit`); continue; } nativeFiles.push(file); } } if (nativeFiles.length > 0) return nativeFiles; // ── 4 & 5 & 6: text-based payloads ────────────────────────────────────── const text = cd.getData('text/plain').trim(); const html = cd.getData('text/html').trim(); // 6: extract src from HTML const imgSrc = extractImgSrc(html); const source = imgSrc || text; if (!source) return []; // 4: base64 data URI if (source.startsWith('data:')) { const file = dataUriToFile(source); if (!file) return []; if (!matchesAccept(file.type, acceptTypes)) return []; if (maxBytes && file.size > maxBytes) { logger.warn('Pasted base64 image exceeds size limit'); return []; } return [file]; } // 5: remote URL if (/^https?:\/\//i.test(source)) { const mimeHint = guessMimeFromUrl(source); if (!matchesAccept(mimeHint, acceptTypes)) return []; try { const file = await fetchUrlAsFile(source); if (!file) return []; if (!matchesAccept(file.type, acceptTypes)) return []; if (maxBytes && file.size > maxBytes) { logger.warn('Pasted URL image exceeds size limit'); return []; } return [file]; } catch (err) { logger.warn('Failed to fetch pasted URL', { error: err instanceof Error ? err.message : String(err), }); return []; } } return []; } // ── helpers ────────────────────────────────────────────────────────────────── function matchesAccept(mimeType: string, acceptTypes: string[]): boolean { if (!acceptTypes.length) return true; return acceptTypes.some((accept) => { if (accept.endsWith('/*')) return mimeType.startsWith(accept.slice(0, -1)); return mimeType === accept || mimeType.startsWith(accept + '/'); }); } function extractImgSrc(html: string): string | null { if (!html) return null; const match = html.match(/]+src=["']([^"']+)["']/i); return match?.[1] ?? null; } function dataUriToFile(dataUri: string): File | null { try { const [header, base64] = dataUri.split(','); const mimeMatch = header.match(/data:([^;]+)/); if (!mimeMatch) return null; const mime = mimeMatch[1]; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const ext = mime.split('/')[1]?.split('+')[0] ?? 'bin'; return new File([bytes], `paste.${ext}`, { type: mime }); } catch { return null; } } function guessMimeFromUrl(url: string): string { const ext = url.split('?')[0].split('.').pop()?.toLowerCase() ?? ''; const map: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', pdf: 'application/pdf', }; return map[ext] ?? 'application/octet-stream'; } async function fetchUrlAsFile(url: string): Promise { const resp = await fetch(url); if (!resp.ok) return null; const blob = await resp.blob(); const ext = url.split('?')[0].split('.').pop()?.toLowerCase() ?? 'bin'; const name = url.split('/').pop()?.split('?')[0] ?? `paste.${ext}`; return new File([blob], name, { type: blob.type || guessMimeFromUrl(url) }); } // ── hook ───────────────────────────────────────────────────────────────────── /** * Listens for paste events (Ctrl+V / Cmd+V) on the given element or * globally on `document` and resolves clipboard content into File objects. * * Supports: native files, screenshots, copied images, base64 URIs, remote URLs. */ export function useClipboardPaste( options: ClipboardPasteOptions, elementRef?: RefObject, ) { const { enabled = true, acceptTypes = ['image'], maxBytes, onFiles, onNoMatch, } = options; // Keep stable refs so we don't re-bind the listener on every render const onFilesRef = useRef(onFiles); const onNoMatchRef = useRef(onNoMatch); onFilesRef.current = onFiles; onNoMatchRef.current = onNoMatch; const handlePaste = useCallback( async (e: Event) => { const clipEvent = e as ClipboardEvent; const target = e.target as HTMLElement; const isTextTarget = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable; // If pasting into a text field/editor, only proceed if clipboard has a // file/blob but NO meaningful text — so we don't hijack mixed content // (e.g. Word copy which has both image and text in clipboard) if (isTextTarget) { const cd = clipEvent.clipboardData; if (!cd) return; const items = Array.from(cd.items); const hasFile = items.some((i) => i.kind === 'file'); const hasText = cd.getData('text/plain').trim().length > 0; // Only intercept if there's a file and no accompanying text if (!hasFile || hasText) return; } const files = await resolveClipboardFiles(clipEvent, acceptTypes, maxBytes); if (files.length > 0) { e.preventDefault(); onFilesRef.current(files); } else { onNoMatchRef.current?.(); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [acceptTypes.join(','), maxBytes], ); useEffect(() => { if (!enabled) return; const target: EventTarget = elementRef?.current ?? document; target.addEventListener('paste', handlePaste); return () => target.removeEventListener('paste', handlePaste); }, [enabled, elementRef, handlePaste]); }