クォート書式のバグ修正: BRの保持と選択行のみの引用

basecamp/lexxy

ブロッククォート適用時に <br> が消失する問題と、選択範囲外の行まで引用されてしまう問題を、選択境界のみでパラグラフを分割する新方式で解決しました。

背景

2つの独立したバグが、同一のクォート書式処理に起因していました。いずれも、ソフト改行(<br>)を含むパラグラフに対してブロッククォートを適用した際に発生する問題です。

1つ目のバグは「BRの消失」です。<br> を含むテキストを選択してクォートを適用すると、改行が失われてテキストが1行に結合されてしまっていました。2つ目は「選択外コンテンツの引用」で、パラグラフ内の一部の行だけを選択してクォートを適用しても、パラグラフ全体が引用符で囲まれてしまっていました。

直前の #1013 では、#splitParagraphsAtLineBreaks が選択の開始点または終了点を含むパラグラフのみを分割し、中間パラグラフをスキップするというガードを削除することで対処しました。しかし本PRでは、そのアプローチ自体を「選択内のすべての <br> をパラグラフに分割する」方式から「選択境界付近の <br> のみで分割する」方式に置き換えています。

技術的な変更

$splitParagraphsAtLineBreaks(全BRをパラグラフに分割)を廃止し、新関数 $splitParagraphsAtLineBreakBoundaries に置き換えることで、分割対象を選択境界に限定しました。

src/editor/contents.js の変更:

-import { $isShadowRoot } from "../helpers/lexical_helper"
+import { $isShadowRoot, $splitParagraphsAtLineBreakBoundaries } from "../helpers/lexical_helper"

 // ...

-      this.#splitParagraphsAtLineBreaks(selection)
+      $splitParagraphsAtLineBreakBoundaries(selection)

新関数は src/helpers/lexical_helper.js に実装されました。処理の核心は「選択の終点(focus)を先に処理し、次に開始点(anchor)を処理する」順序にあります。

export function $splitParagraphsAtLineBreakBoundaries(selection) {
  $ensureForwardRangeSelection(selection)

  // Split focus first so the anchor split position stays valid.
  $splitAtNearestLineBreak(selection.focus, "next")
  $splitAtNearestLineBreak(selection.anchor, "previous")
}

function $splitAtNearestLineBreak(point, direction) {
  const paragraph = point.getNode().getTopLevelElement()
  if (!paragraph || !$isParagraphNode(paragraph)) return

  const pointNode = point.getNode()
  const selectionChild = pointNode.getParent().is(paragraph) ? pointNode : pointNode.getParentOrThrow()
  const lineBreakCaret = $caretAtNearestNodeOfType(selectionChild, LineBreakNode, direction)
  if (!lineBreakCaret) return

  const lineBreak = lineBreakCaret.origin
  const isEdge = lineBreakCaret.getNodeAtCaret() === null

  if (!isEdge) {
    $splitNode(paragraph, lineBreak.getIndexWithinParent())
  }

  lineBreak.remove()
}

function $caretAtNearestNodeOfType(node, klass, direction) {
  for (const caret of $getSiblingCaret(node, direction)) {
    if (caret.origin instanceof klass) return caret
  }
  return null
}

focusを先に処理する理由は、anchorの位置が有効なまま保たれるようにするためです。focusからanchorの順に処理することで、anchor側の分割位置がfocusの分割によって無効化されるのを防いでいます。

$caretAtNearestNodeOfType$getSiblingCaret を使ってキャレット(カーソル位置)を走査し、指定した型(LineBreakNode)に最も近いノードを探します。isEdge チェックにより、パラグラフの端に位置するBRの場合は分割を行わずBRを削除するだけに留めます。

テストも更新されており、期待値が大きく変わっています。

-"<p>Before</p><blockquote><p>First line</p><p>Second line</p></blockquote><p>After</p>"
+"<p>Before</p><blockquote><p>First line<br>Second line</p></blockquote><p>After</p>"

全選択時には <br> がブロッククォート内に保持されるようになり、一部行の選択時には選択した行のみが引用される正しい挙動が確認されています。

設計判断

「全BRを分割」から「境界BRのみを分割」へのパラダイム転換 が本PRの核心です。

旧来の #splitParagraphsAtLineBreaks は、引用ロジックの前処理としてパラグラフを完全に解体してから再構築する方式でした。これに対して $splitParagraphsAtLineBreakBoundaries は、選択範囲の外側に位置するBRのみを切り取り境界として使い、選択範囲内のBRはそのまま残します。「選択範囲内は構造を壊さない」という原則に基づいた設計です。

新関数がインスタンスメソッド(#splitParagraphsAtLineBreaks)ではなくモジュール関数($splitParagraphsAtLineBreakBoundaries)として lexical_helper.js に実装された点も注目できます。Lexicalのコンベンションである $ プレフィックスが付与されており、Lexicalのエディタ状態内で呼び出す関数として位置づけられています。

まとめ

選択境界付近のBRのみを分割対象とすることで、「BRの保持」と「選択行のみの引用」という相反する要求を1つのアルゴリズムで同時に解決しています。全BRを一律に処理する前処理から、選択のセマンティクスを尊重した境界処理へのアプローチ転換は、リッチテキストエディタにおける選択範囲操作の設計指針として参考になります。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
dd747775

この記事は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リンク記法の正確性

ファイル名付きシンタックスハイライト(`diff:path`や`javascript:path`)およびGitHubのPRリンク(`[#1013](URL)`)のカスタムMarkdown構文が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalライブラリの内部実装に関する深い内容であり、専門知識を持つエンジニアという対象読者に完全に適合しています。冗長な説明がなく、簡潔で的確です。

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

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

各セクションの冒頭で要旨を述べる構成や、各段落がトピックセンテンスで始まる構成が徹底されており、可読性が非常に高いです。1段落1トピックの原則も守られています。

Diff内容との照合 ✓ PASS

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

`src/editor/contents.js`, `src/helpers/lexical_helper.js`, `test/browser/tests/formatting/block_formatting.test.js` からのコード引用が、提供されたDiff情報と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PRやLexicalの文脈で使われる`anchor`, `focus`, `caret`, `$splitParagraphsAtLineBreakBoundaries`などの技術用語が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

「focusを先に処理する理由」や「`isEdge`チェックの役割」など、コードの動作に関する説明が、Diff内のコードやコメントと一致しており、技術的に正確です。

事実の突合 ✓ PASS

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

「2つのバグ修正」「#1013からのアプローチ変更」「境界BRのみを分割する新方式」など、記事内のすべての主張がPR DescriptionやDiffによって裏付けられています。ハルシネーションは見られません。

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

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

PR番号(#1013, #1014)、関数名、ファイルパスなどの数値・固有名詞はすべて正確です。

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

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

記事のタイトル「クォート書式のバグ修正: BRの保持と選択行のみの引用」は、PRのタイトル「Fix quote formatting: preserve BRs and quote only selected lines」を正確に反映しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に完全に準拠しており、バージョンサポート状況やリリース日程といった外部知識の捏造はありません。

時間表現の正確性 ✓ PASS

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

PR Descriptionで言及されている「#1013の誤ったアプローチを置き換える」という文脈を、「直前の#1013では...」と正確な時間表現で記述できています。