ブロッククォート内へのペーストで改行後のテキストが外に逃げる問題を修正

basecamp/lexxy

ブロッククォート内に改行を含むプレーンテキストをペーストすると、最初の改行以降のテキストがクォートの外へ抜け出てしまう不具合を修正しました。Lexicalの selection.insertNodes() がデフォルトで ParagraphNodeQuoteNode の外へ分離する挙動を回避する専用の挿入パスを追加することで解決しています。

背景

Lexicalの selection.insertNodes() は、ペーストされたノードを現在の選択位置に挿入する汎用メソッドですが、QuoteNode の内部に ParagraphNode を挿入しようとした場合、そのパラグラフをクォートの外側に押し出す(split out)動作をデフォルトで行います。この挙動はエディタの一般的なコンテキストでは自然ですが、ブロッククォート内にテキストをペーストする場面では意図に反します。

具体的には、"line one\nline two" のようなテキストをペーストすると、Lexicalは "line one""line two" をそれぞれ別の ParagraphNode として生成します。insertNodes() がこれらをクォート内に挿入しようとした際、2番目以降の ParagraphNode がクォート外のトップレベルに配置されてしまい、結果として "line two" がブロッククォートから脱出する形になっていました。

技術的な変更

src/editor/contents.js に、選択位置が QuoteNode の内部かどうかを検出し、該当する場合は専用の挿入処理に切り替えるパスが追加されました。

変更前は、ペーストされたノードを常に insertNodes() で挿入していました。

変更前:

const nodes = $generateNodesFromDOM(this.editor, doc)
if (!this.#insertUploadNodes(nodes)) {
  selection.insertNodes(nodes)
}

変更後:

const nodes = $generateNodesFromDOM(this.editor, doc)
if (!this.#insertUploadNodes(nodes)) {
  if (this.#isInsideQuoteNode(selection)) {
    this.#insertNodesIntoQuote(selection, nodes)
  } else {
    selection.insertNodes(nodes)
  }
}

追加されたプライベートメソッド #isInsideQuoteNode(selection) は、アンカーノード自身が QuoteNode であるか、または getTopLevelElement()QuoteNode であるかを判定します。これにより、クォート内の任意の深さの位置からのペーストを正しく検出できます。

#insertNodesIntoQuote(selection, nodes) は、生成されたノードを QuoteNode の子として直接追加する処理を担います。このメソッドのロジックは以下のように動作します。

  • 現在のカーソル位置の ParagraphNodecurrentParagraph)を特定する
  • ペーストされたノードを順番に処理し、insertAfter を使ってクォート内に追加していく
  • ParagraphNode の場合:現在のパラグラフが空であれば、その子要素を移植して置き換える。空でなければ insertAfter で後続に追加する
  • ParagraphNode 以外のノードの場合:新しい ParagraphNode でラップしてから追加する

この設計により、"line one\nline two" のような単一改行(<br> として処理)、複数行、そして "\n\n" による段落分割(<p> 要素として分割)のいずれのケースも、ブロッククォート内に正しく収まります。

テストは test/browser/tests/paste/paste.test.js にブラウザ統合テストとして追加されており、単一改行・複数行・段落区切りの3パターンが assertEditorHtml() によって期待するHTMLと照合されます。

設計判断

Lexicalの insertNodes() の挙動を修正するのではなく、クォートコンテキストを検出して別の挿入パスに分岐するアプローチが採用されています。

この判断の背景には、insertNodes() がLexicalのコアAPIであり、その汎用的な挙動を変えることがブロッククォート以外の多くのケースに影響を及ぼすリスクがある点が挙げられます。代わりに呼び出し側で文脈を判別し、クォート専用のロジックを切り出すことで、既存の挿入処理に対する影響範囲を最小化しています。

空のパラグラフに対して「子要素を移植して置き換える」処理を特別扱いしている点も注目に値します。クォートブロックを挿入した直後はパラグラフが空の状態であることが多く、ペーストによって余分な空パラグラフが残らないよう配慮されています。

まとめ

本PRは、LexicalのAPIの既定挙動とブロッククォートのセマンティクスとの摩擦に対し、呼び出し側でコンテキストを判別する専用パスを追加することで対処しました。insertNodes() への依存を維持しつつ、クォート内ペーストという特定のシナリオだけを切り離して丁寧にハンドリングする設計は、エディタのノードモデルを扱う上での実践的なパターンといえます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
7e48a1cd

この記事は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/contents.js)とGitHub PRへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークの内部実装に関するトピックを、専門知識を持つエンジニア向けに適切なレベルで解説しています。

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

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

各セクションが「総論→各論」で構成され、各段落はトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードは、提供されたDiffの内容と完全に一致しており、ファイル名も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Lexicalの`QuoteNode`, `ParagraphNode`, `selection.insertNodes()`などの技術用語が、PRの文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

Lexicalのデフォルト挙動の問題点、追加されたメソッドのロジック、テスト内容に関する説明が、DiffやPR情報と整合しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescriptionやDiff内のコードから裏付けられており、ハルシネーション(捏造)は見られません。「設計判断」セクションもコードの構造から導かれる妥当な解説です。

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

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

PR番号(#839)やファイルパスが正確に記載されています。

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

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

記事のタイトルはPRのタイトル「Keep pasted text inside blockquote when it contains line breaks」の内容を正確に和訳・要約しています。

外部知識の正確性 ✓ PASS

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

PR情報に含まれないバージョンサポート状況やリリース日程などの外部知識は記載されておらず、事実に基づいた内容になっています。

時間表現の正確性 ✓ PASS

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

記事内の時間表現は、問題の修正という文脈に即しており、PR情報との矛盾はありません。