From 46f7aff8159b737d55e7bcad2b46f269343f4e87 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Wed, 22 Apr 2026 23:19:18 -0400 Subject: [PATCH] fix(ai-toolbar): ignore editable targets during global paste fallback --- src/components/ai-toolbar.tsx | 17 +++-- tests/ai-toolbar.test.ts | 121 ++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index 3f0ce25..43f7658 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -69,6 +69,15 @@ function ShortcutsList({ os }: { os: Os }) { ); } +function isEditableTarget(target: EventTarget | null): target is HTMLElement { + const element = target as HTMLElement | null; + return !!element && ( + element.tagName === "TEXTAREA" || + element.tagName === "INPUT" || + element.isContentEditable + ); +} + // ─── Types ──────────────────────────────────────────────────────────────────── interface AIToolbarProps { @@ -159,12 +168,7 @@ export const AIToolbar = ({ // ── 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 + if (isEditableTarget(e.target)) return; // textarea's own onPaste covers this const images = extractAllImagesFromClipboard(e.clipboardData ?? null); if (images.length > 0) { @@ -192,6 +196,7 @@ export const AIToolbar = ({ const isV = e.key === "v" || e.key === "V"; const isModifier = e.ctrlKey || e.metaKey; if (!isV || !isModifier || e.shiftKey || e.altKey) return; + if (isEditableTarget(e.target)) return; pasteHandledByEvent = false; diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index e763890..0ea264e 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -102,15 +102,53 @@ const actualReact = React; const documentAddEventListener = mock(); const documentRemoveEventListener = mock(); let lastEffectCleanup: (() => void) | undefined; +const registeredDocumentListeners = new Map< + string, + Array<{ listener: EventListener; options?: AddEventListenerOptions }> +>(); + +const getDocumentListener = ( + type: string, + predicate: (entry: { + listener: EventListener; + options?: AddEventListenerOptions; + }) => boolean = () => true, +) => { + const entry = registeredDocumentListeners.get(type)?.find(predicate); + expect(entry).toBeDefined(); + return entry!.listener; +}; + +const createClipboardData = (files: File[]): DataTransfer => + ({ + files, + items: files.map((file) => ({ + kind: "file", + type: file.type, + getAsFile: () => file, + })), + } as unknown as DataTransfer); beforeEach(() => { documentAddEventListener.mockClear(); documentRemoveEventListener.mockClear(); lastEffectCleanup = undefined; + registeredDocumentListeners.clear(); globalThis.document = { addEventListener: documentAddEventListener, removeEventListener: documentRemoveEventListener, } as Document; + documentAddEventListener.mockImplementation( + ( + type: string, + listener: EventListener, + options?: AddEventListenerOptions, + ) => { + const listeners = registeredDocumentListeners.get(type) ?? []; + listeners.push({ listener, options }); + registeredDocumentListeners.set(type, listeners); + }, + ); mock.module("react", () => ({ ...actualReact, useEffect: (effect: () => void | (() => void)) => { @@ -254,6 +292,89 @@ describe("Event count badge – positioning contract", () => { }); describe("AI capture redesign", () => { + test("document paste forwards clipboard images to onImagesSelect only for non-editable targets", async () => { + const onImagesSelect = mock(); + const image = new File(["image-bytes"], "clipboard.png", { + type: "image/png", + }); + + await renderToolbar({ onImagesSelect }); + + const handleDocumentPaste = getDocumentListener( + "paste", + (entry) => !entry.options?.capture, + ); + const preventDefault = mock(); + + handleDocumentPaste({ + target: { tagName: "DIV", isContentEditable: false }, + clipboardData: createClipboardData([image]), + preventDefault, + } as unknown as Event); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onImagesSelect).toHaveBeenCalledTimes(1); + expect(onImagesSelect).toHaveBeenCalledWith([image]); + }); + + test("Ctrl/Cmd+V fallback forwards clipboard images to onImagesSelect for non-editable targets", async () => { + const onImagesSelect = mock(); + const clipboardRead = mock(async () => [ + { + types: ["image/png"], + getType: async () => new Blob(["image-bytes"], { type: "image/png" }), + }, + ]); + + (globalThis as { navigator?: Navigator }).navigator = { + clipboard: { read: clipboardRead }, + } as Navigator; + + await renderToolbar({ onImagesSelect }); + + const handleDocumentKeydown = getDocumentListener("keydown"); + + await handleDocumentKeydown({ + key: "v", + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + target: { tagName: "DIV", isContentEditable: false }, + } as unknown as Event); + + expect(clipboardRead).toHaveBeenCalledTimes(1); + expect(onImagesSelect).toHaveBeenCalledTimes(1); + expect(onImagesSelect.mock.calls[0]?.[0]).toHaveLength(1); + expect(onImagesSelect.mock.calls[0]?.[0][0]).toBeInstanceOf(File); + expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); + }); + + test("Ctrl/Cmd+V fallback ignores editable targets", async () => { + const onImagesSelect = mock(); + const clipboardRead = mock(async () => []); + + (globalThis as { navigator?: Navigator }).navigator = { + clipboard: { read: clipboardRead }, + } as Navigator; + + await renderToolbar({ onImagesSelect }); + + const handleDocumentKeydown = getDocumentListener("keydown"); + + await handleDocumentKeydown({ + key: "v", + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + target: { tagName: "INPUT", isContentEditable: false }, + } as unknown as Event); + + expect(clipboardRead).not.toHaveBeenCalled(); + expect(onImagesSelect).not.toHaveBeenCalled(); + }); + test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => { await renderToolbar();