Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
14 changes: 13 additions & 1 deletion packages/lexical-rich-text/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});