From 732f3dda2ecc6052c3d78329a564594eb45b38c2 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 4 Oct 2024 16:08:19 -0700 Subject: [PATCH 1/2] Fix #6701 insertion into inline ElementNode --- packages/lexical/src/LexicalSelection.ts | 19 +++++++++++++------ packages/lexical/src/LexicalUtils.ts | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 8f851d4cfa4..fc06fd21865 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1334,7 +1334,7 @@ export class RangeSelection implements BaseSelection { /** * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the * current Selection according to a set of heuristics that determine how surrounding nodes - * should be changed, replaced, or moved to accomodate the incoming ones. + * should be changed, replaced, or moved to accommodate the incoming ones. * * @param nodes - the nodes to insert */ @@ -1353,12 +1353,13 @@ export class RangeSelection implements BaseSelection { } const firstPoint = this.isBackward() ? this.focus : this.anchor; - const firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock)!; + const firstNode = firstPoint.getNode(); + const firstBlock = $getAncestor(firstNode, INTERNAL_$isBlock); const last = nodes[nodes.length - 1]!; // CASE 1: insert inside a code block - if ('__language' in firstBlock && $isElementNode(firstBlock)) { + if ($isElementNode(firstBlock) && '__language' in firstBlock) { if ('__language' in nodes[0]) { this.insertText(nodes[0].getTextContent()); } else { @@ -1397,8 +1398,8 @@ export class RangeSelection implements BaseSelection { const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty(); const insertedParagraph = shouldInsert ? this.insertParagraph() : null; - const lastToInsert = blocks[blocks.length - 1]; - let firstToInsert = blocks[0]; + const lastToInsert: LexicalNode | undefined = blocks[blocks.length - 1]; + let firstToInsert: LexicalNode | undefined = blocks[0]; if (isMergeable(firstToInsert)) { invariant( $isElementNode(firstBlock), @@ -1408,9 +1409,15 @@ export class RangeSelection implements BaseSelection { firstToInsert = blocks[1]; } if (firstToInsert) { + invariant( + firstBlock !== null, + 'Expected node %s of type %s to have a block ElementNode ancestor', + firstNode.constructor.name, + firstNode.getType(), + ); insertRangeAfter(firstBlock, firstToInsert); } - const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock)!; + const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock); if ( insertedParagraph && diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index bf7904d3b90..472703d63d5 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1732,7 +1732,7 @@ export function INTERNAL_$isBlock( export function $getAncestor( node: LexicalNode, predicate: (ancestor: LexicalNode) => ancestor is NodeType, -) { +): NodeType | null { let parent = node; while (parent !== null && parent.getParent() !== null && !predicate(parent)) { parent = parent.getParentOrThrow(); From 2676c69d2e12cc10fc9764b79d2be47020cea8f3 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 4 Oct 2024 17:42:51 -0700 Subject: [PATCH 2/2] Add test --- packages/lexical/src/LexicalSelection.ts | 17 +++++-- .../__tests__/unit/LexicalSelection.test.ts | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index fc06fd21865..e59cab5c83e 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1377,7 +1377,9 @@ export class RangeSelection implements BaseSelection { if (!nodes.some(notInline)) { invariant( $isElementNode(firstBlock), - "Expected 'firstBlock' to be an ElementNode", + 'Expected node %s of type %s to have a block ElementNode ancestor', + firstNode.constructor.name, + firstNode.getType(), ); const index = $removeTextAndSplitBlock(this); firstBlock.splice(index, 0, nodes); @@ -1403,7 +1405,9 @@ export class RangeSelection implements BaseSelection { if (isMergeable(firstToInsert)) { invariant( $isElementNode(firstBlock), - "Expected 'firstBlock' to be an ElementNode", + 'Expected node %s of type %s to have a block ElementNode ancestor', + firstNode.constructor.name, + firstNode.getType(), ); firstBlock.append(...firstToInsert.getChildren()); firstToInsert = blocks[1]; @@ -1411,7 +1415,7 @@ export class RangeSelection implements BaseSelection { if (firstToInsert) { invariant( firstBlock !== null, - 'Expected node %s of type %s to have a block ElementNode ancestor', + 'Expected node %s of type %s to have a block ancestor', firstNode.constructor.name, firstNode.getType(), ); @@ -1455,8 +1459,11 @@ export class RangeSelection implements BaseSelection { return paragraph; } const index = $removeTextAndSplitBlock(this); - const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!; - invariant($isElementNode(block), 'Expected ancestor to be an ElementNode'); + const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock); + invariant( + $isElementNode(block), + 'Expected ancestor to be a block ElementNode', + ); const firstToAppend = block.getChildAtIndex(index); const nodesToInsert = firstToAppend ? [firstToAppend, ...firstToAppend.getNextSiblings()] diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 87b20b797b3..97be0aac4f1 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -16,6 +16,8 @@ import { $isParagraphNode, $isTextNode, $setSelection, + createEditor, + ElementNode, LexicalEditor, ParagraphNode, RangeSelection, @@ -789,3 +791,49 @@ describe('LexicalSelection tests', () => { }); }); }); + +describe('Regression tests for #6701', () => { + test('insertNodes fails an invariant when there is no Block ancestor', async () => { + class InlineElementNode extends ElementNode { + static clone(prevNode: InlineElementNode): InlineElementNode { + return new InlineElementNode(prevNode.__key); + } + static getType() { + return 'inline-element-node'; + } + static importJSON() { + return new InlineElementNode(); + } + isInline() { + return true; + } + exportJSON() { + return {...super.exportJSON(), type: this.getType()}; + } + createDOM() { + return document.createElement('span'); + } + updateDOM() { + return false; + } + } + const editor = createEditor({ + nodes: [InlineElementNode], + onError: (err) => { + throw err; + }, + }); + expect(() => + editor.update( + () => { + const textNode = $createTextNode('test'); + $getRoot().clear().append(new InlineElementNode().append(textNode)); + textNode.select().insertNodes([$createTextNode('more text')]); + }, + {discrete: true}, + ), + ).toThrow( + /Expected node TextNode of type text to have a block ElementNode ancestor/, + ); + }); +});