From b4e286d9774f424a60c3a4d7d5bd33151c55a38e Mon Sep 17 00:00:00 2001 From: Bob Nisco Date: Mon, 8 Dec 2025 15:20:38 -0800 Subject: [PATCH] [fix] Ensure key down targeting works for elements in Shadow DOM --- frontend/app/src/App.tsx | 12 +- .../components/StatusWidget/StatusWidget.tsx | 12 +- .../src/hooks/useRegisterShortcut.test.tsx | 125 ++++++++++++++++++ frontend/lib/src/hooks/useRegisterShortcut.ts | 92 ++++++++++--- frontend/lib/src/index.ts | 5 +- 5 files changed, 224 insertions(+), 22 deletions(-) diff --git a/frontend/app/src/App.tsx b/frontend/app/src/App.tsx index 621d1b37544..bb44eef0f2e 100644 --- a/frontend/app/src/App.tsx +++ b/frontend/app/src/App.tsx @@ -90,6 +90,7 @@ import { IMenuItem, isEmbed, isInChildFrame, + isKeyboardEventFromEditableTarget, isPaddingDisplayed, isPresetTheme, isScrollingHidden, @@ -2186,7 +2187,16 @@ export class App extends PureComponent { this.deferredFileListeners.delete(response.fileId) } - handleKeyDown = (keyName: string): void => { + handleKeyDown = (keyName: string, keyboardEvent?: KeyboardEvent): void => { + // See `isKeyboardEventFromEditableTarget` for editable/shadow DOM behavior. + // We never fire global single-letter shortcuts while the user is typing. + if ( + (keyName === "c" || keyName === "r") && + isKeyboardEventFromEditableTarget(keyboardEvent) + ) { + return + } + switch (keyName) { case "c": // CLEAR CACHE diff --git a/frontend/app/src/components/StatusWidget/StatusWidget.tsx b/frontend/app/src/components/StatusWidget/StatusWidget.tsx index f17c7f8fdf8..2956f2477d7 100644 --- a/frontend/app/src/components/StatusWidget/StatusWidget.tsx +++ b/frontend/app/src/components/StatusWidget/StatusWidget.tsx @@ -31,6 +31,7 @@ import { BaseButtonKind, DynamicIcon, Icon, + isKeyboardEventFromEditableTarget, Placement, ScriptRunState, Timer, @@ -127,9 +128,14 @@ const StatusWidget: React.FC = ({ } } - const handleKeyDown = (keyName: string): void => { - // NOTE: 'r' is handled at the App Level - if (keyName === "a") { + const handleKeyDown = ( + keyName: string, + keyboardEvent?: KeyboardEvent + ): void => { + // NOTE: 'r' and 'c' are handled at the App level. + // See `isKeyboardEventFromEditableTarget` for editable/shadow DOM behavior; + // we suppress the "Always rerun" hotkey while the user is typing. + if (keyName === "a" && !isKeyboardEventFromEditableTarget(keyboardEvent)) { handleAlwaysRerunClick() } } diff --git a/frontend/lib/src/hooks/useRegisterShortcut.test.tsx b/frontend/lib/src/hooks/useRegisterShortcut.test.tsx index bc8ca359c52..8223cfd7ac5 100644 --- a/frontend/lib/src/hooks/useRegisterShortcut.test.tsx +++ b/frontend/lib/src/hooks/useRegisterShortcut.test.tsx @@ -27,7 +27,9 @@ import { render } from "~lib/test_util" import * as Utils from "~lib/util/utils" import { + ensureHotkeysFilterConfigured, formatShortcutForDisplay, + isKeyboardEventFromEditableTarget, useRegisterShortcut, } from "./useRegisterShortcut" @@ -114,6 +116,68 @@ const createKeyboardEvent = ( ...overrides, }) as unknown as KeyboardEvent +describe("isKeyboardEventFromEditableTarget", () => { + it("returns false when no event is passed", () => { + expect(isKeyboardEventFromEditableTarget()).toBe(false) + }) + + it("returns false for events targeting non-editable elements", () => { + const div = document.createElement("div") + const event = createKeyboardEvent({ target: div }) + + expect(isKeyboardEventFromEditableTarget(event)).toBe(false) + }) + + it.each(["input", "textarea", "select"])( + "returns true for events targeting %s elements", + tagName => { + const element = document.createElement(tagName) + const event = createKeyboardEvent({ target: element }) + + expect(isKeyboardEventFromEditableTarget(event)).toBe(true) + } + ) + + it("returns true for events targeting contentEditable elements", () => { + const div = document.createElement("div") + div.setAttribute("contenteditable", "true") + + const event = createKeyboardEvent({ target: div }) + + expect(isKeyboardEventFromEditableTarget(event)).toBe(true) + }) + + it("returns true when the editable element appears in the composed path (shadow DOM)", () => { + const hostDiv = document.createElement("div") + const input = document.createElement("input") + + const event = createKeyboardEvent({ + target: hostDiv, + }) as KeyboardEvent & { composedPath?: () => EventTarget[] } + + event.composedPath = () => [input, hostDiv, document.body] + + expect(isKeyboardEventFromEditableTarget(event)).toBe(true) + }) + + it("returns false when composedPath is empty or undefined and target is not editable", () => { + const hostDiv = document.createElement("div") + + const eventWithEmptyPath = createKeyboardEvent({ + target: hostDiv, + }) as KeyboardEvent & { composedPath?: () => EventTarget[] } + eventWithEmptyPath.composedPath = () => [] + + expect(isKeyboardEventFromEditableTarget(eventWithEmptyPath)).toBe(false) + + const eventWithoutPath = createKeyboardEvent({ + target: hostDiv, + }) + + expect(isKeyboardEventFromEditableTarget(eventWithoutPath)).toBe(false) + }) +}) + describe("useRegisterShortcut", () => { afterEach(() => { vi.clearAllMocks() @@ -181,6 +245,67 @@ describe("useRegisterShortcut", () => { expect(onActivate).not.toHaveBeenCalled() }) + it("configures hotkeys filter to ignore unmodified shortcuts in editable elements, including shadow DOM", () => { + ensureHotkeysFilterConfigured() + + const hotkeys = hotkeysModule.default as typeof hotkeysModule.default & { + filter: (event: KeyboardEvent) => boolean + } + + const hostDiv = document.createElement("div") + const input = document.createElement("input") + + const baseEvent = createKeyboardEvent({ + key: "c", + target: hostDiv, + }) as KeyboardEvent & { composedPath?: () => EventTarget[] } + + baseEvent.composedPath = () => [input, hostDiv, document.body] + + // Unmodified character key inside an input should be blocked. + expect(hotkeys.filter(baseEvent)).toBe(false) + + // With a system modifier, the shortcut should be allowed. + const modifiedEvent = createKeyboardEvent({ + key: "c", + target: hostDiv, + ctrlKey: true, + }) as KeyboardEvent & { composedPath?: () => EventTarget[] } + modifiedEvent.composedPath = () => [input, hostDiv, document.body] + + expect(hotkeys.filter(modifiedEvent)).toBe(true) + }) + + it("prevents activation when typing inside shadow DOM inputs without modifiers", () => { + const shortcut = "n" + const onActivate = vi.fn() + + render( + + ) + + const hostDiv = document.createElement("div") + const input = document.createElement("input") + const handler = hotkeysWithHandlers.__handlers.get("n") + expect(handler).toBeDefined() + + const event = createKeyboardEvent({ + target: hostDiv, + }) as unknown as KeyboardEvent & { composedPath: () => EventTarget[] } + + event.composedPath = () => [input, hostDiv, document.body] + + act(() => { + handler?.(event, {}) + }) + + expect(onActivate).not.toHaveBeenCalled() + }) + it("prevents navigation shortcuts from firing in text inputs without modifiers", () => { const shortcut = "left" const onActivate = vi.fn() diff --git a/frontend/lib/src/hooks/useRegisterShortcut.ts b/frontend/lib/src/hooks/useRegisterShortcut.ts index 1e77866c320..3f35b85dfc0 100644 --- a/frontend/lib/src/hooks/useRegisterShortcut.ts +++ b/frontend/lib/src/hooks/useRegisterShortcut.ts @@ -81,6 +81,75 @@ interface UseRegisterShortcutOptions { let filterConfigured = false +/** + * Find the first editable element (input, textarea, select, or contentEditable) + * associated with a keyboard event. + * + * This must handle events that originate inside shadow DOM trees: in that case, + * listeners attached outside the shadow root see a retargeted event whose + * `target` is the shadow host (e.g. a wrapping `div`), not the actual `` + * element. To correctly detect editable contexts, we inspect the event's + * composed path where available and fall back to `event.target` for older + * browsers that do not support composed paths. + */ +function getEditableEventTarget(event: KeyboardEvent): HTMLElement | null { + // Prefer the composed path because it exposes the *actual* event target + // inside Shadow DOM trees instead of the retargeted shadow host. + const composedPath = event.composedPath?.() + if (composedPath?.length) { + for (const candidate of composedPath) { + const element = candidate as HTMLElement | null + if ( + element?.tagName && + (EDITABLE_TAGS.has(element.tagName) || + // Some environments (e.g. jsdom) expose contentEditable primarily via + // the attribute, so we explicitly check both the property and the + // attribute to reliably detect editable hosts. + element.isContentEditable || + element.getAttribute("contenteditable") === "true") + ) { + return element + } + } + + // If a composed path exists but none of its entries are editable, we treat + // the event as non-editable even if the top-level target is a non-input + // container (e.g. the shadow host). + return null + } + + // Fallback for environments without composedPath: inspect the (possibly + // retargeted) event target. + const target = (event.target || event.srcElement) as HTMLElement | null + if ( + target?.tagName && + (EDITABLE_TAGS.has(target.tagName) || + target.isContentEditable || + target.getAttribute("contenteditable") === "true") + ) { + return target + } + + return null +} + +/** + * Public helper to determine whether a keyboard event originated from an + * editable context (e.g. text input, textarea, contentEditable), including when + * the actual input lives inside a Shadow DOM tree. + * + * This is used by both widget-level and app-level shortcut handlers to ensure + * we never fire single-key shortcuts while the user is typing. + */ +export function isKeyboardEventFromEditableTarget( + event?: KeyboardEvent +): boolean { + if (!event) { + return false + } + return Boolean(getEditableEventTarget(event)) +} + /** * Ensure the hotkeys filter is configured. */ @@ -93,16 +162,11 @@ export function ensureHotkeysFilterConfigured(): void { } hotkeys.filter = event => { - const target = (event.target || event.srcElement) as HTMLElement | null - if (!target) { - return true - } + const editableTarget = getEditableEventTarget(event) - const tagName = target.tagName - const isEditable = - EDITABLE_TAGS.has(tagName) || Boolean(target.isContentEditable) - - if (!isEditable) { + // If the key event did not originate from an editable context, always + // allow shortcuts to fire. + if (!editableTarget) { return true } @@ -174,14 +238,8 @@ function shouldBlockShortcutInInput( parsedShortcut: ShortcutTokens, event: KeyboardEvent ): boolean { - const target = (event.target || event.srcElement) as HTMLElement | null - if (!target) { - return false - } - - const isEditable = - EDITABLE_TAGS.has(target.tagName) || Boolean(target.isContentEditable) - if (!isEditable) { + const editableTarget = getEditableEventTarget(event) + if (!editableTarget) { return false } diff --git a/frontend/lib/src/index.ts b/frontend/lib/src/index.ts index cf1e837e9b9..0925679fc71 100644 --- a/frontend/lib/src/index.ts +++ b/frontend/lib/src/index.ts @@ -83,7 +83,10 @@ export { useCopyToClipboard } from "./hooks/useCopyToClipboard" export { useCrossOriginAttribute } from "./hooks/useCrossOriginAttribute" export { useEmotionTheme } from "./hooks/useEmotionTheme" export { useExecuteWhenChanged } from "./hooks/useExecuteWhenChanged" -export { ensureHotkeysFilterConfigured } from "./hooks/useRegisterShortcut" +export { + ensureHotkeysFilterConfigured, + isKeyboardEventFromEditableTarget, +} from "./hooks/useRegisterShortcut" export { useRequiredContext } from "./hooks/useRequiredContext" export { measureScrollbarGutterSize,