Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds pausing and resuming to the hotkey service #97

Merged
merged 2 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions projects/ngneat/hotkeys/src/lib/hotkeys.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -55,6 +55,10 @@ export class HotkeysService {
private sequenceMaps = new Map<HTMLElement, SequenceSummary>();
private sequenceDebounce: number = 250;

private _isPaused = signal(false);
lucasnbsb marked this conversation as resolved.
Show resolved Hide resolved
// readonly interface for the isPaused value
isPaused = computed(() => this._isPaused());

constructor(
private eventManager: EventManager,
@Inject(DOCUMENT) private document: Document,
Expand Down Expand Up @@ -152,7 +156,12 @@ export class HotkeysService {
return getSequenceCompleteObserver().pipe(
takeUntil<Hotkey>(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()) {
lucasnbsb marked this conversation as resolved.
Show resolved Hide resolved
this.callbacks.forEach((cb) => cb(hotkey, normalizedKeys, hotkey.element));
}
}),
finalize(() => this.removeShortcuts(normalizedKeys)),
);
}
Expand Down Expand Up @@ -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(
Expand All @@ -196,7 +207,10 @@ export class HotkeysService {
this.hotkeys.delete(normalizedKeys);
dispose();
};
}).pipe(takeUntil<KeyboardEvent>(this.dispose.pipe(filter((v) => v === normalizedKeys))));
}).pipe(
filter(() => !this._isPaused()),
takeUntil<KeyboardEvent>(this.dispose.pipe(filter((v) => v === normalizedKeys))),
);
}

removeShortcuts(hotkeys: string | string[]): void {
Expand Down Expand Up @@ -263,4 +277,12 @@ export class HotkeysService {

return isExcluded;
}

pauseHotkeys() {
lucasnbsb marked this conversation as resolved.
Show resolved Hide resolved
this._isPaused.set(true);
}

resumeHotkeys() {
lucasnbsb marked this conversation as resolved.
Show resolved Hide resolved
this._isPaused.set(false);
}
}
86 changes: 86 additions & 0 deletions projects/ngneat/hotkeys/src/lib/tests/hotkeys.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div [hotkeys]="'a'"></div>`);

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(`<div [hotkeys]="'a'" isGlobal></div>`);

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(`<div [hotkeys]="'a'" ${directiveExtras ?? ''}><input></div>`);
Expand Down Expand Up @@ -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(`<div [hotkeys]="'g>m'" [isSequence]="true"></div>`);
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(`<div [hotkeys]="'g>m'" [isSequence]="true" isGlobal></div>`);
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);
Expand Down
25 changes: 25 additions & 0 deletions projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/app/app.component.css
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 7 additions & 1 deletion src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div #container>
<div #container class="container">
<input
#input
placeholder="meta.a"
Expand Down Expand Up @@ -27,4 +27,10 @@
[hotkeysOptions]="{ allowIn: ['INPUT'] }"
[isSequence]="true"
/>

<div class="grid-container">
<button (click)="pauseHotkeys()">pause hotkeys</button>
<button (click)="resumeHotkeys()">resume hotkeys</button>
</div>
<span> Hotkeys are {{ isPaused() ? 'paused' : 'active' }} </span>
</div>
9 changes: 9 additions & 0 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class AppComponent implements AfterViewInit {
input2 = viewChild<ElementRef<HTMLElement>>('input2');
input3 = viewChild<ElementRef<HTMLElement>>('input3');
container = viewChild<ElementRef<HTMLElement>>('container');
isPaused = this.hotkeys.isPaused;

ngAfterViewInit(): void {
this.hotkeys.onShortcut((event, keys) => console.log(keys));
Expand Down Expand Up @@ -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();
}
}
Loading