diff --git a/packages/lexical-link/src/index.ts b/packages/lexical-link/src/index.ts index cc9591fc1ac..70720c6f2dc 100644 --- a/packages/lexical-link/src/index.ts +++ b/packages/lexical-link/src/index.ts @@ -10,9 +10,11 @@ import type { DOMConversionMap, DOMConversionOutput, EditorConfig, + GridSelection, LexicalCommand, LexicalNode, NodeKey, + NodeSelection, RangeSelection, SerializedElementNode, } from 'lexical'; @@ -21,6 +23,7 @@ import {addClassNamesToElement} from '@lexical/utils'; import { $getSelection, $isElementNode, + $isRangeSelection, $setSelection, createCommand, ElementNode, @@ -133,6 +136,25 @@ export class LinkNode extends ElementNode { isInline(): true { return true; } + + extractWithChild( + child: LexicalNode, + selection: RangeSelection | NodeSelection | GridSelection, + destination: 'clone' | 'html', + ): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + + return ( + this.isParentOf(anchorNode) && + this.isParentOf(focusNode) && + selection.getTextContent().length > 0 + ); + } } function convertAnchorElement(domNode: Node): DOMConversionOutput { diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste.spec.mjs index 7222cbb454e..f14547580f9 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste.spec.mjs @@ -1809,4 +1809,82 @@ test.describe('CopyAndPaste', () => { `, ); }); + + test('HTML Copy + paste in front of or after a link', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await pasteFromClipboard(page, { + 'text/html': `textlinktext`, + }); + await moveToEditorBeginning(page); + await pasteFromClipboard(page, { + 'text/html': 'before', + }); + await moveToEditorEnd(page); + await pasteFromClipboard(page, { + 'text/html': 'after', + }); + await assertHTML( + page, + html` +

+ beforetext + + link + + textafter +

+ `, + ); + }); + + test('HTML Copy + paste link by selecting its (partial) content', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await pasteFromClipboard(page, { + 'text/html': `textlinktext`, + }); + await moveLeft(page, 5); + await page.keyboard.down('Shift'); + await moveLeft(page, 2); + await page.keyboard.up('Shift'); + const clipboard = await copyToClipboard(page); + await moveToEditorEnd(page); + await pasteFromClipboard(page, clipboard); + + await assertHTML( + page, + html` +

+ text + + link + + text + + in + +

+ `, + ); + }); }); diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index c5543f25fa7..ad3ba70a7da 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -7,13 +7,20 @@ */ import { + moveToLineBeginning, + pressShiftEnter, +} from '../keyboardShortcuts/index.mjs'; +import { + assertHTML, click, evaluate, expect, focusEditor, + html, initialize, insertImageCaption, insertSampleImage, + selectFromFormatDropdown, sleep, test, } from '../utils/index.mjs'; @@ -94,4 +101,38 @@ test.describe('Selection', () => { expect(await hasSelection('.image-caption-container')).toBe(true); expect(await hasSelection('.editor-shell')).toBe(false); }); + + test('can wrap post-linebreak nodes into new element', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await page.keyboard.type('Line1'); + await pressShiftEnter(page); + await page.keyboard.type('Line2'); + await page.keyboard.down('Shift'); + await moveToLineBeginning(page); + await page.keyboard.up('Shift'); + await selectFromFormatDropdown(page, '.code'); + await assertHTML( + page, + html` +

+ Line1 +
+ + Line2 + +

+ `, + ); + }); }); diff --git a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs index f01ee3a7420..1f7e6f89105 100644 --- a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs +++ b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs @@ -195,3 +195,9 @@ export async function toggleItalic(page) { await page.keyboard.press('i'); await keyUpCtrlOrMeta(page); } + +export async function pressShiftEnter(page) { + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Shift'); +} diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx index cfcd3fd3b8e..80889cefbc4 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx @@ -708,7 +708,6 @@ function BlockFormatDropDown({ } else { const textContent = selection.getTextContent(); const codeNode = $createCodeNode(); - selection.removeText(); selection.insertNodes([codeNode]); selection.insertRawText(textContent); } diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index a253f14e3fe..44e1b6205e3 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1355,7 +1355,8 @@ export class RangeSelection implements BaseSelection { } else if ( !$isElementNode(node) || ($isElementNode(node) && node.isInline()) || - ($isDecoratorNode(target) && target.isTopLevel()) + ($isDecoratorNode(target) && target.isTopLevel()) || + $isLineBreakNode(target) ) { lastNodeInserted = node; target = target.insertAfter(node); @@ -1399,13 +1400,18 @@ export class RangeSelection implements BaseSelection { } } if (siblings.length !== 0) { + const originalTarget = target; for (let i = siblings.length - 1; i >= 0; i--) { const sibling = siblings[i]; const prevParent = sibling.getParentOrThrow(); - if ($isElementNode(target) && !$isElementNode(sibling)) { - target.append(sibling); + if ($isElementNode(target) && !$isBlockElementNode(sibling)) { + if (originalTarget === target) { + target.append(sibling); + } else { + target.insertBefore(sibling); + } target = sibling; - } else if (!$isElementNode(target) && !$isElementNode(sibling)) { + } else if (!$isElementNode(target) && !$isBlockElementNode(sibling)) { target.insertBefore(sibling); target = sibling; } else { @@ -2176,6 +2182,12 @@ function internalResolveSelectionPoints( return [resolvedAnchorPoint, resolvedFocusPoint]; } +function $isBlockElementNode( + node: LexicalNode | null | undefined, +): node is ElementNode { + return $isElementNode(node) && !node.isInline(); +} + // This is used to make a selection when the existing // selection is null, i.e. forcing selection on the editor // when it current exists outside the editor.