diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index e7622f9..9ec0f06 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -40,12 +40,24 @@ function useOs(): Os { return os; } -function createClipboardFallbackFile(blob: Blob, index: number): File { - const createdAt = Date.now() + index; +async function getClipboardBlobIdentity(blob: Blob): Promise { + const bytes = new Uint8Array(await blob.arrayBuffer()); + let hash = 2166136261; + + for (const byte of bytes) { + hash ^= byte; + hash = Math.imul(hash, 16777619); + } + + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +async function createClipboardFallbackFile(blob: Blob): Promise { + const identity = await getClipboardBlobIdentity(blob); const extension = blob.type.split("/")[1] || "png"; - return new File([blob], `clipboard-image-${createdAt}.${extension}`, { + return new File([blob], `clipboard-image-${identity}.${extension}`, { type: blob.type, - lastModified: createdAt, + lastModified: 0, }); } @@ -229,7 +241,7 @@ export const AIToolbar = ({ for (const mimeType of typesToTry) { try { const blob = await clipboardItem.getType(mimeType); - files.push(createClipboardFallbackFile(blob, files.length)); + files.push(await createClipboardFallbackFile(blob)); break; // got this item, move to next clipboardItem } catch { // NotFoundError — type not present, try next @@ -317,6 +329,7 @@ export const AIToolbar = ({ if ( e.key === "Enter" && (e.metaKey || e.ctrlKey) && + !(e.metaKey && e.ctrlKey) && !e.shiftKey && !e.altKey && !aiLoading && diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index f25a282..567555c 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -506,6 +506,24 @@ describe("AI toolbar paste capture", () => { expect(onAiCreate).not.toHaveBeenCalled(); }); + test("Mod+Enter ignores combined Ctrl+Meta modifiers", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + onAiCreate, + }); + const event = createTextareaKeydownEvent({ + ctrlKey: true, + metaKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onAiCreate).not.toHaveBeenCalled(); + }); + test("Mod+Enter does not trigger AI generation while loading", async () => { const onAiCreate = mock(); @@ -683,6 +701,36 @@ describe("AI toolbar paste capture", () => { ); }); + test("clipboard fallback reuses the same synthesized file name for identical clipboard content", async () => { + const onImagesSelect = 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; + + await renderToolbar({ onImagesSelect }); + + const handleDocumentKeydown = getDocumentListener("keydown"); + + await handleDocumentKeydown( + createDocumentKeydownEvent({ ctrlKey: true }), + ); + await handleDocumentKeydown( + createDocumentKeydownEvent({ ctrlKey: true }), + ); + + expect(onImagesSelect).toHaveBeenCalledTimes(2); + expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toBe( + onImagesSelect.mock.calls[1]?.[0][0]?.name, + ); + }); + 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 () => [