feat: add document-level paste and Ctrl/Cmd+V handler for image clipboard support

This commit is contained in:
2026-04-08 19:58:40 -04:00
parent 1c864f162e
commit cac201a4d2
2 changed files with 105 additions and 0 deletions

View File

@@ -137,6 +137,110 @@ export const AIToolbar = ({
// Detect OS after hydration for keyboard shortcut glyphs
const os = useOs();
// Stable ref so the document listener never needs to re-register when
// onImageSelect identity changes between renders (it's an inline async fn).
const onImageSelectRef = useRef(onImageSelect);
useEffect(() => {
onImageSelectRef.current = onImageSelect;
}, [onImageSelect]);
// Document-level paste + Ctrl/Cmd+V keydown handler.
//
// Two-pronged approach because Linux/Chrome does not reliably include image
// data in clipboardData on trusted paste events when no input is focused:
//
// 1. paste event — works when the textarea IS focused (clipboardData has
// the image). The textarea's own onPaste handles that
// case; here we only handle non-editable targets.
//
// 2. keydown Ctrl+V — user gesture that explicitly reads the async
// Clipboard API (navigator.clipboard.read()), which
// always has the full clipboard contents regardless of
// focused element or OS clipboard model (X11/Wayland).
// This is the approach used by Excalidraw's actionPaste.
useEffect(() => {
if (!isAuthenticated || isPending) return;
// ── Handler 1: paste event (works when textarea is NOT focused) ───────
const handleDocumentPaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement;
const isEditableTarget =
target.tagName === "TEXTAREA" ||
target.tagName === "INPUT" ||
target.isContentEditable;
if (isEditableTarget) return; // textarea's own onPaste covers this
const image = extractImageFromClipboard(e.clipboardData ?? null);
if (image) {
e.preventDefault();
onImageSelectRef.current(image);
}
};
// ── Handler 2: keydown Ctrl/Cmd+V → async Clipboard API fallback ─────
// On Linux/Chrome, clipboardData is often empty in paste events when the
// clipboard was set by an external app. navigator.clipboard.read() is
// more reliable when called from a user gesture (keydown).
let pasteHandledByEvent = false;
const PROBE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff"];
const handleDocumentKeydown = async (e: KeyboardEvent) => {
const isV = e.key === "v" || e.key === "V";
const isModifier = e.ctrlKey || e.metaKey;
if (!isV || !isModifier || e.shiftKey || e.altKey) return;
pasteHandledByEvent = false;
// Defer one tick so the synchronous paste event can fire first and
// set pasteHandledByEvent if it already handled an image.
await new Promise<void>((r) => setTimeout(r, 0));
if (pasteHandledByEvent) return;
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
const declaredType = clipboardItem.types.find((t) => t.startsWith("image/"));
const typesToTry = declaredType
? [declaredType, ...PROBE_TYPES.filter((t) => t !== declaredType)]
: PROBE_TYPES;
for (const mimeType of typesToTry) {
try {
const blob = await clipboardItem.getType(mimeType);
const file = new File([blob], "clipboard-image", { type: mimeType });
onImageSelectRef.current(file);
return;
} catch {
// NotFoundError — type not present, try next
}
}
}
} catch {
// clipboard.read() failed (permissions denied, etc.) — ignore
}
};
// Mark that the synchronous paste event handled an image so keydown
// doesn't double-fire
const handlePasteHandled = (e: ClipboardEvent) => {
const image = extractImageFromClipboard(e.clipboardData ?? null);
if (image) pasteHandledByEvent = true;
};
document.addEventListener("paste", handleDocumentPaste);
document.addEventListener("paste", handlePasteHandled, { capture: true });
document.addEventListener("keydown", handleDocumentKeydown);
return () => {
document.removeEventListener("paste", handleDocumentPaste);
document.removeEventListener("paste", handlePasteHandled, {
capture: true,
});
document.removeEventListener("keydown", handleDocumentKeydown);
};
}, [isAuthenticated, isPending]); // onImageSelect intentionally omitted — ref stays current
if (isPending) {
return (
<div className="mb-6 space-y-2">