ソフト改行を段落境界として扱うブロックツールの改善

basecamp/lexxy

ツールバーのブロック切り替えツール(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> のすべての行がそれぞれ個別のリストアイテムになります。

全ブロックツールへの適用

$expandSelectionToLineBreaksAndSplitAtEdgessrc/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 が担当することで、各関数の責務が明確になっています。

旧実装の $splitParagraphsAtLineBreakBoundariessrc/helpers/lexical_helper.js から削除されており(147行追加・20行削除)、$splitNode への依存も除かれています。Lexical の Caret API を活用することでより多様な選択状態に対応できるようになりましたが、これはLexicalが提供するCaret APIへの依存度が高まることも意味します。

まとめ

この変更は、<br>を含む段落でのブロック変換において「選択した行だけを変換し、残りを保持する」という直感的な動作を全ブロックツールに統一的に実装したものです。Lexical の Caret API を活用した前処理パターンにより、既存のブロック変換ロジックへの影響を最小限に抑えながら、段落オフセット選択を含む26種類のエッジケースに対応しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
28d0fa31

この記事は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番号のリンク記法([PR #1049](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalエディタの内部APIや実装の詳細に踏み込んでおり、専門知識を持つエンジニアという対象読者に完全に適合しています。

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

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

セクション内・パラグラフ内ともに構成が論理的です。各段落がトピックセンテンスで始まり、1段落1トピックが守られているため、非常に高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

記事内で引用されているimport文の変更、新関数のコードブロック、各メソッドへの関数追加箇所は、すべて提供されたDiff情報と正確に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

LexicalのAPI名($caretFromPointなど)、関数名($expandSelectionToLineBreaksAndSplitAtEdgesなど)、概念(段落オフセット)などの技術用語が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

関数の置き換え、リストツールにおける二段階処理の仕組み、テストの検証方法など、コード変更の背景にある技術的なロジックが正確に説明されています。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescriptionやDiffで裏付けられています。特にテストの追加行数や検証ケース数などの具体的な数値もPR情報と一致しており、ハルシネーションは見られません。

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

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

PR番号(#1049)、テストの追加行数(212行)、検証ケース数(26種類)、Diffの変更行数(147行追加・20行削除)など、記事内のすべての数値と固有名詞は正確です。

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

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

記事タイトル「ソフト改行を段落境界として扱うブロックツールの改善」は、PRのタイトル「Improve soft-linebreak handling in toolbar tools」の内容を的確に日本語で表現しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に完全に準拠しており、バージョン情報やリリース予定といったPR外の知識の持ち込みはありません。

時間表現の正確性 ✓ PASS

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

「〜するようになりました」のように過去形の表現が適切に使われており、変更が既に完了した事実を正確に伝えています。時間表現の歪曲はありません。