@pyreon/hotkeys 0.24.4 → 0.24.6
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/package.json +5 -7
- package/src/index.ts +0 -49
- package/src/manifest.ts +0 -119
- package/src/parse.ts +0 -91
- package/src/registry.ts +0 -176
- package/src/tests/hooks-coverage.test.ts +0 -120
- package/src/tests/hotkeys-coverage.test.ts +0 -192
- package/src/tests/hotkeys.test.ts +0 -521
- package/src/tests/manifest-snapshot.test.ts +0 -69
- package/src/types.ts +0 -50
- package/src/use-hotkey-scope.ts +0 -20
- package/src/use-hotkey.ts +0 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/hotkeys",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.6",
|
|
4
4
|
"description": "Reactive keyboard shortcut management for Pyreon — scope-aware, conflict detection",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/hotkeys#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
17
|
"!lib/**/*.map",
|
|
18
|
-
"src",
|
|
19
18
|
"README.md",
|
|
20
19
|
"LICENSE"
|
|
21
20
|
],
|
|
@@ -26,7 +25,6 @@
|
|
|
26
25
|
"types": "./lib/types/index.d.ts",
|
|
27
26
|
"exports": {
|
|
28
27
|
".": {
|
|
29
|
-
"bun": "./src/index.ts",
|
|
30
28
|
"import": "./lib/index.js",
|
|
31
29
|
"types": "./lib/types/index.d.ts"
|
|
32
30
|
}
|
|
@@ -43,13 +41,13 @@
|
|
|
43
41
|
},
|
|
44
42
|
"devDependencies": {
|
|
45
43
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
46
|
-
"@pyreon/core": "^0.24.
|
|
44
|
+
"@pyreon/core": "^0.24.6",
|
|
47
45
|
"@pyreon/manifest": "0.13.1",
|
|
48
|
-
"@pyreon/reactivity": "^0.24.
|
|
46
|
+
"@pyreon/reactivity": "^0.24.6",
|
|
49
47
|
"@vitus-labs/tools-lint": "^2.4.0"
|
|
50
48
|
},
|
|
51
49
|
"dependencies": {
|
|
52
|
-
"@pyreon/core": "^0.24.
|
|
53
|
-
"@pyreon/reactivity": "^0.24.
|
|
50
|
+
"@pyreon/core": "^0.24.6",
|
|
51
|
+
"@pyreon/reactivity": "^0.24.6"
|
|
54
52
|
}
|
|
55
53
|
}
|
package/src/index.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
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/manifest.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { defineManifest } from '@pyreon/manifest'
|
|
2
|
-
|
|
3
|
-
export default defineManifest({
|
|
4
|
-
name: '@pyreon/hotkeys',
|
|
5
|
-
title: 'Keyboard Shortcuts',
|
|
6
|
-
tagline:
|
|
7
|
-
'Keyboard shortcut management — scope-aware, modifier keys, conflict detection',
|
|
8
|
-
description:
|
|
9
|
-
'Reactive keyboard shortcut management for Pyreon. Register global or scoped shortcuts with automatic lifecycle management. Supports `mod` alias (Command on Mac, Ctrl elsewhere), multi-key combos, scope-based activation for context-aware shortcuts, and conflict detection. Component-scoped hooks auto-unregister on unmount. Imperative API available for non-component contexts.',
|
|
10
|
-
category: 'universal',
|
|
11
|
-
longExample: `import { useHotkey, useHotkeyScope, registerHotkey, getRegisteredHotkeys, enableScope, disableScope } from '@pyreon/hotkeys'
|
|
12
|
-
|
|
13
|
-
// Global shortcut — auto-unregisters on unmount
|
|
14
|
-
useHotkey('mod+s', (e) => {
|
|
15
|
-
e.preventDefault() // prevent browser save dialog
|
|
16
|
-
save()
|
|
17
|
-
}, { description: 'Save document' })
|
|
18
|
-
|
|
19
|
-
// Platform-aware: mod = ⌘ on Mac, Ctrl on Windows/Linux
|
|
20
|
-
useHotkey('mod+k', () => openCommandPalette())
|
|
21
|
-
|
|
22
|
-
// Multi-key combo
|
|
23
|
-
useHotkey('ctrl+shift+p', () => openPreferences())
|
|
24
|
-
|
|
25
|
-
// Scoped shortcuts — only active when scope is enabled
|
|
26
|
-
useHotkeyScope('editor') // activates 'editor' scope for this component's lifetime
|
|
27
|
-
|
|
28
|
-
useHotkey('ctrl+z', () => undo(), { scope: 'editor', description: 'Undo' })
|
|
29
|
-
useHotkey('ctrl+shift+z', () => redo(), { scope: 'editor', description: 'Redo' })
|
|
30
|
-
useHotkey('ctrl+d', () => duplicateLine(), { scope: 'editor' })
|
|
31
|
-
|
|
32
|
-
// Imperative API — for non-component contexts (stores, middleware)
|
|
33
|
-
const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
|
|
34
|
-
// unregister() when done
|
|
35
|
-
|
|
36
|
-
// Introspection
|
|
37
|
-
const hotkeys = getRegisteredHotkeys() // all registered shortcuts
|
|
38
|
-
enableScope('modal') // programmatically enable a scope
|
|
39
|
-
disableScope('editor') // programmatically disable a scope
|
|
40
|
-
|
|
41
|
-
// Shortcuts can filter input elements — by default, shortcuts
|
|
42
|
-
// don't fire when focused on <input>, <textarea>, <select>.
|
|
43
|
-
// Override with:
|
|
44
|
-
useHotkey('escape', () => closeModal(), { enableOnFormElements: true })`,
|
|
45
|
-
features: [
|
|
46
|
-
'useHotkey(shortcut, handler, options?) — component-scoped, auto-unregisters on unmount',
|
|
47
|
-
'useHotkeyScope(scope) — activate a scope for a component\'s lifetime',
|
|
48
|
-
'mod alias — Command on Mac, Ctrl elsewhere',
|
|
49
|
-
'Scope-based activation for context-aware shortcuts',
|
|
50
|
-
'Imperative API: registerHotkey, enableScope, disableScope, getRegisteredHotkeys',
|
|
51
|
-
'parseShortcut / matchesCombo / formatCombo utilities',
|
|
52
|
-
],
|
|
53
|
-
api: [
|
|
54
|
-
{
|
|
55
|
-
name: 'useHotkey',
|
|
56
|
-
kind: 'hook',
|
|
57
|
-
signature:
|
|
58
|
-
'(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => void',
|
|
59
|
-
summary:
|
|
60
|
-
'Register a keyboard shortcut that auto-unregisters when the component unmounts. Shortcut format: `mod+s`, `ctrl+shift+p`, `escape`, etc. `mod` is Command on Mac, Ctrl elsewhere. By default, shortcuts don\'t fire when focused on form elements (input, textarea, select) — override with `enableOnFormElements: true`. Supports `scope` option for context-aware activation and `description` for introspection.',
|
|
61
|
-
example: `useHotkey('mod+s', (e) => {
|
|
62
|
-
e.preventDefault()
|
|
63
|
-
save()
|
|
64
|
-
}, { description: 'Save' })
|
|
65
|
-
|
|
66
|
-
useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
|
|
67
|
-
useHotkey('escape', () => close(), { enableOnFormElements: true })`,
|
|
68
|
-
mistakes: [
|
|
69
|
-
'Forgetting e.preventDefault() for browser-reserved shortcuts (mod+s, mod+p) — the browser dialog fires alongside your handler',
|
|
70
|
-
'Registering the same shortcut in overlapping scopes without priority — both handlers fire; use scope isolation to prevent conflicts',
|
|
71
|
-
'Using useHotkey outside a component body — the onUnmount cleanup requires an active component setup context',
|
|
72
|
-
'Not activating the scope — useHotkey with a scope option does nothing unless useHotkeyScope(scope) is called or enableScope(scope) is invoked',
|
|
73
|
-
],
|
|
74
|
-
seeAlso: ['useHotkeyScope', 'registerHotkey'],
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
name: 'useHotkeyScope',
|
|
78
|
-
kind: 'hook',
|
|
79
|
-
signature: '(scope: string) => void',
|
|
80
|
-
summary:
|
|
81
|
-
'Activate a hotkey scope for the lifetime of the current component. When the component mounts, the scope is enabled; when it unmounts, the scope is disabled. Shortcuts registered with a matching `scope` option only fire when the scope is active. Multiple components can activate the same scope — it stays active until the last one unmounts.',
|
|
82
|
-
example: `// In an editor component:
|
|
83
|
-
useHotkeyScope('editor')
|
|
84
|
-
useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
|
|
85
|
-
|
|
86
|
-
// In a modal component:
|
|
87
|
-
useHotkeyScope('modal')
|
|
88
|
-
useHotkey('escape', () => close(), { scope: 'modal' })`,
|
|
89
|
-
mistakes: [
|
|
90
|
-
'Using useHotkeyScope outside a component body — the lifecycle hooks require an active setup context',
|
|
91
|
-
'Assuming scope deactivation is immediate on unmount — if another component also activated the scope, it stays active',
|
|
92
|
-
],
|
|
93
|
-
seeAlso: ['useHotkey', 'enableScope', 'disableScope'],
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
name: 'registerHotkey',
|
|
97
|
-
kind: 'function',
|
|
98
|
-
signature:
|
|
99
|
-
'(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => () => void',
|
|
100
|
-
summary:
|
|
101
|
-
'Imperative hotkey registration for non-component contexts (stores, global setup). Returns an unregister function. Unlike useHotkey, this does NOT auto-cleanup on unmount — caller is responsible for calling the returned unregister function.',
|
|
102
|
-
example: `const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
|
|
103
|
-
// Later:
|
|
104
|
-
unregister()`,
|
|
105
|
-
seeAlso: ['useHotkey'],
|
|
106
|
-
},
|
|
107
|
-
],
|
|
108
|
-
gotchas: [
|
|
109
|
-
'By default, shortcuts do NOT fire when focused on form elements (input, textarea, select). Pass `enableOnFormElements: true` in options to override. Escape is a common candidate for this override.',
|
|
110
|
-
{
|
|
111
|
-
label: 'mod alias',
|
|
112
|
-
note: '`mod` maps to Command on macOS, Ctrl on Windows/Linux. Write `mod+s` instead of platform-specific `ctrl+s` / `cmd+s` for cross-platform shortcuts.',
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
label: 'Scopes',
|
|
116
|
-
note: 'Scoped shortcuts only fire when their scope is active. Activate with `useHotkeyScope(scope)` (component-scoped) or `enableScope(scope)` (imperative). Without activation, scoped handlers are silently dormant.',
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
})
|
package/src/parse.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
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(combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key))
|
|
81
|
-
return parts.join('+')
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function capitalize(s: string): string {
|
|
85
|
-
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function isMac(): boolean {
|
|
89
|
-
if (typeof navigator === 'undefined') return false
|
|
90
|
-
return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)
|
|
91
|
-
}
|
package/src/registry.ts
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
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
|
-
// Linear array on purpose. Per-keystroke dispatch (line 35) iterates entries
|
|
9
|
-
// in registration order — O(n) where n = registered hotkeys. For real apps
|
|
10
|
-
// (single-digit to low-tens) the cost is sub-microsecond and well under the
|
|
11
|
-
// 16ms frame budget. Switching to a Map<comboKey, entries[]> would help only
|
|
12
|
-
// past ~5,000 hotkeys, which is unrealistic. The array also preserves
|
|
13
|
-
// registration order for the rare case where two hotkeys match the same
|
|
14
|
-
// combo on different scopes — first-registered wins. Don't replace with a
|
|
15
|
-
// Map without confirming a real app actually hits the perf wall.
|
|
16
|
-
const entries: HotkeyEntry[] = []
|
|
17
|
-
const activeScopes = signal<Set<string>>(new Set(['global']))
|
|
18
|
-
let listenerAttached = false
|
|
19
|
-
let keydownHandler: ((event: KeyboardEvent) => void) | null = null
|
|
20
|
-
|
|
21
|
-
// ─── Input detection ─────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
const INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
|
|
24
|
-
|
|
25
|
-
function isInputFocused(event: KeyboardEvent): boolean {
|
|
26
|
-
const target = event.target as HTMLElement | null
|
|
27
|
-
if (!target) return false
|
|
28
|
-
if (INPUT_TAGS.has(target.tagName)) return true
|
|
29
|
-
if (target.isContentEditable) return true
|
|
30
|
-
return false
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ─── Global listener ─────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
function attachListener(): void {
|
|
36
|
-
if (listenerAttached) return
|
|
37
|
-
if (typeof window === 'undefined') return
|
|
38
|
-
listenerAttached = true
|
|
39
|
-
|
|
40
|
-
keydownHandler = (event) => {
|
|
41
|
-
const scopes = activeScopes.peek()
|
|
42
|
-
|
|
43
|
-
for (const entry of entries) {
|
|
44
|
-
// Check scope
|
|
45
|
-
if (!scopes.has(entry.options.scope)) continue
|
|
46
|
-
|
|
47
|
-
// Check enabled
|
|
48
|
-
const enabled =
|
|
49
|
-
typeof entry.options.enabled === 'function'
|
|
50
|
-
? entry.options.enabled()
|
|
51
|
-
: entry.options.enabled
|
|
52
|
-
if (!enabled) continue
|
|
53
|
-
|
|
54
|
-
// Check input focus
|
|
55
|
-
if (!entry.options.enableOnInputs && isInputFocused(event)) continue
|
|
56
|
-
|
|
57
|
-
// Check key match
|
|
58
|
-
if (!matchesCombo(event, entry.combo)) continue
|
|
59
|
-
|
|
60
|
-
// Match found
|
|
61
|
-
if (entry.options.preventDefault) event.preventDefault()
|
|
62
|
-
if (entry.options.stopPropagation) event.stopPropagation()
|
|
63
|
-
entry.handler(event)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
window.addEventListener('keydown', keydownHandler)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function detachListener(): void {
|
|
71
|
-
if (typeof window === 'undefined') return
|
|
72
|
-
if (!listenerAttached || !keydownHandler) return
|
|
73
|
-
window.removeEventListener('keydown', keydownHandler)
|
|
74
|
-
listenerAttached = false
|
|
75
|
-
keydownHandler = null
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ─── Registration ────────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Register a keyboard shortcut. Returns an unregister function.
|
|
82
|
-
*
|
|
83
|
-
* @example
|
|
84
|
-
* ```ts
|
|
85
|
-
* const unregister = registerHotkey('ctrl+s', (e) => save(), { description: 'Save' })
|
|
86
|
-
* // later: unregister()
|
|
87
|
-
* ```
|
|
88
|
-
*/
|
|
89
|
-
export function registerHotkey(
|
|
90
|
-
shortcut: string,
|
|
91
|
-
handler: (event: KeyboardEvent) => void,
|
|
92
|
-
options?: HotkeyOptions,
|
|
93
|
-
): () => void {
|
|
94
|
-
attachListener()
|
|
95
|
-
|
|
96
|
-
const entry: HotkeyEntry = {
|
|
97
|
-
shortcut,
|
|
98
|
-
combo: parseShortcut(shortcut),
|
|
99
|
-
handler,
|
|
100
|
-
options: {
|
|
101
|
-
scope: options?.scope ?? 'global',
|
|
102
|
-
preventDefault: options?.preventDefault !== false,
|
|
103
|
-
stopPropagation: options?.stopPropagation === true,
|
|
104
|
-
enableOnInputs: options?.enableOnInputs === true,
|
|
105
|
-
enabled: options?.enabled ?? true,
|
|
106
|
-
...(options?.description != null ? { description: options.description } : {}),
|
|
107
|
-
},
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
entries.push(entry)
|
|
111
|
-
|
|
112
|
-
return () => {
|
|
113
|
-
const idx = entries.indexOf(entry)
|
|
114
|
-
if (idx !== -1) entries.splice(idx, 1)
|
|
115
|
-
|
|
116
|
-
// Detach listener if no more entries
|
|
117
|
-
if (entries.length === 0) {
|
|
118
|
-
detachListener()
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ─── Scope management ────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Activate a hotkey scope. 'global' is always active.
|
|
127
|
-
*/
|
|
128
|
-
export function enableScope(scope: string): void {
|
|
129
|
-
const current = activeScopes.peek()
|
|
130
|
-
if (current.has(scope)) return
|
|
131
|
-
const next = new Set(current)
|
|
132
|
-
next.add(scope)
|
|
133
|
-
activeScopes.set(next)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Deactivate a hotkey scope. Cannot deactivate 'global'.
|
|
138
|
-
*/
|
|
139
|
-
export function disableScope(scope: string): void {
|
|
140
|
-
if (scope === 'global') return
|
|
141
|
-
const current = activeScopes.peek()
|
|
142
|
-
if (!current.has(scope)) return
|
|
143
|
-
const next = new Set(current)
|
|
144
|
-
next.delete(scope)
|
|
145
|
-
activeScopes.set(next)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Get the currently active scopes as a reactive signal.
|
|
150
|
-
*/
|
|
151
|
-
export function getActiveScopes(): Signal<Set<string>> {
|
|
152
|
-
return activeScopes
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Get all registered hotkeys (for building help dialogs).
|
|
157
|
-
*/
|
|
158
|
-
export function getRegisteredHotkeys(): ReadonlyArray<{
|
|
159
|
-
shortcut: string
|
|
160
|
-
scope: string
|
|
161
|
-
description?: string
|
|
162
|
-
}> {
|
|
163
|
-
return entries.map((e) => ({
|
|
164
|
-
shortcut: e.shortcut,
|
|
165
|
-
scope: e.options.scope,
|
|
166
|
-
...(e.options.description != null ? { description: e.options.description } : {}),
|
|
167
|
-
}))
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ─── Reset (for testing) ────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
export function _resetHotkeys(): void {
|
|
173
|
-
entries.length = 0
|
|
174
|
-
activeScopes.set(new Set(['global']))
|
|
175
|
-
detachListener()
|
|
176
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
|
|
3
|
-
let unmountCallbacks: Array<() => void> = []
|
|
4
|
-
|
|
5
|
-
vi.mock('@pyreon/core', () => ({
|
|
6
|
-
onUnmount: (fn: () => void) => {
|
|
7
|
-
unmountCallbacks.push(fn)
|
|
8
|
-
},
|
|
9
|
-
}))
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
_resetHotkeys,
|
|
13
|
-
getActiveScopes,
|
|
14
|
-
getRegisteredHotkeys,
|
|
15
|
-
} from '../registry'
|
|
16
|
-
import { useHotkey } from '../use-hotkey'
|
|
17
|
-
import { useHotkeyScope } from '../use-hotkey-scope'
|
|
18
|
-
|
|
19
|
-
function fireKey(
|
|
20
|
-
key: string,
|
|
21
|
-
modifiers: Partial<{
|
|
22
|
-
ctrlKey: boolean
|
|
23
|
-
shiftKey: boolean
|
|
24
|
-
altKey: boolean
|
|
25
|
-
metaKey: boolean
|
|
26
|
-
}> = {},
|
|
27
|
-
): void {
|
|
28
|
-
const event = new KeyboardEvent('keydown', {
|
|
29
|
-
key,
|
|
30
|
-
ctrlKey: modifiers.ctrlKey ?? false,
|
|
31
|
-
shiftKey: modifiers.shiftKey ?? false,
|
|
32
|
-
altKey: modifiers.altKey ?? false,
|
|
33
|
-
metaKey: modifiers.metaKey ?? false,
|
|
34
|
-
bubbles: true,
|
|
35
|
-
cancelable: true,
|
|
36
|
-
})
|
|
37
|
-
window.dispatchEvent(event)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe('useHotkey', () => {
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
_resetHotkeys()
|
|
43
|
-
unmountCallbacks = []
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
_resetHotkeys()
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('registers a hotkey that fires on keydown', () => {
|
|
51
|
-
let fired = false
|
|
52
|
-
useHotkey('ctrl+s', () => {
|
|
53
|
-
fired = true
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
fireKey('s', { ctrlKey: true })
|
|
57
|
-
expect(fired).toBe(true)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('unregisters hotkey on unmount', () => {
|
|
61
|
-
let count = 0
|
|
62
|
-
useHotkey('ctrl+s', () => {
|
|
63
|
-
count++
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
fireKey('s', { ctrlKey: true })
|
|
67
|
-
expect(count).toBe(1)
|
|
68
|
-
|
|
69
|
-
// Simulate unmount
|
|
70
|
-
unmountCallbacks.forEach((fn) => fn())
|
|
71
|
-
|
|
72
|
-
fireKey('s', { ctrlKey: true })
|
|
73
|
-
expect(count).toBe(1) // should not fire after unmount
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it('passes options to registerHotkey', () => {
|
|
77
|
-
useHotkey('ctrl+s', () => {}, { description: 'Save', scope: 'editor' })
|
|
78
|
-
const hotkeys = getRegisteredHotkeys()
|
|
79
|
-
expect(hotkeys).toHaveLength(1)
|
|
80
|
-
expect(hotkeys[0]!.description).toBe('Save')
|
|
81
|
-
expect(hotkeys[0]!.scope).toBe('editor')
|
|
82
|
-
})
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
describe('useHotkeyScope', () => {
|
|
86
|
-
beforeEach(() => {
|
|
87
|
-
_resetHotkeys()
|
|
88
|
-
unmountCallbacks = []
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
afterEach(() => {
|
|
92
|
-
_resetHotkeys()
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('enables a scope', () => {
|
|
96
|
-
useHotkeyScope('modal')
|
|
97
|
-
expect(getActiveScopes().peek().has('modal')).toBe(true)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('disables scope on unmount', () => {
|
|
101
|
-
useHotkeyScope('modal')
|
|
102
|
-
expect(getActiveScopes().peek().has('modal')).toBe(true)
|
|
103
|
-
|
|
104
|
-
// Simulate unmount
|
|
105
|
-
unmountCallbacks.forEach((fn) => fn())
|
|
106
|
-
|
|
107
|
-
expect(getActiveScopes().peek().has('modal')).toBe(false)
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('scoped hotkey fires when scope is active', () => {
|
|
111
|
-
let fired = false
|
|
112
|
-
useHotkeyScope('editor')
|
|
113
|
-
useHotkey('escape', () => {
|
|
114
|
-
fired = true
|
|
115
|
-
}, { scope: 'editor' })
|
|
116
|
-
|
|
117
|
-
fireKey('Escape')
|
|
118
|
-
expect(fired).toBe(true)
|
|
119
|
-
})
|
|
120
|
-
})
|