From 3ebcd7c4619e1d84bf4ba725c21bd1b73bf1726e Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 14 Oct 2021 18:21:53 +0100 Subject: [PATCH] More insertNode fixes (#715) * More insertNode fixes --- .../__tests__/e2e/CopyAndPaste-test.js | 88 ++++++++++++++++++- packages/outline/src/core/OutlineBlockNode.js | 3 + .../src/extensions/OutlineListItemNode.js | 4 + .../src/helpers/OutlineSelectionHelpers.js | 22 +++-- scripts/error-codes/codes.json | 59 +++++++------ 5 files changed, 139 insertions(+), 37 deletions(-) diff --git a/packages/outline-playground/__tests__/e2e/CopyAndPaste-test.js b/packages/outline-playground/__tests__/e2e/CopyAndPaste-test.js index dcf879b50..302da5e8f 100644 --- a/packages/outline-playground/__tests__/e2e/CopyAndPaste-test.js +++ b/packages/outline-playground/__tests__/e2e/CopyAndPaste-test.js @@ -20,6 +20,7 @@ import { pasteFromClipboard, E2E_BROWSER, IS_LINUX, + IS_WINDOWS, } from '../utils'; describe('CopyAndPaste', () => { @@ -301,7 +302,7 @@ describe('CopyAndPaste', () => { }); }); - it('Copy and paste of partial list items', async () => { + it('Copy and paste of partial list items into an empty editor', async () => { const {isRichText, page} = e2e; if (!isRichText) { @@ -380,6 +381,91 @@ describe('CopyAndPaste', () => { }); }); + it('Copy and paste of partial list items into the list', async () => { + const {isRichText, page} = e2e; + + if (!isRichText) { + return; + } + + await page.focus('div.editor'); + + // Add three list items + await page.keyboard.type('- One'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Two'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Three'); + + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + + // Add a paragraph + await page.keyboard.type('Some text.'); + + await assertHTML( + page, + '

Some text.

', + ); + await assertSelection(page, { + anchorPath: [1, 0, 0], + anchorOffset: 10, + focusPath: [1, 0, 0], + focusOffset: 10, + }); + + await page.keyboard.down('Shift'); + await moveToLineBeginning(page); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.up('Shift'); + + await assertSelection(page, { + anchorPath: [1, 0, 0], + anchorOffset: 10, + focusPath: [0, 2, 0, 0], + focusOffset: 3, + }); + + // Copy the partial list item and paragraph + const clipboard = await copyToClipboard(page); + + // Select all and remove content + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + if (!IS_WINDOWS && E2E_BROWSER === 'firefox') { + await page.keyboard.press('ArrowUp'); + } + await moveToLineEnd(page); + + await page.keyboard.down('Enter'); + + await assertHTML( + page, + '

Some text.

', + ); + await assertSelection(page, { + anchorPath: [0, 1], + anchorOffset: 0, + focusPath: [0, 1], + focusOffset: 0, + }); + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + '

Some text.

Some text.

', + ); + await assertSelection(page, { + anchorPath: [1, 0, 0], + anchorOffset: 10, + focusPath: [1, 0, 0], + focusOffset: 10, + }); + }); + it('Copy and paste of list items and paste back into list', async () => { const {isRichText, page} = e2e; diff --git a/packages/outline/src/core/OutlineBlockNode.js b/packages/outline/src/core/OutlineBlockNode.js index 0fe17b1bc..5e04e3732 100644 --- a/packages/outline/src/core/OutlineBlockNode.js +++ b/packages/outline/src/core/OutlineBlockNode.js @@ -291,6 +291,9 @@ export class BlockNode extends OutlineNode { canReplaceWith(replacement: OutlineNode): boolean { return true; } + canInsertAfter(node: OutlineNode): boolean { + return true; + } canBeEmpty(): boolean { return true; } diff --git a/packages/outline/src/extensions/OutlineListItemNode.js b/packages/outline/src/extensions/OutlineListItemNode.js index 7f79fab80..067964426 100644 --- a/packages/outline/src/extensions/OutlineListItemNode.js +++ b/packages/outline/src/extensions/OutlineListItemNode.js @@ -171,6 +171,10 @@ export class ListItemNode extends BlockNode { return true; } + canInsertAfter(node: OutlineNode): boolean { + return isListItemNode(node); + } + canReplaceWith(replacement: OutlineNode): boolean { return isListItemNode(replacement); } diff --git a/packages/outline/src/helpers/OutlineSelectionHelpers.js b/packages/outline/src/helpers/OutlineSelectionHelpers.js index d0c3ea083..50bcc4811 100644 --- a/packages/outline/src/helpers/OutlineSelectionHelpers.js +++ b/packages/outline/src/helpers/OutlineSelectionHelpers.js @@ -1037,21 +1037,29 @@ export function insertNodes( if (siblings.length !== 0) { for (let i = siblings.length - 1; i >= 0; i--) { const sibling = siblings[i]; - const prevParent = sibling.getParent(); + const prevParent = sibling.getParentOrThrow(); if (isBlockNode(target) && !isBlockNode(sibling)) { target.append(sibling); target = sibling; } else { - target.insertAfter(sibling); + if (isBlockNode(sibling) && !sibling.canInsertAfter(target)) { + const prevParentClone = prevParent.constructor.clone(prevParent); + if (!isBlockNode(prevParentClone)) { + invariant( + false, + 'insertNodes: cloned parent clone is not a block', + ); + } + prevParentClone.append(sibling); + target.insertAfter(prevParentClone); + } else { + target.insertAfter(sibling); + } } // Check if the prev parent is empty, as it might need // removing. - if ( - isBlockNode(prevParent) && - prevParent.isEmpty() && - !prevParent.canBeEmpty() - ) { + if (prevParent.isEmpty() && !prevParent.canBeEmpty()) { prevParent.remove(); } } diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 949a87235..3e893d2f0 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -3,36 +3,36 @@ "1": "clearEditor expected plain text root first child to be a ParagraphNode", "2": "Expected rich text root first child to be a ParagraphNode", "3": "insertAfter: list node is not parent of list item node", - "4": "createOffsetModel: could not find node by key", - "5": "insertText: first node is not a text node", + "4": "insertText: first node is not a text node", + "5": "createOffsetModel: could not find node by key", "6": "Editor.getLatestTextContent() can be asynchronous and cannot be used within Editor.update()", "7": "setViewModel: the view model is empty. Ensure the view model's root node never becomes empty.", - "8": "decorate: base method not extended", - "9": "updateDOM: prevInnerDOM is null or undefined", - "10": "updateDOM: innerDOM is null or undefined", - "11": "setFormat: can only be used on non-immutable nodes", - "12": "setStyle: can only be used on non-immutable nodes", - "13": "setTextContent: can only be used on non-immutable text nodes", - "14": "spliceText: can only be used on non-immutable text nodes", - "15": "spliceText: selection not found", - "16": "splitText: can only be used on non-immutable text nodes", - "17": "OutlineNode: Node type %s does not implement .clone().", - "18": "Expected node %s to have a parent.", - "19": "Expected node %s to have a parent block.", - "20": "Expected node %s to have a top parent block.", - "21": "getNodesBetween: ancestor is null", - "22": "getLatest: node not found", - "23": "createDOM: base method not extended", - "24": "updateDOM: base method not extended", - "25": "setFlags: can only be used on non-immutable nodes", - "26": "Expected node with key %s to exist but it's not in the nodeMap.", - "27": "createNodeFromParse: type \"%s\" + not found", - "28": "select: cannot be called on root nodes", - "29": "remove: cannot be called on root nodes", - "30": "replace: cannot be called on root nodes", - "31": "insertBefore: cannot be called on root nodes", - "32": "insertAfter: cannot be called on root nodes", - "33": "rootNode.append: Only block nodes can be appended to the root node", + "8": "select: cannot be called on root nodes", + "9": "remove: cannot be called on root nodes", + "10": "replace: cannot be called on root nodes", + "11": "insertBefore: cannot be called on root nodes", + "12": "insertAfter: cannot be called on root nodes", + "13": "rootNode.append: Only block nodes can be appended to the root node", + "14": "updateDOM: prevInnerDOM is null or undefined", + "15": "updateDOM: innerDOM is null or undefined", + "16": "setFormat: can only be used on non-immutable nodes", + "17": "setStyle: can only be used on non-immutable nodes", + "18": "setTextContent: can only be used on non-immutable text nodes", + "19": "spliceText: can only be used on non-immutable text nodes", + "20": "spliceText: selection not found", + "21": "splitText: can only be used on non-immutable text nodes", + "22": "decorate: base method not extended", + "23": "OutlineNode: Node type %s does not implement .clone().", + "24": "Expected node %s to have a parent.", + "25": "Expected node %s to have a parent block.", + "26": "Expected node %s to have a top parent block.", + "27": "getNodesBetween: ancestor is null", + "28": "getLatest: node not found", + "29": "createDOM: base method not extended", + "30": "updateDOM: base method not extended", + "31": "setFlags: can only be used on non-immutable nodes", + "32": "Expected node with key %s to exist but it's not in the nodeMap.", + "33": "createNodeFromParse: type \"%s\" + not found", "34": "Editor.update() cannot be used within a text node transform.", "35": "Cannot use method in read-only mode.", "36": "Unable to find an active view model. View methods or node methods can only be used synchronously during the callback of editor.update() or viewModel.read().", @@ -43,5 +43,6 @@ "41": "createNode: node does not exist in nodeMap", "42": "reconcileNode: prevNode or nextNode does not exist in nodeMap", "43": "reconcileNode: parentDOM is null", - "44": "Reconciliation: could not find DOM element for node key \"${key}\"" + "44": "Reconciliation: could not find DOM element for node key \"${key}\"", + "45": "insertNodes: cloned parent clone is not a block" }