ペーストされたdata-URI画像をアップロードノードへ自動変換

basecamp/lexxy

Google Docsなどからリッチテキストをペーストすると、<img src="data:image/png;base64,..."> 形式のインライン画像が混入し、保存されたHTMLを肥大化させる問題がありました。本PRでは、ペースト時にこれらのdata-URI画像を検出し、通常のファイルアップロードパイプライン(Active Storage)へ自動的に変換する機能を実装しています。

背景

リッチテキストエディタのペースト処理において、data-URI形式のインライン画像はドキュメントサイズを著しく肥大化させる問題を引き起こします。Google Docsなど多くのオフィスアプリケーションは、コンテンツのコピー時に画像をbase64エンコードしてHTMLに直接埋め込みます。これがエディタにペーストされると、base64ペイロードがそのまま保存HTMLに永続化され、ドキュメントのストレージコストが増大します。

PR本文では、同様の問題をTrixエディタ向けに実装した Basecampの過去の取り組み を参照しており、Lexxyでも同等の保護機構が必要であると判断されたことがわかります。

技術的な変更

本PRの中心となる変更は、新規ファイル src/helpers/inline_image_uri_helper.js の追加と、ノード生成処理の責務の再編成です。

$convertInlineImageDataURIs 関数が新設され、ペースト時にのみ呼び出されます。処理の流れは以下の通りです:

  1. トップレベルノードを走査し、data:image/...;base64, にマッチする ActionTextAttachmentNode を検出
  2. base64データをデコードして File オブジェクトを生成
  3. キャンセル可能な lexxy:file-accept イベントを発火し、ホスト側のファイル許可リストによるフィルタリングを実施
  4. 受理された場合は ActionTextAttachmentUploadNode に置換し、通常のActive Storageアップロードフローへ流す
  5. 拒否された場合はノードを静かに除去

また、$generateFilteredNodesFromDOM というヘルパー関数が廃止され、LexicalEditorElement のメソッド $generateNodesFromDOM に統合されました。

変更前:

export function $generateFilteredNodesFromDOM(editorElement, doc) {
  const nodes = $generateNodesFromDOM(editorElement.editor, doc)
  return filterDisallowedAttachmentNodes(nodes, editorElement)
}

変更後:

$generateNodesFromDOM(doc) {
  let nodes = $generateLexicalNodesFromDOM(this.editor, doc)
  if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this)
  return filterDisallowedAttachmentNodes(nodes, this)
}

PASTE_TAG の有無を確認することで、ペースト操作時のみdata-URI変換が行われ、HTMLのロード時(setValue 経由など)には変換が実行されないことを保証しています。この挙動は追加されたテスト "preserves inline image data URIs untouched (no paste-time conversion)" で明示的に検証されています。

さらに、アップロードノードの生成ロジックが $createUploadNode として Contents クラスのメソッドに抽出されました。

変更前(Uploader クラス内に散在):

$createUploadNodes() {
  this.nodes = this.files.map(file =>
    $createActionTextAttachmentUploadNode({
      ...this.#nodeUrlProperties,
      file: file,
      contentType: file.type
    })
  )
}

get #nodeUrlProperties() {
  return {
    uploadUrl: this.editorElement.directUploadUrl,
    blobUrlTemplate: this.editorElement.blobUrlTemplate
  }
}

変更後(Contents クラスに集約):

$createUploadNode(file) {
  return $createActionTextAttachmentUploadNode({
    file,
    uploadUrl: this.editorElement.directUploadUrl,
    blobUrlTemplate: this.editorElement.blobUrlTemplate,
    contentType: file.type,
  })
}

この変更により Uploader クラスは this.contents.$createUploadNode(file) を呼び出すだけになり、URLプロパティの取得ロジックが一箇所に集約されました。

設計判断

ペースト操作のみに変換を限定する 設計が採用されています。$hasUpdateTag(PASTE_TAG) によるガードにより、HTMLのプログラム的なロード時にはdata-URIがそのまま保持されます。これは、既存コンテンツへの意図しない変換を防ぎつつ、ユーザーの貼り付け操作によるデータ流入のみを対象とする明確な境界設定です。

data-URI変換の拒否フローは、既存のファイルピッカーによるファイルアップロード拒否と同じ lexxy:file-accept イベントを経由します。これにより、ホスト側は画像ソースの違いを意識せず単一のイベントハンドラで許可制御を実装できます。また拒否時にエラーを発生させず静かにノードを除去する動作も、ファイルピッカー拒否時の既存挙動に揃えたものです。

テストファイル test/browser/tests/paste/data_uri_safeguards.test.js は223行にわたり、単独画像・周囲コンテンツとの位置関係・MIMEタイプの保持・拒否フロー・SVGなど複数のケースを網羅しています。また、eslint.config.jsatob: "readonly" が追加されており、base64デコードに使用するグローバル関数をESLintに認識させています。

まとめ

ペーストによるdata-URI画像の混入という具体的な問題に対し、変換処理をペースト操作スコープに限定し、既存の lexxy:file-accept イベントによる許可制御を再利用することで、最小限の変更範囲で解決しています。$generateNodesFromDOM への責務統合と $createUploadNode の抽出は、新機能の追加と同時にコードの重複を除去するリファクタリングも兼ねており、今後のアップロードパイプライン拡張の基盤を整えています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
0c829b7d

この記事は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:filepath```)とPR番号のリンク記法([#1037](URL))がガイドライン通りに正しく使用されています。

対象読者への適合性 ✓ PASS

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

data-URI、base64、Lexicalフレームワークのノード操作など、専門的なトピックを扱っており、対象読者であるエンジニアに適した技術レベルと表現です。

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

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

各セクション、各パラグラフがトピックセンテンスで始まり、1段落1トピックの原則が守られているため、構成が論理的で可読性が高いです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロックは、提供されたDiff情報と正確に一致しています。関数の廃止、メソッドの抽出といった変更内容を的確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

ActionTextAttachmentNode, PASTE_TAG, lexxy:file-accept など、PRの文脈に特有の技術用語や一般的な用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

「ペースト操作時のみ変換が行われる」という説明がDiffの`$hasUpdateTag(PASTE_TAG)`というコードで裏付けられるなど、すべての技術的説明が正確で論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescriptionやDiffから導き出せる内容であり、根拠のない推測や憶測(ハルシネーション)は一切見られません。

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

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

PR番号「#1037」や、追加されたテストファイルの行数「223行」など、記事に含まれる数値や固有名詞はすべて正確です。

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

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

記事のタイトル「ペーストされたdata-URI画像をアップロードノードへ自動変換」は、PRのタイトル「Convert pasted data-URI images into uploads」の内容を的確に反映しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に完全に準拠しており、バージョンサポート状況やリリース予定といったPR外の知識の追加はありません。

時間表現の正確性 ✓ PASS

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

記事内に時間表現の歪曲は見られず、PRで示された事実関係が正確に記述されています。