ソフト改行を段落境界として扱うブロックツールの改善
ツールバーのブロック切り替えツール(Quote、Code、Normal/Heading、リスト類)が、<br>によるソフト改行を段落の境界として認識するようになりました。複数行を<br>でつないだ段落の中から一行だけを選択してブロック変換を適用すると、選択行のみが変換され、残りの行は元の段落として<br>付きで維持されます。
背景
ソフト改行(<br>)を含む段落に対してブロック変換を適用すると、段落全体が変換対象になるという問題がありました。たとえば <p>aaaa<br>bbbb<br>cccc<br>dddd</p> のうち bbbb のみを選択してQuoteに変換しようとしても、段落全体が引用ブロックに変わってしまうという挙動です。
この問題の根本は、Lexicalエディタが<br>を段落区切りではなくインライン要素として扱うため、選択範囲の境界が<br>を越えて段落全体に及んでしまうことにありました。また、選択の開始・終了位置がテキストノードではなく段落要素のオフセット(paragraph offset)として表現される場合にも、既存の境界処理が機能しませんでした。
修正前のコードは $splitParagraphsAtLineBreakBoundaries という関数でこの問題を部分的に解決しようとしていましたが、Quoteツールのみに適用されており、他のブロックツールには適用されていませんでした。また、その実装も境界処理が不完全で、<br>の直前・直後でカーソルがある場合や、空白境界上にカーソルがある場合などのエッジケースに対応できていませんでした。
技術的な変更
$expandSelectionToLineBreaksAndSplitAtEdgesの導入
既存の $splitParagraphsAtLineBreakBoundaries が削除され、$expandSelectionToLineBreaksAndSplitAtEdges という新しい関数に置き換えられました。この関数は src/helpers/lexical_helper.js に実装され、選択範囲の境界を<br>単位まで拡張した後にエッジで段落を分割します。
Lexicalの新しいCaret APIである $caretFromPoint、$getCaretRange、$splitAtPointCaretNext、$normalizeCaret などが活用されており、テキストポイントだけでなく段落要素のオフセット位置(paragraph offset)にも対応しています。これにより、ブラウザの選択範囲がテキストノードではなく段落要素のオフセットとして表現される場合でも、正確に<br>境界を検出できるようになりました。
インポート宣言の変更がその規模を示しています:
変更前:
import { $createNodeSelection, $createParagraphNode, $findMatchingParent, $getCommonAncestor,
$getSelection, $getSiblingCaret, $isDecoratorNode, $isElementNode, $isLineBreakNode,
$isParagraphNode, $isRangeSelection, $isRootNode, $isRootOrShadowRoot, $isTextNode,
$splitNode, LineBreakNode, TextNode } from "lexical"
変更後:
import { $caretFromPoint, $createNodeSelection, $createParagraphNode, $findMatchingParent,
$getCaretInDirection, $getCaretRange, $getChildCaret, $getCommonAncestor, $getSelection,
$getSiblingCaret, $isChildCaret, $isDecoratorNode, $isElementNode, $isExtendableTextPointCaret,
$isLineBreakNode, $isParagraphNode, $isRangeSelection, $isRootNode, $isRootOrShadowRoot,
$isSiblingCaret, $isTextNode, $isTextPointCaret, $normalizeCaret, $rewindSiblingCaret,
$setSelectionFromCaretRange, $splitAtPointCaretNext, TextNode } from "lexical"
$splitSelectedParagraphsAtInnerLineBreaksの追加
リストツール向けに $splitSelectedParagraphsAtInnerLineBreaks という新関数も追加されました。リストへの変換では二段階処理が採用されています。まず $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)
}
}
for (const group of groups) {
if (group.length === 0) continue
const paragraph = $createParagraphNode()
group.forEach(child => paragraph.append(child))
element.insertBefore(paragraph)
}
if (groups.some(group => group.length > 0)) element.remove()
}
}
また、複数段落をまたぐリスト選択における中間段落(最初・最後以外の段落)でも<br>が展開されるよう修正されており、<p>aaaa<br/>bbbb</p><p>cccc<br/>dddd</p><p>eeee<br/>ffff</p> のすべての行がそれぞれ個別のリストアイテムになります。
全ブロックツールへの適用
$expandSelectionToLineBreaksAndSplitAtEdges は src/editor/contents.js 内のすべてのブロック変換処理の冒頭に追加されました。
applyNormalFormat() {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
$expandSelectionToLineBreaksAndSplitAtEdges(selection) // 追加
$setBlocksType(selection, () => $createParagraphNode())
}
applyHeadingFormat(tag) {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
$expandSelectionToLineBreaksAndSplitAtEdges(selection) // 追加
$setBlocksType(selection, () => $createHeadingNode(tag))
}
Quoteツールでは旧関数の呼び出しが $expandSelectionToLineBreaksAndSplitAtEdges に置き換えられ、CodeブロックツールとListツールにも同様の処理が追加されています。
テストの整備
test/browser/tests/formatting/block_formatting.test.js に212行のテストが追加されました。テストでは document.createRange() と setStart/setEnd を使ってブラウザの選択範囲を直接操作することで、テキストオフセット・段落オフセット・セグメント境界・<br>間など26種類の組み合わせが検証されています。
設計判断
選択範囲の前処理として境界調整を行う方式 が採用されました。各ブロック変換処理は従来どおり Lexical 標準の $setBlocksType 等を利用し、その前段に $expandSelectionToLineBreaksAndSplitAtEdges を挟むことで、ブロック変換ロジック本体を変更せずに<br>境界の扱いを改善しています。
リストツールの二段階処理(境界での分割→内部<br>の展開)は、「どこまでを変換対象とするか」と「変換対象内をどう分割するか」を分離した設計です。前者は $expandSelectionToLineBreaksAndSplitAtEdges が、後者は $splitSelectedParagraphsAtInnerLineBreaks が担当することで、各関数の責務が明確になっています。
旧実装の $splitParagraphsAtLineBreakBoundaries は src/helpers/lexical_helper.js から削除されており(147行追加・20行削除)、$splitNode への依存も除かれています。Lexical の Caret API を活用することでより多様な選択状態に対応できるようになりましたが、これはLexicalが提供するCaret APIへの依存度が高まることも意味します。
まとめ
この変更は、<br>を含む段落でのブロック変換において「選択した行だけを変換し、残りを保持する」という直感的な動作を全ブロックツールに統一的に実装したものです。Lexical の Caret API を活用した前処理パターンにより、既存のブロック変換ロジックへの影響を最小限に抑えながら、段落オフセット選択を含む26種類のエッジケースに対応しています。