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.
- package/README.md +361 -7
- package/fesm2022/ngx-keys.mjs +324 -72
- package/fesm2022/ngx-keys.mjs.map +1 -1
- package/index.d.ts +193 -14
- package/package.json +1 -1
package/fesm2022/ngx-keys.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
331
|
+
// Check for conflicts only with currently active shortcuts
|
|
332
|
+
const conflictId = this.findActiveConflict(shortcut);
|
|
264
333
|
if (conflictId) {
|
|
265
|
-
throw KeyboardShortcutsErrorFactory.
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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 && !
|
|
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 (
|
|
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 => !
|
|
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 (!
|
|
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
|
|
909
|
+
const result = new Set();
|
|
676
910
|
if (event.ctrlKey)
|
|
677
|
-
|
|
911
|
+
result.add('ctrl');
|
|
678
912
|
if (event.altKey)
|
|
679
|
-
|
|
913
|
+
result.add('alt');
|
|
680
914
|
if (event.shiftKey)
|
|
681
|
-
|
|
915
|
+
result.add('shift');
|
|
682
916
|
if (event.metaKey)
|
|
683
|
-
|
|
684
|
-
// Add the main key (normalize to lowercase)
|
|
685
|
-
const key = event.key.toLowerCase();
|
|
686
|
-
if (
|
|
687
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|