From 6578046e0322fb8819a166c4b8c0ca046bf71adf Mon Sep 17 00:00:00 2001 From: GermanJablo <43938777+GermanJablo@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:55:22 -0300 Subject: [PATCH] Improvements in insertNodes (#5201) Co-authored-by: EgonBolton <43938777+EgonBolton@users.noreply.github.com> --- packages/lexical-clipboard/src/clipboard.ts | 56 +-------- .../__tests__/utils/index.mjs | 2 + .../unit/LexicalSelectionHelpers.test.ts | 91 -------------- .../unit/LexicalEventHelpers.test.tsx | 2 +- packages/lexical/src/LexicalNode.ts | 31 +++++ packages/lexical/src/LexicalSelection.ts | 113 +++++++++++++----- 6 files changed, 117 insertions(+), 178 deletions(-) diff --git a/packages/lexical-clipboard/src/clipboard.ts b/packages/lexical-clipboard/src/clipboard.ts index c6f863dd42d..e31c24c5869 100644 --- a/packages/lexical-clipboard/src/clipboard.ts +++ b/packages/lexical-clipboard/src/clipboard.ts @@ -18,9 +18,7 @@ import { $createTabNode, $getRoot, $getSelection, - $isDecoratorNode, $isElementNode, - $isLineBreakNode, $isRangeSelection, $isTextNode, $parseSerializedNode, @@ -225,62 +223,10 @@ export function $insertGeneratedNodes( return; } - $basicInsertStrategy(nodes, selection); + selection.insertNodes(nodes); return; } -function $basicInsertStrategy( - nodes: LexicalNode[], - selection: RangeSelection | GridSelection, -) { - // Wrap text and inline nodes in paragraph nodes so we have all blocks at the top-level - const topLevelBlocks = []; - let currentBlock = null; - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - - const isLineBreakNode = $isLineBreakNode(node); - - if ( - isLineBreakNode || - ($isDecoratorNode(node) && node.isInline()) || - ($isElementNode(node) && node.isInline()) || - $isTextNode(node) || - node.isParentRequired() - ) { - if (currentBlock === null) { - currentBlock = node.createParentElementNode(); - topLevelBlocks.push(currentBlock); - // In the case of LineBreakNode, we just need to - // add an empty ParagraphNode to the topLevelBlocks. - if (isLineBreakNode) { - continue; - } - } - - if (currentBlock !== null) { - currentBlock.append(node); - } - } else { - topLevelBlocks.push(node); - currentBlock = null; - } - } - - if ($isRangeSelection(selection)) { - selection.insertNodes(topLevelBlocks); - } else if (DEPRECATED_$isGridSelection(selection)) { - // If there's an active grid selection and a non grid is pasted, add to the anchor. - const anchorCell = selection.anchor.getNode(); - - if (!DEPRECATED_$isGridCellNode(anchorCell)) { - invariant(false, 'Expected Grid Cell in Grid Selection'); - } - - anchorCell.append(...topLevelBlocks); - } -} - function $mergeGridNodesStrategy( nodes: LexicalNode[], selection: RangeSelection | GridSelection, diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 0d199f21ee7..3b8aef2d22a 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -721,6 +721,8 @@ export function prettifyHTML(string, {ignoreClasses, ignoreInlineStyles} = {}) { output = output.replace(/\sstyle="([^"]*)"/g, ''); } + output = output.replace(/\s__playwright_target__="[^"]+"/, ''); + return prettier .format(output, { attributeGroups: ['$DEFAULT', '^data-'], diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts index 31b56b39900..eb5d00a5c38 100644 --- a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts +++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -1934,50 +1934,6 @@ describe('LexicalSelectionHelpers tests', () => { '

foo

', ); }); - - test('a heading node with a child text node and a disjoint sibling text node', async () => { - const editor = createTestEditor(); - - const element = document.createElement('div'); - - editor.setRootElement(element); - - await editor.update(() => { - const root = $getRoot(); - - const paragraph = $createParagraphNode(); - root.append(paragraph); - - setAnchorPoint({ - key: paragraph.getKey(), - offset: 0, - type: 'element', - }); - - setFocusPoint({ - key: paragraph.getKey(), - offset: 0, - type: 'element', - }); - - const heading = $createHeadingNode('h1'); - const child = $createTextNode('foo'); - - heading.append(child); - - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return; - } - - selection.insertNodes([heading, $createTextNode('bar')]); - }); - - expect(element.innerHTML).toBe( - '

foobar

', - ); - }); }); describe('with a paragraph node selected on some existing text', () => { @@ -2114,53 +2070,6 @@ describe('LexicalSelectionHelpers tests', () => { ); }); - test('a heading node with a child text node and a disjoint sibling text node', async () => { - const editor = createTestEditor(); - - const element = document.createElement('div'); - - editor.setRootElement(element); - - await editor.update(() => { - const root = $getRoot(); - - const paragraph = $createParagraphNode(); - const text = $createTextNode('Existing text...'); - - paragraph.append(text); - root.append(paragraph); - - setAnchorPoint({ - key: text.getKey(), - offset: 16, - type: 'text', - }); - - setFocusPoint({ - key: text.getKey(), - offset: 16, - type: 'text', - }); - - const heading = $createHeadingNode('h1'); - const child = $createTextNode('foo'); - - heading.append(child); - - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return; - } - - selection.insertNodes([heading, $createTextNode('bar')]); - }); - - expect(element.innerHTML).toBe( - '

Existing text...foobar

', - ); - }); - test('a paragraph with a child text and a child italic text and a child text', async () => { const editor = createTestEditor(); diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx index 3a6fe6827bb..c1fe5cb4f9d 100644 --- a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx +++ b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx @@ -632,7 +632,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

a
b

', + '


a
b

', inputs: [ pasteHTML(`\na\r\nb\r\n`), ], diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 42419cbbe7f..87f05c89a47 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -898,6 +898,37 @@ export class LexicalNode { return writableReplaceWith; } + /** + * Insert a series of nodes after this LexicalNode (as next siblings) + * + * @param firstToInsert - The first node to insert after this one. + * @param lastToInsert - The last node to insert after this one. Must be a + * later sibling of FirstNode. If not provided, it will be its last sibling. + * + * + * */ + insertRangeAfter(firstToInsert: LexicalNode, lastToInsert?: LexicalNode) { + const lastToInsert2 = + lastToInsert || firstToInsert.getParentOrThrow().getLastChild()!; + let current = firstToInsert; + const nodestToInsert = [firstToInsert]; + while (current !== lastToInsert2) { + if (!current.getNextSibling()) { + invariant( + false, + 'insertRangeAfter: lastToInsert must be a later sibling of firstToInsert', + ); + } + current = current.getNextSibling()!; + nodestToInsert.push(current); + } + + let currentNode: LexicalNode = this; + for (const node of nodestToInsert) { + currentNode = currentNode.insertAfter(node); + } + } + /** * Inserts a node after this LexicalNode (as the next sibling). * diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 2adfa9a4fb4..b1c0e7c22e1 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -56,7 +56,6 @@ import { $getNodeByKey, $getRoot, $hasAncestor, - $isRootOrShadowRoot, $isTokenOrSegmented, $setCompositionKey, doesContainGrapheme, @@ -1558,7 +1557,7 @@ export class RangeSelection implements BaseSelection { } const firstBlock = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!; - // case where we insert inside a code block + // CASE 1: insert inside a code block if ('__language' in firstBlock) { if ('__language' in nodes[0]) { this.insertText(nodes[0].getTextContent()); @@ -1573,60 +1572,72 @@ export class RangeSelection implements BaseSelection { return; } + // CASE 2: All elements of the array are inline const notInline = (node: LexicalNode) => ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline(); - const isMergeable = (node: LexicalNode) => - $isElementNode(node) && - INTERNAL_$isBlock(node) && - !node.isEmpty() && - $isElementNode(firstBlock) && - (!firstBlock.isEmpty() || - !$isRootOrShadowRoot(firstBlock.getParentOrThrow())); - - const firstNotInline = nodes.find(notInline); - - const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty(); - const insertedParagraph = shouldInsert ? this.insertParagraph() : null; const last = nodes[nodes.length - 1]!; const nodeToSelect = $isElementNode(last) ? last.getLastDescendant() || last : last; const nodeToSelectSize = nodeToSelect.getTextContentSize(); - - let currentBlock = firstBlock; - for (const node of nodes) { - if (node === firstNotInline && isMergeable(node)) { - currentBlock.append(...node.getChildren()); - } else if (notInline(node)) { - currentBlock = currentBlock.insertAfter(node) as ElementNode; + function restoreSelection() { + if (nodeToSelect.select) { + nodeToSelect.select(nodeToSelectSize, nodeToSelectSize); } else { - currentBlock.append(node); + nodeToSelect.selectNext(0, 0); } } + if (!nodes.some(notInline)) { + const index = removeTextAndSplitBlock(this); + firstBlock.splice(index, 0, nodes); + restoreSelection(); + return; + } + + // CASE 3: At least 1 element of the array is not inline + const blocks = $wrapInlineNodes(nodes).getChildren(); + const isLI = (node: LexicalNode) => + '__value' in node && '__checked' in node; + const isMergeable = (node: LexicalNode) => + $isElementNode(node) && + INTERNAL_$isBlock(node) && + !node.isEmpty() && + $isElementNode(firstBlock) && + (!firstBlock.isEmpty() || isLI(firstBlock)); + + const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty(); + const insertedParagraph = shouldInsert ? this.insertParagraph() : null; + const lastToInsert = blocks[blocks.length - 1]; + let firstToInsert = blocks[0]; + if (isMergeable(firstToInsert)) { + firstBlock.append(...firstToInsert.getChildren()); + firstToInsert = blocks[1]; + } + if (firstToInsert) { + firstBlock.insertRangeAfter(firstToInsert); + } + const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock)!; if ( insertedParagraph && - $isElementNode(currentBlock) && - INTERNAL_$isBlock(currentBlock) + $isElementNode(lastToInsert) && + (isLI(insertedParagraph) || INTERNAL_$isBlock(lastToInsert)) ) { - currentBlock.append(...insertedParagraph.getChildren()); + lastInsertedBlock.append(...insertedParagraph.getChildren()); insertedParagraph.remove(); } if ($isElementNode(firstBlock) && firstBlock.isEmpty()) { firstBlock.remove(); } - if (!nodeToSelect.select) { - nodeToSelect.selectNext(0, 0); - } else { - nodeToSelect.select(nodeToSelectSize, nodeToSelectSize); - } + restoreSelection(); + // To understand this take a look at the test "can wrap post-linebreak nodes into new element" const lastChild = $isElementNode(firstBlock) ? firstBlock.getLastChild() : null; - if ($isLineBreakNode(lastChild) && currentBlock !== firstBlock) { + if ($isLineBreakNode(lastChild) && lastToInsert !== firstBlock) { lastChild.remove(); } } @@ -3135,3 +3146,43 @@ function removeTextAndSplitBlock(selection: RangeSelection): number { } return pointParent.getIndexWithinParent() + x; } + +function $wrapInlineNodes(nodes: LexicalNode[]) { + // We temporarily insert the topLevelNodes into an arbitrary ElementNode, + // since insertAfter does not work on nodes that have no parent (TO-DO: fix that). + const virtualRoot = $createParagraphNode(); + + let currentBlock = null; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + const isLineBreakNode = $isLineBreakNode(node); + + if ( + isLineBreakNode || + ($isDecoratorNode(node) && node.isInline()) || + ($isElementNode(node) && node.isInline()) || + $isTextNode(node) || + node.isParentRequired() + ) { + if (currentBlock === null) { + currentBlock = node.createParentElementNode(); + virtualRoot.append(currentBlock); + // In the case of LineBreakNode, we just need to + // add an empty ParagraphNode to the topLevelBlocks. + if (isLineBreakNode) { + continue; + } + } + + if (currentBlock !== null) { + currentBlock.append(node); + } + } else { + virtualRoot.append(node); + currentBlock = null; + } + } + + return virtualRoot; +}