複数行選択時のコードブロック適用を単一ブロックに統合
複数行を選択してコードブロックを適用すると、行ごとに別々のブロックが生成される不具合を修正しました。新たに導入した CodeNodeInserter によって、貼り付けや挿入と同じコードパスでブロック形式の切り替えを処理する設計に整理されています。
背景
従来の実装では、複数行を選択してコードブロックを適用すると、各行に対して個別の <pre> 要素が生成されていました。ユーザーが期待する「選択範囲全体を囲む単一のコードブロック」ではなく、行数分のコードブロックが並ぶ状態になっていたのが根本的な問題です。
この問題は Fizzy card #5029 として報告されており、PR の説明では「ブロック形式の切り替えは、貼り付け・挿入と同じコードパスで処理できる」という新しい設計理論(theory)によって解決されています。コードブロックへの変換を「既存ノードの再配置」として統一的に捉え直すことで、専用の分岐処理を排除しています。
技術的な変更
今回の中心的な変更は、src/editor/contents.js に CodeNodeInserter の概念を導入し、選択中のブロックレベルノードを抽出して新しい 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 内の複数段落など複雑なネスト構造においても、意図通りの単一コードブロック生成が保証されます。