Web アプリにおけるキーボードショートカットの実装（Vanilla JS と React のサンプル付き）

こんにちは。YAMAP でフロントエンドエンジニアをしている @SotaSuzuki と申します。
この記事は YAMAP エンジニア Advent Calendar 2019 の 10日目の記事となります。
連投で恐縮ですが、何卒よろしくお願いいたします。

## はじめに

現在行っている社内アプリケーションの開発において、いい感じのインターフェースのキーボードショートカットを実装したいと思い試行錯誤しました。その結果、わりとイメージに近いものが出来たので共有します。

## 使用するライブラリ

スクラッチで開発するのは windows の `ctrlKey` と mac の `metaKey` の扱いなど、わりと面倒くさそうだったので、以下のライブラリを使用しています。

- [HotKeys.js](https://wangchujiang.com/hotkeys/)
    - キーボードショートカットを簡単に実装できる多機能なライブラリ
    - JavaScript でキーボードショートカットを実装するならこれがデファクトスタンダートといえそう
- [keycode](https://github.com/timoxley/keycode)
    - キーボードイベントの `event.keyCode` を[キーマップ](https://github.com/timoxley/keycode/blob/master/index.js#L74-L118)に基づいて文字列へ変換

## 今回作るもの

下記のようなインターフェースのキーボードショートカットを実装します。

```ts
interface KeyMap {
  sequence: string; // 'ctrl+a', 'ctrl+shift+a', ...
  handler(evt: Event): void;
}

type KeyMaps = KeyMap[];
```

HotKeys.js も相当に多機能ではあるのですが、そのままで上記のインターフェースのキーボードショートカットを実装するにはちょっと面倒なことをしなくてはならなそうでした。（HotKeys.js 単体での実装は後述。意外と面倒ではなかった :oden: ）

## Vanilla JavaScript (Babel) での実装

```js:hotkeys.js
import hotkeys from 'hotkeys-js';
import keycode from 'keycode';
import { undo, redo } from './funcs'; // undo, redo 関数は既に実装されているものを import

const orderedModifierKeys = Object.freeze(['command', 'ctrl', 'shift', 'alt']);

const getKeySequence = (codes, options = {
  splitKey = '+',
}) => {
  const keys = codes
    .map(code => keycode(code)) // keycode() で keyCode に対応した文字列へ変換
    .map(code => (code.endsWith('command') ? 'command' : code)); // command キーは 'left command', 'right command' として取得されるため、'command' に統一

  const modifierKeys = keys
    .filter(orderedModifierKeys.includes)
    .sort((a, b) => { // modifierKeys を orderedModifierKeys の順番に並べ替える
      const aIndex = orderedModifierKeys.findIndex(key => key === a);
      const bIndex = orderedModifierKeys.findIndex(key => key === b);
      return aIndex - bIndex;
    });

  const otherKeys = keys.filter(key => !orderedModifierKeys.includes(key));

  return modifierKeys.concat(otherKeys).join(splitKey);
};

const keyMaps = [
  {
    sequence: 'command+z',
    handler:() => undo(),
  },
  {
    sequence: 'command+shift+z',
    handler: () => redo(),
  },
  // ... other cases
];

const registerHotkeys = () => {
  hotkeys('*', evt => {
    const keyCodes = hotkeys.getPressedKeyCodes();
    const sequence = getKeySequence(keyCodes);
    const keyMap = keyMaps.find(keyMap => keyMap.sequence === sequence);
    if (!keyMap) return; // ショートカットが存在しない場合は何もしない
    keyMap.handler(evt);
  })
}

const unregisterrHotkeys = () => {
  hotkeys.unbind('*');
}

window.onload = () => {
  registerHotkeys();
}

window.onbeforeunload = () => {
  unregisterrHotkeys();
}
```

## React での実装

React では useHotkeys という Custom Hook を実装してみます。

```ts:useHotkeys.ts
import { useEffect } from 'react';
import hotkeys from 'hotkeys-js';
import keycode from 'keycode';

// --- 中略 ---

export const useHotkeys = (): void => {
  const keyMaps = [
    // ...
  ];

  useEffect(() => {
    hotkeys('*', (evt: Event) => {
      const keyCodes = hotkeys.getPressedKeyCodes();
      const sequence = getKeySequence(keyCodes);
      const keyMap = keyMaps.find(keyMap => keyMap.sequence === sequence);
      if (!keyMap) return;
      keyMap.handler(evt);
    });

    return () => {
      hotkeys.unbind('*');
    };
  }, [keyMaps]
};
```

## 以上です

以上です。

蛇足ですが、難しいことをしないでキーボードショートカットを実装するなら、[HotKeys.js](https://wangchujiang.com/hotkeys/) で下記のような実装をするのが早いかもしれません。

```js
import hotkeys from 'hotkeys-js';

const keyMaps = [
  {
    sequence: 'ctrl+a',
    handler: handleKeypressCtrlA,
  },
];

const sequences = keyMaps.map(keyMap => keyMap.sequence)

hotkeys(sequences.join(','), (evt, handler) => {
  const keyMap = keyMaps.find(({ sequence }) => sequence === handler.key);
  if (!keyMap) return;
  keyMap.handler(evt);
});
```