From 61c1d9332d68ca8a9c2dd727d22c55096d23474b Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 26 Feb 2026 23:39:09 +0100 Subject: [PATCH] feat(shortcuts): add event.code-based shortcut module Replace the character-based @github/hotkey matching with a custom module that matches against event.code (physical key position). This makes shortcuts layout-independent so they work on non-Latin keyboard layouts (Russian, Greek, Arabic, etc.). The module provides: - install/uninstall for declarative element shortcuts (v-shortcut) - eventToShortcutString for imperative event matching - Sequence support with 1500ms timeout - Form field, IME, shadow DOM, and event.repeat guards --- frontend/src/helpers/shortcut.ts | 207 +++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 frontend/src/helpers/shortcut.ts diff --git a/frontend/src/helpers/shortcut.ts b/frontend/src/helpers/shortcut.ts new file mode 100644 index 000000000..8df3319e7 --- /dev/null +++ b/frontend/src/helpers/shortcut.ts @@ -0,0 +1,207 @@ +import {isAppleDevice} from '@/helpers/isAppleDevice' + +// --- Types --- + +interface ParsedKey { + code: string + ctrl: boolean + alt: boolean + shift: boolean + meta: boolean + mod: boolean +} + +// --- Core functions --- + +function parseKey(keyStr: string): ParsedKey { + const parts = keyStr.split('+') + const code = parts.pop() || '' + const modifiers = new Set(parts.map(m => m.toLowerCase())) + + return { + code, + ctrl: modifiers.has('control'), + alt: modifiers.has('alt'), + shift: modifiers.has('shift'), + meta: modifiers.has('meta'), + mod: modifiers.has('mod'), + } +} + +function matchesKey(event: KeyboardEvent, parsed: ParsedKey): boolean { + if (event.code !== parsed.code) return false + + const isMac = isAppleDevice() + + const wantCtrl = parsed.ctrl || (!isMac && parsed.mod) + const wantMeta = parsed.meta || (isMac && parsed.mod) + + if (event.ctrlKey !== wantCtrl) return false + if (event.altKey !== parsed.alt) return false + if (event.shiftKey !== parsed.shift) return false + if (event.metaKey !== wantMeta) return false + + return true +} + +/** + * Convert a KeyboardEvent to a normalized shortcut string (event.code-based). + * Replacement for eventToHotkeyString from @github/hotkey. + * + * Examples: + * Ctrl+K press -> 'Control+KeyK' + * Cmd+K press -> 'Meta+KeyK' + * plain T -> 'KeyT' + * Shift+Delete -> 'Shift+Delete' + */ +export function eventToShortcutString(event: KeyboardEvent): string { + // Skip modifier-only keys + if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) { + return '' + } + + const parts: string[] = [] + + if (event.ctrlKey) parts.push('Control') + if (event.altKey) parts.push('Alt') + if (event.shiftKey) parts.push('Shift') + if (event.metaKey) parts.push('Meta') + + parts.push(event.code) + + return parts.join('+') +} + +// --- Form field detection --- + +function isFormField(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + + const tagName = target.tagName.toLowerCase() + if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') return true + if (target.contentEditable === 'true') return true + + return false +} + +// --- Install / Uninstall --- + +const SEQUENCE_TIMEOUT = 1500 + +interface Binding { + keys: ParsedKey[][] + el: HTMLElement +} + +const bindings = new Set() +let sequenceBuffer: string[] = [] +let sequenceTimer: ReturnType | null = null + +function resetSequence() { + sequenceBuffer = [] + if (sequenceTimer !== null) { + clearTimeout(sequenceTimer) + sequenceTimer = null + } +} + +function globalKeydownHandler(event: KeyboardEvent) { + if (event.defaultPrevented) return + if (event.isComposing) return + if (event.repeat) return + + const target = (event as any).explicitOriginalTarget || event.target + if (target?.shadowRoot) return + if (isFormField(target)) return + + for (const binding of bindings) { + for (const sequence of binding.keys) { + if (sequence.length === 1) { + // Single-key shortcut + if (matchesKey(event, sequence[0])) { + event.preventDefault() + binding.el.click() + resetSequence() + return + } + } else { + // Sequence shortcut (e.g. 'KeyG KeyO') + const stepIndex = sequenceBuffer.length + if (stepIndex < sequence.length && matchesKey(event, sequence[stepIndex])) { + sequenceBuffer.push(event.code) + + if (sequenceTimer !== null) { + clearTimeout(sequenceTimer) + } + sequenceTimer = setTimeout(resetSequence, SEQUENCE_TIMEOUT) + + if (sequenceBuffer.length === sequence.length) { + event.preventDefault() + binding.el.click() + resetSequence() + return + } + + // Partial match — consume the event + event.preventDefault() + return + } + } + } + } + + // No match for any sequence step — reset + if (sequenceBuffer.length > 0) { + resetSequence() + } +} + +let listenerInstalled = false + +function ensureListener() { + if (!listenerInstalled) { + document.addEventListener('keydown', globalKeydownHandler) + listenerInstalled = true + } +} + +function maybeRemoveListener() { + if (bindings.size === 0 && listenerInstalled) { + document.removeEventListener('keydown', globalKeydownHandler) + listenerInstalled = false + } +} + +/** + * Install a shortcut on an element -- clicking it when shortcut fires. + * Handles sequences (space-separated keys like 'KeyG KeyO'). + */ +export function install(el: HTMLElement, shortcut: string): void { + const sequences = shortcut.split(' ').reduce((acc, part) => { + // Each space-separated token is a step in the sequence + if (acc.length === 0) acc.push([]) + acc[0].push(part) + return acc + }, []) + + const keys = sequences.map(seq => seq.map(parseKey)) + + const binding: Binding = {keys, el} + bindings.add(binding) + ;(el as any).__shortcutBinding = binding + + ensureListener() +} + +/** + * Remove an element's shortcut binding. + */ +export function uninstall(el: HTMLElement): void { + const binding = (el as any).__shortcutBinding as Binding | undefined + if (binding) { + bindings.delete(binding) + delete (el as any).__shortcutBinding + } + + maybeRemoveListener() +}