テーブルセル内でのプロンプトキーボードナビゲーションの修正

basecamp/lexxy

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_COMMANDKEY_TAB_COMMANDKEY_SPACE_COMMANDdoesSpaceSelect が有効な場合)、KEY_ARROW_UP_COMMANDKEY_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 を使用するライフサイクルの設計は、エディタ拡張の競合解決における良いパターンを示しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
9f8323b7

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文、背景、技術的な変更、設計判断、まとめが「総論→各論→結論」の構成で明確に記述されています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```javascript:src/elements/prompt.js)とGitHubリンク記法([#677](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Lexicalのコマンド優先度など、専門知識を持つエンジニアを対象とした適切な技術レベルで書かれています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論から始まり、各パラグラフはトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、可読性が高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のコードブロックは、`src/elements/prompt.js`のDiff内容を正確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`COMMAND_PRIORITY_CRITICAL`や`ハンドラー`などの技術用語が、PRの文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

コマンド優先度の競合が問題の原因であること、優先度を引き上げることで解決するという説明は、PRの内容と整合しており技術的に正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(問題の原因、解決策、テストコードの追加など)は、PRのDescriptionやDiffの内容によって裏付けられています。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#718)とIssue番号(#677)が正確に記載され、リンクされています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRのタイトル「Fix prompt keyboard navigation inside tables」を正確に和訳しており、内容と一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に記載のないバージョンサポート情報やリリース日程などの外部知識は含まれていません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現に歪曲はなく、PRが修正した現在の問題を正しく記述しています。