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;
+}