fix(ai-toolbar): use stable clipboard fallback identities
This commit is contained in:
@@ -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<string> {
|
||||
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<File> {
|
||||
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 &&
|
||||
|
||||
@@ -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 () => [
|
||||
|
||||
Reference in New Issue
Block a user