'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]);
}