ngx-keys 1.0.1 → 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,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, inject, PLATFORM_ID, Injectable } from '@angular/core';
3
- import { isPlatformBrowser } from '@angular/common';
2
+ import { DestroyRef, inject, DOCUMENT, signal, computed, afterNextRender, Injectable } from '@angular/core';
3
+ import { Observable, take } from 'rxjs';
4
4
 
5
5
  /**
6
6
  * Centralized error messages for keyboard shortcuts service
@@ -11,6 +11,9 @@ const KeyboardShortcutsErrors = {
11
11
  SHORTCUT_ALREADY_REGISTERED: (id) => `Shortcut "${id}" already registered`,
12
12
  GROUP_ALREADY_REGISTERED: (id) => `Group "${id}" already registered`,
13
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(', ')}`,
14
17
  SHORTCUT_IDS_ALREADY_REGISTERED: (ids) => `Shortcut IDs already registered: ${ids.join(', ')}`,
15
18
  DUPLICATE_SHORTCUTS_IN_GROUP: (ids) => `Duplicate shortcuts in group: ${ids.join(', ')}`,
16
19
  KEY_CONFLICTS_IN_GROUP: (conflicts) => `Key conflicts: ${conflicts.join(', ')}`,
@@ -52,6 +55,15 @@ class KeyboardShortcutsErrorFactory {
52
55
  static keyConflict(conflictId) {
53
56
  return new KeyboardShortcutError('KEY_CONFLICT', KeyboardShortcutsErrors.KEY_CONFLICT(conflictId), { conflictId });
54
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
+ }
55
67
  static shortcutIdsAlreadyRegistered(ids) {
56
68
  return new KeyboardShortcutError('SHORTCUT_IDS_ALREADY_REGISTERED', KeyboardShortcutsErrors.SHORTCUT_IDS_ALREADY_REGISTERED(ids), { duplicateIds: ids });
57
69
  }
@@ -87,11 +99,51 @@ class KeyboardShortcutsErrorFactory {
87
99
  }
88
100
  }
89
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
+ }
90
131
  class KeyboardShortcuts {
132
+ static MODIFIER_KEYS = new Set(['control', 'alt', 'shift', 'meta']);
133
+ document = inject(DOCUMENT);
134
+ window = this.document.defaultView;
91
135
  shortcuts = new Map();
92
136
  groups = new Map();
93
137
  activeShortcuts = new Set();
94
138
  activeGroups = new Set();
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();
95
147
  // Single consolidated state signal - reduces memory overhead
96
148
  state = signal({
97
149
  shortcuts: new Map(),
@@ -129,21 +181,18 @@ class KeyboardShortcuts {
129
181
  };
130
182
  }, ...(ngDevMode ? [{ debugName: "shortcutsUI$" }] : []));
131
183
  keydownListener = this.handleKeydown.bind(this);
184
+ keyupListener = this.handleKeyup.bind(this);
185
+ blurListener = this.handleWindowBlur.bind(this);
186
+ visibilityListener = this.handleVisibilityChange.bind(this);
132
187
  isListening = false;
133
- isBrowser;
188
+ /** Default timeout (ms) for completing a multi-step sequence */
189
+ sequenceTimeout = 2000;
190
+ /** Runtime state for multi-step sequences */
191
+ pendingSequence = null;
134
192
  constructor() {
135
- // Use try-catch to handle injection context for better testability
136
- try {
137
- const platformId = inject(PLATFORM_ID);
138
- this.isBrowser = isPlatformBrowser(platformId);
139
- }
140
- catch {
141
- // Fallback for testing - assume browser environment
142
- this.isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
143
- }
144
- if (this.isBrowser) {
193
+ afterNextRender(() => {
145
194
  this.startListening();
146
- }
195
+ });
147
196
  }
148
197
  ngOnDestroy() {
149
198
  this.stopListening();
@@ -166,8 +215,8 @@ class KeyboardShortcuts {
166
215
  formatShortcutForUI(shortcut) {
167
216
  return {
168
217
  id: shortcut.id,
169
- keys: this.formatKeysForDisplay(shortcut.keys, false),
170
- macKeys: this.formatKeysForDisplay(shortcut.macKeys, true),
218
+ keys: this.formatStepsForDisplay(shortcut.keys ?? shortcut.steps ?? [], false),
219
+ macKeys: this.formatStepsForDisplay(shortcut.macKeys ?? shortcut.macSteps ?? [], true),
171
220
  description: shortcut.description
172
221
  };
173
222
  }
@@ -199,56 +248,121 @@ class KeyboardShortcuts {
199
248
  .map(key => keyMap[key.toLowerCase()] || key.toUpperCase())
200
249
  .join('+');
201
250
  }
251
+ formatStepsForDisplay(steps, isMac = false) {
252
+ if (!steps)
253
+ return '';
254
+ // If the first element is an array, assume steps is string[][]
255
+ const normalized = this.normalizeToSteps(steps);
256
+ if (normalized.length === 0)
257
+ return '';
258
+ if (normalized.length === 1)
259
+ return this.formatKeysForDisplay(normalized[0], isMac);
260
+ return normalized.map(step => this.formatKeysForDisplay(step, isMac)).join(', ');
261
+ }
262
+ normalizeToSteps(input) {
263
+ if (!input)
264
+ return [];
265
+ // If first element is an array, assume already KeyStep[]
266
+ if (Array.isArray(input[0])) {
267
+ return input;
268
+ }
269
+ // Single step array
270
+ return [input];
271
+ }
202
272
  /**
203
- * Check if a key combination is already registered
204
- * @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
205
275
  */
206
- findConflict(newShortcut) {
276
+ findActiveConflict(newShortcut) {
207
277
  for (const existing of this.shortcuts.values()) {
208
- if (this.keysMatch(newShortcut.keys, existing.keys)) {
278
+ // Only check conflicts with active shortcuts
279
+ if (!this.activeShortcuts.has(existing.id)) {
280
+ continue;
281
+ }
282
+ // Compare single-step shapes if provided
283
+ if (newShortcut.keys && existing.keys && this.keysMatch(newShortcut.keys, existing.keys)) {
209
284
  return existing.id;
210
285
  }
211
- if (this.keysMatch(newShortcut.macKeys, existing.macKeys)) {
286
+ if (newShortcut.macKeys && existing.macKeys && this.keysMatch(newShortcut.macKeys, existing.macKeys)) {
287
+ return existing.id;
288
+ }
289
+ // Compare multi-step shapes
290
+ if (newShortcut.steps && existing.steps && this.stepsMatch(newShortcut.steps, existing.steps)) {
291
+ return existing.id;
292
+ }
293
+ if (newShortcut.macSteps && existing.macSteps && this.stepsMatch(newShortcut.macSteps, existing.macSteps)) {
212
294
  return existing.id;
213
295
  }
214
296
  }
215
297
  return null;
216
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
+ }
217
323
  /**
218
324
  * Register a single keyboard shortcut
219
- * @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
220
326
  */
221
327
  register(shortcut) {
222
328
  if (this.shortcuts.has(shortcut.id)) {
223
329
  throw KeyboardShortcutsErrorFactory.shortcutAlreadyRegistered(shortcut.id);
224
330
  }
225
- const conflictId = this.findConflict(shortcut);
331
+ // Check for conflicts only with currently active shortcuts
332
+ const conflictId = this.findActiveConflict(shortcut);
226
333
  if (conflictId) {
227
- throw KeyboardShortcutsErrorFactory.keyConflict(conflictId);
334
+ throw KeyboardShortcutsErrorFactory.activeKeyConflict(conflictId);
228
335
  }
229
336
  this.shortcuts.set(shortcut.id, shortcut);
230
337
  this.activeShortcuts.add(shortcut.id);
231
338
  this.updateState();
339
+ this.setupActiveUntil(shortcut.activeUntil, this.unregister.bind(this, shortcut.id));
232
340
  }
233
- /**
234
- * Register multiple keyboard shortcuts as a group
235
- * @throws KeyboardShortcutError if group ID is already registered or if any shortcut ID or key combination conflicts
236
- */
237
- registerGroup(groupId, shortcuts) {
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
+ }
238
352
  // Check if group ID already exists
239
353
  if (this.groups.has(groupId)) {
240
354
  throw KeyboardShortcutsErrorFactory.groupAlreadyRegistered(groupId);
241
355
  }
242
- // Check for duplicate shortcut IDs and key combination conflicts
356
+ // Check for duplicate shortcut IDs and key combination conflicts with active shortcuts
243
357
  const duplicateIds = [];
244
358
  const keyConflicts = [];
245
359
  shortcuts.forEach(shortcut => {
246
360
  if (this.shortcuts.has(shortcut.id)) {
247
361
  duplicateIds.push(shortcut.id);
248
362
  }
249
- const conflictId = this.findConflict(shortcut);
363
+ const conflictId = this.findActiveConflict(shortcut);
250
364
  if (conflictId) {
251
- keyConflicts.push(`"${shortcut.id}" conflicts with "${conflictId}"`);
365
+ keyConflicts.push(`"${shortcut.id}" conflicts with active shortcut "${conflictId}"`);
252
366
  }
253
367
  });
254
368
  if (duplicateIds.length > 0) {
@@ -276,7 +390,8 @@ class KeyboardShortcuts {
276
390
  const group = {
277
391
  id: groupId,
278
392
  shortcuts,
279
- active: true
393
+ active: true,
394
+ filter: options.filter
280
395
  };
281
396
  this.groups.set(groupId, group);
282
397
  this.activeGroups.add(groupId);
@@ -284,8 +399,10 @@ class KeyboardShortcuts {
284
399
  shortcuts.forEach(shortcut => {
285
400
  this.shortcuts.set(shortcut.id, shortcut);
286
401
  this.activeShortcuts.add(shortcut.id);
402
+ this.shortcutToGroup.set(shortcut.id, groupId);
287
403
  });
288
404
  });
405
+ this.setupActiveUntil(options.activeUntil, this.unregisterGroup.bind(this, groupId));
289
406
  }
290
407
  /**
291
408
  * Unregister a single keyboard shortcut
@@ -297,6 +414,7 @@ class KeyboardShortcuts {
297
414
  }
298
415
  this.shortcuts.delete(shortcutId);
299
416
  this.activeShortcuts.delete(shortcutId);
417
+ this.shortcutToGroup.delete(shortcutId);
300
418
  this.updateState();
301
419
  }
302
420
  /**
@@ -312,6 +430,7 @@ class KeyboardShortcuts {
312
430
  group.shortcuts.forEach(shortcut => {
313
431
  this.shortcuts.delete(shortcut.id);
314
432
  this.activeShortcuts.delete(shortcut.id);
433
+ this.shortcutToGroup.delete(shortcut.id);
315
434
  });
316
435
  this.groups.delete(groupId);
317
436
  this.activeGroups.delete(groupId);
@@ -319,12 +438,17 @@ class KeyboardShortcuts {
319
438
  }
320
439
  /**
321
440
  * Activate a single keyboard shortcut
322
- * @throws KeyboardShortcutError if shortcut ID doesn't exist
441
+ * @throws KeyboardShortcutError if shortcut ID doesn't exist or if activation would create key conflicts
323
442
  */
324
443
  activate(shortcutId) {
325
444
  if (!this.shortcuts.has(shortcutId)) {
326
445
  throw KeyboardShortcutsErrorFactory.cannotActivateShortcut(shortcutId);
327
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
+ }
328
452
  this.activeShortcuts.add(shortcutId);
329
453
  this.updateState();
330
454
  }
@@ -341,13 +465,22 @@ class KeyboardShortcuts {
341
465
  }
342
466
  /**
343
467
  * Activate a group of keyboard shortcuts
344
- * @throws KeyboardShortcutError if group ID doesn't exist
468
+ * @throws KeyboardShortcutError if group ID doesn't exist or if activation would create key conflicts
345
469
  */
346
470
  activateGroup(groupId) {
347
471
  const group = this.groups.get(groupId);
348
472
  if (!group) {
349
473
  throw KeyboardShortcutsErrorFactory.cannotActivateGroup(groupId);
350
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
+ }
351
484
  this.batchUpdate(() => {
352
485
  group.active = true;
353
486
  this.activeGroups.add(groupId);
@@ -409,69 +542,466 @@ class KeyboardShortcuts {
409
542
  getGroups() {
410
543
  return this.groups;
411
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
+ }
412
649
  startListening() {
413
- if (!this.isBrowser || this.isListening) {
650
+ if (this.isListening) {
414
651
  return;
415
652
  }
416
- document.addEventListener('keydown', this.keydownListener, { passive: false });
653
+ // Listen to both keydown and keyup so we can maintain a Set of currently
654
+ // pressed physical keys. We avoid passive:true because we may call
655
+ // preventDefault() when matching shortcuts.
656
+ this.document.addEventListener('keydown', this.keydownListener, { passive: false });
657
+ this.document.addEventListener('keyup', this.keyupListener, { passive: false });
658
+ // Listen for blur/visibility changes so we can clear the currently-down keys
659
+ // and avoid stale state when the browser or tab loses focus.
660
+ this.window.addEventListener('blur', this.blurListener);
661
+ this.document.addEventListener('visibilitychange', this.visibilityListener);
417
662
  this.isListening = true;
418
663
  }
419
664
  stopListening() {
420
- if (!this.isBrowser || !this.isListening) {
665
+ if (!this.isListening) {
421
666
  return;
422
667
  }
423
- document.removeEventListener('keydown', this.keydownListener);
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);
424
672
  this.isListening = false;
425
673
  }
426
674
  handleKeydown(event) {
427
- const pressedKeys = this.getPressedKeys(event);
675
+ // Update the currently down keys with this event's key
676
+ this.updateCurrentlyDownKeysOnKeydown(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
+ }
428
690
  const isMac = this.isMacPlatform();
691
+ // Evaluate active group-level filters once per event and cache blocked groups
692
+ const blockedGroups = this.precomputeBlockedGroups(event);
693
+ // If there is a pending multi-step sequence, try to advance it first
694
+ if (this.pendingSequence) {
695
+ const pending = this.pendingSequence;
696
+ const shortcut = this.shortcuts.get(pending.shortcutId);
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
+ }
704
+ const steps = isMac
705
+ ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
706
+ : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
707
+ const normalizedSteps = this.normalizeToSteps(steps);
708
+ const expected = normalizedSteps[pending.stepIndex];
709
+ // Use per-event pressed keys for advancing sequence steps. Relying on
710
+ // the accumulated `currentlyDownKeys` can accidentally include keys
711
+ // from previous steps (if tests or callers don't emit keyup), which
712
+ // would prevent matching a simple single-key step like ['s'] after
713
+ // a prior ['k'] step. Use getPressedKeys(event) which reflects the
714
+ // actual modifier/main-key state for this event as a Set<string>.
715
+ const stepPressed = this.getPressedKeys(event);
716
+ if (expected && this.keysMatch(stepPressed, expected)) {
717
+ // Advance sequence
718
+ clearTimeout(pending.timerId);
719
+ pending.stepIndex += 1;
720
+ if (pending.stepIndex >= normalizedSteps.length) {
721
+ // Completed - check filters before executing
722
+ if (!this.shouldProcessEvent(event, shortcut)) {
723
+ this.pendingSequence = null;
724
+ return; // Skip execution due to filters
725
+ }
726
+ event.preventDefault();
727
+ event.stopPropagation();
728
+ try {
729
+ shortcut.action();
730
+ }
731
+ catch (error) {
732
+ console.error(`Error executing keyboard shortcut "${shortcut.id}":`, error);
733
+ }
734
+ this.pendingSequence = null;
735
+ return;
736
+ }
737
+ // Reset timer for next step
738
+ pending.timerId = setTimeout(() => { this.pendingSequence = null; }, this.sequenceTimeout);
739
+ return;
740
+ }
741
+ else {
742
+ // Cancel pending if doesn't match
743
+ this.clearPendingSequence();
744
+ }
745
+ }
746
+ else {
747
+ // pending exists but shortcut not found
748
+ this.clearPendingSequence();
749
+ }
750
+ }
751
+ // No pending sequence - check active shortcuts for a match or sequence start
429
752
  for (const shortcutId of this.activeShortcuts) {
430
753
  const shortcut = this.shortcuts.get(shortcutId);
431
754
  if (!shortcut)
432
755
  continue;
433
- const targetKeys = isMac ? shortcut.macKeys : shortcut.keys;
434
- if (this.keysMatch(pressedKeys, targetKeys)) {
435
- event.preventDefault();
436
- event.stopPropagation();
437
- try {
438
- shortcut.action();
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
+ }
761
+ const steps = isMac
762
+ ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
763
+ : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
764
+ const normalizedSteps = this.normalizeToSteps(steps);
765
+ const firstStep = normalizedSteps[0];
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
+ }
780
+ if (normalizedSteps.length === 1) {
781
+ // single-step
782
+ event.preventDefault();
783
+ event.stopPropagation();
784
+ try {
785
+ shortcut.action();
786
+ }
787
+ catch (error) {
788
+ console.error(`Error executing keyboard shortcut "${shortcut.id}":`, error);
789
+ }
790
+ break;
439
791
  }
440
- catch (error) {
441
- console.error(`Error executing keyboard shortcut "${shortcut.id}":`, error);
792
+ else {
793
+ // start pending sequence
794
+ if (this.pendingSequence) {
795
+ this.clearPendingSequence();
796
+ }
797
+ this.pendingSequence = {
798
+ shortcutId: shortcut.id,
799
+ stepIndex: 1,
800
+ timerId: setTimeout(() => { this.pendingSequence = null; }, this.sequenceTimeout)
801
+ };
802
+ event.preventDefault();
803
+ event.stopPropagation();
804
+ return;
442
805
  }
443
- break; // Only execute the first matching shortcut
444
806
  }
445
807
  }
446
808
  }
447
- getPressedKeys(event) {
448
- const keys = [];
809
+ handleKeyup(event) {
810
+ // Remove the key from currentlyDownKeys on keyup
811
+ const key = event.key ? event.key.toLowerCase() : '';
812
+ if (key && !KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
813
+ this.currentlyDownKeys.delete(key);
814
+ }
815
+ }
816
+ /**
817
+ * Clear the currently-down keys. Exposed for testing and for use by
818
+ * blur/visibilitychange handlers to avoid stale state when the page loses focus.
819
+ */
820
+ clearCurrentlyDownKeys() {
821
+ this.currentlyDownKeys.clear();
822
+ }
823
+ handleWindowBlur() {
824
+ this.clearCurrentlyDownKeys();
825
+ // Clear any pressed keys and any pending multi-step sequence to avoid
826
+ // stale state when the window loses focus.
827
+ this.clearPendingSequence();
828
+ }
829
+ handleVisibilityChange() {
830
+ if (this.document.visibilityState === 'hidden') {
831
+ // When the document becomes hidden, clear both pressed keys and any
832
+ // pending multi-step sequence. This prevents sequences from remaining
833
+ // active when the user switches tabs or minimizes the window.
834
+ this.clearCurrentlyDownKeys();
835
+ this.clearPendingSequence();
836
+ }
837
+ }
838
+ /**
839
+ * Update the currentlyDownKeys set when keydown events happen.
840
+ * Normalizes common keys (function keys, space, etc.) to the same values
841
+ * used by getPressedKeys/keysMatch.
842
+ */
843
+ updateCurrentlyDownKeysOnKeydown(event) {
844
+ const key = event.key ? event.key.toLowerCase() : '';
845
+ // Ignore modifier-only keydown entries
846
+ if (KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
847
+ return;
848
+ }
849
+ // Normalize some special cases similar to the demo component's recording logic
850
+ if (event.code && event.code.startsWith('F') && /^F\d+$/.test(event.code)) {
851
+ this.currentlyDownKeys.add(event.code.toLowerCase());
852
+ return;
853
+ }
854
+ if (key === ' ') {
855
+ this.currentlyDownKeys.add('space');
856
+ return;
857
+ }
858
+ if (key === 'escape') {
859
+ this.currentlyDownKeys.add('escape');
860
+ return;
861
+ }
862
+ if (key === 'enter') {
863
+ this.currentlyDownKeys.add('enter');
864
+ return;
865
+ }
866
+ if (key && key.length > 0) {
867
+ this.currentlyDownKeys.add(key);
868
+ }
869
+ }
870
+ /**
871
+ * Build the pressed keys set used for matching against registered shortcuts.
872
+ * If multiple non-modifier keys are currently down, include them (chord support).
873
+ * Otherwise fall back to single main-key detection from the event for compatibility.
874
+ *
875
+ * Returns a Set<string> (lowercased) to allow O(1) lookups and O(n) comparisons
876
+ * without sorting or allocating sorted arrays on every event.
877
+ */
878
+ buildPressedKeysForMatch(event) {
879
+ const modifiers = new Set();
449
880
  if (event.ctrlKey)
450
- keys.push('ctrl');
881
+ modifiers.add('ctrl');
451
882
  if (event.altKey)
452
- keys.push('alt');
883
+ modifiers.add('alt');
453
884
  if (event.shiftKey)
454
- keys.push('shift');
885
+ modifiers.add('shift');
455
886
  if (event.metaKey)
456
- keys.push('meta');
457
- // Add the main key (normalize to lowercase)
887
+ modifiers.add('meta');
888
+ // Collect non-modifier keys from currentlyDownKeys (excluding modifiers)
889
+ const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyboardShortcuts.MODIFIER_KEYS.has(k));
890
+ const result = new Set();
891
+ // Add modifiers first
892
+ modifiers.forEach(m => result.add(m));
893
+ if (nonModifierKeys.length > 0) {
894
+ nonModifierKeys.forEach(k => result.add(k.toLowerCase()));
895
+ return result;
896
+ }
897
+ // Fallback: single main key from the event (existing behavior)
458
898
  const key = event.key.toLowerCase();
459
- if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
460
- keys.push(key);
899
+ if (!KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
900
+ result.add(key);
901
+ }
902
+ return result;
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
+ */
908
+ getPressedKeys(event) {
909
+ const result = new Set();
910
+ if (event.ctrlKey)
911
+ result.add('ctrl');
912
+ if (event.altKey)
913
+ result.add('alt');
914
+ if (event.shiftKey)
915
+ result.add('shift');
916
+ if (event.metaKey)
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);
461
922
  }
462
- return keys;
923
+ return result;
463
924
  }
925
+ /**
926
+ * Compare pressed keys against a target key combination.
927
+ * Accepts either a Set<string> (preferred) or an array for backwards compatibility.
928
+ * Uses Set-based comparison: sizes must match and every element in target must exist in pressed.
929
+ */
464
930
  keysMatch(pressedKeys, targetKeys) {
465
- if (pressedKeys.length !== targetKeys.length) {
931
+ // Normalize targetKeys into a Set<string> (lowercased)
932
+ const normalizedTarget = new Set(targetKeys.map(k => k.toLowerCase()));
933
+ // Normalize pressedKeys into a Set<string> if it's an array
934
+ const pressedSet = Array.isArray(pressedKeys)
935
+ ? new Set(pressedKeys.map(k => k.toLowerCase()))
936
+ : new Set(Array.from(pressedKeys).map(k => k.toLowerCase()));
937
+ if (pressedSet.size !== normalizedTarget.size) {
938
+ return false;
939
+ }
940
+ // Check if every element in normalizedTarget exists in pressedSet
941
+ for (const key of normalizedTarget) {
942
+ if (!pressedSet.has(key)) {
943
+ return false;
944
+ }
945
+ }
946
+ return true;
947
+ }
948
+ /** Compare two multi-step sequences for equality */
949
+ stepsMatch(a, b) {
950
+ if (a.length !== b.length)
466
951
  return false;
952
+ for (let i = 0; i < a.length; i++) {
953
+ if (!this.keysMatch(a[i], b[i]))
954
+ return false;
467
955
  }
468
- // Normalize and sort both arrays for comparison
469
- const normalizedPressed = pressedKeys.map(key => key.toLowerCase()).sort();
470
- const normalizedTarget = targetKeys.map(key => key.toLowerCase()).sort();
471
- return normalizedPressed.every((key, index) => key === normalizedTarget[index]);
956
+ return true;
957
+ }
958
+ /** Safely clear any pending multi-step sequence */
959
+ clearPendingSequence() {
960
+ if (!this.pendingSequence)
961
+ return;
962
+ try {
963
+ clearTimeout(this.pendingSequence.timerId);
964
+ }
965
+ catch { /* ignore */ }
966
+ this.pendingSequence = null;
472
967
  }
473
968
  isMacPlatform() {
474
- return this.isBrowser && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
969
+ return /Mac|iPod|iPhone|iPad/.test(this.window.navigator.platform ?? '');
970
+ }
971
+ setupActiveUntil(activeUntil, unregister) {
972
+ if (!activeUntil) {
973
+ return;
974
+ }
975
+ if (activeUntil === 'destruct') {
976
+ inject(DestroyRef).onDestroy(unregister);
977
+ return;
978
+ }
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)) {
983
+ activeUntil.onDestroy(unregister);
984
+ return;
985
+ }
986
+ if (activeUntil instanceof Observable) {
987
+ activeUntil.pipe(take(1)).subscribe(unregister);
988
+ return;
989
+ }
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;
475
1005
  }
476
1006
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
477
1007
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, providedIn: 'root' });