画像挿入時のリスト破損を防ぐ ListItemNodeInserter の導入
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 に委譲されます。
同時に、CodeNodeInserter、ShadowRootNodeInserter、NodeSelectionNodeInserter、BlockContainerNodeInserter も新規ファイルとして抽出され、node_inserter.js の INSERTERS 配列に追加されました。これによりインサーター選択ロジックは統一されたファクトリ BaseNodeInserter.for 経由で実行されます。
修正の検証として test/browser/tests/attachments/image_in_list_item.test.js が新規追加され、トップレベル・入れ子・二枚目画像の3パターンでリスト構造の保持を確認しています。テストは Playwright を用いたブラウザ実行で、全体スイートがグリーンになることを保証しています。
設計判断
ブロックデコレータのみをインターセプトする方針が採られました。段落や引用ブロックなどの他のブロック要素は Lexical の既存リスト脱出ロジックに任せ、画像・ファイル添付といった実際にリスト破壊を引き起こす要素だけを対象とすることで、既存のエディタ挙動を最小限に留めています。
インサーターの拡張は 既存の INSERTERS 配列に要素を追加する形で実装されました。新たに設定キーを導入せず、既存のファクトリ呼び出しをそのまま利用できるため、既存プラグインやカスタムコードへの影響はありません。これにより後方互換性が保たれ、利用者はコード変更なしでバグ修正の恩恵を受けられます。
また、基底クラスに静的メソッド for を持たせ、サブクラスがインポートサイクルを形成しないよう設計しました。これによりモジュール間の依存が単方向になり、ビルド時の循環参照エラーを回避しています。拡張性と保守性の両立が意図的に選択されたポイントです。
まとめ
この PR は、リスト内部への画像挿入で生じていた構造破壊を ListItemNodeInserter によって防止し、リストの階層を安全に保つ実装を追加しました。インサーターの分割と基底クラス導入により拡張しやすいアーキテクチャへと改善し、既存機能への影響を最小限に抑えつつテストで完全性を確認しています。