`>` マークダウンショートカット経由で作成した引用ブロックへの複数行ペーストを修正

basecamp/lexxy

> + スペースのマークダウンショートカットで作成した QuoteNodeParagraphNode を持たない構造になるため、複数行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段落目が引用外に出ていました。

技術的な変更

FormatEscapeExtensionQuoteNode のノードトランスフォームを登録することで、マークダウンショートカット生成直後の不正な構造を正規化するアプローチが採用されました。

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」に限定することで、既存の動作を変えずにバグを局所的に解消している点が本変更の核心です。

記事メタデータ

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

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)、背景・技術的な変更・設計判断(各論)、まとめ(結論)の3部構成が明確に適用されており、理想的な記事構成です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト、GitHubのPR番号リンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークの内部構造に言及するなど、専門知識を持つエンジニア向けに適切なレベルの内容となっています。

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

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

各セクション、各パラグラフが総論から始まる構成になっており、トピックセンテンスが明確です。1段落1トピックの原則も守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

Diffからコードブロックが正確に引用されており、ファイル名も一致しています。変更点が明確にわかるようになっています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`QuoteNode`, `ParagraphNode`, `ノードトランスフォーム`といったLexicalの専門用語が文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

バグの原因であるノード構造の違いと、それをノードトランスフォームで解決するという説明は技術的に正確で、コードの変更内容と完全に一致しています。

事実の突合 ✓ PASS

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

記事内のすべての主張(バグの挙動、修正アプローチ、設計判断の背景)が、PRのDescription、Diff、Test planで裏付けられており、ハルシネーションは一切ありません。

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

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

PR番号(#1023)が正確に記載され、リンクも正しく設定されています。

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

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

記事のタイトルはPRのタイトル「Fix paste into quote started with `>` markdown shortcut」の内容を正確に和訳・要約しており、主題と完全に一致しています。

外部知識の正確性 ✓ PASS

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

PRで言及されていないバージョン情報やサポート状況などの外部知識は含まれておらず、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

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

バグが存在していた状況を「存在していました」と過去形で表現するなど、時間表現が正確です。