Skip to content
Closed
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
Expand Up @@ -11,11 +11,14 @@ import {
$computeTableMapSkipCellCheck,
$createTableCellNode,
$createTableNode,
$createTableNodeWithDimensions,
$createTableRowNode,
$createTableSelectionFrom,
$deleteTableRowAtSelection,
$isTableNode,
$isTableRowNode,
$isTableSelection,
type TableCellNode,
type TableMapType,
type TableNode,
type TableSelection,
Expand All @@ -26,8 +29,10 @@ import {
$getRoot,
$getSelection,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
$setSelection,
type RangeSelection,
} from 'lexical';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
import {beforeEach, describe, expect, test} from 'vitest';
Expand Down Expand Up @@ -364,3 +369,86 @@ describe('table selection', () => {
});
});
});

describe('regression #8075', () => {
initializeUnitTest(testEnv => {
function $deleteForward(): void {
const selection = $getSelection();
expect($isRangeSelection(selection)).toBe(true);
(selection as RangeSelection).deleteCharacter(false);
}

test('forward delete removes an empty paragraph before a table', () => {
testEnv.editor.update(
() => {
const paragraph = $createParagraphNode();
$getRoot()
.clear()
.append(paragraph, $createTableNodeWithDimensions(2, 2, false));
paragraph.selectStart();
},
{discrete: true},
);
testEnv.editor.update($deleteForward, {discrete: true});
testEnv.editor.read(() => {
const children = $getRoot().getChildren();
expect(children).toHaveLength(1);
expect($isTableNode(children[0])).toBe(true);
});
});

test('forward delete does not merge a table into a non-empty paragraph', () => {
testEnv.editor.update(
() => {
const paragraph = $createParagraphNode().append(
$createTextNode('before'),
);
$getRoot()
.clear()
.append(paragraph, $createTableNodeWithDimensions(2, 2, false));
paragraph.selectEnd();
},
{discrete: true},
);
testEnv.editor.update($deleteForward, {discrete: true});
testEnv.editor.read(() => {
const children = $getRoot().getChildren();
expect(children).toHaveLength(2);
expect(children[0].getTextContent()).toBe('before');
expect($isTableNode(children[1])).toBe(true);
});
});

test('forward delete from an empty paragraph in a table cell does nothing', () => {
testEnv.editor.update(
() => {
const table = $createTableNodeWithDimensions(1, 2, false);
$getRoot().clear().append(table);
const row = table.getFirstChild();
if (!$isTableRowNode(row)) {
throw new Error('Expected a TableRowNode');
}
const cell = row.getFirstChildOrThrow<TableCellNode>();
const paragraph = $createParagraphNode();
cell.clear().append(paragraph);
paragraph.selectStart();
},
{discrete: true},
);
testEnv.editor.update($deleteForward, {discrete: true});
testEnv.editor.read(() => {
const table = $getRoot().getFirstChild();
if (!$isTableNode(table)) {
throw new Error('Expected a TableNode');
}
const row = table.getFirstChild();
if (!$isTableRowNode(row)) {
throw new Error('Expected a TableRowNode');
}
// The next cell was not pulled into the empty one.
expect(row.getChildrenSize()).toBe(2);
expect(row.getFirstChildOrThrow().getTextContent()).toBe('');
});
});
});
});
23 changes: 22 additions & 1 deletion packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1680,7 +1680,28 @@ export class RangeSelection implements BaseSelection {
const anchor = this.anchor;
let anchorNode: TextNode | ElementNode | null = anchor.getNode();
if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
return;
// Forward deletion stops at an adjacent shadow root (e.g. a table)
// because its content must not be merged into the block at the
// anchor. When that block is empty there is nothing to merge, so we
// fall through to the caret walk below, which removes the empty block
// and leaves the shadow root in place — the same result backwards
// delete already produces. This is restricted to a shadow root that is
// a sibling of the anchor block, so deleting forwards at the end of a
// shadow root (e.g. the last block of a table cell) still bails out.
// See #8075.
const nextSibling = $isElementNode(anchorNode)
? anchorNode.getNextSibling()
: null;
if (
!(
$isElementNode(anchorNode) &&
anchorNode.isEmpty() &&
$isElementNode(nextSibling) &&
nextSibling.isShadowRoot()
)
) {
return;
}
}
const direction = isBackward ? 'previous' : 'next';
const initialCaret = $caretFromPoint(anchor, direction);
Expand Down
108 changes: 108 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,114 @@ describe('Regression tests for #8707', () => {
});
});

describe('Regression tests for #8075', () => {
test('forward delete removes an empty block before a shadow root', () => {
using editor = buildEditorFromExtensions(selectionTestExtension);
editor.update(
() => {
const paragraph = $createParagraphNode();
const shadow = $createTestShadowRootNode().append(
$createParagraphNode().append($createTextNode('inside')),
);
$getRoot().clear().append(paragraph, shadow);
paragraph.selectStart();
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
assert($isRangeSelection(selection), 'Expected RangeSelection');
selection.deleteCharacter(false);
},
{discrete: true},
);

editor.read(() => {
const children = $getRoot().getChildren();
// The empty paragraph is gone and the shadow root is untouched.
expect(children).toHaveLength(1);
assert($isTestShadowRootNode(children[0]), 'Expected shadow root');
expect(children[0].getTextContent()).toBe('inside');
});
});

test('forward delete keeps a non-empty block before a shadow root', () => {
using editor = buildEditorFromExtensions(selectionTestExtension);
editor.update(
() => {
const paragraph = $createParagraphNode().append(
$createTextNode('before'),
);
const shadow = $createTestShadowRootNode().append(
$createParagraphNode().append($createTextNode('inside')),
);
$getRoot().clear().append(paragraph, shadow);
paragraph.selectEnd();
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
assert($isRangeSelection(selection), 'Expected RangeSelection');
selection.deleteCharacter(false);
},
{discrete: true},
);

editor.read(() => {
const children = $getRoot().getChildren();
expect(children).toHaveLength(2);
expect(children[0].getTextContent()).toBe('before');
assert($isTestShadowRootNode(children[1]), 'Expected shadow root');
expect(children[1].getTextContent()).toBe('inside');
});
});

test('forward delete from an empty block at the end of a shadow root does nothing', () => {
using editor = buildEditorFromExtensions(selectionTestExtension);
editor.update(
() => {
const inner = $createParagraphNode();
$getRoot()
.clear()
.append(
$createTestShadowRootNode().append(inner),
$createTestShadowRootNode().append(
$createParagraphNode().append($createTextNode('next')),
),
);
inner.selectStart();
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
assert($isRangeSelection(selection), 'Expected RangeSelection');
selection.deleteCharacter(false);
},
{discrete: true},
);

editor.read(() => {
const children = $getRoot().getChildren();
expect(children).toHaveLength(2);
const shadow = children[0];
assert($isTestShadowRootNode(shadow), 'Expected shadow root');
// The empty block still belongs to the first shadow root; the
// following shadow root was not pulled into it.
expect(shadow.getChildrenSize()).toBe(1);
expect(shadow.getTextContent()).toBe('');
expect(children[1].getTextContent()).toBe('next');
});
});
});

describe('getNodes() and extract()', () => {
let editor: LexicalEditorWithDispose;
let paragraphNode: ParagraphNode;
Expand Down
Loading