テーブルセル内でのプロンプトキーボードナビゲーションの修正
Lexicalのコマンド優先度の競合により、テーブルセル内で@メンションなどのプロンプトを開いた際に矢印キーが正しく動作しない問題を修正しました。プロンプトのキーハンドラーを COMMAND_PRIORITY_HIGH から COMMAND_PRIORITY_CRITICAL に引き上げることで、テーブルプラグインのハンドラーより先にプロンプトが矢印キーイベントを処理できるようになります。
背景
テーブルセル内でプロンプトを開いた際、矢印キーがドロップダウンのオプション選択ではなくテーブルセルの移動として処理されるというバグ(#677)が報告されていました。
Lexicalのコマンドシステムでは、同一優先度のハンドラーは登録順で処理されます。テーブルプラグインはエディタ初期化時に COMMAND_PRIORITY_HIGH でハンドラーを登録しますが、プロンプトのハンドラーも同じ COMMAND_PRIORITY_HIGH でその後に登録されていました。Lexicalは優先度が同じ場合、後から登録されたハンドラーを先に実行する設計ですが、テーブルプラグインがその前に矢印キーイベントを消費してしまい、プロンプトまでイベントが届かない状態になっていました。
この問題は、プロンプトが開いている最中でも「プロンプトのハンドラーが後から登録されているにもかかわらず、テーブルのハンドラーに先を越される」という、Lexicalのコマンド優先度モデルの理解が必要な根本原因を持っていました。
技術的な変更
src/elements/prompt.js の #registerKeyListeners メソッドで、すべてのキーコマンド登録の優先度が COMMAND_PRIORITY_HIGH から COMMAND_PRIORITY_CRITICAL に変更されました。
変更前:
import { $createTextNode, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND } from "lexical"
#registerKeyListeners() {
// We can't use a regular keydown for Enter as Lexical handles it first
this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH))
this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH))
if (this.#doesSpaceSelect) {
this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH))
}
// Register arrow keys with HIGH priority to prevent Lexical's selection handlers from running
this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_HIGH))
this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_HIGH))
}
変更後:
import { $createTextNode, $isTextNode, COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND } from "lexical"
#registerKeyListeners() {
// We can't use a regular keydown for Enter as Lexical handles it first
this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL))
this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL))
if (this.#doesSpaceSelect) {
this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL))
}
// Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_CRITICAL))
this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_CRITICAL))
}
対象となるコマンドは KEY_ENTER_COMMAND、KEY_TAB_COMMAND、KEY_SPACE_COMMAND(doesSpaceSelect が有効な場合)、KEY_ARROW_UP_COMMAND、KEY_ARROW_DOWN_COMMAND の5種類です。プロンプトが開いている間のすべてのキーインタラクションが、一括して最高優先度に引き上げられています。
あわせて test/system/prompts_test.rb にシステムテストが追加されました。テストはテーブル挿入後にプロンプトを開き、矢印キー操作でフォーカスがドロップダウンアイテム間を正しく移動することを aria-selected 属性の変化で検証しています。失敗時のエラーメッセージには「The table plugin intercepted the key event because prompt handlers are not using COMMAND_PRIORITY_CRITICAL.」と根本原因が明記されており、将来の回帰を即座に診断できます。
設計判断
プロンプトが開いている間に限り最高優先度を使うというアプローチが採用されました。
プロンプトのキーハンドラーは、プロンプトが閉じると同時にすべて解除(keyListeners のクリーンアップ)されます。つまり COMMAND_PRIORITY_CRITICAL を占有するのはプロンプトが表示されている期間のみであり、通常の編集操作に影響を与えません。プロンプトは「ユーザーが意図的に開いたUI要素」であり、開いている間はエディタのあらゆるデフォルト動作より優先されるべきという判断は自然です。
一方、コメントが「Register arrow keys with HIGH priority」から「Register arrow keys with CRITICAL priority」に更新された点も見逃せません。コードの意図をインラインで文書化する習慣が維持されており、次に読む開発者が優先度の選択理由を理解しやすくなっています。
まとめ
Lexicalのコマンド優先度システムを正しく活用することで、テーブルプラグインとの競合を最小限のコード変更で解決しました。プロンプトが開いている間のみ COMMAND_PRIORITY_CRITICAL を使用するライフサイクルの設計は、エディタ拡張の競合解決における良いパターンを示しています。