Blockquote の `<br>` 境界処理をキャレットネイティブなAPIで再実装
#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)により、処理の「外側を分割する」か「内側を爆発させる」かという意図の違いが、コードを読むだけで把握できるようになりました。