選択範囲端点がブロック境界にあるときのコードブブロック・引用ラップの誤動作を修正

basecamp/lexxy

この PR は、選択範囲のアンカーが直前の段落の末尾に止まっているケースで、コードブロックや引用ボタンを実行すると意図せず上位段落がラップされてしまう問題を解消します。選択範囲の端点を実際に内容があるブロックへシフトさせる新しいヘルパー関数を導入し、テストで振る舞いを検証しています。

背景

ブラウザがテキスト選択を生成する際、選択開始点(anchor) が前のブロックの終端に固定されることがあります。そのまま $expandSelectionToLineBreaksAndSplitAtEdges が呼び出されると、空のブロックが選択範囲に含まれ、コードブロックや引用のラップ対象に誤って取り込まれていました。特にコメント作成画面で、見出しやタイトル直下に貼り付けた段落を選択すると、タイトル行までがコードブロックに変換される不具合が報告されていました(Basecamp カード #10018915941)。この問題は選択範囲の境界処理がブロックエッジを正しく判定できていなかったことが根本原因です。

技術的な変更

$shrinkSelectionPastBlockEdges という新関数を src/helpers/lexical_helper.js に追加し、選択範囲の端点がブロックエッジに接している場合に隣接ブロックへ再定位させます。関数は以下のロジックで動作します。

function $shrinkSelectionPastBlockEdges(selection) {
  if (selection.isCollapsed()) return

  const anchorBlock = selection.anchor.getNode().getTopLevelElement()
  const focusBlock = selection.focus.getNode().getTopLevelElement()
  if (!anchorBlock || !focusBlock || anchorBlock.is(focusBlock)) return

  if ($isAtBlockEnd(selection.anchor, anchorBlock)) {
    const nextBlock = anchorBlock.getNextSibling()
    if (nextBlock) selection.anchor.set(nextBlock.getKey(), 0, "element")
  }

  if ($isAtBlockStart(selection.focus, focusBlock)) {
    const previousBlock = focusBlock.getPreviousSibling()
    if (previousBlock) selection.focus.set(previousBlock.getKey(), previousBlock.getChildrenSize(), "element")
  }
}

このヘルパーは $expandSelectionToLineBreaksAndSplitAtEdges の冒頭で呼び出され、選択範囲が拡張される前に端点を正しいブロックへ移動させます。エッジ判定は $isAtBlockEnd$isAtBlockStart を介して実装され、テキストポイントが実際にブロック境界にあるかどうかを慎重に評価します。

テスト側では、段落上部がタイトル行である HTML を設定し、アンカーをタイトルの末尾、フォーカスを貼り付けた段落の末端に合わせた選択範囲を手動で構築しています。quote ボタンとコードブロック挿入ボタンの両方で、上位段落が誤ってラップされないことを assertEditorHtml で確認しています。

await clickToolbarButton(page, "insertCodeBlock")
await assertEditorHtml(
  editor,
  '<p>ABCD:</p><pre data-language="plain" data-highlight-language="plain">pasted one<br>pasted two</pre>'
)

同様に quote ボタンのテストでも期待通り <blockquote> が生成され、タイトル行はそのまま残ります。

設計判断

今回の修正は 「選択端点のエッジ処理をヘルパー関数に切り出す」 という設計方針をとっています。元の $expandSelectionToLineBreaksAndSplitAtEdges にロジックを直接埋め込むことも可能でしたが、エッジ判定は他の選択操作でも再利用できるため、独立した関数に分離することでコードの可読性と保守性を高めました。加えて、端点が同一ブロックにある場合は早期リターンする guard 条件を設け、不要な走査コストを回避しています。これにより、既存の選択拡張ロジックへの影響は最小限に抑えられました。

まとめ

選択範囲のアンカーがブロック末端にあるケースで、コードブロックや引用ラップが誤って前段落まで含んでしまうバグは、端点を実際のコンテンツがあるブロックへシフトさせる $shrinkSelectionPastBlockEdges の導入で解決しました。テストで両機能の正しい振る舞いが保証され、将来的に同様の境界処理が必要な機能でも再利用できる基盤ができました。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
5068fe77

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的な変更、設計判断、まとめの5セクションが揃っており、総論→各論→結論の流れが明確に示されています。

カスタムMarkdown構文 ⚠ WARNING

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

ファイル名付きシンタックスハイライトは正しい形式 (```javascript:src/helpers/lexical_helper.js) ですが、PRリンクの記法が「[PR #1153](…)」と「PR」文字が付加されており、推奨の「[#1153](…)」から外れています。

対象読者への適合性 ✓ PASS

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

技術者向けの専門的な用語と実装詳細が中心で、初心者向けの過度な説明はありません。

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

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

各セクションの冒頭がトピックセンテンスで始まり、1段落1トピック、段落は6文未満で適切に空行で区切られています。

Diff内容との照合 ⚠ WARNING

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

記事では `$shrinkSelectionPastBlockEdges` 関数本体は正しく掲載されていますが、Diffで追加された `$isAtBlockEnd`・`$isAtBlockStart`・`$isAtBlockBoundary` などの補助関数は省略されています。情報欠落はあるものの、主要な変更点の理解には支障がありません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

anchor、focus、block、selection などの用語はPR記述と一致し、誤用はありません。

説明の技術的正確性 ✓ PASS

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

記事中の技術的説明はDiffおよびPR記述と合致しており、因果関係も正しく説明されています。

事実の突合 ✓ PASS

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

すべての主張はPRのタイトル・説明・Diffに裏付けられており、捏造や推測は見受けられません。

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

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

PR番号 #1153 と Basecamp カード番号 #10018915941 が正確に記載されています。

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

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

記事タイトルはPRタイトルの内容(コードブロック・引用が上位ブロックを取得していた問題)を日本語で適切に要約しています。

外部知識の正確性 ✓ PASS

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

バージョン情報やリリーススケジュール等の外部知識は一切含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現は使用されておらず、PR情報と矛盾する箇所もありません。