diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index 0f460a9..ce4711f 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -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((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 (
diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 6ddfaea..13a5d63 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -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" }, ];