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
17 changes: 17 additions & 0 deletions packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,21 @@ export class RangeSelection implements BaseSelection {
if (nodes.length === 0) {
return;
}
// Two soft breaks create an empty visual line, which should act as a
// block boundary instead of merging inserted blocks into either side.
const initialAnchorNode = this.anchor.getNode();
const nodeBeforeAnchor =
this.anchor.type === 'element' &&
$isElementNode(initialAnchorNode) &&
this.anchor.offset > 0
? initialAnchorNode.getChildAtIndex(this.anchor.offset - 1)
: this.anchor.type === 'text' && this.anchor.offset === 0
? initialAnchorNode.getPreviousSibling()
: null;
const shouldPreserveInsertedBlocks =
this.isCollapsed() &&
$isLineBreakNode(nodeBeforeAnchor) &&
$isLineBreakNode(nodeBeforeAnchor.getPreviousSibling());
if (!this.isCollapsed()) {
this.removeText();
}
Expand Down Expand Up @@ -1293,6 +1308,7 @@ export class RangeSelection implements BaseSelection {
const nodeToSelect = blocksParent.getLastDescendant()!;
const blocks = blocksParent.getChildren();
const isMergeable = (node: LexicalNode): node is ElementNode =>
!shouldPreserveInsertedBlocks &&
$isElementNode(node) &&
INTERNAL_$isBlock(node) &&
!node.isEmpty() &&
Expand Down Expand Up @@ -1332,6 +1348,7 @@ export class RangeSelection implements BaseSelection {
);

if (
!shouldPreserveInsertedBlocks &&
insertedParagraph &&
$isElementNode(lastInsertedBlock) &&
(insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert))
Expand Down
132 changes: 132 additions & 0 deletions packages/lexical/src/__tests__/unit/Issue4815Repro.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* 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 {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
createEditor,
ElementNode,
} from 'lexical';
import {assert, describe, expect, test} from 'vitest';

class TestHeadingNode extends ElementNode {
$config() {
return this.config('test-heading', {extends: ElementNode});
}

createDOM(): HTMLElement {
return document.createElement('h3');
}

updateDOM(): false {
return false;
}
}

describe('Regression #4815', () => {
test('preserves a pasted block after consecutive line breaks', () => {
const editor = createEditor({
namespace: 'test',
nodes: [TestHeadingNode],
onError: error => {
throw error;
},
});
let pastedTextKey = '';

editor.update(
() => {
const paragraph = $createParagraphNode().append(
$createTextNode('Line of text'),
$createLineBreakNode(),
$createLineBreakNode(),
$createTextNode('Paragraph 1'),
);
$getRoot().append(paragraph);

const heading = new TestHeadingNode().append(
$createTextNode('Heading 3'),
);
const pastedText = $createTextNode('Some paragraph');
pastedTextKey = pastedText.getKey();

paragraph
.select(3, 3)
.insertNodes([heading, $createParagraphNode().append(pastedText)]);
},
{discrete: true},
);

editor.read(() => {
const rootChildren = $getRoot().getChildren();
expect(rootChildren.map(node => node.getType())).toEqual([
'paragraph',
'test-heading',
'paragraph',
'paragraph',
]);
expect(rootChildren.map(node => node.getTextContent())).toEqual([
'Line of text\n',
'Heading 3',
'Some paragraph',
'Paragraph 1',
]);

const selection = $getSelection();
assert($isRangeSelection(selection), 'Expected RangeSelection');
expect(selection.isCollapsed()).toBe(true);
expect(selection.anchor.key).toBe(pastedTextKey);
expect(selection.anchor.offset).toBe('Some paragraph'.length);
});
});

test('keeps the existing merge behavior after a single line break', () => {
const editor = createEditor({
namespace: 'test',
nodes: [TestHeadingNode],
onError: error => {
throw error;
},
});

editor.update(
() => {
const paragraph = $createParagraphNode().append(
$createTextNode('Line of text'),
$createLineBreakNode(),
$createTextNode('Paragraph 1'),
);
$getRoot().append(paragraph);

paragraph
.select(2, 2)
.insertNodes([
new TestHeadingNode().append($createTextNode('Heading 3')),
$createParagraphNode().append($createTextNode('Some paragraph')),
]);
},
{discrete: true},
);

editor.read(() => {
const rootChildren = $getRoot().getChildren();
expect(rootChildren.map(node => node.getType())).toEqual([
'paragraph',
'paragraph',
]);
expect(rootChildren.map(node => node.getTextContent())).toEqual([
'Line of text\nHeading 3',
'Some paragraphParagraph 1',
]);
});
});
});
Loading