@pyreon/hotkeys 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @pyreon/hotkeys — Reactive keyboard shortcut management for Pyreon.
3
+ *
4
+ * Register global or scoped keyboard shortcuts with automatic lifecycle
5
+ * management. Supports modifier keys, aliases, input filtering, and
6
+ * scope-based activation.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { useHotkey, useHotkeyScope } from '@pyreon/hotkeys'
11
+ *
12
+ * // Global shortcut
13
+ * useHotkey('ctrl+s', () => save(), { description: 'Save' })
14
+ *
15
+ * // Scoped shortcut — only active when scope is enabled
16
+ * useHotkeyScope('editor')
17
+ * useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
18
+ *
19
+ * // Platform-aware — mod = ⌘ on Mac, Ctrl elsewhere
20
+ * useHotkey('mod+k', () => openCommandPalette())
21
+ * ```
22
+ */
23
+
24
+ // ─── Hooks ───────────────────────────────────────────────────────────────────
25
+
26
+ export { useHotkey } from './use-hotkey'
27
+ export { useHotkeyScope } from './use-hotkey-scope'
28
+
29
+ // ─── Imperative API ──────────────────────────────────────────────────────────
30
+
31
+ export {
32
+ disableScope,
33
+ enableScope,
34
+ getActiveScopes,
35
+ getRegisteredHotkeys,
36
+ registerHotkey,
37
+ } from './registry'
38
+
39
+ // ─── Utilities ───────────────────────────────────────────────────────────────
40
+
41
+ export { formatCombo, matchesCombo, parseShortcut } from './parse'
42
+
43
+ // ─── Types ───────────────────────────────────────────────────────────────────
44
+
45
+ export type { HotkeyEntry, HotkeyOptions, KeyCombo } from './types'
46
+
47
+ // ─── Testing ─────────────────────────────────────────────────────────────────
48
+
49
+ export { _resetHotkeys } from './registry'
package/src/parse.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { KeyCombo } from './types'
2
+
3
+ // ─── Key aliases ─────────────────────────────────────────────────────────────
4
+
5
+ const KEY_ALIASES: Record<string, string> = {
6
+ esc: 'escape',
7
+ return: 'enter',
8
+ del: 'delete',
9
+ ins: 'insert',
10
+ space: ' ',
11
+ spacebar: ' ',
12
+ up: 'arrowup',
13
+ down: 'arrowdown',
14
+ left: 'arrowleft',
15
+ right: 'arrowright',
16
+ plus: '+',
17
+ }
18
+
19
+ /**
20
+ * Parse a shortcut string like 'ctrl+shift+s' into a KeyCombo.
21
+ * Supports aliases (esc, del, space, etc.) and mod (ctrl on Windows/Linux, meta on Mac).
22
+ */
23
+ export function parseShortcut(shortcut: string): KeyCombo {
24
+ const parts = shortcut.toLowerCase().trim().split('+')
25
+ const combo: KeyCombo = {
26
+ ctrl: false,
27
+ shift: false,
28
+ alt: false,
29
+ meta: false,
30
+ key: '',
31
+ }
32
+
33
+ for (const part of parts) {
34
+ const p = part.trim()
35
+ if (p === 'ctrl' || p === 'control') {
36
+ combo.ctrl = true
37
+ } else if (p === 'shift') {
38
+ combo.shift = true
39
+ } else if (p === 'alt') {
40
+ combo.alt = true
41
+ } else if (p === 'meta' || p === 'cmd' || p === 'command') {
42
+ combo.meta = true
43
+ } else if (p === 'mod') {
44
+ // mod = meta on Mac, ctrl elsewhere
45
+ if (isMac()) {
46
+ combo.meta = true
47
+ } else {
48
+ combo.ctrl = true
49
+ }
50
+ } else {
51
+ combo.key = KEY_ALIASES[p] ?? p
52
+ }
53
+ }
54
+
55
+ return combo
56
+ }
57
+
58
+ /**
59
+ * Check if a KeyboardEvent matches a KeyCombo.
60
+ */
61
+ export function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {
62
+ if (event.ctrlKey !== combo.ctrl) return false
63
+ if (event.shiftKey !== combo.shift) return false
64
+ if (event.altKey !== combo.alt) return false
65
+ if (event.metaKey !== combo.meta) return false
66
+
67
+ const eventKey = event.key.toLowerCase()
68
+ return eventKey === combo.key
69
+ }
70
+
71
+ /**
72
+ * Format a KeyCombo back to a human-readable string.
73
+ */
74
+ export function formatCombo(combo: KeyCombo): string {
75
+ const parts: string[] = []
76
+ if (combo.ctrl) parts.push('Ctrl')
77
+ if (combo.shift) parts.push('Shift')
78
+ if (combo.alt) parts.push('Alt')
79
+ if (combo.meta) parts.push(isMac() ? '⌘' : 'Meta')
80
+ parts.push(
81
+ combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key),
82
+ )
83
+ return parts.join('+')
84
+ }
85
+
86
+ function capitalize(s: string): string {
87
+ return s.charAt(0).toUpperCase() + s.slice(1)
88
+ }
89
+
90
+ function isMac(): boolean {
91
+ if (typeof navigator === 'undefined') return false
92
+ return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)
93
+ }
@@ -0,0 +1,151 @@
1
+ import type { Signal } from '@pyreon/reactivity'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import { matchesCombo, parseShortcut } from './parse'
4
+ import type { HotkeyEntry, HotkeyOptions } from './types'
5
+
6
+ // ─── State ───────────────────────────────────────────────────────────────────
7
+
8
+ const entries: HotkeyEntry[] = []
9
+ const activeScopes = signal<Set<string>>(new Set(['global']))
10
+ let listenerAttached = false
11
+
12
+ // ─── Input detection ─────────────────────────────────────────────────────────
13
+
14
+ const INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
15
+
16
+ function isInputFocused(event: KeyboardEvent): boolean {
17
+ const target = event.target as HTMLElement | null
18
+ if (!target) return false
19
+ if (INPUT_TAGS.has(target.tagName)) return true
20
+ if (target.isContentEditable) return true
21
+ return false
22
+ }
23
+
24
+ // ─── Global listener ─────────────────────────────────────────────────────────
25
+
26
+ function attachListener(): void {
27
+ if (listenerAttached) return
28
+ if (typeof window === 'undefined') return
29
+ listenerAttached = true
30
+
31
+ window.addEventListener('keydown', (event) => {
32
+ const scopes = activeScopes.peek()
33
+
34
+ for (const entry of entries) {
35
+ // Check scope
36
+ if (!scopes.has(entry.options.scope)) continue
37
+
38
+ // Check enabled
39
+ const enabled =
40
+ typeof entry.options.enabled === 'function'
41
+ ? entry.options.enabled()
42
+ : entry.options.enabled
43
+ if (!enabled) continue
44
+
45
+ // Check input focus
46
+ if (!entry.options.enableOnInputs && isInputFocused(event)) continue
47
+
48
+ // Check key match
49
+ if (!matchesCombo(event, entry.combo)) continue
50
+
51
+ // Match found
52
+ if (entry.options.preventDefault) event.preventDefault()
53
+ if (entry.options.stopPropagation) event.stopPropagation()
54
+ entry.handler(event)
55
+ }
56
+ })
57
+ }
58
+
59
+ // ─── Registration ────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Register a keyboard shortcut. Returns an unregister function.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const unregister = registerHotkey('ctrl+s', (e) => save(), { description: 'Save' })
67
+ * // later: unregister()
68
+ * ```
69
+ */
70
+ export function registerHotkey(
71
+ shortcut: string,
72
+ handler: (event: KeyboardEvent) => void,
73
+ options?: HotkeyOptions,
74
+ ): () => void {
75
+ attachListener()
76
+
77
+ const entry: HotkeyEntry = {
78
+ shortcut,
79
+ combo: parseShortcut(shortcut),
80
+ handler,
81
+ options: {
82
+ scope: options?.scope ?? 'global',
83
+ preventDefault: options?.preventDefault !== false,
84
+ stopPropagation: options?.stopPropagation === true,
85
+ enableOnInputs: options?.enableOnInputs === true,
86
+ enabled: options?.enabled ?? true,
87
+ description: options?.description,
88
+ },
89
+ }
90
+
91
+ entries.push(entry)
92
+
93
+ return () => {
94
+ const idx = entries.indexOf(entry)
95
+ if (idx !== -1) entries.splice(idx, 1)
96
+ }
97
+ }
98
+
99
+ // ─── Scope management ────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Activate a hotkey scope. 'global' is always active.
103
+ */
104
+ export function enableScope(scope: string): void {
105
+ const current = activeScopes.peek()
106
+ if (current.has(scope)) return
107
+ const next = new Set(current)
108
+ next.add(scope)
109
+ activeScopes.set(next)
110
+ }
111
+
112
+ /**
113
+ * Deactivate a hotkey scope. Cannot deactivate 'global'.
114
+ */
115
+ export function disableScope(scope: string): void {
116
+ if (scope === 'global') return
117
+ const current = activeScopes.peek()
118
+ if (!current.has(scope)) return
119
+ const next = new Set(current)
120
+ next.delete(scope)
121
+ activeScopes.set(next)
122
+ }
123
+
124
+ /**
125
+ * Get the currently active scopes as a reactive signal.
126
+ */
127
+ export function getActiveScopes(): Signal<Set<string>> {
128
+ return activeScopes
129
+ }
130
+
131
+ /**
132
+ * Get all registered hotkeys (for building help dialogs).
133
+ */
134
+ export function getRegisteredHotkeys(): ReadonlyArray<{
135
+ shortcut: string
136
+ scope: string
137
+ description?: string
138
+ }> {
139
+ return entries.map((e) => ({
140
+ shortcut: e.shortcut,
141
+ scope: e.options.scope,
142
+ description: e.options.description,
143
+ }))
144
+ }
145
+
146
+ // ─── Reset (for testing) ────────────────────────────────────────────────────
147
+
148
+ export function _resetHotkeys(): void {
149
+ entries.length = 0
150
+ activeScopes.set(new Set(['global']))
151
+ }