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
177 changes: 176 additions & 1 deletion packages/lexical/src/__tests__/unit/HTMLCopyAndPaste.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
*
*/

import {$insertDataTransferForRichText} from '@lexical/clipboard';
import {
$getClipboardDataFromSelection,
$insertDataTransferForRichText,
setLexicalClipboardDataTransfer,
} from '@lexical/clipboard';
import {$patchStyleText} from '@lexical/selection';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
$selectAll,
} from 'lexical';
import {
DataTransferMock,
Expand Down Expand Up @@ -132,6 +139,174 @@ describe('HTMLCopyAndPaste tests', () => {
});
});

test('pasting centered paragraph into empty paragraph preserves alignment (Regression #8101)', async () => {
const {editor} = testEnv;

await editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.select();
});

const dataTransfer = new DataTransferMock();
dataTransfer.setData(
'text/html',
'<p style="text-align: center;">centered text</p>',
);
await editor.update(() => {
const selection = $getSelection();
invariant(
$isRangeSelection(selection),
'isRangeSelection(selection)',
);
$insertDataTransferForRichText(dataTransfer, selection, editor);
});

await editor.update(() => {
const root = $getRoot();
const firstChild = root.getFirstChild();
invariant(firstChild !== null, 'firstChild is not null');
invariant($isElementNode(firstChild), 'firstChild is an ElementNode');
expect(firstChild.getFormatType()).toBe('center');
});
});

test('pasting centered paragraph into non-empty paragraph does not change destination alignment', async () => {
const {editor} = testEnv;

await editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('existing'));
root.append(paragraph);
paragraph.selectEnd();
});

const dataTransfer = new DataTransferMock();
dataTransfer.setData(
'text/html',
'<p style="text-align: center;">centered text</p>',
);
await editor.update(() => {
const selection = $getSelection();
invariant(
$isRangeSelection(selection),
'isRangeSelection(selection)',
);
$insertDataTransferForRichText(dataTransfer, selection, editor);
});

await editor.update(() => {
const root = $getRoot();
const firstChild = root.getFirstChild();
invariant(firstChild !== null, 'firstChild is not null');
invariant($isElementNode(firstChild), 'firstChild is an ElementNode');
// Pasting into non-empty paragraph should NOT change its alignment
expect(firstChild.getFormatType()).toBe('');
});
});

test('copy text from centered paragraph and paste into empty paragraph preserves alignment (Regression #8101)', async () => {
const {editor} = testEnv;

// Create centered paragraph, select all text, copy
let clipboardData: ReturnType<typeof $getClipboardDataFromSelection>;
await editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
paragraph.setFormat('center');
paragraph.append($createTextNode('centered text'));
root.append(paragraph);
$selectAll();
clipboardData = $getClipboardDataFromSelection();
});

// Paste into empty paragraph
await editor.update(() => {
const root = $getRoot();
root.clear();
const emptyParagraph = $createParagraphNode();
root.append(emptyParagraph);
emptyParagraph.select();
});

const dataTransfer = new DataTransferMock();
setLexicalClipboardDataTransfer(
dataTransfer as unknown as DataTransfer,
clipboardData!,
);
await editor.update(() => {
const selection = $getSelection();
invariant(
$isRangeSelection(selection),
'isRangeSelection(selection)',
);
$insertDataTransferForRichText(dataTransfer, selection, editor);
});

await editor.update(() => {
const root = $getRoot();
const firstChild = root.getFirstChild();
invariant(firstChild !== null, 'firstChild is not null');
invariant($isElementNode(firstChild), 'firstChild is an ElementNode');
expect(firstChild.getFormatType()).toBe('center');
});
});

test('copy text from centered paragraph and paste into non-empty paragraph does not change alignment', async () => {
const {editor} = testEnv;

// Create centered paragraph, select all text, copy
let clipboardData: ReturnType<typeof $getClipboardDataFromSelection>;
await editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
paragraph.setFormat('center');
paragraph.append($createTextNode('centered text'));
root.append(paragraph);
$selectAll();
clipboardData = $getClipboardDataFromSelection();
});

// Paste into non-empty left-aligned paragraph
await editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('existing'));
root.append(paragraph);
paragraph.selectEnd();
});

const dataTransfer = new DataTransferMock();
setLexicalClipboardDataTransfer(
dataTransfer as unknown as DataTransfer,
clipboardData!,
);
await editor.update(() => {
const selection = $getSelection();
invariant(
$isRangeSelection(selection),
'isRangeSelection(selection)',
);
$insertDataTransferForRichText(dataTransfer, selection, editor);
});

await editor.update(() => {
const root = $getRoot();
const firstChild = root.getFirstChild();
invariant(firstChild !== null, 'firstChild is not null');
invariant($isElementNode(firstChild), 'firstChild is an ElementNode');
// Non-empty destination should NOT change alignment
expect(firstChild.getFormatType()).toBe('');
});
});

test('iOS fix: Word predictions should be handled as plain text to maintain selection formatting', async () => {
const {editor} = testEnv;

Expand Down
11 changes: 10 additions & 1 deletion packages/lexical/src/nodes/LexicalParagraphNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
DOMExportOutput,
LexicalNode,
} from '../LexicalNode';
import type {RangeSelection} from '../LexicalSelection';
import type {BaseSelection, RangeSelection} from '../LexicalSelection';
import type {
ElementFormatType,
SerializedElementNode,
Expand Down Expand Up @@ -123,6 +123,15 @@ export class ParagraphNode extends ElementNode {
return json as SerializedParagraphNode;
}

extractWithChild(
child: LexicalNode,
selection: BaseSelection | null,
destination: 'clone' | 'html',
): boolean {
const formatType = this.getFormatType();
return formatType !== '' && formatType !== 'left' && formatType !== 'start';
}
Comment on lines +126 to +133

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly in Google Docs the text/html representation of the nodes doesn't include the paragraph at all when there's a slice, so I'm not 100% sure that extractWithChild for html destinations is the best approach here for compatibility reasons. It does include that information in the json though, so maybe we should try something like destination === 'clone' instead which would be a bit less invasive for interoperability with other applications since that only affects the json output.


// Mutation

insertNewAfter(
Expand Down
Loading