Blockquote の `<br>` 境界処理をキャレットネイティブなAPIで再実装

basecamp/lexxy

#1049 で導入されたソフト改行境界の処理ロジックを、Lexical のキャレットAPI を直接活用する形に再設計しました。内部ヘルパーの命名を整理し、コードの意図をより明確に表現しています。

背景

#1049 は、ブロック切り替えツール(Quote・Code・Heading・List など)が <br> を段落の境界として扱えるようにした PR です。例えば <p>aaaa<br>bbbb<br>cccc</p> のうち bbbb だけを選択してブロックフォーマットを適用すると、bbbb のみが変換され、前後の行は元の段落として保持されます。

この実装は正しく動作していましたが、内部の $splitParagraphsAtLineBreakBoundaries ヘルパーが処理の途中で $getCaret 呼び出しを挟む構造になっており、キャレットの取得・変換・選択の設定が断片化していました。今回の #1063 はその構造を整理するリファクタリングです。

技術的な変更

$splitParagraphsAtLineBreakBoundaries という単一の関数が、責務の異なる2つのヘルパーに分割されました。

分割後の2関数:

  • $expandSelectionToLineBreaksAndSplitAtEdges: 選択範囲の「外側」—選択の前後に接する <br> を境界として段落を分割する処理を担う。キャレットネイティブな実装で、$normalizeCaret$setSelectionFromCaretRange を使って選択を最深リーフの TextSelection に正規化してから操作する。
  • $splitSelectedParagraphsAtInnerLineBreaks: 選択範囲の「内側」の <br> を爆発させ、各行を独立した段落にする処理を担う。リスト化の2パス目で使われる。

インポート側では src/helpers/lexical_helper.js が新たに $getCaretRange$getChildCaret$normalizeCaret$setSelectionFromCaretRange を取り込んでいます。

-import { $caretFromPoint, ..., $rewindSiblingCaret, $splitAtPointCaretNext, TextNode } from "lexical"
+import { $caretFromPoint, ..., $getCaretRange, $getChildCaret, ..., $normalizeCaret, $setSelectionFromCaretRange, $splitAtPointCaretNext, TextNode } from "lexical"

呼び出し側の src/editor/contents.js では、すべての呼び出し箇所が旧関数から新関数へ置き換えられています。また、blockquote の「追加」パスでは $splitParagraphsAtLineBreakBoundaries の戻り値(段落リスト)を使っていましたが、この依存を排除し、this.#topLevelElementsInSelection(selection) を直接呼び出すように変更されました。

-      const elements = $splitParagraphsAtLineBreakBoundaries(selection)
+       $expandSelectionToLineBreaksAndSplitAtEdges(selection)
+      const elements = this.#topLevelElementsInSelection(selection)

これにより $expandSelectionToLineBreaksAndSplitAtEdges は副作用(DOMへの分割)のみを担い、戻り値で要素リストを返す責務を持たなくなっています。

また、旧実装にあった collapsed 選択のエッジケース処理も整理されました。旧コードでは focusBr.is(anchorBr) の一致を検出した場合に outward-only 検索に切り替える条件分岐が必要でしたが、新実装では collapsed 選択を最初から outward-only で処理するよう直接制御しています。

// 旧: collapsed 選択を検出してから方向を切り替え
if (focusBr && anchorBr && focusBr.is(anchorBr)) {
  focusBr = $boundaryLineBreak(focusCaret, true)
  anchorBr = $boundaryLineBreak(anchorCaret, true)
  ...
}

// 新: collapsed 選択は最初から outward-only として処理
// $expandSelectionToLineBreaksAndSplitAtEdges 内で直接制御

$splitSelectedParagraphsAtInnerLineBreaks は段落の子要素を走査して <br> を区切りにグループ分けし、それぞれを新しい段落として挿入するシンプルな実装になっています。

export function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
  const topLevelElements = new Set()
  for (const node of selection.getNodes()) {
    const topLevel = node.getTopLevelElement()
    if (topLevel) topLevelElements.add(topLevel)
  }

  for (const element of topLevelElements) {
    if (!$isParagraphNode(element)) continue
    const children = element.getChildren()
    if (!children.some($isLineBreakNode)) continue

    const groups = [ [] ]
    for (const child of children) {
      if ($isLineBreakNode(child)) {
        groups.push([])
        child.remove()
      } else {
        groups[groups.length - 1].push(child)
      }
    }
    // 各 group を独立した段落に変換
  }
}

設計判断

キャレットネイティブ化 が今回のリファクタリングの核心です。Lexical はキャレット($getCaretRange$getSiblingCaret など)を使って DOM ツリーを走査する低レベル API を提供しており、$caretFromPoint で選択点をキャレットに変換してから処理する従来の流れは途中で選択オブジェクトとキャレットが混在しがちでした。新実装はキャレットを操作の基本単位として一貫して使い、最後に $setSelectionFromCaretRange で選択に変換することで、変換コストを1箇所に集約しています。

また、$getParentAtCaret を採用することで、child モードと sibling モードで期待するノードを返す振る舞いを明示的に扱えるようになっています。$normalizeCaret による最深リーフへの正規化は、ブロック要素の端に選択が触れたときに意図しない要素が含まれる問題を防ぐための一手です。

関数が副作用と戻り値の両方を担うという設計も解消されており、$expandSelectionToLineBreaksAndSplitAtEdges は分割だけを行い、その後の要素収集は呼び出し側の #topLevelElementsInSelection が担うという明確な責務分離になっています。

まとめ

本PRは機能変更を伴わないリファクタリングですが、キャレットAPIへの一本化・関数の責務分離・collapsed 選択の特殊ケース除去という3点で、<br> 境界処理の保守性を高めています。命名の整理(splitAtEdges vs splitAtInnerLineBreaks)により、処理の「外側を分割する」か「内側を爆発させる」かという意図の違いが、コードを読むだけで把握できるようになりました。

記事メタデータ

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

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術的変更・設計判断(各論)→まとめ(結論)という「総論→各論→結論」の3部構成が明確に適用されており、模範的です。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きのシンタックスハイライト(```diff:path/to/file```)や、PR番号のリンク記法([#1063](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

「キャレットネイティブ」「TextSelection」などの専門用語を適切に用いており、専門知識を持つエンジニアという対象読者に適合した内容です。

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

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

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

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードは、提供されたDiff情報と完全に一致しています。コードの省略箇所もコメントで補足されており、理解を妨げません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「キャレットネイティブ」「副作用」「責務分離」といった技術用語を、PRの文脈に合わせて正確かつ適切に使用できています。

説明の技術的正確性 ✓ PASS

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

関数の分割、依存関係の排除、collapsed選択の処理方法の変更など、技術的な変更点に関する説明がすべてPR情報とDiffの内容によって裏付けられており、正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張が、PRのDescriptionやDiff内のコード変更といった事実に基づいており、ハルシネーション(捏造)は一切見られません。

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

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

PR番号(#1049, #1063)や関数名、ファイルパスといった固有名詞がすべて正確に記載されています。

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

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

記事のタイトル「Blockquote の <br> 境界処理をキャレットネイティブなAPIで再実装」は、PRのタイトル「Blockquote LineBreak Refactor」の内容をより具体的に、かつ正確に要約しています。

外部知識の正確性 ✓ PASS

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

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

時間表現の正確性 ✓ PASS

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

旧実装(「〜でした」)と今回の変更(「〜します」)に関する時間表現が正しく使い分けられており、PR情報との矛盾はありません。