加具留矢流余

かぐるやるよ

Javascriptでキーボードショートカットを実装する

anntにキーボードショートカットを実装したいと思って調べてみても、検索に引っかかるのはライブラリの使い方ばっかりであまり具体的な仕組みが見つからない。あまりブラックボックスなライブラリを使いたくないという気持ちと、この程度簡単に実装できるだろというイキリで更に調査を進めるとshortcut.jsという非常にシンプルなライブラリが見つかった。

www.openjs.com コンパクトにまとまっているので、内容を読み解いた上で使いやすいようにクラスとして実装し直した。 注意点としてshortcut.jsはブラウザのバグやIEにも対応しているが、シンプルさを優先して今回の実装では省いている。

前提知識

キーボードに限らずブラウザのイベントは要素にaddEventListenerを登録することで監視することができる。キーボードのイベントとしてkeydown、keyupが定義されていて、keydownはその名の通りキーが押し込まれたときに発生するイベント、keyupはキーを離したときに発生するイベントとなっている。今回はkeydownイベントを監視することで、予め登録されたショートカットが押されたかを判断している。

keydownやkeyupのリスナーを登録しておくと、登録したコールバック関数にKeyboard Eventというオブジェクトが渡される。keyプロパティcodeプロパティでどのキーが押されたかを確認できる。keyもcodeプロパティも押されたキーを取得するプロパティだが、両者には明確に違いがあるので使用する際にはキーボード: keydown と keyupを確認するのが良い。

Ctrlキー、Altキー、Cmdキーが押されているかどうかはctrlKey、altKey、metaKeyプロパティで取得できる。keyプロパティで押されたボタンを、ctrlKeyなどでコントロールキーを始めとしたキーが押されているかどうかを判断できるので、これでキーボードショートカットを実装できる。

プロパティ名 概要
key 押されたキーの文字を表す
code キーボードのレイアウトに依存しない押されたキーの位置を表すコードを表す
ctrlKey Ctrlキーが押されているか
altKey Altキーが押されているか
metaKey Cmdキーが押されているか

参考資料
ブラウザイベントの紹介
KeyboardEvent - Web APIs | MDN

プログラムの概要

ShortcutController

ショートカットを管理するメインのクラス。registerShortcutでキーバインドと関数を登録した状態で、handleメソッドをaddEventListenerで登録すればショートカットが使えるようになる。registerShortcutメソッドは使いやすいようにCtrl+Sのように'+'区切りでキーバインドを登録できるようにしてある。handleメソッドでは登録されているShortcutObject(次項参照)のigniteメソッドを一つづつ呼び出していく。

ShortcutObject

一つのショートカットを管理するためのホルダークラス。igniteメソッドは入力されたキーの情報を引数に取って、ショートカットの条件に合致した場合にのみ登録された関数を実行する。

プログラムの実装


const ALLOWED_KEY = ['backspace', 'esc', 'escape', 'tab', 'space', 'return']
/**
 * 使用可能なコードか判定を行う.
 * 1文字の英字かALLOWED_KEYに含まれる特殊文字のみが登録可能です.
 * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
 * 入力は小文字である必要があります
 * @param {String} code 
 */
function allowedCode(code) {
  if (code.length === 1 && code.match(/[a-z]/i)) {
    // 長さが1
    return true;
  }
  return ALLOWED_KEY.includes(code);
}

/**
 * 1つのショートカットを表現するオブジェクト
 */
class ShortcutObject {

  /**
   * コンストラクタ
   * @param {*} char: ショートカットとして使用する英字1文字
   * @param {*} ctrl: コントロールボタンと同時に押す必要があるか
   * @param {*} meta: (Mac用): コマンドボタンと同時に押す必要があるか。
   * @param {*} propagate: Eventを伝播させるか。
   * @param {*} func: ショートカットが押されたときに実行する関数
   */
  constructor(char, ctrl, meta, propagate, func) {
    this.char = char;
    this.ctrl = ctrl;
    this.meta = meta;
    this.propagate = propagate;
    this.func = func;
  }

  /**
   * 条件に合致したか判定して、合致した場合のみ登録された関数を実行
   * @param {String} char 
   * @param {Boolean} ctrl 
   * @param {Boolean} meta 
   */
  ignite(event, char, ctrl, meta) {
    if (char === this.char && ctrl === this.ctrl && meta === this.meta) {
      this.func();
      if (!this.propagate) {
        event.preventDefault();
        if (event.stopPropagation()) {
          event.stopPropagation();
        }
      }
    }
  }

}
class ShortcutController {
  /**
   * コンストラクタ
   */
  constructor() {
    this.shortcutList = [];
  }

  /**
   * ショートカットを登録する 
   * @param {String} keybind: キーバインドを表す文字列, 例) Ctrl+S
   * @param {Function} callback: ショートカットが押されたときに呼び出す関数
   * @param {Boolean} propagate : デフォルトのショートカットを呼び出すか。
   */
  registerShortcut(keybind, callback, propagate=false) {
    // + で区切ってすべて小文字のリストに
    // 例)Ctrl+A => [ctrl, a]
    const keys = keybind.split('+').map((s) => s.toLowerCase());

    // キーバインドをパース
    let needCtrl = false;
    let needMeta = false;
    let char = null;
    keys.forEach((k) => {
      if (k === 'ctrl') {
        needCtrl = true;
      } else if (k === 'meta') {
        needMeta = true; 
      } else if(allowedCode(k)){
        char = k;
      } else {
        // 使用できないキーが含まれている
        throw new Error('Irrelvalent Shortcut');
      }
    });
    if (char === null) {
      throw new Error('Irrelvalent Shortcut');
    }

    // ショートカットをリストに追加する
    const shortcut = new ShortcutObject(char, needCtrl, needMeta, propagate, callback);
    this.shortcutList.push(shortcut);
  }

  /**
   * addEventListenerようのハンドラー
   * 
   * @param {Event} event 
   */
  handle(event) {
    const ctrl = event.ctrlKey;
    const meta = event.metaKey;
    const char = event.key.toLowerCase();

    this.shortcutList.forEach((shortcut) => {
      shortcut.ignite(event, char, ctrl, meta);
    });
  }
}

export default {
  ShortcutController,
}

割と短く実装出来た。流し読みして見切り発射の実装なので何か漏れがあるかも。上でも書いたがshortcut.jsではブラウザ周りのバグなどにも対応してそうなので、特段の主義主張が無いのであればこちらを使うのがおすすめです。

mikebird28.hatenablog.jp

mikebird28.hatenablog.jp