feat: add document-level paste and Ctrl/Cmd+V handler for image clipboard support
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ShortcutDefinition {
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
||||
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
||||
{ modifiers: ["mod", "V"], label: "Paste image" },
|
||||
{ modifiers: ["esc"], label: "Clear prompt" },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user