Skip to content

Commit 950ec1a

Browse files
authored
Merge pull request #97 from lucasnbsb/pause-hotkeys
feat: adds pausing and resuming to the hotkey service
2 parents 10ce51b + f11c600 commit 950ec1a

File tree

7 files changed

+179
-6
lines changed

7 files changed

+179
-6
lines changed

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,19 @@ this.hotkeys.removeShortcuts('meta.a');
210210
this.hotkeys.removeShortcuts(['meta.1', 'meta.2']);
211211
```
212212

213+
### `pauseShortcuts and resumeShortcuts`
214+
215+
Pause the handling of all shortcuts. This is especially useful to prevent hotkeys from firing when a modal or sidebar is open.
216+
217+
```ts
218+
// hotkey subscriptions won't emmit, and hotkeys callbacks won't be called
219+
this.hotkeys.pauseShortcuts();
220+
// hotkeys will work again
221+
this.hotkeys.resumeShortcuts();
222+
// get the current state of the hotkeys
223+
this.hotkeys.isPaused();
224+
```
225+
213226
### `setSequenceDebounce`
214227

215228
Set the number of milliseconds to debounce a sequence of keys

projects/ngneat/hotkeys/src/lib/hotkeys.service.ts

+25-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { DOCUMENT } from '@angular/common';
2-
import { Inject, Injectable } from '@angular/core';
2+
import { computed, Inject, Injectable, signal } from '@angular/core';
33
import { EventManager } from '@angular/platform-browser';
44
import { EMPTY, fromEvent, Observable, of, Subject, Subscriber, Subscription } from 'rxjs';
55
import { debounceTime, filter, finalize, mergeMap, takeUntil, tap } from 'rxjs/operators';
@@ -55,6 +55,10 @@ export class HotkeysService {
5555
private sequenceMaps = new Map<HTMLElement, SequenceSummary>();
5656
private sequenceDebounce: number = 250;
5757

58+
private _isActive = signal(true);
59+
// readonly interface for the isActive value
60+
isActive = computed(() => this._isActive());
61+
5862
constructor(
5963
private eventManager: EventManager,
6064
@Inject(DOCUMENT) private document: Document,
@@ -152,7 +156,10 @@ export class HotkeysService {
152156
return getSequenceCompleteObserver().pipe(
153157
takeUntil<Hotkey>(this.dispose.pipe(filter((v) => v === normalizedKeys))),
154158
filter((hotkey) => !this.targetIsExcluded(hotkey.allowIn)),
155-
tap((hotkey) => this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element))),
159+
filter((hotkey) => this._isActive()),
160+
tap((hotkey) => {
161+
this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element));
162+
}),
156163
finalize(() => this.removeShortcuts(normalizedKeys)),
157164
);
158165
}
@@ -182,8 +189,10 @@ export class HotkeysService {
182189
e.preventDefault();
183190
}
184191

185-
this.callbacks.forEach((cb) => cb(e, normalizedKeys, hotkey.element));
186-
observer.next(e);
192+
if (this._isActive()) {
193+
this.callbacks.forEach((cb) => cb(e, normalizedKeys, hotkey.element));
194+
observer.next(e);
195+
}
187196
};
188197

189198
const dispose = this.eventManager.addEventListener(
@@ -196,7 +205,10 @@ export class HotkeysService {
196205
this.hotkeys.delete(normalizedKeys);
197206
dispose();
198207
};
199-
}).pipe(takeUntil<KeyboardEvent>(this.dispose.pipe(filter((v) => v === normalizedKeys))));
208+
}).pipe(
209+
filter(() => this._isActive()),
210+
takeUntil<KeyboardEvent>(this.dispose.pipe(filter((v) => v === normalizedKeys))),
211+
);
200212
}
201213

202214
removeShortcuts(hotkeys: string | string[]): void {
@@ -263,4 +275,12 @@ export class HotkeysService {
263275

264276
return isExcluded;
265277
}
278+
279+
pause() {
280+
this._isActive.set(false);
281+
}
282+
283+
resume() {
284+
this._isActive.set(true);
285+
}
266286
}

projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts

+86
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,38 @@ describe('Directive: Hotkeys', () => {
1616
spectator.fixture.detectChanges();
1717
});
1818

19+
it('should not trigger hotkey output if hotkeys are paused, should trigger again when resumed', () => {
20+
spectator = createDirective(`<div [hotkeys]="'a'"></div>`);
21+
22+
const hotkeysService = spectator.inject(HotkeysService);
23+
hotkeysService.pause();
24+
const spyFcn = createSpy('subscribe', (e) => {});
25+
spectator.output('hotkey').subscribe(spyFcn);
26+
spectator.fixture.detectChanges();
27+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'a');
28+
expect(spyFcn).not.toHaveBeenCalled();
29+
30+
hotkeysService.resume();
31+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'a');
32+
expect(spyFcn).toHaveBeenCalled();
33+
});
34+
35+
it('should not trigger global hotkey output if hotkeys are paused, should trigger again when resumed', () => {
36+
spectator = createDirective(`<div [hotkeys]="'a'" isGlobal></div>`);
37+
38+
const hotkeysService = spectator.inject(HotkeysService);
39+
hotkeysService.pause();
40+
const spyFcn = createSpy('subscribe', (e) => {});
41+
spectator.output('hotkey').subscribe(spyFcn);
42+
spectator.fixture.detectChanges();
43+
spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'a');
44+
expect(spyFcn).not.toHaveBeenCalled();
45+
46+
hotkeysService.resume();
47+
spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'a');
48+
expect(spyFcn).toHaveBeenCalled();
49+
});
50+
1951
const shouldIgnoreOnInputTest = (directiveExtras?: string) => {
2052
const spyFcn = createSpy('subscribe', (...args) => {});
2153
spectator = createDirective(`<div [hotkeys]="'a'" ${directiveExtras ?? ''}><input></div>`);
@@ -169,6 +201,60 @@ describe('Directive: Sequence Hotkeys', () => {
169201
return run();
170202
});
171203

204+
it('should not trigger sequence hotkey if hotkeys are paused, should trigger again when resumed', () => {
205+
const run = async () => {
206+
// * Need to space out time to prevent other test keystrokes from interfering with sequence
207+
await sleep(250);
208+
const spyFcn = createSpy('subscribe', (...args) => {});
209+
spectator = createDirective(`<div [hotkeys]="'g>m'" [isSequence]="true"></div>`);
210+
const hotkeysService = spectator.inject(HotkeysService);
211+
212+
hotkeysService.pause();
213+
spectator.output('hotkey').subscribe(spyFcn);
214+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'g');
215+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'm');
216+
await sleep(250);
217+
spectator.fixture.detectChanges();
218+
expect(spyFcn).not.toHaveBeenCalled();
219+
220+
hotkeysService.resume();
221+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'g');
222+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'm');
223+
await sleep(250);
224+
spectator.fixture.detectChanges();
225+
expect(spyFcn).toHaveBeenCalled();
226+
};
227+
228+
return run();
229+
});
230+
231+
it('should not trigger global sequence hotkey if hotkeys are paused, should trigger again when resumed', () => {
232+
const run = async () => {
233+
// * Need to space out time to prevent other test keystrokes from interfering with sequence
234+
await sleep(250);
235+
const spyFcn = createSpy('subscribe', (...args) => {});
236+
spectator = createDirective(`<div [hotkeys]="'g>m'" [isSequence]="true" isGlobal></div>`);
237+
const hotkeysService = spectator.inject(HotkeysService);
238+
239+
hotkeysService.pause();
240+
spectator.output('hotkey').subscribe(spyFcn);
241+
spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'g');
242+
spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'm');
243+
await sleep(250);
244+
spectator.fixture.detectChanges();
245+
expect(spyFcn).not.toHaveBeenCalled();
246+
247+
hotkeysService.resume();
248+
spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'g');
249+
spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'm');
250+
await sleep(250);
251+
spectator.fixture.detectChanges();
252+
expect(spyFcn).toHaveBeenCalled();
253+
};
254+
255+
return run();
256+
});
257+
172258
const shouldIgnoreOnInputTest = async (directiveExtras?: string) => {
173259
// * Need to space out time to prevent other test keystrokes from interfering with sequence
174260
await sleep(250);

projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts

+25
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ describe('Service: Hotkeys', () => {
4545
expect(spyFcn).toHaveBeenCalled();
4646
});
4747

48+
it('should not listen to keydown if hotkeys are paused, should listen again when resumed', () => {
49+
const spyFcn = createSpy('subscribe', (e) => {});
50+
spectator.service.addShortcut({ keys: 'a' }).subscribe(spyFcn);
51+
spectator.service.pause();
52+
fakeKeyboardPress('a');
53+
expect(spyFcn).not.toHaveBeenCalled();
54+
spectator.service.resume();
55+
fakeKeyboardPress('a');
56+
expect(spyFcn).toHaveBeenCalled();
57+
});
58+
4859
it('should listen to keyup', () => {
4960
const spyFcn = createSpy('subscribe', (e) => {});
5061
spectator.service.addShortcut({ keys: 'a', trigger: 'keyup' }).subscribe(spyFcn);
@@ -60,6 +71,20 @@ describe('Service: Hotkeys', () => {
6071
expect(spyFcn).toHaveBeenCalled();
6172
});
6273

74+
it('should not call callback when hotkeys are paused, should call again when resumed', () => {
75+
const spyFcn = createSpy('subscribe', (...args) => {});
76+
spectator.service.addShortcut({ keys: 'a' }).subscribe();
77+
spectator.service.onShortcut(spyFcn);
78+
79+
spectator.service.pause();
80+
fakeKeyboardPress('a');
81+
expect(spyFcn).not.toHaveBeenCalled();
82+
83+
spectator.service.resume();
84+
fakeKeyboardPress('a');
85+
expect(spyFcn).toHaveBeenCalled();
86+
});
87+
6388
it('should honor target element', () => {
6489
const spyFcn = createSpy('subscribe', (...args) => {});
6590
spectator.service.addShortcut({ keys: 'a', element: document.body }).subscribe(spyFcn);

src/app/app.component.css

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.container {
2+
padding: 2rem;
3+
max-width: 400px;
4+
margin: auto;
5+
display: flex;
6+
flex-direction: column;
7+
gap: 1rem;
8+
}
9+
10+
.grid-container {
11+
display: grid;
12+
grid-template-columns: repeat(2, 1fr);
13+
gap: 1rem;
14+
}

src/app/app.component.html

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div #container>
1+
<div #container class="container">
22
<input
33
#input
44
placeholder="meta.a"
@@ -27,4 +27,10 @@
2727
[hotkeysOptions]="{ allowIn: ['INPUT'] }"
2828
[isSequence]="true"
2929
/>
30+
31+
<div class="grid-container">
32+
<button (click)="pauseHotkeys()">pause hotkeys</button>
33+
<button (click)="resumeHotkeys()">resume hotkeys</button>
34+
</div>
35+
<span> Hotkeys are {{ isActive() ? 'active' : 'paused' }} </span>
3036
</div>

src/app/app.component.ts

+9
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class AppComponent implements AfterViewInit {
1616
input2 = viewChild<ElementRef<HTMLElement>>('input2');
1717
input3 = viewChild<ElementRef<HTMLElement>>('input3');
1818
container = viewChild<ElementRef<HTMLElement>>('container');
19+
isActive = this.hotkeys.isActive;
1920

2021
ngAfterViewInit(): void {
2122
this.hotkeys.onShortcut((event, keys) => console.log(keys));
@@ -101,4 +102,12 @@ export class AppComponent implements AfterViewInit {
101102
handleHotkey(e: KeyboardEvent) {
102103
console.log('New document hotkey', e);
103104
}
105+
106+
pauseHotkeys() {
107+
this.hotkeys.pause();
108+
}
109+
110+
resumeHotkeys() {
111+
this.hotkeys.resume();
112+
}
104113
}

0 commit comments

Comments
 (0)