貼り付け時のリストから不正子ノードを除去して番号ずれを防止

basecamp/lexxy

Lexxy エディタでは、HelpScout などからコピーした番号付きリストを貼り付けた際に、余分な先頭の "1." が生成されて番号がずれる問題を修正しました。 stray <br> やテキストノードが <ol>/<ul> の直接子として残ることが原因です。今回の変更により、貼り付け HTML のクリーンアップが標準化され、リストの正しい番号付けが保証されます。

背景

貼り付けた HTML に <ol> の直下に <br> や空白テキストが混在すると、Lexical の $generateNodesFromDOM がそれらを空の <li value="1"><br></li> に変換し、余分なリスト項目が生成されます。これによりユーザーは本来の「1.」が二重に表示され、文書の可読性が低下します。問題は HelpScout など外部ツールが生成する HTML の構造に起因し、エディタ側での対策が必要でした。

技術的な変更

Contents#formatPastedDOM は従来の内部ヘルパー (#unwrapPlaceholderAnchors#stripTableCellColorStyles) の呼び出しをやめ、PastedContentFormatter に処理を委譲するよう変更されました。新しいインスタンスを生成し format() を実行するだけで、貼り付け DOM の正規化が完結します。

@@
-    this.#unwrapPlaceholderAnchors(doc)
-    this.#stripTableCellColorStyles(doc)
+    new PastedContentFormatter(doc).format()

PastedContentFormatter は 3 つのプライベートメソッドを持ち、順にプレースホルダーアンカーの展開、テーブルセルのインラインカラー除去、リストの stray 子ノード除去を行います。各メソッドは対象要素を走査し、条件に合致しないノードを安全に削除します。特に #stripStrayListChildren<ol>/<ul> の子ノードを走査し、<li> 以外をすべて removeChild で除去します。

@@
-  #stripStrayListChildren() {
-    for (const list of this.doc.querySelectorAll("ol, ul")) {
-      for (const child of Array.from(list.childNodes)) {
-        if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") continue
-        list.removeChild(child)
-      }
-    }
-  }
+  #stripStrayListChildren() {
+    for (const list of this.doc.querySelectorAll("ol, ul")) {
+      for (const child of Array.from(list.childNodes)) {
+        if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") continue
+        list.removeChild(child)
+      }
+    }
+  }

追加されたテスト clean_invalid_pasted_list_html.test.js では、<ol><ul> に対し stray <br> とテキストノードが混在した HTML を貼り付け、結果のエディタ HTML が余分な <li> を含まないことを検証しています。テストは失敗していた main ブランチの挙動と、修正後の期待結果を明示的に比較しています。

@@
-    await assertEditorHtml(
-      editor,
-      '<ol><li value="1">First item</li><li value="2">Second item</li><li value="3">Third item</li></ol>',
-    )
+    await assertEditorHtml(
+      editor,
+      '<ol><li value="1">First item</li><li value="2">Second item</li><li value="3">Third item</li></ol>',
+    )

設計判断

処理の集中化 を目的に新たに PastedContentFormatter クラスを導入した点が本変更の核となります。従来は Contents に散在していたクリーニングロジックを一箇所に集約することで、将来的な拡張やテストの追加が容易になる設計です。クラス化によりプライベートメソッドがカプセル化され、外部から直接触れられない安全な実装となります。

また、既存機能との互換性 を保つために元のアンカー展開やテーブルカラー除去ロジックをそのまま移植し、#stripStrayListChildren だけを新規に加えました。これにより、現行エディタの挙動は変わらず、リスト番号ずれの問題のみが解消されます。変更範囲が限定的であることは、リグレッションリスクの低減につながります。

まとめ

今回の PR は、貼り付け時に生成される不正なリスト子ノードを除去することで、番号ずれを防止する重要なバグ修正です。処理を PastedContentFormatter に統合したことでコードの可読性と保守性が向上し、テストで期待通りの動作が確認されています。エディタ利用者はヘルプデスクツールからのコピー貼り付けでも正しいリスト番号を得られるようになりました。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
84288ae5

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的な変更、設計判断(任意)およびまとめが揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きシンタックスハイライトは正しい形式です。ただし PR リンクが "[PR #1119](URL)" となっており、#1119 だけのリンク形式ではないため軽微な違反としました。

対象読者への適合性 ✓ PASS

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

エンジニア向けに適切なレベルで記述されており、初心者向けの過剰説明はありません。

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

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

各セクションは総論パラグラフ→各論→結論の構成で、段落はトピックセンテンスで始まり、1段落1トピック、長さも適切です。

Diff内容との照合 ⚠ WARNING

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

コード例は Diff 内容と概ね一致していますが、Diff の表記が「削除/追加が同一内容」となっており、実際には新規ファイル追加です。この表記のずれは注意が必要です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語は PR と一致し、誤用はありません。

説明の技術的正確性 ✓ PASS

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

技術的な説明は PR の問題点・根本原因・修正内容と整合しており、誤りは見当たりません。

事実の突合 ✓ PASS

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

記事内の主張はすべて PR の記述や Diff で裏付けられており、捏造や推測はありません。

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

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

PR 番号 #1119 が正しく記載されており、他の数値や固有名詞の誤りはありません。

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

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

記事タイトルは PR タイトル「Strip invalid child nodes from pasted lists」の意味を日本語で正確に伝えています。

外部知識の正確性 ✓ PASS

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

バージョンサポートやリリース情報等、PR に記載されていない外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PR の記述と一致しています。