クォートブロックへのペースト処理を修正:`QuoteNodeInserter` の削除でデフォルト挙動に委ねる

basecamp/lexxy

クォートブロックへのペースト時に発生していた「カーソル位置の無視」「エディタの応答不能」「余分な空行の挿入」という3つのバグを、カスタム実装の削除というシンプルな方法で解消しました。

背景

クォートブロックへのペーストには、互いに関連する3つのバグが存在していました。いずれも QuoteNodeInserter というカスタム実装が根本原因でした。

具体的には、以下の問題が報告されていました:

  • カーソル位置の無視: ペーストしたテキストがカーソル位置ではなくクォートの末尾に挿入される
  • エディタの応答不能: 空のクォートブロックにHTMLをペーストすると壊れた構造が生成され、エディタが操作不能になる
  • 先頭への空行挿入: ペーストコンテンツの前に余分な空行が挿入される

これらはすべて、QuoteNodeInserter.insertNodesselection.focus.getNode() に対して insertAfter を呼び出していたことに起因します。この方法ではテキストノード内のカーソルオフセットが考慮されず、フォーカスノードの後ろに無条件で挿入されていました。

技術的な変更

修正内容は src/editor/contents/node_inserter.js から QuoteNodeInserter クラス全体を削除するというものです。追加コードはゼロで、削除のみの変更です。

削除されたクラス:

// Lexical will split a QuoteNode when inserting other Elements - we want them simply inserted as-is
class QuoteNodeInserter extends NodeInserter {
  static handles(selection) {
    return $getNearestNodeOfType(selection.anchor?.getNode(), QuoteNode)
  }

  insertNodes(nodes) {
    if (!this.selection.isCollapsed()) { this.selection.removeText() }

    $ensureForwardRangeSelection(this.selection)
    let lastNode = this.selection.focus.getNode()
    for (const node of nodes) {
      lastNode = lastNode.insertAfter(node)
    }

    lastNode.selectEnd()
  }
}

NodeInserter.for() のインサーター選択リストからも QuoteNodeInserter のエントリが除去され、クォートブロック内でのペーストはLexicalのデフォルト処理に委ねられます。あわせて QuoteNode のimportも不要になったため削除されています。

また、test/browser/tests/paste/paste_into_quote.test.js が新規追加されています。テストはPlaywrightを使ったブラウザテストで、カーソル位置へのテキスト挿入・HTMLペースト時のエディタ応答確認を実機相当の環境で検証します。

// Place cursor after "Hello" using Home then 5x ArrowRight
await editor.send("Home")
for (let i = 0; i < 5; i++) await editor.send("ArrowRight")
await editor.flush()

await editor.paste("INSERTED", { html: "<span>INSERTED</span>" })
await editor.flush()

// The pasted text should appear at cursor position, not at the end
await assertEditorHtml(editor, "<blockquote><p>HelloINSERTED World</p></blockquote>")

テストは Home キーと ArrowRight でカーソルを文字単位で移動させ、ペースト後のHTML構造を直接アサートすることで、カーソル位置の精度を実証しています。

設計判断

カスタム実装を削除してLexicalのデフォルト挙動に委ねるという方針が採用されました。

PRの説明によると、当初は $splitAtPointCaret を使った手動修正も検討されました。しかし最終的にはカスタムハンドラ自体を除去することで全テストが通過したため、Lexicalの標準実装に処理を委ねる方向が選ばれています。QuoteNodeInserter はもともと「LexicalがQuoteNodeを挿入時に分割してしまう」問題への対処として実装されたものでしたが、その実装自体がカーソルオフセットを無視するという新たな問題を生んでいました。

カスタム処理の削除はコードの複雑性を減らし、Lexical本体のバグ修正や改善の恩恵を自動的に受けられる状態に戻すという点でも合理的な判断です。

まとめ

QuoteNodeInserter という21行のカスタム実装を削除することで、3つの連鎖するバグが一度に解消されました。「フレームワークのデフォルト挙動を上書きするコードが、そのフレームワーク本来の能力(カーソルオフセットの考慮)を損なっていた」という構図は、カスタム処理の必要性を定期的に見直すことの重要性を示しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
368d0f41

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

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

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークの内部実装に関する内容であり、専門知識を持つエンジニアという対象読者に完全に適合しています。

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

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

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

Diff内容との照合 ✓ PASS

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

削除されたクラスのコード引用、追加されたテストコードの抜粋ともに、提供されたDiff情報を正確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「QuoteNodeInserter」「$splitAtPointCaret」など、PRのコンテキストに登場する技術用語を正確に使用しています。

説明の技術的正確性 ✓ PASS

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

バグの原因が「insertAfterがカーソルオフセットを無視するため」であるという説明は、PR Descriptionの記述と一致しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(3つのバグ、原因、修正方法、当初の検討案)がPR DescriptionやDiffによって裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#1012)や削除されたコードの行数(21行)など、記事に含まれる数値や固有名詞はすべて正確です。

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

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

記事のタイトルはPRの主題を的確に要約し、さらに「どのように解決したか」という具体的な実装にまで踏み込んでおり、内容と完全に一致しています。

外部知識の正確性 ✓ PASS

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

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

時間表現の正確性 ✓ PASS

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

PR Descriptionの取り消し線で表現された「当初の検討」という過去の経緯を正確に捉えており、時間表現に歪曲はありません。