Detect key sequence using RxJS and Angular

Reading Time: 5 minutes

Loading

Introduction

This is day 12 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to detect key sequence. When user inputs the correct secret code, the application calls an external JavaScript library to render unicorns.

In this blog post, I describe how to use RxJS operators (scan, map, filter) to keep track of inputted keys. When the application detects that the key sequence matches the secret code, it calls cornify.js to render a unicorn at a random location.

let's go

Create a new Angular project in workspace

ng generate application day12-key-sequence-detection

Create Detect Key Sequence feature module

First, we create a KeySequenceDetection feature module and import it into AppModule. The feature ultimately encapsulates one component that is KeySequenceDetectionComponent.

Then, Import KeySequenceDetectionModule in AppModule

// key-sequence-detection.module.ts

@NgModule({
  declarations: [
    KeySequenceDetectionComponent,
  ],
  imports: [
    CommonModule
  ],
  exports: [
    KeySequenceDetectionComponent
  ]
})
export class KeySequenceDetectionModule { }

// app.module.ts

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    KeySequenceDetectionModule
  ],
  providers:[],
  bootstrap: [AppComponent]
})
export class AppModule { }

Declare component in feature module

In KeySequenceDetection feature module, we declare KeySequenceDetectionComponent to listen to window’s keyup event, compare keys against the secret code (wesbos) and display unicorn when a match is found.

src/app/key-sequence-detection
├── index.ts
├── key-sequence-detection
│   ├── key-sequence-detection.component.spec.ts
│   └── key-sequence-detection.component.ts
└── key-sequence-detection.module.ts

In KeySequenceDetectionComponent, we define app selector, inline template and inline CSS styles. We will add the RxJS codes to implement the functions in later sections. For your information, <app-key-sequence-detection> is the tag of KeySequenceDetectionComponent.

import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { filter, fromEvent, map, scan, Subject, takeUntil  } from 'rxjs';

@Component({
  selector: 'app-key-sequence-detection',
  template: `
    <div><p>Type the secret code to display unicorn(s)!</p></div>
  `,
  styles: [`
    :host {
      display: block;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class KeySequenceDetectionComponent implements OnInit, OnDestroy {

  readonly secretCode = 'wesbos';
  constructor() { }

  ngOnInit(): void {}

  ngOnDestroy(): void {}
}

Next, I delete boilerplate codes in AppComponent and render KeySequenceDetectionComponent in inline template.

import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  template: '<app-key-sequence-detection></app-key-sequence-detection>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day12 Key Sequence Detection';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}

Add window service to listen to key event

In order to detect key sequence on native Window, I write a window service to inject to KeySequenceDetectionComponent to listen to keyup event. The sample code is from Brian Love’s blog post here.

// core/services/window.service.ts

import { isPlatformBrowser } from "@angular/common";
import { ClassProvider, FactoryProvider, InjectionToken, PLATFORM_ID } from '@angular/core';

/* Create a new injection token for injecting the window into a component. */
export const WINDOW = new InjectionToken('WindowToken');

/* Define abstract class for obtaining reference to the global window object. */
export abstract class WindowRef {
  get nativeWindow(): Window | Object {
    throw new Error('Not implemented.');
  }
}

/* Define class that implements the abstract class and returns the native window object. */
export class BrowserWindowRef extends WindowRef {

  constructor() {
    super();
  }

  override get nativeWindow(): Object | Window {
    return window;    
  }
}

/* Create an factory function that returns the native window object. */
export function windowFactory(browserWindowRef: BrowserWindowRef, platformId: Object): Window | Object {
  if (isPlatformBrowser(platformId)) {
    return browserWindowRef.nativeWindow;
  }
  return new Object();
}

/* Create a injectable provider for the WindowRef token that uses the BrowserWindowRef class. */
const browserWindowProvider: ClassProvider = {
  provide: WindowRef,
  useClass: BrowserWindowRef
};

/* Create an injectable provider that uses the windowFactory function for returning the native window object. */
const windowProvider: FactoryProvider = {
  provide: WINDOW,
  useFactory: windowFactory,
  deps: [ WindowRef, PLATFORM_ID ]
};

/* Create an array of providers. */
export const WINDOW_PROVIDERS = [
  browserWindowProvider,
  windowProvider
];

Then, we provide WINDOW injection token in CoreModule and import CoreModule to AppModule.

// core.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { WINDOW_PROVIDERS } from './services/window.service';

@NgModule({
  declarations: [],
  imports: [
    CommonModule
  ],
  providers: [WINDOW_PROVIDERS]
})
export class CoreModule { }

// app.module.ts

... other import statements ...
import { CoreModule } from './core';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ... other imports ...
    CoreModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Detect key sequence using RxJS

Now, I am going to apply RxJS to implement keyup event in KeySequenceDetectionComponent

I take step-by-step approach from declaring global JS library, token injection, creating subscription in ngOnInit and unsubscribing the subscription in ngOnDestroy.

Install and declare global JS library

First, I add script tag in the body of index.htm. I choose to add the JS script in the body because the application does not require it to execute immediately. The cornify.js library is needed when user types the correct secret code.

// index.html

<body>
  <app-root></app-root>
  <script type="text/javascript" src="https://www.cornify.com/js/cornify.js"></script>
</body>

Then, go back to KeySequenceDetectionComponent and declare a global variable of cornify.js at the top of the file.

The variable name is cornify_add to match the function name in the JS library.

// key-sequence-detection.component.ts

declare var cornify_add: any;

Inject Window to KeySequenceDetectionComponent

// key-sequence-detection.component.ts 

import { WINDOW } from '../../core';

destroy$ = new Subject<void>();

constructor(@Inject(WINDOW) private window: Window) { }

At the same time, I initialize a destroy$ subject to unsubscribe the subscription at the end of the post.

Implement RxJS logic to detect key sequence in ngOnInit

// key-sequence-detection.component.ts 

ngOnInit(): void {
    fromEvent(this.window, 'keyup')
      .pipe(
        filter(e => e instanceof KeyboardEvent),
        map(e => e as KeyboardEvent),
        scan((acc, e) => {
          acc.push(e.key);
          acc.splice(-this.secretCode.length - 1, acc.length - this.secretCode.length);
          return acc;
        }, [] as string[]),
        map(acc => acc.join('')),
        filter(inputtedCode => inputtedCode.includes(this.secretCode)),
        takeUntil(this.destroy$)
      )
      .subscribe(() => cornify_add());
}
  • fromEvent(this.window, ‘keyup’) listens to window keyup event
  • filter(e => e instanceof KeyboardEvent) discards event that is not KeyboardEvent
  • map(e => e as KeyboardEvent) casts event to KeyboardEvent because filter operator only passes down KeyboardEvent
  • scan(….) accumulates keys in an array and return return last six keys
  • map(acc => acc.join(”)) concatenates array elements into a string
  • filter(inputtedCode => inputtedCode.includes(this.secretCode)) compares the input matches the secret code
  • takeUntil(this.destroy$) indicates the event stream continues until this.destroy$ subject completes
  • .subscribe(() => cornify_add()) shows a beautiful unicorn

Unsubscribe the subscription

After subscribing to a subscription in ngOnInit, I have to unsubscribe it in ngOnDestroy.

ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
}

Finally, I have a browser window covered by mystical unicorns.

Final Thoughts

In this post, I show how to use RxJS and Angular to detect key sequence. The first takeaway is to create a window provider in core module and inject window in the component. The second takeaway is to declare a global var variable for the external JS library. The final takeaway is when subscription is created in ngOnInit, developers need to unsubscribe it in ngDestroy.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day12-key-sequence-detection
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day12-key-sequence-detection/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30
  4. Angular Window Provider: https://brianflove.com/2018-01-11/angular-window-provider