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:
@@ -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' }
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user