From 0b9ef956a4f2aa5e5d8240816b6ffc9699c48910 Mon Sep 17 00:00:00 2001 From: Sherry Date: Fri, 3 May 2024 10:18:51 +0800 Subject: [PATCH] [lexical-html] Feature: Support copy pasting block and inline nodes properly (#5857) --- packages/lexical-code/src/CodeNode.ts | 31 +-- packages/lexical-html/src/index.ts | 97 +++++++- .../__tests__/e2e/CodeBlock.spec.mjs | 8 +- .../html/HTMLCopyAndPaste.spec.mjs | 225 ++++++++++++++++++ .../html/ListsHTMLCopyAndPaste.spec.mjs | 57 +++++ .../html/TablesHTMLCopyAndPaste.spec.mjs | 100 ++++++++ packages/lexical-utils/src/index.ts | 8 +- packages/lexical/src/LexicalEditor.ts | 2 + packages/lexical/src/LexicalUtils.ts | 26 ++ packages/lexical/src/index.ts | 3 + packages/lexical/src/nodes/ArtificialNode.ts | 23 ++ packages/lexical/src/nodes/LexicalTextNode.ts | 8 +- 12 files changed, 545 insertions(+), 43 deletions(-) create mode 100644 packages/lexical/src/nodes/ArtificialNode.ts diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts index ef2588aa598..361accf2cc1 100644 --- a/packages/lexical-code/src/CodeNode.ts +++ b/packages/lexical-code/src/CodeNode.ts @@ -168,13 +168,7 @@ export class CodeNode extends ElementNode { const td = node as HTMLTableCellElement; const table: HTMLTableElement | null = td.closest('table'); - if (isGitHubCodeCell(td)) { - return { - conversion: convertTableCellElement, - priority: 3, - }; - } - if (table && isGitHubCodeTable(table)) { + if (isGitHubCodeCell(td) || (table && isGitHubCodeTable(table))) { // Return a no-op if it's a table cell in a code table, but not a code line. // Otherwise it'll fall back to the T return { @@ -348,13 +342,6 @@ function convertDivElement(domNode: Node): DOMConversionOutput { }; } return { - after: (childLexicalNodes) => { - const domParent = domNode.parentNode; - if (domParent != null && domNode !== domParent.lastChild) { - childLexicalNodes.push($createLineBreakNode()); - } - return childLexicalNodes; - }, node: isCode ? $createCodeNode() : null, }; } @@ -367,22 +354,6 @@ function convertCodeNoop(): DOMConversionOutput { return {node: null}; } -function convertTableCellElement(domNode: Node): DOMConversionOutput { - // domNode is a since we matched it by nodeName - const cell = domNode as HTMLTableCellElement; - - return { - after: (childLexicalNodes) => { - if (cell.parentNode && cell.parentNode.nextSibling) { - // Append newline between code lines - childLexicalNodes.push($createLineBreakNode()); - } - return childLexicalNodes; - }, - node: null, - }; -} - function isCodeElement(div: HTMLElement): boolean { return div.style.fontFamily.match('monospace') !== null; } diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 52f946f8057..415e0217cf7 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -19,8 +19,18 @@ import { $cloneWithProperties, $sliceSelectedTextNodeContent, } from '@lexical/selection'; -import {isHTMLElement} from '@lexical/utils'; -import {$getRoot, $isElementNode, $isTextNode} from 'lexical'; +import {isBlockDomNode, isHTMLElement} from '@lexical/utils'; +import { + $createLineBreakNode, + $createParagraphNode, + $getRoot, + $isBlockElementNode, + $isElementNode, + $isRootOrShadowRoot, + $isTextNode, + ArtificialNode__DO_NOT_USE, + ElementNode, +} from 'lexical'; /** * How you parse your html string to get a document is left up to you. In the browser you can use the native @@ -33,15 +43,22 @@ export function $generateNodesFromDOM( ): Array { const elements = dom.body ? dom.body.childNodes : []; let lexicalNodes: Array = []; + const allArtificialNodes: Array = []; for (let i = 0; i < elements.length; i++) { const element = elements[i]; if (!IGNORE_TAGS.has(element.nodeName)) { - const lexicalNode = $createNodesFromDOM(element, editor); + const lexicalNode = $createNodesFromDOM( + element, + editor, + allArtificialNodes, + false, + ); if (lexicalNode !== null) { lexicalNodes = lexicalNodes.concat(lexicalNode); } } } + unwrapArtificalNodes(allArtificialNodes); return lexicalNodes; } @@ -161,7 +178,6 @@ function getConversionFunction( if (cachedConversions !== undefined) { for (const cachedConversion of cachedConversions) { const domConversion = cachedConversion(domNode); - if ( domConversion !== null && (currentConversion === null || @@ -180,6 +196,8 @@ const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); function $createNodesFromDOM( node: Node, editor: LexicalEditor, + allArtificialNodes: Array, + hasBlockAncestorLexicalNode: boolean, forChildMap: Map = new Map(), parentLexicalNode?: LexicalNode | null | undefined, ): Array { @@ -234,11 +252,20 @@ function $createNodesFromDOM( const children = node.childNodes; let childLexicalNodes = []; + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode != null && + $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; + for (let i = 0; i < children.length; i++) { childLexicalNodes.push( ...$createNodesFromDOM( children[i], editor, + allArtificialNodes, + hasBlockAncestorLexicalNodeForChildren, new Map(forChildMap), currentLexicalNode, ), @@ -249,6 +276,22 @@ function $createNodesFromDOM( childLexicalNodes = postTransform(childLexicalNodes); } + if (isBlockDomNode(node)) { + if (!hasBlockAncestorLexicalNodeForChildren) { + childLexicalNodes = wrapContinuousInlines( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }); + } + } + if (currentLexicalNode == null) { // If it hasn't been converted to a LexicalNode, we hoist its children // up to the same level as it. @@ -263,3 +306,49 @@ function $createNodesFromDOM( return lexicalNodes; } + +function wrapContinuousInlines( + domNode: Node, + nodes: Array, + createWrapperFn: () => ElementNode, +): Array { + const out: Array = []; + let continuousInlines: Array = []; + // wrap contiguous inline child nodes in para + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isBlockElementNode(node)) { + out.push(node); + } else { + continuousInlines.push(node); + if ( + i === nodes.length - 1 || + (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) + ) { + const wrapper = createWrapperFn(); + wrapper.append(...continuousInlines); + out.push(wrapper); + continuousInlines = []; + } + } + } + return out; +} + +function unwrapArtificalNodes( + allArtificialNodes: Array, +) { + for (const node of allArtificialNodes) { + if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { + node.insertAfter($createLineBreakNode()); + } + } + // Replace artificial node with it's children + for (const node of allArtificialNodes) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + } +} diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs index 5d6a72fb4a6..4ec9ee67b67 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs @@ -1027,7 +1027,11 @@ test.describe('CodeBlock', () => {

- XDS_RICH_TEXT_AREA + + XDS_RICH_TEXT_AREA +

@@ -1120,7 +1124,7 @@ test.describe('CodeBlock', () => { { expectedHTML: EXPECTED_HTML_GOOGLE_SPREADSHEET, name: 'Google Spreadsheet', - pastedHTML: `
SurfaceMWP_WORK_LS_COMPOSER77349
LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
`, + pastedHTML: `
SurfaceMWP_WORK_LS_COMPOSER77349
LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
`, }, ]; diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs index 3b832edde30..cb0a9bed93a 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs @@ -275,4 +275,229 @@ test.describe('HTML CopyAndPaste', () => { `, ); }); + + test('Copy + paste single div', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + + const clipboard = { + 'text/html': ` + 123 +
+ 456 +
`, + }; + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +

+ 123 +

+

+ 456 +

+ `, + ); + await assertSelection(page, { + anchorOffset: 3, + anchorPath: [1, 0, 0], + focusOffset: 3, + focusPath: [1, 0, 0], + }); + }); + + test('Copy + paste nested divs', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + + const clipboard = { + 'text/html': html` +
+ a +
+ b b +
+ c +
+
+ z +
+
+ d e +
+ fg +
+ `, + }; + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +

+ a +

+

+ b b +

+

+ c +

+

+ z +

+

+ d e +

+

+ fg +

+ `, + ); + await assertSelection(page, { + anchorOffset: 2, + anchorPath: [5, 0, 0], + focusOffset: 2, + focusPath: [5, 0, 0], + }); + }); + + test('Copy + paste nested div in a span', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + + const clipboard = { + 'text/html': html` + + 123 +
456
+
+ `, + }; + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +

+ 123 +

+

+ 456 +

+ `, + ); + await assertSelection(page, { + anchorOffset: 3, + anchorPath: [1, 0, 0], + focusOffset: 3, + focusPath: [1, 0, 0], + }); + }); + + test('Copy + paste nested span in a div', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + + const clipboard = { + 'text/html': html` +
+ + 123 +
456
+
+
+ `, + }; + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +

+ 123 +

+

+ 456 +

+ `, + ); + await assertSelection(page, { + anchorOffset: 3, + anchorPath: [1, 0, 0], + focusOffset: 3, + focusPath: [1, 0, 0], + }); + }); + + test('Copy + paste multiple nested spans and divs', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + const clipboard = { + 'text/html': html` +
+ a b + + c d + e + +
+ f + g h +
+
+ `, + }; + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +

+ a b c d e +

+

+ f g h +

+ `, + ); + await assertSelection(page, { + anchorOffset: 5, + anchorPath: [1, 0, 0], + focusOffset: 5, + focusPath: [1, 0, 0], + }); + }); }); diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs index 33bf100adad..dbb58e82d3c 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs @@ -416,4 +416,61 @@ test.describe('HTML Lists CopyAndPaste', () => { `, ); }); + + test('Copy + paste a nested divs in a list', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + + const clipboard = { + 'text/html': html` +
    +
  1. + 1 +
    2
    + 3 +
  2. +
  3. + A +
    B
    + C +
  4. +
+ `, + }; + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +
    +
  1. + 1 +
    + 2 +
    + 3 +
  2. +
  3. + A +
    + B +
    + C +
  4. +
+ `, + ); + + await assertSelection(page, { + anchorOffset: 1, + anchorPath: [0, 1, 4, 0], + focusOffset: 1, + focusPath: [0, 1, 4, 0], + }); + }); }); diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs index d697742b6bc..3b1c2693400 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs @@ -471,4 +471,104 @@ test.describe('HTML Tables CopyAndPaste', () => { `, ); }); + + test('Copy + paste nested block and inline html in a table', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + + test.fixme( + isCollab, + 'Table selection styles are not properly synced to the right hand frame', + ); + + await focusEditor(page); + + const clipboard = { + 'text/html': html` + 123 + + + + + + + + + + + +
+ 456 + + 789 +
+ 000 +
+
+ ABC +
+ 000 +
+ 000 +
+
+
+ DEF +
+ `, + }; + + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +

+ 123 +

+ + + + + + + + + +
+

+ 456 +

+
+

+ 789 +

+

+ 000 +

+
+

+ ABC +

+

+ 000 +

+

+ 000 +

+
+

+ DEF +

+
+ `, + ); + }); }); diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 2a52e780973..3d2346cc009 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -44,7 +44,13 @@ import normalizeClassNames from 'shared/normalizeClassNames'; export {default as markSelection} from './markSelection'; export {default as mergeRegister} from './mergeRegister'; export {default as positionNodeOnRange} from './positionNodeOnRange'; -export {$splitNode, isHTMLAnchorElement, isHTMLElement} from 'lexical'; +export { + $splitNode, + isBlockDomNode, + isHTMLAnchorElement, + isHTMLElement, + isInlineDomNode, +} from 'lexical'; // Hotfix to export these with inlined types #5918 export const CAN_USE_BEFORE_INPUT: boolean = CAN_USE_BEFORE_INPUT_; export const CAN_USE_DOM: boolean = CAN_USE_DOM_; diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 79b3bc0f3e3..468cfbd413a 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -37,6 +37,7 @@ import { getDOMSelection, markAllNodesAsDirty, } from './LexicalUtils'; +import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; import {DecoratorNode} from './nodes/LexicalDecoratorNode'; import {LineBreakNode} from './nodes/LexicalLineBreakNode'; import {ParagraphNode} from './nodes/LexicalParagraphNode'; @@ -421,6 +422,7 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { LineBreakNode, TabNode, ParagraphNode, + ArtificialNode__DO_NOT_USE, ...(config.nodes || []), ]; const {onError, html} = config; diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index f9af189d991..52d78f07f36 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1584,6 +1584,32 @@ export function isHTMLElement(x: Node | EventTarget): x is HTMLElement { return x.nodeType === 1; } +/** + * + * @param node - the Dom Node to check + * @returns if the Dom Node is an inline node + */ +export function isInlineDomNode(node: Node) { + const inlineNodes = new RegExp( + /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/, + 'i', + ); + return node.nodeName.match(inlineNodes) !== null; +} + +/** + * + * @param node - the Dom Node to check + * @returns if the Dom Node is a block node + */ +export function isBlockDomNode(node: Node) { + const blockNodes = new RegExp( + /^(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|td|tfoot|ul|video)$/, + 'i', + ); + return node.nodeName.match(blockNodes) !== null; +} + /** * This function is for internal use of the library. * Please do not use it as it may change in the future. diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 01c14c34ada..d46e369b288 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -157,11 +157,14 @@ export { $setSelection, $splitNode, getNearestEditorFromDOMNode, + isBlockDomNode, isHTMLAnchorElement, isHTMLElement, + isInlineDomNode, isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, } from './LexicalUtils'; +export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode'; export {$isElementNode, ElementNode} from './nodes/LexicalElementNode'; export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode'; diff --git a/packages/lexical/src/nodes/ArtificialNode.ts b/packages/lexical/src/nodes/ArtificialNode.ts new file mode 100644 index 00000000000..0f01d2c3493 --- /dev/null +++ b/packages/lexical/src/nodes/ArtificialNode.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {EditorConfig} from 'lexical'; + +import {ElementNode} from './LexicalElementNode'; + +// TODO: Cleanup ArtificialNode__DO_NOT_USE #5966 +export class ArtificialNode__DO_NOT_USE extends ElementNode { + static getType(): string { + return 'artificial'; + } + + createDOM(config: EditorConfig): HTMLElement { + // this isnt supposed to be used and is not used anywhere but defining it to appease the API + const dom = document.createElement('div'); + return dom; + } +} diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index 26bc0f63849..269fa116af8 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -62,6 +62,7 @@ import { getCachedClassNameArray, internalMarkSiblingsAsDirty, isHTMLElement, + isInlineDomNode, toggleTextFormatType, } from '../LexicalUtils'; import {$createLineBreakNode} from './LexicalLineBreakNode'; @@ -1261,11 +1262,6 @@ function convertTextDOMNode(domNode: Node): DOMConversionOutput { return {node: $createTextNode(textContent)}; } -const inlineParents = new RegExp( - /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/, - 'i', -); - function findTextInLine(text: Text, forward: boolean): null | Text { let node: Node = text; // eslint-disable-next-line no-constant-condition @@ -1284,7 +1280,7 @@ function findTextInLine(text: Text, forward: boolean): null | Text { if (node.nodeType === DOM_ELEMENT_TYPE) { const display = (node as HTMLElement).style.display; if ( - (display === '' && node.nodeName.match(inlineParents) === null) || + (display === '' && !isInlineDomNode(node)) || (display !== '' && !display.startsWith('inline')) ) { return null;