Quote要素上のキャレットでのペーストがクラッシュする問題の修正

basecamp/lexxy

Lexical の選択範囲挿入ロジックが、ブロックコンテナ(<blockquote>)上にキャレットがある状態でのペーストでエラー #211 と #212 を発生させていた点を、選択範囲正規化と新規インサーターで解消しました。

背景

Lexical の RangeSelection.insertNodes は、選択ポイントがインライン子を持つブロック祖先を必須としているため、QuoteNode のように内部に段落だけを持つブロックコンテナ上にキャレットが置かれると不整合が生じ、Invariant エラー #211(インライン貼り付け)または #212(ブロック貼り付け)がスローされていました。

この挙動は実際に Sentry で多数報告され、ユーザーが引用内部でテキストや段落を貼り付けようとした際にコンテンツが失われるという深刻な UX 問題となっていました。

開発側は Lexical 本体の挙動を変更せずに回避策を提供する必要があり、選択範囲を正しく正規化した上で既存の挿入ロジックを再利用できる手段が求められました。

技術的な変更

src/editor/contents/node_inserter.jsBlockContainerNodeInserter クラスが新規追加され、ブロックコンテナ上のポイントを検出して処理を委譲します。インポート文も拡張され、$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 を根本的に回避できました。既存コードへの侵入性は最小限に抑えつつ、テストで全シナリオの成功を確認しているため、ユーザー体験の安定化が期待されます。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

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

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きハイライトは正しい形式ですが、PRリンクが "[PR #1109](URL)" となっており、要件の "[#1109](URL)" 形式になっていません。

対象読者への適合性 ✓ PASS

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

エンジニア向けの技術的内容に絞られており、初心者向けの過度な説明はありません。

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

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

各セクションの冒頭が要点を示すトピックセンテンスになっており、1段落1トピック、段落長も適切です。空行で区切られています。

Diff内容との照合 ⚠ WARNING

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

コードブロックはDiffと概ね一致していますが、テストファイルのスニペットが一部省略されており、Diffの全容(Markdown貼り付けシナリオ)を網羅していません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Lexical、RangeSelection、Invariant エラー #211/#212 など、PRで使用されている用語と一致しています。

説明の技術的正確性 ✓ PASS

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

エラーが発生する原因や新規インサーターの役割がPR説明と整合しています。

事実の突合 ✓ PASS

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

記事の全主張はPRのタイトル・説明・Diffで裏付けられ、捏造や推測は見受けられません。

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

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

PR番号 #1109、エラー番号 #211/#212 など数値は正確です。

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

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

記事タイトルはPRタイトルの日本語要約で、内容と合致しています。

外部知識の正確性 ✓ PASS

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

記事内にPR外部のバージョンサポート情報等は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はありません。