ソフトブレークとハードブレークが混在する選択範囲での引用フォーマットバグを修正
ソフトブレーク(Shift+Enter)とハードブレーク(Enter)が混在するテキストで、選択範囲の一部にのみ引用フォーマットを適用しようとすると、選択外の行まで引用に取り込まれてしまうバグが修正されました。
背景
Lexicalエディタでは、段落(ハードブレーク)の境界をまたいで選択を行うと、selection.getNodes() が部分的に選択された段落の全子ノードを返す仕様になっています。この仕様が、引用フォーマット適用時の誤動作を引き起こしていました。
具体的には、<p>Line one<br>Line two</p><p>Line three<br>Line four</p> のような構造で「Line two」から「Line three」のみを選択して引用を適用すると、selection.getNodes() が「Line one」と「Line four」も含む全ノードを返してしまいます。その結果、#splitParagraphsAtLineBreaks メソッドが全ての行を「選択済み」と判定し、分割処理をスキップして全行を引用ブロックに含めていました。
この挙動は、Lexicalが部分選択された段落に対してその全子ノードを返す設計に起因しており、選択範囲の実際の境界を判定するには getNodes() では不十分でした。
技術的な変更
#splitParagraphsAtLineBreaks メソッドの選択範囲判定ロジックを、getNodes() による全ノード列挙から、選択のアンカー・フォーカス位置による境界チェックに切り替えました。
変更前:
#splitParagraphsAtLineBreaks(selection) {
const selectedNodeKeys = new Set(selection.getNodes().map(n => n.getKey()))
const topLevelElements = this.#topLevelElementsInSelection(selection)
for (const element of topLevelElements) {
// ...
if (groups.every(group => group.some(child => selectedNodeKeys.has(child.getKey())))) continue
// ...
}
}
変更後:
#splitParagraphsAtLineBreaks(selection) {
const anchorKey = selection.anchor.getNode().getKey()
const focusKey = selection.focus.getNode().getKey()
const topLevelElements = this.#topLevelElementsInSelection(selection)
for (const element of topLevelElements) {
// ...
const hasEndpoint = children.some(child =>
child.getKey() === anchorKey || child.getKey() === focusKey
)
if (!hasEndpoint) continue
// ...
}
}
変更の核心は、「どのノードが選択されているか」ではなく「どの段落に選択の端点(アンカーまたはフォーカス)が含まれているか」を判定するように切り替えたことです。selection.anchor と selection.focus はそれぞれ選択の開始点・終了点を表し、これらのキーと一致する子ノードを持つ段落のみが分割の対象となります。
選択範囲の両端点を持たない中間の段落(アンカーとフォーカスの間に完全に含まれる段落)は hasEndpoint が false になるため、ループ先頭の continue でスキップされます。これにより、中間段落は分割されずに丸ごと引用ブロックに取り込まれ、端点を含む境界段落のみが実際に選択された行とそれ以外に分割されます。
あわせて、test/browser/tests/formatting/block_formatting.test.js に70行のテストケースが追加されました。「ソフトブレークのみの段落を選択して引用」と「ハードブレークをまたいだ混合選択に引用」の2シナリオをカバーし、再発防止が図られています。
設計判断
選択範囲の境界を「ノードの包含関係」ではなく「アンカー/フォーカスの位置」で判定する設計が採用されました。
getNodes() はLexicalの公開APIとして利用可能ですが、このメソッドが返すノード集合はLexicalの内部的な選択モデルに基づいており、部分選択された段落に対してはその全子ノードを含みます。ソフトブレークを含む段落の分割という文脈では、この集合は意図した選択範囲と一致しません。一方、selection.anchor と selection.focus は選択の正確な開始・終了地点を直接参照するため、「境界段落かどうか」の判定に適しています。
旧ロジックが groups.every(...) による全グループの包含チェックで処理をスキップしていたのに対し、新ロジックはより早い段階(段落単位)でスキップ判定を行っています。この変更により、条件分岐がシンプルになり、中間段落の扱いが明示的になりました。
まとめ
selection.getNodes() の返すノード集合がLexicalの選択モデル上の「広い」集合であることを認識し、選択の正確な境界情報を持つ anchor/focus に判定ロジックを切り替えた修正です。ライブラリAPIの仕様を深く理解した上でのピンポイントな対処であり、既存の分割処理のフローを変えずに判定条件のみを置き換えることで、副作用を最小化した設計判断といえます。