rx-hotkeys 2.6.0 → 3.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 +136 -98
- package/dist/hotkeys.d.ts +88 -37
- package/dist/hotkeys.d.ts.map +1 -1
- package/dist/hotkeys.js +228 -97
- package/dist/hotkeys.test.js +402 -238
- package/dist/keys.d.ts +6 -0
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +21 -0
- package/dist/testutils.d.ts +7 -4
- package/dist/testutils.d.ts.map +1 -1
- package/dist/testutils.js +9 -6
- package/package.json +1 -1
- package/dist/tt.d.ts +0 -125
- package/dist/tt.d.ts.map +0 -1
- package/dist/tt.js +0 -384
- package/dist/ttt.d.ts +0 -164
- package/dist/ttt.d.ts.map +0 -1
- package/dist/ttt.js +0 -439
- package/dist/tttt.d.ts +0 -67
- package/dist/tttt.d.ts.map +0 -1
- package/dist/tttt.js +0 -301
package/dist/hotkeys.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { fromEvent, BehaviorSubject, EMPTY, filter, map, bufferCount, withLatestFrom, tap, catchError, scan, merge, } from "rxjs";
|
|
1
|
+
import { fromEvent, BehaviorSubject, EMPTY, filter, map, bufferCount, withLatestFrom, tap, catchError, scan, merge, Subject, takeUntil, share, } from "rxjs";
|
|
2
|
+
import { Keys, KeyAliases } from "./keys.js";
|
|
2
3
|
// --- Enums, Interfaces and Types ---
|
|
3
4
|
export var ShortcutTypes;
|
|
4
5
|
(function (ShortcutTypes) {
|
|
@@ -26,6 +27,28 @@ function compareKey(eventKey, configuredKey) {
|
|
|
26
27
|
}
|
|
27
28
|
return eventKey === configuredKey;
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Normalizes a string representation of a key into a canonical StandardKey.
|
|
32
|
+
* Handles case-insensitivity, aliases, and special characters.
|
|
33
|
+
* @param key The raw key string to normalize.
|
|
34
|
+
* @returns A StandardKey if valid, otherwise null.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeKey(key) {
|
|
37
|
+
// 1. Handle spacebar explicitly to avoid trimming
|
|
38
|
+
if (key === Keys.Space) {
|
|
39
|
+
return Keys.Space;
|
|
40
|
+
}
|
|
41
|
+
// 2. Trim and convert to lower case for consistent matching
|
|
42
|
+
const normalizedStr = key.trim().toLowerCase();
|
|
43
|
+
if (normalizedStr === "") {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// 3. Look up in aliases, then in standard key values, then check for single char
|
|
47
|
+
const finalKey = KeyAliases[normalizedStr] ||
|
|
48
|
+
Object.values(Keys).find(k => k.toLowerCase() === normalizedStr) ||
|
|
49
|
+
(normalizedStr.length === 1 ? normalizedStr.toUpperCase() : undefined);
|
|
50
|
+
return finalKey || null;
|
|
51
|
+
}
|
|
29
52
|
// --- Hotkeys Library ---
|
|
30
53
|
/**
|
|
31
54
|
* Manages keyboard shortcuts for web applications.
|
|
@@ -34,8 +57,10 @@ function compareKey(eventKey, configuredKey) {
|
|
|
34
57
|
*/
|
|
35
58
|
export class Hotkeys {
|
|
36
59
|
static KEYDOWN_EVENT = "keydown";
|
|
60
|
+
static KEYUP_EVENT = "keyup";
|
|
37
61
|
static LOG_PREFIX = "Hotkeys:";
|
|
38
|
-
|
|
62
|
+
keydownStreams;
|
|
63
|
+
keyupStreams;
|
|
39
64
|
activeContext$;
|
|
40
65
|
activeShortcuts;
|
|
41
66
|
debugMode;
|
|
@@ -50,13 +75,32 @@ export class Hotkeys {
|
|
|
50
75
|
if (typeof document === "undefined" || typeof performance === "undefined") {
|
|
51
76
|
throw new Error(`${Hotkeys.LOG_PREFIX} Hotkeys can only be used in a browser environment with global "document" and "performance" objects.`);
|
|
52
77
|
}
|
|
53
|
-
this.
|
|
78
|
+
this.keydownStreams = new WeakMap();
|
|
79
|
+
this.keyupStreams = new WeakMap();
|
|
54
80
|
this.activeContext$ = new BehaviorSubject(initialContext);
|
|
55
81
|
this.activeShortcuts = new Map();
|
|
56
82
|
if (this.debugMode) {
|
|
57
83
|
console.log(`${Hotkeys.LOG_PREFIX} Library initialized. Initial context: "${initialContext}". Debug mode: ${debugMode}.`);
|
|
58
84
|
}
|
|
59
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Gets or creates a shared event stream for a given event type and target.
|
|
88
|
+
* @param eventType The type of event ("keydown" or "keyup").
|
|
89
|
+
* @param target The DOM element to attach the listener to.
|
|
90
|
+
* @returns A shared Observable for the specified event.
|
|
91
|
+
*/
|
|
92
|
+
_getEventStream(eventType, target) {
|
|
93
|
+
const streamCache = eventType === "keydown" ? this.keydownStreams : this.keyupStreams;
|
|
94
|
+
if (!streamCache.has(target)) {
|
|
95
|
+
const newStream = fromEvent(target, eventType).pipe(share());
|
|
96
|
+
streamCache.set(target, newStream);
|
|
97
|
+
if (this.debugMode) {
|
|
98
|
+
const targetName = target === document ? "document" : `element "${target.id || target.tagName}"`;
|
|
99
|
+
console.log(`${Hotkeys.LOG_PREFIX} Created new shared listener for "${eventType}" on ${targetName}.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return streamCache.get(target);
|
|
103
|
+
}
|
|
60
104
|
/**
|
|
61
105
|
* Sets the active context for shortcuts.
|
|
62
106
|
* Only shortcuts matching this context (or shortcuts with no specific context defined)
|
|
@@ -159,31 +203,30 @@ export class Hotkeys {
|
|
|
159
203
|
* @returns True if the shortcutConfig matches the event, false otherwise.
|
|
160
204
|
*/
|
|
161
205
|
_shortcutMatchesEvent(shortcutConfig, event) {
|
|
162
|
-
|
|
206
|
+
// Use the same robust heuristic here for consistency ---
|
|
207
|
+
let keyTriggers;
|
|
208
|
+
const configKeys = shortcutConfig.keys;
|
|
209
|
+
if (typeof configKeys === "string" && configKeys.length > 1 && configKeys.includes("+")) {
|
|
210
|
+
keyTriggers = this._parseCombinationString(configKeys);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
keyTriggers = Array.isArray(configKeys) ? configKeys : [configKeys];
|
|
214
|
+
}
|
|
163
215
|
for (const keyInput of keyTriggers) {
|
|
164
216
|
let configuredMainKey;
|
|
165
217
|
let ctrlKeyConfig;
|
|
166
218
|
let altKeyConfig;
|
|
167
219
|
let shiftKeyConfig;
|
|
168
220
|
let metaKeyConfig;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
else {
|
|
179
|
-
if (!keyInput.key || keyInput.key === "")
|
|
180
|
-
continue; // Invalid trigger, skip
|
|
181
|
-
configuredMainKey = keyInput.key;
|
|
182
|
-
ctrlKeyConfig = keyInput.ctrlKey;
|
|
183
|
-
altKeyConfig = keyInput.altKey;
|
|
184
|
-
shiftKeyConfig = keyInput.shiftKey;
|
|
185
|
-
metaKeyConfig = keyInput.metaKey;
|
|
186
|
-
}
|
|
221
|
+
// This logic is now simplified because _parseKeyTrigger handles normalization
|
|
222
|
+
const parsed = this._parseKeyTrigger(keyInput, shortcutConfig.id);
|
|
223
|
+
if (!parsed)
|
|
224
|
+
continue;
|
|
225
|
+
configuredMainKey = parsed.configuredMainKey;
|
|
226
|
+
ctrlKeyConfig = parsed.ctrlKeyConfig;
|
|
227
|
+
altKeyConfig = parsed.altKeyConfig;
|
|
228
|
+
shiftKeyConfig = parsed.shiftKeyConfig;
|
|
229
|
+
metaKeyConfig = parsed.metaKeyConfig;
|
|
187
230
|
const keyMatch = compareKey(event.key, configuredMainKey);
|
|
188
231
|
if (!keyMatch)
|
|
189
232
|
continue;
|
|
@@ -212,17 +255,17 @@ export class Hotkeys {
|
|
|
212
255
|
}
|
|
213
256
|
}), map(([event, /* _activeCtx */]) => event));
|
|
214
257
|
}
|
|
215
|
-
_registerShortcut(config,
|
|
258
|
+
_registerShortcut(config, terminator$, type, detailsForLog) {
|
|
216
259
|
const existingShortcut = this.activeShortcuts.get(config.id);
|
|
217
260
|
if (existingShortcut) {
|
|
218
|
-
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut with ID "${config.id}" already exists.
|
|
219
|
-
existingShortcut.
|
|
261
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut with ID "${config.id}" already exists. The old instance will be terminated and overwritten.`);
|
|
262
|
+
existingShortcut.terminator$.next();
|
|
263
|
+
existingShortcut.terminator$.complete();
|
|
220
264
|
}
|
|
221
|
-
this.activeShortcuts.set(config.id, { id: config.id, config,
|
|
265
|
+
this.activeShortcuts.set(config.id, { id: config.id, config, terminator$ });
|
|
222
266
|
if (this.debugMode) {
|
|
223
267
|
console.log(`${Hotkeys.LOG_PREFIX} ${type} shortcut "${config.id}" added. ${detailsForLog}, Context: ${config.context ?? "any"}`);
|
|
224
268
|
}
|
|
225
|
-
return config.id;
|
|
226
269
|
}
|
|
227
270
|
/**
|
|
228
271
|
* Parses a single key trigger definition (either shorthand StandardKey or an object with modifiers)
|
|
@@ -233,17 +276,18 @@ export class Hotkeys {
|
|
|
233
276
|
*/
|
|
234
277
|
_parseKeyTrigger(keyInput, shortcutId) {
|
|
235
278
|
if (typeof keyInput === "string") {
|
|
236
|
-
|
|
237
|
-
|
|
279
|
+
const finalKey = normalizeKey(keyInput);
|
|
280
|
+
if (!finalKey) {
|
|
281
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Could not parse key: "${keyInput}" in shortcut "${shortcutId}".`);
|
|
238
282
|
return null;
|
|
239
283
|
}
|
|
240
284
|
return {
|
|
241
|
-
configuredMainKey:
|
|
285
|
+
configuredMainKey: finalKey,
|
|
242
286
|
ctrlKeyConfig: false,
|
|
243
287
|
altKeyConfig: false,
|
|
244
288
|
shiftKeyConfig: false,
|
|
245
289
|
metaKeyConfig: false,
|
|
246
|
-
logDetails: `key: "${
|
|
290
|
+
logDetails: `key: "${finalKey}" (no mods)`,
|
|
247
291
|
};
|
|
248
292
|
}
|
|
249
293
|
else {
|
|
@@ -251,11 +295,22 @@ export class Hotkeys {
|
|
|
251
295
|
console.warn(`${Hotkeys.LOG_PREFIX} Invalid "key" property in shortcut "${shortcutId}". Key must be a non-empty string value from Keys.`);
|
|
252
296
|
return null;
|
|
253
297
|
}
|
|
298
|
+
const hasModifiers = keyInput.ctrlKey || keyInput.altKey || keyInput.shiftKey || keyInput.metaKey;
|
|
299
|
+
if (!hasModifiers) {
|
|
300
|
+
return {
|
|
301
|
+
configuredMainKey: keyInput.key,
|
|
302
|
+
ctrlKeyConfig: false,
|
|
303
|
+
altKeyConfig: false,
|
|
304
|
+
shiftKeyConfig: false,
|
|
305
|
+
metaKeyConfig: false,
|
|
306
|
+
logDetails: `key: "${keyInput.key}" (no mods)`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
254
309
|
const logDetails = `key: "${keyInput.key}"` +
|
|
255
|
-
(keyInput.ctrlKey
|
|
256
|
-
(keyInput.altKey
|
|
257
|
-
(keyInput.shiftKey
|
|
258
|
-
(keyInput.metaKey
|
|
310
|
+
(keyInput.ctrlKey ? `, ctrl: true` : "") +
|
|
311
|
+
(keyInput.altKey ? `, alt: true` : "") +
|
|
312
|
+
(keyInput.shiftKey ? `, shift: true` : "") +
|
|
313
|
+
(keyInput.metaKey ? `, meta: true` : "");
|
|
259
314
|
return {
|
|
260
315
|
configuredMainKey: keyInput.key,
|
|
261
316
|
ctrlKeyConfig: keyInput.ctrlKey,
|
|
@@ -266,53 +321,118 @@ export class Hotkeys {
|
|
|
266
321
|
};
|
|
267
322
|
}
|
|
268
323
|
}
|
|
324
|
+
_parseCombinationString(shortcut) {
|
|
325
|
+
const parts = shortcut.toLowerCase().split("+").map(p => p.trim());
|
|
326
|
+
const mainKeyStr = parts.pop();
|
|
327
|
+
if (!mainKeyStr) {
|
|
328
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Invalid shortcut string: "${shortcut}". No main key found.`);
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
const trigger = {
|
|
332
|
+
key: "",
|
|
333
|
+
ctrlKey: false, altKey: false, shiftKey: false, metaKey: false
|
|
334
|
+
};
|
|
335
|
+
const finalKey = normalizeKey(mainKeyStr);
|
|
336
|
+
if (!finalKey) {
|
|
337
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Could not parse key: "${mainKeyStr}" in shortcut string "${shortcut}".`);
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
trigger.key = finalKey;
|
|
341
|
+
for (const part of parts) {
|
|
342
|
+
if (part === "ctrl" || part === "control")
|
|
343
|
+
trigger.ctrlKey = true;
|
|
344
|
+
else if (part === "alt" || part === "option")
|
|
345
|
+
trigger.altKey = true;
|
|
346
|
+
else if (part === "shift")
|
|
347
|
+
trigger.shiftKey = true;
|
|
348
|
+
else if (part === "meta" || part === "cmd" || part === "command" || part === "win")
|
|
349
|
+
trigger.metaKey = true;
|
|
350
|
+
else
|
|
351
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Unknown modifier: "${part}" in shortcut string "${shortcut}".`);
|
|
352
|
+
}
|
|
353
|
+
return [trigger];
|
|
354
|
+
}
|
|
355
|
+
_parseSequenceString(sequence) {
|
|
356
|
+
const keyStrings = sequence.split("->").map(k => k.trim());
|
|
357
|
+
const results = [];
|
|
358
|
+
for (const keyStr of keyStrings) {
|
|
359
|
+
const finalKey = normalizeKey(keyStr);
|
|
360
|
+
if (finalKey) {
|
|
361
|
+
results.push(finalKey);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Could not parse key: "${keyStr}" in sequence string "${sequence}".`);
|
|
365
|
+
return []; // Fail fast
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return results;
|
|
369
|
+
}
|
|
269
370
|
/**
|
|
270
|
-
* Registers a key combination shortcut (e.g., Ctrl+S, Shift+Enter, or a single key like Escape)
|
|
271
|
-
*
|
|
371
|
+
* Registers a key combination shortcut (e.g., Ctrl+S, Shift+Enter, or a single key like Escape)
|
|
372
|
+
* and returns an Observable that emits the `KeyboardEvent` when the combination is triggered.
|
|
272
373
|
* @param config - Configuration object for the key combination.
|
|
273
374
|
* See {@link KeyCombinationConfig} for details.
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
375
|
+
* @returns An `Observable<KeyboardEvent>` that you can subscribe to. The stream will be automatically
|
|
376
|
+
* completed if the shortcut is removed via `remove(id)` or `destroy()`, or if it's overwritten.
|
|
377
|
+
* If the configuration is invalid, an empty Observable is returned and a warning is logged.
|
|
277
378
|
* @example
|
|
278
379
|
* ```typescript
|
|
279
380
|
* import { Keys } from "./keys";
|
|
280
381
|
* // For Ctrl+S
|
|
281
|
-
* keyManager.addCombination({
|
|
382
|
+
* const save$ = keyManager.addCombination({
|
|
282
383
|
* id: "saveFile",
|
|
283
384
|
* keys: { key: Keys.S, ctrlKey: true },
|
|
284
|
-
* callback: () => console.log("File saved!"),
|
|
285
385
|
* context: "editor"
|
|
286
386
|
* });
|
|
387
|
+
* save$.subscribe(event => console.log("File saved!", event));
|
|
388
|
+
*
|
|
389
|
+
* // For Ctrl+S using a string
|
|
390
|
+
* const save$ = keyManager.addCombination({ id: "saveFile", keys: "ctrl+s" });
|
|
391
|
+
* save$.subscribe(event => console.log("File saved!", event));
|
|
392
|
+
*
|
|
287
393
|
* // For just the Escape key, or Ctrl+Space
|
|
288
|
-
* keyManager.addCombination({
|
|
394
|
+
* const close$ = keyManager.addCombination({
|
|
289
395
|
* id: "closeModal",
|
|
290
396
|
* keys: [Keys.Escape, {key: Keys.Space, ctrlKey: true}],
|
|
291
|
-
* callback: () => console.log("Modal closed!")
|
|
292
397
|
* });
|
|
398
|
+
* close$.subscribe(() => console.log("Modal closed!"));
|
|
399
|
+
*
|
|
400
|
+
* // For the Escape key on a specific element
|
|
401
|
+
* const myModal = document.getElementById("my-modal");
|
|
402
|
+
* const close$ = keyManager.addCombination({ id: "closeModal", keys: Keys.Escape, target: myModal });
|
|
403
|
+
* close$.subscribe(() => console.log("Modal closed!"));
|
|
293
404
|
* ```
|
|
294
405
|
*/
|
|
295
406
|
addCombination(config) {
|
|
296
|
-
const { keys,
|
|
407
|
+
const { keys, context, preventDefault = false, id, strict = false, target = document, event: eventType = "keydown" } = config;
|
|
408
|
+
if (config.callback) {
|
|
409
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" was provided a callback, but "addCombination" now returns an Observable. The callback will be ignored. Please subscribe to the returned Observable instead.`);
|
|
410
|
+
}
|
|
297
411
|
if (context != null && strict) {
|
|
298
|
-
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" has both a context(${context}) and the
|
|
412
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" has both a context(${context}) and the "strict" flag. The "strict" flag will be ignored.`);
|
|
413
|
+
}
|
|
414
|
+
let keyTriggers;
|
|
415
|
+
if (typeof keys === "string" && keys.length > 1 && keys.includes("+")) {
|
|
416
|
+
keyTriggers = this._parseCombinationString(keys);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
keyTriggers = Array.isArray(keys) ? keys : [keys];
|
|
299
420
|
}
|
|
300
|
-
const keyTriggers = Array.isArray(keys) ? keys : [keys];
|
|
301
421
|
if (keyTriggers.length === 0) {
|
|
302
|
-
console.warn(`${Hotkeys.LOG_PREFIX} "keys"
|
|
303
|
-
return
|
|
422
|
+
console.warn(`${Hotkeys.LOG_PREFIX} "keys" definition for combination shortcut "${id}" is empty or invalid. Shortcut not added.`);
|
|
423
|
+
return EMPTY;
|
|
304
424
|
}
|
|
425
|
+
const sourceStream$ = this._getEventStream(eventType, target);
|
|
305
426
|
const observables = [];
|
|
306
427
|
const logParts = [];
|
|
307
428
|
for (const keyInput of keyTriggers) {
|
|
308
429
|
const parsedTrigger = this._parseKeyTrigger(keyInput, id);
|
|
309
430
|
if (!parsedTrigger) {
|
|
310
|
-
|
|
311
|
-
return undefined;
|
|
431
|
+
return EMPTY;
|
|
312
432
|
}
|
|
313
433
|
const { configuredMainKey, ctrlKeyConfig, altKeyConfig, shiftKeyConfig, metaKeyConfig, logDetails } = parsedTrigger;
|
|
314
434
|
logParts.push(`{ ${logDetails} }`);
|
|
315
|
-
const stream = this.filterByContext(
|
|
435
|
+
const stream = this.filterByContext(sourceStream$, context, strict).pipe(filter(event => {
|
|
316
436
|
const ctrlMatch = (ctrlKeyConfig === undefined) ? true : (event.ctrlKey === ctrlKeyConfig);
|
|
317
437
|
const altMatch = (altKeyConfig === undefined) ? true : (event.altKey === altKeyConfig);
|
|
318
438
|
const shiftMatch = (shiftKeyConfig === undefined) ? true : (event.shiftKey === shiftKeyConfig);
|
|
@@ -331,7 +451,7 @@ export class Hotkeys {
|
|
|
331
451
|
}
|
|
332
452
|
for (const [, otherAS] of this.activeShortcuts) {
|
|
333
453
|
if (otherAS.config.id !== id &&
|
|
334
|
-
|
|
454
|
+
"keys" in otherAS.config &&
|
|
335
455
|
otherAS.config.context === currentSpecificContext &&
|
|
336
456
|
this._shortcutMatchesEvent(otherAS.config, event)) {
|
|
337
457
|
if (this.debugMode) {
|
|
@@ -345,13 +465,15 @@ export class Hotkeys {
|
|
|
345
465
|
observables.push(stream);
|
|
346
466
|
}
|
|
347
467
|
if (observables.length === 0) {
|
|
348
|
-
//
|
|
468
|
+
// This path should now be much harder to hit, but remains a safeguard.
|
|
349
469
|
console.warn(`${Hotkeys.LOG_PREFIX} No valid key triggers for combination shortcut "${id}". Shortcut not added.`);
|
|
350
|
-
return
|
|
470
|
+
return EMPTY;
|
|
351
471
|
}
|
|
472
|
+
const terminator$ = new Subject();
|
|
352
473
|
const finalShortcut$ = merge(...observables);
|
|
353
|
-
const overallLogDetails =
|
|
354
|
-
|
|
474
|
+
const overallLogDetails = `Triggers: [ ${logParts.join(", ")} ]`;
|
|
475
|
+
this._registerShortcut(config, terminator$, ShortcutTypes.Combination, overallLogDetails);
|
|
476
|
+
return finalShortcut$.pipe(tap(event => {
|
|
355
477
|
if (this.debugMode) {
|
|
356
478
|
const preventAction = preventDefault ? ", preventing default" : "";
|
|
357
479
|
console.log(`${Hotkeys.LOG_PREFIX} Combination "${id}" triggered by key "${event.key}" ${preventAction}.`);
|
|
@@ -361,54 +483,67 @@ export class Hotkeys {
|
|
|
361
483
|
}), catchError(err => {
|
|
362
484
|
console.error(`${Hotkeys.LOG_PREFIX} Error in combination stream for shortcut "${id}":`, err);
|
|
363
485
|
return EMPTY;
|
|
364
|
-
}))
|
|
365
|
-
try {
|
|
366
|
-
callback(event);
|
|
367
|
-
}
|
|
368
|
-
catch (e) {
|
|
369
|
-
console.error(`${Hotkeys.LOG_PREFIX} Error in user callback for combination shortcut "${id}":`, e);
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
return this._registerShortcut(config, subscription, ShortcutTypes.Combination, overallLogDetails);
|
|
486
|
+
}), takeUntil(terminator$));
|
|
373
487
|
}
|
|
374
488
|
/**
|
|
375
|
-
* Registers a key sequence shortcut (e.g., g -> i, or ArrowUp -> ArrowUp -> ArrowDown)
|
|
376
|
-
*
|
|
489
|
+
* Registers a key sequence shortcut (e.g., g -> i, or ArrowUp -> ArrowUp -> ArrowDown)
|
|
490
|
+
* and returns an Observable that emits the final `KeyboardEvent` of the sequence when it's completed.
|
|
377
491
|
* An optional timeout can be specified for the time allowed between key presses in the sequence.
|
|
378
492
|
* @param config - Configuration object for the key sequence.
|
|
379
493
|
* See {@link KeySequenceConfig} for details.
|
|
380
494
|
* Each key in the `sequence` array must be a value from the `Keys` object.
|
|
381
|
-
*
|
|
382
|
-
*
|
|
495
|
+
* Or using string for `sequence`.
|
|
496
|
+
* @returns An `Observable<KeyboardEvent>` that you can subscribe to. The stream will be automatically
|
|
497
|
+
* completed if the shortcut is removed via `remove(id)` or `destroy()`, or if it's overwritten.
|
|
498
|
+
* If the configuration is invalid, an empty Observable is returned and a warning is logged.
|
|
383
499
|
* @example
|
|
384
500
|
* ```typescript
|
|
385
501
|
* import { Keys } from "./keys";
|
|
386
|
-
* keyManager.addSequence({
|
|
502
|
+
* const konami$ = keyManager.addSequence({
|
|
387
503
|
* id: "konamiCode",
|
|
388
504
|
* sequence: [Keys.ArrowUp, Keys.ArrowUp, Keys.ArrowDown, Keys.ArrowDown, Keys.A, Keys.B],
|
|
389
|
-
* callback: () => console.log("Konami!"),
|
|
390
505
|
* sequenceTimeoutMs: 2000 // 2 seconds between keys
|
|
391
506
|
* });
|
|
507
|
+
* konami$.subscribe(event => console.log("Konami!", event));
|
|
508
|
+
* ```
|
|
509
|
+
* ```typescript
|
|
510
|
+
* // Using a string for the sequence
|
|
511
|
+
* const konami$ = keyManager.addSequence({
|
|
512
|
+
* id: "konamiCode",
|
|
513
|
+
* sequence: "up -> up -> down -> down -> a -> b",
|
|
514
|
+
* sequenceTimeoutMs: 2000
|
|
515
|
+
* });
|
|
516
|
+
* konami$.subscribe(event => console.log("Konami!", event));
|
|
392
517
|
* ```
|
|
393
518
|
*/
|
|
394
519
|
addSequence(config) {
|
|
395
|
-
const { sequence,
|
|
396
|
-
if (
|
|
520
|
+
const { sequence, context, preventDefault = false, id, sequenceTimeoutMs, strict = false, target = document, event: eventType = "keydown" } = config;
|
|
521
|
+
if (config.callback) {
|
|
522
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" was provided a callback, but "addSequence" now returns an Observable. The callback will be ignored. Please subscribe to the returned Observable instead.`);
|
|
523
|
+
}
|
|
524
|
+
let configuredSequence;
|
|
525
|
+
if (typeof sequence === "string") {
|
|
526
|
+
configuredSequence = this._parseSequenceString(sequence);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
configuredSequence = sequence;
|
|
530
|
+
}
|
|
531
|
+
if (!Array.isArray(configuredSequence) || configuredSequence.length === 0) {
|
|
397
532
|
console.warn(`${Hotkeys.LOG_PREFIX} Sequence for shortcut "${id}" is empty or invalid. Shortcut not added.`);
|
|
398
|
-
return
|
|
533
|
+
return EMPTY;
|
|
399
534
|
}
|
|
400
535
|
// Corrected validation: Check for actual empty string, not a string that trims to empty.
|
|
401
|
-
if (
|
|
536
|
+
if (configuredSequence.some(key => typeof key !== "string" || key === "")) { // StandardKey type should prevent empty strings.
|
|
402
537
|
console.warn(`${Hotkeys.LOG_PREFIX} Invalid key in sequence for shortcut "${id}". All keys must be non-empty string values from Keys. Shortcut not added.`);
|
|
403
|
-
return
|
|
538
|
+
return EMPTY;
|
|
404
539
|
}
|
|
405
540
|
if (context && strict) {
|
|
406
|
-
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" has both a context and the
|
|
541
|
+
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" has both a context and the "strict" flag. The "strict" flag will be ignored.`);
|
|
407
542
|
}
|
|
408
|
-
const configuredSequence = sequence;
|
|
409
543
|
const sequenceLength = configuredSequence.length;
|
|
410
544
|
let shortcut$;
|
|
411
|
-
const
|
|
545
|
+
const sourceStream$ = this._getEventStream(eventType, target);
|
|
546
|
+
const baseKeydownStream$ = this.filterByContext(sourceStream$, context, strict);
|
|
412
547
|
if (sequenceTimeoutMs && sequenceTimeoutMs > 0) {
|
|
413
548
|
shortcut$ = baseKeydownStream$.pipe(scan((acc, event) => {
|
|
414
549
|
let { matchedEvents, lastEventTime } = acc;
|
|
@@ -465,6 +600,7 @@ export class Hotkeys {
|
|
|
465
600
|
return events.every((event, index) => compareKey(event.key, configuredSequence[index]));
|
|
466
601
|
}));
|
|
467
602
|
}
|
|
603
|
+
const terminator$ = new Subject();
|
|
468
604
|
const finalShortcutWithPriority$ = shortcut$.pipe(filter((completedEvents) => {
|
|
469
605
|
if (context != null || strict) { // This sequence is NOT global or strict
|
|
470
606
|
return true;
|
|
@@ -478,7 +614,7 @@ export class Hotkeys {
|
|
|
478
614
|
if (otherAS.config.id !== id &&
|
|
479
615
|
"sequence" in otherAS.config &&
|
|
480
616
|
otherAS.config.context === currentSpecificContext &&
|
|
481
|
-
this._areSequencesIdentical(
|
|
617
|
+
this._areSequencesIdentical(configuredSequence, typeof otherAS.config.sequence === "string" ? this._parseSequenceString(otherAS.config.sequence) : otherAS.config.sequence)) {
|
|
482
618
|
if (this.debugMode) {
|
|
483
619
|
console.log(`${Hotkeys.LOG_PREFIX} Global sequence shortcut "${id}" suppressed by identical specific-context shortcut "${otherAS.config.id}".`);
|
|
484
620
|
}
|
|
@@ -499,22 +635,13 @@ export class Hotkeys {
|
|
|
499
635
|
console.error(`${Hotkeys.LOG_PREFIX} Error in sequence stream for shortcut "${id}":`, err);
|
|
500
636
|
return EMPTY;
|
|
501
637
|
}));
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (events.length > 0)
|
|
506
|
-
callback(events[events.length - 1]);
|
|
507
|
-
}
|
|
508
|
-
catch (e) {
|
|
509
|
-
console.error(`${Hotkeys.LOG_PREFIX} Error in user callback for sequence shortcut "${id}":`, e);
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
|
-
const logDetails = `Sequence: ${sequence.join(" -> ")}${sequenceTimeoutMs && sequenceTimeoutMs > 0 ? ` (timeout: ${sequenceTimeoutMs}ms)` : ""}`;
|
|
513
|
-
return this._registerShortcut(config, subscription, ShortcutTypes.Sequence, logDetails);
|
|
638
|
+
const logDetails = `Sequence: ${configuredSequence.join(" -> ")}${sequenceTimeoutMs && sequenceTimeoutMs > 0 ? ` (timeout: ${sequenceTimeoutMs}ms)` : ""}`;
|
|
639
|
+
this._registerShortcut(config, terminator$, ShortcutTypes.Sequence, logDetails);
|
|
640
|
+
return finalShortcutWithPriority$.pipe(map((events) => events[events.length - 1]), takeUntil(terminator$));
|
|
514
641
|
}
|
|
515
642
|
/**
|
|
516
643
|
* Removes a registered shortcut by its ID.
|
|
517
|
-
* This will
|
|
644
|
+
* This will complete the corresponding Observable stream for any subscribers.
|
|
518
645
|
* @param id - The unique ID of the shortcut to remove.
|
|
519
646
|
* @returns True if the shortcut was found and removed, false otherwise.
|
|
520
647
|
* A warning is logged to the console if no shortcut with the given ID is found.
|
|
@@ -522,7 +649,8 @@ export class Hotkeys {
|
|
|
522
649
|
remove(id) {
|
|
523
650
|
const shortcut = this.activeShortcuts.get(id);
|
|
524
651
|
if (shortcut) {
|
|
525
|
-
shortcut.
|
|
652
|
+
shortcut.terminator$.next();
|
|
653
|
+
shortcut.terminator$.complete();
|
|
526
654
|
this.activeShortcuts.delete(id);
|
|
527
655
|
if (this.debugMode)
|
|
528
656
|
console.log(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" removed.`);
|
|
@@ -558,8 +686,11 @@ export class Hotkeys {
|
|
|
558
686
|
*/
|
|
559
687
|
destroy() {
|
|
560
688
|
if (this.debugMode)
|
|
561
|
-
console.log(`${Hotkeys.LOG_PREFIX} Destroying library instance and
|
|
562
|
-
this.activeShortcuts.forEach(shortcut =>
|
|
689
|
+
console.log(`${Hotkeys.LOG_PREFIX} Destroying library instance and terminating all shortcut streams.`);
|
|
690
|
+
this.activeShortcuts.forEach(shortcut => {
|
|
691
|
+
shortcut.terminator$.next();
|
|
692
|
+
shortcut.terminator$.complete();
|
|
693
|
+
});
|
|
563
694
|
this.activeShortcuts.clear();
|
|
564
695
|
this.activeContext$.complete();
|
|
565
696
|
if (this.debugMode)
|