From 502bd6237ac3c9667240f87b217baf738d0f8dbc Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Thu, 23 Apr 2026 05:10:55 -0400 Subject: [PATCH] fix(ai-toolbar): align fallback shortcut and identity guards --- src/components/ai-toolbar.tsx | 1 + tests/ai-toolbar.test.ts | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index 942aa64..5b01bfd 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -222,6 +222,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 (e.ctrlKey && e.metaKey) return; if (isEditableTarget(e.target)) return; pasteHandledByEvent = false; diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index b9516f3..85215a6 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -693,6 +693,26 @@ describe("AI toolbar paste capture", () => { expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); }); + test("Ctrl+Meta+V does not trigger the async clipboard fallback", 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( + createDocumentKeydownEvent({ ctrlKey: true, metaKey: true }), + ); + + expect(clipboardRead).not.toHaveBeenCalled(); + expect(onImagesSelect).not.toHaveBeenCalled(); + }); + test("clipboard fallback creates distinct files for same-sized clipboard images", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [ @@ -797,6 +817,51 @@ describe("AI toolbar paste capture", () => { ); }); + test("synchronous paste and async fallback normalize identical clipboard payloads to the same file identity", async () => { + const syncOnImagesSelect = mock(); + const asyncOnImagesSelect = mock(); + const clipboardRead = mock(async () => [ + { + types: ["image/png"], + getType: async () => new Blob(["same-image"], { type: "image/png" }), + }, + ]); + + (globalThis as { navigator?: Navigator }).navigator = { + clipboard: { read: clipboardRead }, + } as Navigator; + + const { textareaProps } = await renderToolbarBoundary({ + onImagesSelect: syncOnImagesSelect, + }); + await textareaProps.onPaste?.({ + clipboardData: createClipboardData([ + new File(["same-image"], "clipboard.png", { type: "image/png" }), + ]), + preventDefault: mock(), + } as unknown as React.ClipboardEvent); + await new Promise((resolve) => setTimeout(resolve, 0)); + + registeredDocumentListeners.clear(); + documentAddEventListener.mockClear(); + documentRemoveEventListener.mockClear(); + await renderToolbar({ onImagesSelect: asyncOnImagesSelect }); + const handleDocumentKeydown = getDocumentListener("keydown"); + + await handleDocumentKeydown( + createDocumentKeydownEvent({ ctrlKey: true }), + ); + + expect(syncOnImagesSelect).toHaveBeenCalledTimes(1); + expect(asyncOnImagesSelect).toHaveBeenCalledTimes(1); + expect(syncOnImagesSelect.mock.calls[0]?.[0][0]?.name).toBe( + asyncOnImagesSelect.mock.calls[0]?.[0][0]?.name, + ); + expect(syncOnImagesSelect.mock.calls[0]?.[0][0]?.lastModified).toBe( + asyncOnImagesSelect.mock.calls[0]?.[0][0]?.lastModified, + ); + }); + test("async clipboard fallback does not double-handle a paste already handled by the synchronous document paste flow", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [