複数行選択時のコードブロック適用を単一ブロックに統合

basecamp/lexxy

複数行を選択してコードブロックを適用すると、行ごとに別々のブロックが生成される不具合を修正しました。新たに導入した CodeNodeInserter によって、貼り付けや挿入と同じコードパスでブロック形式の切り替えを処理する設計に整理されています。

背景

従来の実装では、複数行を選択してコードブロックを適用すると、各行に対して個別の <pre> 要素が生成されていました。ユーザーが期待する「選択範囲全体を囲む単一のコードブロック」ではなく、行数分のコードブロックが並ぶ状態になっていたのが根本的な問題です。

この問題は Fizzy card #5029 として報告されており、PR の説明では「ブロック形式の切り替えは、貼り付け・挿入と同じコードパスで処理できる」という新しい設計理論(theory)によって解決されています。コードブロックへの変換を「既存ノードの再配置」として統一的に捉え直すことで、専用の分岐処理を排除しています。

技術的な変更

今回の中心的な変更は、src/editor/contents.jsCodeNodeInserter の概念を導入し、選択中のブロックレベルノードを抽出して新しい CodeNode に再挿入するアプローチへの切り替えです。

インポート側では、@lexical/code から CodeNode クラス自体を、@lexical/rich-text から QuoteNode を追加でインポートしています。また、@lexical/selection から $ensureForwardRangeSelection@lexical/utils から $getNearestBlockElementAncestorOrThrow$getNearestNodeOfType を追加で取り込み、選択範囲の正規化とブロック要素の探索に活用しています。

 import {
-  $createLineBreakNode, $createParagraphNode, $createTextNode, $getNodeByKey, $getRoot, $getSelection,
+  $createLineBreakNode, $createParagraphNode, $createTextNode, $getChildCaretAtIndex, $getNodeByKey, $getRoot, $getSelection,
+  $hasUpdateTag,
   $isElementNode, $isLineBreakNode, $isNodeSelection, $isParagraphNode, $isRangeSelection, $isRootNode, $isRootOrShadowRoot, $isTextNode, $setSelection,
   HISTORY_MERGE_TAG,
   PASTE_TAG
-} from "lexical"
+} from "lexical"

-import { $createCodeNode, $isCodeNode } from "@lexical/code"
-import { $createHeadingNode, $createQuoteNode, $isQuoteNode } from "@lexical/rich-text"
+import { $createCodeNode, $isCodeNode, CodeNode } from "@lexical/code"
+import { $createHeadingNode, $createQuoteNode, $isQuoteNode, QuoteNode } from "@lexical/rich-text"
-import { $forEachSelectedTextNode, $setBlocksType } from "@lexical/selection"
+import { $ensureForwardRangeSelection, $forEachSelectedTextNode, $setBlocksType } from "@lexical/selection"
+import { $getNearestBlockElementAncestorOrThrow, $getNearestNodeOfType } from "@lexical/utils"

insertDOM メソッドでは、PASTE_TAG の判定を tag === PASTE_TAG の引数チェックから $hasUpdateTag(PASTE_TAG) による更新コンテキスト内のタグチェックに変更しています。これにより、貼り付けコンテキストの判定がより堅牢になっています。

src/editor/selection.js では、単一行判定ロジック に追加条件を設けました。アンカーノードとフォーカスノードが属するブロック要素(anchorBlock / focusBlock)を取得し、両者が異なりかつアンカーがトップレベル要素でない場合は単一行ではないと判定するようになっています。

+    // When anchor and focus are in different block-level children of the same
+    // top-level element (e.g. two paragraphs inside a blockquote), this is a
+    // multi-line selection, not a single-line one.
+    const anchorBlock = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParent()
+    const focusBlock = $isElementNode(focusNode) ? focusNode : focusNode.getParent()
+    if (anchorBlock !== focusBlock && anchorBlock !== anchorElement) {
+      return false
+    }

この条件は、blockquote 内に複数の段落がある場合(例:<blockquote><p>Line one</p><p>Line two</p></blockquote>)のような、ネストされたブロック構造での複数行選択を正しく識別するために重要です。

src/editor/contents/uploader.js では、$insertUploadNodes の呼び出しを forEach からスプレッド構文を用いた単一呼び出しに変更しています。

-    this.nodes.forEach(this.contents.insertAtCursor)
+    this.contents.insertAtCursor(...this.nodes)

設計判断

「ブロック形式の切り替えを挿入・貼り付けと同一のコードパスに統一する」 という設計方針が採用されました。

PR の説明にある通り、従来は「フォーマットの切り替え」と「ノードの挿入」が別々の処理として実装されていました。今回の変更では、コードブロックへの変換を「選択ノードを抽出し、新しい CodeNode に再挿入する」操作として定義することで、既存の挿入処理との共通化を図っています。これはトグル解除(コードブロック→段落)においても同様で、CodeNode の内容をテキストで分割して個別の <p> 要素に戻すという対称的な処理になっています。

$hasUpdateTag を使ったコンテキスト判定への変更も、同じ方向性を示しています。コールサイトで tag を引き回す代わりに、Lexical の更新コンテキスト内でタグを参照することで、処理の呼び出し元を問わずに貼り付け判定が機能するようになっています。

まとめ

今回の修正は、単なるバグ修正にとどまらず、ブロック形式の切り替えを挿入・貼り付けと同一の抽象で扱うという設計上の整理を伴っています。CodeNodeInserter の導入と選択範囲の正規化の強化により、blockquote 内の複数段落など複雑なネスト構造においても、意図通りの単一コードブロック生成が保証されます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
69982c2f

この記事は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リンク記法の正確性

ファイル名付きシンタックスハイライト(```diff:filepath```)およびPR番号のリンク記法([#912](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークの内部実装に関する内容であり、専門知識を持つエンジニアという対象読者に適した技術レベルと表現で書かれています。

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

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

各セクションが総論→各論の構成になっており、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。これにより、非常に高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

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

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「CodeNodeInserter」「$hasUpdateTag」「$getNearestBlockElementAncestorOrThrow」など、Lexicalフレームワークに関連する技術用語が文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

変更の理由や実装の詳細に関する説明は、PRのDescriptionやDiffの内容に完全に裏付けられており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(Fizzyカード番号、新しい設計理論など)は、PRのDescriptionやDiffから検証可能であり、ハルシネーション(捏造)は一切見られません。

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

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

PR番号(#912)やIssue番号(Fizzy card #5029)などの数値・固有名詞はすべて正確に記載されています。

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

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

記事のタイトル「複数行選択時のコードブロック適用を単一ブロックに統合」は、PRのタイトル「Fix multi-line selection creating separate code blocks per line」の内容を的確に反映しています。

外部知識の正確性 ✓ PASS

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

PR情報に含まれない外部知識(バージョンのサポート状況、リリース日程など)の追加はなく、記事内容は提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

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

記事内での時間表現は事実に基づいており、PR情報との矛盾や歪曲は見られません。