From fe7e558104e73032b6e6bf561156417dab881b68 Mon Sep 17 00:00:00 2001 From: lucasnbsb Date: Sun, 2 Feb 2025 19:25:37 -0300 Subject: [PATCH 1/2] feat: adds pausing and resuming to the hotkey service adds a mechanism to pause and resume the hotkeys event, callbacks and subscription emissions closes #62 --- README.md | 13 +++ .../ngneat/hotkeys/src/lib/hotkeys.service.ts | 32 +++++-- .../src/lib/tests/hotkeys.directive.spec.ts | 86 +++++++++++++++++++ .../src/lib/tests/hotkeys.service.spec.ts | 25 ++++++ src/app/app.component.css | 14 +++ src/app/app.component.html | 8 +- src/app/app.component.ts | 9 ++ 7 files changed, 181 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0f46e93..68f014e 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,19 @@ this.hotkeys.removeShortcuts('meta.a'); this.hotkeys.removeShortcuts(['meta.1', 'meta.2']); ``` +### `pauseShortcuts and resumeShortcuts` + +Pause the handling of all shortcuts. This is especially useful to prevent hotkeys from firing when a modal or sidebar is open. + +```ts +// hotkey subscriptions won't emmit, and hotkeys callbacks won't be called +this.hotkeys.pauseShortcuts(); +// hotkeys will work again +this.hotkeys.resumeShortcuts(); +// get the current state of the hotkeys +this.hotkeys.isPaused(); +``` + ### `setSequenceDebounce` Set the number of milliseconds to debounce a sequence of keys diff --git a/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts b/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts index dd459ac..6750a31 100644 --- a/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts +++ b/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable } from '@angular/core'; +import { computed, Inject, Injectable, signal } from '@angular/core'; import { EventManager } from '@angular/platform-browser'; import { EMPTY, fromEvent, Observable, of, Subject, Subscriber, Subscription } from 'rxjs'; import { debounceTime, filter, finalize, mergeMap, takeUntil, tap } from 'rxjs/operators'; @@ -55,6 +55,10 @@ export class HotkeysService { private sequenceMaps = new Map(); private sequenceDebounce: number = 250; + private _isPaused = signal(false); + // readonly interface for the isPaused value + isPaused = computed(() => this._isPaused()); + constructor( private eventManager: EventManager, @Inject(DOCUMENT) private document: Document, @@ -152,7 +156,12 @@ export class HotkeysService { return getSequenceCompleteObserver().pipe( takeUntil(this.dispose.pipe(filter((v) => v === normalizedKeys))), filter((hotkey) => !this.targetIsExcluded(hotkey.allowIn)), - tap((hotkey) => this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element))), + filter((hotkey) => !this._isPaused()), + tap((hotkey) => { + if (!this._isPaused()) { + this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element)); + } + }), finalize(() => this.removeShortcuts(normalizedKeys)), ); } @@ -182,8 +191,10 @@ export class HotkeysService { e.preventDefault(); } - this.callbacks.forEach((cb) => cb(e, normalizedKeys, hotkey.element)); - observer.next(e); + if (!this._isPaused()) { + this.callbacks.forEach((cb) => cb(e, normalizedKeys, hotkey.element)); + observer.next(e); + } }; const dispose = this.eventManager.addEventListener( @@ -196,7 +207,10 @@ export class HotkeysService { this.hotkeys.delete(normalizedKeys); dispose(); }; - }).pipe(takeUntil(this.dispose.pipe(filter((v) => v === normalizedKeys)))); + }).pipe( + filter(() => !this._isPaused()), + takeUntil(this.dispose.pipe(filter((v) => v === normalizedKeys))), + ); } removeShortcuts(hotkeys: string | string[]): void { @@ -263,4 +277,12 @@ export class HotkeysService { return isExcluded; } + + pauseHotkeys() { + this._isPaused.set(true); + } + + resumeHotkeys() { + this._isPaused.set(false); + } } diff --git a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts index 790dae8..fa8b105 100644 --- a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts +++ b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts @@ -16,6 +16,38 @@ describe('Directive: Hotkeys', () => { spectator.fixture.detectChanges(); }); + it('should not trigger hotkey output if hotkeys are paused, should trigger again when resumed', () => { + spectator = createDirective(`
`); + + const hotkeysService = spectator.inject(HotkeysService); + hotkeysService.pauseHotkeys(); + const spyFcn = createSpy('subscribe', (e) => {}); + spectator.output('hotkey').subscribe(spyFcn); + spectator.fixture.detectChanges(); + spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'a'); + expect(spyFcn).not.toHaveBeenCalled(); + + hotkeysService.resumeHotkeys(); + spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'a'); + expect(spyFcn).toHaveBeenCalled(); + }); + + it('should not trigger global hotkey output if hotkeys are paused, should trigger again when resumed', () => { + spectator = createDirective(`
`); + + const hotkeysService = spectator.inject(HotkeysService); + hotkeysService.pauseHotkeys(); + const spyFcn = createSpy('subscribe', (e) => {}); + spectator.output('hotkey').subscribe(spyFcn); + spectator.fixture.detectChanges(); + spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'a'); + expect(spyFcn).not.toHaveBeenCalled(); + + hotkeysService.resumeHotkeys(); + spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'a'); + expect(spyFcn).toHaveBeenCalled(); + }); + const shouldIgnoreOnInputTest = (directiveExtras?: string) => { const spyFcn = createSpy('subscribe', (...args) => {}); spectator = createDirective(`
`); @@ -169,6 +201,60 @@ describe('Directive: Sequence Hotkeys', () => { return run(); }); + it('should not trigger sequence hotkey if hotkeys are paused, should trigger again when resumed', () => { + const run = async () => { + // * Need to space out time to prevent other test keystrokes from interfering with sequence + await sleep(250); + const spyFcn = createSpy('subscribe', (...args) => {}); + spectator = createDirective(`
`); + const hotkeysService = spectator.inject(HotkeysService); + + hotkeysService.pauseHotkeys(); + spectator.output('hotkey').subscribe(spyFcn); + spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'g'); + spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'm'); + await sleep(250); + spectator.fixture.detectChanges(); + expect(spyFcn).not.toHaveBeenCalled(); + + hotkeysService.resumeHotkeys(); + spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'g'); + spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'm'); + await sleep(250); + spectator.fixture.detectChanges(); + expect(spyFcn).toHaveBeenCalled(); + }; + + return run(); + }); + + it('should not trigger global sequence hotkey if hotkeys are paused, should trigger again when resumed', () => { + const run = async () => { + // * Need to space out time to prevent other test keystrokes from interfering with sequence + await sleep(250); + const spyFcn = createSpy('subscribe', (...args) => {}); + spectator = createDirective(`
`); + const hotkeysService = spectator.inject(HotkeysService); + + hotkeysService.pauseHotkeys(); + spectator.output('hotkey').subscribe(spyFcn); + spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'g'); + spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'm'); + await sleep(250); + spectator.fixture.detectChanges(); + expect(spyFcn).not.toHaveBeenCalled(); + + hotkeysService.resumeHotkeys(); + spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'g'); + spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'm'); + await sleep(250); + spectator.fixture.detectChanges(); + expect(spyFcn).toHaveBeenCalled(); + }; + + return run(); + }); + const shouldIgnoreOnInputTest = async (directiveExtras?: string) => { // * Need to space out time to prevent other test keystrokes from interfering with sequence await sleep(250); diff --git a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts index 98f1c7c..78fe2b3 100644 --- a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts +++ b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts @@ -45,6 +45,17 @@ describe('Service: Hotkeys', () => { expect(spyFcn).toHaveBeenCalled(); }); + it('should not listen to keydown if hotkeys are paused, should listen again when resumed', () => { + const spyFcn = createSpy('subscribe', (e) => {}); + spectator.service.addShortcut({ keys: 'a' }).subscribe(spyFcn); + spectator.service.pauseHotkeys(); + fakeKeyboardPress('a'); + expect(spyFcn).not.toHaveBeenCalled(); + spectator.service.resumeHotkeys(); + fakeKeyboardPress('a'); + expect(spyFcn).toHaveBeenCalled(); + }); + it('should listen to keyup', () => { const spyFcn = createSpy('subscribe', (e) => {}); spectator.service.addShortcut({ keys: 'a', trigger: 'keyup' }).subscribe(spyFcn); @@ -60,6 +71,20 @@ describe('Service: Hotkeys', () => { expect(spyFcn).toHaveBeenCalled(); }); + it('should not call callback when hotkeys are paused, should call again when resumed', () => { + const spyFcn = createSpy('subscribe', (...args) => {}); + spectator.service.addShortcut({ keys: 'a' }).subscribe(); + spectator.service.onShortcut(spyFcn); + + spectator.service.pauseHotkeys(); + fakeKeyboardPress('a'); + expect(spyFcn).not.toHaveBeenCalled(); + + spectator.service.resumeHotkeys(); + fakeKeyboardPress('a'); + expect(spyFcn).toHaveBeenCalled(); + }); + it('should honor target element', () => { const spyFcn = createSpy('subscribe', (...args) => {}); spectator.service.addShortcut({ keys: 'a', element: document.body }).subscribe(spyFcn); diff --git a/src/app/app.component.css b/src/app/app.component.css index e69de29..0b464a2 100644 --- a/src/app/app.component.css +++ b/src/app/app.component.css @@ -0,0 +1,14 @@ +.container { + padding: 2rem; + max-width: 400px; + margin: auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.grid-container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} diff --git a/src/app/app.component.html b/src/app/app.component.html index 0a638aa..bee170b 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,4 @@ -
+
+ +
+ + +
+ Hotkeys are {{ isPaused() ? 'paused' : 'active' }}
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 14482c8..0f2a2a9 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -16,6 +16,7 @@ export class AppComponent implements AfterViewInit { input2 = viewChild>('input2'); input3 = viewChild>('input3'); container = viewChild>('container'); + isPaused = this.hotkeys.isPaused; ngAfterViewInit(): void { this.hotkeys.onShortcut((event, keys) => console.log(keys)); @@ -101,4 +102,12 @@ export class AppComponent implements AfterViewInit { handleHotkey(e: KeyboardEvent) { console.log('New document hotkey', e); } + + pauseHotkeys() { + this.hotkeys.pauseHotkeys(); + } + + resumeHotkeys() { + this.hotkeys.resumeHotkeys(); + } } From f11c600ee365f9a1a6466936d93fae2353290272 Mon Sep 17 00:00:00 2001 From: lucasnbsb Date: Mon, 3 Feb 2025 21:50:02 -0300 Subject: [PATCH 2/2] feat: adds pausing and resuming to the hotkey service changes the semantic of the flag from isPaused to isActive closes #62 --- .../ngneat/hotkeys/src/lib/hotkeys.service.ts | 24 +++++++++---------- .../src/lib/tests/hotkeys.directive.spec.ts | 16 ++++++------- .../src/lib/tests/hotkeys.service.spec.ts | 8 +++---- src/app/app.component.html | 2 +- src/app/app.component.ts | 6 ++--- 5 files changed, 27 insertions(+), 29 deletions(-) diff --git a/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts b/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts index 6750a31..d753e83 100644 --- a/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts +++ b/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts @@ -55,9 +55,9 @@ export class HotkeysService { private sequenceMaps = new Map(); private sequenceDebounce: number = 250; - private _isPaused = signal(false); - // readonly interface for the isPaused value - isPaused = computed(() => this._isPaused()); + private _isActive = signal(true); + // readonly interface for the isActive value + isActive = computed(() => this._isActive()); constructor( private eventManager: EventManager, @@ -156,11 +156,9 @@ export class HotkeysService { return getSequenceCompleteObserver().pipe( takeUntil(this.dispose.pipe(filter((v) => v === normalizedKeys))), filter((hotkey) => !this.targetIsExcluded(hotkey.allowIn)), - filter((hotkey) => !this._isPaused()), + filter((hotkey) => this._isActive()), tap((hotkey) => { - if (!this._isPaused()) { - this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element)); - } + this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element)); }), finalize(() => this.removeShortcuts(normalizedKeys)), ); @@ -191,7 +189,7 @@ export class HotkeysService { e.preventDefault(); } - if (!this._isPaused()) { + if (this._isActive()) { this.callbacks.forEach((cb) => cb(e, normalizedKeys, hotkey.element)); observer.next(e); } @@ -208,7 +206,7 @@ export class HotkeysService { dispose(); }; }).pipe( - filter(() => !this._isPaused()), + filter(() => this._isActive()), takeUntil(this.dispose.pipe(filter((v) => v === normalizedKeys))), ); } @@ -278,11 +276,11 @@ export class HotkeysService { return isExcluded; } - pauseHotkeys() { - this._isPaused.set(true); + pause() { + this._isActive.set(false); } - resumeHotkeys() { - this._isPaused.set(false); + resume() { + this._isActive.set(true); } } diff --git a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts index fa8b105..f6bcf76 100644 --- a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts +++ b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts @@ -20,14 +20,14 @@ describe('Directive: Hotkeys', () => { spectator = createDirective(`
`); const hotkeysService = spectator.inject(HotkeysService); - hotkeysService.pauseHotkeys(); + hotkeysService.pause(); const spyFcn = createSpy('subscribe', (e) => {}); spectator.output('hotkey').subscribe(spyFcn); spectator.fixture.detectChanges(); spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'a'); expect(spyFcn).not.toHaveBeenCalled(); - hotkeysService.resumeHotkeys(); + hotkeysService.resume(); spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'a'); expect(spyFcn).toHaveBeenCalled(); }); @@ -36,14 +36,14 @@ describe('Directive: Hotkeys', () => { spectator = createDirective(`
`); const hotkeysService = spectator.inject(HotkeysService); - hotkeysService.pauseHotkeys(); + hotkeysService.pause(); const spyFcn = createSpy('subscribe', (e) => {}); spectator.output('hotkey').subscribe(spyFcn); spectator.fixture.detectChanges(); spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'a'); expect(spyFcn).not.toHaveBeenCalled(); - hotkeysService.resumeHotkeys(); + hotkeysService.resume(); spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'a'); expect(spyFcn).toHaveBeenCalled(); }); @@ -209,7 +209,7 @@ describe('Directive: Sequence Hotkeys', () => { spectator = createDirective(`
`); const hotkeysService = spectator.inject(HotkeysService); - hotkeysService.pauseHotkeys(); + hotkeysService.pause(); spectator.output('hotkey').subscribe(spyFcn); spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'g'); spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'm'); @@ -217,7 +217,7 @@ describe('Directive: Sequence Hotkeys', () => { spectator.fixture.detectChanges(); expect(spyFcn).not.toHaveBeenCalled(); - hotkeysService.resumeHotkeys(); + hotkeysService.resume(); spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'g'); spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'm'); await sleep(250); @@ -236,7 +236,7 @@ describe('Directive: Sequence Hotkeys', () => { spectator = createDirective(`
`); const hotkeysService = spectator.inject(HotkeysService); - hotkeysService.pauseHotkeys(); + hotkeysService.pause(); spectator.output('hotkey').subscribe(spyFcn); spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'g'); spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'm'); @@ -244,7 +244,7 @@ describe('Directive: Sequence Hotkeys', () => { spectator.fixture.detectChanges(); expect(spyFcn).not.toHaveBeenCalled(); - hotkeysService.resumeHotkeys(); + hotkeysService.resume(); spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'g'); spectator.dispatchKeyboardEvent(document.documentElement, 'keydown', 'm'); await sleep(250); diff --git a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts index 78fe2b3..68963bd 100644 --- a/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts +++ b/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts @@ -48,10 +48,10 @@ describe('Service: Hotkeys', () => { it('should not listen to keydown if hotkeys are paused, should listen again when resumed', () => { const spyFcn = createSpy('subscribe', (e) => {}); spectator.service.addShortcut({ keys: 'a' }).subscribe(spyFcn); - spectator.service.pauseHotkeys(); + spectator.service.pause(); fakeKeyboardPress('a'); expect(spyFcn).not.toHaveBeenCalled(); - spectator.service.resumeHotkeys(); + spectator.service.resume(); fakeKeyboardPress('a'); expect(spyFcn).toHaveBeenCalled(); }); @@ -76,11 +76,11 @@ describe('Service: Hotkeys', () => { spectator.service.addShortcut({ keys: 'a' }).subscribe(); spectator.service.onShortcut(spyFcn); - spectator.service.pauseHotkeys(); + spectator.service.pause(); fakeKeyboardPress('a'); expect(spyFcn).not.toHaveBeenCalled(); - spectator.service.resumeHotkeys(); + spectator.service.resume(); fakeKeyboardPress('a'); expect(spyFcn).toHaveBeenCalled(); }); diff --git a/src/app/app.component.html b/src/app/app.component.html index bee170b..b78758b 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -32,5 +32,5 @@
- Hotkeys are {{ isPaused() ? 'paused' : 'active' }} + Hotkeys are {{ isActive() ? 'active' : 'paused' }} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 0f2a2a9..e6410da 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -16,7 +16,7 @@ export class AppComponent implements AfterViewInit { input2 = viewChild>('input2'); input3 = viewChild>('input3'); container = viewChild>('container'); - isPaused = this.hotkeys.isPaused; + isActive = this.hotkeys.isActive; ngAfterViewInit(): void { this.hotkeys.onShortcut((event, keys) => console.log(keys)); @@ -104,10 +104,10 @@ export class AppComponent implements AfterViewInit { } pauseHotkeys() { - this.hotkeys.pauseHotkeys(); + this.hotkeys.pause(); } resumeHotkeys() { - this.hotkeys.resumeHotkeys(); + this.hotkeys.resume(); } }