All files / src/hotkeys useHotkeys.ts

98.41% Statements 124/126
94% Branches 47/50
100% Functions 7/7
98.41% Lines 124/126

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 2191x 1x 1x   1x 1x   1x   1x         1x 1x   1x 1x   1x       1x 36x 36x 36x 4x 36x 1x                                     21x 21x 21x 21x 315x 21x 315x 294x 294x 315x 21x 21x                           1x                                         1x 1x             1x 11x 11x 11x 11x   11x 11x 11x 11x 11x 11x 10x 10x 10x 2x 1x 1x 1x 1x 2x 10x 1x 1x 1x 10x 11x 11x   1x       1x 34x 34x 34x   1x 21x 21x 21x 21x   21x 21x 21x   21x 21x 21x   21x 36x   32x 36x 343x 343x 9x 9x 9x 9x 9x   23x 36x 23x 36x   23x 36x 7x 7x 23x   23x 36x 1x 1x   23x 23x 19x 23x 23x 2x 2x 2x 2x 2x 2x 21x   21x 21x 21x 21x   1x                   1x 1x 24x 24x 24x 24x  
import { hotkeys as hotkeysConfig } from '@/../config.json';
import { useEffect, useMemo, useRef } from 'react';
import { matches, normalizeKey, parseBinding, parseSequence, resolveBinding } from './matcher';
import type { HotkeyConfig, HotkeyHandlers, ParsedBinding } from './types';
import { Logger } from '@/utils/Logger';
import { getErrorMessage } from '@/helpers/exceptions';
 
const logger = new Logger('useHotkeys');
 
/**
 * Idle window (ms) after which an in-progress key sequence is abandoned.
 * Pressing the next key of a sequence more than this long after the previous
 * one resets the buffer, so a stale partial press can't complete a sequence
 * later. Sourced from `config.json` (`hotkeys.sequenceResetMs`).
 */
const SEQUENCE_RESET_MS = hotkeysConfig.sequenceResetMs;
 
/** Keys that are modifiers on their own; excluded from the sequence buffer. */
const MODIFIER_KEYS = new Set(['meta', 'control', 'alt', 'shift']);
 
/**
 * Returns `true` when the event originated from an editable element
 * (input, textarea, select, or a contentEditable host). Hotkeys are
 * suppressed in these cases so typing is not hijacked.
 */
function isEditableTarget(event: KeyboardEvent): boolean {
  const target = event.target;
  if (!(target instanceof HTMLElement)) return false;
  const tag = target.tagName;
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
  if (target.isContentEditable) return true;
  return false;
}
 
interface CompiledChord {
  config: HotkeyConfig;
  binding: ParsedBinding;
}
 
interface CompiledSequence {
  config: HotkeyConfig;
  sequence: string[];
}
 
interface CompiledHotkeys {
  chords: CompiledChord[];
  sequences: CompiledSequence[];
}
 
function compile(configs: HotkeyConfig[]): CompiledHotkeys {
  const chords: CompiledChord[] = [];
  const sequences: CompiledSequence[] = [];
  for (const config of configs) {
    if (config.sequential) {
      sequences.push({ config, sequence: parseSequence(resolveBinding(config.keys)) });
    } else {
      chords.push({ config, binding: parseBinding(resolveBinding(config.keys)) });
    }
  }
  return { chords, sequences };
}
 
/**
 * Options accepted by {@link useHotkeys}. All fields optional.
 * @source
 */
export interface UseHotkeysOptions {
  /**
   * Called after a hotkey's handler is invoked, with the matched config.
   * Typical use: flash a status-bar confirmation when `config.flash` is set.
   */
  onTriggered?: (config: HotkeyConfig) => void;
}
 
/**
 * Installs a single global `keydown` listener on `document` that dispatches
 * to the supplied handler map based on the application's hotkey config.
 *
 * The hook is intentionally generic: it has no knowledge of the concrete
 * actions. Callers pass a `handlers` map keyed by the `id` field from
 * `config.json`, and the hook takes care of:
 * - Skipping events originating from inputs / textareas / contentEditable hosts
 * - Matching modifier combos strictly (so `ctrl+s` won't fire on `ctrl+shift+s`)
 * - Calling `preventDefault` + `stopPropagation` when a binding fires
 * - Awaiting async handlers and logging any errors
 * @param handlers - Map of hotkey action id → handler.
 * @param options - Optional hooks, e.g. `onTriggered` for status-bar feedback.
 * @example
 * ```ts
 * useHotkeys(
 *   { showHotkeyHelp: () => setHelpOpen(true) },
 *   { onTriggered: (cfg) => cfg.flash && flashStatusText(cfg.flash) },
 * );
 * ```
 * @source
 */
/**
 * Invokes a matched hotkey's handler (when one is registered) and notifies
 * `onTriggered`. A hotkey with only a `flash` and no handler still notifies,
 * so a flash-only shortcut shows its status message. `onTriggered` is skipped
 * when the handler throws, so a failed action doesn't flash a confirmation.
 * Logs every trigger to the console (before the handler runs, so it's recorded
 * even if the handler throws).
 */
function invokeHandler(
  config: HotkeyConfig,
  handlers: HotkeyHandlers,
  onTriggered?: (config: HotkeyConfig) => void,
): void {
  logger.debug(`Hotkey triggered: "${config.id}"`, {
    keys: config.keys,
    description: config.description,
  });
  const handler = handlers[config.id];
  if (handler) {
    try {
      const result = handler();
      if (result instanceof Promise) {
        result.catch((error) => {
          logger.error(`Hotkey handler "${config.id}" failed: ${getErrorMessage(error)}`, {
            error,
          });
        });
      }
    } catch (error) {
      logger.error(`Hotkey handler "${config.id}" threw: ${getErrorMessage(error)}`, { error });
      return;
    }
  }
  onTriggered?.(config);
}
 
/**
 * A hotkey does something worth firing for if it has a registered handler or a
 * `flash` message (flash-only shortcuts). Hotkeys with neither are skipped so
 * their key combo isn't swallowed for no effect.
 */
function isActionable(config: HotkeyConfig, handlers: HotkeyHandlers): boolean {
  return Boolean(handlers[config.id]) || Boolean(config.flash);
}
 
export function useHotkeys(handlers: HotkeyHandlers, options: UseHotkeysOptions = {}): void {
  // Trusted static config: JSON import infers literal types that don't widen to
  // HotkeyConfig[]; shape is validated by config.json's authored structure.
  const compiled = useMemo(() => compile(hotkeysConfig.bindings as HotkeyConfig[]), []);
  const { onTriggered } = options;
 
  // Rolling buffer of recently pressed keys, for matching sequential hotkeys.
  const sequenceBuffer = useRef<string[]>([]);
  const lastKeyTime = useRef<number>(0);
 
  useEffect(() => {
    const { chords, sequences } = compiled;
    const maxSequenceLength = sequences.reduce((max, s) => Math.max(max, s.sequence.length), 0);
 
    const onKeyDown = (event: KeyboardEvent) => {
      if (isEditableTarget(event)) return;
 
      // Chord hotkeys: single-event modifier combos matched strictly.
      for (const { config, binding } of chords) {
        if (!matches(event, binding)) continue;
        if (!isActionable(config, handlers)) continue;
        event.preventDefault();
        event.stopPropagation();
        invokeHandler(config, handlers, onTriggered);
        return;
      }
 
      // Sequential hotkeys: keys pressed one after another (e.g. konami code).
      if (sequences.length === 0) return;
      const key = normalizeKey(event.key);
      if (MODIFIER_KEYS.has(key)) return; // ignore lone modifier presses
 
      const now = Date.now();
      if (now - lastKeyTime.current > SEQUENCE_RESET_MS) {
        sequenceBuffer.current = [];
      }
      lastKeyTime.current = now;
 
      sequenceBuffer.current.push(key);
      if (sequenceBuffer.current.length > maxSequenceLength) {
        sequenceBuffer.current = sequenceBuffer.current.slice(-maxSequenceLength);
      }
 
      for (const { config, sequence } of sequences) {
        if (!isActionable(config, handlers)) continue;
        const tail = sequenceBuffer.current.slice(-sequence.length);
        if (tail.length !== sequence.length) continue;
        if (!tail.every((k, i) => k === sequence[i])) continue;
        event.preventDefault();
        event.stopPropagation();
        invokeHandler(config, handlers, onTriggered);
        sequenceBuffer.current = [];
        return;
      }
    };
 
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [compiled, handlers, onTriggered]);
}
 
/**
 * Returns the raw list of hotkey configs loaded from `config.json`. Useful
 * for the help modal or any UI that needs to enumerate the bindings.
 * @returns Array of hotkey configs, in declaration order.
 * @example
 * ```ts
 * const hotkeys = getHotkeyConfigs();
 * hotkeys.forEach((h) => console.log(h.id, h.description));
 * ```
 * @source
 */
export function getHotkeyConfigs(): HotkeyConfig[] {
  // Trusted static config: JSON import infers literal types that don't widen to
  // HotkeyConfig[]; shape is validated by config.json's authored structure.
  return hotkeysConfig.bindings as HotkeyConfig[];
}