Ignore history shortcuts while editing

This commit is contained in:
lucaronin
2026-04-22 21:06:27 +02:00
parent 633d9f1496
commit 0ffb7c65a9
4 changed files with 89 additions and 1 deletions

View File

@@ -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()
})
})

View File

@@ -25,6 +25,8 @@ export type AppCommandDispatchSource =
| 'native-menu'
| 'app-event'
type SuppressedShortcutSource = Extract<AppCommandDispatchSource, 'renderer-keyboard'>
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<string | null>,
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
}

View File

@@ -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) {

View File

@@ -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<string | null>,
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))