rx-hotkeys 1.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/README.md +205 -0
- package/dist/hotkeys.d.ts +163 -0
- package/dist/hotkeys.d.ts.map +1 -0
- package/dist/hotkeys.js +321 -0
- package/dist/hotkeys.test.d.ts +2 -0
- package/dist/hotkeys.test.d.ts.map +1 -0
- package/dist/hotkeys.test.js +360 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/keys.d.ts +155 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +117 -0
- package/dist/testutils.d.ts +14 -0
- package/dist/testutils.d.ts.map +1 -0
- package/dist/testutils.js +31 -0
- package/package.json +39 -0
package/dist/hotkeys.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { fromEvent, BehaviorSubject, EMPTY, filter, map, bufferCount, withLatestFrom, tap, catchError, scan, } from "rxjs";
|
|
2
|
+
// --- Helper function to compare keys ---
|
|
3
|
+
/**
|
|
4
|
+
* Compares a browser event's key with a configured key.
|
|
5
|
+
* - For single character keys (e.g., "a", "A", "7"), comparison is case-insensitive.
|
|
6
|
+
* - For multi-character special keys (e.g., "Enter", "ArrowUp"), comparison is case-sensitive.
|
|
7
|
+
* @param eventKey The `key` property from the `KeyboardEvent`.
|
|
8
|
+
* @param configuredKey The key string from `Keys` used in the configuration.
|
|
9
|
+
* @returns True if the keys match according to the rules, false otherwise.
|
|
10
|
+
*/
|
|
11
|
+
function compareKey(eventKey, configuredKey) {
|
|
12
|
+
if (configuredKey.length === 1 && eventKey.length === 1) {
|
|
13
|
+
return eventKey.toLowerCase() === configuredKey.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
return eventKey === configuredKey;
|
|
16
|
+
}
|
|
17
|
+
// --- Hotkeys Library ---
|
|
18
|
+
/**
|
|
19
|
+
* Manages keyboard shortcuts for web applications.
|
|
20
|
+
* Allows registration of single key combinations (e.g., Ctrl+S) and key sequences (e.g., g -> i).
|
|
21
|
+
* Supports contexts to enable/disable shortcuts based on application state.
|
|
22
|
+
*/
|
|
23
|
+
export class HotKeys {
|
|
24
|
+
static KEYDOWN_EVENT = "keydown";
|
|
25
|
+
static LOG_PREFIX = "Hotkeys:";
|
|
26
|
+
keydown$;
|
|
27
|
+
activeContext$;
|
|
28
|
+
activeShortcuts;
|
|
29
|
+
debugMode;
|
|
30
|
+
/**
|
|
31
|
+
* Creates an instance of Hotkeys.
|
|
32
|
+
* @param initialContext - Optional initial context name. Shortcuts will only trigger if their context matches this, or if they have no context defined.
|
|
33
|
+
* @param debugMode - Optional. If true, debug messages will be logged to the console. Defaults to false.
|
|
34
|
+
* @throws Error if not in a browser environment (i.e., `document` or `performance` is undefined).
|
|
35
|
+
*/
|
|
36
|
+
constructor(initialContext = null, debugMode = false) {
|
|
37
|
+
this.debugMode = debugMode;
|
|
38
|
+
if (typeof document === "undefined" || typeof performance === "undefined") {
|
|
39
|
+
throw new Error(`${HotKeys.LOG_PREFIX} Hotkeys can only be used in a browser environment with global 'document' and 'performance' objects.`);
|
|
40
|
+
}
|
|
41
|
+
this.keydown$ = fromEvent(document, HotKeys.KEYDOWN_EVENT);
|
|
42
|
+
this.activeContext$ = new BehaviorSubject(initialContext);
|
|
43
|
+
this.activeShortcuts = new Map();
|
|
44
|
+
if (this.debugMode) {
|
|
45
|
+
console.log(`${HotKeys.LOG_PREFIX} Library initialized. Initial context: "${initialContext}". Debug mode: ${debugMode}.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Sets the active context for shortcuts.
|
|
50
|
+
* Only shortcuts matching this context (or shortcuts with no specific context defined)
|
|
51
|
+
* will be active and can be triggered.
|
|
52
|
+
* @param contextName - The name of the context (e.g., "modal", "editor", "global").
|
|
53
|
+
* Pass `null` to activate shortcuts with no context or to deactivate context-specific shortcuts.
|
|
54
|
+
*/
|
|
55
|
+
setContext(contextName) {
|
|
56
|
+
if (this.debugMode) {
|
|
57
|
+
console.log(`${HotKeys.LOG_PREFIX} Context changed to "${contextName}"`);
|
|
58
|
+
}
|
|
59
|
+
this.activeContext$.next(contextName);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Gets the current active context.
|
|
63
|
+
* @returns The current context name as a string, or `null` if no context is set.
|
|
64
|
+
*/
|
|
65
|
+
getContext() {
|
|
66
|
+
return this.activeContext$.getValue();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Enables or disables debug logging for the Hotkeys instance.
|
|
70
|
+
* When enabled, various internal actions and shortcut triggers will be logged to the console.
|
|
71
|
+
* @param enable - True to enable debug logs, false to disable.
|
|
72
|
+
*/
|
|
73
|
+
setDebugMode(enable) {
|
|
74
|
+
this.debugMode = enable;
|
|
75
|
+
if (this.debugMode) {
|
|
76
|
+
console.log(`${HotKeys.LOG_PREFIX} Debug mode ${enable ? 'enabled' : 'disabled'}.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Checks if a shortcut with the given ID is currently registered and active.
|
|
81
|
+
* @param id - The unique ID of the shortcut to check.
|
|
82
|
+
* @returns True if a shortcut with the specified ID exists, false otherwise.
|
|
83
|
+
*/
|
|
84
|
+
hasShortcut(id) {
|
|
85
|
+
return this.activeShortcuts.has(id);
|
|
86
|
+
}
|
|
87
|
+
filterByContext(source$, context) {
|
|
88
|
+
return source$.pipe(withLatestFrom(this.activeContext$), filter(([/* event */ , activeCtx]) => context == null || context === activeCtx), map(([event, /* _activeCtx */]) => event));
|
|
89
|
+
}
|
|
90
|
+
_registerShortcut(config, subscription, type, detailsForLog) {
|
|
91
|
+
const existingShortcut = this.activeShortcuts.get(config.id);
|
|
92
|
+
if (existingShortcut) {
|
|
93
|
+
console.warn(`${HotKeys.LOG_PREFIX} Shortcut with ID "${config.id}" already exists. It will be overwritten.`);
|
|
94
|
+
existingShortcut.subscription.unsubscribe();
|
|
95
|
+
}
|
|
96
|
+
this.activeShortcuts.set(config.id, { id: config.id, config, subscription });
|
|
97
|
+
if (this.debugMode) {
|
|
98
|
+
console.log(`${HotKeys.LOG_PREFIX} ${type} shortcut "${config.id}" added. ${detailsForLog}, Context: ${config.context ?? "any"}`);
|
|
99
|
+
}
|
|
100
|
+
return config.id;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Registers a key combination shortcut (e.g., Ctrl+S, Shift+Enter).
|
|
104
|
+
* The callback is triggered when the specified key and modifier keys are pressed simultaneously.
|
|
105
|
+
* @param config - Configuration object for the key combination.
|
|
106
|
+
* See {@link KeyCombinationConfig} for details.
|
|
107
|
+
* The `key` property within `config.keys` must be a value from the `Keys` object.
|
|
108
|
+
* @returns The ID of the registered shortcut if successful, or `undefined` if the configuration is invalid (e.g., empty key).
|
|
109
|
+
* A warning is logged to the console if the configuration is invalid or if a shortcut with the same ID is overwritten.
|
|
110
|
+
* @example
|
|
111
|
+
* ```typescript
|
|
112
|
+
* import { Keys } from './keys';
|
|
113
|
+
* keyManager.addCombination({
|
|
114
|
+
* id: "saveFile",
|
|
115
|
+
* keys: { key: Keys.S, ctrlKey: true },
|
|
116
|
+
* callback: () => console.log("File saved!"),
|
|
117
|
+
* context: "editor"
|
|
118
|
+
* });
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
addCombination(config) {
|
|
122
|
+
const { keys, callback, context, preventDefault = false, id } = config;
|
|
123
|
+
if (!keys || !keys.key || typeof keys.key !== 'string' || keys.key.trim() === '') {
|
|
124
|
+
console.warn(`${HotKeys.LOG_PREFIX} Invalid 'keys.key' for combination shortcut "${id}". Key must be a non-empty value from Keys. Shortcut not added.`);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const configuredMainKey = keys.key;
|
|
128
|
+
const shortcut$ = this.filterByContext(this.keydown$, context).pipe(filter(event => (keys.ctrlKey === undefined || event.ctrlKey === keys.ctrlKey) &&
|
|
129
|
+
(keys.altKey === undefined || event.altKey === keys.altKey) &&
|
|
130
|
+
(keys.shiftKey === undefined || event.shiftKey === keys.shiftKey) &&
|
|
131
|
+
(keys.metaKey === undefined || event.metaKey === keys.metaKey)), filter(event => compareKey(event.key, configuredMainKey)), tap(event => {
|
|
132
|
+
if (this.debugMode) {
|
|
133
|
+
const preventAction = preventDefault ? ", preventing default" : "";
|
|
134
|
+
console.log(`${HotKeys.LOG_PREFIX} Combination "${id}" triggered${preventAction}.`);
|
|
135
|
+
}
|
|
136
|
+
if (preventDefault)
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
}), catchError(err => {
|
|
139
|
+
console.error(`${HotKeys.LOG_PREFIX} Error in combination stream for shortcut "${id}":`, err);
|
|
140
|
+
return EMPTY;
|
|
141
|
+
}));
|
|
142
|
+
const subscription = shortcut$.subscribe(event => {
|
|
143
|
+
try {
|
|
144
|
+
callback(event);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
console.error(`${HotKeys.LOG_PREFIX} Error in user callback for combination shortcut "${id}":`, e);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
const keyDetails = `key: "${keys.key}"` +
|
|
151
|
+
(keys.ctrlKey !== undefined ? `, ctrlKey: ${keys.ctrlKey}` : "") +
|
|
152
|
+
(keys.altKey !== undefined ? `, altKey: ${keys.altKey}` : "") +
|
|
153
|
+
(keys.shiftKey !== undefined ? `, shiftKey: ${keys.shiftKey}` : "") +
|
|
154
|
+
(keys.metaKey !== undefined ? `, metaKey: ${keys.metaKey}` : "");
|
|
155
|
+
return this._registerShortcut(config, subscription, "Combination", `Keys: { ${keyDetails} }`);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Registers a key sequence shortcut (e.g., g -> i, or ArrowUp -> ArrowUp -> ArrowDown).
|
|
159
|
+
* The callback is triggered when the specified keys are pressed in order.
|
|
160
|
+
* An optional timeout can be specified for the time allowed between key presses in the sequence.
|
|
161
|
+
* @param config - Configuration object for the key sequence.
|
|
162
|
+
* See {@link KeySequenceConfig} for details.
|
|
163
|
+
* Each key in the `sequence` array must be a value from the `Keys` object.
|
|
164
|
+
* @returns The ID of the registered shortcut if successful, or `undefined` if the configuration is invalid (e.g., empty sequence or invalid keys).
|
|
165
|
+
* A warning is logged to the console if the configuration is invalid or if a shortcut with the same ID is overwritten.
|
|
166
|
+
* @example
|
|
167
|
+
* ```typescript
|
|
168
|
+
* import { Keys } from './keys';
|
|
169
|
+
* keyManager.addSequence({
|
|
170
|
+
* id: "konamiCode",
|
|
171
|
+
* sequence: [Keys.ArrowUp, Keys.ArrowUp, Keys.ArrowDown, Keys.ArrowDown, Keys.A, Keys.B],
|
|
172
|
+
* callback: () => console.log("Konami!"),
|
|
173
|
+
* sequenceTimeoutMs: 2000 // 2 seconds between keys
|
|
174
|
+
* });
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
addSequence(config) {
|
|
178
|
+
const { sequence, callback, context, preventDefault = false, id, sequenceTimeoutMs } = config;
|
|
179
|
+
if (!Array.isArray(sequence) || sequence.length === 0) {
|
|
180
|
+
console.warn(`${HotKeys.LOG_PREFIX} Sequence for shortcut "${id}" is empty or invalid. Shortcut not added.`);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
if (sequence.some(key => typeof key !== 'string' || key.trim() === '')) {
|
|
184
|
+
console.warn(`${HotKeys.LOG_PREFIX} Invalid key in sequence for shortcut "${id}". All keys must be non-empty strings from Keys. Shortcut not added.`);
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const configuredSequence = sequence;
|
|
188
|
+
const sequenceLength = configuredSequence.length;
|
|
189
|
+
let shortcut$;
|
|
190
|
+
const baseKeydownStream$ = this.filterByContext(this.keydown$, context);
|
|
191
|
+
if (sequenceTimeoutMs && sequenceTimeoutMs > 0) {
|
|
192
|
+
shortcut$ = baseKeydownStream$.pipe(scan((acc, event) => {
|
|
193
|
+
let { matchedEvents, lastEventTime } = acc;
|
|
194
|
+
const currentTime = performance.now();
|
|
195
|
+
if (acc.emitState === 'emit') {
|
|
196
|
+
matchedEvents = [];
|
|
197
|
+
lastEventTime = 0;
|
|
198
|
+
}
|
|
199
|
+
if (matchedEvents.length > 0 && (currentTime - lastEventTime > sequenceTimeoutMs)) {
|
|
200
|
+
if (this.debugMode) {
|
|
201
|
+
console.log(`${HotKeys.LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) attempt timed out. Matched: ${matchedEvents.map(e => e.key).join(',')}. Resetting.`);
|
|
202
|
+
}
|
|
203
|
+
matchedEvents = [];
|
|
204
|
+
}
|
|
205
|
+
const nextExpectedKeyIndex = matchedEvents.length;
|
|
206
|
+
if (nextExpectedKeyIndex >= sequenceLength) {
|
|
207
|
+
if (sequenceLength > 0 && compareKey(event.key, configuredSequence[0])) {
|
|
208
|
+
return { matchedEvents: [event], lastEventTime: currentTime, emitState: 'in-progress' };
|
|
209
|
+
}
|
|
210
|
+
return { matchedEvents: [], lastEventTime: 0, emitState: 'ignore' };
|
|
211
|
+
}
|
|
212
|
+
if (compareKey(event.key, configuredSequence[nextExpectedKeyIndex])) {
|
|
213
|
+
const newMatchedEvents = [...matchedEvents, event];
|
|
214
|
+
if (newMatchedEvents.length === sequenceLength) {
|
|
215
|
+
if (this.debugMode)
|
|
216
|
+
console.log(`${HotKeys.LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) matched.`);
|
|
217
|
+
return { matchedEvents: newMatchedEvents, lastEventTime: currentTime, emitState: 'emit' };
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
return { matchedEvents: newMatchedEvents, lastEventTime: currentTime, emitState: 'in-progress' };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
if (matchedEvents.length > 0 && this.debugMode) {
|
|
225
|
+
console.log(`${HotKeys.LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) broken by key "${event.key}". Matched: ${matchedEvents.map(e => e.key).join(',')}. Resetting.`);
|
|
226
|
+
}
|
|
227
|
+
if (sequenceLength > 0 && compareKey(event.key, configuredSequence[0])) {
|
|
228
|
+
return { matchedEvents: [event], lastEventTime: currentTime, emitState: 'in-progress' };
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
return { matchedEvents: [], lastEventTime: 0, emitState: 'ignore' };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}, { matchedEvents: [], lastEventTime: 0, emitState: 'ignore' }), filter(state => state.emitState === 'emit'), map(state => state.matchedEvents));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
shortcut$ = baseKeydownStream$.pipe(bufferCount(sequenceLength, 1), filter((events) => {
|
|
238
|
+
if (events.length < sequenceLength)
|
|
239
|
+
return false;
|
|
240
|
+
return events.every((event, index) => compareKey(event.key, configuredSequence[index]));
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
const finalShortcut$ = shortcut$.pipe(tap((events) => {
|
|
244
|
+
if (this.debugMode) {
|
|
245
|
+
const timeoutInfo = (sequenceTimeoutMs && sequenceTimeoutMs > 0) ? ` (with timeout logic)` : ` (no timeout logic)`;
|
|
246
|
+
const preventAction = preventDefault ? ", preventing default for last event" : "";
|
|
247
|
+
console.log(`${HotKeys.LOG_PREFIX} Sequence "${id}" triggered${timeoutInfo}${preventAction}.`);
|
|
248
|
+
}
|
|
249
|
+
if (preventDefault && events.length > 0) {
|
|
250
|
+
events[events.length - 1].preventDefault();
|
|
251
|
+
}
|
|
252
|
+
}), catchError(err => {
|
|
253
|
+
console.error(`${HotKeys.LOG_PREFIX} Error in sequence stream for shortcut "${id}":`, err);
|
|
254
|
+
return EMPTY;
|
|
255
|
+
}));
|
|
256
|
+
const subscription = finalShortcut$.subscribe((events) => {
|
|
257
|
+
try {
|
|
258
|
+
if (events.length > 0)
|
|
259
|
+
callback(events[events.length - 1]);
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
console.error(`${HotKeys.LOG_PREFIX} Error in user callback for sequence shortcut "${id}":`, e);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
const logDetails = `Sequence: ${sequence.join(" -> ")}${sequenceTimeoutMs && sequenceTimeoutMs > 0 ? ` (timeout: ${sequenceTimeoutMs}ms)` : ''}`;
|
|
266
|
+
return this._registerShortcut(config, subscription, "Sequence", logDetails);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Removes a registered shortcut by its ID.
|
|
270
|
+
* This will unsubscribe from the underlying keyboard event stream for that shortcut.
|
|
271
|
+
* @param id - The unique ID of the shortcut to remove.
|
|
272
|
+
* @returns True if the shortcut was found and removed, false otherwise.
|
|
273
|
+
* A warning is logged to the console if no shortcut with the given ID is found.
|
|
274
|
+
*/
|
|
275
|
+
remove(id) {
|
|
276
|
+
const shortcut = this.activeShortcuts.get(id);
|
|
277
|
+
if (shortcut) {
|
|
278
|
+
shortcut.subscription.unsubscribe();
|
|
279
|
+
this.activeShortcuts.delete(id);
|
|
280
|
+
if (this.debugMode)
|
|
281
|
+
console.log(`${HotKeys.LOG_PREFIX} Shortcut "${id}" removed.`);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
console.warn(`${HotKeys.LOG_PREFIX} Shortcut with ID "${id}" not found for removal.`);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Retrieves a list of all currently active (registered) shortcut configurations.
|
|
289
|
+
* This can be useful for displaying available shortcuts to the user or for debugging.
|
|
290
|
+
* @returns An array of objects, where each object represents an active shortcut
|
|
291
|
+
* and includes its `id`, `description` (if provided), `context` (if any),
|
|
292
|
+
* and `type` ("combination" or "sequence").
|
|
293
|
+
*/
|
|
294
|
+
getActiveShortcuts() {
|
|
295
|
+
const shortcuts = [];
|
|
296
|
+
for (const [id, activeShortcut] of this.activeShortcuts.entries()) {
|
|
297
|
+
shortcuts.push({
|
|
298
|
+
id,
|
|
299
|
+
description: activeShortcut.config.description,
|
|
300
|
+
context: activeShortcut.config.context,
|
|
301
|
+
type: 'keys' in activeShortcut.config ? "combination" : "sequence"
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return shortcuts;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Cleans up all active subscriptions and resources used by the Hotkeys instance.
|
|
308
|
+
* This method should be called when the Hotkeys instance is no longer needed
|
|
309
|
+
* (e.g., when a component unmounts or the application is shutting down) to prevent memory leaks.
|
|
310
|
+
* After calling `destroy()`, the instance should not be used further.
|
|
311
|
+
*/
|
|
312
|
+
destroy() {
|
|
313
|
+
if (this.debugMode)
|
|
314
|
+
console.log(`${HotKeys.LOG_PREFIX} Destroying library instance and unsubscribing all shortcuts.`);
|
|
315
|
+
this.activeShortcuts.forEach(shortcut => shortcut.subscription.unsubscribe());
|
|
316
|
+
this.activeShortcuts.clear();
|
|
317
|
+
this.activeContext$.complete(); // Complete the BehaviorSubject to release its resources
|
|
318
|
+
if (this.debugMode)
|
|
319
|
+
console.log(`${HotKeys.LOG_PREFIX} Library destroyed.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hotkeys.test.d.ts","sourceRoot":"","sources":["../src/hotkeys.test.ts"],"names":[],"mappings":""}
|