Chrome拡張のショートカットは event.key だけで作らない
macOS Optionでevent.keyがDeadや特殊文字になる問題と、日本語IMEのcompositionを壊さない入力所有権の設計。
Google Sheetsをキーボード中心で操作するChrome拡張 Spex を作っています。
Spexでは Alt / Option を起点に、複数キーのシーケンスでGoogle Sheetsの操作を実行します。
最初は、キーボードショートカットの実装で難しいのは「キーの組み合わせをどう管理するか」だと思っていました。
実際に難しかったのはそこではありませんでした。
macOSのOptionキーと、日本語IMEと、Google Sheets自身の入力状態をどう共存させるかです。
この記事では、ショートカットを増やす話ではなく、既存Webアプリの入力を壊さないためにどう設計したかを書きます。
event.key を信じるとmacOSで崩れる
Windowsで Alt + O を押すと、KeyboardEvent.key から o を取れることを期待しがちです。
しかしmacOSではOptionキーが文字入力にも使われます。
実際、次のようなイベントが発生します。
Option + E -> key = Dead, code = KeyE
Option + F -> key = ƒ, code = KeyF
Option + C -> key = ç, code = KeyC
Option + O -> key = ø, code = KeyO
つまり、
if (event.key === "o") {
// Format root
}
のような実装は、物理的にはOキーを押していてもmacOSでは成立しません。
SpexではAlt / Optionシーケンス中の英数字について、KeyboardEvent.code を優先して物理キーを解決するようにしています。
概念的には次のような処理です。
function resolveMnemonic(event: KeyboardEvent): string | null {
if (/^Key[A-Z]$/.test(event.code)) {
return event.code.slice(3).toLowerCase();
}
if (/^Digit[0-9]$/.test(event.code)) {
return event.code.slice(5);
}
return null;
}
これは「常に code を使うべき」という話ではありません。
テキスト入力なら key の意味は重要です。しかしSpexが解決したいのは、Alt sequence中にユーザーが押した物理的なmnemonic keyです。
用途に応じて key と code のどちらを意味として扱うかを分ける必要がありました。
さらに難しいのが日本語IME
macOS Optionだけなら、code の正規化でかなり改善します。
日本語IMEが入ると、話が一段複雑になります。
例えばSpexが Alt → O → B を処理している途中で、IMEが文字入力として反応すると、keydown 以外にも次のイベントが関わります。
compositionstart
compositionupdate
keypress
beforeinput
compositionend
ここでSpexが keydown だけ preventDefault() しても十分とは限りません。
beforeinput がそのままGoogle Sheetsへ届けば、ユーザーがショートカットを押しただけなのに、セルへ意図しない文字が入ることがあります。
ではcomposition系イベントを全部止めればよいかというと、それも違います。
それをすると普通の日本語入力そのものを壊します。
「IME中かどうか」だけでは足りない
最初に考えやすいルールは、
isComposing === trueならショートカットを無効化する
です。
しかしこれだけでは足りません。
区別したいケースが2つあるからです。
ケースA: Spex開始前からIME入力中
ユーザーがセルを日本語入力している最中にAltを押した。
この場合、Spexは起動すべきではありません。
IMEとGoogle Sheetsへ完全に入力を返すべきです。
ケースB: Spexがキーを捕捉した後にIMEが反応した
ユーザーはショートカットとして O を押したが、その物理キーにIMEが反応してcompositionを開始した。
この場合は逆です。
Spexがすでにそのキーをショートカットとして受理しているので、IMEが生成しようとする文字入力だけをGoogle Sheetsへ流さない必要があります。
つまり必要だったのは、単純な isComposing ではなく、そのcompositionを誰が所有しているかという考え方でした。
Input Ownershipという考え方
Spexでは概念的に、入力を次のように分類しています。
External IME composition
-> Spex開始前から存在
-> Spexは触らない
Spex-owned composition
-> Spexが捕捉したphysical keyに反応して開始
-> 文字挿入だけ抑止
-> shortcut sequenceは継続
これにより、
- 日本語入力中はSpexを起動しない
- Spex sequence中にIMEが反応しても文字をセルへ入れない
- command完了後は通常入力へ戻れる
を同時に満たせます。
keydown だけをトランザクションとして扱わない
ここから得た教訓は、IME環境でのキーボード操作では、1回の物理入力を keydown 1イベントとして見ない方がよい、ということでした。
Spexではsequence中の入力について、少なくとも次を一つの入力トランザクションとして考えています。
keydown
keypress
beforeinput
keyup
compositionstart
compositionupdate
compositionend
もちろん、すべてのイベントで常に preventDefault() するわけではありません。
Spexが所有している入力だけを抑止することが重要です。
通常入力、Ctrl / Meta系の既存ショートカット、Spex開始前からのIME compositionには干渉しません。
Google Sheetsの「contenteditable」も素直ではなかった
IME以外にも、Google Sheets特有の落とし穴がありました。
通常、既存Webアプリ上でショートカットを実装するときは、
input
textarea
contenteditable=true
ならショートカットを無効化する、というContext Gateを考えます。
ところがGoogle Sheetsでは、通常のセル選択中でも #waffle-rich-text-editor がactive elementになり、contenteditable=true を持つことがあります。
さらに日本語入力後には、editable classを持ったまま画面外へ退避したstale editorが残る状態も観測しました。
そのため、
if (element.isContentEditable) return false;
ではSpexがほぼ起動できなくなります。
逆に editable classだけを見ても、日本語入力後のstale stateを本当の編集中と誤認します。
最終的には、実際にeditorがviewport内へ表示されているかまでobservable stateとして見る設計にしました。
概念的には次の区別です。
cell-input
-> 通常セル選択
-> Spex起動可
cell-input editable + visible onscreen
-> 実セル編集
-> Spex起動不可
cell-input editable + hidden offscreen
-> stale editor
-> Spex起動可
DOMの属性名だけで意味を推測するのではなく、ユーザーから見た実状態を組み合わせて判定する必要がありました。
compositionend が遅れてくる
もう一つ厄介だったのが非同期的なイベント順序です。
Command自体は完了しているのに、Spex-ownedな compositionend が後から届くケースがあります。
雑に、
compositionend -> shortcut stateをreset
としていると、直後の次のAlt sequenceを壊します。
そのため、compositionの終了イベントも「現在のsequenceを無条件に終了させるイベント」とは扱っていません。
どのinteractionに属するイベントなのかを意識して扱う必要があります。
E2EではIME由来イベントまで投入する
この種の問題はunit testだけでは安心できませんでした。
Spexでは実Google SheetsをPlaywrightで開き、production content bundleに対して、概念的に次を確認しています。
- 通常セル選択でAltを開始できる
Alt → O → BでBold状態が実際に変わる- IME由来の
compositionstart/beforeinputを投入する - 意図しない文字入力がセルへ流れない
- Bold commandは完了する
- 遅れて
compositionendが来ても、その後もう一度Alt sequenceを開始できる - 実セル編集中はAltをSpexが奪わない
「一度動いた」だけでなく、元の入力UXを壊していないことを受入条件にしています。
keyboard-firstなツールほど、キーボードを奪わないことが重要だった
Spexを作る前は、keyboard-firstというと「できるだけ多くのキーを捕捉する」方向に考えがちでした。
実際には逆でした。
既存アプリ上にkeyboard UXを足す場合、重要なのは、
- いつ自分が入力を所有するか
- いつhost applicationへ返すか
- どのイベントまで同じ物理入力に属すると考えるか
- ユーザーが普通に入力しているときに何もしないか
を明示することです。
特にIMEを使う環境では、keydown handlerを書くことと、入力システムを設計することは別物でした。
Spexでこの設計を使っています
この記事の設計は、Google Sheets向けChrome拡張Spexで実際に使っています。
- Spex: https://spex.kikuta.dev/ja
- Chrome Web Store: https://chromewebstore.google.com/detail/gahnolkboklnjhnaahjdkingoejbepcc
Spexの紹介というより、IMEやmacOS Optionを含むキーボード拡張を作る方の参考になれば嬉しいです。