クリップボードに画像ファイルとHTMLが共存するとき、アップロードパスを優先する

basecamp/lexxy

クリップボードに画像ファイルとHTMLの <img> タグが同時に含まれる場合、Lexxyはアップロードパスを優先するようになりました。これにより、コピー元のリモートURLがドキュメントに残るという問題が修正されています。

背景

ブラウザがクリップボードデータを複数のフォーマットで同時に提供するケースが、この問題の根本原因でした。一部のアプリケーションは、画像をコピーした際にHTMLの <img> スニペットとバイナリファイルの両方をクリップボードに乗せます。ユーザーとしては「画像ファイルを貼り付けた」つもりでも、Lexxyは text/html フォーマットを先に確認するロジックだったため、<img src="https://example.com/...">のようなリモートURLがそのままドキュメントに挿入されていました。

この挙動はアップロード機能を完全に迂回してしまい、Lexxyが提供するファイル管理の仕組みが機能しないという問題でした。本来であれば、添付ファイルのアップロードをサポートするエディタに対してファイルが貼り付けられた場合は、アップロードパスを通るべきです。

技術的な変更

src/editor/clipboard.js#handleAttachmentPaste メソッドに、HTMLがコピー元の画像を表すものかどうかを検出する分岐が追加されました。

変更前:

const html = clipboardData.getData("text/html")
if (html) {
  this.contents.insertHtml(html, { tag: PASTE_TAG })
  return true
}

this.#preservingScrollPosition(() => {
  const files = clipboardData.files
  if (files.length) {
    this.contents.uploadFiles(files, { selectLast: true })
  }
})

return true

変更後:

const html = clipboardData.getData("text/html")
const files = clipboardData.files

if (files.length && this.#isCopiedImageHTML(html)) {
  this.#uploadFilesPreservingScroll(files)
  return true
}

if (html) {
  this.contents.insertHtml(html, { tag: PASTE_TAG })
  return true
}

this.#uploadFilesPreservingScroll(files)

return true

新設された #isCopiedImageHTML(html) メソッドは、HTMLをDOMとしてパースし、body の直下の子要素が1つだけであり、かつそのタグが IMG であるかどうかを判定します。

#isCopiedImageHTML(html) {
  if (!html) return false

  const doc = parseHtml(html)
  const elementChildren = Array.from(doc.body.children)

  return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
}

あわせて、スクロール位置を保持しながらファイルをアップロードする処理は #uploadFilesPreservingScroll(files) として切り出され、複数の分岐から再利用できるようになっています。

テストインフラの拡張

この修正を検証するため、test/browser/helpers/editor_handle.jspaste() ヘルパーがファイルを含む混合クリップボードペイロードをサポートするよう拡張されました。

ブラウザはセキュリティ上の制約からプログラムによるクリップボードアクセスを許可しないため、既存のペーストテストはすべて ClipboardEvent を合成してディスパッチする方式を取っています。しかし ClipboardEventclipboardData にはファイルを直接含めることができず、Firefoxでは合成ファイルペイロードを無視するケースがあります。そこで、ファイルが含まれる場合はカスタムの clipboardData オブジェクトを持つ通常の Event を生成し、Object.definePropertyclipboardData を付与する方式に切り替えています。ファイルなしの通常テキスト/HTMLペーストでは従来の ClipboardEvent パスがそのまま使われるため、既存テストへの影響はありません。

if (files.length > 0) {
  const clipboardFiles = buildFiles()
  const clipboardData = {
    files: clipboardFiles,
    getData(type) {
      if (type === "text/plain") return text ?? ""
      if (type === "text/html") return html ?? ""
      return ""
    },
  }

  event = new Event("paste", { bubbles: true, cancelable: true })
  Object.defineProperty(event, "clipboardData", { value: clipboardData })
} else {
  event = new ClipboardEvent("paste", { ... })
}

テストケース自体は test/browser/tests/paste.test.js に追加されており、HTMLの <img> とバイナリPNGファイルを同時に貼り付けた際に uploadFiles が呼び出されることを window.__uploadFilesCalls への記録で検証しています。

設計判断

HTMLを完全に無視するのではなく「コピーされた画像のHTMLか否か」で判断する という選択が、この変更の核心です。

ファイルが存在する場合は常にアップロードパスを優先する、という単純なアプローチも考えられますが、それでは「HTMLにリッチコンテンツを含むファイルを貼り付けた場合」など、HTMLを活かすべきシナリオを壊すリスクがあります。#isCopiedImageHTML はHTMLのDOM構造を検査し、「ボディ直下に <IMG> が1つだけある」という非常に具体的な条件を満たす場合だけファイルを優先します。このヒューリスティックは、「アプリがコピー時に自動生成した <img> スニペット」というユースケースに絞って対応しており、既存のHTMLペーストパスへの干渉を最小化しています。

また、.claude/skills/bugs-reproducer.md へのドキュメント追加は、同種のバグを再現する際の注意点を開発者ガイドラインとして明文化するものです。Firefoxでの合成ファイルペイロードの挙動差異や、混合クリップボードペイロードの再現方法を記録することで、将来のデバッグコストを下げる意図が読み取れます。

まとめ

クリップボードの filestext/html を個別に処理していた既存ロジックに対し、「両者が同時に存在する場合のHTMLの性質」を判断する一段階上のチェックを加えることで、最小限のコード変更で優先順位の逆転を解消しています。テストインフラの拡張によってブラウザの制約を回避しつつ混合ペイロードの回帰テストが可能になり、同種のバグが今後も検出できる体制が整えられた変更といえます。

記事メタデータ

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

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

ファイル名付きシンタックスハイライト(```言語:ファイルパス)およびPR番号のリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

クリップボードイベントのハンドリングやテストの合成イベントなど、専門知識を持つエンジニアを対象とした適切な技術レベルで記述されています。

パラグラフ・ライティング ⚠ WARNING

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

ほとんどの段落はトピックセンテンスで始まり適切ですが、「テストインフラの拡張」セクションに6文を超える段落が1つ存在します。

Diff内容との照合 ⚠ WARNING

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

引用されているコードの大部分はDiffと一致していますが、「技術的な変更」セクションの「変更前」のコードが、説明のために再構成されており、Diffの元のコードと厳密には一致しません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`ClipboardEvent`、`DOMパース`、`Object.defineProperty`などの技術用語が文脈に応じて正確に使用されています。

説明の技術的正確性 ✓ PASS

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

クリップボードの優先順位変更ロジックや、ブラウザの制約を回避するテスト手法に関する説明は、提示されたコードと整合しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのTitle, Description, Diff内容で裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#829)や引用されているファイルパスはすべて正確です。

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

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

記事のタイトルはPRの主題「Prefer pasted image files over copied image HTML」を正確に反映しています。

外部知識の正確性 ✓ PASS

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

PR情報に記載のない、バージョンサポート状況やリリース日程などの外部知識の追加はありませんでした。

時間表現の正確性 ✓ PASS

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

「~するようになりました」といった時間表現は、完了した変更を正確に記述しており、PRのステータスと一致しています。