リストの先頭空アイテムをBackspaceで段落に変換する挙動を修正

basecamp/lexxy

リスト先頭の空アイテムでBackspaceを押した際、アイテムを削除するのではなくリストの上に空の段落を挿入する挙動に変更されました。これにより、ユーザーが期待する「リストから抜け出す」操作が正しく機能するようになります。

背景

Backspaceによるリストアイテム削除の挙動が、アイテムの位置によって異なる扱いを必要としていました。Lexicalエディタでは、空のリストアイテムで collapseAtStart が発生すると、デフォルトでアイテムを段落に変換してリストの上に配置する動作をします。しかし、この動作は先頭以外の空アイテムに適用されると、カーソルがリスト外に飛んでしまうという問題を引き起こしていました。

そのため #948 以前は、#removeEmptyListItem メソッドが「次の兄弟が存在する場合のみ」独自処理を行い、それ以外はLexicalのデフォルトに委ねる設計になっていました。しかし、この設計は先頭アイテムのケースを正しく扱えていませんでした。先頭アイテムには前の兄弟がなく、次の兄弟はあるため、独自処理が実行されて削除されてしまい、Lexicalのデフォルト動作(段落への変換)が発動しなかったのです。

結果として、先頭の空アイテムでBackspaceを押すと、期待される「空段落をリスト上部に作る」動作ではなく、アイテムがただ削除される挙動になっていました。

技術的な変更

#removeEmptyListItem メソッドのロジックが、アイテムの位置(先頭か否か)に応じて処理を分岐するように再設計されました。

変更前:

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

listItem.remove()
return true

変更後:

const previousSibling = listItem.getPreviousSibling()
if (previousSibling) {
  previousSibling.selectEnd()
  listItem.remove()
  return true
}

const listNode = $getNearestNodeOfType(listItem, ListNode)
if (!listNode) return false

const paragraph = $createParagraphNode()
listNode.insertBefore(paragraph)
listItem.remove()
paragraph.selectStart()
return true

前の兄弟(previousSibling)が存在する場合は、従来通りそのアイテム末尾にカーソルを移動してアイテムを削除します。前の兄弟が存在しない(=先頭アイテム)の場合は、$getNearestNodeOfType でリストノードを取得し、$createParagraphNode で新しい段落を生成してリストの直前に挿入、その後アイテムを削除して段落の先頭にカーソルを置きます。

なお、リストに兄弟が一切ない(唯一のアイテム)場合は nextSibling の存在チェックにより早期リターンし、Lexicalのデフォルト動作に委ねる設計は維持されています。

テストも同様に更新されており、先頭アイテム削除後のHTMLアサーションが <ul><li>Some text</li></ul> から <p><br></p><ul><li>Some text</li></ul> に変更されています。また、「上に段落がある状態での先頭アイテム削除」シナリオをカバーするテストも追加されました。

設計判断

位置に応じた明示的な分岐#removeEmptyListItem 内に設け、Lexicalのデフォルト動作への依存をより意図的に制御する設計が採用されました。

変更前は「次の兄弟があるか」という条件で独自処理の適用を判定していたため、先頭アイテム(次の兄弟はあるが前の兄弟はない)が意図せず独自処理に巻き込まれていました。変更後は「前の兄弟があるか」という条件を主軸にし、先頭アイテムの処理を明示的に独自実装することで、各ケースの挙動がコメントと対応する形で整理されています。

また、段落の挿入に $createParagraphNodeinsertBefore を組み合わせる手法はLexicalの標準APIに沿っており、エディタのノードツリーを直接操作する既存の実装スタイルと一貫しています。

まとめ

#removeEmptyListItem の条件分岐を「前の兄弟の有無」を軸に再構成することで、先頭・中間・末尾の各ケースが意図通りの挙動を示すようになりました。Lexicalのデフォルト動作を無効化する範囲を最小限に絞りつつ、先頭アイテムの「リストから抜け出す」操作を正しくハンドルした修正です。

記事メタデータ

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

この記事は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/editor/selection.js)およびPR番号のリンク記法([#948](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalエディタの内部実装に関する内容であり、専門用語も適切に使用されているため、対象読者であるエンジニアに適した技術レベルです。

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

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

各セクションが総論→各論の構成になっており、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。段落の長さも適切です。

Diff内容との照合 ⚠ WARNING

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

変更後のコード引用は正確ですが、「変更前」のコード引用がDiffの内容と完全には一致していません。ロジックの骨子は伝わりますが、引用の正確性に欠けます。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`collapseAtStart`や`$getNearestNodeOfType`など、Lexicalに関連する技術用語が文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

変更前後の挙動の違いや、その原因についての技術的な説明は、Diff内のコードやコメントと整合性が取れており、正確です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのタイトル、Description、およびDiffの内容に基づいており、根拠のない推測や憶測(ハルシネーション)は見られません。

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

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

PR番号(#948)やメソッド名(`#removeEmptyListItem`)などの固有名詞は正確に記載されています。

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

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

記事のタイトル「リストの先頭空アイテムをBackspaceで段落に変換する挙動を修正」は、PRのタイトル「Convert empty first list item to paragraph on backspace」の内容を正確に反映しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に限定されており、バージョンサポート状況などの外部知識の捏造はありません。

時間表現の正確性 ✓ PASS

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

「以前は」「変更されました」といった時間表現は、PRの文脈と一致しており正確です。