fix(ai-toolbar): ignore editable targets during global paste fallback

This commit is contained in:
2026-04-22 23:19:18 -04:00
parent 8cc868c22a
commit 46f7aff815
2 changed files with 132 additions and 6 deletions

View File

@@ -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();