`>` マークダウンショートカット経由で作成した引用ブロックへの複数行ペーストを修正
> + スペースのマークダウンショートカットで作成した QuoteNode は ParagraphNode を持たない構造になるため、複数行HTMLのペースト時に引用外へ脱出するバグが存在していました。#1023 では、QuoteNode のノードトランスフォームを登録することでこの問題を根本から解決しています。
背景
> + スペースのマークダウンショートカットと、ツールバーの Quote ボタンとでは、生成される QuoteNode の内部構造が異なっていました。ツールバーボタンは既存の段落を QuoteNode でラップするため、結果として QuoteNode > ParagraphNode という正規の構造になります。一方、マークダウンショートカットは QuoteNode の直下に LineBreakNode だけを配置した構造を生成していました。
Lexical のペースト処理はブロックレベルの子ノード(ParagraphNode など)を期待しており、QuoteNode 直下に LineBreakNode しかない状態でHTMLをペーストすると、最初の段落だけが引用ブロック内に入り、2段落目以降が引用外に脱出してしまう現象が発生します。テストプランによれば、バグのある状態では <p>First paragraph</p><p>Second paragraph</p> をペーストした結果が <blockquote>First paragraph</blockquote><p>Second paragraph</p> になり、1段落目の <p> ラッパーが失われ、2段落目が引用外に出ていました。
技術的な変更
FormatEscapeExtension に QuoteNode のノードトランスフォームを登録することで、マークダウンショートカット生成直後の不正な構造を正規化するアプローチが採用されました。
src/extensions/format_escape_extension.js では、mergeRegister 内に editor.registerNodeTransform(QuoteNode, $ensureQuoteHasParagraphChild) の1行が追加されています。
変更前:
import { $isQuoteNode } from "@lexical/rich-text"
import { $isBlankNode, $isCursorOnLastLine, $trimTrailingBlankNodes } from "../helpers/lexical_helper"
// ...
KEY_ARROW_DOWN_COMMAND,
(event) => $handleArrowDownInCodeBlock(event),
COMMAND_PRIORITY_NORMAL
)
変更後:
import { $isQuoteNode, QuoteNode } from "@lexical/rich-text"
import { $containsRangeSelection, $isBlankNode, $isCursorOnLastLine, $trimTrailingBlankNodes } from "../helpers/lexical_helper"
// ...
KEY_ARROW_DOWN_COMMAND,
(event) => $handleArrowDownInCodeBlock(event),
COMMAND_PRIORITY_NORMAL
),
editor.registerNodeTransform(QuoteNode, $ensureQuoteHasParagraphChild)
トランスフォーム関数 $ensureQuoteHasParagraphChild の実装は次のとおりです。
function $ensureQuoteHasParagraphChild(quoteNode) {
if (!quoteNode.isEmpty()) return
quoteNode.append($createParagraphNode())
if ($containsRangeSelection(quoteNode)) quoteNode.getFirstChild().select()
}
quoteNode.isEmpty() が false の場合、つまり既にいずれかの子ノードを持つ場合はトランスフォームを即座にスキップします。これにより、ツールバーボタン経由や既存の段落を持つ引用ブロックには影響しません。空の QuoteNode に対してのみ ParagraphNode を追加し、カーソルが引用ルートにある場合は追加した ParagraphNode 内へカーソルを移動させます。
src/helpers/lexical_helper.js には、カーソルが特定ノードの内部にあるかを判定する $containsRangeSelection ヘルパーが追加されました。
export function $containsRangeSelection(node, selection = $getSelection()) {
if ($isRangeSelection(selection)) {
const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode())
return $findMatchingParent(commonAncestor, parent => parent.is(node))
} else {
return false
}
}
フォーカスノードとアンカーノードの共通祖先から上方向に $findMatchingParent で走査し、指定ノードが祖先に存在するかを確認します。セレクションが RangeSelection でない場合は false を返す安全なフォールバック付きです。
設計判断
ペースト処理側ではなく QuoteNode のノードトランスフォームとして修正を実装する方式が選ばれています。
ペースト時のハンドラーで分岐を追加する方法も考えられますが、ノードトランスフォームを使うことで「QuoteNode が空の状態で存在してはならない」という不変条件をデータレイヤーで保証できます。ノードが生成・更新されるたびにトランスフォームが自動的に評価されるため、ペースト以外の経路(Undo/Redo、プログラム的な挿入など)でも同じ保護が働きます。
isEmpty() によるガード条件は、ツールバーボタン経由の引用ブロック(すでに ParagraphNode を持つ)には一切手を加えないことを保証しており、既存テストへの影響をゼロに抑えています。また、Playwright テストではトランスフォームの適用を expect.poll で待機してからペーストを実行しており、トランスフォームとペースト処理の競合状態を明示的に回避しています。
まとめ
マークダウンショートカットが生成する不正な QuoteNode 構造を、ノードトランスフォームによって宣言的に正規化することで、ペースト処理の前提条件を保証する設計になっています。修正の適用範囲を「空の QuoteNode」に限定することで、既存の動作を変えずにバグを局所的に解消している点が本変更の核心です。