Skip to content
Merged
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
33 changes: 22 additions & 11 deletions packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,15 +1406,6 @@ export class RangeSelection implements BaseSelection {
if (!this.isCollapsed()) {
this.removeText();
}
if (this.anchor.key === 'root') {
this.insertParagraph();
const selection = $getSelection();
invariant(
$isRangeSelection(selection),
'Expected RangeSelection after insertParagraph',
);
return selection.insertNodes(nodes);
}
// @experimental named-slots. Anchor on a slot value root (e.g. after a
// slot-scoped Cmd+A leaves the selection on the slot's element point)
// has __parent === null, so the block-finding walk below would throw.
Expand Down Expand Up @@ -1448,6 +1439,25 @@ export class RangeSelection implements BaseSelection {
}
}

// The anchor is an element point directly on a root or shadow root that is
// not a named-slot host (handled above). This includes the document root
// (e.g. an empty editor) and shadow roots that hold block-level children
// directly — for instance the block cursor between or after the children of
// a decorator-only container or the playground CollapsibleContentNode.
// Roots and shadow roots hold blocks (and shadow roots) directly, so splice
// the nodes in at the anchor offset: a block node (such as a pasted
// DecoratorNode) goes in as-is, while inline runs are wrapped in a block
// first since a root/shadow root cannot contain inline children.
if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) {
const blocksParent = $wrapInlineNodes(nodes);
const nodeToSelect = blocksParent.getLastDescendant();
anchorNode.splice(this.anchor.offset, 0, blocksParent.getChildren());
if (nodeToSelect !== null) {
nodeToSelect.selectEnd();
}
return;
}

const firstPoint = this.isBackward() ? this.focus : this.anchor;
const firstNode = firstPoint.getNode();
const firstBlock = $findMatchingParent(firstNode, INTERNAL_$isBlock);
Expand Down Expand Up @@ -1570,9 +1580,10 @@ export class RangeSelection implements BaseSelection {
* @returns the newly inserted node.
*/
insertParagraph(): ElementNode | null {
if (this.anchor.key === 'root') {
const anchorNode = this.anchor.getNode();
if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) {
const paragraph = $createParagraphNode();
$getRoot().splice(this.anchor.offset, 0, [paragraph]);
anchorNode.splice(this.anchor.offset, 0, [paragraph]);
paragraph.select();
return paragraph;
}
Expand Down
227 changes: 227 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
$getCaretInDirection,
$getRoot,
$getSelection,
$isDecoratorNode,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
$selectAll,
$setSelection,
Expand All @@ -43,6 +45,8 @@ import {
$assertRangeSelection,
$createTestDecoratorNode,
$createTestInlineElementNode,
$createTestShadowRootNode,
$isTestShadowRootNode,
initializeUnitTest,
invariant,
} from '../utils';
Expand Down Expand Up @@ -874,6 +878,229 @@ describe('Regression tests for #6701', () => {
});
});

describe('Regression tests for #8707', () => {
// A shadow root that holds block-level children directly (e.g. a
// decorator-only container). Placing the caret adjacent to a block child
// shows the block cursor, whose RangeSelection is a collapsed element point
// on the shadow root itself. Inserting block-level content there (such as
// pasting a copied decorator) used to throw because a shadow root has no
// block ancestor to split. Roots and shadow roots hold blocks directly, so
// the block goes straight in at the anchor offset with no paragraph wrapper.
initializeUnitTest(testEnv => {
test('inserts a block decorator after the block cursor at the end of a shadow root', () => {
const {editor} = testEnv;
let shadowKey = '';
editor.update(
() => {
const shadow = $createTestShadowRootNode();
shadow.append($createTestDecoratorNode().setIsInline(false));
$getRoot().clear().append(shadow);
shadowKey = shadow.getKey();
// Block cursor: collapsed element point after the decorator.
shadow.select(1, 1);
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
invariant($isRangeSelection(selection), 'Expected RangeSelection');
selection.insertNodes([
$createTestDecoratorNode().setIsInline(false),
]);
},
{discrete: true},
);

editor.read(() => {
const root = $getRoot();
// The decorator landed in the same shadow root as the block cursor,
// not the outer document root.
expect(root.getChildrenSize()).toBe(1);
const shadow = root.getFirstChildOrThrow();
invariant($isTestShadowRootNode(shadow), 'Expected shadow root');
expect(shadow.getKey()).toBe(shadowKey);
const children = shadow.getChildren();
expect(children).toHaveLength(2);
expect(children.every($isDecoratorNode)).toBe(true);
});
});

test('inserts a block decorator before the block cursor at the start of a shadow root', () => {
const {editor} = testEnv;
let existingKey = '';
editor.update(
() => {
const shadow = $createTestShadowRootNode();
const existing = $createTestDecoratorNode().setIsInline(false);
shadow.append(existing);
$getRoot().clear().append(shadow);
existingKey = existing.getKey();
// Block cursor: collapsed element point before the decorator.
shadow.select(0, 0);
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
invariant($isRangeSelection(selection), 'Expected RangeSelection');
selection.insertNodes([
$createTestDecoratorNode().setIsInline(false),
]);
},
{discrete: true},
);

editor.read(() => {
const shadow = $getRoot().getFirstChildOrThrow();
invariant($isTestShadowRootNode(shadow), 'Expected shadow root');
const children = shadow.getChildren();
expect(children).toHaveLength(2);
expect(children.every($isDecoratorNode)).toBe(true);
// Inserted before the pre-existing decorator.
expect(children[1].getKey()).toBe(existingKey);
});
});

test('inserts a block decorator into an empty shadow root', () => {
const {editor} = testEnv;
editor.update(
() => {
const shadow = $createTestShadowRootNode();
$getRoot().clear().append(shadow);
shadow.select(0, 0);
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
invariant($isRangeSelection(selection), 'Expected RangeSelection');
selection.insertNodes([
$createTestDecoratorNode().setIsInline(false),
]);
},
{discrete: true},
);

editor.read(() => {
const shadow = $getRoot().getFirstChildOrThrow();
invariant($isTestShadowRootNode(shadow), 'Expected shadow root');
const children = shadow.getChildren();
expect(children).toHaveLength(1);
expect($isDecoratorNode(children[0])).toBe(true);
});
});

test('inserts a block element at the block cursor inside a shadow root', () => {
const {editor} = testEnv;
editor.update(
() => {
const shadow = $createTestShadowRootNode();
shadow.append($createTestDecoratorNode().setIsInline(false));
$getRoot().clear().append(shadow);
shadow.select(1, 1);
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
invariant($isRangeSelection(selection), 'Expected RangeSelection');
selection.insertNodes([
$createParagraphNode().append($createTextNode('inserted')),
]);
},
{discrete: true},
);

editor.read(() => {
const shadow = $getRoot().getFirstChildOrThrow();
invariant($isTestShadowRootNode(shadow), 'Expected shadow root');
const children = shadow.getChildren();
expect(children).toHaveLength(2);
expect($isDecoratorNode(children[0])).toBe(true);
expect($isParagraphNode(children[1])).toBe(true);
expect(children[1].getTextContent()).toBe('inserted');
});
});

test('insertParagraph at an element point on a shadow root seeds into that shadow root', () => {
const {editor} = testEnv;
editor.update(
() => {
const shadow = $createTestShadowRootNode();
shadow.append($createTestDecoratorNode().setIsInline(false));
$getRoot().clear().append(shadow);
shadow.select(1, 1);
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
invariant($isRangeSelection(selection), 'Expected RangeSelection');
const paragraph = selection.insertParagraph();
invariant(paragraph !== null, 'Expected a paragraph to be inserted');
expect(paragraph.getParent()!.is($getRoot().getFirstChild())).toBe(
true,
);
},
{discrete: true},
);

editor.read(() => {
const root = $getRoot();
expect(root.getChildrenSize()).toBe(1);
const shadow = root.getFirstChildOrThrow();
invariant($isTestShadowRootNode(shadow), 'Expected shadow root');
expect(shadow.getChildrenSize()).toBe(2);
expect($isParagraphNode(shadow.getLastChild())).toBe(true);
});
});

test('inserts a block decorator at a root element point without wrapping it in a paragraph', () => {
const {editor} = testEnv;
editor.update(
() => {
$getRoot()
.clear()
.append($createParagraphNode().append($createTextNode('existing')));
// Element point directly on the root, after the paragraph.
$getRoot().select(1, 1);
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
invariant($isRangeSelection(selection), 'Expected RangeSelection');
selection.insertNodes([
$createTestDecoratorNode().setIsInline(false),
]);
},
{discrete: true},
);

editor.read(() => {
const children = $getRoot().getChildren();
// The decorator is a direct child of root; no empty paragraph wrapper
// was created for it.
expect(children).toHaveLength(2);
expect($isParagraphNode(children[0])).toBe(true);
expect($isDecoratorNode(children[1])).toBe(true);
});
});
});
});

describe('getNodes()', () => {
initializeUnitTest(testEnv => {
let paragraphNode: ParagraphNode;
Expand Down