Реализация горячих клавиш (Keyboard Shortcuts) в десктоп-приложении

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация горячих клавиш (Keyboard Shortcuts) в десктоп-приложении
Средняя
от 1 рабочего дня до 3 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация горячих клавиш (Keyboard Shortcuts) в десктоп-приложении

Горячие клавиши в десктоп-приложении работают на двух уровнях: локальный (только когда приложение в фокусе) и глобальный (работает поверх других окон). Electron и Tauri реализуют оба уровня по-разному, и правильный выбор зависит от требований. Рассмотрим реализацию на Electron с полноценной системой привязки клавиш, конфликтами и пользовательскими настройками.

Архитектура системы хоткеев

Хорошая система горячих клавиш состоит из нескольких слоёв:

  1. Registry — хранит все зарегистрированные привязки
  2. Parser — нормализует строки вида "Ctrl+Shift+K" в структуры
  3. Matcher — сравнивает события клавиатуры с привязками
  4. Scope — контекст, в котором активны привязки (глобально, только в редакторе и т.д.)
  5. 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 рабочих дня.