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 ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AIToolbarProps {
|
interface AIToolbarProps {
|
||||||
@@ -159,12 +168,7 @@ export const AIToolbar = ({
|
|||||||
|
|
||||||
// ── Handler 1: paste event (works when textarea is NOT focused) ───────
|
// ── Handler 1: paste event (works when textarea is NOT focused) ───────
|
||||||
const handleDocumentPaste = (e: ClipboardEvent) => {
|
const handleDocumentPaste = (e: ClipboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
if (isEditableTarget(e.target)) return; // textarea's own onPaste covers this
|
||||||
const isEditableTarget =
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.isContentEditable;
|
|
||||||
if (isEditableTarget) return; // textarea's own onPaste covers this
|
|
||||||
|
|
||||||
const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
|
const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
@@ -192,6 +196,7 @@ export const AIToolbar = ({
|
|||||||
const isV = e.key === "v" || e.key === "V";
|
const isV = e.key === "v" || e.key === "V";
|
||||||
const isModifier = e.ctrlKey || e.metaKey;
|
const isModifier = e.ctrlKey || e.metaKey;
|
||||||
if (!isV || !isModifier || e.shiftKey || e.altKey) return;
|
if (!isV || !isModifier || e.shiftKey || e.altKey) return;
|
||||||
|
if (isEditableTarget(e.target)) return;
|
||||||
|
|
||||||
pasteHandledByEvent = false;
|
pasteHandledByEvent = false;
|
||||||
|
|
||||||
|
|||||||
@@ -102,15 +102,53 @@ const actualReact = React;
|
|||||||
const documentAddEventListener = mock();
|
const documentAddEventListener = mock();
|
||||||
const documentRemoveEventListener = mock();
|
const documentRemoveEventListener = mock();
|
||||||
let lastEffectCleanup: (() => void) | undefined;
|
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(() => {
|
beforeEach(() => {
|
||||||
documentAddEventListener.mockClear();
|
documentAddEventListener.mockClear();
|
||||||
documentRemoveEventListener.mockClear();
|
documentRemoveEventListener.mockClear();
|
||||||
lastEffectCleanup = undefined;
|
lastEffectCleanup = undefined;
|
||||||
|
registeredDocumentListeners.clear();
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
addEventListener: documentAddEventListener,
|
addEventListener: documentAddEventListener,
|
||||||
removeEventListener: documentRemoveEventListener,
|
removeEventListener: documentRemoveEventListener,
|
||||||
} as Document;
|
} 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", () => ({
|
mock.module("react", () => ({
|
||||||
...actualReact,
|
...actualReact,
|
||||||
useEffect: (effect: () => void | (() => void)) => {
|
useEffect: (effect: () => void | (() => void)) => {
|
||||||
@@ -254,6 +292,89 @@ describe("Event count badge – positioning contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AI capture redesign", () => {
|
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 () => {
|
test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => {
|
||||||
await renderToolbar();
|
await renderToolbar();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user