@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/LICENSE +21 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +221 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +198 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +130 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +52 -0
- package/src/index.ts +49 -0
- package/src/parse.ts +93 -0
- package/src/registry.ts +151 -0
- package/src/tests/hotkeys.test.ts +461 -0
- package/src/types.ts +54 -0
- package/src/use-hotkey-scope.ts +20 -0
- package/src/use-hotkey.ts +26 -0
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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|