クォート書式のバグ修正: BRの保持と選択行のみの引用
ブロッククォート適用時に <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を一律に処理する前処理から、選択のセマンティクスを尊重した境界処理へのアプローチ転換は、リッチテキストエディタにおける選択範囲操作の設計指針として参考になります。