Arrow キーによる選択を同期化しイベントハンドリングを明確化

basecamp/lexxy

Arrow キーでのノード選択が非同期に実行され、古い選択状態が参照されるバグを解消し、ハンドラが処理したイベントに対して preventDefault() が自動的に呼ばれるようになりました。

背景

Lexxy のエディタでは、矢印キーで前後のノードへ移動する際に await nextFrame が挿入されていたため、選択状態がフレーム待ちの間に更新されず、hasNodeSelection がステールになるケースがありました。この問題は複数プラットフォーム(特に Playwright の Firefox)でテストが不安定になる原因となっていました。

さらに、withCurrentNode がコマンドコンテキスト専用に使用されていたにも関わらず、実装上は常にラップされており、不要な更新が走っていました。これに加えて、Arrow キーのハンドラが true を返してもデフォルトのブラウザ挙動が残り、イベント伝播が不明瞭でした。

技術的な変更

await nextFrame の削除により、#selectPreviousNode などのハンドラは即座に実行され、選択状態が最新の hasNodeSelection を参照できるようになりました。変更前後のコードは以下の通りです。

@@ -387,7 +387,7 @@
-  async #selectPreviousNode(event) {
-    if (event?.shiftKey) return false
-
-    if (this.hasNodeSelection) {
-      return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
-    } else {
-      return this.#selectInLexical(this.nodeBeforeCursor)
-    }
-  }
+  #selectPreviousNode(event) {
+    if (event.shiftKey) {
+      return this.#withCurrentNodeSelectionNode((currentNode) => {
+        const selection = this.#rangeSelectDecorator(currentNode, "forward")
+        selection.modify("extend", true, "character")
+        return true
+      })
+    } else {
+      // non‑shift navigation handled synchronously
+      return this.#selectInLexical(this.nodeBeforeCursor)
+    }
+  }

withCurrentNodewithCurrentNodeSelectionNode にリネームされ、ノード取得ロジックを明示化しました。これにより、コマンドコンテキスト外で editor.update を呼び出す必要がなくなります。

@@ -1,5 +1,5 @@
-import { ListenerBin, registerEventListener } from "../helpers/listener_helper"
+import { ListenerBin, handlingDefault, registerEventListener } from "../helpers/listener_helper"

handlingDefault 関数を src/helpers/listener_helper.js に追加し、ハンドラが true を返した場合に自動で event.preventDefault() を呼び出すようにしました。

export function handlingDefault(handler) {
  return event => {
    const handled = handler(event)
    if (handled) event.preventDefault()
    return handled
  }
}

Arrow キーのコマンド登録はすべて handlingDefault でラップされ、ハンドラの戻り値がそのまま処理完了を示すよう統一されました。

@@ -330,10 +330,10 @@
-      this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW),
-      this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW),
-      this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
-      this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
+      this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, handlingDefault(this.#selectPreviousNode.bind(this)), COMMAND_PRIORITY_LOW),
+      this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, handlingDefault(this.#selectNextNode.bind(this)), COMMAND_PRIORITY_LOW),
+      this.editor.registerCommand(KEY_ARROW_UP_COMMAND, handlingDefault(this.#selectPreviousTopLevelNode.bind(this)), COMMAND_PRIORITY_LOW),
+      this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, handlingDefault(this.#selectNextTopLevelNode.bind(this)), COMMAND_PRIORITY_LOW),

テスト mention_deletion.test.js では、削除後のテキストマッチパターンが厳密化され、スペース消費の期待結果が反映されています。

@@ -135,10 +135,10 @@
-    expect(text).toMatch(/Before[\s]*X[\s]*after/)
+    expect(text).toMatch(/Before[\s]*Xafter/)

設計判断

選択処理を同期化してイベントハンドラに責務を集中させる 方針が取られました。非同期待機 (await nextFrame) を除去したことで、選択ロジックは即時に実行され、状態の一貫性が保証されます。また、handlingDefault の導入は「ハンドラが処理したらデフォルト動作を抑制する」規則をコードベース全体に一括適用でき、個別の event.preventDefault() 呼び出しを削減します。

withCurrentNode のリネーム は、名前だけで「現在選択中のノードを取得する」意図が明確になるようにした点が評価できます。これにより、将来同様のヘルパーが追加された場合でも命名規則が一貫します。

テストの期待値調整 は、実装変更に合わせて仕様を正確に記述し直した例です。変更前後のテスト差分は、実装が期待通りに動作していることを検証する重要な安全弁となります。

まとめ

この PR は、矢印キーでの選択を同期化し、古い選択状態によるバグを根本的に排除すると同時に、ハンドラが処理したイベントに対して自動的に preventDefault() を付与する仕組みを導入しました。結果としてコードパスがシンプルになり、テストの安定性と将来的な拡張性が向上します。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
4db2223c

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文→背景→技術的な変更→設計判断(任意)→まとめの流れが明確で、各必須要素が揃っている。まとめはリード文の単なる繰り返しになっていない。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きシンタックスハイライトは正しく使用されているが、GitHubリンクが指定フォーマット([#1088](URL)や[abc1234](URL))になっていない。PRリンクは `[PR #1088](URL)` となっているため警告。

対象読者への適合性 ✓ PASS

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

対象はエンジニアで、専門用語中心の記述になっており、初心者向けの過度な解説はない。

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

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

各セクションは総論・各論・結論のパラグラフ構成となっており、段落は1トピック・6文未満で空行で区切られている。

Diff内容との照合 ✓ PASS

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

記事中のdiffブロックは提供されたPRのdiffと内容・ファイル名が一致しており、抜粋・省略もなく正確に反映されている。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`await nextFrame`, `withCurrentNode`, `withCurrentNodeSelectionNode`, `handlingDefault`, `preventDefault` などの用語はPRと一致し、誤用は見られない。

説明の技術的正確性 ✓ PASS

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

技術的な変更や設計判断の説明はPRの記述と整合性が取れており、因果関係も論理的に示されている。

事実の突合 ✓ PASS

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

記事の全ての主張はPRのタイトル・説明・diffで裏付けられており、根拠のない数値や外部知識の付加はない。

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

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

PR番号 #1088 が正しく記載されており、その他数値・固有名詞の誤りはない。

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

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

記事タイトルはPRの内容(Arrowキー選択の同期化)を正確に表現している。

外部知識の正確性 ✓ PASS

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

LTS・リリース日程等の外部知識は一切記載されていない。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PRの記述と一致している。