Arrow キーによる選択を同期化しイベントハンドリングを明確化
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)
+ }
+ }
withCurrentNode が withCurrentNodeSelectionNode にリネームされ、ノード取得ロジックを明示化しました。これにより、コマンドコンテキスト外で 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() を付与する仕組みを導入しました。結果としてコードパスがシンプルになり、テストの安定性と将来的な拡張性が向上します。