04/29/2017

Hotkeys in Angular2

Background

For the app that I was building for a customer, I needed hotkey support for Angular2. For a plain old javascript web app, I've used the excellent javascript library mousetrap (https://craig.is/killing/mice) to great success and I wanted to use it in angular2 app as well.

Mousetrap for angular => angular2-hotkeys

As it turns out, somebody already created an nice angular2 wrapper for mousetrap called angular2-hotkeys (https://github.com/brtnshrdr/angular2-hotkeys) that wraps mousetrap and allows you to import a HotkeysService and register keys for it.

To install it, simply follow the instructions in the README.

Now a component can just request the HotkeysService in it's constructor and register a hotkey for itself by invoking the HotkeysService.add() method.

Additionally, the component should also remove the hotkey once it gets destroyed. To do this, we store the returned value of the HotkeysService.add() method and supply it as an argument to the HotkeysService.remove() method when the component is destroyed.

In Angular, this can be done by implementing OnDestroy and it's ngOnDestroy method. When the component gets destroyed, angular invokes the method and and the previously registered hotkey is removed.

A complete example could look like this:

import { Component, OnDestroy } from '@angular/core';
import { HotkeysService, Hotkey } from 'angular2-hotkeys';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnDestroy {
  title = 'app works!';
  hotkeyCtrlLeft: Hotkey | Hotkey[];
  hotkeyCtrlRight: Hotkey | Hotkey[];

  constructor(private hotkeysService: HotkeysService) {
    this.hotkeyCtrlLeft = hotkeysService.add(new Hotkey('ctrl+left', this.ctrlLeftPressed));
    this.hotkeyCtrlRight = hotkeysService.add(new Hotkey('ctrl+right', this.ctrlRightPressed));
  }

  ctrlLeftPressed = (event: KeyboardEvent, combo: string): boolean => {
    this.title = 'ctrl+left pressed';
    return true;
  }

  ctrlRightPressed = (event: KeyboardEvent, combo: string): boolean => {
    this.title = 'ctrl+right pressed';
    return true;
  }

  ngOnDestroy() {
    this.hotkeysService.remove(this.hotkeyCtrlLeft);
    this.hotkeysService.remove(this.hotkeyCtrlRight);
  }
}

Beyond "Hello World!"

Now this works fine for a simple app, but there are a couple of problems:

  • If one component registers a hotkey and a second component also registered the same hotkey, the previous subscription would be overriden.
  • Additionally the subscription / unsubscription logic leakes into each and every component that wants to register a hotkey.
  • The Hotkey events are not the flexible Observable<T> as we have come to expect in angular.
  • Keys are hardcoded inside of each component and therefore difficult to change

Wrapping Hotkeys in a CommandService

To solve that problem, I've introduced a CommandService. It's basically an EventAggregator, that upon initialization reads in a config.json that specifies which keys should be mapped to which commands. It exposes an Observable and registers all the hotkeys specified in the config.json.

Everytime one of those keys are pressed, it triggers the corresponding commands. Instead of importing the HotkeysService itself, all components import the CommandService and subscribe to it's observable. If the user presses a registered hotkey, a Command is triggered and the components check if they are interested in the comand and if so, take action.

Besides allowing easy updating of the hotkeys by editing the config.json, this moves the hotkey registration code to one place, which makes switching the hotkeys library a breeze (in case that should be necessary in the future). This approach also captures the essence of what the hotkeys are doing - they are issuing a command to components. It also allows reusing the CommandService to explicitedly raise those commands from other components.

An implementation of the CommandService looks like that:

CommandService.ts

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import { HotkeysService, Hotkey } from 'angular2-hotkeys';
import { Subject } from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';

class HotkeyConfig {
  [key: string]: string[];
}

class ConfigModel {
  hotkeys: HotkeyConfig;
}

export class Command {
  name: string;
  combo: string;
  ev: KeyboardEvent;
}

@Injectable()
export class CommandService {

  private subject: Subject<Command>;
  commands: Observable<Command>;

  constructor(private hotkeysService: HotkeysService,
              private http: Http) {
    this.subject = new Subject<Command>();
    this.commands = this.subject.asObservable();
    this.http.get('assets/config.json').toPromise()
      .then(r => r.json() as ConfigModel)
      .then(c => {
        for (const key in c.hotkeys) {
          const commands = c.hotkeys[key];
          hotkeysService.add(new Hotkey(key, (ev, combo) => this.hotkey(ev, combo, commands)));
        }
      });
  }

  hotkey(ev: KeyboardEvent, combo: string, commands: string[]): boolean {
    commands.forEach(c => {
      const command = {
        name: c,
        ev: ev,
        combo: combo
      } as Command;
      this.subject.next(command);
    });
    return true;
  }
}

An config.json example:

{
  "hotkeys": {
    "left": [ "MainComponent.MoveLeft" ],
    "right": [ "MainComponent.MoveRight" ],
    "ctrl+left": [ "AppComponent.Back", "MainComponent.MoveLeft" ],
    "ctrl+right": [ "AppComponent.Forward", "MainComponent.MoveRight" ]
  }
}

A consuming Component would look like that:

MainComponent.ts

import { Component, OnDestroy } from '@angular/core';
import { Command, CommandService } from './command.service';
import { Subscription } from 'rxjs/Subscription';

@Component({
  moduleId: module.id,
  selector: 'main',
  templateUrl: 'main.component.html'
})
export class MainComponent implements OnDestroy {
  command: string = 'None';
  subscription: Subscription;
  constructor(private commandService: CommandService) {
    this.subscription = commandService.commands.subscribe(c => this.handleCommand(c));
  }
  handleCommand(command: Command) {
    switch (command.name) {
      case 'MainComponent.MoveLeft': this.command = 'left!'; break;
      case 'MainComponent.MoveRight': this.command = 'right!'; break;
    }
  }
  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

Sample App

I've pushed a simple sample app that uses the CommandService to github (https://github.com/8/hotkey-sample).

References

Last updated 05/07/2017 15:26:16
blog comments powered by Disqus
Questions?
Ask Martin