diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index aa90191bf20..0dce9f43f43 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1915,6 +1915,19 @@ export class RangeSelection implements BaseSelection { } $ensureRootHasParagraph(); } + // A full-range (e.g. select-all) delete can empty the whole document down + // to a single non-paragraph block -- a heading, quote, list, or nested + // list -- which then lingers empty while keeping its type instead of + // collapsing to a plain paragraph like an empty editor. `collapseAtStart` + // above only covers backwards deletion of a heading/quote, so handle the + // forward-delete and list cases here. This lives in `deleteCharacter` + // rather than in the shared `removeText`/`$removeTextFromCaretRange` + // primitive on purpose: `insertText` also routes through `removeText` to + // clear the old selection before inserting (as the Prettier "format" flow + // does on a lone code block), and that replacement must keep its block. + if (!wasCollapsed && this.isCollapsed()) { + $collapseEmptiedRootToParagraph(this); + } } /** @@ -2294,6 +2307,54 @@ function $collapseAtStart( return false; } +/** + * After a full-range delete, collapse a document that is left as a single + * empty non-paragraph block (heading, quote, list, nested list, ...) down to a + * single empty paragraph, matching the empty-editor state. Bails out unless the + * root's only content is that one block sitting on a single, empty branch -- + * any sibling content, decorator, shadow root, or surviving text leaves the + * block untouched. + */ +function $collapseEmptiedRootToParagraph(selection: RangeSelection): void { + const anchorNode = selection.anchor.getNode(); + const block = $isElementNode(anchorNode) + ? anchorNode + : anchorNode.getParent(); + if ( + !block || + !$isElementNode(block) || + $isParagraphNode(block) || + $isRootOrShadowRoot(block) || + !block.isEmpty() + ) { + return; + } + // Walk up to the top-level block (the direct child of the root), bailing on + // anything that means this is not a plain, fully-emptied document. + let topLevel: ElementNode = block; + for ( + let parent = block.getParent(); + parent !== null && !$isRootNode(parent); + parent = parent.getParent() + ) { + if ( + $isRootOrShadowRoot(parent) || + !$isElementNode(parent) || + parent.getChildrenSize() !== 1 + ) { + return; + } + topLevel = parent; + } + const root = topLevel.getParent(); + if (!$isRootNode(root) || root.getChildrenSize() !== 1) { + return; + } + const paragraph = $createParagraphNode(); + topLevel.replace(paragraph); + paragraph.selectStart(); +} + function $swapPoints(selection: RangeSelection): void { const focus = selection.focus; const anchor = selection.anchor; diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 53ff3f34596..77569179bbb 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -14,7 +14,11 @@ import { type ListNode, } from '@lexical/list'; import {$createMarkNode} from '@lexical/mark'; -import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, +} from '@lexical/rich-text'; import { $createTableCellNode, $createTableNode, @@ -757,6 +761,193 @@ describe('LexicalCaret', () => { ); }); }); + describe('select-all + delete collapses to an empty paragraph (#5835)', () => { + // The collapse lives in RangeSelection.deleteCharacter (the delete key + // path), not in the shared $removeTextFromCaretRange primitive, so + // exercise it the way the editor does: select all, then delete. Both + // directions (Backspace and forward Delete) must collapse. + function $selectAllAndDelete(isBackward: boolean): void { + const selection = $selectAll(); + $setSelection(selection); + selection.deleteCharacter(isBackward); + } + for (const isBackward of [true, false]) { + const label = isBackward ? 'Backspace' : 'Delete'; + test(`heading as the only block collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createHeadingNode('h1').append($createTextNode('hi')), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test(`quote as the only block collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append($createQuoteNode().append($createTextNode('hi'))); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + }, + {discrete: true}, + ); + }); + test(`list followed by a paragraph collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append($createTextNode('two')), + ), + $createParagraphNode().append($createTextNode('after')), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test(`nested list collapses (${label})`, () => { + testEnv.editor.update( + () => { + $getRoot() + .clear() + .append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('two')), + ), + ), + ), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect($getRoot().getAllTextNodes()).toEqual([]); + }, + {discrete: true}, + ); + }); + test(`paragraph as the first block is left unchanged (${label})`, () => { + testEnv.editor.update( + () => { + const paragraphNode = $createParagraphNode().append( + $createTextNode('hi'), + ); + $getRoot() + .clear() + .append( + paragraphNode, + $createHeadingNode('h1').append($createTextNode('there')), + ); + $selectAllAndDelete(isBackward); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant( + $isParagraphNode(children[0]), + 'Expected a ParagraphNode', + ); + expect(children[0].isEmpty()).toBe(true); + }, + {discrete: true}, + ); + }); + } + test('partial delete that only empties the first block is unaffected', () => { + testEnv.editor.update( + () => { + const heading = $createHeadingNode('h1').append( + $createTextNode('abc'), + ); + const paragraphNode = $createParagraphNode().append( + $createTextNode('def'), + ); + $getRoot().clear().append(heading, paragraphNode); + // Select only the heading's own text, not the whole document. + const sel = $createRangeSelection(); + const headingText = heading.getFirstChildOrThrow(); + const paragraphText = paragraphNode.getFirstChildOrThrow(); + invariant( + $isTextNode(headingText) && $isTextNode(paragraphText), + 'Expected TextNodes', + ); + sel.anchor.set(headingText.getKey(), 0, 'text'); + sel.focus.set(headingText.getKey(), 3, 'text'); + $setSelection(sel); + // Forward delete: a backwards delete of an emptied first block + // is separately collapsed by the pre-existing collapseAtStart + // path, which is not what this test is guarding. + sel.deleteCharacter(false); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(2); + // The heading keeps its type; it is not converted to a paragraph. + invariant($isHeadingNode(children[0]), 'Expected a HeadingNode'); + expect(children[0].isEmpty()).toBe(true); + expect(children[1]).toBe(paragraphNode); + expect(paragraphNode.getTextContent()).toBe('def'); + }, + {discrete: true}, + ); + }); + test('replacing all text in a lone non-paragraph block keeps its type', () => { + // Guards the Prettier "format" regression: selecting all of a lone + // block's content and inserting (a replace, which routes through + // removeText) must keep the block, not collapse it to a paragraph. + testEnv.editor.update( + () => { + const heading = $createHeadingNode('h1').append( + $createTextNode('hi'), + ); + $getRoot().clear().append(heading); + const selection = heading.select(0); + $setSelection(selection); + selection.insertText('there'); + const children = $getRoot().getChildren(); + expect(children).toHaveLength(1); + invariant($isHeadingNode(children[0]), 'Expected a HeadingNode'); + expect(children[0].getTextContent()).toBe('there'); + }, + {discrete: true}, + ); + }); + }); describe('ported Table e2e tests', () => { test('Can delete all with range selection anchored in table', () => { testEnv.editor.update(