From 0ffb7c65a9625aa04c14dabb17e6da15c2435182 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 22 Apr 2026 21:06:27 +0200 Subject: [PATCH] Ignore history shortcuts while editing --- src/hooks/appCommandDispatcher.test.ts | 10 ++++++++ src/hooks/appCommandDispatcher.ts | 31 +++++++++++++++++++++++ src/hooks/appKeyboardShortcuts.ts | 15 +++++++++++- src/hooks/useAppKeyboard.test.ts | 34 ++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index a57d6ec4..2aa640ce 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -7,6 +7,7 @@ import { findShortcutCommandIdForEvent, isAppCommandId, isNativeMenuCommandId, + recordSuppressedShortcutCommand, resetAppCommandDispatchStateForTests, type AppCommandHandlers, } from './appCommandDispatcher' @@ -280,4 +281,13 @@ describe('appCommandDispatcher', () => { expect(executeAppCommand(APP_COMMAND_IDS.viewToggleAiChat, handlers, 'renderer-keyboard')).toBe(false) expect(handlers.onToggleAIChat).toHaveBeenCalledTimes(1) }) + + it('suppresses a native-menu history echo after renderer keyboard yields to text editing', () => { + const handlers = makeHandlers() + + recordSuppressedShortcutCommand(APP_COMMAND_IDS.viewGoBack) + + expect(executeAppCommand(APP_COMMAND_IDS.viewGoBack, handlers, 'native-menu')).toBe(false) + expect(handlers.onGoBack).not.toHaveBeenCalled() + }) }) diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index 7b1d1827..a71a8ab2 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -25,6 +25,8 @@ export type AppCommandDispatchSource = | 'native-menu' | 'app-event' +type SuppressedShortcutSource = Extract + export interface AppCommandHandlers { onSetViewMode: (mode: ViewMode) => void onCreateNote: () => void @@ -154,6 +156,14 @@ let lastCommandDispatch: } | null = null +let lastSuppressedShortcutCommand: + | { + id: AppCommandId + source: SuppressedShortcutSource + timestamp: number + } + | null = null + function now(): number { return globalThis.performance?.now?.() ?? Date.now() } @@ -175,6 +185,16 @@ function shouldSuppressDuplicateCommand( return currentTimestamp - lastCommandDispatch.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS } +function shouldSuppressShortcutEchoAfterKeyboardYield( + id: AppCommandId, + source: AppCommandDispatchSource, + currentTimestamp: number, +): boolean { + if (source !== 'native-menu') return false + if (!lastSuppressedShortcutCommand || lastSuppressedShortcutCommand.id !== id) return false + return currentTimestamp - lastSuppressedShortcutCommand.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS +} + function dispatchActiveTabCommand( pathRef: MutableRefObject, handler: (path: string) => void, @@ -249,6 +269,9 @@ export function executeAppCommand( source: AppCommandDispatchSource, ): boolean { const timestamp = now() + if (shouldSuppressShortcutEchoAfterKeyboardYield(id, source, timestamp)) { + return false + } if (shouldSuppressDuplicateCommand(id, source, timestamp)) { return false } @@ -260,6 +283,14 @@ export function executeAppCommand( return dispatched } +export function recordSuppressedShortcutCommand( + id: AppCommandId, + source: SuppressedShortcutSource = 'renderer-keyboard', +): void { + lastSuppressedShortcutCommand = { id, source, timestamp: now() } +} + export function resetAppCommandDispatchStateForTests(): void { lastCommandDispatch = null + lastSuppressedShortcutCommand = null } diff --git a/src/hooks/appKeyboardShortcuts.ts b/src/hooks/appKeyboardShortcuts.ts index 6ecce560..4fe9d417 100644 --- a/src/hooks/appKeyboardShortcuts.ts +++ b/src/hooks/appKeyboardShortcuts.ts @@ -3,6 +3,7 @@ import { APP_COMMAND_IDS, executeAppCommand, findShortcutCommandIdForEvent, + recordSuppressedShortcutCommand, type AppCommandHandlers, } from './appCommandDispatcher' @@ -33,6 +34,10 @@ export type KeyboardActions = Pick< > const TEXT_EDITING_KEYS = new Set(['Backspace', 'Delete']) +const TEXT_EDITING_BLOCKED_COMMANDS = new Set([ + APP_COMMAND_IDS.viewGoBack, + APP_COMMAND_IDS.viewGoForward, +]) function isTextInputFocused(): boolean { const active = document.activeElement @@ -44,7 +49,15 @@ function isTextInputFocused(): boolean { export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) { const commandId = findShortcutCommandIdForEvent(event) if (commandId === null) return - if (TEXT_EDITING_KEYS.has(event.key) && isTextInputFocused()) return + + const textInputFocused = isTextInputFocused() + if (textInputFocused) { + if (TEXT_EDITING_KEYS.has(event.key)) return + if (TEXT_EDITING_BLOCKED_COMMANDS.has(commandId)) { + recordSuppressedShortcutCommand(commandId, 'renderer-keyboard') + return + } + } event.preventDefault() if (commandId === APP_COMMAND_IDS.editFindInVault) { diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index 3d832cf6..bcbbdc9c 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -43,6 +43,8 @@ function makeActions() { onZoomIn: vi.fn(), onZoomOut: vi.fn(), onZoomReset: vi.fn(), + onGoBack: vi.fn(), + onGoForward: vi.fn(), activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, multiSelectionCommandRef: { current: null }, } @@ -283,6 +285,38 @@ describe('useAppKeyboard', () => { expect(actions.onDeleteNote).not.toHaveBeenCalled() }) + it('Cmd+Left does not go back when contenteditable is focused', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + withFocusedContentEditable((editable) => { + fireKeyOnTarget(editable, 'ArrowLeft', { metaKey: true, code: 'ArrowLeft' }) + expect(actions.onGoBack).not.toHaveBeenCalled() + }) + }) + + it('Cmd+Right does not go forward when contenteditable is focused', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + withFocusedContentEditable((editable) => { + fireKeyOnTarget(editable, 'ArrowRight', { metaKey: true, code: 'ArrowRight' }) + expect(actions.onGoForward).not.toHaveBeenCalled() + }) + }) + + it('Cmd+Left still goes back when no text input is focused', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + fireKey('ArrowLeft', { metaKey: true, code: 'ArrowLeft' }) + expect(actions.onGoBack).toHaveBeenCalled() + }) + + it('Cmd+Right still goes forward when no text input is focused', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + fireKey('ArrowRight', { metaKey: true, code: 'ArrowRight' }) + expect(actions.onGoForward).toHaveBeenCalled() + }) + it('Cmd+K still works when text input is focused', () => { const actions = makeActions() renderHook(() => useAppKeyboard(actions))