Skip to content

Commit

Permalink
Improvements in insertNodes (#5201)
Browse files Browse the repository at this point in the history
Co-authored-by: EgonBolton <[email protected]>
  • Loading branch information
GermanJablo and GermanJablo authored Dec 1, 2023
1 parent 96072d5 commit 6578046
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 178 deletions.
56 changes: 1 addition & 55 deletions packages/lexical-clipboard/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ import {
$createTabNode,
$getRoot,
$getSelection,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
$parseSerializedNode,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/lexical-playground/__tests__/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1934,50 +1934,6 @@ describe('LexicalSelectionHelpers tests', () => {
'<h1 dir="ltr"><span data-lexical-text="true">foo</span></h1>',
);
});

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(
'<h1 dir="ltr"><span data-lexical-text="true">foobar</span></h1>',
);
});
});

describe('with a paragraph node selected on some existing text', () => {
Expand Down Expand Up @@ -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(
'<p dir="ltr"><span data-lexical-text="true">Existing text...foobar</span></p>',
);
});

test('a paragraph with a child text and a child italic text and a child text', async () => {
const editor = createTestEditor();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ describe('LexicalEventHelpers', () => {
},
{
expectedHTML:
'<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">a</span><br><span data-lexical-text="true">b</span><br><br></p>',
'<p class="editor-paragraph" dir="ltr"><br><span data-lexical-text="true">a</span><br><span data-lexical-text="true">b</span><br><br></p>',
inputs: [
pasteHTML(`<span style="white-space: pre">\na\r\nb\r\n</span>`),
],
Expand Down
31 changes: 31 additions & 0 deletions packages/lexical/src/LexicalNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
113 changes: 82 additions & 31 deletions packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ import {
$getNodeByKey,
$getRoot,
$hasAncestor,
$isRootOrShadowRoot,
$isTokenOrSegmented,
$setCompositionKey,
doesContainGrapheme,
Expand Down Expand Up @@ -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());
Expand All @@ -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();
}
}
Expand Down Expand Up @@ -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;
}

2 comments on commit 6578046

@vercel
Copy link

@vercel vercel bot commented on 6578046 Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

lexical – ./packages/lexical-website

lexical-git-main-fbopensource.vercel.app
lexical-fbopensource.vercel.app
lexical.dev
lexicaljs.com
www.lexical.dev
lexicaljs.org

@vercel
Copy link

@vercel vercel bot commented on 6578046 Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

lexical-playground – ./packages/lexical-playground

lexical-playground.vercel.app
lexical-playground-git-main-fbopensource.vercel.app
playground.lexical.dev
lexical-playground-fbopensource.vercel.app

Please sign in to comment.