Metronome.fm のキーボードショートカットを、自由に割り当てられるようにしました。どの操作にどのキーを使うかを自分で決められ、決めたキーはアカウントに保存されて、ほかの端末でも同じキーで使えます。
これまでのショートカットは、Space で再生、A で AI に BPM を尋ねる、のように決まったキーだけでした。メトロノームは楽器を持ったまま操作するので、手の届きやすいキーは人によって違います。普段使っているほかのアプリとキーを揃えたい人もいます。キーを割り当てる画面と仕組みは、エディターの Zed を参考に作りました。
この記事では、できるようになったことと、作るときに考えたことを書きます。
キーを押して割り当てる
サインインした状態で、メニューの「ショートカットを変更」か、ショートカットの一覧(? キー)の「編集」から開きます。

- 操作の名前・キーで探す
- キーの欄。クリックしてから、割り当てたいキーを押す
- 割り当てを JSON でまとめて書くタブ
キーの欄をクリックしてから、キーボードで割り当てたいキーを押すと、そのキーになります。Esc でやめ、Backspace で割り当てを外します。
キーは Zed と同じく、mod-shift-z のように修飾キーとキーを - でつないだ文字列で保存しています。mod は Mac では ⌘、Windows では Ctrl です。そのため、同じ設定のまま Mac でも Windows でも使えます。Mac の Control は ctrl、Windows キーは win として区別し、そのキーがない端末では効きません。Control・Option・Shift・⌘ を全部押す、いわゆる Hyper キーも割り当てられます。
画面に出すキーの名前は、OS のメニューと同じ書き方にしています。Mac は ⇧⌘Z のように記号をつなげ、Windows は Ctrl+Shift+Z と出します。
同じキーは 2 つの操作に割り当てない

- ほかの操作で使っているキーを押すと、割り当てずに理由を出す
- Mac の Control を含む Hyper キーも割り当てられる
- ブラウザが使うキーは、何を上書きするかを出す
ほかの操作で使っているキーは、割り当てられないようにしました。画面には操作が 1 行ずつ並んでいて、使われているキーを押すと、どの操作で使っているかをその行に出します。
Zed でキーを割り当てる画面(キーマップエディター)は、同じキーを 2 つの操作に割り当てても受け付け、両方の行に印を付けます。最初はそれに倣いました。ところが使ってみると、どちらの操作が動くのかが画面からはわかりませんでした。使いたいキーがほかの操作のものなら、先にそちらを変えてもらう形にしています。
⌘R(再読み込み)のような、ブラウザが使うキーは割り当てられます。そのキーを使うかどうかは使う人が決めることなので、割り当ては受け付けて、「ブラウザの『再読み込み』を上書きします」と知らせるだけにしました。ただし ⌘W・⌘T・⌘N は、Chrome がページにキーを渡さずにタブやウインドウを開け閉めするので、割り当てても動きません。この 3 つ(Shift 付きを含む)だけは受け付けません。
OS の側でキーを取るアプリにも気をつける必要があります。試しにキーの欄で Hyper+L を押したら、欄には ⌘V が入りました。キー操作を自動化するアプリの Keyboard Maestro に「Hyper+L で LGTM という文字を入力する」マクロを入れていて、その文字を ⌘V で貼り付けていたためです。ブラウザまで届かないキーはページから見えないので、欄に入ったキーをそのまま表示して、押したキーと違えば気づけるようにしています。
BPM や言語ごとにキーを作る
「BPM を 120 にする」「表示言語を日本語にする」のように、操作に値(BPM の数や言語)を付けたものにもキーを割り当てられます。値ごとに行を足し、それぞれにキーを割り当てます。

- 言語は選び、その行にキーを割り当てる
- 押すと、値とキーの行が 1 つ増える
- 使えない値の行は、直すまで保存できない
値を付けられる操作は、いまは 3 つです。
操作 | 値の入れ方 |
|---|---|
表示言語を切り替える | 対応している言語から選ぶ |
保存した設定を開く | 設定の ID を書く |
BPM を変える | 40〜300 の整数を書く |
最初は「日本語にする」「English にする」のように、言語ごとの行をアプリの側で用意して並べていました。言語は数が決まっているので、それで足りると考えたためです。ところが、BPM や保存した設定は値がいくらでもあり、用意した行では並べきれません。そこで、Zed で値(引数)を付けられる操作と同じく、使う人が値ごとに行を足す形に作り直しました。
割り当ては JSON で保存していて、値を付ける操作は、操作の下に「値 → キー」を入れ子にします。この JSON は、後の節で書く JSON のタブで直接書くこともできます。
{
"bpm:set": { "120": "alt-1", "90": "alt-2" },
"locale:set": { "ja": "alt-j" }
}保存した設定を開くキーは、その設定のページ(URL の / の後ろに設定の ID が付いたページ)を開くだけにしました。設定があるかどうかは、割り当てるときにも押したときにも確かめません。設定は後から消されたり非公開になったりするので、そのたびにキーの割り当てのほうを合わせようとすると手間が増えます。
開けない設定のページは、もとから「見つからないページ」(404)として扱い、アプリの画面に戻していました。ただ、何も言わずに戻していたので、キーを押した人には何も起きなかったように見えます。そこで、戻るときに「この設定は見つかりませんでした」と知らせるようにしました。共有されたリンクが切れていたときにも同じように知らせます。
あいまいに探す
割り当てられる操作が増えると、一覧から目で探すのは大変です。画面の上の検索欄で、操作の名前(「トラックを追加」など)・アクションの名前(program:add-track のような英数字の名前)・キーから探せます。

- 検索欄に「トック」と入れる
- 当たった文字(ト・ック)に色が付く
検索は、あいまい検索にしました。打った文字が順番どおりに含まれていれば、間が空いていても当たります。「トック」と打てば「トラックを追加」が出ます。VS Code や Zed のコマンドパレットと同じ考え方です。
当たり方には点を付け、よく当たったものから並べます。続けて当たった文字と、語の頭(program:add-track の add や track)で当たった文字ほど点を高くし、間が空くほど低くします。
// 1 文字ずつ当てていき、続けて当たった・語の頭で当たったときに点を足す
state.score +=
SCORE.match +
(previous !== undefined && gap === 0 ? SCORE.consecutive : 0) +
(isWordStart(text, found) ? SCORE.wordStart : 0) -
Math.min(gap * SCORE.gap, SCORE.maxGapPenalty);比べる前に、同じ字として比べられるよう文字の形を 1 字ずつ整えています。大文字と小文字、ひらがなとカタカナ、全角と半角、アクセントの有無(é と e)は同じ字として扱います。「とらっく」と打っても当たります。
当たった文字に色を付けるため、何文字目で当たったかも返しています。ここで気をつけたのは、整えると文字数が変わる字です。たとえば「㌔」は、全角と半角を揃える処理(NFKC)にかけると「キロ」の 2 文字になり、その後ろの文字の位置がずれます。そうした字は整えずに元の字のまま比べ、文字数が変わらないようにしました。
表示していない言語の名前でも探す
検索は、いま表示している言語以外の名前でも当たります。日本語の表示で「add track」と打っても、英語の表示で「トラック」と打っても、「トラックを追加」の行が出ます。

- 検索欄に英語で「add track」と入れる
- 当たった英語の名前を、行の 2 行目に出す
以前は、操作の名前を表示している言語の分しかブラウザに送っていなかったので、ほかの言語の名前では探せませんでした。全言語の翻訳を送ると重くなるので、ショートカットの操作の名前だけを、サーバーで全言語ぶん読み込んでブラウザに渡しています。20 個ほどの名前が言語の数だけなので、言語が増えても数 KB です。
// ショートカットの操作の名前だけを、すべての言語ぶん読み込んで、翻訳と一緒に渡す
const loadKeymapSearchLabels = async () =>
Object.fromEntries(
await Promise.all(
SUPPORTED_LOCALES.map(async (locale) => {
const messages = await import(`../messages/${locale}.json`);
return [locale, messages.default.mfm.dialogs.keyboardShortcuts.items];
}),
),
);表示していない言語の名前で当たったときは、その名前を行に出します。何もしないと、打った文字が行のどこにもなく、なぜ当たったのかがわからないためです。
JSON で書いて、ファイルで渡す
JSON のタブでは、割り当てをまとめて書けます。書いた JSON をファイルにして画面にドロップすれば読み込めるので、ほかの端末に移したり人に渡したりするのにも使えます。
{
"type": "metronome.fm/keymap",
"version": 1,
"bindings": {
"theme:switch": "shift-d",
"volume:toggle-mute": null,
"bpm:set": { "120": "alt-1" }
}
}書かなかった操作は既定のキーのまま、null は割り当てを外す、という決まりも Zed と同じです。
このアプリでは、設定(BPM・拍子・トラック)も JSON で書き出して、ドロップで読み込めます。ドロップされたファイルがどちらの JSON かを見分けるため、どちらのファイルにも先頭に type を置き、まず type を見てどちらのファイルかを決めてから、中身を確かめるようにしました。Excalidraw のファイルにある "type": "excalidraw" と同じやり方です。
読み込むときは、知らない操作・使えない値(対応していない言語、範囲の外の BPM)・同じキーの重なりがあれば、理由を出してファイルごと読み込みません。一部だけ読み込むと、どこが入ってどこが入らなかったのかがわからなくなるためです。
1 文字のキーを使えるままにする
ショートカットに 1 文字のキー(A や K のように修飾キーを付けないキー)を使うのは、エディターではあまり見ません。文字を打つのが主な作業なので、打った文字と区別できないためです。
メトロノームは、文字を打つより操作するほうが多いアプリです。YouTube の K(再生)・M(ミュート)・F(全画面)と同じく、1 文字のキーのほうが手早く使えます。入力欄で文字を打っているあいだは効かないようにして、1 文字のキーを使えるままにしました。
作っている途中で、1 文字のキーならではの不具合も見つかりました。メニューを開いたまま A を押すと、A で始まる項目に移るはずが、AI の画面が開いていました。メニューには、文字を打つとその文字で始まる項目へ移る機能があり、メニューより先にショートカットがキーを受け取っていたためです。
いまは、メニューやモーダルが開いているあいだはショートカットを止めています。効かせるのは、開いているものを閉じるキー(? で一覧を閉じるなど)だけです。
これから
割り当てられる操作は、これから増やしていきます。BPM を 1 ずつ上げ下げする、タップテンポ、前後のトラックに移る、テーマやアクセントの色を選ぶ、などを考えています。
操作が増えると、いまの既定のキーも見直しが要ります。いまの既定は、操作が少なかったころに頭文字で決めたものです。よく使う操作ほど押しやすいキーになるよう、既定のキーを一度すべて外して振り直すつもりです。振り直しても、自分で変えたキーはそのまま残ります。保存しているのは、既定から変えたところだけだからです。