クリップボードに画像ファイルとHTMLが共存するとき、アップロードパスを優先する
クリップボードに画像ファイルと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.js の paste() ヘルパーがファイルを含む混合クリップボードペイロードをサポートするよう拡張されました。
ブラウザはセキュリティ上の制約からプログラムによるクリップボードアクセスを許可しないため、既存のペーストテストはすべて ClipboardEvent を合成してディスパッチする方式を取っています。しかし ClipboardEvent の clipboardData にはファイルを直接含めることができず、Firefoxでは合成ファイルペイロードを無視するケースがあります。そこで、ファイルが含まれる場合はカスタムの clipboardData オブジェクトを持つ通常の Event を生成し、Object.defineProperty で clipboardData を付与する方式に切り替えています。ファイルなしの通常テキスト/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での合成ファイルペイロードの挙動差異や、混合クリップボードペイロードの再現方法を記録することで、将来のデバッグコストを下げる意図が読み取れます。
まとめ
クリップボードの files と text/html を個別に処理していた既存ロジックに対し、「両者が同時に存在する場合のHTMLの性質」を判断する一段階上のチェックを加えることで、最小限のコード変更で優先順位の逆転を解消しています。テストインフラの拡張によってブラウザの制約を回避しつつ混合ペイロードの回帰テストが可能になり、同種のバグが今後も検出できる体制が整えられた変更といえます。