UNPKG

@keybindy/core

Version:

A lightweight and framework-agnostic keyboard shortcut manager for web apps. Define, register, and handle keybindings with ease.

361 lines (358 loc) 14.6 kB
import { expandAliases } from './utils/expandAliases.js'; import { normalizeKey } from './utils/normalizeKey.js'; import { generateUID } from './utils/generateUID.js'; import { ScopeManager } from './ScopeManager.js'; import { EventEmitter } from './utils/eventemitter.js'; import { warn, log } from './utils/log.js'; /** * Manages keyboard shortcuts with support for scopes, enabling/disabling, * dynamic registration, and cheat sheet generation. */ class ShortcutManager extends ScopeManager { shortcuts = []; pressedKeys = new Set(); typingEmitter = new EventEmitter(); activeSequences = []; onShortcutFired = () => { }; constructor(onShortcutFired) { if (typeof window === 'undefined') { throw new Error('[Keybindy] Unsupported environment'); } super(); this.onShortcutFired = onShortcutFired || (() => { }); this.start(); } start() { window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keyup', this.handleKeyUp); } /** * Disables all shortcuts in the specified scope or all scopes if no scope is provided. * @param scope - The scope to disable shortcuts in. */ disableAll(scope) { if (!scope) { this.shortcuts.forEach(s => (s.enabled = false)); return; } this.shortcuts.forEach(s => (s.options?.scope === scope ? (s.enabled = false) : null)); } /** * Enables all shortcuts in the specified scope or all scopes if no scope is provided. * @param scope - The scope to enable shortcuts in. */ enableAll(scope) { if (!scope) { this.shortcuts.forEach(s => (s.enabled = true)); return; } this.shortcuts.forEach(s => (s.options?.scope === scope ? (s.enabled = true) : null)); } /** * Registers a callback to be called when a key is typed. * @param callback - The callback function to be called. */ onTyping(callback) { return this.typingEmitter.on(callback); } /** * Handles `keydown` events, checks for matching shortcuts, * and triggers the appropriate handler. * @param e - The keyboard event object. * @private */ handleKeyDown = (e) => { const key = normalizeKey(e.code).toLowerCase(); const now = Date.now(); this.pressedKeys.add(key); // Emit typing event this.typingEmitter.emit({ key: e.key, event: e }); const triggeredShortcuts = new Set(); for (const shortcut of this.shortcuts) { const { options, enabled, keys: expectedKeys, handler } = shortcut; if (!enabled) continue; if (options?.scope && options.scope !== this.getActiveScope()) continue; const expected = expectedKeys.map(k => k.toLowerCase()); if (options?.sequential) { const delay = options.sequenceDelay ?? 1000; // Check if this shortcut sequence already has a buffer let seq = this.activeSequences.find(s => JSON.stringify(s.keys) === JSON.stringify(expected)); if (!seq) { // If this is a potential new sequence if (key === expected[0]) { seq = { keys: expected, buffer: [{ key, time: now }], }; this.activeSequences.push(seq); } } else { // Continue existing sequence seq.buffer.push({ key, time: now }); // Reset buffer if delay is exceeded seq.buffer = seq.buffer.filter(entry => now - entry.time <= delay); const pressedSeq = seq.buffer.map(entry => entry.key); const isMatch = expected.every((k, i) => pressedSeq[i] === k); const isExactMismatch = expected.length === pressedSeq.length && !isMatch; if (isExactMismatch) { this.clearSequence(seq.keys); continue; } if (seq.buffer.length > 1) { let minGap = Infinity; for (let i = 1; i < seq.buffer.length; i++) { const gap = seq.buffer[i].time - seq.buffer[i - 1].time; if (gap < minGap) minGap = gap; } // Avoid treating simultaneous press as sequential const MIN_SEQUENTIAL_GAP = 100; if (minGap < MIN_SEQUENTIAL_GAP) { this.clearSequence(seq.keys); continue; } } // Prevent firing sequential if keys are still pressed if (pressedSeq.length === expected.length && pressedSeq.every(k => this.pressedKeys.has(k))) { this.clearSequence(seq.keys); continue; } if (isMatch && expected.length === pressedSeq.length) { if (options.preventDefault) e.preventDefault(); handler(e); this.onShortcutFired(shortcut); triggeredShortcuts.add(shortcut.id); this.clearSequence(seq.keys); } } } else { // Simultaneous key check const allMatch = expected.every(k => this.pressedKeys.has(k)); const matchingSequential = this.activeSequences.find(seq => JSON.stringify(seq.keys) === JSON.stringify(expected)); if (allMatch && !matchingSequential) { if (options?.preventDefault) e.preventDefault(); handler(e); this.onShortcutFired(shortcut); return; } } } // Cleanup stale sequences that were not triggered this.activeSequences = this.activeSequences.filter(seq => { const delay = this.shortcuts.find(s => JSON.stringify(s.keys) === JSON.stringify(seq.keys))?.options ?.sequenceDelay ?? 1000; return now - seq.buffer[0]?.time <= delay; }); }; /** * Clears a sequence of keys from active sequences. * @param keys - The key combination to clear. * @private */ clearSequence(keys) { this.activeSequences = this.activeSequences.filter(s => JSON.stringify(s.keys) !== JSON.stringify(keys)); } /** * Handles `keyup` events by removing the released key from the pressed keys set. * @param e - The keyboard event object. * @private */ handleKeyUp = (e) => { const key = normalizeKey(e.code).toLowerCase(); this.pressedKeys.delete(key); }; /** * Registers a keyboard shortcut with the provided handler and options. * Duplicate bindings in the same scope are overwritten. * * @param keys - A key combination or list of combinations. * @param handler - Callback function to execute when shortcut is triggered. * @param options - Optional configuration including scope, ID, and metadata. */ register(keys, handler, options) { const bindings = Array.isArray(keys[0]) ? keys : [keys]; const id = options?.data?.id || generateUID(); for (const binding of bindings) { const expandedCombos = expandAliases(binding); for (const combo of expandedCombos) { const normalized = combo.map(k => k.toLowerCase()); // Remove duplicates this.shortcuts = this.shortcuts.filter(s => JSON.stringify(s.keys) !== JSON.stringify(normalized) || s.options?.scope !== (options?.scope || this.getActiveScope()) || s.id !== id); this.shortcuts.push({ id, keys: normalized, handler, options: { ...options, sequential: options?.sequential || false, sequenceDelay: options?.sequenceDelay || 1000, scope: options?.scope || this.getActiveScope(), }, enabled: true, }); this.pushScope(options?.scope ?? 'global'); } } } /** * Unregisters a previously registered shortcut based on the key combination and scope. * @param keys - The key combination to remove. * @param scope - The scope in which the shortcut was registered (default: "global"). */ unregister(keys, scope = 'global') { const expandedCombos = expandAliases(keys); for (const combo of expandedCombos) { const normalized = combo.map(k => k.toLowerCase()); this.shortcuts = this.shortcuts.filter(s => s.options?.scope !== scope || JSON.stringify(s.keys) !== JSON.stringify(normalized)); } } /** * Toggles the state (enabled/disabled) of a shortcut. * @param keys - The shortcut key combination. * @param scope - The scope to match against. * @param state - The new state (`true`, `false`, or `"toggle"`). * @private */ toggleState(keys, scope, state) { const expandedCombos = expandAliases(keys); let matched = false; for (const combo of expandedCombos) { const normalized = combo.map(k => k.toLowerCase()); this.shortcuts.forEach(s => { const sameScope = !s.options?.scope || s.options.scope === scope; const sameKeys = JSON.stringify(s.keys) === JSON.stringify(normalized); if (sameKeys && sameScope) { matched = true; s.enabled = state === 'toggle' ? !s.enabled : state; } }); } if (!matched) { warn(`No matching shortcut for ${JSON.stringify(keys)} in scope "${scope}"`); } } /** * Enables a specific shortcut based on key combination and scope. * @param keys - The key combination to enable. * @param scope - The target scope (default: "global"). */ enable(keys, scope = 'global') { this.toggleState(keys, scope, true); } /** * Disables a specific shortcut based on key combination and scope. * @param keys - The key combination to disable. * @param scope - The target scope (default: "global"). */ disable(keys, scope = 'global') { this.toggleState(keys, scope, false); } /** * Toggles a specific shortcut's enabled state based on key combination and scope. * @param keys - The key combination to toggle. * @param scope - The target scope (default: "global"). */ toggle(keys, scope = 'global') { this.toggleState(keys, scope, 'toggle'); } /** * Clears the internal state, removing all pressed keys and event listeners. * This does not unregister shortcuts. */ clear() { this.pressedKeys.clear(); window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keyup', this.handleKeyUp); log('Instance cleared'); } /** * Completely destroys the manager instance by clearing all listeners and shortcuts. * Prevents further registration of shortcuts. */ destroy() { this.clear(); this.shortcuts = []; this.resetScope(); this.activeSequences = []; log('Instance destroyed'); } /** * Generates a simplified cheat sheet of registered shortcuts for the current scope. * Useful for displaying in a UI. * * @param scope - Optional scope filter (default is the currently active scope). * @returns An array of objects containing key combos and associated data. */ getCheatSheet(scope = this.getActiveScope()) { const grouped = new Map(); for (const s of this.shortcuts) { if (s.options?.scope && s.options.scope !== scope) continue; const id = s.id; const keyCombo = s.keys .map(k => { if (k.startsWith('ctrl')) return 'ctrl'; if (k.startsWith('shift')) return 'shift'; if (k.startsWith('alt')) return 'alt'; if (k.startsWith('meta')) return 'meta'; return k; }) .join(s.options?.sequential ? ' → ' : ' + ') .toUpperCase(); if (!grouped.has(id)) { grouped.set(id, { keys: new Set([keyCombo]), data: s.options?.data ?? {}, }); } else { grouped.get(id).keys.add(keyCombo); } } return Array.from(grouped.values()).map(g => ({ keys: Array.from(g.keys), ...g.data, })); } /** * Returns detailed information about all shortcuts organized by scope. * * @param scope - Optional scope to filter by. If omitted, returns info for all scopes. * @returns A scope-specific breakdown of all registered shortcuts. */ getScopesInfo(scope) { const scopesMap = {}; for (const s of this.shortcuts) { const sScope = s.options?.scope || 'global'; if (scope && sScope !== scope) continue; if (!scopesMap[sScope]) { scopesMap[sScope] = { shortcuts: [] }; } scopesMap[sScope].shortcuts.push({ keys: s.keys.map(k => k.toUpperCase()), id: s.id, enabled: s.enabled ?? true, data: s.options?.data ?? {}, }); if (sScope === this.getActiveScope()) { scopesMap[sScope].isActive = true; } } return scope ? scopesMap[scope] || null : scopesMap; } } export { ShortcutManager };