空リストアイテム削除時のカーソルジャンプを修正

basecamp/lexxy

空のリストアイテムでBackspaceを押した際、Lexicalのデフォルト動作がカーソルをドキュメント上部に飛ばしてしまう問題を修正しました。DELETE_CHARACTER_COMMAND をインターセプトして空アイテムを削除し、隣接アイテムへカーソルを適切に移動させます。

背景

Lexical エディタには、リストアイテムの先頭でBackspaceを押した際に collapseAtStart を呼び出し、そのアイテムを段落(paragraph)へと変換するデフォルト挙動があります。この動作はリストの最後のアイテムを「リストから抜け出す」ために利用する場面では自然ですが、兄弟アイテムが存在する空アイテムに対して実行されると、段落がリスト全体の上方に挿入される形となり、カーソルがリスト外のドキュメント上部へジャンプするという問題を引き起こしていました。

具体的なシナリオは次の通りです。リストアイテムの先頭にカーソルを置いてEnterを押すと、空の新規アイテムが現在位置の上に挿入されます。その空アイテムにカーソルを移動してBackspaceを押すと、Lexicalは空アイテムをリストから外して段落へ変換し、カーソルがリスト外へ移動してしまいます。

技術的な変更

修正は selection.jslexical_helper.js の2ファイルに分かれており、「空アイテムの判定」と「カーソル再配置」の2つの責務を分離しています。

lexical_helper.js に新たに追加された $isListItemStructurallyEmpty 関数は、リストアイテムが意味のあるコンテンツを含まないかどうかを判定します。getTextContent().trim() === "" のような単純な文字列比較ではなく、子ノードを走査してデコレーターノード(メンション・添付ファイルなど)が存在する場合は非空と判定します。これは、一部のデコレーターノードが \ufeff などの不可視文字を getTextContent() で返すため、文字列比較では誤って空と見なされる恐れがあるためです。

export function $isListItemStructurallyEmpty(listItem) {
  const children = listItem.getChildren()
  for (const child of children) {
    if ($isDecoratorNode(child)) return false
    if ($isLineBreakNode(child)) continue
    if ($isTextNode(child)) {
      if (child.getTextContent().trim() !== "") return false
    } else if ($isElementNode(child)) {
      if (child.getTextContent().trim() !== "") return false
    }
  }
  return true
}

selection.js 側では、#selectDecoratorNodeBeforeDeletion メソッドの冒頭に #removeEmptyListItem の呼び出しを追加しています。このメソッドはBackspace操作の処理パイプライン上にあり、backwards フラグが true(Backspace)の場合のみ動作します。

#selectDecoratorNodeBeforeDeletion(backwards) {
  if (backwards && this.#removeEmptyListItem()) return true
  // ...
}

#removeEmptyListItem の本体では、セレクションが collapsed(カーソル状態)であること、カーソルが ListItemNode 内にあること、そのアイテムが構造的に空であることを順に確認します。次の兄弟(nextSibling)が存在しない場合は即座に false を返してLexicalのデフォルト処理へ委ねます。これにより、リストの最後のアイテムでのBackspaceは従来通りリスト終了として機能します。次の兄弟が存在する場合は、前の兄弟があれば previousSibling.selectEnd()、なければ nextSibling.selectStart() でカーソルを移動した後、空アイテムを削除します。

#removeEmptyListItem() {
  const selection = $getSelection()
  if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false

  const anchorNode = selection.anchor.getNode()
  const listItem = $getNearestNodeOfType(anchorNode, ListItemNode)
  if (!listItem) return false

  if (!$isListItemStructurallyEmpty(listItem)) return false

  const nextSibling = listItem.getNextSibling()
  if (!nextSibling) return false

  const previousSibling = listItem.getPreviousSibling()
  if (previousSibling) {
    previousSibling.selectEnd()
  } else {
    nextSibling.selectStart()
  }
  // ...
}

設計判断

「最後のアイテムは意図的にデフォルト動作へ委ねる」 という選択が、この修正の核心にある設計判断です。

リストの最後の空アイテムでのBackspaceは、「リストから抜け出す」という一般的なエディタの慣用操作です。この動作を上書きしてしまうと、キーボードだけでリストを終了する手段が失われます。nextSibling の存在を条件にすることで、兄弟アイテムを持つ「中間の空アイテム」の削除のみをインターセプトし、最後のアイテムのケースはLexicalのデフォルトへ明示的にフォールスルーする設計になっています。

また、空判定に専用の $isListItemStructurallyEmpty を用意した点も注目に値します。デコレーターノードを「非空コンテンツ」として扱うことで、メンションや添付ファイルを含むアイテムが誤って削除されるリスクを排除しています。

まとめ

Lexicalのデフォルト動作を全面的に置き換えるのではなく、「兄弟が存在する空アイテム」という特定条件下のみでインターセプトし、それ以外はデフォルトへ委ねる設計は、フレームワークの挙動との共存を重視した最小変更の修正です。デコレーターノードを考慮した構造的な空判定ロジックの切り出しにより、同様の判定が必要な将来の拡張にも再利用可能な形になっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
c6db8a12

この記事は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リンク記法の正確性

ファイル名付きのシンタックスハイライト(`言語:ファイルパス`)およびPR番号のリンク記法(`[#番号](URL)`)が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークに関する専門用語が適切に使用されており、対象読者であるエンジニアにとって冗長な説明がなく、簡潔で理解しやすい内容になっています。

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

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

各セクションが「総論→各論」で構成され、各パラグラフがトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が遵守されています。これにより、記事の要点を素早く把握できます。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロックは、提供されたDiff情報と正確に一致しています。変更の核心部分が適切に抽出されており、説明との整合性も取れています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PR情報(Description、Diffコメント)に含まれる`collapseAtStart`、`DecoratorNode`、`siblings`といった技術用語を正確に使用しており、技術的な誤解を招く表現はありません。

説明の技術的正確性 ✓ PASS

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

「最後のアイテムはデフォルト動作に委ねる」という条件分岐の理由や、「構造的な空判定」の必要性など、コード変更の背景にある技術的な説明が正確かつ論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのタイトル、Description、Diff内のコードやコメントによって裏付けられています。ハルシネーション(捏造)や根拠のない推測は見られません。

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

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

PR番号(#887)、ファイル名(selection.js, lexical_helper.js)、関数名などがすべて正確に記載されています。

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

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

記事のタイトル「空リストアイテム削除時のカーソルジャンプを修正」は、PRのタイトル「Fix cursor jumping to top of document when deleting empty list item」の内容を的確に要約しています。

外部知識の正確性 ✓ PASS

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

記事には、バージョン情報やリリース予定など、PR情報に記載のない外部知識は含まれておらず、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

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

記事内には時間表現の歪曲はなく、PRで記述されている事象を客観的に説明しています。