ngx-keys 1.1.0 → 1.3.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 +808 -19
- package/fesm2022/ngx-keys.mjs +935 -119
- package/fesm2022/ngx-keys.mjs.map +1 -1
- package/index.d.ts +484 -25
- 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 { InjectionToken, DestroyRef, inject, DOCUMENT, signal, computed, afterNextRender, Injectable, ElementRef, output, HostBinding, Input, Directive } 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,19 +99,256 @@ class KeyboardShortcutsErrorFactory {
|
|
|
88
99
|
}
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Injection token for providing KeyboardShortcuts configuration.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* providers: [
|
|
108
|
+
* provideKeyboardShortcutsConfig({ sequenceTimeoutMs: Infinity })
|
|
109
|
+
* ]
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
const KEYBOARD_SHORTCUTS_CONFIG = new InjectionToken('KEYBOARD_SHORTCUTS_CONFIG', {
|
|
113
|
+
providedIn: 'root',
|
|
114
|
+
factory: () => ({})
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* Provides KeyboardShortcuts configuration using Angular's modern provider pattern.
|
|
118
|
+
*
|
|
119
|
+
* @param config - Configuration options for KeyboardShortcuts service
|
|
120
|
+
* @returns Provider array for use in Angular dependency injection
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```typescript
|
|
124
|
+
* bootstrapApplication(AppComponent, {
|
|
125
|
+
* providers: [
|
|
126
|
+
* provideKeyboardShortcutsConfig({ sequenceTimeoutMs: Infinity })
|
|
127
|
+
* ]
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
function provideKeyboardShortcutsConfig(config) {
|
|
132
|
+
return [{ provide: KEYBOARD_SHORTCUTS_CONFIG, useValue: config }];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Initial version number for state change detection.
|
|
136
|
+
* Used internally to track state updates efficiently.
|
|
137
|
+
* @internal
|
|
138
|
+
*/
|
|
139
|
+
const INITIAL_STATE_VERSION = 0;
|
|
140
|
+
/**
|
|
141
|
+
* Increment value for state version updates.
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
const STATE_VERSION_INCREMENT = 1;
|
|
145
|
+
/**
|
|
146
|
+
* Index of the first element in an array.
|
|
147
|
+
* Used for readability when checking array structure.
|
|
148
|
+
* @internal
|
|
149
|
+
*/
|
|
150
|
+
const FIRST_INDEX = 0;
|
|
151
|
+
/**
|
|
152
|
+
* Index representing the first step in a multi-step sequence.
|
|
153
|
+
* @internal
|
|
154
|
+
*/
|
|
155
|
+
const FIRST_STEP_INDEX = 0;
|
|
156
|
+
/**
|
|
157
|
+
* Index representing the second step in a multi-step sequence (after first step completes).
|
|
158
|
+
* @internal
|
|
159
|
+
*/
|
|
160
|
+
const SECOND_STEP_INDEX = 1;
|
|
161
|
+
/**
|
|
162
|
+
* Threshold for determining if a shortcut is a single-step sequence.
|
|
163
|
+
* @internal
|
|
164
|
+
*/
|
|
165
|
+
const SINGLE_STEP_LENGTH = 1;
|
|
166
|
+
/**
|
|
167
|
+
* Minimum count indicating that at least one item exists.
|
|
168
|
+
* @internal
|
|
169
|
+
*/
|
|
170
|
+
const MIN_COUNT_ONE = 1;
|
|
171
|
+
/**
|
|
172
|
+
* Minimum length for a valid key string.
|
|
173
|
+
* @internal
|
|
174
|
+
*/
|
|
175
|
+
const MIN_KEY_LENGTH = 0;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Utility class for keyboard shortcut key matching and normalization.
|
|
179
|
+
* Provides methods for comparing key combinations and normalizing key input.
|
|
180
|
+
*/
|
|
181
|
+
class KeyMatcher {
|
|
182
|
+
static MODIFIER_KEYS = new Set(['ctrl', 'control', 'alt', 'shift', 'meta']);
|
|
183
|
+
/**
|
|
184
|
+
* Normalize a key string to lowercase for consistent comparison.
|
|
185
|
+
*
|
|
186
|
+
* @param key - The key string to normalize
|
|
187
|
+
* @returns The normalized (lowercase) key string
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```typescript
|
|
191
|
+
* KeyMatcher.normalizeKey('Ctrl'); // returns 'ctrl'
|
|
192
|
+
* KeyMatcher.normalizeKey('A'); // returns 'a'
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
static normalizeKey(key) {
|
|
196
|
+
return key.toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Check if a key is a modifier key (ctrl, alt, shift, meta).
|
|
200
|
+
*
|
|
201
|
+
* @param key - The key to check
|
|
202
|
+
* @returns true if the key is a modifier key
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```typescript
|
|
206
|
+
* KeyMatcher.isModifierKey('ctrl'); // returns true
|
|
207
|
+
* KeyMatcher.isModifierKey('a'); // returns false
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
static isModifierKey(key) {
|
|
211
|
+
return this.MODIFIER_KEYS.has(key.toLowerCase());
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Compare two key combinations for equality.
|
|
215
|
+
* Supports both Set<string> and string[] as input.
|
|
216
|
+
* Keys are normalized (lowercased) before comparison.
|
|
217
|
+
*
|
|
218
|
+
* @param a - First key combination (Set or array)
|
|
219
|
+
* @param b - Second key combination (array)
|
|
220
|
+
* @returns true if both combinations contain the same keys
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```typescript
|
|
224
|
+
* KeyMatcher.keysMatch(['ctrl', 's'], ['ctrl', 's']); // returns true
|
|
225
|
+
* KeyMatcher.keysMatch(['ctrl', 's'], ['Ctrl', 'S']); // returns true (normalized)
|
|
226
|
+
* KeyMatcher.keysMatch(['ctrl', 's'], ['alt', 's']); // returns false
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
static keysMatch(a, b) {
|
|
230
|
+
const setA = a instanceof Set ? a : this.normalizeKeysToSet(a);
|
|
231
|
+
const setB = this.normalizeKeysToSet(b);
|
|
232
|
+
if (setA.size !== setB.size) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
for (const key of setA) {
|
|
236
|
+
if (!setB.has(key)) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Compare two multi-step sequences for equality.
|
|
244
|
+
* Each sequence is an array of steps, where each step is an array of keys.
|
|
245
|
+
*
|
|
246
|
+
* @param a - First multi-step sequence
|
|
247
|
+
* @param b - Second multi-step sequence
|
|
248
|
+
* @returns true if both sequences are identical
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ```typescript
|
|
252
|
+
* const seq1 = [['ctrl', 'k'], ['s']];
|
|
253
|
+
* const seq2 = [['ctrl', 'k'], ['s']];
|
|
254
|
+
* KeyMatcher.stepsMatch(seq1, seq2); // returns true
|
|
255
|
+
*
|
|
256
|
+
* const seq3 = [['ctrl', 'k'], ['t']];
|
|
257
|
+
* KeyMatcher.stepsMatch(seq1, seq3); // returns false (different final step)
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
static stepsMatch(a, b) {
|
|
261
|
+
if (a.length !== b.length) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
for (let i = 0; i < a.length; i++) {
|
|
265
|
+
if (!this.keysMatch(a[i], b[i])) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Normalize an array of keys to a Set of lowercase keys.
|
|
273
|
+
*
|
|
274
|
+
* @param keys - Array of keys to normalize
|
|
275
|
+
* @returns Set of normalized (lowercase) keys
|
|
276
|
+
*
|
|
277
|
+
* @internal
|
|
278
|
+
*/
|
|
279
|
+
static normalizeKeysToSet(keys) {
|
|
280
|
+
return new Set(keys.map(k => k.toLowerCase()));
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Get the set of modifier key names.
|
|
284
|
+
* Useful for filtering or checking if a key is a modifier.
|
|
285
|
+
*
|
|
286
|
+
* @returns ReadonlySet of modifier key names
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* ```typescript
|
|
290
|
+
* const modifiers = KeyMatcher.getModifierKeys();
|
|
291
|
+
* console.log(modifiers.has('ctrl')); // true
|
|
292
|
+
* console.log(modifiers.has('a')); // false
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
static getModifierKeys() {
|
|
296
|
+
return this.MODIFIER_KEYS;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Type guard to detect KeyboardShortcutGroupOptions at runtime.
|
|
302
|
+
* Centralising this logic keeps registerGroup simpler and less fragile.
|
|
303
|
+
*/
|
|
304
|
+
function isGroupOptions(param) {
|
|
305
|
+
if (!param || typeof param !== 'object')
|
|
306
|
+
return false;
|
|
307
|
+
// Narrow to object for property checks
|
|
308
|
+
const obj = param;
|
|
309
|
+
return ('filter' in obj) || ('activeUntil' in obj);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Detect real DestroyRef instances or duck-typed objects exposing onDestroy(fn).
|
|
313
|
+
* Returns true for either an actual DestroyRef or an object with an onDestroy method.
|
|
314
|
+
*/
|
|
315
|
+
function isDestroyRefLike(obj) {
|
|
316
|
+
if (!obj || typeof obj !== 'object')
|
|
317
|
+
return false;
|
|
318
|
+
try {
|
|
319
|
+
// Prefer instanceof when available (real DestroyRef)
|
|
320
|
+
if (obj instanceof DestroyRef)
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// instanceof may throw if DestroyRef is not constructable in certain runtimes/tests
|
|
325
|
+
}
|
|
326
|
+
const o = obj;
|
|
327
|
+
return typeof o['onDestroy'] === 'function';
|
|
328
|
+
}
|
|
91
329
|
class KeyboardShortcuts {
|
|
330
|
+
document = inject(DOCUMENT);
|
|
331
|
+
window = this.document.defaultView;
|
|
332
|
+
config = inject(KEYBOARD_SHORTCUTS_CONFIG);
|
|
92
333
|
shortcuts = new Map();
|
|
93
334
|
groups = new Map();
|
|
94
335
|
activeShortcuts = new Set();
|
|
95
336
|
activeGroups = new Set();
|
|
96
337
|
currentlyDownKeys = new Set();
|
|
338
|
+
// O(1) lookup from shortcutId to its groupId to avoid scanning all groups per event
|
|
339
|
+
shortcutToGroup = new Map();
|
|
340
|
+
/**
|
|
341
|
+
* Named global filters that apply to all shortcuts.
|
|
342
|
+
* All global filters must return `true` for a shortcut to be processed.
|
|
343
|
+
*/
|
|
344
|
+
globalFilters = new Map();
|
|
97
345
|
// Single consolidated state signal - reduces memory overhead
|
|
98
346
|
state = signal({
|
|
99
347
|
shortcuts: new Map(),
|
|
100
348
|
groups: new Map(),
|
|
101
349
|
activeShortcuts: new Set(),
|
|
102
350
|
activeGroups: new Set(),
|
|
103
|
-
version:
|
|
351
|
+
version: INITIAL_STATE_VERSION // for change detection optimization
|
|
104
352
|
}, ...(ngDevMode ? [{ debugName: "state" }] : []));
|
|
105
353
|
// Primary computed signal - consumers derive what they need from this
|
|
106
354
|
shortcuts$ = computed(() => {
|
|
@@ -135,24 +383,12 @@ class KeyboardShortcuts {
|
|
|
135
383
|
blurListener = this.handleWindowBlur.bind(this);
|
|
136
384
|
visibilityListener = this.handleVisibilityChange.bind(this);
|
|
137
385
|
isListening = false;
|
|
138
|
-
isBrowser;
|
|
139
|
-
/** Default timeout (ms) for completing a multi-step sequence */
|
|
140
|
-
sequenceTimeout = 2000;
|
|
141
386
|
/** Runtime state for multi-step sequences */
|
|
142
387
|
pendingSequence = null;
|
|
143
388
|
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) {
|
|
389
|
+
afterNextRender(() => {
|
|
154
390
|
this.startListening();
|
|
155
|
-
}
|
|
391
|
+
});
|
|
156
392
|
}
|
|
157
393
|
ngOnDestroy() {
|
|
158
394
|
this.stopListening();
|
|
@@ -166,7 +402,7 @@ class KeyboardShortcuts {
|
|
|
166
402
|
groups: new Map(this.groups),
|
|
167
403
|
activeShortcuts: new Set(this.activeShortcuts),
|
|
168
404
|
activeGroups: new Set(this.activeGroups),
|
|
169
|
-
version: current.version +
|
|
405
|
+
version: current.version + STATE_VERSION_INCREMENT
|
|
170
406
|
}));
|
|
171
407
|
}
|
|
172
408
|
/**
|
|
@@ -213,28 +449,32 @@ class KeyboardShortcuts {
|
|
|
213
449
|
return '';
|
|
214
450
|
// If the first element is an array, assume steps is string[][]
|
|
215
451
|
const normalized = this.normalizeToSteps(steps);
|
|
216
|
-
if (normalized.length ===
|
|
452
|
+
if (normalized.length === MIN_KEY_LENGTH)
|
|
217
453
|
return '';
|
|
218
|
-
if (normalized.length ===
|
|
219
|
-
return this.formatKeysForDisplay(normalized[
|
|
454
|
+
if (normalized.length === SINGLE_STEP_LENGTH)
|
|
455
|
+
return this.formatKeysForDisplay(normalized[FIRST_INDEX], isMac);
|
|
220
456
|
return normalized.map(step => this.formatKeysForDisplay(step, isMac)).join(', ');
|
|
221
457
|
}
|
|
222
458
|
normalizeToSteps(input) {
|
|
223
459
|
if (!input)
|
|
224
460
|
return [];
|
|
225
461
|
// If first element is an array, assume already KeyStep[]
|
|
226
|
-
if (Array.isArray(input[
|
|
462
|
+
if (Array.isArray(input[FIRST_INDEX])) {
|
|
227
463
|
return input;
|
|
228
464
|
}
|
|
229
465
|
// Single step array
|
|
230
466
|
return [input];
|
|
231
467
|
}
|
|
232
468
|
/**
|
|
233
|
-
* Check if a key combination is already registered
|
|
234
|
-
* @returns The ID of the conflicting shortcut, or null if no conflict
|
|
469
|
+
* Check if a key combination is already registered by an active shortcut
|
|
470
|
+
* @returns The ID of the conflicting active shortcut, or null if no active conflict
|
|
235
471
|
*/
|
|
236
|
-
|
|
472
|
+
findActiveConflict(newShortcut) {
|
|
237
473
|
for (const existing of this.shortcuts.values()) {
|
|
474
|
+
// Only check conflicts with active shortcuts
|
|
475
|
+
if (!this.activeShortcuts.has(existing.id)) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
238
478
|
// Compare single-step shapes if provided
|
|
239
479
|
if (newShortcut.keys && existing.keys && this.keysMatch(newShortcut.keys, existing.keys)) {
|
|
240
480
|
return existing.id;
|
|
@@ -252,48 +492,79 @@ class KeyboardShortcuts {
|
|
|
252
492
|
}
|
|
253
493
|
return null;
|
|
254
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* Check if activating a shortcut would create key conflicts with other active shortcuts
|
|
497
|
+
* @returns Array of conflicting shortcut IDs that would be created by activation
|
|
498
|
+
*/
|
|
499
|
+
findActivationConflicts(shortcutId) {
|
|
500
|
+
const shortcut = this.shortcuts.get(shortcutId);
|
|
501
|
+
if (!shortcut)
|
|
502
|
+
return [];
|
|
503
|
+
const conflicts = [];
|
|
504
|
+
for (const existing of this.shortcuts.values()) {
|
|
505
|
+
// Skip self and inactive shortcuts
|
|
506
|
+
if (existing.id === shortcutId || !this.activeShortcuts.has(existing.id)) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
// Check for key conflicts
|
|
510
|
+
if ((shortcut.keys && existing.keys && this.keysMatch(shortcut.keys, existing.keys)) ||
|
|
511
|
+
(shortcut.macKeys && existing.macKeys && this.keysMatch(shortcut.macKeys, existing.macKeys)) ||
|
|
512
|
+
(shortcut.steps && existing.steps && this.stepsMatch(shortcut.steps, existing.steps)) ||
|
|
513
|
+
(shortcut.macSteps && existing.macSteps && this.stepsMatch(shortcut.macSteps, existing.macSteps))) {
|
|
514
|
+
conflicts.push(existing.id);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return conflicts;
|
|
518
|
+
}
|
|
255
519
|
/**
|
|
256
520
|
* Register a single keyboard shortcut
|
|
257
|
-
* @throws KeyboardShortcutError if shortcut ID is already registered or
|
|
521
|
+
* @throws KeyboardShortcutError if shortcut ID is already registered or if the shortcut would conflict with currently active shortcuts
|
|
258
522
|
*/
|
|
259
523
|
register(shortcut) {
|
|
260
524
|
if (this.shortcuts.has(shortcut.id)) {
|
|
261
525
|
throw KeyboardShortcutsErrorFactory.shortcutAlreadyRegistered(shortcut.id);
|
|
262
526
|
}
|
|
263
|
-
|
|
527
|
+
// Check for conflicts only with currently active shortcuts
|
|
528
|
+
const conflictId = this.findActiveConflict(shortcut);
|
|
264
529
|
if (conflictId) {
|
|
265
|
-
throw KeyboardShortcutsErrorFactory.
|
|
530
|
+
throw KeyboardShortcutsErrorFactory.activeKeyConflict(conflictId);
|
|
266
531
|
}
|
|
267
532
|
this.shortcuts.set(shortcut.id, shortcut);
|
|
268
533
|
this.activeShortcuts.add(shortcut.id);
|
|
269
534
|
this.updateState();
|
|
270
535
|
this.setupActiveUntil(shortcut.activeUntil, this.unregister.bind(this, shortcut.id));
|
|
271
536
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
537
|
+
registerGroup(groupId, shortcuts, optionsOrActiveUntil) {
|
|
538
|
+
// Parse parameters - support both old (activeUntil) and new (options) formats
|
|
539
|
+
let options;
|
|
540
|
+
if (isGroupOptions(optionsOrActiveUntil)) {
|
|
541
|
+
// New format with options object
|
|
542
|
+
options = optionsOrActiveUntil;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
// Old format with just activeUntil parameter
|
|
546
|
+
options = { activeUntil: optionsOrActiveUntil };
|
|
547
|
+
}
|
|
277
548
|
// Check if group ID already exists
|
|
278
549
|
if (this.groups.has(groupId)) {
|
|
279
550
|
throw KeyboardShortcutsErrorFactory.groupAlreadyRegistered(groupId);
|
|
280
551
|
}
|
|
281
|
-
// Check for duplicate shortcut IDs and key combination conflicts
|
|
552
|
+
// Check for duplicate shortcut IDs and key combination conflicts with active shortcuts
|
|
282
553
|
const duplicateIds = [];
|
|
283
554
|
const keyConflicts = [];
|
|
284
555
|
shortcuts.forEach(shortcut => {
|
|
285
556
|
if (this.shortcuts.has(shortcut.id)) {
|
|
286
557
|
duplicateIds.push(shortcut.id);
|
|
287
558
|
}
|
|
288
|
-
const conflictId = this.
|
|
559
|
+
const conflictId = this.findActiveConflict(shortcut);
|
|
289
560
|
if (conflictId) {
|
|
290
|
-
keyConflicts.push(`"${shortcut.id}" conflicts with "${conflictId}"`);
|
|
561
|
+
keyConflicts.push(`"${shortcut.id}" conflicts with active shortcut "${conflictId}"`);
|
|
291
562
|
}
|
|
292
563
|
});
|
|
293
|
-
if (duplicateIds.length >
|
|
564
|
+
if (duplicateIds.length > MIN_KEY_LENGTH) {
|
|
294
565
|
throw KeyboardShortcutsErrorFactory.shortcutIdsAlreadyRegistered(duplicateIds);
|
|
295
566
|
}
|
|
296
|
-
if (keyConflicts.length >
|
|
567
|
+
if (keyConflicts.length > MIN_KEY_LENGTH) {
|
|
297
568
|
throw KeyboardShortcutsErrorFactory.keyConflictsInGroup(keyConflicts);
|
|
298
569
|
}
|
|
299
570
|
// Validate that all shortcuts have unique IDs within the group
|
|
@@ -307,7 +578,7 @@ class KeyboardShortcuts {
|
|
|
307
578
|
groupIds.add(shortcut.id);
|
|
308
579
|
}
|
|
309
580
|
});
|
|
310
|
-
if (duplicatesInGroup.length >
|
|
581
|
+
if (duplicatesInGroup.length > MIN_KEY_LENGTH) {
|
|
311
582
|
throw KeyboardShortcutsErrorFactory.duplicateShortcutsInGroup(duplicatesInGroup);
|
|
312
583
|
}
|
|
313
584
|
// Use batch update to reduce signal updates
|
|
@@ -315,7 +586,8 @@ class KeyboardShortcuts {
|
|
|
315
586
|
const group = {
|
|
316
587
|
id: groupId,
|
|
317
588
|
shortcuts,
|
|
318
|
-
active: true
|
|
589
|
+
active: true,
|
|
590
|
+
filter: options.filter
|
|
319
591
|
};
|
|
320
592
|
this.groups.set(groupId, group);
|
|
321
593
|
this.activeGroups.add(groupId);
|
|
@@ -323,9 +595,10 @@ class KeyboardShortcuts {
|
|
|
323
595
|
shortcuts.forEach(shortcut => {
|
|
324
596
|
this.shortcuts.set(shortcut.id, shortcut);
|
|
325
597
|
this.activeShortcuts.add(shortcut.id);
|
|
598
|
+
this.shortcutToGroup.set(shortcut.id, groupId);
|
|
326
599
|
});
|
|
327
600
|
});
|
|
328
|
-
this.setupActiveUntil(activeUntil, this.unregisterGroup.bind(this, groupId));
|
|
601
|
+
this.setupActiveUntil(options.activeUntil, this.unregisterGroup.bind(this, groupId));
|
|
329
602
|
}
|
|
330
603
|
/**
|
|
331
604
|
* Unregister a single keyboard shortcut
|
|
@@ -337,6 +610,7 @@ class KeyboardShortcuts {
|
|
|
337
610
|
}
|
|
338
611
|
this.shortcuts.delete(shortcutId);
|
|
339
612
|
this.activeShortcuts.delete(shortcutId);
|
|
613
|
+
this.shortcutToGroup.delete(shortcutId);
|
|
340
614
|
this.updateState();
|
|
341
615
|
}
|
|
342
616
|
/**
|
|
@@ -352,6 +626,7 @@ class KeyboardShortcuts {
|
|
|
352
626
|
group.shortcuts.forEach(shortcut => {
|
|
353
627
|
this.shortcuts.delete(shortcut.id);
|
|
354
628
|
this.activeShortcuts.delete(shortcut.id);
|
|
629
|
+
this.shortcutToGroup.delete(shortcut.id);
|
|
355
630
|
});
|
|
356
631
|
this.groups.delete(groupId);
|
|
357
632
|
this.activeGroups.delete(groupId);
|
|
@@ -359,12 +634,17 @@ class KeyboardShortcuts {
|
|
|
359
634
|
}
|
|
360
635
|
/**
|
|
361
636
|
* Activate a single keyboard shortcut
|
|
362
|
-
* @throws KeyboardShortcutError if shortcut ID doesn't exist
|
|
637
|
+
* @throws KeyboardShortcutError if shortcut ID doesn't exist or if activation would create key conflicts
|
|
363
638
|
*/
|
|
364
639
|
activate(shortcutId) {
|
|
365
640
|
if (!this.shortcuts.has(shortcutId)) {
|
|
366
641
|
throw KeyboardShortcutsErrorFactory.cannotActivateShortcut(shortcutId);
|
|
367
642
|
}
|
|
643
|
+
// Check for conflicts that would be created by activation
|
|
644
|
+
const conflicts = this.findActivationConflicts(shortcutId);
|
|
645
|
+
if (conflicts.length > MIN_KEY_LENGTH) {
|
|
646
|
+
throw KeyboardShortcutsErrorFactory.activationKeyConflict(shortcutId, conflicts);
|
|
647
|
+
}
|
|
368
648
|
this.activeShortcuts.add(shortcutId);
|
|
369
649
|
this.updateState();
|
|
370
650
|
}
|
|
@@ -381,13 +661,22 @@ class KeyboardShortcuts {
|
|
|
381
661
|
}
|
|
382
662
|
/**
|
|
383
663
|
* Activate a group of keyboard shortcuts
|
|
384
|
-
* @throws KeyboardShortcutError if group ID doesn't exist
|
|
664
|
+
* @throws KeyboardShortcutError if group ID doesn't exist or if activation would create key conflicts
|
|
385
665
|
*/
|
|
386
666
|
activateGroup(groupId) {
|
|
387
667
|
const group = this.groups.get(groupId);
|
|
388
668
|
if (!group) {
|
|
389
669
|
throw KeyboardShortcutsErrorFactory.cannotActivateGroup(groupId);
|
|
390
670
|
}
|
|
671
|
+
// Check for conflicts that would be created by activating all shortcuts in the group
|
|
672
|
+
const allConflicts = [];
|
|
673
|
+
group.shortcuts.forEach(shortcut => {
|
|
674
|
+
const conflicts = this.findActivationConflicts(shortcut.id);
|
|
675
|
+
allConflicts.push(...conflicts);
|
|
676
|
+
});
|
|
677
|
+
if (allConflicts.length > MIN_KEY_LENGTH) {
|
|
678
|
+
throw KeyboardShortcutsErrorFactory.groupActivationKeyConflict(groupId, allConflicts);
|
|
679
|
+
}
|
|
391
680
|
this.batchUpdate(() => {
|
|
392
681
|
group.active = true;
|
|
393
682
|
this.activeGroups.add(groupId);
|
|
@@ -449,45 +738,306 @@ class KeyboardShortcuts {
|
|
|
449
738
|
getGroups() {
|
|
450
739
|
return this.groups;
|
|
451
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* Add a named global filter that applies to all shortcuts.
|
|
743
|
+
* All global filters must return `true` for a shortcut to execute.
|
|
744
|
+
*
|
|
745
|
+
* @param name - Unique name for this filter
|
|
746
|
+
* @param filter - Function that returns `true` to allow shortcuts, `false` to block them
|
|
747
|
+
*
|
|
748
|
+
* @example
|
|
749
|
+
* ```typescript
|
|
750
|
+
* // Block shortcuts in form elements
|
|
751
|
+
* keyboardService.addFilter('forms', (event) => {
|
|
752
|
+
* const target = event.target as HTMLElement;
|
|
753
|
+
* const tagName = target?.tagName?.toLowerCase();
|
|
754
|
+
* return !['input', 'textarea', 'select'].includes(tagName) && !target?.isContentEditable;
|
|
755
|
+
* });
|
|
756
|
+
*
|
|
757
|
+
* // Block shortcuts when modal is open
|
|
758
|
+
* keyboardService.addFilter('modal', (event) => {
|
|
759
|
+
* return !document.querySelector('.modal.active');
|
|
760
|
+
* });
|
|
761
|
+
* ```
|
|
762
|
+
*/
|
|
763
|
+
addFilter(name, filter) {
|
|
764
|
+
this.globalFilters.set(name, filter);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Remove a named global filter.
|
|
768
|
+
*
|
|
769
|
+
* @param name - Name of the filter to remove
|
|
770
|
+
* @returns `true` if filter was removed, `false` if it didn't exist
|
|
771
|
+
*/
|
|
772
|
+
removeFilter(name) {
|
|
773
|
+
return this.globalFilters.delete(name);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Get a named global filter.
|
|
777
|
+
*
|
|
778
|
+
* @param name - Name of the filter to retrieve
|
|
779
|
+
* @returns The filter function, or undefined if not found
|
|
780
|
+
*/
|
|
781
|
+
getFilter(name) {
|
|
782
|
+
return this.globalFilters.get(name);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Get all global filter names.
|
|
786
|
+
*
|
|
787
|
+
* @returns Array of filter names
|
|
788
|
+
*/
|
|
789
|
+
getFilterNames() {
|
|
790
|
+
return Array.from(this.globalFilters.keys());
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Remove all global filters.
|
|
794
|
+
*/
|
|
795
|
+
clearFilters() {
|
|
796
|
+
this.globalFilters.clear();
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Check if a named filter exists.
|
|
800
|
+
*
|
|
801
|
+
* @param name - Name of the filter to check
|
|
802
|
+
* @returns `true` if filter exists, `false` otherwise
|
|
803
|
+
*/
|
|
804
|
+
hasFilter(name) {
|
|
805
|
+
return this.globalFilters.has(name);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Remove the filter from a specific group
|
|
809
|
+
* @param groupId - The group ID
|
|
810
|
+
* @throws KeyboardShortcutError if group doesn't exist
|
|
811
|
+
*/
|
|
812
|
+
removeGroupFilter(groupId) {
|
|
813
|
+
const group = this.groups.get(groupId);
|
|
814
|
+
if (!group) {
|
|
815
|
+
throw KeyboardShortcutsErrorFactory.cannotDeactivateGroup(groupId);
|
|
816
|
+
}
|
|
817
|
+
if (!group.filter) {
|
|
818
|
+
// No filter to remove, silently succeed
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
this.groups.set(groupId, {
|
|
822
|
+
...group,
|
|
823
|
+
filter: undefined
|
|
824
|
+
});
|
|
825
|
+
this.updateState();
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Remove the filter from a specific shortcut
|
|
829
|
+
* @param shortcutId - The shortcut ID
|
|
830
|
+
* @throws KeyboardShortcutError if shortcut doesn't exist
|
|
831
|
+
*/
|
|
832
|
+
removeShortcutFilter(shortcutId) {
|
|
833
|
+
const shortcut = this.shortcuts.get(shortcutId);
|
|
834
|
+
if (!shortcut) {
|
|
835
|
+
throw KeyboardShortcutsErrorFactory.cannotDeactivateShortcut(shortcutId);
|
|
836
|
+
}
|
|
837
|
+
if (!shortcut.filter) {
|
|
838
|
+
// No filter to remove, silently succeed
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
this.shortcuts.set(shortcutId, {
|
|
842
|
+
...shortcut,
|
|
843
|
+
filter: undefined
|
|
844
|
+
});
|
|
845
|
+
this.updateState();
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Remove all filters from all groups
|
|
849
|
+
*/
|
|
850
|
+
clearAllGroupFilters() {
|
|
851
|
+
this.batchUpdate(() => {
|
|
852
|
+
this.groups.forEach((group, id) => {
|
|
853
|
+
if (group.filter) {
|
|
854
|
+
this.removeGroupFilter(id);
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Remove all filters from all shortcuts
|
|
861
|
+
*/
|
|
862
|
+
clearAllShortcutFilters() {
|
|
863
|
+
this.batchUpdate(() => {
|
|
864
|
+
this.shortcuts.forEach((shortcut, id) => {
|
|
865
|
+
if (shortcut.filter) {
|
|
866
|
+
this.removeShortcutFilter(id);
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Check if a group has a filter
|
|
873
|
+
* @param groupId - The group ID
|
|
874
|
+
* @returns True if the group has a filter
|
|
875
|
+
*/
|
|
876
|
+
hasGroupFilter(groupId) {
|
|
877
|
+
const group = this.groups.get(groupId);
|
|
878
|
+
return !!group?.filter;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Check if a shortcut has a filter
|
|
882
|
+
* @param shortcutId - The shortcut ID
|
|
883
|
+
* @returns True if the shortcut has a filter
|
|
884
|
+
*/
|
|
885
|
+
hasShortcutFilter(shortcutId) {
|
|
886
|
+
const shortcut = this.shortcuts.get(shortcutId);
|
|
887
|
+
return !!shortcut?.filter;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Register multiple shortcuts in a single batch update
|
|
891
|
+
* @param shortcuts - Array of shortcuts to register
|
|
892
|
+
*/
|
|
893
|
+
registerMany(shortcuts) {
|
|
894
|
+
this.batchUpdate(() => {
|
|
895
|
+
shortcuts.forEach(shortcut => this.register(shortcut));
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Unregister multiple shortcuts in a single batch update
|
|
900
|
+
* @param ids - Array of shortcut IDs to unregister
|
|
901
|
+
*/
|
|
902
|
+
unregisterMany(ids) {
|
|
903
|
+
this.batchUpdate(() => {
|
|
904
|
+
ids.forEach(id => this.unregister(id));
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Unregister multiple groups in a single batch update
|
|
909
|
+
* @param ids - Array of group IDs to unregister
|
|
910
|
+
*/
|
|
911
|
+
unregisterGroups(ids) {
|
|
912
|
+
this.batchUpdate(() => {
|
|
913
|
+
ids.forEach(id => this.unregisterGroup(id));
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Clear all shortcuts and groups (nuclear reset)
|
|
918
|
+
*/
|
|
919
|
+
clearAll() {
|
|
920
|
+
this.batchUpdate(() => {
|
|
921
|
+
this.shortcuts.clear();
|
|
922
|
+
this.groups.clear();
|
|
923
|
+
this.activeShortcuts.clear();
|
|
924
|
+
this.activeGroups.clear();
|
|
925
|
+
this.shortcutToGroup.clear();
|
|
926
|
+
this.globalFilters.clear();
|
|
927
|
+
this.clearCurrentlyDownKeys();
|
|
928
|
+
this.clearPendingSequence();
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Get all shortcuts belonging to a specific group
|
|
933
|
+
* @param groupId - The group ID
|
|
934
|
+
* @returns Array of shortcuts in the group
|
|
935
|
+
*/
|
|
936
|
+
getGroupShortcuts(groupId) {
|
|
937
|
+
const group = this.groups.get(groupId);
|
|
938
|
+
if (!group)
|
|
939
|
+
return [];
|
|
940
|
+
return [...group.shortcuts];
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Normalize a key to lowercase for consistent comparison
|
|
944
|
+
*/
|
|
945
|
+
normalizeKey(key) {
|
|
946
|
+
return key.toLowerCase();
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Find the group that contains a specific shortcut.
|
|
950
|
+
*
|
|
951
|
+
* @param shortcutId - The ID of the shortcut to find
|
|
952
|
+
* @returns The group containing the shortcut, or undefined if not found in any group
|
|
953
|
+
*/
|
|
954
|
+
findGroupForShortcut(shortcutId) {
|
|
955
|
+
const groupId = this.shortcutToGroup.get(shortcutId);
|
|
956
|
+
return groupId ? this.groups.get(groupId) : undefined;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Check if a keyboard event should be processed based on global, group, and per-shortcut filters.
|
|
960
|
+
* Filter hierarchy: Global filters → Group filter → Individual shortcut filter
|
|
961
|
+
*
|
|
962
|
+
* @param event - The keyboard event to evaluate
|
|
963
|
+
* @param shortcut - The shortcut being evaluated (for per-shortcut filter)
|
|
964
|
+
* @returns `true` if event should be processed, `false` if it should be ignored
|
|
965
|
+
*/
|
|
966
|
+
shouldProcessEvent(event, shortcut) {
|
|
967
|
+
// First, check all global filters - ALL must return true
|
|
968
|
+
// Note: handleKeydown pre-checks these once per event for early exit,
|
|
969
|
+
// but we keep this for direct calls and completeness.
|
|
970
|
+
for (const globalFilter of this.globalFilters.values()) {
|
|
971
|
+
if (!globalFilter(event)) {
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// Then check group filter if shortcut belongs to a group
|
|
976
|
+
const group = this.findGroupForShortcut(shortcut.id);
|
|
977
|
+
if (group?.filter && !group.filter(event)) {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
// Finally check per-shortcut filter if it exists
|
|
981
|
+
if (shortcut.filter && !shortcut.filter(event)) {
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
452
986
|
startListening() {
|
|
453
|
-
if (
|
|
987
|
+
if (this.isListening) {
|
|
454
988
|
return;
|
|
455
989
|
}
|
|
456
990
|
// Listen to both keydown and keyup so we can maintain a Set of currently
|
|
457
991
|
// pressed physical keys. We avoid passive:true because we may call
|
|
458
992
|
// preventDefault() when matching shortcuts.
|
|
459
|
-
document.addEventListener('keydown', this.keydownListener, { passive: false });
|
|
460
|
-
document.addEventListener('keyup', this.keyupListener, { passive: false });
|
|
993
|
+
this.document.addEventListener('keydown', this.keydownListener, { passive: false });
|
|
994
|
+
this.document.addEventListener('keyup', this.keyupListener, { passive: false });
|
|
461
995
|
// Listen for blur/visibility changes so we can clear the currently-down keys
|
|
462
996
|
// and avoid stale state when the browser or tab loses focus.
|
|
463
|
-
window.addEventListener('blur', this.blurListener);
|
|
464
|
-
document.addEventListener('visibilitychange', this.visibilityListener);
|
|
997
|
+
this.window.addEventListener('blur', this.blurListener);
|
|
998
|
+
this.document.addEventListener('visibilitychange', this.visibilityListener);
|
|
465
999
|
this.isListening = true;
|
|
466
1000
|
}
|
|
467
1001
|
stopListening() {
|
|
468
|
-
if (!this.
|
|
1002
|
+
if (!this.isListening) {
|
|
469
1003
|
return;
|
|
470
1004
|
}
|
|
471
|
-
document.removeEventListener('keydown', this.keydownListener);
|
|
472
|
-
document.removeEventListener('keyup', this.keyupListener);
|
|
473
|
-
window.removeEventListener('blur', this.blurListener);
|
|
474
|
-
document.removeEventListener('visibilitychange', this.visibilityListener);
|
|
1005
|
+
this.document.removeEventListener('keydown', this.keydownListener);
|
|
1006
|
+
this.document.removeEventListener('keyup', this.keyupListener);
|
|
1007
|
+
this.window.removeEventListener('blur', this.blurListener);
|
|
1008
|
+
this.document.removeEventListener('visibilitychange', this.visibilityListener);
|
|
475
1009
|
this.isListening = false;
|
|
476
1010
|
}
|
|
477
1011
|
handleKeydown(event) {
|
|
478
1012
|
// Update the currently down keys with this event's key
|
|
479
1013
|
this.updateCurrentlyDownKeysOnKeydown(event);
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
1014
|
+
// Fast path: if any global filter blocks this event, bail out before
|
|
1015
|
+
// scanning all active shortcuts. This drastically reduces per-event work
|
|
1016
|
+
// when filters are commonly blocking (e.g., while typing in inputs).
|
|
1017
|
+
if (this.globalFilters.size > MIN_KEY_LENGTH) {
|
|
1018
|
+
for (const f of this.globalFilters.values()) {
|
|
1019
|
+
if (!f(event)) {
|
|
1020
|
+
// Also clear any pending multi-step sequence – entering a globally
|
|
1021
|
+
// filtered context should not allow sequences to continue.
|
|
1022
|
+
this.clearPendingSequence();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
485
1027
|
const isMac = this.isMacPlatform();
|
|
1028
|
+
// Evaluate active group-level filters once per event and cache blocked groups
|
|
1029
|
+
const blockedGroups = this.precomputeBlockedGroups(event);
|
|
486
1030
|
// If there is a pending multi-step sequence, try to advance it first
|
|
487
1031
|
if (this.pendingSequence) {
|
|
488
1032
|
const pending = this.pendingSequence;
|
|
489
1033
|
const shortcut = this.shortcuts.get(pending.shortcutId);
|
|
490
1034
|
if (shortcut) {
|
|
1035
|
+
// If the pending shortcut belongs to a blocked group, cancel sequence
|
|
1036
|
+
const g = this.findGroupForShortcut(shortcut.id);
|
|
1037
|
+
if (g && blockedGroups.has(g.id)) {
|
|
1038
|
+
this.clearPendingSequence();
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
491
1041
|
const steps = isMac
|
|
492
1042
|
? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
|
|
493
1043
|
: (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
|
|
@@ -498,14 +1048,18 @@ class KeyboardShortcuts {
|
|
|
498
1048
|
// from previous steps (if tests or callers don't emit keyup), which
|
|
499
1049
|
// would prevent matching a simple single-key step like ['s'] after
|
|
500
1050
|
// a prior ['k'] step. Use getPressedKeys(event) which reflects the
|
|
501
|
-
// actual modifier/main-key state for this event
|
|
1051
|
+
// actual modifier/main-key state for this event as a Set<string>.
|
|
502
1052
|
const stepPressed = this.getPressedKeys(event);
|
|
503
1053
|
if (expected && this.keysMatch(stepPressed, expected)) {
|
|
504
1054
|
// Advance sequence
|
|
505
1055
|
clearTimeout(pending.timerId);
|
|
506
|
-
pending.stepIndex +=
|
|
1056
|
+
pending.stepIndex += STATE_VERSION_INCREMENT;
|
|
507
1057
|
if (pending.stepIndex >= normalizedSteps.length) {
|
|
508
|
-
// Completed
|
|
1058
|
+
// Completed - check filters before executing
|
|
1059
|
+
if (!this.shouldProcessEvent(event, shortcut)) {
|
|
1060
|
+
this.pendingSequence = null;
|
|
1061
|
+
return; // Skip execution due to filters
|
|
1062
|
+
}
|
|
509
1063
|
event.preventDefault();
|
|
510
1064
|
event.stopPropagation();
|
|
511
1065
|
try {
|
|
@@ -517,8 +1071,10 @@ class KeyboardShortcuts {
|
|
|
517
1071
|
this.pendingSequence = null;
|
|
518
1072
|
return;
|
|
519
1073
|
}
|
|
520
|
-
// Reset timer for next step
|
|
521
|
-
|
|
1074
|
+
// Reset timer for next step (only if shortcut has timeout configured)
|
|
1075
|
+
if (shortcut.sequenceTimeout !== undefined) {
|
|
1076
|
+
pending.timerId = setTimeout(() => { this.pendingSequence = null; }, shortcut.sequenceTimeout);
|
|
1077
|
+
}
|
|
522
1078
|
return;
|
|
523
1079
|
}
|
|
524
1080
|
else {
|
|
@@ -536,13 +1092,31 @@ class KeyboardShortcuts {
|
|
|
536
1092
|
const shortcut = this.shortcuts.get(shortcutId);
|
|
537
1093
|
if (!shortcut)
|
|
538
1094
|
continue;
|
|
1095
|
+
// Skip expensive matching entirely when the shortcut's group is blocked
|
|
1096
|
+
const g = this.findGroupForShortcut(shortcut.id);
|
|
1097
|
+
if (g && blockedGroups.has(g.id)) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
539
1100
|
const steps = isMac
|
|
540
1101
|
? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
|
|
541
1102
|
: (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
|
|
542
1103
|
const normalizedSteps = this.normalizeToSteps(steps);
|
|
543
|
-
const firstStep = normalizedSteps[
|
|
544
|
-
|
|
545
|
-
|
|
1104
|
+
const firstStep = normalizedSteps[FIRST_INDEX];
|
|
1105
|
+
// Decide which pressed-keys representation to use for this shortcut's
|
|
1106
|
+
// expected step: if it requires multiple non-modifier keys, treat it as
|
|
1107
|
+
// a chord and use accumulated keys; otherwise use per-event keys to avoid
|
|
1108
|
+
// interference from previously pressed non-modifier keys.
|
|
1109
|
+
const nonModifierCount = firstStep.filter(k => !KeyMatcher.isModifierKey(k)).length;
|
|
1110
|
+
// Normalize pressed keys to a Set<string> for consistent typing
|
|
1111
|
+
const pressedForStep = nonModifierCount > MIN_COUNT_ONE
|
|
1112
|
+
? this.buildPressedKeysForMatch(event)
|
|
1113
|
+
: this.getPressedKeys(event);
|
|
1114
|
+
if (this.keysMatch(pressedForStep, firstStep)) {
|
|
1115
|
+
// Check if this event should be processed based on filters
|
|
1116
|
+
if (!this.shouldProcessEvent(event, shortcut)) {
|
|
1117
|
+
continue; // Skip this shortcut due to filters
|
|
1118
|
+
}
|
|
1119
|
+
if (normalizedSteps.length === SINGLE_STEP_LENGTH) {
|
|
546
1120
|
// single-step
|
|
547
1121
|
event.preventDefault();
|
|
548
1122
|
event.stopPropagation();
|
|
@@ -559,10 +1133,13 @@ class KeyboardShortcuts {
|
|
|
559
1133
|
if (this.pendingSequence) {
|
|
560
1134
|
this.clearPendingSequence();
|
|
561
1135
|
}
|
|
1136
|
+
const timerId = shortcut.sequenceTimeout !== undefined
|
|
1137
|
+
? setTimeout(() => { this.pendingSequence = null; }, shortcut.sequenceTimeout)
|
|
1138
|
+
: null;
|
|
562
1139
|
this.pendingSequence = {
|
|
563
1140
|
shortcutId: shortcut.id,
|
|
564
|
-
stepIndex:
|
|
565
|
-
timerId
|
|
1141
|
+
stepIndex: SECOND_STEP_INDEX,
|
|
1142
|
+
timerId
|
|
566
1143
|
};
|
|
567
1144
|
event.preventDefault();
|
|
568
1145
|
event.stopPropagation();
|
|
@@ -572,9 +1149,9 @@ class KeyboardShortcuts {
|
|
|
572
1149
|
}
|
|
573
1150
|
}
|
|
574
1151
|
handleKeyup(event) {
|
|
575
|
-
// Remove the key from
|
|
1152
|
+
// Remove the key from currently DownKeys on keyup
|
|
576
1153
|
const key = event.key ? event.key.toLowerCase() : '';
|
|
577
|
-
if (key && !
|
|
1154
|
+
if (key && !KeyMatcher.isModifierKey(key)) {
|
|
578
1155
|
this.currentlyDownKeys.delete(key);
|
|
579
1156
|
}
|
|
580
1157
|
}
|
|
@@ -592,13 +1169,18 @@ class KeyboardShortcuts {
|
|
|
592
1169
|
this.clearPendingSequence();
|
|
593
1170
|
}
|
|
594
1171
|
handleVisibilityChange() {
|
|
595
|
-
if (document.visibilityState === 'hidden') {
|
|
1172
|
+
if (this.document.visibilityState === 'hidden') {
|
|
596
1173
|
// When the document becomes hidden, clear both pressed keys and any
|
|
597
1174
|
// pending multi-step sequence. This prevents sequences from remaining
|
|
598
1175
|
// active when the user switches tabs or minimizes the window.
|
|
599
1176
|
this.clearCurrentlyDownKeys();
|
|
600
1177
|
this.clearPendingSequence();
|
|
601
1178
|
}
|
|
1179
|
+
else if (this.document.visibilityState === 'visible') {
|
|
1180
|
+
// When returning to visibility, clear keys to avoid stale state
|
|
1181
|
+
// from keys that may have been released while document was hidden
|
|
1182
|
+
this.clearCurrentlyDownKeys();
|
|
1183
|
+
}
|
|
602
1184
|
}
|
|
603
1185
|
/**
|
|
604
1186
|
* Update the currentlyDownKeys set when keydown events happen.
|
|
@@ -608,7 +1190,7 @@ class KeyboardShortcuts {
|
|
|
608
1190
|
updateCurrentlyDownKeysOnKeydown(event) {
|
|
609
1191
|
const key = event.key ? event.key.toLowerCase() : '';
|
|
610
1192
|
// Ignore modifier-only keydown entries
|
|
611
|
-
if (
|
|
1193
|
+
if (KeyMatcher.isModifierKey(key)) {
|
|
612
1194
|
return;
|
|
613
1195
|
}
|
|
614
1196
|
// Normalize some special cases similar to the demo component's recording logic
|
|
@@ -628,15 +1210,10 @@ class KeyboardShortcuts {
|
|
|
628
1210
|
this.currentlyDownKeys.add('enter');
|
|
629
1211
|
return;
|
|
630
1212
|
}
|
|
631
|
-
if (key && key.length >
|
|
1213
|
+
if (key && key.length > MIN_KEY_LENGTH) {
|
|
632
1214
|
this.currentlyDownKeys.add(key);
|
|
633
1215
|
}
|
|
634
1216
|
}
|
|
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
1217
|
/**
|
|
641
1218
|
* Build the pressed keys set used for matching against registered shortcuts.
|
|
642
1219
|
* If multiple non-modifier keys are currently down, include them (chord support).
|
|
@@ -656,83 +1233,75 @@ class KeyboardShortcuts {
|
|
|
656
1233
|
if (event.metaKey)
|
|
657
1234
|
modifiers.add('meta');
|
|
658
1235
|
// Collect non-modifier keys from currentlyDownKeys (excluding modifiers)
|
|
659
|
-
const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !
|
|
1236
|
+
const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyMatcher.isModifierKey(k));
|
|
660
1237
|
const result = new Set();
|
|
661
1238
|
// Add modifiers first
|
|
662
1239
|
modifiers.forEach(m => result.add(m));
|
|
663
|
-
if (nonModifierKeys.length >
|
|
1240
|
+
if (nonModifierKeys.length > MIN_KEY_LENGTH) {
|
|
664
1241
|
nonModifierKeys.forEach(k => result.add(k.toLowerCase()));
|
|
665
1242
|
return result;
|
|
666
1243
|
}
|
|
667
1244
|
// Fallback: single main key from the event (existing behavior)
|
|
668
1245
|
const key = event.key.toLowerCase();
|
|
669
|
-
if (!
|
|
1246
|
+
if (!KeyMatcher.isModifierKey(key)) {
|
|
670
1247
|
result.add(key);
|
|
671
1248
|
}
|
|
672
1249
|
return result;
|
|
673
1250
|
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Return the pressed keys for this event as a Set<string>.
|
|
1253
|
+
* This is the canonical internal API used for matching.
|
|
1254
|
+
*/
|
|
674
1255
|
getPressedKeys(event) {
|
|
675
|
-
const
|
|
1256
|
+
const result = new Set();
|
|
676
1257
|
if (event.ctrlKey)
|
|
677
|
-
|
|
1258
|
+
result.add('ctrl');
|
|
678
1259
|
if (event.altKey)
|
|
679
|
-
|
|
1260
|
+
result.add('alt');
|
|
680
1261
|
if (event.shiftKey)
|
|
681
|
-
|
|
1262
|
+
result.add('shift');
|
|
682
1263
|
if (event.metaKey)
|
|
683
|
-
|
|
684
|
-
// Add the main key (normalize to lowercase)
|
|
685
|
-
const key = event.key.toLowerCase();
|
|
686
|
-
if (
|
|
687
|
-
|
|
1264
|
+
result.add('meta');
|
|
1265
|
+
// Add the main key (normalize to lowercase) if it's not a modifier
|
|
1266
|
+
const key = (event.key ?? '').toLowerCase();
|
|
1267
|
+
if (key && !KeyMatcher.isModifierKey(key)) {
|
|
1268
|
+
result.add(key);
|
|
688
1269
|
}
|
|
689
|
-
return
|
|
1270
|
+
return result;
|
|
690
1271
|
}
|
|
691
1272
|
/**
|
|
692
1273
|
* Compare pressed keys against a target key combination.
|
|
693
1274
|
* Accepts either a Set<string> (preferred) or an array for backwards compatibility.
|
|
694
1275
|
* Uses Set-based comparison: sizes must match and every element in target must exist in pressed.
|
|
695
1276
|
*/
|
|
1277
|
+
/**
|
|
1278
|
+
* @deprecated Use KeyMatcher.keysMatch() instead
|
|
1279
|
+
*/
|
|
696
1280
|
keysMatch(pressedKeys, targetKeys) {
|
|
697
|
-
|
|
698
|
-
const normalizedTarget = new Set(targetKeys.map(k => k.toLowerCase()));
|
|
699
|
-
// Normalize pressedKeys into a Set<string> if it's an array
|
|
700
|
-
const pressedSet = Array.isArray(pressedKeys)
|
|
701
|
-
? new Set(pressedKeys.map(k => k.toLowerCase()))
|
|
702
|
-
: new Set(Array.from(pressedKeys).map(k => k.toLowerCase()));
|
|
703
|
-
if (pressedSet.size !== normalizedTarget.size) {
|
|
704
|
-
return false;
|
|
705
|
-
}
|
|
706
|
-
// Check if every element in normalizedTarget exists in pressedSet
|
|
707
|
-
for (const key of normalizedTarget) {
|
|
708
|
-
if (!pressedSet.has(key)) {
|
|
709
|
-
return false;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
return true;
|
|
1281
|
+
return KeyMatcher.keysMatch(pressedKeys, targetKeys);
|
|
713
1282
|
}
|
|
714
|
-
/**
|
|
1283
|
+
/**
|
|
1284
|
+
* Compare two multi-step sequences for equality
|
|
1285
|
+
* @deprecated Use KeyMatcher.stepsMatch() instead
|
|
1286
|
+
*/
|
|
715
1287
|
stepsMatch(a, b) {
|
|
716
|
-
|
|
717
|
-
return false;
|
|
718
|
-
for (let i = 0; i < a.length; i++) {
|
|
719
|
-
if (!this.keysMatch(a[i], b[i]))
|
|
720
|
-
return false;
|
|
721
|
-
}
|
|
722
|
-
return true;
|
|
1288
|
+
return KeyMatcher.stepsMatch(a, b);
|
|
723
1289
|
}
|
|
724
1290
|
/** Safely clear any pending multi-step sequence */
|
|
725
1291
|
clearPendingSequence() {
|
|
726
1292
|
if (!this.pendingSequence)
|
|
727
1293
|
return;
|
|
728
1294
|
try {
|
|
729
|
-
|
|
1295
|
+
// Only clear timeout if one was set
|
|
1296
|
+
if (this.pendingSequence.timerId !== null) {
|
|
1297
|
+
clearTimeout(this.pendingSequence.timerId);
|
|
1298
|
+
}
|
|
730
1299
|
}
|
|
731
1300
|
catch { /* ignore */ }
|
|
732
1301
|
this.pendingSequence = null;
|
|
733
1302
|
}
|
|
734
1303
|
isMacPlatform() {
|
|
735
|
-
return
|
|
1304
|
+
return /Mac|iPod|iPhone|iPad/.test(this.window.navigator.platform ?? '');
|
|
736
1305
|
}
|
|
737
1306
|
setupActiveUntil(activeUntil, unregister) {
|
|
738
1307
|
if (!activeUntil) {
|
|
@@ -742,7 +1311,10 @@ class KeyboardShortcuts {
|
|
|
742
1311
|
inject(DestroyRef).onDestroy(unregister);
|
|
743
1312
|
return;
|
|
744
1313
|
}
|
|
745
|
-
|
|
1314
|
+
// Support both real DestroyRef instances and duck-typed objects (e.g.,
|
|
1315
|
+
// Jasmine spies) that expose an onDestroy(fn) method for backwards
|
|
1316
|
+
// compatibility with earlier APIs and tests.
|
|
1317
|
+
if (isDestroyRefLike(activeUntil)) {
|
|
746
1318
|
activeUntil.onDestroy(unregister);
|
|
747
1319
|
return;
|
|
748
1320
|
}
|
|
@@ -751,6 +1323,21 @@ class KeyboardShortcuts {
|
|
|
751
1323
|
return;
|
|
752
1324
|
}
|
|
753
1325
|
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Evaluate group filters once per event and return the set of blocked group IDs.
|
|
1328
|
+
*/
|
|
1329
|
+
precomputeBlockedGroups(event) {
|
|
1330
|
+
const blocked = new Set();
|
|
1331
|
+
if (this.activeGroups.size === MIN_KEY_LENGTH)
|
|
1332
|
+
return blocked;
|
|
1333
|
+
for (const groupId of this.activeGroups) {
|
|
1334
|
+
const group = this.groups.get(groupId);
|
|
1335
|
+
if (group && group.filter && !group.filter(event)) {
|
|
1336
|
+
blocked.add(groupId);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return blocked;
|
|
1340
|
+
}
|
|
754
1341
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
755
1342
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, providedIn: 'root' });
|
|
756
1343
|
}
|
|
@@ -761,6 +1348,235 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
|
|
|
761
1348
|
}]
|
|
762
1349
|
}], ctorParameters: () => [] });
|
|
763
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* Directive for registering keyboard shortcuts directly on elements in templates.
|
|
1353
|
+
*
|
|
1354
|
+
* This directive automatically:
|
|
1355
|
+
* - Registers the shortcut when the directive initializes
|
|
1356
|
+
* - Triggers the host element's click event (default) or executes a custom action
|
|
1357
|
+
* - Unregisters the shortcut when the component is destroyed
|
|
1358
|
+
*
|
|
1359
|
+
* @example
|
|
1360
|
+
* Basic usage with click trigger:
|
|
1361
|
+
* ```html
|
|
1362
|
+
* <button ngxKeys
|
|
1363
|
+
* keys="ctrl,s"
|
|
1364
|
+
* description="Save document"
|
|
1365
|
+
* (click)="save()">
|
|
1366
|
+
* Save
|
|
1367
|
+
* </button>
|
|
1368
|
+
* ```
|
|
1369
|
+
*
|
|
1370
|
+
* @example
|
|
1371
|
+
* With custom action:
|
|
1372
|
+
* ```html
|
|
1373
|
+
* <button ngxKeys
|
|
1374
|
+
* keys="ctrl,s"
|
|
1375
|
+
* description="Save document"
|
|
1376
|
+
* [action]="customSaveAction">
|
|
1377
|
+
* Save
|
|
1378
|
+
* </button>
|
|
1379
|
+
* ```
|
|
1380
|
+
*
|
|
1381
|
+
* @example
|
|
1382
|
+
* Multi-step shortcuts:
|
|
1383
|
+
* ```html
|
|
1384
|
+
* <button ngxKeys
|
|
1385
|
+
* [steps]="[['ctrl', 'k'], ['ctrl', 's']]"
|
|
1386
|
+
* description="Format document"
|
|
1387
|
+
* (click)="format()">
|
|
1388
|
+
* Format
|
|
1389
|
+
* </button>
|
|
1390
|
+
* ```
|
|
1391
|
+
*
|
|
1392
|
+
* @example
|
|
1393
|
+
* Mac-specific keys:
|
|
1394
|
+
* ```html
|
|
1395
|
+
* <button ngxKeys
|
|
1396
|
+
* keys="ctrl,s"
|
|
1397
|
+
* macKeys="cmd,s"
|
|
1398
|
+
* description="Save document"
|
|
1399
|
+
* (click)="save()">
|
|
1400
|
+
* Save
|
|
1401
|
+
* </button>
|
|
1402
|
+
* ```
|
|
1403
|
+
*
|
|
1404
|
+
* @example
|
|
1405
|
+
* On non-interactive elements:
|
|
1406
|
+
* ```html
|
|
1407
|
+
* <div ngxKeys
|
|
1408
|
+
* keys="?"
|
|
1409
|
+
* description="Show help"
|
|
1410
|
+
* [action]="showHelp">
|
|
1411
|
+
* Help content
|
|
1412
|
+
* </div>
|
|
1413
|
+
* ```
|
|
1414
|
+
*/
|
|
1415
|
+
class KeyboardShortcutDirective {
|
|
1416
|
+
keyboardShortcuts = inject(KeyboardShortcuts);
|
|
1417
|
+
elementRef = inject((ElementRef));
|
|
1418
|
+
/**
|
|
1419
|
+
* Comma-separated string of keys for the shortcut (e.g., "ctrl,s" or "alt,shift,k")
|
|
1420
|
+
* Use either `keys` for single-step shortcuts or `steps` for multi-step shortcuts.
|
|
1421
|
+
*/
|
|
1422
|
+
keys;
|
|
1423
|
+
/**
|
|
1424
|
+
* Comma-separated string of keys for Mac users (e.g., "cmd,s")
|
|
1425
|
+
* If not provided, `keys` will be used for all platforms.
|
|
1426
|
+
*/
|
|
1427
|
+
macKeys;
|
|
1428
|
+
/**
|
|
1429
|
+
* Multi-step shortcut as an array of key arrays.
|
|
1430
|
+
* Each inner array represents one step in the sequence.
|
|
1431
|
+
* Example: [['ctrl', 'k'], ['ctrl', 's']] for Ctrl+K followed by Ctrl+S
|
|
1432
|
+
*/
|
|
1433
|
+
steps;
|
|
1434
|
+
/**
|
|
1435
|
+
* Multi-step shortcut for Mac users.
|
|
1436
|
+
* Example: [['cmd', 'k'], ['cmd', 's']]
|
|
1437
|
+
*/
|
|
1438
|
+
macSteps;
|
|
1439
|
+
/**
|
|
1440
|
+
* Description of what the shortcut does (displayed in help/documentation)
|
|
1441
|
+
*/
|
|
1442
|
+
description;
|
|
1443
|
+
/**
|
|
1444
|
+
* Optional custom action to execute when the shortcut is triggered.
|
|
1445
|
+
* If not provided, the directive will trigger a click event on the host element.
|
|
1446
|
+
*/
|
|
1447
|
+
action;
|
|
1448
|
+
/**
|
|
1449
|
+
* Optional custom ID for the shortcut. If not provided, a unique ID will be generated.
|
|
1450
|
+
* Useful for programmatically referencing the shortcut or for debugging.
|
|
1451
|
+
*/
|
|
1452
|
+
shortcutId;
|
|
1453
|
+
/**
|
|
1454
|
+
* Event emitted when the keyboard shortcut is triggered.
|
|
1455
|
+
* This fires in addition to the action or click trigger.
|
|
1456
|
+
*/
|
|
1457
|
+
triggered = output();
|
|
1458
|
+
/**
|
|
1459
|
+
* Adds a data attribute to the host element for styling or testing purposes
|
|
1460
|
+
*/
|
|
1461
|
+
get dataAttribute() {
|
|
1462
|
+
return this.generatedId;
|
|
1463
|
+
}
|
|
1464
|
+
generatedId = '';
|
|
1465
|
+
isRegistered = false;
|
|
1466
|
+
ngOnInit() {
|
|
1467
|
+
// Generate unique ID if not provided
|
|
1468
|
+
this.generatedId = this.shortcutId || this.generateUniqueId();
|
|
1469
|
+
// Validate inputs
|
|
1470
|
+
this.validateInputs();
|
|
1471
|
+
// Parse keys from comma-separated strings
|
|
1472
|
+
const parsedKeys = this.keys ? this.parseKeys(this.keys) : undefined;
|
|
1473
|
+
const parsedMacKeys = this.macKeys ? this.parseKeys(this.macKeys) : undefined;
|
|
1474
|
+
// Define the action: custom action or default click behavior
|
|
1475
|
+
const shortcutAction = () => {
|
|
1476
|
+
if (this.action) {
|
|
1477
|
+
this.action();
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
// Trigger click on the host element
|
|
1481
|
+
this.elementRef.nativeElement.click();
|
|
1482
|
+
}
|
|
1483
|
+
// Emit the triggered event
|
|
1484
|
+
this.triggered.emit();
|
|
1485
|
+
};
|
|
1486
|
+
// Register the shortcut
|
|
1487
|
+
try {
|
|
1488
|
+
this.keyboardShortcuts.register({
|
|
1489
|
+
id: this.generatedId,
|
|
1490
|
+
keys: parsedKeys,
|
|
1491
|
+
macKeys: parsedMacKeys,
|
|
1492
|
+
steps: this.steps,
|
|
1493
|
+
macSteps: this.macSteps,
|
|
1494
|
+
action: shortcutAction,
|
|
1495
|
+
description: this.description,
|
|
1496
|
+
});
|
|
1497
|
+
this.isRegistered = true;
|
|
1498
|
+
}
|
|
1499
|
+
catch (error) {
|
|
1500
|
+
console.error(`[ngxKeys] Failed to register shortcut:`, error);
|
|
1501
|
+
throw error;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
ngOnDestroy() {
|
|
1505
|
+
// Automatically unregister the shortcut when the directive is destroyed
|
|
1506
|
+
if (this.isRegistered) {
|
|
1507
|
+
try {
|
|
1508
|
+
this.keyboardShortcuts.unregister(this.generatedId);
|
|
1509
|
+
}
|
|
1510
|
+
catch (error) {
|
|
1511
|
+
// Silently handle unregister errors (shortcut might have been manually removed)
|
|
1512
|
+
console.warn(`[ngxKeys] Failed to unregister shortcut ${this.generatedId}:`, error);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Parse comma-separated key string into an array
|
|
1518
|
+
* Example: "ctrl,s" -> ["ctrl", "s"]
|
|
1519
|
+
*/
|
|
1520
|
+
parseKeys(keysString) {
|
|
1521
|
+
return keysString
|
|
1522
|
+
.split(',')
|
|
1523
|
+
.map(key => key.trim())
|
|
1524
|
+
.filter(key => key.length > 0);
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Generate a unique ID for the shortcut based on the element and keys
|
|
1528
|
+
*/
|
|
1529
|
+
generateUniqueId() {
|
|
1530
|
+
const timestamp = Date.now();
|
|
1531
|
+
const random = Math.random().toString(36).substring(2, 9);
|
|
1532
|
+
const keysStr = this.keys || this.steps?.flat().join('-') || 'unknown';
|
|
1533
|
+
return `ngx-shortcut-${keysStr}-${timestamp}-${random}`;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Validate that required inputs are provided correctly
|
|
1537
|
+
*/
|
|
1538
|
+
validateInputs() {
|
|
1539
|
+
const hasSingleStep = this.keys || this.macKeys;
|
|
1540
|
+
const hasMultiStep = this.steps || this.macSteps;
|
|
1541
|
+
if (!hasSingleStep && !hasMultiStep) {
|
|
1542
|
+
throw new Error(`[ngxKeys] Must provide either 'keys'/'macKeys' for single-step shortcuts or 'steps'/'macSteps' for multi-step shortcuts.`);
|
|
1543
|
+
}
|
|
1544
|
+
if (hasSingleStep && hasMultiStep) {
|
|
1545
|
+
throw new Error(`[ngxKeys] Cannot use both single-step ('keys'/'macKeys') and multi-step ('steps'/'macSteps') inputs simultaneously. Choose one approach.`);
|
|
1546
|
+
}
|
|
1547
|
+
if (!this.description) {
|
|
1548
|
+
throw new Error(`[ngxKeys] 'description' input is required.`);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcutDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1552
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.2.4", type: KeyboardShortcutDirective, isStandalone: true, selector: "[ngxKeys]", inputs: { keys: "keys", macKeys: "macKeys", steps: "steps", macSteps: "macSteps", description: "description", action: "action", shortcutId: "shortcutId" }, outputs: { triggered: "triggered" }, host: { properties: { "attr.data-keyboard-shortcut": "this.dataAttribute" } }, ngImport: i0 });
|
|
1553
|
+
}
|
|
1554
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcutDirective, decorators: [{
|
|
1555
|
+
type: Directive,
|
|
1556
|
+
args: [{
|
|
1557
|
+
selector: '[ngxKeys]',
|
|
1558
|
+
standalone: true,
|
|
1559
|
+
}]
|
|
1560
|
+
}], propDecorators: { keys: [{
|
|
1561
|
+
type: Input
|
|
1562
|
+
}], macKeys: [{
|
|
1563
|
+
type: Input
|
|
1564
|
+
}], steps: [{
|
|
1565
|
+
type: Input
|
|
1566
|
+
}], macSteps: [{
|
|
1567
|
+
type: Input
|
|
1568
|
+
}], description: [{
|
|
1569
|
+
type: Input,
|
|
1570
|
+
args: [{ required: true }]
|
|
1571
|
+
}], action: [{
|
|
1572
|
+
type: Input
|
|
1573
|
+
}], shortcutId: [{
|
|
1574
|
+
type: Input
|
|
1575
|
+
}], dataAttribute: [{
|
|
1576
|
+
type: HostBinding,
|
|
1577
|
+
args: ['attr.data-keyboard-shortcut']
|
|
1578
|
+
}] } });
|
|
1579
|
+
|
|
764
1580
|
/*
|
|
765
1581
|
* Public API Surface of ngx-keys
|
|
766
1582
|
*/
|
|
@@ -769,5 +1585,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
|
|
|
769
1585
|
* Generated bundle index. Do not edit.
|
|
770
1586
|
*/
|
|
771
1587
|
|
|
772
|
-
export { KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors };
|
|
1588
|
+
export { KEYBOARD_SHORTCUTS_CONFIG, KeyboardShortcutDirective, KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors, provideKeyboardShortcutsConfig };
|
|
773
1589
|
//# sourceMappingURL=ngx-keys.mjs.map
|