From 862983a368db95501a332b9374626357a86ca335 Mon Sep 17 00:00:00 2001 From: Vivek Jariwala Date: Sun, 14 Jun 2026 12:21:41 -0400 Subject: [PATCH] [lexical-rich-text] Bug Fix: Paste browser-copied images as files instead of HTML (#8681) When the user copies an image from a browser webpage via right-click Copy Image, the clipboard simultaneously holds two MIME types: Files (the actual image file) and text/html (an img element pointing to the remote URL). The PASTE_COMMAND handler in @lexical/rich-text was only dispatching DRAG_DROP_PASTE when files.length > 0 && !hasTextContent. Because text/html was always counted as text content, DRAG_DROP_PASTE was never dispatched and the image was silently imported as HTML instead of as the actual File object. Fix: In the PASTE_COMMAND handler, also check for the absence of text/plain as a second condition for preferring the file over the HTML fallback. Applications such as Word place a rasterized image in the Files slot alongside text/plain, text/html, and text/rtf when copying rich text. The presence of text/plain is the reliable signal that the primary intent is a text paste. A browser-copied image never puts text/plain in the clipboard, so checking !hasPlainText correctly distinguishes the two cases without affecting eventFiles signature or any other call sites. The dispatch condition becomes: files.length > 0 && (!hasTextContent || !hasPlainText) This handles all cases correctly: - Files only (no text): !hasTextContent is true, DRAG_DROP_PASTE fires - Browser image (Files + text/html, no text/plain): !hasPlainText is true, DRAG_DROP_PASTE fires with the actual image file - Word text (Files + text/html + text/plain): both conditions are false, falls through to HTML import as before eventFiles in @lexical/utils is unchanged. The fix is entirely contained in the PASTE_COMMAND handler in @lexical/rich-text. Added unit tests documenting the eventFiles return values for each clipboard scenario and a regression test for the Word text copy case. --- .../__tests__/unit/RichTextPasteFiles.test.ts | 109 +++++++++++++++ packages/lexical-rich-text/src/index.ts | 14 +- .../unit/LexicalUtilsEventFiles.test.ts | 130 ++++++++++++++++++ 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 packages/lexical-rich-text/src/__tests__/unit/RichTextPasteFiles.test.ts create mode 100644 packages/lexical-utils/src/__tests__/unit/LexicalUtilsEventFiles.test.ts diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextPasteFiles.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextPasteFiles.test.ts new file mode 100644 index 00000000000..88f104ddd3e --- /dev/null +++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextPasteFiles.test.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {DRAG_DROP_PASTE, registerRichText} from '@lexical/rich-text'; +import {PASTE_COMMAND} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {afterEach, describe, expect, test, vi} from 'vitest'; + +// Build a ClipboardEvent-shaped object that satisfies the PASTE_COMMAND +// handler in registerRichText. The handler reads: +// - objectKlassEquals(event, ClipboardEvent) -> needs constructor.name +// - eventFiles(event) -> reads event.clipboardData.{types, files} +// - event.clipboardData.types.includes('text/plain') +// +// We use new ClipboardEvent(...) so constructor.name is 'ClipboardEvent' +// and objectKlassEquals passes, then override clipboardData with a plain +// object carrying the types and files we need. +function makePasteEvent( + types: readonly string[], + files: File[], +): ClipboardEvent { + const fileList = Object.assign(files.slice(), { + item: (i: number) => files[i] ?? null, + }) as unknown as FileList; + + const event = new ClipboardEvent('paste', {bubbles: true, cancelable: true}); + // Override the read-only clipboardData with our own shape. + Object.defineProperty(event, 'clipboardData', { + value: {files: fileList, getData: () => '', types}, + }); + return event; +} + +const pngFile = new File(['img'], 'photo.png', {type: 'image/png'}); + +describe('registerRichText PASTE_COMMAND file handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + initializeUnitTest(testEnv => { + test('browser-copied image (Files + text/html, no text/plain) dispatches DRAG_DROP_PASTE', async () => { + const {editor} = testEnv; + registerRichText(editor); + + const handler = vi.fn(() => true); + editor.registerCommand(DRAG_DROP_PASTE, handler, 4); + + // Browser right-click Copy Image: clipboard has Files and text/html + // but no text/plain. The handler must route this to DRAG_DROP_PASTE. + const event = makePasteEvent(['Files', 'text/html'], [pngFile]); + await editor.dispatchCommand(PASTE_COMMAND, event); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith([pngFile], expect.anything()); + }); + + test('Word-copied text (Files + text/html + text/plain) does NOT dispatch DRAG_DROP_PASTE', async () => { + const {editor} = testEnv; + registerRichText(editor); + + const handler = vi.fn(() => true); + editor.registerCommand(DRAG_DROP_PASTE, handler, 4); + + // Word places a rasterized PNG alongside text/plain and text/html. + // The presence of text/plain signals that the user intends a text + // paste, so DRAG_DROP_PASTE must NOT fire. + const wordPng = new File(['img'], 'image.png', {type: 'image/png'}); + const event = makePasteEvent( + ['text/plain', 'text/html', 'Files'], + [wordPng], + ); + await editor.dispatchCommand(PASTE_COMMAND, event); + + expect(handler).not.toHaveBeenCalled(); + }); + + test('pure file paste (Files only, no text types) dispatches DRAG_DROP_PASTE', async () => { + const {editor} = testEnv; + registerRichText(editor); + + const handler = vi.fn(() => true); + editor.registerCommand(DRAG_DROP_PASTE, handler, 4); + + const event = makePasteEvent(['Files'], [pngFile]); + await editor.dispatchCommand(PASTE_COMMAND, event); + + expect(handler).toHaveBeenCalledOnce(); + }); + + test('text/html-only paste (no files) does NOT dispatch DRAG_DROP_PASTE', async () => { + const {editor} = testEnv; + registerRichText(editor); + + const handler = vi.fn(() => true); + editor.registerCommand(DRAG_DROP_PASTE, handler, 4); + + const event = makePasteEvent(['text/html'], []); + await editor.dispatchCommand(PASTE_COMMAND, event); + + expect(handler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index 1d98ba9e5f6..65aa881ac44 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -1262,7 +1262,19 @@ export function registerRichText( PASTE_COMMAND, event => { const [, files, hasTextContent] = eventFiles(event); - if (files.length > 0 && !hasTextContent) { + // When the clipboard contains both Files and text/html (e.g. a + // browser right-click Copy Image), prefer the actual File object + // over the text/html fallback. However, applications such as Word + // also place a rasterized image in the Files slot alongside + // text/plain and text/html when copying rich text. The presence of + // text/plain is the reliable signal that the primary intent is a + // text paste, not a file paste, so we only bypass hasTextContent + // when text/plain is absent from the clipboard. + const hasPlainText = + objectKlassEquals(event, ClipboardEvent) && + event.clipboardData !== null && + event.clipboardData.types.includes('text/plain'); + if (files.length > 0 && (!hasTextContent || !hasPlainText)) { editor.dispatchCommand(DRAG_DROP_PASTE, files); return true; } diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalUtilsEventFiles.test.ts b/packages/lexical-utils/src/__tests__/unit/LexicalUtilsEventFiles.test.ts new file mode 100644 index 00000000000..242618a534a --- /dev/null +++ b/packages/lexical-utils/src/__tests__/unit/LexicalUtilsEventFiles.test.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {eventFiles} from '@lexical/utils'; +import {describe, expect, it} from 'vitest'; + +// The jsdom DataTransferMock does not implement .items or .files, so we +// construct minimal DataTransfer-shaped objects directly. The eventFiles +// function only reads .types and .files from dataTransfer, and for the +// ClipboardEvent path it reads .clipboardData. +// +// For ClipboardEvent: eventFiles reads event.clipboardData.types and +// event.clipboardData.files. +// For DragEvent: eventFiles reads event.dataTransfer.types and .files. + +function makeClipboardEvent( + types: readonly string[], + files: File[], +): ClipboardEvent { + const fileList = Object.assign(files.slice(), { + item: (i: number) => files[i] ?? null, + }) as unknown as FileList; + + const clipboardData = { + files: fileList, + types, + } as unknown as DataTransfer; + + return new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData, + }); +} + +function makeDragEvent(types: readonly string[], files: File[]): DragEvent { + const fileList = Object.assign(files.slice(), { + item: (i: number) => files[i] ?? null, + }) as unknown as FileList; + + const dataTransfer = { + files: fileList, + types, + } as unknown as DataTransfer; + + return new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); +} + +const pngFile = new File(['img'], 'image.png', {type: 'image/png'}); + +describe('eventFiles', () => { + it('returns [false, [], false] when event has no DataTransfer', () => { + const event = new KeyboardEvent('keydown') as unknown as ClipboardEvent; + expect(eventFiles(event)).toStrictEqual([false, [], false]); + }); + + it('returns hasContent=true for text/html only clipboard (no files)', () => { + const event = makeClipboardEvent(['text/html'], []); + const [hasFiles, files, hasContent] = eventFiles(event); + expect(hasFiles).toBe(false); + expect(files).toHaveLength(0); + expect(hasContent).toBe(true); + }); + + it('returns hasContent=true for text/plain only clipboard (no files)', () => { + const event = makeClipboardEvent(['text/plain'], []); + const [hasFiles, files, hasContent] = eventFiles(event); + expect(hasFiles).toBe(false); + expect(files).toHaveLength(0); + expect(hasContent).toBe(true); + }); + + it('returns hasFiles=true and hasContent=false when only Files present', () => { + const event = makeClipboardEvent(['Files'], [pngFile]); + const [hasFiles, files, hasContent] = eventFiles(event); + expect(hasFiles).toBe(true); + expect(files).toHaveLength(1); + expect(hasContent).toBe(false); + }); + + describe('browser-copied image: Files + text/html, no text/plain', () => { + // When the user right-clicks an image in a browser and chooses Copy Image, + // the clipboard holds Files (the actual image) and text/html (a remote img + // fallback), but no text/plain. eventFiles reports hasFiles=true and + // hasContent=true. The PASTE_COMMAND handler in @lexical/rich-text then + // uses the absence of text/plain as the additional signal to prefer the + // file over the HTML fallback and dispatch DRAG_DROP_PASTE. + it('returns hasFiles=true and hasContent=true (text/html present)', () => { + const event = makeClipboardEvent(['Files', 'text/html'], [pngFile]); + const [hasFiles, files, hasContent] = eventFiles(event); + expect(hasFiles).toBe(true); + expect(files).toHaveLength(1); + expect(hasContent).toBe(true); + }); + + it('DragEvent with files and text/html behaves the same way', () => { + const event = makeDragEvent(['Files', 'text/html'], [pngFile]); + const [hasFiles, files, hasContent] = eventFiles(event); + expect(hasFiles).toBe(true); + expect(files).toHaveLength(1); + expect(hasContent).toBe(true); + }); + }); + + describe('Word-copied text: Files + text/html + text/plain', () => { + // Word places a rasterized PNG in the Files slot alongside text/plain, + // text/html, and text/rtf when copying rich text. eventFiles reports + // hasContent=true. The PASTE_COMMAND handler checks text/plain separately + // to avoid treating this as a file-first paste. + it('returns hasFiles=true and hasContent=true (all types present)', () => { + const event = makeClipboardEvent( + ['text/plain', 'text/html', 'Files'], + [pngFile], + ); + const [hasFiles, files, hasContent] = eventFiles(event); + expect(hasFiles).toBe(true); + expect(files).toHaveLength(1); + expect(hasContent).toBe(true); + }); + }); +});