fix(ai-toolbar): ignore editable targets during global paste fallback
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user