@ngrithms/hotkeys 0.1.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 +390 -0
- package/cheatsheet/package.json +4 -0
- package/fesm2022/ngrithms-hotkeys-cheatsheet.mjs +317 -0
- package/fesm2022/ngrithms-hotkeys-cheatsheet.mjs.map +1 -0
- package/fesm2022/ngrithms-hotkeys.mjs +781 -0
- package/fesm2022/ngrithms-hotkeys.mjs.map +1 -0
- package/package.json +53 -0
- package/types/ngrithms-hotkeys-cheatsheet.d.ts +66 -0
- package/types/ngrithms-hotkeys.d.ts +270 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, makeEnvironmentProviders, inject, PLATFORM_ID, signal, computed, DestroyRef, Injectable, input, Directive, EventEmitter, Output } from '@angular/core';
|
|
3
|
+
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
|
4
|
+
import { Subject } from 'rxjs';
|
|
5
|
+
|
|
6
|
+
/** Resolved hotkey configuration. Always returns a fully-defaulted object. */
|
|
7
|
+
const HOTKEY_CONFIG = new InjectionToken('HOTKEY_CONFIG');
|
|
8
|
+
const DEFAULT_HOTKEY_CONFIG = {
|
|
9
|
+
cheatsheetHotkey: '?',
|
|
10
|
+
allowInInputs: false,
|
|
11
|
+
sequenceTimeoutMs: 1000,
|
|
12
|
+
preventDefault: true,
|
|
13
|
+
allowDuplicateBindings: false,
|
|
14
|
+
allowReservedShortcuts: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register `@ngrithms/hotkeys` for the application.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* // app.config.ts
|
|
22
|
+
* export const appConfig: ApplicationConfig = {
|
|
23
|
+
* providers: [provideHotkeys({ sequenceTimeoutMs: 1000 })],
|
|
24
|
+
* };
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
function provideHotkeys(config = {}) {
|
|
28
|
+
return makeEnvironmentProviders([
|
|
29
|
+
{
|
|
30
|
+
provide: HOTKEY_CONFIG,
|
|
31
|
+
useValue: { ...DEFAULT_HOTKEY_CONFIG, ...config },
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Internal helpers for parsing combo strings and matching `KeyboardEvent`s.
|
|
38
|
+
*
|
|
39
|
+
* Combo grammar:
|
|
40
|
+
* <step> ::= <modifier>+ ... + <key>
|
|
41
|
+
* <key> ::= a printable character or special name (escape, enter, arrowup, ...)
|
|
42
|
+
* modifiers: mod | meta | ctrl | alt | shift
|
|
43
|
+
* sequences: <step> ' ' <step> ' ' ...
|
|
44
|
+
*
|
|
45
|
+
* 'mod+s' → single step, mod (= meta on Mac, ctrl elsewhere) + s
|
|
46
|
+
* 'shift+/' → single step, shift + /
|
|
47
|
+
* 'g i' → two-step sequence: press g then i within sequenceTimeoutMs
|
|
48
|
+
* 'mod+shift+k' → mod + shift + k
|
|
49
|
+
*
|
|
50
|
+
* Key matching uses `KeyboardEvent.key` after lowercasing. This is the simplest
|
|
51
|
+
* approach and matches what users type on a US layout. Non-US layouts may need
|
|
52
|
+
* to bind by physical position — that's a future enhancement; for now use
|
|
53
|
+
* letter-based bindings (`mod+k`) which work consistently across layouts since
|
|
54
|
+
* `key` reports the produced letter.
|
|
55
|
+
*/
|
|
56
|
+
const SPECIAL_KEY_ALIASES = {
|
|
57
|
+
esc: 'escape',
|
|
58
|
+
return: 'enter',
|
|
59
|
+
del: 'delete',
|
|
60
|
+
ins: 'insert',
|
|
61
|
+
space: ' ',
|
|
62
|
+
spacebar: ' ',
|
|
63
|
+
up: 'arrowup',
|
|
64
|
+
down: 'arrowdown',
|
|
65
|
+
left: 'arrowleft',
|
|
66
|
+
right: 'arrowright',
|
|
67
|
+
plus: '+',
|
|
68
|
+
};
|
|
69
|
+
let _isMac = null;
|
|
70
|
+
function isMacPlatform() {
|
|
71
|
+
if (_isMac !== null)
|
|
72
|
+
return _isMac;
|
|
73
|
+
if (typeof navigator === 'undefined')
|
|
74
|
+
return (_isMac = false);
|
|
75
|
+
// `navigator.platform` is deprecated but still the most reliable signal
|
|
76
|
+
// for cmd-vs-ctrl resolution; userAgentData.platform is the modern alt.
|
|
77
|
+
const platform = navigator.userAgentData?.platform ??
|
|
78
|
+
navigator.platform ??
|
|
79
|
+
'';
|
|
80
|
+
return (_isMac = /mac|iphone|ipad|ipod/i.test(platform));
|
|
81
|
+
}
|
|
82
|
+
/** Reset platform cache. Test-only. */
|
|
83
|
+
function _resetPlatformCacheForTests() {
|
|
84
|
+
_isMac = null;
|
|
85
|
+
}
|
|
86
|
+
/** Parse a combo string into one or more sequence steps. */
|
|
87
|
+
function parseHotkey(spec) {
|
|
88
|
+
const steps = spec
|
|
89
|
+
.trim()
|
|
90
|
+
.split(/\s+/)
|
|
91
|
+
.filter((s) => s.length > 0);
|
|
92
|
+
if (steps.length === 0) {
|
|
93
|
+
throw new Error(`[@ngrithms/hotkeys] empty combo string`);
|
|
94
|
+
}
|
|
95
|
+
return steps.map(parseStep);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse a single combo string or an array of combo strings into an array of
|
|
99
|
+
* step sequences (one entry per combo). The matcher tries each in order — a
|
|
100
|
+
* binding fires if any of its combos match.
|
|
101
|
+
*/
|
|
102
|
+
function parseHotkeyMulti(keys) {
|
|
103
|
+
const list = typeof keys === 'string' ? [keys] : [...keys];
|
|
104
|
+
if (list.length === 0) {
|
|
105
|
+
throw new Error(`[@ngrithms/hotkeys] keys array cannot be empty`);
|
|
106
|
+
}
|
|
107
|
+
return list.map(parseHotkey);
|
|
108
|
+
}
|
|
109
|
+
function parseStep(step) {
|
|
110
|
+
const lower = step.toLowerCase();
|
|
111
|
+
const result = {
|
|
112
|
+
key: '',
|
|
113
|
+
mod: false,
|
|
114
|
+
meta: false,
|
|
115
|
+
ctrl: false,
|
|
116
|
+
alt: false,
|
|
117
|
+
shift: false,
|
|
118
|
+
};
|
|
119
|
+
// Split into modifier-part and key-part. The key is always the segment after
|
|
120
|
+
// the LAST '+', except when the spec ends in '++' (literal '+' key) or is just '+'.
|
|
121
|
+
let keyPart;
|
|
122
|
+
let modifierPart;
|
|
123
|
+
if (lower === '+') {
|
|
124
|
+
keyPart = '+';
|
|
125
|
+
modifierPart = '';
|
|
126
|
+
}
|
|
127
|
+
else if (lower.endsWith('++')) {
|
|
128
|
+
keyPart = '+';
|
|
129
|
+
modifierPart = lower.slice(0, -2);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const idx = lower.lastIndexOf('+');
|
|
133
|
+
if (idx === -1) {
|
|
134
|
+
keyPart = lower;
|
|
135
|
+
modifierPart = '';
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
keyPart = lower.slice(idx + 1);
|
|
139
|
+
modifierPart = lower.slice(0, idx);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (keyPart === '') {
|
|
143
|
+
throw new Error(`[@ngrithms/hotkeys] no key specified in "${step}"`);
|
|
144
|
+
}
|
|
145
|
+
if (modifierPart !== '') {
|
|
146
|
+
for (const p of modifierPart.split('+')) {
|
|
147
|
+
if (p === '')
|
|
148
|
+
continue;
|
|
149
|
+
switch (p) {
|
|
150
|
+
case 'mod':
|
|
151
|
+
result.mod = true;
|
|
152
|
+
break;
|
|
153
|
+
case 'meta':
|
|
154
|
+
case 'cmd':
|
|
155
|
+
case 'command':
|
|
156
|
+
case 'super':
|
|
157
|
+
case 'win':
|
|
158
|
+
result.meta = true;
|
|
159
|
+
break;
|
|
160
|
+
case 'ctrl':
|
|
161
|
+
case 'control':
|
|
162
|
+
result.ctrl = true;
|
|
163
|
+
break;
|
|
164
|
+
case 'alt':
|
|
165
|
+
case 'option':
|
|
166
|
+
case 'opt':
|
|
167
|
+
result.alt = true;
|
|
168
|
+
break;
|
|
169
|
+
case 'shift':
|
|
170
|
+
result.shift = true;
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
throw new Error(`[@ngrithms/hotkeys] unknown modifier "${p}" in "${step}"`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
result.key = SPECIAL_KEY_ALIASES[keyPart] ?? keyPart;
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
/** Compare a parsed step against an actual KeyboardEvent. */
|
|
181
|
+
function stepMatchesEvent(step, e, mac) {
|
|
182
|
+
const eventKey = e.key.toLowerCase();
|
|
183
|
+
if (eventKey !== step.key)
|
|
184
|
+
return false;
|
|
185
|
+
// `mod` resolves to meta on Mac, ctrl elsewhere.
|
|
186
|
+
const wantMeta = step.meta || (step.mod && mac);
|
|
187
|
+
const wantCtrl = step.ctrl || (step.mod && !mac);
|
|
188
|
+
if (wantMeta !== e.metaKey)
|
|
189
|
+
return false;
|
|
190
|
+
if (wantCtrl !== e.ctrlKey)
|
|
191
|
+
return false;
|
|
192
|
+
if (step.alt !== e.altKey)
|
|
193
|
+
return false;
|
|
194
|
+
if (step.shift !== e.shiftKey)
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
/** Render a combo step into a display string (used by the cheatsheet). */
|
|
199
|
+
function comboToDisplay(combo, mac = isMacPlatform()) {
|
|
200
|
+
const steps = parseHotkey(combo);
|
|
201
|
+
return steps
|
|
202
|
+
.map((s) => {
|
|
203
|
+
const parts = [];
|
|
204
|
+
if (s.mod || s.meta)
|
|
205
|
+
parts.push(mac ? '⌘' : 'Ctrl');
|
|
206
|
+
if (s.ctrl && !s.mod)
|
|
207
|
+
parts.push('Ctrl');
|
|
208
|
+
if (s.alt)
|
|
209
|
+
parts.push(mac ? '⌥' : 'Alt');
|
|
210
|
+
if (s.shift)
|
|
211
|
+
parts.push(mac ? '⇧' : 'Shift');
|
|
212
|
+
parts.push(prettifyKey(s.key));
|
|
213
|
+
return parts.join(mac ? '' : '+');
|
|
214
|
+
})
|
|
215
|
+
.join(' then ');
|
|
216
|
+
}
|
|
217
|
+
function prettifyKey(key) {
|
|
218
|
+
switch (key) {
|
|
219
|
+
case ' ':
|
|
220
|
+
return 'Space';
|
|
221
|
+
case 'arrowup':
|
|
222
|
+
return '↑';
|
|
223
|
+
case 'arrowdown':
|
|
224
|
+
return '↓';
|
|
225
|
+
case 'arrowleft':
|
|
226
|
+
return '←';
|
|
227
|
+
case 'arrowright':
|
|
228
|
+
return '→';
|
|
229
|
+
case 'escape':
|
|
230
|
+
return 'Esc';
|
|
231
|
+
case 'enter':
|
|
232
|
+
return '⏎';
|
|
233
|
+
case 'backspace':
|
|
234
|
+
return '⌫';
|
|
235
|
+
case 'delete':
|
|
236
|
+
return 'Del';
|
|
237
|
+
case 'tab':
|
|
238
|
+
return 'Tab';
|
|
239
|
+
default:
|
|
240
|
+
return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Combos reserved by browsers / operating systems that never reach the page,
|
|
245
|
+
* regardless of `preventDefault()`. Used by the dev-mode warning so users learn
|
|
246
|
+
* about the limitation at registration time rather than discovering it in
|
|
247
|
+
* production. Source: empirical testing across Chrome / Safari / Firefox on
|
|
248
|
+
* macOS, Windows, and Linux as of 2026.
|
|
249
|
+
*
|
|
250
|
+
* Returns `null` when the combo is fine, or a short human description when it
|
|
251
|
+
* is reserved (used in the warning text).
|
|
252
|
+
*/
|
|
253
|
+
function reservedComboReason(combo) {
|
|
254
|
+
// Multi-step bindings have at most one reserved step at the end; check each.
|
|
255
|
+
// (Most reserved combos are single-key with a modifier.)
|
|
256
|
+
const k = combo.key;
|
|
257
|
+
const hasMod = combo.mod || combo.meta || combo.ctrl;
|
|
258
|
+
if (hasMod && !combo.shift && !combo.alt) {
|
|
259
|
+
// Single mod (Cmd or Ctrl) + key — the largest reserved set.
|
|
260
|
+
switch (k) {
|
|
261
|
+
case 'n':
|
|
262
|
+
return 'new window';
|
|
263
|
+
case 't':
|
|
264
|
+
return 'new tab';
|
|
265
|
+
case 'w':
|
|
266
|
+
return 'close tab';
|
|
267
|
+
case 'q':
|
|
268
|
+
return 'quit application';
|
|
269
|
+
case 'r':
|
|
270
|
+
return 'reload page';
|
|
271
|
+
case 'l':
|
|
272
|
+
return 'focus address bar';
|
|
273
|
+
case 'tab':
|
|
274
|
+
return 'switch tabs';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (hasMod && combo.shift && !combo.alt) {
|
|
278
|
+
switch (k) {
|
|
279
|
+
case 'n':
|
|
280
|
+
return 'open incognito / private window';
|
|
281
|
+
case 't':
|
|
282
|
+
return 'reopen closed tab';
|
|
283
|
+
case 'w':
|
|
284
|
+
return 'close all tabs';
|
|
285
|
+
case 'tab':
|
|
286
|
+
return 'switch tabs (reverse)';
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (k === 'f11')
|
|
290
|
+
return 'toggle fullscreen';
|
|
291
|
+
if (k === 'f5')
|
|
292
|
+
return 'reload page';
|
|
293
|
+
// Cmd+M minimizes on macOS — only flag if explicit meta (not mod, since mod+m
|
|
294
|
+
// on Linux/Windows is fine).
|
|
295
|
+
if (combo.meta && !combo.ctrl && !combo.shift && !combo.alt && k === 'm') {
|
|
296
|
+
return 'minimize window (macOS)';
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
/** True if focus is in an editable element. */
|
|
301
|
+
function isEditableTarget(target) {
|
|
302
|
+
if (!(target instanceof Element))
|
|
303
|
+
return false;
|
|
304
|
+
const tag = target.tagName;
|
|
305
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
306
|
+
return true;
|
|
307
|
+
const el = target;
|
|
308
|
+
// `isContentEditable` is the canonical browser check, but jsdom does not
|
|
309
|
+
// populate it for detached / freshly-attached elements. Fall back to the
|
|
310
|
+
// attribute so tests and SSR-attached nodes behave the same as real browsers.
|
|
311
|
+
if (el.isContentEditable)
|
|
312
|
+
return true;
|
|
313
|
+
const attr = el.getAttribute('contenteditable');
|
|
314
|
+
return attr !== null && attr !== 'false';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Registry and dispatcher for keyboard shortcuts.
|
|
319
|
+
*
|
|
320
|
+
* Bindings are registered either programmatically via `register()` or
|
|
321
|
+
* declaratively via the `(ngrHotkey)` directive (recommended — the directive
|
|
322
|
+
* auto-unregisters on destroy).
|
|
323
|
+
*
|
|
324
|
+
* A single binding can listen for multiple combos by passing `keys` as an
|
|
325
|
+
* array (e.g. `['mod+s', 'mod+shift+s']`). The handler fires once when any of
|
|
326
|
+
* them matches; the `HotkeyTriggerEvent.combo` reports which one actually fired.
|
|
327
|
+
*
|
|
328
|
+
* Matching is global at the `document` level. When multiple bindings share a
|
|
329
|
+
* combo, scope precedence wins: bindings in the active (top-of-stack) scope
|
|
330
|
+
* fire first; if none match there, `'global'` bindings fire.
|
|
331
|
+
*
|
|
332
|
+
* Sequences (e.g. `'g i'`) require pressing each step within
|
|
333
|
+
* `config.sequenceTimeoutMs`. The buffer resets on any non-modifier key that
|
|
334
|
+
* doesn't extend a known sequence prefix.
|
|
335
|
+
*
|
|
336
|
+
* SSR-safe: on the server platform, `register()` is a no-op and signals stay
|
|
337
|
+
* empty. All DOM listeners are guarded by `isPlatformBrowser`.
|
|
338
|
+
*/
|
|
339
|
+
class HotkeysService {
|
|
340
|
+
config = inject(HOTKEY_CONFIG);
|
|
341
|
+
document = inject(DOCUMENT);
|
|
342
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
343
|
+
mac = this.isBrowser ? isMacPlatform() : false;
|
|
344
|
+
_bindings = signal([], ...(ngDevMode ? [{ debugName: "_bindings" }] : /* istanbul ignore next */ []));
|
|
345
|
+
_scopeStack = signal(['global'], ...(ngDevMode ? [{ debugName: "_scopeStack" }] : /* istanbul ignore next */ []));
|
|
346
|
+
_lastTriggered = signal(null, ...(ngDevMode ? [{ debugName: "_lastTriggered" }] : /* istanbul ignore next */ []));
|
|
347
|
+
trigger$ = new Subject();
|
|
348
|
+
/** All currently-registered bindings (read-only view for cheatsheets). */
|
|
349
|
+
registered = computed(() => this._bindings().map((b) => publicShape(b)), ...(ngDevMode ? [{ debugName: "registered" }] : /* istanbul ignore next */ []));
|
|
350
|
+
/** Top-of-stack scope. Bindings in this scope (or `'global'`) are active. */
|
|
351
|
+
activeScope = computed(() => this._scopeStack().at(-1) ?? 'global', ...(ngDevMode ? [{ debugName: "activeScope" }] : /* istanbul ignore next */ []));
|
|
352
|
+
lastTriggered = this._lastTriggered.asReadonly();
|
|
353
|
+
onTrigger = this.trigger$.asObservable();
|
|
354
|
+
nextId = 1;
|
|
355
|
+
sequenceBuffer = [];
|
|
356
|
+
sequenceTimerId = null;
|
|
357
|
+
keydownListener = null;
|
|
358
|
+
listenerAttached = false;
|
|
359
|
+
constructor() {
|
|
360
|
+
if (!this.isBrowser)
|
|
361
|
+
return;
|
|
362
|
+
inject(DestroyRef).onDestroy(() => this.detachListener());
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Register a binding. Returns an unsubscribe function that removes it.
|
|
366
|
+
* Idiomatic use is via the `(ngrHotkey)` directive, which calls this for you
|
|
367
|
+
* and wires the unsubscribe into the host's `DestroyRef`.
|
|
368
|
+
*/
|
|
369
|
+
register(binding) {
|
|
370
|
+
if (!this.isBrowser)
|
|
371
|
+
return () => undefined;
|
|
372
|
+
const comboStrings = typeof binding.keys === 'string' ? [binding.keys] : [...binding.keys];
|
|
373
|
+
const combos = parseHotkeyMulti(binding.keys);
|
|
374
|
+
const scope = binding.scope ?? 'global';
|
|
375
|
+
const stored = {
|
|
376
|
+
id: this.nextId++,
|
|
377
|
+
keys: binding.keys,
|
|
378
|
+
comboStrings,
|
|
379
|
+
combos,
|
|
380
|
+
scope,
|
|
381
|
+
category: binding.category,
|
|
382
|
+
description: binding.description,
|
|
383
|
+
allowInInputs: binding.allowInInputs,
|
|
384
|
+
preventDefault: binding.preventDefault,
|
|
385
|
+
handler: binding.handler,
|
|
386
|
+
};
|
|
387
|
+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
388
|
+
this.maybeWarnConflict(stored);
|
|
389
|
+
this.maybeWarnReserved(stored);
|
|
390
|
+
}
|
|
391
|
+
this._bindings.update((list) => [...list, stored]);
|
|
392
|
+
this.ensureListener();
|
|
393
|
+
return () => {
|
|
394
|
+
this._bindings.update((list) => list.filter((b) => b.id !== stored.id));
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Push a scope onto the activation stack. Returns a function that pops it.
|
|
399
|
+
* Prefer the `[ngrHotkeyScope]` directive for component-bound scopes — it
|
|
400
|
+
* auto-pops on destroy.
|
|
401
|
+
*/
|
|
402
|
+
pushScope(name) {
|
|
403
|
+
if (!this.isBrowser)
|
|
404
|
+
return () => undefined;
|
|
405
|
+
this._scopeStack.update((s) => [...s, name]);
|
|
406
|
+
return () => {
|
|
407
|
+
this._scopeStack.update((s) => {
|
|
408
|
+
// Remove the last occurrence of `name` so nested pushes of the same
|
|
409
|
+
// scope don't accidentally pop a sibling.
|
|
410
|
+
const idx = s.lastIndexOf(name);
|
|
411
|
+
if (idx === -1)
|
|
412
|
+
return s;
|
|
413
|
+
return [...s.slice(0, idx), ...s.slice(idx + 1)];
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
ensureListener() {
|
|
418
|
+
if (this.listenerAttached || !this.isBrowser)
|
|
419
|
+
return;
|
|
420
|
+
this.keydownListener = (e) => this.handleKeyDown(e);
|
|
421
|
+
this.document.addEventListener('keydown', this.keydownListener);
|
|
422
|
+
this.listenerAttached = true;
|
|
423
|
+
}
|
|
424
|
+
detachListener() {
|
|
425
|
+
if (!this.listenerAttached || this.keydownListener === null)
|
|
426
|
+
return;
|
|
427
|
+
this.document.removeEventListener('keydown', this.keydownListener);
|
|
428
|
+
this.keydownListener = null;
|
|
429
|
+
this.listenerAttached = false;
|
|
430
|
+
this.clearSequenceTimer();
|
|
431
|
+
}
|
|
432
|
+
handleKeyDown(e) {
|
|
433
|
+
// Ignore pure modifier presses (Shift/Ctrl/Alt/Meta by themselves) — they're
|
|
434
|
+
// never the "key" of a combo and would otherwise spam the sequence buffer.
|
|
435
|
+
const key = e.key.toLowerCase();
|
|
436
|
+
if (key === 'shift' || key === 'control' || key === 'alt' || key === 'meta') {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const editable = isEditableTarget(e.target);
|
|
440
|
+
const activeScope = this.activeScope();
|
|
441
|
+
const candidates = this._bindings();
|
|
442
|
+
// Extend the sequence buffer with this keystroke and try to match.
|
|
443
|
+
const newBuffer = [...this.sequenceBuffer, eventToComboStep(e)];
|
|
444
|
+
// First pass: exact match against the new buffer (full sequence completed).
|
|
445
|
+
const fullMatch = this.findBindingMatch(candidates, newBuffer, activeScope, editable);
|
|
446
|
+
if (fullMatch !== null) {
|
|
447
|
+
this.fire(fullMatch, e);
|
|
448
|
+
this.resetSequence();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// Second pass: is any longer binding still in progress (newBuffer is a prefix)?
|
|
452
|
+
if (this.hasPrefixMatch(candidates, newBuffer, activeScope, editable)) {
|
|
453
|
+
this.sequenceBuffer = newBuffer;
|
|
454
|
+
this.scheduleSequenceTimeout();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Otherwise: maybe the *new* keystroke alone starts a fresh sequence.
|
|
458
|
+
this.resetSequence();
|
|
459
|
+
const freshBuffer = [eventToComboStep(e)];
|
|
460
|
+
const freshMatch = this.findBindingMatch(candidates, freshBuffer, activeScope, editable);
|
|
461
|
+
if (freshMatch !== null) {
|
|
462
|
+
this.fire(freshMatch, e);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (this.hasPrefixMatch(candidates, freshBuffer, activeScope, editable)) {
|
|
466
|
+
this.sequenceBuffer = freshBuffer;
|
|
467
|
+
this.scheduleSequenceTimeout();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
findBindingMatch(bindings, buffer, activeScope, editable) {
|
|
471
|
+
// Prefer the active scope over global when both match the same combo.
|
|
472
|
+
const findIn = (scope) => {
|
|
473
|
+
for (const b of bindings) {
|
|
474
|
+
if (b.scope !== scope)
|
|
475
|
+
continue;
|
|
476
|
+
if (!this.respectsInputFilter(b, editable))
|
|
477
|
+
continue;
|
|
478
|
+
const idx = exactComboIndex(b, buffer);
|
|
479
|
+
if (idx !== -1)
|
|
480
|
+
return { binding: b, comboIndex: idx };
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
};
|
|
484
|
+
const inActiveScope = findIn(activeScope);
|
|
485
|
+
if (inActiveScope !== null)
|
|
486
|
+
return inActiveScope;
|
|
487
|
+
if (activeScope !== 'global') {
|
|
488
|
+
const inGlobal = findIn('global');
|
|
489
|
+
if (inGlobal !== null)
|
|
490
|
+
return inGlobal;
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
hasPrefixMatch(bindings, buffer, activeScope, editable) {
|
|
495
|
+
return bindings.some((b) => (b.scope === 'global' || b.scope === activeScope) &&
|
|
496
|
+
this.respectsInputFilter(b, editable) &&
|
|
497
|
+
b.combos.some((c) => c.length > buffer.length && bufferIsPrefixOf(buffer, c)));
|
|
498
|
+
}
|
|
499
|
+
respectsInputFilter(b, editable) {
|
|
500
|
+
if (!editable)
|
|
501
|
+
return true;
|
|
502
|
+
const allow = b.allowInInputs ?? this.config.allowInInputs;
|
|
503
|
+
return allow;
|
|
504
|
+
}
|
|
505
|
+
fire(match, e) {
|
|
506
|
+
const { binding, comboIndex } = match;
|
|
507
|
+
const preventDefault = binding.preventDefault ?? this.config.preventDefault;
|
|
508
|
+
if (preventDefault)
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
const event = {
|
|
511
|
+
combo: binding.comboStrings[comboIndex],
|
|
512
|
+
scope: binding.scope,
|
|
513
|
+
ts: Date.now(),
|
|
514
|
+
};
|
|
515
|
+
this._lastTriggered.set(event);
|
|
516
|
+
this.trigger$.next(event);
|
|
517
|
+
try {
|
|
518
|
+
binding.handler(e);
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
// Don't let a handler error tear down the listener for everything else.
|
|
522
|
+
console.error('[@ngrithms/hotkeys] handler threw', err);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
scheduleSequenceTimeout() {
|
|
526
|
+
this.clearSequenceTimer();
|
|
527
|
+
this.sequenceTimerId = setTimeout(() => {
|
|
528
|
+
this.resetSequence();
|
|
529
|
+
}, this.config.sequenceTimeoutMs);
|
|
530
|
+
}
|
|
531
|
+
clearSequenceTimer() {
|
|
532
|
+
if (this.sequenceTimerId !== null) {
|
|
533
|
+
clearTimeout(this.sequenceTimerId);
|
|
534
|
+
this.sequenceTimerId = null;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
resetSequence() {
|
|
538
|
+
this.sequenceBuffer = [];
|
|
539
|
+
this.clearSequenceTimer();
|
|
540
|
+
}
|
|
541
|
+
maybeWarnConflict(stored) {
|
|
542
|
+
if (this.config.allowDuplicateBindings)
|
|
543
|
+
return;
|
|
544
|
+
for (const existing of this._bindings()) {
|
|
545
|
+
if (existing.scope !== stored.scope)
|
|
546
|
+
continue;
|
|
547
|
+
for (let i = 0; i < stored.combos.length; i++) {
|
|
548
|
+
const newCombo = stored.combos[i];
|
|
549
|
+
for (let j = 0; j < existing.combos.length; j++) {
|
|
550
|
+
if (combosAreEqual(newCombo, existing.combos[j])) {
|
|
551
|
+
const combo = stored.comboStrings[i];
|
|
552
|
+
const existingLabel = existing.description ?? `(no description, id ${existing.id})`;
|
|
553
|
+
const newLabel = stored.description ?? `(no description, id ${stored.id})`;
|
|
554
|
+
console.warn(`[@ngrithms/hotkeys] Two bindings registered for "${combo}" in scope "${stored.scope}". ` +
|
|
555
|
+
`The later registration will be shadowed.\n` +
|
|
556
|
+
` • ${existingLabel}\n` +
|
|
557
|
+
` • ${newLabel} ← new\n` +
|
|
558
|
+
`Set provideHotkeys({ allowDuplicateBindings: true }) to silence.`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
maybeWarnReserved(stored) {
|
|
565
|
+
if (this.config.allowReservedShortcuts)
|
|
566
|
+
return;
|
|
567
|
+
for (let i = 0; i < stored.combos.length; i++) {
|
|
568
|
+
const combo = stored.combos[i];
|
|
569
|
+
// Single-step combos with a modifier are the only realistic OS-reserved ones.
|
|
570
|
+
// Skip sequence shortcuts.
|
|
571
|
+
if (combo.length !== 1)
|
|
572
|
+
continue;
|
|
573
|
+
const reason = reservedComboReason(combo[0]);
|
|
574
|
+
if (reason !== null) {
|
|
575
|
+
console.warn(`[@ngrithms/hotkeys] "${stored.comboStrings[i]}" is reserved by the browser / OS (${reason}) ` +
|
|
576
|
+
`and cannot be intercepted from JavaScript. The binding will not fire. ` +
|
|
577
|
+
`See https://github.com/aboudbadra/ngrithms-hotkeys#reserved-shortcuts`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HotkeysService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
582
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HotkeysService, providedIn: 'root' });
|
|
583
|
+
}
|
|
584
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HotkeysService, decorators: [{
|
|
585
|
+
type: Injectable,
|
|
586
|
+
args: [{ providedIn: 'root' }]
|
|
587
|
+
}], ctorParameters: () => [] });
|
|
588
|
+
function publicShape(b) {
|
|
589
|
+
return {
|
|
590
|
+
keys: b.keys,
|
|
591
|
+
handler: b.handler,
|
|
592
|
+
scope: b.scope,
|
|
593
|
+
category: b.category,
|
|
594
|
+
description: b.description,
|
|
595
|
+
allowInInputs: b.allowInInputs,
|
|
596
|
+
preventDefault: b.preventDefault,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function eventToComboStep(e) {
|
|
600
|
+
return {
|
|
601
|
+
key: e.key.toLowerCase(),
|
|
602
|
+
mod: false,
|
|
603
|
+
meta: e.metaKey,
|
|
604
|
+
ctrl: e.ctrlKey,
|
|
605
|
+
alt: e.altKey,
|
|
606
|
+
shift: e.shiftKey,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function stepEquals(spec, actual) {
|
|
610
|
+
// `actual` always has `mod=false` (events carry explicit meta/ctrl). For a
|
|
611
|
+
// spec written with `mod`, accept either modifier based on platform.
|
|
612
|
+
if (spec.key !== actual.key)
|
|
613
|
+
return false;
|
|
614
|
+
const mac = isMacPlatform();
|
|
615
|
+
const wantMeta = spec.meta || (spec.mod && mac);
|
|
616
|
+
const wantCtrl = spec.ctrl || (spec.mod && !mac);
|
|
617
|
+
if (wantMeta !== actual.meta)
|
|
618
|
+
return false;
|
|
619
|
+
if (wantCtrl !== actual.ctrl)
|
|
620
|
+
return false;
|
|
621
|
+
if (spec.alt !== actual.alt)
|
|
622
|
+
return false;
|
|
623
|
+
if (spec.shift !== actual.shift)
|
|
624
|
+
return false;
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
function exactComboIndex(b, buffer) {
|
|
628
|
+
for (let i = 0; i < b.combos.length; i++) {
|
|
629
|
+
const combo = b.combos[i];
|
|
630
|
+
if (combo.length !== buffer.length)
|
|
631
|
+
continue;
|
|
632
|
+
if (combo.every((s, j) => stepEquals(s, buffer[j])))
|
|
633
|
+
return i;
|
|
634
|
+
}
|
|
635
|
+
return -1;
|
|
636
|
+
}
|
|
637
|
+
function bufferIsPrefixOf(buffer, combo) {
|
|
638
|
+
if (buffer.length > combo.length)
|
|
639
|
+
return false;
|
|
640
|
+
return buffer.every((s, i) => stepEquals(s, combo[i]));
|
|
641
|
+
}
|
|
642
|
+
function combosAreEqual(a, b) {
|
|
643
|
+
// Compare parsed combos directly (modifier-by-modifier, key, sequence length).
|
|
644
|
+
// We do NOT collapse `mod` into ctrl/meta here — `mod+s` and `meta+s` may or
|
|
645
|
+
// may not collide depending on platform, but they're written differently so
|
|
646
|
+
// we treat them as distinct registrations. The runtime matcher handles the
|
|
647
|
+
// platform resolution; the warning is about literal source-level collisions.
|
|
648
|
+
if (a.length !== b.length)
|
|
649
|
+
return false;
|
|
650
|
+
return a.every((sa, i) => {
|
|
651
|
+
const sb = b[i];
|
|
652
|
+
return (sa.key === sb.key &&
|
|
653
|
+
sa.mod === sb.mod &&
|
|
654
|
+
sa.meta === sb.meta &&
|
|
655
|
+
sa.ctrl === sb.ctrl &&
|
|
656
|
+
sa.alt === sb.alt &&
|
|
657
|
+
sa.shift === sb.shift);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Activates a hotkey scope while this directive's host element is mounted.
|
|
663
|
+
*
|
|
664
|
+
* Use this around a modal, dialog, drawer, or any region whose shortcuts should
|
|
665
|
+
* shadow global bindings while it is open. The scope is auto-popped on destroy
|
|
666
|
+
* — no manual lifecycle wiring needed.
|
|
667
|
+
*
|
|
668
|
+
* Child `(ngrHotkey)` bindings inherit this scope automatically via DI.
|
|
669
|
+
*
|
|
670
|
+
* ```html
|
|
671
|
+
* <dialog *ngIf="open()" ngrHotkeyScope="modal">
|
|
672
|
+
* <button (ngrHotkey)="close()" hotkey="escape">Close</button>
|
|
673
|
+
* </dialog>
|
|
674
|
+
* ```
|
|
675
|
+
*
|
|
676
|
+
* While the dialog is mounted, `escape` fires `close()` instead of any global
|
|
677
|
+
* `escape` binding. When the dialog unmounts, global bindings take over again.
|
|
678
|
+
*/
|
|
679
|
+
class HotkeyScopeDirective {
|
|
680
|
+
hotkeys = inject(HotkeysService);
|
|
681
|
+
/** Scope name to push while this element is mounted. */
|
|
682
|
+
ngrHotkeyScope = input.required(...(ngDevMode ? [{ debugName: "ngrHotkeyScope" }] : /* istanbul ignore next */ []));
|
|
683
|
+
release = null;
|
|
684
|
+
ngOnInit() {
|
|
685
|
+
this.release = this.hotkeys.pushScope(this.ngrHotkeyScope());
|
|
686
|
+
}
|
|
687
|
+
ngOnDestroy() {
|
|
688
|
+
this.release?.();
|
|
689
|
+
this.release = null;
|
|
690
|
+
}
|
|
691
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HotkeyScopeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
692
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.13", type: HotkeyScopeDirective, isStandalone: true, selector: "[ngrHotkeyScope]", inputs: { ngrHotkeyScope: { classPropertyName: "ngrHotkeyScope", publicName: "ngrHotkeyScope", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
|
|
693
|
+
}
|
|
694
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HotkeyScopeDirective, decorators: [{
|
|
695
|
+
type: Directive,
|
|
696
|
+
args: [{
|
|
697
|
+
selector: '[ngrHotkeyScope]',
|
|
698
|
+
standalone: true,
|
|
699
|
+
}]
|
|
700
|
+
}], propDecorators: { ngrHotkeyScope: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngrHotkeyScope", required: true }] }] } });
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Declarative shortcut binding. Registers on init, unregisters on destroy.
|
|
704
|
+
*
|
|
705
|
+
* ```html
|
|
706
|
+
* <button (ngrHotkey)="save()" hotkey="mod+s" hotkeyDescription="Save">Save</button>
|
|
707
|
+
* <div (ngrHotkey)="gotoInbox()" hotkey="g i" hotkeyDescription="Go to inbox"></div>
|
|
708
|
+
* <!-- multi-combo: one handler, two combos -->
|
|
709
|
+
* <button (ngrHotkey)="save()" [hotkey]="['mod+s', 'mod+shift+s']" hotkeyDescription="Save">Save</button>
|
|
710
|
+
* ```
|
|
711
|
+
*
|
|
712
|
+
* The host element does not need to be focused — matching is `document`-level.
|
|
713
|
+
* Use `[ngrHotkeyScope]` on an ancestor to scope the binding to a UI region.
|
|
714
|
+
*/
|
|
715
|
+
class HotkeyDirective {
|
|
716
|
+
hotkeys = inject(HotkeysService);
|
|
717
|
+
// Inherit scope from the nearest ancestor `[ngrHotkeyScope]`, if any. This walks
|
|
718
|
+
// the element-injector tree, so any ancestor element with the scope directive
|
|
719
|
+
// wins. Read at ngOnInit time so the parent input is guaranteed set.
|
|
720
|
+
parentScope = inject(HotkeyScopeDirective, { optional: true });
|
|
721
|
+
/**
|
|
722
|
+
* Combo string (`'mod+s'`, `'g i'`, `'escape'`, ...) or an array of strings
|
|
723
|
+
* mapping multiple combos to the same handler. Required.
|
|
724
|
+
*/
|
|
725
|
+
hotkey = input.required(...(ngDevMode ? [{ debugName: "hotkey" }] : /* istanbul ignore next */ []));
|
|
726
|
+
/** Logical scope. Defaults to the surrounding `ngrHotkeyScope`, or `'global'`. */
|
|
727
|
+
hotkeyScope = input(undefined, ...(ngDevMode ? [{ debugName: "hotkeyScope" }] : /* istanbul ignore next */ []));
|
|
728
|
+
/**
|
|
729
|
+
* Optional sub-grouping label for the cheatsheet. Bindings sharing a category
|
|
730
|
+
* appear under a single subheader within their scope.
|
|
731
|
+
*/
|
|
732
|
+
hotkeyCategory = input(undefined, ...(ngDevMode ? [{ debugName: "hotkeyCategory" }] : /* istanbul ignore next */ []));
|
|
733
|
+
/** Description shown in the cheatsheet. Bindings without a description are hidden. */
|
|
734
|
+
hotkeyDescription = input(undefined, ...(ngDevMode ? [{ debugName: "hotkeyDescription" }] : /* istanbul ignore next */ []));
|
|
735
|
+
/** Allow firing when focus is inside an input/textarea/contenteditable. */
|
|
736
|
+
hotkeyAllowInInputs = input(undefined, ...(ngDevMode ? [{ debugName: "hotkeyAllowInInputs" }] : /* istanbul ignore next */ []));
|
|
737
|
+
/** Override `preventDefault` for this binding. */
|
|
738
|
+
hotkeyPreventDefault = input(undefined, ...(ngDevMode ? [{ debugName: "hotkeyPreventDefault" }] : /* istanbul ignore next */ []));
|
|
739
|
+
/** Fired when the combo is matched. The DOM `KeyboardEvent` is the payload. */
|
|
740
|
+
ngrHotkey = new EventEmitter();
|
|
741
|
+
dispose = null;
|
|
742
|
+
ngOnInit() {
|
|
743
|
+
const scope = this.hotkeyScope() ?? this.parentScope?.ngrHotkeyScope() ?? 'global';
|
|
744
|
+
this.dispose = this.hotkeys.register({
|
|
745
|
+
keys: this.hotkey(),
|
|
746
|
+
scope,
|
|
747
|
+
category: this.hotkeyCategory(),
|
|
748
|
+
description: this.hotkeyDescription(),
|
|
749
|
+
allowInInputs: this.hotkeyAllowInInputs(),
|
|
750
|
+
preventDefault: this.hotkeyPreventDefault(),
|
|
751
|
+
handler: (e) => this.ngrHotkey.emit(e),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
ngOnDestroy() {
|
|
755
|
+
this.dispose?.();
|
|
756
|
+
this.dispose = null;
|
|
757
|
+
}
|
|
758
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HotkeyDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
759
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.13", type: HotkeyDirective, isStandalone: true, selector: "[ngrHotkey]", inputs: { hotkey: { classPropertyName: "hotkey", publicName: "hotkey", isSignal: true, isRequired: true, transformFunction: null }, hotkeyScope: { classPropertyName: "hotkeyScope", publicName: "hotkeyScope", isSignal: true, isRequired: false, transformFunction: null }, hotkeyCategory: { classPropertyName: "hotkeyCategory", publicName: "hotkeyCategory", isSignal: true, isRequired: false, transformFunction: null }, hotkeyDescription: { classPropertyName: "hotkeyDescription", publicName: "hotkeyDescription", isSignal: true, isRequired: false, transformFunction: null }, hotkeyAllowInInputs: { classPropertyName: "hotkeyAllowInInputs", publicName: "hotkeyAllowInInputs", isSignal: true, isRequired: false, transformFunction: null }, hotkeyPreventDefault: { classPropertyName: "hotkeyPreventDefault", publicName: "hotkeyPreventDefault", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { ngrHotkey: "ngrHotkey" }, ngImport: i0 });
|
|
760
|
+
}
|
|
761
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HotkeyDirective, decorators: [{
|
|
762
|
+
type: Directive,
|
|
763
|
+
args: [{
|
|
764
|
+
selector: '[ngrHotkey]',
|
|
765
|
+
standalone: true,
|
|
766
|
+
}]
|
|
767
|
+
}], propDecorators: { hotkey: [{ type: i0.Input, args: [{ isSignal: true, alias: "hotkey", required: true }] }], hotkeyScope: [{ type: i0.Input, args: [{ isSignal: true, alias: "hotkeyScope", required: false }] }], hotkeyCategory: [{ type: i0.Input, args: [{ isSignal: true, alias: "hotkeyCategory", required: false }] }], hotkeyDescription: [{ type: i0.Input, args: [{ isSignal: true, alias: "hotkeyDescription", required: false }] }], hotkeyAllowInInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "hotkeyAllowInInputs", required: false }] }], hotkeyPreventDefault: [{ type: i0.Input, args: [{ isSignal: true, alias: "hotkeyPreventDefault", required: false }] }], ngrHotkey: [{
|
|
768
|
+
type: Output
|
|
769
|
+
}] } });
|
|
770
|
+
|
|
771
|
+
/*
|
|
772
|
+
* Public API surface of @ngrithms/hotkeys
|
|
773
|
+
*/
|
|
774
|
+
// Setup
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Generated bundle index. Do not edit.
|
|
778
|
+
*/
|
|
779
|
+
|
|
780
|
+
export { DEFAULT_HOTKEY_CONFIG, HOTKEY_CONFIG, HotkeyDirective, HotkeyScopeDirective, HotkeysService, comboToDisplay, isMacPlatform, provideHotkeys };
|
|
781
|
+
//# sourceMappingURL=ngrithms-hotkeys.mjs.map
|