12k
All articles

Как добавить горячие клавиши в веб-приложение

Как добавить клавиатурные сокращения в веб‑приложение: global keydown, защита при вводе, модификаторы Mac и Windows, последовательности и очистка в React.

OpenReplay Team
OpenReplay Team
Как добавить горячие клавиши в веб-приложение

Чтобы добавить горячие клавиши в веб-приложение, повесьте один обработчик keydown на document, сопоставляйте event.key вместе с булевыми флагами модификаторов, пропускайте событие, если его цель — редактируемый элемент, и удаляйте обработчик по той же ссылке на функцию при размонтировании владеющего им компонента.

Первое сочетание клавиш обычно пишется быстро. Проблемы, как правило, приходят позже: кто-то вводит «k» в поле поиска — и открывается командная палитра, или коллега на Mac обнаруживает, что сочетание вообще не работает.

Эта статья начинается с наивного обработчика и по очереди устраняет каждый сбой: срабатывание во время набора текста, различия модификаторов на Mac и Windows, последовательности из двух клавиш, утечки обработчиков в React и правила доступности, относящиеся именно к горячим клавишам. Большинство исправлений — это несколько строк TypeScript, которые можно вставить в уже существующий обработчик.

Ключевые выводы

  • Обработчик keydown на document получает каждое нажатие клавиши на странице, поэтому обработчик должен досрочно завершаться, когда event.target — это input, textarea, select или любой элемент, у которого isContentEditable равно true.
  • Сочетание, проверяющее только event.ctrlKey, никогда не сработает на Mac, потому что клавиша Command устанавливает event.metaKey; проверяйте event.metaKey || event.ctrlKey, чтобы одна привязка покрывала обе платформы.
  • Для сопоставления определение платформы не требуется; определяйте платформу только для отображения — это единственное применение navigator.platform, которое документирует MDN.
  • Последовательность вроде g, затем i требует буфера, тайм-аута истечения, сброса при любой клавише, не являющейся префиксом, и повторной проверки этой клавиши как начала новой последовательности.
  • В React регистрируйте обработчик в useEffect, удаляйте ту же ссылку в функции очистки и мемоизируйте обработчик через useCallback, если он читает пропсы или состояние.

Наивный обработчик горячих клавиш на JavaScript

Простейшее рабочее сочетание — это обработчик keydown, который сравнивает event.key с символом и вызывает preventDefault() при совпадении. Используйте event.key, но никогда — устаревший keyCode.

document.addEventListener('keydown', (event) => {
  if (event.ctrlKey && event.key.toLowerCase() === 'k') {
    event.preventDefault();
    openCommandPalette();
  }
});

Приведение event.key к нижнему регистру позволяет сопоставлению пережить Caps Lock и Shift. Всё остальное в этом обработчике — баг, ожидающий своего пользователя.

Как не дать горячим клавишам срабатывать, пока пользователь печатает?

Обработчик горячих клавиш должен проверять event.target прежде, чем что-либо делать, потому что обработчик на уровне документа получает и те нажатия, которые пользователь вводит в поле поиска. Обычный фильтр проверяет три имени тегов, и в этой ментальной модели кроется дыра: область с contenteditable сохраняет собственный тег (обычно div), поэтому редактор форматированного текста проходит проверку и сочетание срабатывает посреди предложения. Записи сессий приложений с глобальными горячими клавишами показывают ровно это: пользователь печатает в поле, и страница уходит на другой маршрут по букве, которая оказалась привязанной.

Используйте вместо этого isContentEditable. Он равен true для любого элемента, который пользователь может редактировать, включая тот, что наследует редактируемость от предка:

function isTyping(target: EventTarget | null): boolean {
  if (!(target instanceof HTMLElement)) return false;
  const tag = target.tagName;
  return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
    || target.isContentEditable;
}

document.addEventListener('keydown', (event) => {
  if (isTyping(event.target)) return;
  // matching below
});

Выходите в самом начале обработчика, чтобы ничто ниже по цепочке, включая буфер последовательностей из следующего раздела, никогда не увидело набираемое нажатие.

Как обрабатывать клавиши-модификаторы на Mac и Windows?

Сочетание, проверяющее только event.ctrlKey, на Mac мертво, потому что клавиша Command устанавливает event.metaKey. Принимайте при сопоставлении любой из модификаторов:

const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 'k') { /* ... */ }

Это слегка «пересопоставляет» (Ctrl+K тоже сработает на Mac), что безвредно. Зато позволяет избежать определения платформы в пути сопоставления. navigator.platform задокументирован как ненадёжный для детекции, и единственное применение, которое одобряет MDN, — выбор между ⌘ и Ctrl при отображении сочетания пользователю. Пусть он там и остаётся:

Физическая клавишаСвойство событияОтображение
Command (macOS)metaKey
Control (Windows/Linux)ctrlKeyCtrl
Клавиша WindowsmetaKeyНе привязывать
const isMac = navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone';
const formatKeys = (keys: string[]) =>
  keys.map((k) => (k === 'mod' ? (isMac ? '' : 'Ctrl') : k)).join(isMac ? '' : '+');

Как поддержать последовательности клавиш вроде g, затем i?

Последовательность из двух клавиш требует буфера, тайм-аута, очищающего его, сброса, когда буфер перестаёт быть валидным префиксом, и повторной проверки «провинившейся» клавиши как начала новой последовательности. Если эту клавишу отбросить, пользователю придётся нажимать её дважды. Ещё два правила: игнорируйте события keydown, у которых event.repeat равно true, чтобы удерживаемая клавиша не забивала буфер, и игнорируйте нажатия только модификаторов (Shift, Control, Meta, Alt, AltGraph), иначе нажатие Shift перед аккордом отменит любую незавершённую последовательность.

БуферРезультат после добавления клавишиДействие
любойравен привязкевыполнить её, очистить буфер
любойпрефикс привязкисохранить буфер, перезапустить тайм-аут
длина > 1ничему не соответствуеточистить буфер, подать клавишу снова отдельно
длина 1ничему не соответствуеточистить буфер
любойсработал тайм-ауточистить буфер
type Binding = { keys: string[]; description: string; run: () => void };
const bindings: Binding[] = [
  { keys: ['g', 'i'], description: 'Go to inbox', run: () => navigate('/inbox') },
  { keys: ['g', 'p'], description: 'Go to projects', run: () => navigate('/projects') },
];
const MODIFIERS = new Set(['Control', 'Meta', 'Shift', 'Alt', 'AltGraph']);
let buffer: string[] = [];
let timer: ReturnType<typeof setTimeout> | undefined;

function reset() { buffer = []; clearTimeout(timer); }

function feed(key: string) {
  buffer.push(key);
  const exact = bindings.find(
    (b) => b.keys.length === buffer.length && b.keys.every((k, i) => k === buffer[i]),
  );
  if (exact) { exact.run(); reset(); return; }
  if (bindings.some((b) => buffer.every((k, i) => b.keys[i] === k))) {
    clearTimeout(timer);
    timer = setTimeout(reset, 800);
    return;
  }
  const retry = buffer.length > 1;
  reset();
  if (retry) feed(key);
}

document.addEventListener('keydown', (event) => {
  if (isTyping(event.target) || event.repeat || MODIFIERS.has(event.key)) return;
  if (event.metaKey || event.ctrlKey || event.altKey) return; // chords go elsewhere
  feed(event.key.toLowerCase());
});

Окно в 800 мс — это выбор, а не результат измерений; типичное значение — несколько сотен миллисекунд.

Как зарегистрировать и корректно удалить обработчик горячих клавиш в React?

В React добавляйте обработчик внутри useEffect и удаляйте ту же ссылку на функцию в функции очистки; если обработчик читает пропсы или состояние, мемоизируйте его через useCallback и укажите в массиве зависимостей эффекта. Без очистки каждое повторное монтирование добавляет ещё один обработчик, и одно нажатие выполняет действие дважды.

function useShortcuts(bindings: Binding[]) {
  const handleKeyDown = useCallback((event: KeyboardEvent) => {
    if (isTyping(event.target)) return;
    // match against bindings here
  }, [bindings]);

  useEffect(() => {
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);
}

Следите за зависимостью bindings в этом хуке. Если вызывающий код передаёт литерал массива прямо в вызове, то на каждом рендере это будет новый массив, поэтому идентичность handleKeyDown меняется, и эффект каждый раз удаляет и заново вешает обработчик. Ничего не ломается, но эта «болтанка» — пустая работа. Объявите массив на уровне модуля или оберните его в useMemo в вызывающем компоненте.

Начиная с React 18, Strict Mode в режиме разработки прогоняет каждый Effect через дополнительный цикл настройки и разрушения, поэтому очистка, удаляющая не ту ссылку, которую добавила, сразу проявляется как удвоенный обработчик. Вне React правило то же: один addEventListener, один соответствующий removeEventListener, одна и та же функция.

Делайте горячие клавиши доступными и обнаруживаемыми

К горячим клавишам относятся три специфических правила. Не привязывайте комбинации, зарезервированные браузером, включая Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+T и Tab, и не вызывайте preventDefault() на нативных редакторских аккордах вроде Cmd/Ctrl+C. Никогда не делайте сочетание клавиш единственным путём к функции; для того же действия должен существовать пункт меню или кнопка. А для односимвольных привязок WCAG 2.1 SC 2.1.4 Character Key Shortcuts (уровень A) требует одного из трёх: возможности отключить сочетание, возможности переназначить его так, чтобы оно включало клавишу вроде Ctrl или Alt, либо достаточно узкой области действия, чтобы оно срабатывало только тогда, когда фокус находится в его собственном компоненте.

Для обнаруживаемости привяжите ? к диалогу справки, который отрисовывает тот же массив bindings, что использует механизм сопоставления. Сопоставляйте по event.key === '?', а не по Shift плюс клавиша слеша, чтобы это работало и на раскладках, где ? находится на другой физической клавише.

if (event.key === '?' && !isTyping(event.target)) {
  event.preventDefault();
  helpDialog.showModal();
}

// inside the dialog
{bindings.map((b) => (
  <li key={b.keys.join(' ')}><kbd>{formatKeys(b.keys)}</kbd> {b.description}</li>
))}

showModal() на нативном <dialog> бесплатно даёт вам обработку Escape; об управлении фокусом внутри него см. руководство по распространённым проблемам доступности в модальных окнах.

Когда стоит взять библиотеку для горячих клавиш?

Как только привязок становится больше пары штук, области видимости, обнаружение конфликтов и обработку последовательностей стоит делегировать. TanStack Hotkeys — один из вариантов: клавиша Mod в привязке разрешается в Command на Mac и в Control везде остальном, а нажатия, адресованные сфокусированным полям ввода, пропускаются за вас. На обзорной странице библиотека всё ещё помечена как alpha с предупреждением, что API может измениться, так что фиксируйте версию и будьте готовы к изменениям.

Заключение

Горячие клавиши ломаются в предсказуемых местах: цель события, клавиша-модификатор, буфер последовательностей и жизненный цикл обработчика. Начните с защиты isTyping и сопоставления metaKey || ctrlKey в том обработчике, который у вас уже есть, а затем перенесите привязки в единый массив, чтобы механизм сопоставления и диалог справки по ? читали из одного источника.

Часто задаваемые вопросы

В чём разница между event.key и event.code для горячих клавиш?

event.key даёт символ, который производит клавиша с учётом раскладки клавиатуры и удерживаемых модификаторов, тогда как event.code называет физическую позицию клавиши и остаётся неизменным при любой раскладке. Сопоставляйте сочетания по event.key, чтобы привязка 'k' означала букву, напечатанную на клавише, на любой клавиатуре. Оставьте event.code для ввода на основе позиции, например WASD в играх. TanStack Hotkeys откатывается к event.code только для буквенных и цифровых клавиш и только когда event.key возвращает вместо них специальный символ, как это происходит при Option плюс буква в macOS.

Что использовать для горячих клавиш: keydown, keyup или keypress?

Используйте keydown. MDN помечает keypress как устаревший, и он срабатывает только для клавиш, производящих символ, поэтому никогда не сообщает об Escape, клавишах со стрелками или отдельно нажатом модификаторе. keydown срабатывает для каждой клавиши, предоставляет event.key и булевы флаги модификаторов и является тем событием, в котором preventDefault останавливает собственное действие браузера. keyup приходит уже после того, как браузер отреагировал на keydown, поэтому не может подавить нативное сочетание или вставленный символ.

Срабатывают ли горячие клавиши, когда пользователь печатает с помощью IME, например японского или китайского ввода?

Да. Обработчик keydown на уровне документа по-прежнему получает нажатия, пока IME формирует композицию, поэтому выходите досрочно, когда event.isComposing равно true. Этот флаг остаётся true для каждого события клавиатуры между моментом, когда IME открывает сессию композиции, и моментом, когда он её закрывает, — именно в этом окне ваши горячие клавиши должны не мешать. Защита isTyping покрывает большинство случаев, потому что композиция происходит в редактируемом элементе, но isComposing добавляет вторую проверку для кастомных текстовых поверхностей.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.