ngx-keys 1.1.0 → 1.2.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.
@@ -1,6 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, inject, PLATFORM_ID, DestroyRef, Injectable } from '@angular/core';
3
- import { isPlatformBrowser } from '@angular/common';
2
+ import { DestroyRef, inject, DOCUMENT, signal, computed, afterNextRender, Injectable } from '@angular/core';
4
3
  import { Observable, take } from 'rxjs';
5
4
 
6
5
  /**
@@ -12,6 +11,9 @@ const KeyboardShortcutsErrors = {
12
11
  SHORTCUT_ALREADY_REGISTERED: (id) => `Shortcut "${id}" already registered`,
13
12
  GROUP_ALREADY_REGISTERED: (id) => `Group "${id}" already registered`,
14
13
  KEY_CONFLICT: (conflictId) => `Key conflict with "${conflictId}"`,
14
+ ACTIVE_KEY_CONFLICT: (conflictId) => `Key conflict with active shortcut "${conflictId}"`,
15
+ ACTIVATION_KEY_CONFLICT: (shortcutId, conflictIds) => `Cannot activate "${shortcutId}": would conflict with active shortcuts: ${conflictIds.join(', ')}`,
16
+ GROUP_ACTIVATION_KEY_CONFLICT: (groupId, conflictIds) => `Cannot activate group "${groupId}": would conflict with active shortcuts: ${conflictIds.join(', ')}`,
15
17
  SHORTCUT_IDS_ALREADY_REGISTERED: (ids) => `Shortcut IDs already registered: ${ids.join(', ')}`,
16
18
  DUPLICATE_SHORTCUTS_IN_GROUP: (ids) => `Duplicate shortcuts in group: ${ids.join(', ')}`,
17
19
  KEY_CONFLICTS_IN_GROUP: (conflicts) => `Key conflicts: ${conflicts.join(', ')}`,
@@ -53,6 +55,15 @@ class KeyboardShortcutsErrorFactory {
53
55
  static keyConflict(conflictId) {
54
56
  return new KeyboardShortcutError('KEY_CONFLICT', KeyboardShortcutsErrors.KEY_CONFLICT(conflictId), { conflictId });
55
57
  }
58
+ static activeKeyConflict(conflictId) {
59
+ return new KeyboardShortcutError('ACTIVE_KEY_CONFLICT', KeyboardShortcutsErrors.ACTIVE_KEY_CONFLICT(conflictId), { conflictId });
60
+ }
61
+ static activationKeyConflict(shortcutId, conflictIds) {
62
+ return new KeyboardShortcutError('ACTIVATION_KEY_CONFLICT', KeyboardShortcutsErrors.ACTIVATION_KEY_CONFLICT(shortcutId, conflictIds), { shortcutId, conflictIds });
63
+ }
64
+ static groupActivationKeyConflict(groupId, conflictIds) {
65
+ return new KeyboardShortcutError('GROUP_ACTIVATION_KEY_CONFLICT', KeyboardShortcutsErrors.GROUP_ACTIVATION_KEY_CONFLICT(groupId, conflictIds), { groupId, conflictIds });
66
+ }
56
67
  static shortcutIdsAlreadyRegistered(ids) {
57
68
  return new KeyboardShortcutError('SHORTCUT_IDS_ALREADY_REGISTERED', KeyboardShortcutsErrors.SHORTCUT_IDS_ALREADY_REGISTERED(ids), { duplicateIds: ids });
58
69
  }
@@ -88,12 +99,51 @@ class KeyboardShortcutsErrorFactory {
88
99
  }
89
100
  }
90
101
 
102
+ /**
103
+ * Type guard to detect KeyboardShortcutGroupOptions at runtime.
104
+ * Centralising this logic keeps registerGroup simpler and less fragile.
105
+ */
106
+ function isGroupOptions(param) {
107
+ if (!param || typeof param !== 'object')
108
+ return false;
109
+ // Narrow to object for property checks
110
+ const obj = param;
111
+ return ('filter' in obj) || ('activeUntil' in obj);
112
+ }
113
+ /**
114
+ * Detect real DestroyRef instances or duck-typed objects exposing onDestroy(fn).
115
+ * Returns true for either an actual DestroyRef or an object with an onDestroy method.
116
+ */
117
+ function isDestroyRefLike(obj) {
118
+ if (!obj || typeof obj !== 'object')
119
+ return false;
120
+ try {
121
+ // Prefer instanceof when available (real DestroyRef)
122
+ if (obj instanceof DestroyRef)
123
+ return true;
124
+ }
125
+ catch {
126
+ // instanceof may throw if DestroyRef is not constructable in certain runtimes/tests
127
+ }
128
+ const o = obj;
129
+ return typeof o['onDestroy'] === 'function';
130
+ }
91
131
  class KeyboardShortcuts {
132
+ static MODIFIER_KEYS = new Set(['control', 'alt', 'shift', 'meta']);
133
+ document = inject(DOCUMENT);
134
+ window = this.document.defaultView;
92
135
  shortcuts = new Map();
93
136
  groups = new Map();
94
137
  activeShortcuts = new Set();
95
138
  activeGroups = new Set();
96
139
  currentlyDownKeys = new Set();
140
+ // O(1) lookup from shortcutId to its groupId to avoid scanning all groups per event
141
+ shortcutToGroup = new Map();
142
+ /**
143
+ * Named global filters that apply to all shortcuts.
144
+ * All global filters must return `true` for a shortcut to be processed.
145
+ */
146
+ globalFilters = new Map();
97
147
  // Single consolidated state signal - reduces memory overhead
98
148
  state = signal({
99
149
  shortcuts: new Map(),
@@ -135,24 +185,14 @@ class KeyboardShortcuts {
135
185
  blurListener = this.handleWindowBlur.bind(this);
136
186
  visibilityListener = this.handleVisibilityChange.bind(this);
137
187
  isListening = false;
138
- isBrowser;
139
188
  /** Default timeout (ms) for completing a multi-step sequence */
140
189
  sequenceTimeout = 2000;
141
190
  /** Runtime state for multi-step sequences */
142
191
  pendingSequence = null;
143
192
  constructor() {
144
- // Use try-catch to handle injection context for better testability
145
- try {
146
- const platformId = inject(PLATFORM_ID);
147
- this.isBrowser = isPlatformBrowser(platformId);
148
- }
149
- catch {
150
- // Fallback for testing - assume browser environment
151
- this.isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
152
- }
153
- if (this.isBrowser) {
193
+ afterNextRender(() => {
154
194
  this.startListening();
155
- }
195
+ });
156
196
  }
157
197
  ngOnDestroy() {
158
198
  this.stopListening();
@@ -230,11 +270,15 @@ class KeyboardShortcuts {
230
270
  return [input];
231
271
  }
232
272
  /**
233
- * Check if a key combination is already registered
234
- * @returns The ID of the conflicting shortcut, or null if no conflict
273
+ * Check if a key combination is already registered by an active shortcut
274
+ * @returns The ID of the conflicting active shortcut, or null if no active conflict
235
275
  */
236
- findConflict(newShortcut) {
276
+ findActiveConflict(newShortcut) {
237
277
  for (const existing of this.shortcuts.values()) {
278
+ // Only check conflicts with active shortcuts
279
+ if (!this.activeShortcuts.has(existing.id)) {
280
+ continue;
281
+ }
238
282
  // Compare single-step shapes if provided
239
283
  if (newShortcut.keys && existing.keys && this.keysMatch(newShortcut.keys, existing.keys)) {
240
284
  return existing.id;
@@ -252,42 +296,73 @@ class KeyboardShortcuts {
252
296
  }
253
297
  return null;
254
298
  }
299
+ /**
300
+ * Check if activating a shortcut would create key conflicts with other active shortcuts
301
+ * @returns Array of conflicting shortcut IDs that would be created by activation
302
+ */
303
+ findActivationConflicts(shortcutId) {
304
+ const shortcut = this.shortcuts.get(shortcutId);
305
+ if (!shortcut)
306
+ return [];
307
+ const conflicts = [];
308
+ for (const existing of this.shortcuts.values()) {
309
+ // Skip self and inactive shortcuts
310
+ if (existing.id === shortcutId || !this.activeShortcuts.has(existing.id)) {
311
+ continue;
312
+ }
313
+ // Check for key conflicts
314
+ if ((shortcut.keys && existing.keys && this.keysMatch(shortcut.keys, existing.keys)) ||
315
+ (shortcut.macKeys && existing.macKeys && this.keysMatch(shortcut.macKeys, existing.macKeys)) ||
316
+ (shortcut.steps && existing.steps && this.stepsMatch(shortcut.steps, existing.steps)) ||
317
+ (shortcut.macSteps && existing.macSteps && this.stepsMatch(shortcut.macSteps, existing.macSteps))) {
318
+ conflicts.push(existing.id);
319
+ }
320
+ }
321
+ return conflicts;
322
+ }
255
323
  /**
256
324
  * Register a single keyboard shortcut
257
- * @throws KeyboardShortcutError if shortcut ID is already registered or key combination is in use
325
+ * @throws KeyboardShortcutError if shortcut ID is already registered or if the shortcut would conflict with currently active shortcuts
258
326
  */
259
327
  register(shortcut) {
260
328
  if (this.shortcuts.has(shortcut.id)) {
261
329
  throw KeyboardShortcutsErrorFactory.shortcutAlreadyRegistered(shortcut.id);
262
330
  }
263
- const conflictId = this.findConflict(shortcut);
331
+ // Check for conflicts only with currently active shortcuts
332
+ const conflictId = this.findActiveConflict(shortcut);
264
333
  if (conflictId) {
265
- throw KeyboardShortcutsErrorFactory.keyConflict(conflictId);
334
+ throw KeyboardShortcutsErrorFactory.activeKeyConflict(conflictId);
266
335
  }
267
336
  this.shortcuts.set(shortcut.id, shortcut);
268
337
  this.activeShortcuts.add(shortcut.id);
269
338
  this.updateState();
270
339
  this.setupActiveUntil(shortcut.activeUntil, this.unregister.bind(this, shortcut.id));
271
340
  }
272
- /**
273
- * Register multiple keyboard shortcuts as a group
274
- * @throws KeyboardShortcutError if group ID is already registered or if any shortcut ID or key combination conflicts
275
- */
276
- registerGroup(groupId, shortcuts, activeUntil) {
341
+ registerGroup(groupId, shortcuts, optionsOrActiveUntil) {
342
+ // Parse parameters - support both old (activeUntil) and new (options) formats
343
+ let options;
344
+ if (isGroupOptions(optionsOrActiveUntil)) {
345
+ // New format with options object
346
+ options = optionsOrActiveUntil;
347
+ }
348
+ else {
349
+ // Old format with just activeUntil parameter
350
+ options = { activeUntil: optionsOrActiveUntil };
351
+ }
277
352
  // Check if group ID already exists
278
353
  if (this.groups.has(groupId)) {
279
354
  throw KeyboardShortcutsErrorFactory.groupAlreadyRegistered(groupId);
280
355
  }
281
- // Check for duplicate shortcut IDs and key combination conflicts
356
+ // Check for duplicate shortcut IDs and key combination conflicts with active shortcuts
282
357
  const duplicateIds = [];
283
358
  const keyConflicts = [];
284
359
  shortcuts.forEach(shortcut => {
285
360
  if (this.shortcuts.has(shortcut.id)) {
286
361
  duplicateIds.push(shortcut.id);
287
362
  }
288
- const conflictId = this.findConflict(shortcut);
363
+ const conflictId = this.findActiveConflict(shortcut);
289
364
  if (conflictId) {
290
- keyConflicts.push(`"${shortcut.id}" conflicts with "${conflictId}"`);
365
+ keyConflicts.push(`"${shortcut.id}" conflicts with active shortcut "${conflictId}"`);
291
366
  }
292
367
  });
293
368
  if (duplicateIds.length > 0) {
@@ -315,7 +390,8 @@ class KeyboardShortcuts {
315
390
  const group = {
316
391
  id: groupId,
317
392
  shortcuts,
318
- active: true
393
+ active: true,
394
+ filter: options.filter
319
395
  };
320
396
  this.groups.set(groupId, group);
321
397
  this.activeGroups.add(groupId);
@@ -323,9 +399,10 @@ class KeyboardShortcuts {
323
399
  shortcuts.forEach(shortcut => {
324
400
  this.shortcuts.set(shortcut.id, shortcut);
325
401
  this.activeShortcuts.add(shortcut.id);
402
+ this.shortcutToGroup.set(shortcut.id, groupId);
326
403
  });
327
404
  });
328
- this.setupActiveUntil(activeUntil, this.unregisterGroup.bind(this, groupId));
405
+ this.setupActiveUntil(options.activeUntil, this.unregisterGroup.bind(this, groupId));
329
406
  }
330
407
  /**
331
408
  * Unregister a single keyboard shortcut
@@ -337,6 +414,7 @@ class KeyboardShortcuts {
337
414
  }
338
415
  this.shortcuts.delete(shortcutId);
339
416
  this.activeShortcuts.delete(shortcutId);
417
+ this.shortcutToGroup.delete(shortcutId);
340
418
  this.updateState();
341
419
  }
342
420
  /**
@@ -352,6 +430,7 @@ class KeyboardShortcuts {
352
430
  group.shortcuts.forEach(shortcut => {
353
431
  this.shortcuts.delete(shortcut.id);
354
432
  this.activeShortcuts.delete(shortcut.id);
433
+ this.shortcutToGroup.delete(shortcut.id);
355
434
  });
356
435
  this.groups.delete(groupId);
357
436
  this.activeGroups.delete(groupId);
@@ -359,12 +438,17 @@ class KeyboardShortcuts {
359
438
  }
360
439
  /**
361
440
  * Activate a single keyboard shortcut
362
- * @throws KeyboardShortcutError if shortcut ID doesn't exist
441
+ * @throws KeyboardShortcutError if shortcut ID doesn't exist or if activation would create key conflicts
363
442
  */
364
443
  activate(shortcutId) {
365
444
  if (!this.shortcuts.has(shortcutId)) {
366
445
  throw KeyboardShortcutsErrorFactory.cannotActivateShortcut(shortcutId);
367
446
  }
447
+ // Check for conflicts that would be created by activation
448
+ const conflicts = this.findActivationConflicts(shortcutId);
449
+ if (conflicts.length > 0) {
450
+ throw KeyboardShortcutsErrorFactory.activationKeyConflict(shortcutId, conflicts);
451
+ }
368
452
  this.activeShortcuts.add(shortcutId);
369
453
  this.updateState();
370
454
  }
@@ -381,13 +465,22 @@ class KeyboardShortcuts {
381
465
  }
382
466
  /**
383
467
  * Activate a group of keyboard shortcuts
384
- * @throws KeyboardShortcutError if group ID doesn't exist
468
+ * @throws KeyboardShortcutError if group ID doesn't exist or if activation would create key conflicts
385
469
  */
386
470
  activateGroup(groupId) {
387
471
  const group = this.groups.get(groupId);
388
472
  if (!group) {
389
473
  throw KeyboardShortcutsErrorFactory.cannotActivateGroup(groupId);
390
474
  }
475
+ // Check for conflicts that would be created by activating all shortcuts in the group
476
+ const allConflicts = [];
477
+ group.shortcuts.forEach(shortcut => {
478
+ const conflicts = this.findActivationConflicts(shortcut.id);
479
+ allConflicts.push(...conflicts);
480
+ });
481
+ if (allConflicts.length > 0) {
482
+ throw KeyboardShortcutsErrorFactory.groupActivationKeyConflict(groupId, allConflicts);
483
+ }
391
484
  this.batchUpdate(() => {
392
485
  group.active = true;
393
486
  this.activeGroups.add(groupId);
@@ -449,45 +542,165 @@ class KeyboardShortcuts {
449
542
  getGroups() {
450
543
  return this.groups;
451
544
  }
545
+ /**
546
+ * Add a named global filter that applies to all shortcuts.
547
+ * All global filters must return `true` for a shortcut to execute.
548
+ *
549
+ * @param name - Unique name for this filter
550
+ * @param filter - Function that returns `true` to allow shortcuts, `false` to block them
551
+ *
552
+ * @example
553
+ * ```typescript
554
+ * // Block shortcuts in form elements
555
+ * keyboardService.addFilter('forms', (event) => {
556
+ * const target = event.target as HTMLElement;
557
+ * const tagName = target?.tagName?.toLowerCase();
558
+ * return !['input', 'textarea', 'select'].includes(tagName) && !target?.isContentEditable;
559
+ * });
560
+ *
561
+ * // Block shortcuts when modal is open
562
+ * keyboardService.addFilter('modal', (event) => {
563
+ * return !document.querySelector('.modal.active');
564
+ * });
565
+ * ```
566
+ */
567
+ addFilter(name, filter) {
568
+ this.globalFilters.set(name, filter);
569
+ }
570
+ /**
571
+ * Remove a named global filter.
572
+ *
573
+ * @param name - Name of the filter to remove
574
+ * @returns `true` if filter was removed, `false` if it didn't exist
575
+ */
576
+ removeFilter(name) {
577
+ return this.globalFilters.delete(name);
578
+ }
579
+ /**
580
+ * Get a named global filter.
581
+ *
582
+ * @param name - Name of the filter to retrieve
583
+ * @returns The filter function, or undefined if not found
584
+ */
585
+ getFilter(name) {
586
+ return this.globalFilters.get(name);
587
+ }
588
+ /**
589
+ * Get all global filter names.
590
+ *
591
+ * @returns Array of filter names
592
+ */
593
+ getFilterNames() {
594
+ return Array.from(this.globalFilters.keys());
595
+ }
596
+ /**
597
+ * Remove all global filters.
598
+ */
599
+ clearFilters() {
600
+ this.globalFilters.clear();
601
+ }
602
+ /**
603
+ * Check if a named filter exists.
604
+ *
605
+ * @param name - Name of the filter to check
606
+ * @returns `true` if filter exists, `false` otherwise
607
+ */
608
+ hasFilter(name) {
609
+ return this.globalFilters.has(name);
610
+ }
611
+ /**
612
+ * Find the group that contains a specific shortcut.
613
+ *
614
+ * @param shortcutId - The ID of the shortcut to find
615
+ * @returns The group containing the shortcut, or undefined if not found in any group
616
+ */
617
+ findGroupForShortcut(shortcutId) {
618
+ const groupId = this.shortcutToGroup.get(shortcutId);
619
+ return groupId ? this.groups.get(groupId) : undefined;
620
+ }
621
+ /**
622
+ * Check if a keyboard event should be processed based on global, group, and per-shortcut filters.
623
+ * Filter hierarchy: Global filters → Group filter → Individual shortcut filter
624
+ *
625
+ * @param event - The keyboard event to evaluate
626
+ * @param shortcut - The shortcut being evaluated (for per-shortcut filter)
627
+ * @returns `true` if event should be processed, `false` if it should be ignored
628
+ */
629
+ shouldProcessEvent(event, shortcut) {
630
+ // First, check all global filters - ALL must return true
631
+ // Note: handleKeydown pre-checks these once per event for early exit,
632
+ // but we keep this for direct calls and completeness.
633
+ for (const globalFilter of this.globalFilters.values()) {
634
+ if (!globalFilter(event)) {
635
+ return false;
636
+ }
637
+ }
638
+ // Then check group filter if shortcut belongs to a group
639
+ const group = this.findGroupForShortcut(shortcut.id);
640
+ if (group?.filter && !group.filter(event)) {
641
+ return false;
642
+ }
643
+ // Finally check per-shortcut filter if it exists
644
+ if (shortcut.filter && !shortcut.filter(event)) {
645
+ return false;
646
+ }
647
+ return true;
648
+ }
452
649
  startListening() {
453
- if (!this.isBrowser || this.isListening) {
650
+ if (this.isListening) {
454
651
  return;
455
652
  }
456
653
  // Listen to both keydown and keyup so we can maintain a Set of currently
457
654
  // pressed physical keys. We avoid passive:true because we may call
458
655
  // preventDefault() when matching shortcuts.
459
- document.addEventListener('keydown', this.keydownListener, { passive: false });
460
- document.addEventListener('keyup', this.keyupListener, { passive: false });
656
+ this.document.addEventListener('keydown', this.keydownListener, { passive: false });
657
+ this.document.addEventListener('keyup', this.keyupListener, { passive: false });
461
658
  // Listen for blur/visibility changes so we can clear the currently-down keys
462
659
  // and avoid stale state when the browser or tab loses focus.
463
- window.addEventListener('blur', this.blurListener);
464
- document.addEventListener('visibilitychange', this.visibilityListener);
660
+ this.window.addEventListener('blur', this.blurListener);
661
+ this.document.addEventListener('visibilitychange', this.visibilityListener);
465
662
  this.isListening = true;
466
663
  }
467
664
  stopListening() {
468
- if (!this.isBrowser || !this.isListening) {
665
+ if (!this.isListening) {
469
666
  return;
470
667
  }
471
- document.removeEventListener('keydown', this.keydownListener);
472
- document.removeEventListener('keyup', this.keyupListener);
473
- window.removeEventListener('blur', this.blurListener);
474
- document.removeEventListener('visibilitychange', this.visibilityListener);
668
+ this.document.removeEventListener('keydown', this.keydownListener);
669
+ this.document.removeEventListener('keyup', this.keyupListener);
670
+ this.window.removeEventListener('blur', this.blurListener);
671
+ this.document.removeEventListener('visibilitychange', this.visibilityListener);
475
672
  this.isListening = false;
476
673
  }
477
674
  handleKeydown(event) {
478
675
  // Update the currently down keys with this event's key
479
676
  this.updateCurrentlyDownKeysOnKeydown(event);
480
- // Build the pressed keys set used for matching. Prefer the currentlyDownKeys
481
- // if it contains more than one non-modifier key; otherwise fall back to the
482
- // traditional per-event pressed keys calculation for compatibility.
483
- // Use a Set for matching to avoid allocations and sorting on every event
484
- const pressedKeys = this.buildPressedKeysForMatch(event);
677
+ // Fast path: if any global filter blocks this event, bail out before
678
+ // scanning all active shortcuts. This drastically reduces per-event work
679
+ // when filters are commonly blocking (e.g., while typing in inputs).
680
+ if (this.globalFilters.size > 0) {
681
+ for (const f of this.globalFilters.values()) {
682
+ if (!f(event)) {
683
+ // Also clear any pending multi-step sequence – entering a globally
684
+ // filtered context should not allow sequences to continue.
685
+ this.clearPendingSequence();
686
+ return;
687
+ }
688
+ }
689
+ }
485
690
  const isMac = this.isMacPlatform();
691
+ // Evaluate active group-level filters once per event and cache blocked groups
692
+ const blockedGroups = this.precomputeBlockedGroups(event);
486
693
  // If there is a pending multi-step sequence, try to advance it first
487
694
  if (this.pendingSequence) {
488
695
  const pending = this.pendingSequence;
489
696
  const shortcut = this.shortcuts.get(pending.shortcutId);
490
697
  if (shortcut) {
698
+ // If the pending shortcut belongs to a blocked group, cancel sequence
699
+ const g = this.findGroupForShortcut(shortcut.id);
700
+ if (g && blockedGroups.has(g.id)) {
701
+ this.clearPendingSequence();
702
+ return;
703
+ }
491
704
  const steps = isMac
492
705
  ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
493
706
  : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
@@ -498,14 +711,18 @@ class KeyboardShortcuts {
498
711
  // from previous steps (if tests or callers don't emit keyup), which
499
712
  // would prevent matching a simple single-key step like ['s'] after
500
713
  // a prior ['k'] step. Use getPressedKeys(event) which reflects the
501
- // actual modifier/main-key state for this event.
714
+ // actual modifier/main-key state for this event as a Set<string>.
502
715
  const stepPressed = this.getPressedKeys(event);
503
716
  if (expected && this.keysMatch(stepPressed, expected)) {
504
717
  // Advance sequence
505
718
  clearTimeout(pending.timerId);
506
719
  pending.stepIndex += 1;
507
720
  if (pending.stepIndex >= normalizedSteps.length) {
508
- // Completed
721
+ // Completed - check filters before executing
722
+ if (!this.shouldProcessEvent(event, shortcut)) {
723
+ this.pendingSequence = null;
724
+ return; // Skip execution due to filters
725
+ }
509
726
  event.preventDefault();
510
727
  event.stopPropagation();
511
728
  try {
@@ -536,12 +753,30 @@ class KeyboardShortcuts {
536
753
  const shortcut = this.shortcuts.get(shortcutId);
537
754
  if (!shortcut)
538
755
  continue;
756
+ // Skip expensive matching entirely when the shortcut's group is blocked
757
+ const g = this.findGroupForShortcut(shortcut.id);
758
+ if (g && blockedGroups.has(g.id)) {
759
+ continue;
760
+ }
539
761
  const steps = isMac
540
762
  ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
541
763
  : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
542
764
  const normalizedSteps = this.normalizeToSteps(steps);
543
765
  const firstStep = normalizedSteps[0];
544
- if (this.keysMatch(pressedKeys, firstStep)) {
766
+ // Decide which pressed-keys representation to use for this shortcut's
767
+ // expected step: if it requires multiple non-modifier keys, treat it as
768
+ // a chord and use accumulated keys; otherwise use per-event keys to avoid
769
+ // interference from previously pressed non-modifier keys.
770
+ const nonModifierCount = firstStep.filter(k => !KeyboardShortcuts.MODIFIER_KEYS.has(k.toLowerCase())).length;
771
+ // Normalize pressed keys to a Set<string> for consistent typing
772
+ const pressedForStep = nonModifierCount > 1
773
+ ? this.buildPressedKeysForMatch(event)
774
+ : this.getPressedKeys(event);
775
+ if (this.keysMatch(pressedForStep, firstStep)) {
776
+ // Check if this event should be processed based on filters
777
+ if (!this.shouldProcessEvent(event, shortcut)) {
778
+ continue; // Skip this shortcut due to filters
779
+ }
545
780
  if (normalizedSteps.length === 1) {
546
781
  // single-step
547
782
  event.preventDefault();
@@ -574,7 +809,7 @@ class KeyboardShortcuts {
574
809
  handleKeyup(event) {
575
810
  // Remove the key from currentlyDownKeys on keyup
576
811
  const key = event.key ? event.key.toLowerCase() : '';
577
- if (key && !['control', 'alt', 'shift', 'meta'].includes(key)) {
812
+ if (key && !KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
578
813
  this.currentlyDownKeys.delete(key);
579
814
  }
580
815
  }
@@ -592,7 +827,7 @@ class KeyboardShortcuts {
592
827
  this.clearPendingSequence();
593
828
  }
594
829
  handleVisibilityChange() {
595
- if (document.visibilityState === 'hidden') {
830
+ if (this.document.visibilityState === 'hidden') {
596
831
  // When the document becomes hidden, clear both pressed keys and any
597
832
  // pending multi-step sequence. This prevents sequences from remaining
598
833
  // active when the user switches tabs or minimizes the window.
@@ -608,7 +843,7 @@ class KeyboardShortcuts {
608
843
  updateCurrentlyDownKeysOnKeydown(event) {
609
844
  const key = event.key ? event.key.toLowerCase() : '';
610
845
  // Ignore modifier-only keydown entries
611
- if (['control', 'alt', 'shift', 'meta'].includes(key)) {
846
+ if (KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
612
847
  return;
613
848
  }
614
849
  // Normalize some special cases similar to the demo component's recording logic
@@ -632,11 +867,6 @@ class KeyboardShortcuts {
632
867
  this.currentlyDownKeys.add(key);
633
868
  }
634
869
  }
635
- /**
636
- * Build the pressed keys array used for matching against registered shortcuts.
637
- * If multiple non-modifier keys are currently down, include them (chord support).
638
- * Otherwise fall back to single main-key detection from the event for compatibility.
639
- */
640
870
  /**
641
871
  * Build the pressed keys set used for matching against registered shortcuts.
642
872
  * If multiple non-modifier keys are currently down, include them (chord support).
@@ -656,7 +886,7 @@ class KeyboardShortcuts {
656
886
  if (event.metaKey)
657
887
  modifiers.add('meta');
658
888
  // Collect non-modifier keys from currentlyDownKeys (excluding modifiers)
659
- const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !['control', 'alt', 'shift', 'meta'].includes(k));
889
+ const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyboardShortcuts.MODIFIER_KEYS.has(k));
660
890
  const result = new Set();
661
891
  // Add modifiers first
662
892
  modifiers.forEach(m => result.add(m));
@@ -666,27 +896,31 @@ class KeyboardShortcuts {
666
896
  }
667
897
  // Fallback: single main key from the event (existing behavior)
668
898
  const key = event.key.toLowerCase();
669
- if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
899
+ if (!KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
670
900
  result.add(key);
671
901
  }
672
902
  return result;
673
903
  }
904
+ /**
905
+ * Return the pressed keys for this event as a Set<string>.
906
+ * This is the canonical internal API used for matching.
907
+ */
674
908
  getPressedKeys(event) {
675
- const keys = [];
909
+ const result = new Set();
676
910
  if (event.ctrlKey)
677
- keys.push('ctrl');
911
+ result.add('ctrl');
678
912
  if (event.altKey)
679
- keys.push('alt');
913
+ result.add('alt');
680
914
  if (event.shiftKey)
681
- keys.push('shift');
915
+ result.add('shift');
682
916
  if (event.metaKey)
683
- keys.push('meta');
684
- // Add the main key (normalize to lowercase)
685
- const key = event.key.toLowerCase();
686
- if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
687
- keys.push(key);
917
+ result.add('meta');
918
+ // Add the main key (normalize to lowercase) if it's not a modifier
919
+ const key = (event.key ?? '').toLowerCase();
920
+ if (key && !KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
921
+ result.add(key);
688
922
  }
689
- return keys;
923
+ return result;
690
924
  }
691
925
  /**
692
926
  * Compare pressed keys against a target key combination.
@@ -732,7 +966,7 @@ class KeyboardShortcuts {
732
966
  this.pendingSequence = null;
733
967
  }
734
968
  isMacPlatform() {
735
- return this.isBrowser && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
969
+ return /Mac|iPod|iPhone|iPad/.test(this.window.navigator.platform ?? '');
736
970
  }
737
971
  setupActiveUntil(activeUntil, unregister) {
738
972
  if (!activeUntil) {
@@ -742,7 +976,10 @@ class KeyboardShortcuts {
742
976
  inject(DestroyRef).onDestroy(unregister);
743
977
  return;
744
978
  }
745
- if (activeUntil instanceof DestroyRef) {
979
+ // Support both real DestroyRef instances and duck-typed objects (e.g.,
980
+ // Jasmine spies) that expose an onDestroy(fn) method for backwards
981
+ // compatibility with earlier APIs and tests.
982
+ if (isDestroyRefLike(activeUntil)) {
746
983
  activeUntil.onDestroy(unregister);
747
984
  return;
748
985
  }
@@ -751,6 +988,21 @@ class KeyboardShortcuts {
751
988
  return;
752
989
  }
753
990
  }
991
+ /**
992
+ * Evaluate group filters once per event and return the set of blocked group IDs.
993
+ */
994
+ precomputeBlockedGroups(event) {
995
+ const blocked = new Set();
996
+ if (this.activeGroups.size === 0)
997
+ return blocked;
998
+ for (const groupId of this.activeGroups) {
999
+ const group = this.groups.get(groupId);
1000
+ if (group && group.filter && !group.filter(event)) {
1001
+ blocked.add(groupId);
1002
+ }
1003
+ }
1004
+ return blocked;
1005
+ }
754
1006
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
755
1007
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, providedIn: 'root' });
756
1008
  }