Merge pull request #77292 from NousResearch/bb/composer-mixed-paste

Pasting a thread with images keeps the text and skips the blank thumbnails
This commit is contained in:
brooklyn!
2026-08-02 22:35:35 -05:00
committed by GitHub
4 changed files with 74 additions and 17 deletions
+6
View File
@@ -5410,6 +5410,12 @@ function buildApplicationMenu() {
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
// ⌘⇧V is only wired up by this item existing: an accelerator with no menu
// entry is never translated into an editor command, so the chord was a
// no-op in every input in the app. The composer inserts plain text on
// every paste anyway, so this is the same result as ⌘V there — it's the
// terminal, preview, and other editable surfaces that need the strip.
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' }
]
+8 -10
View File
@@ -446,18 +446,12 @@ export function ChatBar({
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (imageBlobs.length > 0 && onAttachImageBlob) {
triggerHaptic('selection')
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
@@ -469,6 +463,10 @@ export function ChatBar({
if (!pastedText) {
event.preventDefault()
if (imageBlobs.length > 0) {
return
}
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
// the Linux clipboard the DOM paste event reads, so a host screenshot
// arrives as an empty paste (no blobs, no text). Fall back to the main
@@ -212,6 +212,48 @@ describe('extractClipboardImageBlobs', () => {
expect(extractClipboardImageBlobs(clipboard)).toEqual([image])
})
// A rich-text copy (Discord thread, web page, doc) carries prose plus whatever
// inline images the page decorated it with. That is a TEXT paste: attaching the
// page's placeholder graphics as composer images while the text vanished is the
// "blank attachments, no message" bug.
it('ignores inline HTML images when the copy carries its own text', () => {
const clipboard = {
files: { length: 0, item: () => null },
getData: (type: string) =>
type === 'text/html'
? `<p>hello from the thread</p><img src="data:image/png;base64,${'A'.repeat(20_000)}">`
: 'hello from the thread',
items: []
} as unknown as DataTransfer
expect(extractClipboardImageBlobs(clipboard)).toEqual([])
})
it('keeps inline HTML images when the copy is image-only', () => {
const clipboard = {
files: { length: 0, item: () => null },
getData: (type: string) =>
type === 'text/html' ? `<img src="data:image/png;base64,${'A'.repeat(20_000)}">` : '',
items: []
} as unknown as DataTransfer
const blobs = extractClipboardImageBlobs(clipboard)
expect(blobs).toHaveLength(1)
expect(blobs[0]?.type).toBe('image/png')
})
it('drops sub-thumbnail inline images — spacers, trackers, blurhash placeholders', () => {
const clipboard = {
files: { length: 0, item: () => null },
getData: (type: string) =>
type === 'text/html' ? `<img src="data:image/png;base64,${'A'.repeat(64)}">` : '',
items: []
} as unknown as DataTransfer
expect(extractClipboardImageBlobs(clipboard)).toEqual([])
})
})
describe('blobDedupeKey', () => {
@@ -70,6 +70,11 @@ const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
// `:` or `:D` smiley doesn't open a popover the user didn't ask for.
const EMOJI_TRIGGER_RE = /(?:^|[\s\uFFFC])(:)([a-zA-Z0-9_+-]{2,})$/
const INLINE_IMAGE_SRC_RE = /<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi
// Below this, an inline data URL is chrome rather than content — a spacer, a
// 1×1 tracker, or a blurhash placeholder. Real pasted artwork clears it easily.
const MIN_INLINE_IMAGE_BYTES = 4096
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
if (blob instanceof File) {
@@ -125,16 +130,22 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
if (DATA_IMAGE_URL_RE.test(text)) {
push(dataUrlToBlob(text))
return blobs
}
if (blobs.length === 0) {
const html = clipboard.getData('text/html')
// Inline `<img src="data:…">` in the clipboard's HTML — but only for a copy
// that carried no text of its own. A rich-text copy WITH prose is a text
// paste that happens to contain images, and its data URLs are the page's
// decorations rather than content: Discord ships a 32×5 blurhash placeholder
// beside every image embed, so copying a thread attached a blank thumbnail
// and (because an image paste swallows the event) dropped the text entirely.
if (!text) {
for (const match of clipboard.getData('text/html').matchAll(INLINE_IMAGE_SRC_RE)) {
const blob = dataUrlToBlob(match[1])
if (html) {
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
for (const match of matches) {
push(dataUrlToBlob(match[1]))
if (blob && blob.size >= MIN_INLINE_IMAGE_BYTES) {
push(blob)
}
}
}