diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 18c1b4c53c5a9..fc96aa1f85896 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -15,15 +15,20 @@ import { matchesKeybindingShortcutModifiers, normalizeEventKey, resolveEventKeys, - type ShortcutEventLike, + type ShortcutEventLike as SharedShortcutEventLike, type ShortcutModifierStateLike, } from "@t3tools/shared/keybindings"; import { isElectron } from "./env"; +import { isEditableFocused } from "./lib/editableFocus"; import { projectScriptIdFromCommand } from "./projectScripts"; -export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; +export type { ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings"; +export interface ShortcutEventLike extends SharedShortcutEventLike { + target?: EventTarget | null; +} + export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; @@ -177,6 +182,9 @@ export function resolveShortcutCommand( ): KeybindingCommand | null { const platform = resolvePlatform(options); const context = resolveContext(options); + if (options?.context?.editableFocus === undefined && typeof document !== "undefined") { + context.editableFocus = isEditableFocused(event.target ?? document.activeElement); + } for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; diff --git a/apps/web/src/keybindingsFocus.test.ts b/apps/web/src/keybindingsFocus.test.ts new file mode 100644 index 0000000000000..34be53f1709e9 --- /dev/null +++ b/apps/web/src/keybindingsFocus.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { KeybindingCommand } from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { resolveShortcutCommand } from "./keybindings"; +import * as editableFocus from "./lib/editableFocus"; + +function setupFocus() { + const editor = new EventTarget(); + const other = new EventTarget(); + vi.stubGlobal("document", { activeElement: editor }); + const focus = vi + .spyOn(editableFocus, "isEditableFocused") + .mockImplementation((target) => target === editor); + return { + editor, + other, + [Symbol.dispose]() { + focus.mockRestore(); + vi.unstubAllGlobals(); + }, + }; +} + +function keydown(target?: EventTarget) { + return { + key: "k", + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + ...(target ? { target } : {}), + }; +} + +function bindings(command: KeybindingCommand, when = "!editableFocus") { + return compileResolvedKeybindingsConfig([{ key: "mod+k", command, when }]); +} + +describe("editable shortcut conditions", () => { + it.each(["commandPalette.toggle", "sidebar.toggle", "thread.next", "thread.jump.1"] as const)( + "honors !editableFocus for %s without a caller-provided focus context", + (command) => { + using focus = setupFocus(); + const rules = bindings(command); + + expect( + resolveShortcutCommand(keydown(focus.editor), rules, { + platform: "Linux", + context: { terminalFocus: false }, + }), + ).toBeNull(); + expect(resolveShortcutCommand(keydown(focus.other), rules, { platform: "Linux" })).toBe( + command, + ); + }, + ); + + it("uses the active element when a forwarded shortcut has no target", () => { + using _focus = setupFocus(); + expect( + resolveShortcutCommand(keydown(), bindings("commandPalette.toggle"), { platform: "Linux" }), + ).toBeNull(); + }); + + it("honors a positive editableFocus condition", () => { + using focus = setupFocus(); + const rules = bindings("commandPalette.toggle", "editableFocus"); + expect(resolveShortcutCommand(keydown(focus.editor), rules, { platform: "Linux" })).toBe( + "commandPalette.toggle", + ); + expect(resolveShortcutCommand(keydown(focus.other), rules, { platform: "Linux" })).toBeNull(); + }); + + it("preserves explicit focus overrides", () => { + using focus = setupFocus(); + const rules = bindings("commandPalette.toggle"); + + expect( + resolveShortcutCommand(keydown(focus.editor), rules, { + platform: "Linux", + context: { editableFocus: false }, + }), + ).toBe("commandPalette.toggle"); + expect( + resolveShortcutCommand(keydown(focus.other), rules, { + platform: "Linux", + context: { editableFocus: true }, + }), + ).toBeNull(); + }); + + it("keeps shortcuts without an editable-focus condition active while editing", () => { + using focus = setupFocus(); + expect( + resolveShortcutCommand(keydown(focus.editor), bindings("commandPalette.toggle", "true"), { + platform: "Linux", + }), + ).toBe("commandPalette.toggle"); + }); +});