ソフトブレークとハードブレークが混在する選択範囲での引用フォーマットバグを修正

basecamp/lexxy

ソフトブレーク(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.anchorselection.focus はそれぞれ選択の開始点・終了点を表し、これらのキーと一致する子ノードを持つ段落のみが分割の対象となります。

選択範囲の両端点を持たない中間の段落(アンカーとフォーカスの間に完全に含まれる段落)は hasEndpointfalse になるため、ループ先頭の continue でスキップされます。これにより、中間段落は分割されずに丸ごと引用ブロックに取り込まれ、端点を含む境界段落のみが実際に選択された行とそれ以外に分割されます。

あわせて、test/browser/tests/formatting/block_formatting.test.js に70行のテストケースが追加されました。「ソフトブレークのみの段落を選択して引用」と「ハードブレークをまたいだ混合選択に引用」の2シナリオをカバーし、再発防止が図られています。

設計判断

選択範囲の境界を「ノードの包含関係」ではなく「アンカー/フォーカスの位置」で判定する設計が採用されました。

getNodes() はLexicalの公開APIとして利用可能ですが、このメソッドが返すノード集合はLexicalの内部的な選択モデルに基づいており、部分選択された段落に対してはその全子ノードを含みます。ソフトブレークを含む段落の分割という文脈では、この集合は意図した選択範囲と一致しません。一方、selection.anchorselection.focus は選択の正確な開始・終了地点を直接参照するため、「境界段落かどうか」の判定に適しています。

旧ロジックが groups.every(...) による全グループの包含チェックで処理をスキップしていたのに対し、新ロジックはより早い段階(段落単位)でスキップ判定を行っています。この変更により、条件分岐がシンプルになり、中間段落の扱いが明示的になりました。

まとめ

selection.getNodes() の返すノード集合がLexicalの選択モデル上の「広い」集合であることを認識し、選択の正確な境界情報を持つ anchor/focus に判定ロジックを切り替えた修正です。ライブラリAPIの仕様を深く理解した上でのピンポイントな対処であり、既存の分割処理のフローを変えずに判定条件のみを置き換えることで、副作用を最小化した設計判断といえます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
0da0f90e

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的な変更、設計判断、まとめが揃っており、「総論→各論→結論」の構成が明確です。ガイドラインを完全に遵守しています。

カスタムMarkdown構文 ✓ PASS

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

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

対象読者への適合性 ✓ PASS

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

Lexicalエディタの内部API(selection.getNodesなど)に関する知識を前提としており、専門知識を持つエンジニアという対象読者に適合しています。

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

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

各段落がトピックセンテンスで始まり、1段落1トピックが守られています。セクション内も総論→各論の構成になっており、非常に可読性が高いです。

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiffの内容を正確に反映しています。削除された行と追加された行のロジックが的確に示されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「ソフトブレーク」「ハードブレーク」「アンカー」「フォーカス」など、PRの文脈に沿った技術用語が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

「selection.getNodes()が部分選択された段落の全子ノードを返す」という問題の根本原因から、アンカー/フォーカスを用いた解決策まで、技術的な説明が正確かつ論理的です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのDescriptionとDiffの内容で裏付けられており、根拠のない推測や情報の創作(ハルシネーション)は見られません。

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

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

PR番号(#885)、テストケースの追加行数(70行)など、記事に含まれる数値や固有名詞はすべて正確です。

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

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

記事のタイトルは、PRのタイトル「Fix quote formatting consuming unselected lines with mixed break types」の内容を的確に要約し、反映しています。

外部知識の正確性 ✓ PASS

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

PR情報に含まれないバージョン情報やサポート状況といった外部知識の追記はなく、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

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

「修正されました」という過去形の表現は、PRが完了したという事実と一致しており、時間表現に歪曲はありません。