All files / src/utils keyboardUtils.ts

93.16% Statements 109/117
81.17% Branches 69/85
100% Functions 19/19
94.49% Lines 103/109

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427                                                                                            22x             56x                                                           113x 75x     38x           38x 38x 3x   3x 1x 1x   2x 1x 1x   1x 1x 1x         35x   35x 4x 4x   31x 2x 2x   29x 1x 1x     28x 28x                                               2x 2x                                                                           13x 13x     13x 13x               13x 13x     13x     13x   13x 1x 12x 1x   11x     13x                         25x   25x     53x 53x   25x 25x   25x 53x 28x   25x         25x 125x       25x   25x                       11x     11x       11x                     3x   3x     6x     6x 3x 3x       3x                         32x   32x     49x   49x 30x 30x     19x                     15x 7x   8x 8x                   12x     12x 1x       11x 2x       9x 1x     8x                           10x 8x     2x 2x                   3x 3x 6x     6x 4x 4x 3x 3x   3x     3x                 42x    
/**
 * Keyboard utilities for shortcut handling and platform detection
 * 
 * This module provides cross-platform keyboard shortcut handling with proper
 * platform detection (Mac vs. Windows/Linux) and key combination parsing.
 * 
 * @module utils/keyboardUtils
 * 
 * @example
 * ```typescript
 * import { detectPlatform, getKeyCombination, formatKeyDisplay } from './keyboardUtils';
 * 
 * // Detect user's platform
 * const platform = detectPlatform(); // 'mac' | 'windows' | 'linux' | 'unknown'
 * 
 * // Handle keyboard event
 * document.addEventListener('keydown', (e) => {
 *   const combo = getKeyCombination(e);
 *   console.log('Pressed:', combo); // e.g., 'ctrl+shift+k'
 * });
 * ```
 */
 
import { Platform } from '../types/keyboard';
import { 
  PLATFORM_DETECTION, 
  KEY_DISPLAY_NAMES, 
  INPUT_ELEMENT_TAGS,
  BYPASS_INPUT_CHECK_KEYS 
} from '../constants/keyboardShortcuts';
 
/**
 * Interface for navigator.userAgentData (experimental API)
 */
interface NavigatorUAData {
  platform?: string;
}
 
/**
 * Extended Navigator interface with userAgentData property
 */
interface NavigatorWithUserAgentData extends Navigator {
  userAgentData?: NavigatorUAData;
}
 
// Cache platform detection result for performance
let cachedPlatform: Platform | null = null;
 
/**
 * Reset the cached platform (used for testing)
 * @internal
 */
export function resetPlatformCache(): void {
  cachedPlatform = null;
}
 
/**
 * Detect the current platform
 * 
 * Uses modern navigator.userAgentData when available, with fallback to
 * deprecated navigator.platform for older browsers.
 * Result is cached for performance.
 * 
 * @returns The detected platform
 * 
 * @example
 * ```typescript
 * // Simple platform detection
 * const platform = detectPlatform();
 * 
 * if (platform === 'mac') {
 *   console.log('User is on macOS - show Cmd shortcuts');
 * } else if (platform === 'windows') {
 *   console.log('User is on Windows - show Ctrl shortcuts');
 * }
 * 
 * // Use in keyboard shortcut display
 * const modifier = platform === 'mac' ? '⌘' : 'Ctrl';
 * const shortcutText = `${modifier}+K to search`;
 * ```
 */
export function detectPlatform(): Platform {
  // Return cached result if available
  if (cachedPlatform !== null) {
    return cachedPlatform;
  }
  
  Iif (typeof window === 'undefined') {
    cachedPlatform = 'unknown';
    return cachedPlatform;
  }
  
  // Use modern userAgentData API if available
  const nav = navigator as NavigatorWithUserAgentData;
  if ('userAgentData' in navigator && nav.userAgentData?.platform) {
    const platform = nav.userAgentData.platform.toUpperCase();
    
    if (platform.indexOf(PLATFORM_DETECTION.MAC) >= 0) {
      cachedPlatform = 'mac';
      return cachedPlatform;
    }
    if (platform.indexOf(PLATFORM_DETECTION.WINDOWS) >= 0) {
      cachedPlatform = 'windows';
      return cachedPlatform;
    }
    Eif (platform.indexOf(PLATFORM_DETECTION.LINUX) >= 0) {
      cachedPlatform = 'linux';
      return cachedPlatform;
    }
  }
  
  // Fallback to deprecated platform property for older browsers
  const platform = window.navigator.platform.toUpperCase();
  
  if (platform.indexOf(PLATFORM_DETECTION.MAC) >= 0) {
    cachedPlatform = 'mac';
    return cachedPlatform;
  }
  if (platform.indexOf(PLATFORM_DETECTION.WINDOWS) >= 0) {
    cachedPlatform = 'windows';
    return cachedPlatform;
  }
  if (platform.indexOf(PLATFORM_DETECTION.LINUX) >= 0) {
    cachedPlatform = 'linux';
    return cachedPlatform;
  }
  
  cachedPlatform = 'unknown';
  return cachedPlatform;
}
 
/**
 * Get platform modifier key (Cmd on Mac, Ctrl elsewhere)
 * 
 * @returns The platform-specific modifier key name
 * 
 * @example
 * ```typescript
 * // Get the right modifier for current platform
 * const modifier = getPlatformModifier();
 * 
 * // Use in shortcut configuration
 * const shortcuts = {
 *   search: `${modifier}+k`,    // 'cmd+k' on Mac, 'ctrl+k' elsewhere
 *   save: `${modifier}+s`,      // 'cmd+s' on Mac, 'ctrl+s' elsewhere
 * };
 * 
 * // Display in UI
 * const displayText = modifier === 'cmd' ? '⌘+K' : 'Ctrl+K';
 * ```
 */
export function getPlatformModifier(): 'cmd' | 'ctrl' {
  const platform = detectPlatform();
  return platform === 'mac' ? 'cmd' : 'ctrl';
}
 
 
 
/**
 * Get key combination string from keyboard event
 * 
 * Converts KeyboardEvent to a normalized key combination string.
 * Handles platform differences (Cmd vs. Ctrl) and special keys.
 * 
 * @param event - The keyboard event
 * @returns Key combination string (e.g., 'ctrl+shift+k')
 * 
 * @example
 * ```typescript
 * // In event handler
 * document.addEventListener('keydown', (event) => {
 *   const combo = getKeyCombination(event);
 *   
 *   // Match against shortcuts
 *   if (combo === 'ctrl+shift+k' || combo === 'cmd+shift+k') {
 *     event.preventDefault();
 *     openCommandPalette();
 *   }
 *   
 *   // Log for debugging
 *   console.log('Key combo:', combo);
 *   // Examples: 'ctrl+s', 'cmd+k', 'shift+enter', 'escape'
 * });
 * 
 * // Handle special keys
 * // Space key: 'ctrl+space'
 * // Escape key: 'escape'
 * // Enter key: 'shift+enter'
 * ```
 */
export function getKeyCombination(event: KeyboardEvent): string {
  const parts: string[] = [];
  const platform: Platform = detectPlatform();
  
  // Add modifiers in consistent order
  if (event.ctrlKey) parts.push('ctrl');
  Iif (event.metaKey) {
    // On Mac, metaKey is Command key; on Windows/Linux, it's Windows/Super key
    if (platform === 'mac') {
      parts.push('cmd');
    } else {
      parts.push('meta');
    }
  }
  if (event.shiftKey) parts.push('shift');
  Iif (event.altKey) parts.push('alt');
  
  // Add the main key (lowercase for consistency)
  const key = event.key.toLowerCase();
  
  // Handle special keys
  Iif (key === ' ') {
    parts.push('space');
  } else if (key === 'escape') {
    parts.push('escape');
  } else if (key === 'enter') {
    parts.push('enter');
  } else {
    parts.push(key);
  }
  
  return parts.join('+');
}
 
/**
 * Normalize shortcut string for comparison
 * 
 * Maintains the canonical modifier key order (ctrl, cmd, meta, shift, alt)
 * to ensure consistent matching with getKeyCombination output.
 * 
 * @param shortcut - The shortcut string (e.g., 'Ctrl+K', 'cmd+k')
 * @returns Normalized shortcut string
 */
export function normalizeShortcut(shortcut: string): string {
  const modifierOrder: string[] = ['ctrl', 'cmd', 'meta', 'shift', 'alt'];
 
  const parts: string[] = shortcut
    .toLowerCase()
    .split('+')
    .map((part: string) => part.trim())
    .filter((part: string) => part.length > 0);
 
  const modifiers: string[] = [];
  const others: string[] = [];
 
  for (const part of parts) {
    if (modifierOrder.includes(part)) {
      modifiers.push(part);
    } else {
      others.push(part);
    }
  }
 
  // Ensure modifiers follow the same canonical order as getKeyCombination
  const orderedModifiers: string[] = modifierOrder.filter(
    (modifier: string) => modifiers.includes(modifier),
  );
 
  // Sort non-modifier keys for determinism; typically there is only one main key
  const orderedOthers: string[] = [...others].sort();
 
  return [...orderedModifiers, ...orderedOthers].join('+');
}
 
/**
 * Check if two shortcuts match
 * 
 * @param shortcut1 - First shortcut string
 * @param shortcut2 - Second shortcut string
 * @returns True if shortcuts match
 */
export function shortcutsMatch(shortcut1: string, shortcut2: string): boolean {
  // Handle cmd/meta/ctrl equivalence for cross-platform matching
  const normalized1 = normalizeShortcut(
    shortcut1.replace(/cmd/g, 'ctrl').replace(/meta/g, 'ctrl')
  );
  const normalized2 = normalizeShortcut(
    shortcut2.replace(/cmd/g, 'ctrl').replace(/meta/g, 'ctrl')
  );
  
  return normalized1 === normalized2;
}
 
/**
 * Format shortcut for display based on platform
 * 
 * @param keys - Key combination string (e.g., 'ctrl+k')
 * @param platform - Target platform (defaults to current)
 * @returns Formatted shortcut string
 */
export function formatShortcut(keys: string, platform?: Platform): string {
  const targetPlatform = platform || detectPlatform();
  
  return keys
    .split('+')
    .map(key => {
      const lowerKey = key.toLowerCase();
      
      // Check if key has platform-specific display name
      if (lowerKey in KEY_DISPLAY_NAMES) {
        const keyName = lowerKey as keyof typeof KEY_DISPLAY_NAMES;
        return KEY_DISPLAY_NAMES[keyName][targetPlatform];
      }
      
      // Capitalize first letter for other keys
      return key.charAt(0).toUpperCase() + key.slice(1);
    })
    .join(targetPlatform === 'mac' ? '' : '+');
}
 
/**
 * Split formatted shortcut into individual keys for badge display
 * 
 * @param keys - Key combination string
 * @param platform - Target platform
 * @returns Array of individual key display strings
 */
export function splitShortcutKeys(keys: string, platform?: Platform): string[] {
  const targetPlatform = platform || detectPlatform();
  
  return keys
    .split('+')
    .map(key => {
      const lowerKey = key.toLowerCase();
      
      if (lowerKey in KEY_DISPLAY_NAMES) {
        const keyName = lowerKey as keyof typeof KEY_DISPLAY_NAMES;
        return KEY_DISPLAY_NAMES[keyName][targetPlatform];
      }
      
      return key.charAt(0).toUpperCase() + key.slice(1);
    });
}
 
/**
 * Check if element is an input that should prevent shortcuts
 * 
 * @param element - DOM element to check
 * @returns True if element is an input
 */
export function isInputElement(element: Element | null): boolean {
  if (!element || !element.tagName) {
    return false;
  }
  const tagName = element.tagName.toUpperCase();
  return INPUT_ELEMENT_TAGS.includes(tagName as typeof INPUT_ELEMENT_TAGS[number]);
}
 
/**
 * Check if keyboard event should be ignored for shortcuts
 * 
 * @param event - Keyboard event
 * @returns True if event should be ignored
 */
export function shouldIgnoreKeyboardEvent(event: KeyboardEvent): boolean {
  const target = event.target as Element | null;
  
  // Check if key should bypass input check (e.g., Escape)
  if (BYPASS_INPUT_CHECK_KEYS.includes(event.key as typeof BYPASS_INPUT_CHECK_KEYS[number])) {
    return false;
  }
  
  // Ignore if focused on input element
  if (target && isInputElement(target)) {
    return true;
  }
  
  // Ignore if contenteditable
  if (target && target.getAttribute && target.getAttribute('contenteditable') === 'true') {
    return true;
  }
  
  return false;
}
 
/**
 * Get keyboard shortcut for current platform
 * 
 * @param defaultKeys - Default key combination
 * @param platformKeys - Platform-specific overrides
 * @returns Key combination for current platform
 */
export function getPlatformShortcut(
  defaultKeys: string,
  platformKeys?: Partial<Record<Platform, string>>
): string {
  if (!platformKeys) {
    return defaultKeys;
  }
  
  const platform = detectPlatform();
  return platformKeys[platform] || defaultKeys;
}
 
/**
 * Create accessible label for keyboard shortcut
 * 
 * @param keys - Key combination string
 * @returns Accessible label string
 */
export function getShortcutAriaLabel(keys: string): string {
  const platform = detectPlatform();
  const parts = keys.split('+').map(key => {
    const lowerKey = key.toLowerCase();
    
    // Use full names for screen readers
    if (lowerKey === 'ctrl') return 'Control';
    Iif (lowerKey === 'cmd') return 'Command';
    if (lowerKey === 'shift') return 'Shift';
    Iif (lowerKey === 'alt') return platform === 'mac' ? 'Option' : 'Alt';
    Iif (lowerKey === 'meta') return platform === 'mac' ? 'Command' : 'Meta';
    
    return key.charAt(0).toUpperCase() + key.slice(1);
  });
  
  return parts.join(' + ');
}
 
/**
 * Check if keyboard shortcuts are supported in the current environment
 * 
 * @returns True if keyboard shortcuts are supported
 */
export function areKeyboardShortcutsSupported(): boolean {
  return typeof window !== 'undefined' && typeof window.addEventListener === 'function';
}