kikuta@dev:~$cat ./notes/spex-ime-option-input-ownership.md
·12 min·kikuta

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です。

用途に応じて keycode のどちらを意味として扱うかを分ける必要がありました。

さらに難しいのが日本語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に対して、概念的に次を確認しています。

  1. 通常セル選択でAltを開始できる
  2. Alt → O → B でBold状態が実際に変わる
  3. IME由来の compositionstart / beforeinput を投入する
  4. 意図しない文字入力がセルへ流れない
  5. Bold commandは完了する
  6. 遅れて compositionend が来ても、その後もう一度Alt sequenceを開始できる
  7. 実セル編集中はAltをSpexが奪わない

「一度動いた」だけでなく、元の入力UXを壊していないことを受入条件にしています。

keyboard-firstなツールほど、キーボードを奪わないことが重要だった

Spexを作る前は、keyboard-firstというと「できるだけ多くのキーを捕捉する」方向に考えがちでした。

実際には逆でした。

既存アプリ上にkeyboard UXを足す場合、重要なのは、

  • いつ自分が入力を所有するか
  • いつhost applicationへ返すか
  • どのイベントまで同じ物理入力に属すると考えるか
  • ユーザーが普通に入力しているときに何もしないか

を明示することです。

特にIMEを使う環境では、keydown handlerを書くことと、入力システムを設計することは別物でした。

Spexでこの設計を使っています

この記事の設計は、Google Sheets向けChrome拡張Spexで実際に使っています。

Spexの紹介というより、IMEやmacOS Optionを含むキーボード拡張を作る方の参考になれば嬉しいです。