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
|
// Detect OS after hydration for keyboard shortcut glyphs
|
||||||
const os = useOs();
|
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) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 space-y-2">
|
<div className="mb-6 space-y-2">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ShortcutDefinition {
|
|||||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||||
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
||||||
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
||||||
|
{ modifiers: ["mod", "V"], label: "Paste image" },
|
||||||
{ modifiers: ["esc"], label: "Clear prompt" },
|
{ modifiers: ["esc"], label: "Clear prompt" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user