From e206136156e152191e60c95470c5a48ca8d72af6 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 26 May 2026 21:55:08 -0700 Subject: [PATCH] add physical key mapping for useHotkeys to support alt keyboard languages (#2051) --- .../hotkeys/hotkey-manager-settings.tsx | 23 +++---- src/renderer/hooks/use-hotkeys.ts | 6 +- src/shared/hooks/use-hotkeys.ts | 12 +++- src/shared/utils/hotkeys.ts | 37 ++++++++++ src/shared/utils/keyboard-code-to-hotkey.ts | 68 +++++++++++++++++++ 5 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 src/shared/utils/hotkeys.ts create mode 100644 src/shared/utils/keyboard-code-to-hotkey.ts diff --git a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx index 34736b0d4..176ac08ff 100644 --- a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx +++ b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx @@ -18,6 +18,10 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Icon } from '/@/shared/components/icon/icon'; import { Table } from '/@/shared/components/table/table'; import { TextInput } from '/@/shared/components/text-input/text-input'; +import { + keyboardCodeToHotkeyKey, + MODIFIER_KEY_CODES, +} from '/@/shared/utils/keyboard-code-to-hotkey'; const ipc = isElectron() ? window.api.ipc : null; @@ -112,25 +116,16 @@ export const HotkeyManagerSettings = memo(() => { const debouncedSetHotkey = debounce( (binding: BindingActions, e: KeyboardEvent) => { e.preventDefault(); - const IGNORED_KEYS = ['Control', 'Alt', 'Shift', 'Meta', ' ', 'Escape']; const keys: string[] = []; if (e.ctrlKey) keys.push('mod'); if (e.altKey) keys.push('alt'); if (e.shiftKey) keys.push('shift'); if (e.metaKey) keys.push('meta'); - if (e.key === ' ') keys.push('space'); - if (!IGNORED_KEYS.includes(e.key)) { - if (e.code.includes('Numpad')) { - if (e.key === '+') keys.push('numpadadd'); - else if (e.key === '-') keys.push('numpadsubtract'); - else if (e.key === '*') keys.push('numpadmultiply'); - else if (e.key === '/') keys.push('numpaddivide'); - else if (e.key === '.') keys.push('numpaddecimal'); - else keys.push(`numpad${e.key}`.toLowerCase()); - } else if (e.key === '+') { - keys.push('equal'); - } else { - keys.push(e.key?.toLowerCase()); + + if (!MODIFIER_KEY_CODES.has(e.code) && e.code !== 'Escape') { + const hotkeyKey = keyboardCodeToHotkeyKey(e.code); + if (hotkeyKey) { + keys.push(hotkeyKey); } } diff --git a/src/renderer/hooks/use-hotkeys.ts b/src/renderer/hooks/use-hotkeys.ts index 42bc13274..1dc2899d3 100644 --- a/src/renderer/hooks/use-hotkeys.ts +++ b/src/renderer/hooks/use-hotkeys.ts @@ -2,8 +2,10 @@ import { type HotkeyItem as MantineHotkeyItem, useHotkeys as useMantineHotkeys, } from '@mantine/hooks'; +import { useMemo } from 'react'; import { useAppStore } from '/@/renderer/store'; +import { withPhysicalKeys } from '/@/shared/utils/hotkeys'; const EMPTY_HOTKEYS: MantineHotkeyItem[] = []; @@ -13,8 +15,10 @@ export const useHotkeys = ( triggerOnContentEditable?: boolean, ) => { const commandPaletteOpened = useAppStore((state) => state.commandPalette.opened); + const physicalHotkeys = useMemo(() => withPhysicalKeys(hotkeys), [hotkeys]); + useMantineHotkeys( - commandPaletteOpened ? EMPTY_HOTKEYS : hotkeys, + commandPaletteOpened ? EMPTY_HOTKEYS : physicalHotkeys, tagsToIgnore, triggerOnContentEditable, ); diff --git a/src/shared/hooks/use-hotkeys.ts b/src/shared/hooks/use-hotkeys.ts index b5a1d30fe..625e17473 100644 --- a/src/shared/hooks/use-hotkeys.ts +++ b/src/shared/hooks/use-hotkeys.ts @@ -2,7 +2,17 @@ import { type HotkeyItem as MantineHotkeyItem, useHotkeys as useMantineHotkeys, } from '@mantine/hooks'; +import { useMemo } from 'react'; -export const useHotkeys = useMantineHotkeys; +import { withPhysicalKeys } from '/@/shared/utils/hotkeys'; + +export const useHotkeys = ( + hotkeys: MantineHotkeyItem[], + tagsToIgnore?: string[], + triggerOnContentEditable?: boolean, +) => { + const physicalHotkeys = useMemo(() => withPhysicalKeys(hotkeys), [hotkeys]); + useMantineHotkeys(physicalHotkeys, tagsToIgnore, triggerOnContentEditable); +}; export type HotkeyItem = MantineHotkeyItem; diff --git a/src/shared/utils/hotkeys.ts b/src/shared/utils/hotkeys.ts new file mode 100644 index 000000000..01b4bf90b --- /dev/null +++ b/src/shared/utils/hotkeys.ts @@ -0,0 +1,37 @@ +import type { HotkeyItem } from '@mantine/hooks'; + +const RESERVED_KEYS = new Set(['alt', 'ctrl', 'meta', 'mod', 'shift']); + +/** + * Converts stored hotkey strings to Mantine's physical-key format. + * Mantine matches KeyboardEvent.code via normalizeKey, which turns Digit1 into + * "digit1" but leaves "1" as "1" — so mod+1 must become mod+Digit1. + */ +export const toPhysicalHotkey = (hotkey: string): string => + hotkey + .split('+') + .map((part) => part.trim()) + .map((part) => { + if (part === '[plus]') { + return part; + } + + const lower = part.toLowerCase(); + if (RESERVED_KEYS.has(lower)) { + return lower; + } + + if (/^\d$/.test(part)) { + return `Digit${part}`; + } + + return part; + }) + .join('+'); + +export const withPhysicalKeys = (hotkeys: HotkeyItem[]): HotkeyItem[] => + hotkeys.map(([hotkey, handler, options]) => [ + toPhysicalHotkey(hotkey), + handler, + { ...options, usePhysicalKeys: true }, + ]); diff --git a/src/shared/utils/keyboard-code-to-hotkey.ts b/src/shared/utils/keyboard-code-to-hotkey.ts new file mode 100644 index 000000000..e33dbbd74 --- /dev/null +++ b/src/shared/utils/keyboard-code-to-hotkey.ts @@ -0,0 +1,68 @@ +const CODE_TO_HOTKEY_KEY: Record = { + ArrowDown: 'arrowdown', + ArrowLeft: 'arrowleft', + ArrowRight: 'arrowright', + ArrowUp: 'arrowup', + Backspace: 'backspace', + Delete: 'delete', + End: 'end', + Enter: 'enter', + Equal: 'equal', + Escape: 'escape', + Home: 'home', + Insert: 'insert', + Minus: 'minus', + PageDown: 'pagedown', + PageUp: 'pageup', + Space: 'space', + Tab: 'tab', +}; + +const NUMPAD_CODE_TO_HOTKEY_KEY: Record = { + Add: 'numpadadd', + Decimal: 'numpaddecimal', + Divide: 'numpaddivide', + Enter: 'numpadenter', + Multiply: 'numpadmultiply', + Subtract: 'numpadsubtract', +}; + +export const MODIFIER_KEY_CODES = new Set([ + 'AltLeft', + 'AltRight', + 'ControlLeft', + 'ControlRight', + 'MetaLeft', + 'MetaRight', + 'ShiftLeft', + 'ShiftRight', +]); + +export const keyboardCodeToHotkeyKey = (code: string): null | string => { + const mapped = CODE_TO_HOTKEY_KEY[code]; + if (mapped) { + return mapped; + } + + if (code.startsWith('Key')) { + return code.slice(3).toLowerCase(); + } + + if (code.startsWith('Digit')) { + return code.slice(5); + } + + if (code.startsWith('Numpad')) { + const suffix = code.slice(6); + const numpadMapped = NUMPAD_CODE_TO_HOTKEY_KEY[suffix]; + if (numpadMapped) { + return numpadMapped; + } + + if (/^\d$/.test(suffix)) { + return `numpad${suffix}`; + } + } + + return null; +};