Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion frontend/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
IMenuItem,
isEmbed,
isInChildFrame,
isKeyboardEventFromEditableTarget,
isPaddingDisplayed,
isPresetTheme,
isScrollingHidden,
Expand Down Expand Up @@ -2186,7 +2187,16 @@ export class App extends PureComponent<Props, State> {
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
Expand Down
12 changes: 9 additions & 3 deletions frontend/app/src/components/StatusWidget/StatusWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
BaseButtonKind,
DynamicIcon,
Icon,
isKeyboardEventFromEditableTarget,
Placement,
ScriptRunState,
Timer,
Expand Down Expand Up @@ -127,9 +128,14 @@ const StatusWidget: React.FC<StatusWidgetProps> = ({
}
}

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()
}
}
Expand Down
125 changes: 125 additions & 0 deletions frontend/lib/src/hooks/useRegisterShortcut.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import { render } from "~lib/test_util"
import * as Utils from "~lib/util/utils"

import {
ensureHotkeysFilterConfigured,
formatShortcutForDisplay,
isKeyboardEventFromEditableTarget,
useRegisterShortcut,
} from "./useRegisterShortcut"

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
<TestComponent
shortcut={shortcut}
disabled={false}
onActivate={onActivate}
/>
)

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()
Expand Down
92 changes: 75 additions & 17 deletions frontend/lib/src/hooks/useRegisterShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>`
* 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.
*/
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down
5 changes: 4 additions & 1 deletion frontend/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading