HTMLペーストでプレースホルダーアンカーを除去し、実リンクと@メンションを正確に保持

basecamp/lexxy

レンダリング済みコンテンツからコピー&ペーストする際に、<a href="#"><a href=""> として実装されているプレースホルダーアンカーをプレーンテキストに変換しつつ、意味のある実URLを持つリンクは維持するようになりました。

背景

編集状態でない通常の表示画面(レンダリング済みビュー)からコンテンツをコピーすると、@メンションやインタラクティブ要素が <a href="#"> タグでラップされた状態でクリップボードに入ります。これをエディタにペーストすると、href="#" を持つ空のリンクノードが生成され、表示と構造が意図しない状態になるという問題がありました(Fizzy card #3785)。

href="#"href="" は、JavaScriptで動作するUI要素(メンションのポップアップ表示など)のためにHTMLの慣例として使われるプレースホルダーです。こうした要素は「リンク」ではなくテキストコンテンツとしてペーストされるべきですが、これまでのペースト処理ではアンカータグの有無だけでリンクノードへの変換が判定されていたため、プレースホルダーも実リンクも区別なく処理されていました。

技術的な変更

src/editor/contents.js#unwrapPlaceholderAnchors プライベートメソッドが追加され、insertDOM の冒頭でDOMを操作する前に呼ばれるようになりました。

変更前:

insertDOM(doc, { tag } = {}) {
  this.editor.update(() => {
    const selection = $getSelection()
    if (!$isRangeSelection(selection)) return

変更後:

insertDOM(doc, { tag } = {}) {
  this.#unwrapPlaceholderAnchors(doc)

  this.editor.update(() => {
    const selection = $getSelection()
    if (!$isRangeSelection(selection)) return

追加されたメソッドの実装は以下のとおりです。

#unwrapPlaceholderAnchors(doc) {
  for (const anchor of doc.querySelectorAll("a")) {
    const href = anchor.getAttribute("href") || ""
    if (href === "" || href === "#") {
      anchor.replaceWith(...anchor.childNodes)
    }
  }
}

querySelectorAll("a") で全アンカーを走査し、href が空文字または "#" の場合のみ replaceWith(...anchor.childNodes) で自身をその子ノード群に置き換えます。これにより、@Jorge のようなテキストコンテンツはそのまま保持されつつ、アンカータグだけが除去されます。href に実URLを持つアンカーはこの条件に合致しないため、リンクノードとして変換される従来の処理へとそのまま流れます。

テストは test/browser/tests/paste/links.test.js に3ケースが追加されました。実URLのみ、href="#" のみ、そして両者が混在するケースをそれぞれカバーし、混在ケースでは実リンクが保持されつつプレースホルダーがプレーンテキスト化されることを確認しています。

設計判断

Lexical(エディタフレームワーク)のノード変換処理に入力するDOM自体を前処理する方式 が採用されました。

Lexicalのペースト処理はDOMをノードツリーに変換しますが、この変換ロジックを変更するのではなく、変換前のDOMを正規化するアプローチが取られています。insertDOM の冒頭でプレースホルダーアンカーを除去することで、Lexical側の変換ロジックからは「最初からアンカーが存在しなかった」状態に見えます。これにより、フレームワーク内部のリンク変換ロジックに手を加えることなく問題を解決しています。

また、getAttribute("href") || "" という記述により、href 属性が存在しない場合も同一の条件式で処理できます。プレースホルダーとして扱う条件を """#" の2値に限定することで、誤って実リンクを除去するリスクを最小化しています。

まとめ

この変更は、レンダリング済みビューとエディタの間でコンテンツをやり取りする際のインピーダンスミスマッチを、DOM前処理というシンプルな手法で解消しています。フレームワークの変換パイプラインに触れず、入力の正規化だけで問題を解決した点は、変更の局所性と保守性の観点で優れた設計判断です。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
21ddb50d

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

ファイル名付きシンタックスハイライト(`javascript:src/editor/contents.js`)およびPR・外部チケットへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

DOM操作やエディタフレームワークに関する知識を前提としており、専門知識を持つエンジニアという対象読者に適合した内容です。

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

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

各セクションが総論→各論の構成になっており、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。段落の長さも適切で、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiffの内容を正確に反映しています。メソッドの追加箇所や実装内容がDiffと完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「プレースホルダーアンカー」「DOM前処理」「ノード変換」「インピーダンスミスマッチ」など、技術用語が文脈に応じて正確かつ効果的に使用されています。

説明の技術的正確性 ✓ PASS

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

「DOMを前処理することでLexicalのコアロジックに触れずに問題を解決した」という説明は、Diffのコード構造から導かれる正確な分析であり、技術的に妥当です。

事実の突合 ✓ PASS

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

記事内のすべての主張(問題の背景、修正内容、テスト追加など)は、PRのDescriptionやDiff内のコード・コメントで裏付けられており、ハルシネーションは見られません。

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

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

PR番号(#841)やFizzyカード番号(#3785)などの固有名詞・数値が正確に記載されています。

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

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

タイトルは「プレースホルダーアンカーの除去」と「実リンクの保持」という変更の核心を的確に表現しており、PRの内容と完全に一致しています。

外部知識の正確性 ✓ PASS

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

記事に含まれる情報はすべてPR情報(Description, Diff, コメント)に基づいており、PRに記載のないバージョン情報やサポート状況などの外部知識の捏造はありません。

時間表現の正確性 ✓ PASS

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

「〜するようになりました」という表現は、マージ済みの変更を説明する上で適切であり、時間表現の歪曲はありません。