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/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
- keydown$;
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.keydown$ = fromEvent(document, Hotkeys.KEYDOWN_EVENT);
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
- const keyTriggers = Array.isArray(shortcutConfig.keys) ? shortcutConfig.keys : [shortcutConfig.keys];
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
- if (typeof keyInput === "string") {
170
- if (keyInput === "")
171
- continue; // Invalid trigger, skip
172
- configuredMainKey = keyInput;
173
- ctrlKeyConfig = false;
174
- altKeyConfig = false;
175
- shiftKeyConfig = false;
176
- metaKeyConfig = false;
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, subscription, type, detailsForLog) {
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. It will be overwritten.`);
219
- existingShortcut.subscription.unsubscribe();
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, subscription });
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
- if (keyInput === "") {
237
- console.warn(`${Hotkeys.LOG_PREFIX} Invalid key (shorthand) in shortcut "${shortcutId}". Key string must not be empty.`);
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: keyInput,
285
+ configuredMainKey: finalKey,
242
286
  ctrlKeyConfig: false,
243
287
  altKeyConfig: false,
244
288
  shiftKeyConfig: false,
245
289
  metaKeyConfig: false,
246
- logDetails: `key: "${keyInput}" (no mods)`,
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 !== undefined ? `, ctrl: ${keyInput.ctrlKey}` : "") +
256
- (keyInput.altKey !== undefined ? `, alt: ${keyInput.altKey}` : "") +
257
- (keyInput.shiftKey !== undefined ? `, shift: ${keyInput.shiftKey}` : "") +
258
- (keyInput.metaKey !== undefined ? `, meta: ${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
- * The callback is triggered when the specified key and modifier keys (if any) are pressed.
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
- * The `key` property (or the direct `StandardKey` if using shorthand) must be a value from the `Keys` object.
275
- * @returns The ID of the registered shortcut if successful, or `undefined` if the configuration is invalid.
276
- * A warning is logged to the console if the configuration is invalid or if a shortcut with the same ID is overwritten.
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, callback, context, preventDefault = false, id, strict = false } = config;
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 'strict' flag. The 'strict' flag will be ignored.`);
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" array for combination shortcut "${id}" is empty. Shortcut not added.`);
303
- return undefined;
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
- // Error already logged by _parseKeyTrigger
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(this.keydown$, context, strict).pipe(filter(event => {
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
- 'keys' in otherAS.config &&
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
- // Should be caught by keyTriggers.length === 0, but as a safeguard.
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 undefined;
470
+ return EMPTY;
351
471
  }
472
+ const terminator$ = new Subject();
352
473
  const finalShortcut$ = merge(...observables);
353
- const overallLogDetails = Array.isArray(keys) ? `Triggers: [ ${logParts.join(", ")} ]` : logParts[0];
354
- const subscription = finalShortcut$.pipe(tap(event => {
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
- })).subscribe(event => {
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
- * The callback is triggered when the specified keys are pressed in order.
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
- * @returns The ID of the registered shortcut if successful, or `undefined` if the configuration is invalid (e.g., empty sequence or invalid keys).
382
- * A warning is logged to the console if the configuration is invalid or if a shortcut with the same ID is overwritten.
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, callback, context, preventDefault = false, id, sequenceTimeoutMs, strict = false } = config;
396
- if (!Array.isArray(sequence) || sequence.length === 0) {
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 undefined;
533
+ return EMPTY;
399
534
  }
400
535
  // Corrected validation: Check for actual empty string, not a string that trims to empty.
401
- if (sequence.some(key => typeof key !== "string" || key === "")) { // StandardKey type should prevent empty strings.
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 undefined;
538
+ return EMPTY;
404
539
  }
405
540
  if (context && strict) {
406
- console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" has both a context and the 'strict' flag. The 'strict' flag will be ignored.`);
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 baseKeydownStream$ = this.filterByContext(this.keydown$, context, strict);
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(sequence, otherAS.config.sequence)) {
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 subscription = finalShortcutWithPriority$.subscribe((events) => {
503
- try {
504
- // Ensure callback receives the last event of the sequence, similar to combination.
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 unsubscribe from the underlying keyboard event stream for that shortcut.
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.subscription.unsubscribe();
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 unsubscribing all shortcuts.`);
562
- this.activeShortcuts.forEach(shortcut => shortcut.subscription.unsubscribe());
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)