Quote要素上のキャレットでのペーストがクラッシュする問題の修正
Lexical の選択範囲挿入ロジックが、ブロックコンテナ(<blockquote>)上にキャレットがある状態でのペーストでエラー #211 と #212 を発生させていた点を、選択範囲正規化と新規インサーターで解消しました。
背景
Lexical の RangeSelection.insertNodes は、選択ポイントがインライン子を持つブロック祖先を必須としているため、QuoteNode のように内部に段落だけを持つブロックコンテナ上にキャレットが置かれると不整合が生じ、Invariant エラー #211(インライン貼り付け)または #212(ブロック貼り付け)がスローされていました。
この挙動は実際に Sentry で多数報告され、ユーザーが引用内部でテキストや段落を貼り付けようとした際にコンテンツが失われるという深刻な UX 問題となっていました。
開発側は Lexical 本体の挙動を変更せずに回避策を提供する必要があり、選択範囲を正しく正規化した上で既存の挿入ロジックを再利用できる手段が求められました。
技術的な変更
src/editor/contents/node_inserter.js に BlockContainerNodeInserter クラスが新規追加され、ブロックコンテナ上のポイントを検出して処理を委譲します。インポート文も拡張され、$normalizeSelection__EXPERIMENTAL が $normalizeSelection として利用可能になりました。
import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getChildCaretAtIndex, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $normalizeSelection__EXPERIMENTAL as $normalizeSelection } from "lexical"
BlockContainerNodeInserter.handles は、選択が RangeSelection かつアンカーまたはフォーカスがブロックコンテナ上にあるか $isPointOnBlockContainer で判定し、該当時にインスタンス化されます。
static handles(selection) {
return $isRangeSelection(selection) &&
[selection.anchor, selection.focus].some($isPointOnBlockContainer)
}
insertNodes メソッドは $normalizeSelection を呼び出して選択範囲をリーフ位置に降格させた後、従来の selection.insertNodes を実行することでエラーを防止します。
insertNodes(nodes) {
$normalizeSelection(this.selection)
this.selection.insertNodes(nodes)
}
ポイント判定ロジック $isPointOnBlockContainer は、要素ポイントが子ノードを持ち、かつその子がブロック(インラインでない)であるかを確認し、ブロックコンテナの条件を正確に捕捉します。
function $isPointOnBlockContainer(point) {
if (point.type === "element") {
const firstChild = point.getNode().getFirstChild()
return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
}
return false
}
テストは test/browser/tests/paste/paste_with_caret_on_quote.test.js に追加され、インライン貼り付け・ブロック貼り付け・Markdown 貼り付けの 3 シナリオがすべてエラーなく成功することを Playwright で検証しています。
test.describe("Paste with the caret on a quote element", () => {
// ...省略...
test("pasting inline content does not crash and lands inside the quote", async ({ editor }) => {
// ...省略...
})
test("pasting block content does not crash and is preserved", async ({ editor }) => {
// ...省略...
})
})
設計判断
新規インサーターを導入して既存ロジックをラップする 方針が採用され、Lexical のコア実装へのパッチを回避しつつ、必要なケースだけを対象化できました。これにより、他の選択範囲やノードタイプへの影響を最小限に抑えることができます。
$normalizeSelection__EXPERIMENTAL の使用は、内部的に Lexical が DOM 選択を正規化する同一ロジックを再利用している点で、重複実装を防ぎつつ信頼性の高い正常化を保証しています。実験的 API である点は、将来的に正式 API へ移行する余地を残す設計でもあります。
インサーターの判定ロジックはシンプルかつ拡張性が高く、将来的に他のブロックコンテナ(例:<div> やカスタムブロック)でも同様のハンドリングが可能です。これにより、同様のクラッシュが他箇所で発生した際にも容易に対応できる基盤が整備されました。
まとめ
BlockContainerNodeInserter の導入と選択範囲正規化により、引用ブロック上のキャレットでのペーストが引き起こす Lexical エラー #211/212 を根本的に回避できました。既存コードへの侵入性は最小限に抑えつつ、テストで全シナリオの成功を確認しているため、ユーザー体験の安定化が期待されます。