RxJS - Key Combination Detection

03/27/2021, Sat
Categories: #JavaScript
Tags: #RxJS

Hot Keys

A hot key combination such as "ctrl + shift + alt + a" could be used to trigger a behavior in a web application such as the opening of a dialog or panel. It also provides a convenient shortcut when an action is often repeated in a workflow. There are certainly many libraries out there designated for such a task, but if you are already using RxJS, it is flexible enough that this functionality can be replicated without using an additional library.

Starting from general key press detection

// Undesirable key repeat events from non-modifier keys

import { fromEvent } from "rxjs";
import { filter } from "rxjs/operators";

const keyboardDown$ = fromEvent(document, "keydown");

keyboardDown$.subscribe((event) => {
  console.log(event.code);
});

The code above is able to detect held down keys such as the "ctrl", "shift", "alt", "capslock", "os" without repeats, but all the other keys on the keyboard will trigger multiple times when held down.

We wish to know the key that is held down, but only care that it was done before and is still being held, as the repeat events will not be of something of interest because it is considered noise in the case of a key holding combination.

There is a property in the key event which informs us of whether that the key was held down after the first keydown event for a key. We will choose to only concern ourselves with the initial key press of the held down key.

import { fromEvent } from 'rxjs';
import { filter } from 'rxjs/operators';

const keyboardDown$ = fromEvent(document, 'keydown'),
  .pipe(filter(event => !event.repeat));

keyboardDown$
  .subscribe(event => {
    console.log(event.code);
  });

Now to discern the modifier keys of our choosing, a long-lived history of the keys pressed is required, and the use of Subjects will be suited for this task.

For simplicity's sake, only the left side of the keyboard's modifier keys will be used to demonstrate the key combination.

import { fromEvent, merge, Subject } from "rxjs";
import { filter, scan, map } from "rxjs/operators";

const keyboardDown$ = fromEvent(document, "keydown").pipe(
  filter((event) => !event.repeat)
);

const keyboardUp$ = fromEvent(document, "keyup").pipe(
  filter((event) => !event.repeat)
);

// Track the keys for holding
const ctlLeftDown$ = keyboardDown$.pipe(
  filter((event) => event.code === "ControlLeft")
);

const shiftLeftDown$ = keyboardDown$.pipe(
  filter((event) => event.code === "ShiftLeft")
);

const altLeftDown$ = keyboardDown$.pipe(
  filter((event) => event.code === "AltLeft")
);

const aDown$ = keyboardDown$.pipe(filter((event) => event.code === "KeyA"));

// Track the keys for releasing
const ctlLeftUp$ = keyboardUp$.pipe(
  filter((event) => event.code === "ControlLeft")
);

const shiftLeftUp$ = keyboardUp$.pipe(
  filter((event) => event.code === "ShiftLeft")
);

const altLeftUp$ = keyboardUp$.pipe(
  filter((event) => event.code === "AltLeft")
);

const aUp$ = keyboardUp$.pipe(filter((event) => event.code === "KeyA"));

const keyGroupDown = [shiftLeftDown$, ctlLeftDown$, altLeftDown$, aDown$],
  keyGroupDownLen = keyGroupDown.length,
  keyGroupUp = [shiftLeftUp$, ctlLeftUp$, altLeftUp$, aUp$];

// Subjects will be the record keepers of the held down key combination group
const add$ = new Subject(),
  clear$ = new Subject(),
  remove$ = new Subject();

const add = (value) => {
  return (state) => {
    return state.add(value);
  };
};

const remove = (value) => {
  return (state) => {
    state.delete(value);
    return state;
  };
};

const clear = () => () => new Set();

const keyUpAndDowns$ = merge(
  add$.pipe(map(add)),
  clear$.pipe(map(clear)),
  remove$.pipe(map(remove))
).pipe(scan((state, innerFn) => innerFn(state), new Set()));

keyUpAndDowns$
  .pipe(
    filter((keyCombo) => {
      return keyCombo.size === keyGroupDownLen;
    })
  )
  .subscribe((keyCombo) => {
    console.log("Key combination detected, keyCombo);
  });

merge(...keyGroupDown).subscribe((res) => {
  add$.next(res.code);
});

merge(...keyGroupUp).subscribe((res) => {
  remove$.next(res.code);
});

A Set is used to track the unique keydown interactions because a subset of the key combination can be held and pressed multiple times before the full key combination can be logged in its entirety. Take for example, pressing "ctrl + shift + alt", "ctrl + shift + alt" before finally pressing "ctrl + shift + alt + a", but we only care about the last set of keys.