Реализация горячих клавиш (Keyboard Shortcuts) в десктоп-приложении
Горячие клавиши в десктоп-приложении работают на двух уровнях: локальный (только когда приложение в фокусе) и глобальный (работает поверх других окон). Electron и Tauri реализуют оба уровня по-разному, и правильный выбор зависит от требований. Рассмотрим реализацию на Electron с полноценной системой привязки клавиш, конфликтами и пользовательскими настройками.
Архитектура системы хоткеев
Хорошая система горячих клавиш состоит из нескольких слоёв:
- Registry — хранит все зарегистрированные привязки
-
Parser — нормализует строки вида
"Ctrl+Shift+K"в структуры - Matcher — сравнивает события клавиатуры с привязками
- Scope — контекст, в котором активны привязки (глобально, только в редакторе и т.д.)
- Persistence — сохранение пользовательских настроек
// src/shortcuts/types.ts
export interface KeyCombo {
key: string // e.g. 'k', 'F5', 'Delete'
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean // Cmd на macOS
}
export interface ShortcutDefinition {
id: string
combo: KeyCombo | KeyCombo[] // массив = chord (последовательность)
scope: string
label: string
action: () => void
allowInInput?: boolean // разрешить при фокусе в input
}
export interface ShortcutConflict {
existing: ShortcutDefinition
incoming: ShortcutDefinition
}
Парсер и нормализация
// src/shortcuts/parser.ts
const KEY_ALIASES: Record<string, string> = {
'esc': 'Escape',
'del': 'Delete',
'ins': 'Insert',
'return': 'Enter',
'space': ' ',
'up': 'ArrowUp',
'down': 'ArrowDown',
'left': 'ArrowLeft',
'right': 'ArrowRight',
}
export function parseCombo(input: string): KeyCombo {
const parts = input.toLowerCase().split('+').map(p => p.trim())
const combo: KeyCombo = { key: '' }
for (const part of parts) {
switch (part) {
case 'ctrl': combo.ctrl = true; break
case 'shift': combo.shift = true; break
case 'alt': combo.alt = true; break
case 'meta':
case 'cmd':
case 'mod': combo.meta = true; break
default:
combo.key = KEY_ALIASES[part] ?? part.length === 1
? part.toUpperCase()
: part.charAt(0).toUpperCase() + part.slice(1)
}
}
return combo
}
export function comboToString(combo: KeyCombo): string {
const parts: string[] = []
const isMac = process.platform === 'darwin'
if (combo.ctrl) parts.push(isMac ? '⌃' : 'Ctrl')
if (combo.alt) parts.push(isMac ? '⌥' : 'Alt')
if (combo.shift) parts.push(isMac ? '⇧' : 'Shift')
if (combo.meta) parts.push(isMac ? '⌘' : 'Win')
parts.push(combo.key)
return parts.join(isMac ? '' : '+')
}
export function matchesEvent(combo: KeyCombo, event: KeyboardEvent): boolean {
return (
event.key === combo.key &&
!!event.ctrlKey === !!combo.ctrl &&
!!event.shiftKey === !!combo.shift &&
!!event.altKey === !!combo.alt &&
!!event.metaKey === !!combo.meta
)
}
Registry — центральный реестр привязок
// src/shortcuts/registry.ts
import { ShortcutDefinition, ShortcutConflict, KeyCombo } from './types'
import { parseCombo, matchesEvent } from './parser'
export class ShortcutRegistry {
private shortcuts = new Map<string, ShortcutDefinition>()
private chordBuffer: string[] = []
private chordTimer: ReturnType<typeof setTimeout> | null = null
private readonly CHORD_TIMEOUT_MS = 1500
register(def: ShortcutDefinition): ShortcutConflict | null {
const conflict = this.findConflict(def)
if (conflict) return conflict
this.shortcuts.set(def.id, def)
return null
}
unregister(id: string) {
this.shortcuts.delete(id)
}
updateCombo(id: string, newCombo: KeyCombo | string) {
const def = this.shortcuts.get(id)
if (!def) return
const parsed = typeof newCombo === 'string' ? parseCombo(newCombo) : newCombo
this.shortcuts.set(id, { ...def, combo: parsed })
}
private findConflict(incoming: ShortcutDefinition): ShortcutConflict | null {
for (const [, existing] of this.shortcuts) {
if (existing.scope !== incoming.scope) continue
const existingCombos = Array.isArray(existing.combo)
? existing.combo
: [existing.combo]
const incomingCombos = Array.isArray(incoming.combo)
? incoming.combo
: [incoming.combo]
for (const ec of existingCombos) {
for (const ic of incomingCombos) {
if (JSON.stringify(ec) === JSON.stringify(ic)) {
return { existing, incoming }
}
}
}
}
return null
}
handleKeyEvent(event: KeyboardEvent, currentScope: string): boolean {
// Проверяем, находится ли фокус в input/textarea/contenteditable
const target = event.target as HTMLElement
const isInputFocused =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
for (const [, def] of this.shortcuts) {
if (def.scope !== currentScope && def.scope !== 'global') continue
if (isInputFocused && !def.allowInInput) continue
const combos = Array.isArray(def.combo) ? def.combo : [def.combo]
if (combos.length === 1) {
// Простая комбинация
if (matchesEvent(combos[0], event)) {
event.preventDefault()
def.action()
return true
}
} else {
// Chord: последовательность клавиш
// Реализация ниже
}
}
return false
}
getAll(): ShortcutDefinition[] {
return Array.from(this.shortcuts.values())
}
}
Глобальные хоткеи в Electron
Глобальные горячие клавиши регистрируются через globalShortcut в main-процессе и работают даже когда окно не в фокусе:
// main/global-shortcuts.ts
import { globalShortcut, BrowserWindow } from 'electron'
export function registerGlobalShortcuts(win: BrowserWindow) {
// Показать/скрыть окно (как Spotlight)
globalShortcut.register('CommandOrControl+Shift+Space', () => {
if (win.isVisible()) {
win.hide()
} else {
win.show()
win.focus()
}
})
// Быстрая заметка
globalShortcut.register('CommandOrControl+Shift+N', () => {
win.webContents.send('shortcut:quick-note')
})
return () => globalShortcut.unregisterAll()
}
На macOS CommandOrControl автоматически маппится на Command, на Windows/Linux — на Ctrl. Список поддерживаемых модификаторов и ключей: https://www.electronjs.org/docs/latest/api/accelerator
Важно: регистрировать globalShortcut нужно после события app.whenReady(), иначе они не сработают.
Хук для renderer-процесса
// src/hooks/useShortcuts.ts
import { useEffect, useRef } from 'react'
import { ShortcutRegistry } from '../shortcuts/registry'
import { ShortcutDefinition } from '../shortcuts/types'
const registry = new ShortcutRegistry()
export function useShortcuts(
shortcuts: Omit<ShortcutDefinition, 'id'>[],
scope: string
) {
const scopeRef = useRef(scope)
scopeRef.current = scope
useEffect(() => {
const ids: string[] = []
shortcuts.forEach((s, i) => {
const id = `${scope}-${i}-${Date.now()}`
const conflict = registry.register({ ...s, id, scope })
if (conflict) {
console.warn(
`Shortcut conflict: "${s.label}" conflicts with "${conflict.existing.label}"`
)
return
}
ids.push(id)
})
const handler = (e: KeyboardEvent) => {
registry.handleKeyEvent(e, scopeRef.current)
}
document.addEventListener('keydown', handler)
return () => {
document.removeEventListener('keydown', handler)
ids.forEach(id => registry.unregister(id))
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}
// Использование в компоненте:
// useShortcuts([
// { combo: parseCombo('Ctrl+S'), label: 'Сохранить', action: handleSave, scope: 'editor' },
// { combo: parseCombo('Ctrl+Z'), label: 'Отменить', action: handleUndo, scope: 'editor' },
// ], 'editor')
Пользовательские настройки и персистентность
// main/shortcut-store.ts
import Store from 'electron-store'
import { KeyCombo } from '../src/shortcuts/types'
interface ShortcutStore {
customBindings: Record<string, KeyCombo>
}
const store = new Store<ShortcutStore>({
defaults: { customBindings: {} },
})
export function getCustomBinding(id: string): KeyCombo | null {
return store.get(`customBindings.${id}`, null)
}
export function saveCustomBinding(id: string, combo: KeyCombo) {
store.set(`customBindings.${id}`, combo)
}
export function resetAllBindings() {
store.delete('customBindings')
}
При запуске приложения загружаем сохранённые привязки и применяем их к реестру:
// main/index.ts — после создания окна
import { getCustomBinding } from './shortcut-store'
import { registry } from './registry-singleton'
for (const def of registry.getAll()) {
const custom = getCustomBinding(def.id)
if (custom) registry.updateCombo(def.id, custom)
}
UI для переназначения клавиш
Компонент записи новой комбинации клавиш ("key capture"):
// src/components/KeyCapture.tsx
import { useState, useRef, useCallback } from 'react'
import { KeyCombo } from '../shortcuts/types'
import { comboToString, matchesEvent } from '../shortcuts/parser'
interface KeyCaptureProps {
value: KeyCombo
onChange: (combo: KeyCombo) => void
}
export function KeyCapture({ value, onChange }: KeyCaptureProps) {
const [capturing, setCapturing] = useState(false)
const [preview, setPreview] = useState<KeyCombo | null>(null)
const ref = useRef<HTMLButtonElement>(null)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
// Игнорируем одиночные модификаторы
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
const newCombo: KeyCombo = {
key: e.key,
ctrl: e.ctrlKey,
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey,
}
setPreview(newCombo)
onChange(newCombo)
setCapturing(false)
ref.current?.blur()
}, [onChange])
return (
<button
ref={ref}
className={`
px-3 py-1.5 rounded border font-mono text-sm
${capturing
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-200'
: 'border-gray-300 bg-white hover:border-gray-400'
}
`}
onClick={() => setCapturing(true)}
onKeyDown={capturing ? handleKeyDown : undefined}
onBlur={() => setCapturing(false)}
>
{capturing ? 'Нажмите комбинацию…' : comboToString(preview ?? value)}
</button>
)
}
Типичные сроки
Базовая реализация с локальными хоткеями через addEventListener — 3–4 часа. Полная система с реестром, scope-ами, chord-последовательностями, глобальными хоткеями в Electron, UI для переназначения, персистентностью и unit-тестами — 3–4 рабочих дня.







