diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 0e29db4f2dd77..dafc47a2b6e86 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -19,6 +19,7 @@ import { act, fakeTimers, render } from "../../test/render"; import { flatToMarkdown, pmToFlat, serializeEditorDoc } from "~/composer-rich-text-doc"; import { isMacPlatform } from "~/lib/utils"; +import { registerCommands } from "~/shortcuts/dispatcher"; import { ComposerPromptEditorTiptap as ComposerPromptEditor, type ComposerPromptEditorHandle, @@ -36,11 +37,13 @@ function Composer({ richTextEnabled, skills, onChange, + onCommandKeyDown = () => false, }: { value: string; richTextEnabled: boolean; skills: ReadonlyArray; onChange?: ComposerPromptEditorProps["onChange"] | undefined; + onCommandKeyDown?: ComposerPromptEditorProps["onCommandKeyDown"] | undefined; }) { const [prompt, setPrompt] = useState({ value, cursor: value.length }); return ( @@ -58,6 +61,7 @@ function Composer({ onChange?.(...args); }} onPaste={() => {}} + onCommandKeyDown={onCommandKeyDown} /> ); } @@ -67,9 +71,16 @@ function composer( richTextEnabled = true, skills: ReadonlyArray = [], onChange?: ComposerPromptEditorProps["onChange"], + onCommandKeyDown?: ComposerPromptEditorProps["onCommandKeyDown"], ) { return ( - + ); } @@ -254,6 +265,55 @@ it.each([ }, ); +it.each([ + ["e", false, false], + ["i", false, false], + ["e", true, false], + ["e", false, true], +])( + "leaves Mod+%s to an app command bound to it instead of formatting (composer command: %s, declined capture command: %s)", + async (key, composerClaimsKey, declinedCapture) => { + const openUsage = vi.fn((event: KeyboardEvent) => { + if (declinedCapture) return false; + event.preventDefault(); + }); + using _commands = { + [Symbol.dispose]: registerCommands( + { "usage.open": openUsage }, + { + keybindings: () => [ + { + command: "usage.open", + shortcut: { + key, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ], + capture: declinedCapture, + }, + ), + }; + const composerCommand = vi.fn(() => composerClaimsKey); + await using screen = await mountEditor("word", true, [], undefined, composerCommand); + const { editor } = screen; + await act(async () => { + editor.commands.focus(); + editor.commands.setTextSelection({ from: 1, to: 5 }); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control"; + await userEvent.keyboard(`{${modifier}>}${key}{/${modifier}}`); + expect(composerCommand).toHaveBeenCalled(); + expect(openUsage).toHaveBeenCalledTimes(composerClaimsKey ? 0 : 1); + expect(serializeEditorDoc(editor.state.doc).value).toBe(declinedCapture ? "`word`" : "word"); + }, +); + it.each([false, true])( "composes an umlaut without wrapping the preedit text (rich text: %s)", async (richTextEnabled) => { diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 474b2f1f181c6..d8e80601322b5 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -112,7 +112,9 @@ import { } from "~/composer-undo-grouping"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { isRichTextFormattingShortcut } from "~/keybindings"; import { cn, isMacPlatform } from "~/lib/utils"; +import { hasCommandForKeyDown } from "~/shortcuts/dispatcher"; import { basenameOfPath } from "~/pierre-icons"; import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; import { FileTagChipContent } from "./chat/FileTagChip"; @@ -1107,6 +1109,19 @@ function ComposerPromptEditorTiptapInner( editable: !disabled, editorProps: { attributes: editorAttributes, + handleDOMEvents: { + // Composer commands keep precedence; otherwise true skips the editor's keymaps without + // preventDefault, so a bound app command still runs. + keydown: (_view, event) => { + if (!isRichTextFormattingShortcut(event)) return false; + if (readCallbacks().onCommandKeyDown?.(event.key, event)) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + return hasCommandForKeyDown(event); + }, + }, handleKeyDown: (view, event) => { // In a right-to-left line the start is on the right. const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft"; diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index cf2be46dcbdcc..364adea75fc76 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -398,6 +398,13 @@ export function isRichTextBoldShortcut( ); } +/** Mod+B, Mod+I and Mod+E, the mark chords the rich-text composer binds. */ +export function isRichTextFormattingShortcut(event: ShortcutEventLike): boolean { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return false; + const keys = resolveEventKeys(event); + return keys.has("b") || keys.has("i") || keys.has("e"); +} + export function isTerminalClearShortcut( event: ShortcutEventLike, platform = navigator.platform, diff --git a/apps/web/src/shortcuts/dispatcher.ts b/apps/web/src/shortcuts/dispatcher.ts index 5ce799cf482d5..9e361e825db7f 100644 --- a/apps/web/src/shortcuts/dispatcher.ts +++ b/apps/web/src/shortcuts/dispatcher.ts @@ -57,16 +57,21 @@ function isInsideConfirmation(target: EventTarget | null): boolean { return target instanceof Element && target.closest('[role="alertdialog"]') !== null; } -function runScope(scope: CommandScope, event: KeyboardEvent): boolean { +function scopeCommandHandler(scope: CommandScope, event: KeyboardEvent) { const { commands, options } = scope; - if (options.when && !options.when(event)) return false; - if (options.onKeyDown && options.onKeyDown(event) !== false) return true; const command = resolveShortcutCommand( event, options.keybindings?.() ?? appAtomRegistry.get(primaryServerKeybindingsAtom), { context: { ...readFocusContext(event), ...options.context?.() } }, ); - const handler = command === null ? undefined : commands[command]; + return command === null ? undefined : commands[command]; +} + +function runScope(scope: CommandScope, event: KeyboardEvent): boolean { + const { options } = scope; + if (options.when && !options.when(event)) return false; + if (options.onKeyDown && options.onKeyDown(event) !== false) return true; + const handler = scopeCommandHandler(scope, event); if (handler === undefined) return false; if (isInsideConfirmation(event.target)) { event.preventDefault(); @@ -75,6 +80,18 @@ function runScope(scope: CommandScope, event: KeyboardEvent): boolean { return handler(event) !== false; } +/** + * Whether a bubble-phase command is bound to this keydown, for widgets that would otherwise + * consume it. Capture scopes have already run (and declined) by the time a widget sees it. + */ +export function hasCommandForKeyDown(event: KeyboardEvent): boolean { + return globalScopes.some( + (scope) => + (!scope.options.when || scope.options.when(event)) && + scopeCommandHandler(scope, event) !== undefined, + ); +} + function runScopes(scopes: readonly CommandScope[], event: KeyboardEvent): boolean { for (let index = scopes.length - 1; index >= 0; index -= 1) { if (event.defaultPrevented) return true;