@remcostoeten/use-shortcut 1.3.0 → 2.0.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/CHANGELOG.md +8 -0
- package/README.md +33 -313
- package/dist/cli/index.mjs +88 -216
- package/dist/index.d.mts +39 -68
- package/dist/index.d.ts +39 -68
- package/dist/index.js +350 -361
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +351 -354
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/__tests__/features.test.ts +43 -12
- package/src/builder.ts +37 -476
- package/src/constants.ts +59 -13
- package/src/formatter.ts +37 -22
- package/src/hook.ts +150 -31
- package/src/index.ts +1 -12
- package/src/parser.ts +6 -3
- package/src/runtime/binding.ts +136 -0
- package/src/runtime/conflicts.ts +43 -0
- package/src/runtime/debug.ts +6 -0
- package/src/runtime/guards.ts +82 -0
- package/src/runtime/keys.ts +63 -0
- package/src/runtime/listener.ts +142 -0
- package/src/runtime/recording.ts +39 -0
- package/src/runtime/types.ts +48 -0
- package/src/types.ts +16 -19
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ParsedShortcut, ShortcutConflict } from "../types"
|
|
2
|
+
import type { ShortcutRegistry } from "./types"
|
|
3
|
+
|
|
4
|
+
import { _canonicalizeParsed } from "./keys"
|
|
5
|
+
|
|
6
|
+
function _isPrefix(a: string[], b: string[]): boolean {
|
|
7
|
+
if (a.length > b.length) return false
|
|
8
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
9
|
+
if (a[i] !== b[i]) {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function _detectConflict(newSteps: ParsedShortcut[], existingSteps: ParsedShortcut[]): ShortcutConflict["reason"] | null {
|
|
17
|
+
const newCanonicalSteps = newSteps.map(_canonicalizeParsed)
|
|
18
|
+
const existingCanonicalSteps = existingSteps.map(_canonicalizeParsed)
|
|
19
|
+
const newCombo = newCanonicalSteps.join(" ")
|
|
20
|
+
const existingCombo = existingCanonicalSteps.join(" ")
|
|
21
|
+
|
|
22
|
+
if (newCombo === existingCombo) return "exact"
|
|
23
|
+
if (_isPrefix(newCanonicalSteps, existingCanonicalSteps) || _isPrefix(existingCanonicalSteps, newCanonicalSteps)) {
|
|
24
|
+
return "sequence-prefix"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function _emitConflict(registry: ShortcutRegistry, conflict: ShortcutConflict) {
|
|
31
|
+
const conflictWarnings = registry.options.conflictWarnings ?? true
|
|
32
|
+
|
|
33
|
+
if (registry.options.onConflict) {
|
|
34
|
+
registry.options.onConflict(conflict)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!conflictWarnings) return
|
|
39
|
+
|
|
40
|
+
console.warn(
|
|
41
|
+
`[useShortcut] Conflict detected (${conflict.reason}) between "${conflict.combo}" and "${conflict.existingCombo}"`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ExceptPreset, ExceptPredicate, ShortcutScope } from "../types"
|
|
2
|
+
|
|
3
|
+
export const _IGNORED_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"])
|
|
4
|
+
|
|
5
|
+
export const _EXCEPT_PREDICATES: Record<ExceptPreset, ExceptPredicate> = {
|
|
6
|
+
input: e => {
|
|
7
|
+
if (!(e.target instanceof HTMLElement)) return false
|
|
8
|
+
const target = e.target
|
|
9
|
+
return _IGNORED_TAGS.has(target.tagName)
|
|
10
|
+
},
|
|
11
|
+
editable: e => {
|
|
12
|
+
if (!(e.target instanceof HTMLElement)) return false
|
|
13
|
+
const target = e.target
|
|
14
|
+
return target.isContentEditable
|
|
15
|
+
},
|
|
16
|
+
typing: e => {
|
|
17
|
+
if (!(e.target instanceof HTMLElement)) return false
|
|
18
|
+
const target = e.target
|
|
19
|
+
return _IGNORED_TAGS.has(target.tagName) || target.isContentEditable
|
|
20
|
+
},
|
|
21
|
+
modal: () => {
|
|
22
|
+
if (
|
|
23
|
+
typeof document === "undefined" ||
|
|
24
|
+
typeof document.querySelector !== "function"
|
|
25
|
+
)
|
|
26
|
+
return false
|
|
27
|
+
return (
|
|
28
|
+
document.querySelector('[data-modal="true"], [role="dialog"]') !==
|
|
29
|
+
null
|
|
30
|
+
)
|
|
31
|
+
},
|
|
32
|
+
disabled: e => {
|
|
33
|
+
if (!(e.target instanceof HTMLElement)) return false
|
|
34
|
+
const target = e.target
|
|
35
|
+
return (
|
|
36
|
+
target.hasAttribute("disabled") ||
|
|
37
|
+
target.getAttribute("aria-disabled") === "true"
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function _shouldExcept(
|
|
43
|
+
event: KeyboardEvent,
|
|
44
|
+
except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
|
|
45
|
+
): boolean {
|
|
46
|
+
if (!except) return false
|
|
47
|
+
|
|
48
|
+
if (typeof except === "function") {
|
|
49
|
+
return except(event)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(except)) {
|
|
53
|
+
return except.some(preset => _EXCEPT_PREDICATES[preset]?.(event))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return _EXCEPT_PREDICATES[except]?.(event) ?? false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function _normalizeScopes(scopes?: ShortcutScope): string[] {
|
|
60
|
+
if (!scopes) return []
|
|
61
|
+
return (Array.isArray(scopes) ? scopes : [scopes])
|
|
62
|
+
.map(scope => scope.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function _scopeMatch(
|
|
67
|
+
requiredScopes: Set<string>,
|
|
68
|
+
activeScopes: Set<string>
|
|
69
|
+
): boolean {
|
|
70
|
+
if (requiredScopes.size === 0) return true
|
|
71
|
+
for (const required of requiredScopes) {
|
|
72
|
+
if (activeScopes.has(required)) return true
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function _isPureModifier(event: KeyboardEvent): boolean {
|
|
78
|
+
const key = event.key.toLowerCase()
|
|
79
|
+
return (
|
|
80
|
+
key === "shift" || key === "control" || key === "alt" || key === "meta"
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { detectPlatform, ModifierDisplayOrder, ModifierKey } from "../constants"
|
|
2
|
+
import { formatShortcut } from "../formatter"
|
|
3
|
+
import type { ModifierFlags, ParsedShortcut } from "../types"
|
|
4
|
+
|
|
5
|
+
export function _getActiveModifierTokens(
|
|
6
|
+
modifiers: Partial<ModifierFlags>
|
|
7
|
+
): string[] {
|
|
8
|
+
const platform = detectPlatform()
|
|
9
|
+
const order = ModifierDisplayOrder[platform]
|
|
10
|
+
const tokens: string[] = []
|
|
11
|
+
|
|
12
|
+
for (const key of order) {
|
|
13
|
+
if (key === ModifierKey.CTRL && modifiers.ctrl) tokens.push("ctrl")
|
|
14
|
+
if (key === ModifierKey.ALT && modifiers.alt) tokens.push("alt")
|
|
15
|
+
if (key === ModifierKey.SHIFT && modifiers.shift) tokens.push("shift")
|
|
16
|
+
if (key === ModifierKey.META && modifiers.cmd) tokens.push("cmd")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return tokens
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function _buildComboString(
|
|
23
|
+
modifiers: Partial<ModifierFlags>,
|
|
24
|
+
key: string
|
|
25
|
+
): string {
|
|
26
|
+
const tokens = _getActiveModifierTokens(modifiers)
|
|
27
|
+
return [...tokens, key].join("+")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function _formatSequenceDisplay(steps: string[]): string {
|
|
31
|
+
return steps.map(step => formatShortcut(step)).join(" then ")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function _canonicalizeParsed(parsed: ParsedShortcut): string {
|
|
35
|
+
const modifiers: string[] = []
|
|
36
|
+
if (parsed.modifiers.ctrl) modifiers.push("ctrl")
|
|
37
|
+
if (parsed.modifiers.alt) modifiers.push("alt")
|
|
38
|
+
if (parsed.modifiers.shift) modifiers.push("shift")
|
|
39
|
+
if (parsed.modifiers.meta) modifiers.push("cmd")
|
|
40
|
+
const key =
|
|
41
|
+
parsed.key === " " || parsed.key === "Spacebar"
|
|
42
|
+
? "space"
|
|
43
|
+
: parsed.key.toLowerCase()
|
|
44
|
+
return [...modifiers, key].join("+")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function _eventToCombo(event: KeyboardEvent): string {
|
|
48
|
+
const modifiers: string[] = []
|
|
49
|
+
if (event.ctrlKey) modifiers.push("ctrl")
|
|
50
|
+
if (event.altKey) modifiers.push("alt")
|
|
51
|
+
if (event.shiftKey) modifiers.push("shift")
|
|
52
|
+
if (event.metaKey) modifiers.push("cmd")
|
|
53
|
+
|
|
54
|
+
const key =
|
|
55
|
+
event.key === " " || event.key === "Spacebar"
|
|
56
|
+
? "space"
|
|
57
|
+
: event.key.toLowerCase()
|
|
58
|
+
return [...modifiers, key].join("+")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function _eventToMatchStep(event: KeyboardEvent): string {
|
|
62
|
+
return _eventToCombo(event)
|
|
63
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { matchesShortcut } from "../parser"
|
|
2
|
+
|
|
3
|
+
import { _debugLog } from "./debug"
|
|
4
|
+
import { _eventToMatchStep } from "./keys"
|
|
5
|
+
import { _IGNORED_TAGS, _scopeMatch, _shouldExcept } from "./guards"
|
|
6
|
+
import type { RegistryEntry, ShortcutRegistry } from "./types"
|
|
7
|
+
|
|
8
|
+
function _sortEntries(entries: RegistryEntry[]): RegistryEntry[] {
|
|
9
|
+
if (entries.length <= 1) return entries
|
|
10
|
+
|
|
11
|
+
return [...entries].sort((a, b) => {
|
|
12
|
+
if (b.priority !== a.priority) return b.priority - a.priority
|
|
13
|
+
return a.id - b.id
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function _dispatchRegistryEvent(registry: ShortcutRegistry, event: KeyboardEvent) {
|
|
18
|
+
const runtimeOptions = registry.options
|
|
19
|
+
if (runtimeOptions.disabled) return
|
|
20
|
+
if (runtimeOptions.eventFilter && !runtimeOptions.eventFilter(event)) return
|
|
21
|
+
|
|
22
|
+
const candidateCombos = new Set<string>()
|
|
23
|
+
const firstStepCombos = registry.firstStepIndex.get(_eventToMatchStep(event))
|
|
24
|
+
if (firstStepCombos) {
|
|
25
|
+
for (const combo of firstStepCombos) candidateCombos.add(combo)
|
|
26
|
+
}
|
|
27
|
+
for (const combo of registry.activeSequenceCombos) {
|
|
28
|
+
candidateCombos.add(combo)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const combo of candidateCombos) {
|
|
32
|
+
const comboEntries = registry.listeners.get(combo)
|
|
33
|
+
if (!comboEntries) continue
|
|
34
|
+
|
|
35
|
+
const orderedEntries = _sortEntries(comboEntries)
|
|
36
|
+
|
|
37
|
+
for (const item of orderedEntries) {
|
|
38
|
+
if (!item.isEnabled) continue
|
|
39
|
+
|
|
40
|
+
if (!_scopeMatch(item.scopes, registry.activeScopes)) {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (runtimeOptions.ignoreInputs !== false && !item.except) {
|
|
45
|
+
const targetEl = event.target as HTMLElement
|
|
46
|
+
if (targetEl && (_IGNORED_TAGS.has(targetEl.tagName) || targetEl.isContentEditable)) {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (_shouldExcept(event, item.except)) {
|
|
52
|
+
_debugLog(runtimeOptions.debug, "Skipped due to except condition:", combo)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const expected = item.parsedSteps[item.progress]
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
|
|
59
|
+
if (item.progress > 0 && now - item.lastMatchedAt > item.sequenceTimeout) {
|
|
60
|
+
item.progress = 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let matched = false
|
|
64
|
+
|
|
65
|
+
if (matchesShortcut(event, expected)) {
|
|
66
|
+
item.progress += 1
|
|
67
|
+
item.lastMatchedAt = now
|
|
68
|
+
|
|
69
|
+
if (item.progress === item.parsedSteps.length) {
|
|
70
|
+
matched = true
|
|
71
|
+
item.progress = 0
|
|
72
|
+
}
|
|
73
|
+
} else if (item.progress > 0 && matchesShortcut(event, item.parsedSteps[0])) {
|
|
74
|
+
item.progress = 1
|
|
75
|
+
item.lastMatchedAt = now
|
|
76
|
+
} else {
|
|
77
|
+
item.progress = 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const cb of item.attemptCallbacks) {
|
|
81
|
+
cb(matched, event)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!matched) continue
|
|
85
|
+
|
|
86
|
+
_debugLog(runtimeOptions.debug, "MATCHED:", combo)
|
|
87
|
+
|
|
88
|
+
if (item.preventDefault) {
|
|
89
|
+
event.preventDefault()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (item.stopPropagation) {
|
|
93
|
+
event.stopPropagation()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const executeHandler = () => item.userHandler(event)
|
|
97
|
+
|
|
98
|
+
if (item.delay > 0) {
|
|
99
|
+
_debugLog(runtimeOptions.debug, "Delaying execution by", item.delay, "ms")
|
|
100
|
+
setTimeout(executeHandler, item.delay)
|
|
101
|
+
} else {
|
|
102
|
+
executeHandler()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (item.stopOnMatch) {
|
|
106
|
+
break
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (comboEntries.some((entry) => entry.progress > 0)) {
|
|
111
|
+
registry.activeSequenceCombos.add(combo)
|
|
112
|
+
} else {
|
|
113
|
+
registry.activeSequenceCombos.delete(combo)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function _attachRegistryListener(registry: ShortcutRegistry) {
|
|
119
|
+
if (registry.listener) return
|
|
120
|
+
|
|
121
|
+
const target = registry.options.target ?? (typeof window !== "undefined" ? window : null)
|
|
122
|
+
if (!target) return
|
|
123
|
+
|
|
124
|
+
const eventType = registry.options.eventType ?? "keydown"
|
|
125
|
+
const listener = (event: KeyboardEvent) => _dispatchRegistryEvent(registry, event)
|
|
126
|
+
target.addEventListener(eventType, listener as EventListener)
|
|
127
|
+
|
|
128
|
+
registry.listener = listener
|
|
129
|
+
registry.listenerTarget = target
|
|
130
|
+
registry.listenerEventType = eventType
|
|
131
|
+
|
|
132
|
+
_debugLog(registry.options.debug, "Listener attached")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function _detachRegistryListener(registry: ShortcutRegistry) {
|
|
136
|
+
if (!registry.listener || !registry.listenerTarget) return
|
|
137
|
+
|
|
138
|
+
registry.listenerTarget.removeEventListener(registry.listenerEventType, registry.listener as EventListener)
|
|
139
|
+
registry.listener = null
|
|
140
|
+
registry.listenerTarget = null
|
|
141
|
+
_debugLog(registry.options.debug, "Listener detached")
|
|
142
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ShortcutRecordingOptions, UseShortcutOptions } from "../types"
|
|
2
|
+
import { _eventToCombo } from "./keys"
|
|
3
|
+
import { _isPureModifier } from "./guards"
|
|
4
|
+
|
|
5
|
+
export function _createRecorder(options: UseShortcutOptions) {
|
|
6
|
+
return (recordingOptions: ShortcutRecordingOptions = {}): Promise<string> => {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const target = recordingOptions.target ?? options.target ?? (typeof window !== "undefined" ? window : null)
|
|
9
|
+
const eventType = recordingOptions.eventType ?? options.eventType ?? "keydown"
|
|
10
|
+
|
|
11
|
+
if (!target) {
|
|
12
|
+
reject(new Error("[useShortcut] Cannot record shortcut without a target."))
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
17
|
+
|
|
18
|
+
const listener = (event: Event) => {
|
|
19
|
+
const keyboardEvent = event as KeyboardEvent
|
|
20
|
+
if (_isPureModifier(keyboardEvent)) return
|
|
21
|
+
|
|
22
|
+
keyboardEvent.preventDefault()
|
|
23
|
+
target.removeEventListener(eventType, listener as EventListener)
|
|
24
|
+
if (timeout) clearTimeout(timeout)
|
|
25
|
+
resolve(_eventToCombo(keyboardEvent))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
target.addEventListener(eventType, listener as EventListener)
|
|
29
|
+
|
|
30
|
+
const timeoutMs = recordingOptions.timeoutMs
|
|
31
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
32
|
+
timeout = setTimeout(() => {
|
|
33
|
+
target.removeEventListener(eventType, listener as EventListener)
|
|
34
|
+
reject(new Error(`[useShortcut] Recording timed out after ${timeoutMs}ms.`))
|
|
35
|
+
}, timeoutMs)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ModifierFlags,
|
|
3
|
+
ShortcutHandler,
|
|
4
|
+
UseShortcutOptions,
|
|
5
|
+
ExceptPreset,
|
|
6
|
+
ExceptPredicate,
|
|
7
|
+
ShortcutScope,
|
|
8
|
+
ParsedShortcut,
|
|
9
|
+
} from "../types"
|
|
10
|
+
|
|
11
|
+
export type BuilderState = {
|
|
12
|
+
modifiers: Partial<ModifierFlags>
|
|
13
|
+
steps: string[]
|
|
14
|
+
options: UseShortcutOptions
|
|
15
|
+
except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
|
|
16
|
+
scopes?: ShortcutScope
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RegistryEntry = {
|
|
20
|
+
id: number
|
|
21
|
+
userHandler: ShortcutHandler
|
|
22
|
+
isEnabled: boolean
|
|
23
|
+
attemptCallbacks: Set<(matched: boolean, event: KeyboardEvent) => void>
|
|
24
|
+
parsedSteps: ParsedShortcut[]
|
|
25
|
+
scopes: Set<string>
|
|
26
|
+
progress: number
|
|
27
|
+
lastMatchedAt: number
|
|
28
|
+
except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
|
|
29
|
+
delay: number
|
|
30
|
+
sequenceTimeout: number
|
|
31
|
+
preventDefault: boolean
|
|
32
|
+
stopPropagation: boolean
|
|
33
|
+
stopOnMatch: boolean
|
|
34
|
+
priority: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ShortcutRegistry = {
|
|
38
|
+
listeners: Map<string, RegistryEntry[]>
|
|
39
|
+
firstStepIndex: Map<string, Set<string>>
|
|
40
|
+
activeSequenceCombos: Set<string>
|
|
41
|
+
options: UseShortcutOptions
|
|
42
|
+
activeScopes: Set<string>
|
|
43
|
+
nextId: number
|
|
44
|
+
listener: ((event: KeyboardEvent) => void) | null
|
|
45
|
+
listenerTarget: (HTMLElement | Window) | null
|
|
46
|
+
listenerEventType: "keydown" | "keyup"
|
|
47
|
+
}
|
|
48
|
+
|
package/src/types.ts
CHANGED
|
@@ -83,8 +83,10 @@ export type ExceptPredicate = (event: KeyboardEvent) => boolean
|
|
|
83
83
|
*/
|
|
84
84
|
export type ExceptPreset = "input" | "editable" | "typing" | "modal" | "disabled"
|
|
85
85
|
|
|
86
|
+
/** Scope selector used to enable/disable subsets of shortcuts at runtime. */
|
|
86
87
|
export type ShortcutScope = string | string[]
|
|
87
88
|
|
|
89
|
+
/** Conflict metadata emitted when two registered shortcuts overlap. */
|
|
88
90
|
export type ShortcutConflict = {
|
|
89
91
|
combo: string
|
|
90
92
|
existingCombo: string
|
|
@@ -105,8 +107,6 @@ export type HandlerOptions = {
|
|
|
105
107
|
description?: string
|
|
106
108
|
/** Disable this specific shortcut */
|
|
107
109
|
disabled?: boolean
|
|
108
|
-
/** Limit shortcut to a specific DOM element */
|
|
109
|
-
scope?: HTMLElement | null
|
|
110
110
|
/** Conditions to skip the shortcut */
|
|
111
111
|
except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
|
|
112
112
|
/** Required named scopes that must be active */
|
|
@@ -142,14 +142,6 @@ export type ShortcutResult = {
|
|
|
142
142
|
onAttempt?: (callback: (matched: boolean, event: KeyboardEvent) => void) => () => void
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
export type RemainingModifiers<Used extends Partial<ModifierFlags>> = Exclude<
|
|
146
|
-
ModifierName,
|
|
147
|
-
| (Used extends { ctrl: true } ? "ctrl" : never)
|
|
148
|
-
| (Used extends { shift: true } ? "shift" : never)
|
|
149
|
-
| (Used extends { alt: true } ? "alt" : never)
|
|
150
|
-
| (Used extends { cmd: true } ? "cmd" | "mod" : never)
|
|
151
|
-
>
|
|
152
|
-
|
|
153
145
|
/**
|
|
154
146
|
* Chainable modifier builder with type-safe exhaustion
|
|
155
147
|
* Each modifier can only be used once in a chain
|
|
@@ -160,35 +152,36 @@ export type ModifierChain<Used extends Partial<ModifierFlags>> = {
|
|
|
160
152
|
alt: Used["alt"] extends true ? never : ModifierChain<Used & { alt: true }>
|
|
161
153
|
cmd: Used["cmd"] extends true ? never : ModifierChain<Used & { cmd: true }>
|
|
162
154
|
mod: Used["cmd"] extends true ? never : ModifierChain<Used & { cmd: true }>
|
|
163
|
-
key: <K extends ActionKey>(key: K) => KeyChain<
|
|
155
|
+
key: <K extends ActionKey>(key: K) => KeyChain<K>
|
|
164
156
|
in: (scopes: ShortcutScope) => ModifierChain<Used>
|
|
165
157
|
}
|
|
166
158
|
|
|
167
159
|
/**
|
|
168
160
|
* Chain state after calling `.key()` - ready to attach a handler
|
|
169
161
|
*/
|
|
170
|
-
export type KeyChain<
|
|
162
|
+
export type KeyChain<Key extends string> = {
|
|
171
163
|
/** Attach a handler to this shortcut */
|
|
172
164
|
on: (handler: ShortcutHandler, options?: HandlerOptions) => ShortcutResult
|
|
173
165
|
/** Attach a handler with inline options */
|
|
174
166
|
handle: (options: HandlerOptions & { handler: ShortcutHandler }) => ShortcutResult
|
|
175
167
|
/** Add exception conditions before attaching handler */
|
|
176
|
-
except: (condition: ExceptPreset | ExceptPreset[] | ExceptPredicate) => KeyChainWithExcept<
|
|
168
|
+
except: (condition: ExceptPreset | ExceptPreset[] | ExceptPredicate) => KeyChainWithExcept<Key>
|
|
177
169
|
/** Add required named scopes */
|
|
178
|
-
in: (scopes: ShortcutScope) => KeyChain<
|
|
170
|
+
in: (scopes: ShortcutScope) => KeyChain<Key>
|
|
179
171
|
/** Add the next step in a sequence */
|
|
180
|
-
then: <K extends ActionKey | string>(key: K) => KeyChain
|
|
172
|
+
then: <K extends ActionKey | string>(key: K) => KeyChain<`${Key} ${K}`>
|
|
181
173
|
}
|
|
182
174
|
|
|
183
175
|
/**
|
|
184
176
|
* Chain state after calling `.except()` - ready to attach handler
|
|
185
177
|
*/
|
|
186
|
-
export type KeyChainWithExcept<
|
|
178
|
+
export type KeyChainWithExcept<Key extends string> = {
|
|
187
179
|
on: (handler: ShortcutHandler, options?: Omit<HandlerOptions, "except">) => ShortcutResult
|
|
188
|
-
in: (scopes: ShortcutScope) => KeyChainWithExcept<
|
|
189
|
-
then: <K extends ActionKey | string>(key: K) => KeyChainWithExcept
|
|
180
|
+
in: (scopes: ShortcutScope) => KeyChainWithExcept<Key>
|
|
181
|
+
then: <K extends ActionKey | string>(key: K) => KeyChainWithExcept<`${Key} ${K}`>
|
|
190
182
|
}
|
|
191
183
|
|
|
184
|
+
/** Options for `ShortcutBuilder.record()` and low-level recording flows. */
|
|
192
185
|
export type ShortcutRecordingOptions = {
|
|
193
186
|
target?: HTMLElement | Window | null
|
|
194
187
|
eventType?: "keydown" | "keyup"
|
|
@@ -204,7 +197,7 @@ export type ShortcutBuilder = ModifierChain<EmptyModifiers> & {
|
|
|
204
197
|
alt: ModifierChain<{ alt: true }>
|
|
205
198
|
cmd: ModifierChain<{ cmd: true }>
|
|
206
199
|
mod: ModifierChain<{ cmd: true }>
|
|
207
|
-
key: <K extends ActionKey>(key: K) => KeyChain<
|
|
200
|
+
key: <K extends ActionKey>(key: K) => KeyChain<K>
|
|
208
201
|
/** Set required scopes for upcoming chain calls */
|
|
209
202
|
in: (scopes: ShortcutScope) => ShortcutBuilder
|
|
210
203
|
/** Update active scopes at runtime */
|
|
@@ -249,18 +242,22 @@ export type UseShortcutOptions = {
|
|
|
249
242
|
eventFilter?: (event: KeyboardEvent) => boolean
|
|
250
243
|
}
|
|
251
244
|
|
|
245
|
+
/** Single shortcut-map entry used by `registerShortcutMap` and `useShortcutMap`. */
|
|
252
246
|
export type ShortcutMapEntry = {
|
|
253
247
|
keys: string | string[]
|
|
254
248
|
handler: ShortcutHandler
|
|
255
249
|
options?: HandlerOptions
|
|
256
250
|
}
|
|
257
251
|
|
|
252
|
+
/** Bulk registration shape mapping action ids to key+handler definitions. */
|
|
258
253
|
export type ShortcutMap = Record<string, ShortcutMapEntry>
|
|
259
254
|
|
|
255
|
+
/** Return type for map registrations, keyed by the same ids as the source map. */
|
|
260
256
|
export type ShortcutMapResult<T extends ShortcutMap = ShortcutMap> = {
|
|
261
257
|
[K in keyof T]: ShortcutResult
|
|
262
258
|
}
|
|
263
259
|
|
|
260
|
+
/** Imperative grouping controller for binding/unbinding many shortcut registrations together. */
|
|
264
261
|
export type ShortcutGroup = {
|
|
265
262
|
add: (...results: ShortcutResult[]) => void
|
|
266
263
|
addMany: (results: ShortcutResult[] | Record<string, ShortcutResult>) => void
|