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
61 changes: 61 additions & 0 deletions packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

/**
Expand Down Expand Up @@ -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;
Expand Down
193 changes: 192 additions & 1 deletion packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading