diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx index 39944c0a93a..33bab79e518 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx @@ -11,11 +11,14 @@ import { $computeTableMapSkipCellCheck, $createTableCellNode, $createTableNode, + $createTableNodeWithDimensions, $createTableRowNode, $createTableSelectionFrom, $deleteTableRowAtSelection, + $isTableNode, $isTableRowNode, $isTableSelection, + type TableCellNode, type TableMapType, type TableNode, type TableSelection, @@ -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'; @@ -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(); + 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(''); + }); + }); + }); +}); diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index f181216bd85..531907dad3d 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -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); diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index ea57867ec5e..fb28869d227 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -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;