import { KeyboardEvent as ReactKeyboardEvent } from 'react'; export interface HotkeyBinding { code: string; alt?: boolean; ctrl?: boolean; meta?: boolean; shift?: boolean; } const MODIFIER_CODES = new Set([ 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight', ]); const CODE_LABEL_MAP: Record = { Minus: '-', Equal: '=', Backquote: '`', BracketLeft: '[', BracketRight: ']', Backslash: '\\', IntlBackslash: '\\', Semicolon: ';', Quote: '\'', Comma: ',', Period: '.', Slash: '/', Space: 'Space', Tab: 'Tab', Escape: 'Esc', Enter: 'Enter', NumpadEnter: 'Num Enter', NumpadAdd: 'Num +', NumpadSubtract: 'Num -', NumpadMultiply: 'Num *', NumpadDivide: 'Num /', NumpadDecimal: 'Num .', NumpadComma: 'Num ,', NumpadEqual: 'Num =', }; export const isMacLike = (): boolean => { if (typeof navigator === 'undefined') { return false; } const platform = navigator.platform?.toLowerCase() ?? ''; const userAgent = navigator.userAgent?.toLowerCase() ?? ''; return /mac|iphone|ipad|ipod/.test(platform) || /mac|iphone|ipad|ipod/.test(userAgent); }; export const isModifierCode = (code: string): boolean => MODIFIER_CODES.has(code); const isFunctionKey = (code: string): boolean => /^F\d{1,2}$/.test(code); export const bindingEquals = (a?: HotkeyBinding | null, b?: HotkeyBinding | null): boolean => { if (!a && !b) return true; if (!a || !b) return false; return ( a.code === b.code && Boolean(a.alt) === Boolean(b.alt) && Boolean(a.ctrl) === Boolean(b.ctrl) && Boolean(a.meta) === Boolean(b.meta) && Boolean(a.shift) === Boolean(b.shift) ); }; export const bindingMatchesEvent = (binding: HotkeyBinding, event: KeyboardEvent): boolean => { return ( event.code === binding.code && event.altKey === Boolean(binding.alt) && event.ctrlKey === Boolean(binding.ctrl) && event.metaKey === Boolean(binding.meta) && event.shiftKey === Boolean(binding.shift) ); }; export const eventToBinding = (event: KeyboardEvent | ReactKeyboardEvent): HotkeyBinding | null => { const code = event.code; if (!code || isModifierCode(code)) { return null; } const binding: HotkeyBinding = { code, alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey, }; // Require at least one modifier to avoid clashing with text input if (!binding.alt && !binding.ctrl && !binding.meta) { return null; } return binding; }; const getKeyLabel = (code: string): string => { if (CODE_LABEL_MAP[code]) { return CODE_LABEL_MAP[code]; } if (code.startsWith('Key')) { return code.slice(3); } if (code.startsWith('Digit')) { return code.slice(5); } if (code.startsWith('Numpad')) { const remainder = code.slice(6); if (/^[0-9]$/.test(remainder)) { return `Num ${remainder}`; } return `Num ${remainder}`; } // Match function keys (F1-F12) if (isFunctionKey(code)) { return code; } switch (code) { case 'ArrowUp': return '↑'; case 'ArrowDown': return '↓'; case 'ArrowLeft': return '←'; case 'ArrowRight': return '→'; default: return code; } }; export const getDisplayParts = (binding: HotkeyBinding | null | undefined, macLike: boolean): string[] => { if (!binding) return []; const parts: string[] = []; if (binding.meta) { parts.push(macLike ? '⌘' : 'Win'); } if (binding.ctrl) { parts.push(macLike ? '⌃' : 'Ctrl'); } if (binding.alt) { parts.push(macLike ? '⌥' : 'Alt'); } if (binding.shift) { parts.push(macLike ? '⇧' : 'Shift'); } parts.push(getKeyLabel(binding.code)); return parts; }; export const serializeBindings = (bindings: Record): string => { return JSON.stringify(bindings); }; export const deserializeBindings = (value: string | null | undefined): Record => { if (!value) { return {}; } try { const parsed = JSON.parse(value) as Record; if (typeof parsed !== 'object' || parsed === null) { return {}; } return parsed; } catch (error) { console.warn('Failed to parse stored hotkey bindings', error); return {}; } }; export const normalizeBinding = (binding: HotkeyBinding): HotkeyBinding => ({ code: binding.code, alt: Boolean(binding.alt), ctrl: Boolean(binding.ctrl), meta: Boolean(binding.meta), shift: Boolean(binding.shift), });