Shift+Enter で挿入したソフト改行の選択行のみにブロック引用を適用する修正

basecamp/lexxy

ブロック引用コマンドがソフト改行(Shift+Enter)を含む段落全体を対象にしていたバグを修正しました。選択した視覚的行のみをパラグラフから分割して引用ブロックに変換することで、期待通りの部分的な引用が可能になります。

背景

Shift+Enter によるソフト改行は、エディタ上では複数行に見えていても、内部的には単一の段落ノード(ParagraphNode)の中に LineBreakNode を挟んだ構造として表現されます。Lexxy のブロック引用コマンドは段落単位でノードラッピングを行う toggleNodeWrappingAllSelectedNodes を呼び出していたため、選択範囲が中間行だけであっても段落全体がブロック引用に変換されてしまっていました。

たとえば First line<br>Second line<br>Third line という段落で「Second line」だけを選択してから引用ボタンを押すと、3行すべてがブロック引用になるという問題です。期待される動作は <p>First line</p><blockquote><p>Second line</p></blockquote><p>Third line</p> のように、選択行だけが独立した引用ブロックになることです。

技術的な変更

wrapSelectedSoftBreakLines メソッドを新設 し、引用コマンドの実行前にソフト改行を含む段落の部分選択ケースを検出・処理するロジックを追加しました。このメソッドが true を返した場合(=ソフト改行の部分選択が処理された場合)に限り、従来の toggleNodeWrappingAllSelectedNodes の呼び出しをスキップします。

src/editor/command_dispatcher.js の変更は次の通りです:

変更前:

dispatchInsertQuoteBlock() {
  this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode())
}

変更後:

dispatchInsertQuoteBlock() {
  if (!this.contents.wrapSelectedSoftBreakLines(() => $createQuoteNode())) {
    this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode())
  }
}

src/editor/contents.js に追加された wrapSelectedSoftBreakLines は、読み取りフェーズと更新フェーズの2段階で動作します。まず editor.getEditorState().read() の中で、選択がソフト改行を含む段落内の部分選択かどうかを判定し、対象の段落キーと選択行範囲(selectedLineRange)を取得します。段落全体が選択されている場合(start === 0 && end === lines.length - 1)はこのメソッドの処理対象外として false を返し、従来の全体ラッピング処理に委ねます。

wrapSelectedSoftBreakLines(newNodeFn) {
  let paragraphKey = null
  let selectedLineRange = null

  this.editor.getEditorState().read(() => {
    const selection = $getSelection()
    if (!$isRangeSelection(selection) || selection.isCollapsed()) return

    const paragraph = this.#getSelectedParagraphWithSoftLineBreaks(selection)
    if (!paragraph) return

    const lines = this.#splitParagraphIntoLines(paragraph)
    selectedLineRange = this.#getSelectedLineRange(lines, selection)

    if (!selectedLineRange) return

    const { start, end } = selectedLineRange
    if (start === 0 && end === lines.length - 1) return

    paragraphKey = paragraph.getKey()
  })

  if (!paragraphKey || !selectedLineRange) return false

  this.editor.update(() => {
    const paragraph = $getNodeByKey(paragraphKey)
    if (!paragraph || !$isParagraphNode(paragraph)) return

    const lines = this.#splitParagraphIntoLines(paragraph)
    this.#replaceParagraphWithWrappedSelectedLines(paragraph, lines, selectedLineRange, newNodeFn)
  })

  return true
}

読み取りフェーズで条件を満たした場合のみ editor.update() を実行し、 #replaceParagraphWithWrappedSelectedLines によって段落を選択行・非選択行に分割し、選択行のみを引用ノードでラップして置換します。読み取りと更新を分離した2フェーズ設計は、Lexical の推奨パターンに沿ったものです。

設計判断

既存の toggleNodeWrappingAllSelectedNodes を置き換えず、前段のガード処理として新メソッドを追加する方式 が採用されました。ソフト改行を含む部分選択という特殊ケースだけを先に処理し、それ以外(選択範囲が複数段落にまたがる場合、段落全体が選択されている場合、ソフト改行を含まない段落の場合)はそのまま既存の汎用処理に流れます。これにより、既存の引用トグルロジックへの影響を最小限に抑えながら、バグを修正しています。

また、 #splitParagraphIntoLines#getSelectedLineRange をプライベートメソッドとして抽象化しているため、同種のソフト改行対応が将来他のフォーマットコマンド(太字・斜体など)に必要になった際にも再利用できる構造になっています。

テストは test/browser/tests/toolbar.test.js<p>First line<br>Second line<br>Third line</p> のセットアップで追加されており、引用対象の行分割後の HTML 構造を直接アサートすることでリグレッションを防ぎます。

まとめ

本PRは、LineBreakNode による「見た目上の改行」と「段落の区切り」の非対称性から生じるフォーマット操作のバグを、段落の事前分割によって解消した変更です。ガード処理として新メソッドを前段に挿入するアプローチにより、既存のノードラッピング処理の汎用性を損なわずに特殊ケースに対応した設計は、同種の問題への対処パターンとして参考になります。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
a6f7a274

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

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークの内部実装に関する内容であり、専門用語の使用も適切で、対象読者であるエンジニアに適した技術レベルです。

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

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

各セクションが総論→各論の構造を持ち、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロック(`src/editor/command_dispatcher.js`, `src/editor/contents.js`)は、提供されたDiffの内容と正確に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`LineBreakNode`, `ParagraphNode`, `toggleNodeWrappingAllSelectedNodes`など、Lexicalフレームワークに関する技術用語が文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

新設された`wrapSelectedSoftBreakLines`がガード処理として機能する仕組みや、読み取りと更新の2フェーズで動作する点など、技術的な説明はコードの変更内容と整合しており、正確です。

事実の突合 ⚠ WARNING

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

「設計判断」セクションにおける「将来他のフォーマットコマンドに必要になった際にも再利用できる構造」という記述は、PR情報には明記されておらず、コード構造からの推測に基づいています。

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

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

PR番号(#828)が正確に記載・リンクされています。

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

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

記事のタイトルは、PRのタイトル「Fix #4687: Quote only selected soft-break lines」の内容を的確に要約しており、主題の齟齬はありません。

外部知識の正確性 ✓ PASS

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

バージョンサポートやリリース日程など、PR情報に基づかない外部知識の追記はなく、事実に基づいた記述がされています。

時間表現の正確性 ✓ PASS

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

「〜を修正しました」「〜を追加しました」といった過去形・完了形の表現が使われており、PRが過去の変更であるという時間軸と一致しています。