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.
- package/README.md +466 -8
- package/fesm2022/ngx-keys.mjs +595 -65
- package/fesm2022/ngx-keys.mjs.map +1 -1
- package/index.d.ts +251 -14
- package/package.json +1 -1
package/fesm2022/ngx-keys.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, computed,
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
170
|
-
macKeys: this.
|
|
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
|
-
|
|
276
|
+
findActiveConflict(newShortcut) {
|
|
207
277
|
for (const existing of this.shortcuts.values()) {
|
|
208
|
-
|
|
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
|
|
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
|
-
|
|
331
|
+
// Check for conflicts only with currently active shortcuts
|
|
332
|
+
const conflictId = this.findActiveConflict(shortcut);
|
|
226
333
|
if (conflictId) {
|
|
227
|
-
throw KeyboardShortcutsErrorFactory.
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
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 (
|
|
650
|
+
if (this.isListening) {
|
|
414
651
|
return;
|
|
415
652
|
}
|
|
416
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
881
|
+
modifiers.add('ctrl');
|
|
451
882
|
if (event.altKey)
|
|
452
|
-
|
|
883
|
+
modifiers.add('alt');
|
|
453
884
|
if (event.shiftKey)
|
|
454
|
-
|
|
885
|
+
modifiers.add('shift');
|
|
455
886
|
if (event.metaKey)
|
|
456
|
-
|
|
457
|
-
//
|
|
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 (!
|
|
460
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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' });
|