diff --git a/src/util/device.ts b/src/util/device.ts index 6d06a49030..48463425a2 100644 --- a/src/util/device.ts +++ b/src/util/device.ts @@ -1,16 +1,30 @@ -const userAgent = window.navigator.userAgent; +function getUserAgent(): string { + return typeof navigator === 'undefined' ? '' : (navigator.userAgent ?? ''); +} + +function getPlatform(): string { + if (typeof navigator === 'undefined') { + return ''; + } + + return ( + (navigator as any).userAgentData?.platform ?? navigator.platform ?? '' + ); +} /** * */ export function isIOSDevice() { - return /iPad|iPhone|iPod/i.test(userAgent) && !(window as any).MSStream; + const userAgent = getUserAgent(); + return /iPad|iPhone|iPod/i.test(userAgent) && !(globalThis as any).MSStream; } /** * */ export function isAndroidDevice() { + const userAgent = getUserAgent(); return /Android/i.test(userAgent); } @@ -20,3 +34,18 @@ export function isAndroidDevice() { export function isMobileDevice() { return isAndroidDevice() || isIOSDevice(); } + +/** + * Detects whether the user is on an Apple device (iOS/iPadOS/macOS). + */ +export function isAppleDevice(): boolean { + const ua = getUserAgent(); + const platform = getPlatform(); + + const isIPadIPhoneIPod = /iPad|iPhone|iPod/i.test(ua); + // Note: iPadOS 13+ reports itself as Mac, so isMacLike covers both + // macOS and iPadOS. + const isMacLike = /Mac/i.test(platform); + + return isIPadIPhoneIPod || isMacLike; +} diff --git a/src/util/hotkeys.spec.ts b/src/util/hotkeys.spec.ts new file mode 100644 index 0000000000..f12a5a12f4 --- /dev/null +++ b/src/util/hotkeys.spec.ts @@ -0,0 +1,264 @@ +import { + hotkeyFromKeyboardEvent, + normalizeHotkeyString, + tokenizeHotkeyString, +} from './hotkeys'; + +describe('hotkeys util', () => { + describe('tokenizeHotkeyString', () => { + it('returns empty array for empty string', () => { + expect(tokenizeHotkeyString('')).toEqual([]); + }); + + it('returns empty array for whitespace-only string', () => { + expect(tokenizeHotkeyString(' ')).toEqual([]); + }); + + it('returns empty array for null/undefined', () => { + expect(tokenizeHotkeyString(null as any)).toEqual([]); + expect(tokenizeHotkeyString(undefined as any)).toEqual([]); + }); + + it('tokenizes a single key', () => { + expect(tokenizeHotkeyString('a')).toEqual(['a']); + }); + + it('tokenizes modifier+key', () => { + expect(tokenizeHotkeyString('ctrl+k')).toEqual(['ctrl', 'k']); + }); + + it('handles + as a standalone key', () => { + expect(tokenizeHotkeyString('+')).toEqual(['+']); + }); + + it('handles ++ as modifier followed by literal +', () => { + expect(tokenizeHotkeyString('meta++')).toEqual(['meta', '+']); + }); + + it('handles +++ as modifier + literal + key', () => { + expect(tokenizeHotkeyString('ctrl+++')).toEqual(['ctrl', '+']); + }); + + it('trims whitespace from tokens', () => { + expect(tokenizeHotkeyString(' ctrl + k ')).toEqual(['ctrl', 'k']); + }); + + it('handles spaced + as literal plus key', () => { + expect(tokenizeHotkeyString('ctrl + +')).toEqual(['ctrl', '+']); + }); + }); + + describe('normalizeHotkeyString', () => { + it('returns null for null/undefined/empty', () => { + expect(normalizeHotkeyString(null as any)).toBeNull(); + expect(normalizeHotkeyString(undefined as any)).toBeNull(); + expect(normalizeHotkeyString('')).toBeNull(); + }); + + it('returns null for modifier-only input', () => { + expect(normalizeHotkeyString('ctrl+shift')).toBeNull(); + expect(normalizeHotkeyString('meta')).toBeNull(); + }); + + it('resolves aliases to canonical names', () => { + expect(normalizeHotkeyString('cmd+k')).toBe('meta+k'); + expect(normalizeHotkeyString('command+k')).toBe('meta+k'); + expect(normalizeHotkeyString('option+x')).toBe('alt+x'); + expect(normalizeHotkeyString('control+a')).toBe('ctrl+a'); + expect(normalizeHotkeyString('return')).toBe('enter'); + expect(normalizeHotkeyString('esc')).toBe('escape'); + }); + + it('orders modifiers as meta+ctrl+alt+shift', () => { + expect(normalizeHotkeyString('shift+ctrl+alt+meta+k')).toBe( + 'meta+ctrl+alt+shift+k' + ); + }); + + it('keeps + as a key', () => { + expect(normalizeHotkeyString('+')).toBe('+'); + }); + + it('supports ++ as the + key token', () => { + expect(normalizeHotkeyString('meta++')).toBe('meta++'); + }); + + it('drops shift for the + key to match KeyboardEvent canonicalization', () => { + expect(normalizeHotkeyString('ctrl+shift++')).toBe('ctrl++'); + }); + + it('handles spaced + as literal plus key', () => { + expect(normalizeHotkeyString('ctrl + +')).toBe('ctrl++'); + }); + + it('keeps backspace and delete as distinct keys', () => { + expect(normalizeHotkeyString('backspace')).toBe('backspace'); + expect(normalizeHotkeyString('delete')).toBe('delete'); + expect(normalizeHotkeyString('del')).toBe('delete'); + }); + }); + + describe('hotkeyFromKeyboardEvent', () => { + it('returns null for pure modifier presses', () => { + expect( + hotkeyFromKeyboardEvent({ + key: 'Shift', + code: 'ShiftLeft', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: true, + } as any) + ).toBeNull(); + + expect( + hotkeyFromKeyboardEvent({ + key: 'Control', + code: 'ControlLeft', + altKey: false, + ctrlKey: true, + metaKey: false, + shiftKey: false, + } as any) + ).toBeNull(); + }); + + it('includes multiple modifiers', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: 'a', + code: 'KeyA', + altKey: true, + ctrlKey: true, + metaKey: false, + shiftKey: false, + } as any); + + expect(pressed).toBe('ctrl+alt+a'); + }); + + it('handles function keys', () => { + expect( + hotkeyFromKeyboardEvent({ + key: 'F1', + code: 'F1', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('f1'); + + expect( + hotkeyFromKeyboardEvent({ + key: 'F12', + code: 'F12', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('f12'); + }); + + it('handles arrow keys', () => { + expect( + hotkeyFromKeyboardEvent({ + key: 'ArrowLeft', + code: 'ArrowLeft', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('arrowleft'); + + expect( + hotkeyFromKeyboardEvent({ + key: 'ArrowRight', + code: 'ArrowRight', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('arrowright'); + }); + + it('uses event.code for non-QWERTY letter normalization', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: '@', + code: 'KeyQ', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + // Uses code "KeyQ" to normalize to "q" (layout-independent) + expect(pressed).toBe('q'); + }); + + it('uses event.code for digit normalization', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: ')', + code: 'Digit0', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + // Uses code "Digit0" to normalize to "0" (layout-independent) + expect(pressed).toBe('0'); + }); + + it('does not include shift for the + key', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: '+', + code: 'Equal', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: true, + } as any); + + expect(pressed).toBe('+'); + }); + + it('still includes shift for letter keys', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: 'A', + code: 'KeyA', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: true, + } as any); + + expect(pressed).toBe('shift+a'); + }); + + it('keeps Backspace and Delete as distinct keys', () => { + const backspace = hotkeyFromKeyboardEvent({ + key: 'Backspace', + code: 'Backspace', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + const del = hotkeyFromKeyboardEvent({ + key: 'Delete', + code: 'Delete', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + expect(backspace).toBe('backspace'); + expect(del).toBe('delete'); + }); + }); +}); diff --git a/src/util/hotkeys.ts b/src/util/hotkeys.ts new file mode 100644 index 0000000000..f9863ce1c5 --- /dev/null +++ b/src/util/hotkeys.ts @@ -0,0 +1,255 @@ +/** + * These helpers provide a consistent way to: + * - Normalize user-defined hotkey strings (e.g. "cmd+k", "ctrl+shift+p") + * - Normalize `KeyboardEvent`s into the same canonical hotkey format + * + * **Canonical format** + * - Modifiers are ordered: `meta`, `ctrl`, `alt`, `shift` + * - Tokens are joined with `+` + * - The final token is the non-modifier key + * + * Examples: + * - `"cmd+k"` → `"meta+k"` + * - `"return"` → `"enter"` + * - `"backspace"` → `"backspace"` + * - `"alt+shift+l"` → `"alt+shift+l"` + * + * Note: `keycodes.ts` defines single-key constants (e.g. `ENTER`, `ESCAPE`) + * used for simple `event.key` comparisons in components. This module handles + * multi-key hotkey combinations with modifier normalization and alias + * resolution — a different concern. + */ +const NORMALIZED_HOTKEY_SEPARATOR = '+'; + +const KEY_ALIASES: Record = { + cmd: 'meta', + command: 'meta', + win: 'meta', + windows: 'meta', + control: 'ctrl', + option: 'alt', + esc: 'escape', + return: 'enter', + del: 'delete', + up: 'arrowup', + down: 'arrowdown', + left: 'arrowleft', + right: 'arrowright', + spacebar: 'space', +}; + +export const tokenizeHotkeyString = (hotkey: string): string[] => { + const raw = (hotkey ?? '').trim(); + if (!raw) { + return []; + } + + // Allow `+` as a hotkey. + if (raw === '+') { + return ['+']; + } + + // Split on `+`, but treat `++` as the `+` key. + const tokens: string[] = []; + let current = ''; + let index = 0; + + while (index < raw.length) { + const char = raw[index]; + if (char !== '+') { + current += char; + index++; + continue; + } + + let nextIndex = index + 1; + while (raw[nextIndex]?.trim() === '') { + nextIndex++; + } + + const nextChar = raw[nextIndex]; + if (nextChar === '+') { + const token = current.trim(); + if (token) { + tokens.push(token); + } + tokens.push('+'); + current = ''; + index = nextIndex + 1; + continue; + } + + const token = current.trim(); + if (token) { + tokens.push(token); + } + current = ''; + index++; + } + + const tail = current.trim(); + if (tail) { + tokens.push(tail); + } + + return tokens; +}; + +const normalizeKey = (key: string): string | null => { + const normalized = key.trim().toLowerCase(); + if (!normalized) { + return null; + } + + if (Object.hasOwn(KEY_ALIASES, normalized)) { + return KEY_ALIASES[normalized]; + } + + return normalized; +}; + +const normalizeModifiersAndKey = (input: { + key: string; + alt: boolean; + ctrl: boolean; + meta: boolean; + shift: boolean; +}): string | null => { + const normalizedKey = normalizeKey(input.key); + if (!normalizedKey) { + return null; + } + + // Ignore pure modifier presses + if (['shift', 'alt', 'ctrl', 'meta'].includes(normalizedKey)) { + return null; + } + + const parts: string[] = []; + + if (input.meta) { + parts.push('meta'); + } + if (input.ctrl) { + parts.push('ctrl'); + } + if (input.alt) { + parts.push('alt'); + } + if (input.shift && normalizedKey !== '+') { + parts.push('shift'); + } + + parts.push(normalizedKey); + + return parts.join(NORMALIZED_HOTKEY_SEPARATOR); +}; + +/** + * Normalize a user-defined hotkey string to the canonical format. + * + * Returns `null` for empty/invalid inputs or if the string only contains + * modifiers (e.g. `"ctrl+shift"`). + * + * If multiple non-modifier keys are present, only the last one is used + * (e.g. `"ctrl+a+b"` becomes `"ctrl+b"`). + * + * @param hotkey - User-provided hotkey string. + */ +export const normalizeHotkeyString = (hotkey: string): string | null => { + if (!hotkey) { + return null; + } + + const tokens = tokenizeHotkeyString(hotkey); + if (tokens.length === 0) { + return null; + } + + let alt = false; + let ctrl = false; + let meta = false; + let shift = false; + let key: string | null = null; + + for (const token of tokens) { + const normalized = normalizeKey(token); + if (!normalized) { + continue; + } + + if (normalized === 'alt') { + alt = true; + continue; + } + if (normalized === 'ctrl') { + ctrl = true; + continue; + } + if (normalized === 'meta') { + meta = true; + continue; + } + if (normalized === 'shift') { + shift = true; + continue; + } + + // Last non-modifier wins + key = normalized; + } + + if (!key) { + return null; + } + + return normalizeModifiersAndKey({ key, alt, ctrl, meta, shift }); +}; + +const normalizeEventKey = (event: KeyboardEvent): string | null => { + const code = (event.code || '').trim(); + if (/^Key[A-Z]$/.test(code)) { + return code.slice(3).toLowerCase(); + } + + if (/^Digit\d$/.test(code)) { + return code.slice(5); + } + + const key = event.key; + + if (key === ' ') { + return 'space'; + } + + return normalizeKey(key); +}; + +/** + * Convert a `KeyboardEvent` into a canonical hotkey string. + * + * Uses `event.code` when possible for letter/digit keys to avoid + * layout-dependent results. + * + * @param event - Keyboard event to normalize. + */ +export const hotkeyFromKeyboardEvent = ( + event: KeyboardEvent +): string | null => { + const key = normalizeEventKey(event); + if (!key) { + return null; + } + + // `+` typically requires Shift on many keyboard layouts, but users expect to + // write hotkeys like `meta++` (⌘+) without explicitly adding `shift`. + const shift = key === '+' ? false : event.shiftKey; + + return normalizeModifiersAndKey({ + key, + alt: event.altKey, + ctrl: event.ctrlKey, + meta: event.metaKey, + shift, + }); +}; diff --git a/src/util/keycodes.ts b/src/util/keycodes.ts index 177b757a27..0eb68bd507 100644 --- a/src/util/keycodes.ts +++ b/src/util/keycodes.ts @@ -1,3 +1,5 @@ +// Single-key constants for simple event.key comparisons. +// For multi-key hotkey combination handling, see `hotkeys.ts`. export const TAB = 'Tab'; export const ENTER = 'Enter'; export const ESCAPE = 'Escape';