export interface ShortcutBinding { id: string; keys: string | string[]; handler: (event: KeyboardEvent) => void; description?: string; preventDefault?: boolean; stopPropagation?: boolean; allowInEditable?: boolean; disabled?: boolean; } export interface ShortcutMatchOptions { platform?: string; } export interface ShortcutGuardOptions { allowInEditable?: boolean; ignoreWhenDefaultPrevented?: boolean; } export interface ShortcutHandlerOptions extends ShortcutMatchOptions, ShortcutGuardOptions { capture?: boolean; } export interface InstallShortcutOptions extends ShortcutHandlerOptions { target?: Pick; } const MODIFIER_ORDER = ['mod', 'ctrl', 'alt', 'shift', 'meta'] as const; const MODIFIER_TOKENS = new Set(MODIFIER_ORDER); const NON_EDITING_INPUT_TYPES = new Set([ 'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit', ]); const KEY_ALIASES: Record = { ' ': 'space', arrowdown: 'arrowdown', arrowleft: 'arrowleft', arrowright: 'arrowright', arrowup: 'arrowup', cmd: 'meta', command: 'meta', commandorcontrol: 'mod', control: 'ctrl', ctl: 'ctrl', del: 'delete', down: 'arrowdown', esc: 'escape', left: 'arrowleft', option: 'alt', opt: 'alt', return: 'enter', right: 'arrowright', spacebar: 'space', up: 'arrowup', win: 'meta', windows: 'meta', }; export function normaliseShortcutString(shortcut: string): string { const tokens = shortcut .split('+') .map((token) => normaliseKeyToken(token)) .filter(Boolean); if (tokens.length === 0) { throw new Error('Keyboard shortcut cannot be empty.'); } const modifiers = new Set(); let key: string | undefined; for (const token of tokens) { if (MODIFIER_TOKENS.has(token)) { modifiers.add(token); } else { key = token; } } if (!key) { throw new Error(`Keyboard shortcut "${shortcut}" is missing a non-modifier key.`); } return [...MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)), key].join('+'); } export function normaliseKeyToken(token: string | undefined): string { if (!token) { return ''; } const compact = token.trim().toLowerCase().replace(/\s+/g, ''); if (!compact) { return ''; } return KEY_ALIASES[compact] ?? compact; } export function getKeyboardShortcut( event: KeyboardEvent, options: ShortcutMatchOptions & { useModAlias?: boolean } = {}, ): string { const key = normaliseKeyToken(event.key); const modifiers = new Set(); const applePlatform = isApplePlatform(options.platform); if (options.useModAlias) { const primaryModifierPressed = applePlatform ? event.metaKey : event.ctrlKey; if (primaryModifierPressed) { modifiers.add('mod'); } if (applePlatform && event.ctrlKey) { modifiers.add('ctrl'); } if (!applePlatform && event.metaKey) { modifiers.add('meta'); } } else { if (event.ctrlKey) { modifiers.add('ctrl'); } if (event.metaKey) { modifiers.add('meta'); } } if (event.altKey) { modifiers.add('alt'); } if (event.shiftKey) { modifiers.add('shift'); } const ordered = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)); if (key && !MODIFIER_TOKENS.has(key)) { ordered.push(key as (typeof MODIFIER_ORDER)[number]); } return ordered.join('+'); } export function eventMatchesShortcut( event: KeyboardEvent, shortcut: string, options: ShortcutMatchOptions = {}, ): boolean { let normalisedShortcut: string; try { normalisedShortcut = normaliseShortcutString(shortcut); } catch { return false; } const usesModAlias = normalisedShortcut.split('+').includes('mod'); return getKeyboardShortcut(event, { platform: options.platform, useModAlias: usesModAlias, }) === normalisedShortcut; } export function isEditableElement(target: EventTarget | null): boolean { if (typeof Element === 'undefined' || !(target instanceof Element)) { return false; } if (target.closest('[data-global-shortcuts="true"], [data-allow-global-shortcuts="true"]')) { return false; } if (target.closest('[data-ignore-shortcuts="true"], [data-disable-shortcuts="true"]')) { return true; } const editableElement = target.closest( 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"], [role="searchbox"], [role="combobox"]', ); if (!editableElement) { return false; } const tagName = editableElement.tagName.toLowerCase(); if (tagName === 'textarea' || tagName === 'select') { return true; } if (tagName === 'input') { const type = typeof HTMLInputElement !== 'undefined' && editableElement instanceof HTMLInputElement ? editableElement.type : editableElement.getAttribute('type') ?? 'text'; return !NON_EDITING_INPUT_TYPES.has(type.toLowerCase()); } return true; } export function shouldIgnoreShortcut( event: KeyboardEvent, options: ShortcutGuardOptions = {}, ): boolean { if (options.ignoreWhenDefaultPrevented !== false && event.defaultPrevented) { return true; } if (!options.allowInEditable && isEditableElement(event.target)) { return true; } return false; } export function createShortcutHandler( bindings: ShortcutBinding[], options: ShortcutHandlerOptions = {}, ): (event: KeyboardEvent) => void { const preparedBindings = bindings.map((binding) => ({ ...binding, keys: Array.isArray(binding.keys) ? binding.keys : [binding.keys], })); return (event: KeyboardEvent) => { for (const binding of preparedBindings) { if (binding.disabled) { continue; } if ( shouldIgnoreShortcut(event, { allowInEditable: binding.allowInEditable ?? options.allowInEditable, ignoreWhenDefaultPrevented: options.ignoreWhenDefaultPrevented, }) ) { continue; } const matched = binding.keys.some((shortcut) => eventMatchesShortcut(event, shortcut, { platform: options.platform, }), ); if (!matched) { continue; } if (binding.preventDefault !== false) { event.preventDefault(); } if (binding.stopPropagation) { event.stopPropagation(); } binding.handler(event); return; } }; } export function installShortcutHandler( bindings: ShortcutBinding[], options: InstallShortcutOptions = {}, ): () => void { const target = options.target ?? (typeof window !== 'undefined' ? window : undefined); if (!target) { return () => undefined; } const handler = createShortcutHandler(bindings, options); const listenerOptions: AddEventListenerOptions = { capture: options.capture, }; target.addEventListener('keydown', handler as EventListener, listenerOptions); return () => { target.removeEventListener('keydown', handler as EventListener, listenerOptions); }; } export function isApplePlatform(platform?: string): boolean { const value = platform ?? (typeof navigator !== 'undefined' ? navigator.platform : ''); return /Mac|iPhone|iPad|iPod/i.test(value); }