画像挿入時のリスト破損を防ぐ ListItemNodeInserter の導入

basecamp/lexxy

Lexical エディタで画像をリストのサブバレットに貼り付けると、リスト構造が壊れるバグを修正し、ListItemNodeInserter を新たに導入してリストの整合性を保つようにしました。

背景

Basecamp のカード #9962829025 で報告されたように、画像を入れ子のリストアイテムに挿入すると次の画像がトップレベルのバレットに回り込み、さらに画像を続けて貼るとバレット自体が消失します。これにより編集体験が著しく損なわれ、文書構造の信頼性が低下します。

原因は Lexical の既定実装 RangeSelection.insertNodesブロックデコレータ(画像やファイル添付)をリストアイテム内部に直接配置しようとしたためです。リストアイテムはインラインコンテンツのみ保持できる仕様なので、ブロックノードは誤って別の <li> に持ち上げられ、結果として空のバレットが残り、入れ子リストが切断されます。

この問題は特に入れ子リストで顕在化し、トップレベルでは比較的影響が見えにくいものの、階層が深くなるほど破損のリスクが高まります。したがって、リスト内でのブロックデコレータ挿入を安全に処理するメカニズムが必要です。

技術的な変更

PR では node_inserter の実装をディレクトリ分割し、共通基底クラス BaseNodeInserter を新設しました。これにより各インサーターが独立したファイルに配置され、インポートサイクルを回避しつつ拡張性が向上しています。

新規作成された ListItemNodeInserter は、選択範囲がリストアイテム内にありかつ挿入ノードにブロックデコレータが含まれる場合に handles が真となります。処理は次の手順で行われます:

static handles(selection) {
  return $isRangeSelection(selection) &&
    $getNearestNodeOfType(selection.anchor.getNode(), ListItemNode)
}
#insertAroundList(nodes) {
  if (!this.selection.isCollapsed()) { this.selection.removeText() }
  const anchorNode = this.selection.anchor.getNode()
  const outerList = this.#outermostList(anchorNode)
  const topItem = this.#topLevelItemFor(anchorNode, outerList)
  const splitAfterItem = $isBlankNode(topItem) ? topItem.getPreviousSibling() : topItem
  const splitIndex = splitAfterItem ? splitAfterItem.getIndexWithinParent() + 1 : 0
  const [listBefore, listAfter] = $splitNode(outerList, splitIndex)
  if ($isBlankNode(topItem)) { topItem.remove() }
  let anchor = listBefore ?? listAfter
  for (const node of nodes) { anchor.insertAfter(node); anchor = node }
}

このロジックは、外側のリストを分割し、ブロックデコレータをリストレベルの直下に配置することで、<li> 内にブロックが残らないようにします。空のバレット(Enter で生成された挿入ポイント)は除去され、既存のインラインコンテンツは従来通り selection.insertNodes に委譲されます。

同時に、CodeNodeInserterShadowRootNodeInserterNodeSelectionNodeInserterBlockContainerNodeInserter も新規ファイルとして抽出され、node_inserter.jsINSERTERS 配列に追加されました。これによりインサーター選択ロジックは統一されたファクトリ BaseNodeInserter.for 経由で実行されます。

修正の検証として test/browser/tests/attachments/image_in_list_item.test.js が新規追加され、トップレベル・入れ子・二枚目画像の3パターンでリスト構造の保持を確認しています。テストは Playwright を用いたブラウザ実行で、全体スイートがグリーンになることを保証しています。

設計判断

ブロックデコレータのみをインターセプトする方針が採られました。段落や引用ブロックなどの他のブロック要素は Lexical の既存リスト脱出ロジックに任せ、画像・ファイル添付といった実際にリスト破壊を引き起こす要素だけを対象とすることで、既存のエディタ挙動を最小限に留めています。

インサーターの拡張は 既存の INSERTERS 配列に要素を追加する形で実装されました。新たに設定キーを導入せず、既存のファクトリ呼び出しをそのまま利用できるため、既存プラグインやカスタムコードへの影響はありません。これにより後方互換性が保たれ、利用者はコード変更なしでバグ修正の恩恵を受けられます。

また、基底クラスに静的メソッド for を持たせ、サブクラスがインポートサイクルを形成しないよう設計しました。これによりモジュール間の依存が単方向になり、ビルド時の循環参照エラーを回避しています。拡張性と保守性の両立が意図的に選択されたポイントです。

まとめ

この PR は、リスト内部への画像挿入で生じていた構造破壊を ListItemNodeInserter によって防止し、リストの階層を安全に保つ実装を追加しました。インサーターの分割と基底クラス導入により拡張しやすいアーキテクチャへと改善し、既存機能への影響を最小限に抑えつつテストで完全性を確認しています。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、設計判断(任意)およびまとめがすべて揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは `言語:ファイルパス` の形式で正しく記述され、PRリンクは `[#1123](URL)` 形式で正しくハイパーリンク化されています。

対象読者への適合性 ✓ PASS

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

技術的な詳細が中心で、エンジニア向けの内容になっており、初心者向けの過度な説明はありません。

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

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

各セクションの冒頭が要旨を示す総論パラグラフになっており、段落はトピックセンテンスで始まり、1段落1トピックで適切な長さに保たれ、空行で区切られています。

Diff内容との照合 ✓ PASS

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

記事中のコードスニペットは提供された Diff に正確に対応しており、ファイル名・内容とも一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語は PR の記述と合致し、誤用や不適切な用語は見られません。

説明の技術的正確性 ✓ PASS

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

技術的な説明は PR の問題点・原因・実装内容と整合し、根拠が示されています。

事実の突合 ✓ PASS

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

すべての事実は PR のタイトル、説明、Diff、カード番号に裏付けられており、推測や架空の情報はありません。

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

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

PR 番号 #1123、カード番号 9962829025 など数値は正しく記載されています。

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

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

記事タイトルは PR の内容(画像挿入時のリスト破損修正)を日本語で正確に表現しています。

外部知識の正確性 ✓ PASS

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

PR に記載されていない外部知識(LTS、リリース日等)は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現に関する記述はなく、PR の記述と矛盾する表現もありません。