diff --git a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts index 94582207ffc..a4c49cb6dfd 100644 --- a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts +++ b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts @@ -15,14 +15,19 @@ import { } from '@lexical/link'; import {$createMarkNode, $isMarkNode} from '@lexical/mark'; import { + $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, + $getSelection, + $isLineBreakNode, + $isRangeSelection, + $isTextNode, $selectAll, ParagraphNode, + RangeSelection, SerializedParagraphNode, - TextNode, -} from 'lexical/src'; +} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; const editorConfig = Object.freeze({ @@ -47,20 +52,20 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('/'); + const linkNode = $createLinkNode('/'); expect(linkNode.__type).toBe('link'); expect(linkNode.__url).toBe('/'); }); - expect(() => new LinkNode('')).toThrow(); + expect(() => $createLinkNode('')).toThrow(); }); test('LineBreakNode.clone()', async () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('/'); + const linkNode = $createLinkNode('/'); const linkNodeClone = LinkNode.clone(linkNode); @@ -73,7 +78,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo'); + const linkNode = $createLinkNode('https://example.com/foo'); expect(linkNode.getURL()).toBe('https://example.com/foo'); }); @@ -83,7 +88,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo'); + const linkNode = $createLinkNode('https://example.com/foo'); expect(linkNode.getURL()).toBe('https://example.com/foo'); @@ -97,7 +102,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { target: '_blank', }); @@ -109,7 +114,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { target: '_blank', }); @@ -125,7 +130,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { rel: 'noopener noreferrer', target: '_blank', }); @@ -138,7 +143,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { rel: 'noopener', target: '_blank', }); @@ -155,7 +160,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { title: 'Hello world', }); @@ -167,7 +172,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { title: 'Hello world', }); @@ -183,7 +188,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo'); + const linkNode = $createLinkNode('https://example.com/foo'); expect(linkNode.createDOM(editorConfig).outerHTML).toBe( '', @@ -201,7 +206,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { rel: 'noopener noreferrer', target: '_blank', title: 'Hello world', @@ -226,7 +231,7 @@ describe('LexicalLinkNode tests', () => { await editor.update(() => { // eslint-disable-next-line no-script-url - const linkNode = new LinkNode('javascript:alert(0)'); + const linkNode = $createLinkNode('javascript:alert(0)'); expect(linkNode.createDOM(editorConfig).outerHTML).toBe( '', ); @@ -237,7 +242,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo'); + const linkNode = $createLinkNode('https://example.com/foo'); const domElement = linkNode.createDOM(editorConfig); @@ -245,7 +250,7 @@ describe('LexicalLinkNode tests', () => { '', ); - const newLinkNode = new LinkNode('https://example.com/bar'); + const newLinkNode = $createLinkNode('https://example.com/bar'); const result = newLinkNode.updateDOM( linkNode, domElement, @@ -263,7 +268,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { rel: 'noopener noreferrer', target: '_blank', title: 'Hello world', @@ -275,7 +280,7 @@ describe('LexicalLinkNode tests', () => { '', ); - const newLinkNode = new LinkNode('https://example.com/bar', { + const newLinkNode = $createLinkNode('https://example.com/bar', { rel: 'noopener', target: '_self', title: 'World hello', @@ -297,7 +302,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { rel: 'noopener noreferrer', target: '_blank', title: 'Hello world', @@ -309,7 +314,7 @@ describe('LexicalLinkNode tests', () => { '', ); - const newLinkNode = new LinkNode('https://example.com/bar'); + const newLinkNode = $createLinkNode('https://example.com/bar'); const result = newLinkNode.updateDOM( linkNode, domElement, @@ -327,7 +332,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo'); + const linkNode = $createLinkNode('https://example.com/foo'); expect(linkNode.canInsertTextBefore()).toBe(false); }); @@ -337,7 +342,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo'); + const linkNode = $createLinkNode('https://example.com/foo'); expect(linkNode.canInsertTextAfter()).toBe(false); }); @@ -347,7 +352,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo'); + const linkNode = $createLinkNode('https://example.com/foo'); const createdLinkNode = $createLinkNode('https://example.com/foo'); @@ -362,7 +367,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode('https://example.com/foo', { + const linkNode = $createLinkNode('https://example.com/foo', { rel: 'noopener noreferrer', target: '_blank', title: 'Hello world', @@ -388,7 +393,7 @@ describe('LexicalLinkNode tests', () => { const {editor} = testEnv; await editor.update(() => { - const linkNode = new LinkNode(''); + const linkNode = $createLinkNode(''); expect($isLinkNode(linkNode)).toBe(true); }); @@ -397,14 +402,27 @@ describe('LexicalLinkNode tests', () => { test('$toggleLink applies the title attribute when creating', async () => { const {editor} = testEnv; await editor.update(() => { - const p = new ParagraphNode(); - p.append(new TextNode('Some text')); + const p = $createParagraphNode(); + const textNode = $createTextNode('Some text'); + p.append(textNode); $getRoot().append(p); - }); - - await editor.update(() => { $selectAll(); $toggleLink('https://lexical.dev/', {title: 'Lexical Website'}); + const linkNode = p.getFirstChild() as LinkNode; + expect($isLinkNode(linkNode)).toBe(true); + expect(linkNode.getTitle()).toBe('Lexical Website'); + const selection = $getSelection() as RangeSelection; + expect($isRangeSelection(selection)).toBe(true); + expect(selection.anchor).toMatchObject({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); + expect(selection.focus).toMatchObject({ + key: textNode.getKey(), + offset: textNode.getTextContentSize(), + type: 'text', + }); }); const paragraph = editor!.getEditorState().toJSON().root @@ -442,6 +460,7 @@ describe('LexicalLinkNode tests', () => { expect(textNode.getTextContent()).toBe('some '); // Check link node and its nested structure + expect($isLinkNode(linkNode)).toBe(true); if ($isLinkNode(linkNode)) { expect(linkNode.getURL()).toBe('https://example.com/foo'); expect(linkNode.getRel()).toBe('noreferrer'); @@ -470,6 +489,7 @@ describe('LexicalLinkNode tests', () => { expect(textNode.getTextContent()).toBe('some '); // Check mark node is preserved and moved up to paragraph level + expect($isMarkNode(markNode)).toBe(true); if ($isMarkNode(markNode)) { expect(markNode.getType()).toBe('mark'); expect(markNode.getIDs()).toEqual(['knetk']); @@ -477,5 +497,64 @@ describe('LexicalLinkNode tests', () => { } }); }); + + test('$toggleLink adds link with embedded LineBreakNode', async () => { + const {editor} = testEnv; + await editor.update(() => { + const paragraph = $createParagraphNode(); + const precedingText = $createTextNode('some '); // space after + const textNode = $createTextNode('text'); + paragraph.append(precedingText, textNode, $createLineBreakNode()); + $getRoot().clear().append(paragraph); + paragraph.select(1); + $toggleLink('https://example.com/foo', { + rel: 'noreferrer', + }); + }); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const [precedingText, linkNode] = paragraph.getChildren(); + + // Check first text node + expect(precedingText.getTextContent()).toBe('some '); + + // Check link node and its nested structure + expect($isLinkNode(linkNode)).toBe(true); + if ($isLinkNode(linkNode)) { + expect(linkNode.getURL()).toBe('https://example.com/foo'); + expect(linkNode.getRel()).toBe('noreferrer'); + expect( + linkNode.getChildren().map((node) => node.getTextContent()), + ).toEqual(['text', '\n']); + expect($getSelection()).toMatchObject({ + anchor: { + key: linkNode.getFirstChildOrThrow().getKey(), + offset: 0, + type: 'text', + }, + focus: {key: linkNode.getKey(), offset: 2, type: 'element'}, + }); + } + }); + + await editor.update(() => { + $selectAll(); + $toggleLink(null); + }); + + // Verify structure after link removal + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const children = paragraph.getChildren(); + expect(children.map((node) => node.getTextContent())).toEqual([ + 'some text', + '\n', + ]); + const [textNode, lineBreakNode] = children; + expect($isTextNode(textNode)).toBe(true); + expect($isLineBreakNode(lineBreakNode)).toBe(true); + }); + }); }); }); diff --git a/packages/lexical-link/src/index.ts b/packages/lexical-link/src/index.ts index 1ddb4dcec47..b2cdaefc89c 100644 --- a/packages/lexical-link/src/index.ts +++ b/packages/lexical-link/src/index.ts @@ -14,6 +14,7 @@ import type { LexicalCommand, LexicalNode, NodeKey, + Point, RangeSelection, SerializedElementNode, } from 'lexical'; @@ -28,10 +29,13 @@ import { $getSelection, $isElementNode, $isRangeSelection, + $normalizeSelection__EXPERIMENTAL, + $setSelection, createCommand, ElementNode, Spread, } from 'lexical'; +import invariant from 'shared/invariant'; export type LinkAttributes = { rel?: null | string; @@ -477,6 +481,66 @@ export const TOGGLE_LINK_COMMAND: LexicalCommand< string | ({url: string} & LinkAttributes) | null > = createCommand('TOGGLE_LINK_COMMAND'); +function $getPointNode(point: Point, offset: number): LexicalNode | null { + if (point.type === 'element') { + const node = point.getNode(); + invariant( + $isElementNode(node), + '$getPointNode: element point is not an ElementNode', + ); + const childNode = node.getChildren()[point.offset + offset]; + return childNode || null; + } + return null; +} + +/** + * Preserve the logical start/end of a RangeSelection in situations where + * the point is an element that may be reparented in the callback. + * + * @param $fn The function to run + * @returns The result of the callback + */ +function $withSelectedNodes($fn: () => T): T { + const initialSelection = $getSelection(); + if (!$isRangeSelection(initialSelection)) { + return $fn(); + } + const normalized = $normalizeSelection__EXPERIMENTAL(initialSelection); + const isBackwards = normalized.isBackward(); + const anchorNode = $getPointNode(normalized.anchor, isBackwards ? -1 : 0); + const focusNode = $getPointNode(normalized.focus, isBackwards ? 0 : -1); + const rval = $fn(); + if (anchorNode || focusNode) { + const updatedSelection = $getSelection(); + if ($isRangeSelection(updatedSelection)) { + const finalSelection = updatedSelection.clone(); + if (anchorNode) { + const anchorParent = anchorNode.getParent(); + if (anchorParent) { + finalSelection.anchor.set( + anchorParent.getKey(), + anchorNode.getIndexWithinParent() + (isBackwards ? 1 : 0), + 'element', + ); + } + } + if (focusNode) { + const focusParent = focusNode.getParent(); + if (focusParent) { + finalSelection.focus.set( + focusParent.getKey(), + focusNode.getIndexWithinParent() + (isBackwards ? 0 : 1), + 'element', + ); + } + } + $setSelection($normalizeSelection__EXPERIMENTAL(finalSelection)); + } + } + return rval; +} + /** * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null, * but saves any children and brings them up to the parent node. @@ -515,93 +579,82 @@ export function $toggleLink( parentLink.remove(); } }); - } else { - // Add or merge LinkNodes - if (nodes.length === 1) { - const firstNode = nodes[0]; - // if the first node is a LinkNode or if its - // parent is a LinkNode, we update the URL, target and rel. - const linkNode = $getAncestor(firstNode, $isLinkNode); - if (linkNode !== null) { - linkNode.setURL(url); - if (target !== undefined) { - linkNode.setTarget(target); - } - if (rel !== null) { - linkNode.setRel(rel); - } - if (title !== undefined) { - linkNode.setTitle(title); - } - return; - } + return; + } + const updatedNodes = new Set(); + const updateLinkNode = (linkNode: LinkNode) => { + if (updatedNodes.has(linkNode.getKey())) { + return; + } + updatedNodes.add(linkNode.getKey()); + linkNode.setURL(url); + if (target !== undefined) { + linkNode.setTarget(target); + } + if (rel !== undefined) { + linkNode.setRel(rel); } + if (title !== undefined) { + linkNode.setTitle(title); + } + }; + // Add or merge LinkNodes + if (nodes.length === 1) { + const firstNode = nodes[0]; + // if the first node is a LinkNode or if its + // parent is a LinkNode, we update the URL, target and rel. + const linkNode = $getAncestor(firstNode, $isLinkNode); + if (linkNode !== null) { + return updateLinkNode(linkNode); + } + } - let prevParent: ElementNode | LinkNode | null = null; + $withSelectedNodes(() => { let linkNode: LinkNode | null = null; - - nodes.forEach((node) => { - const parent = node.getParent(); - - if ( - parent === linkNode || - parent === null || - ($isElementNode(node) && !node.isInline()) - ) { - return; + for (const node of nodes) { + if (!node.isAttached()) { + continue; } - - if ($isLinkNode(parent)) { - linkNode = parent; - parent.setURL(url); - if (target !== undefined) { - parent.setTarget(target); - } - if (rel !== null) { - linkNode.setRel(rel); - } - if (title !== undefined) { - linkNode.setTitle(title); - } - return; + const parentLinkNode = $getAncestor(node, $isLinkNode); + if (parentLinkNode) { + updateLinkNode(parentLinkNode); + continue; } - - if (!parent.is(prevParent)) { - prevParent = parent; - linkNode = $createLinkNode(url, {rel, target, title}); - - if ($isLinkNode(parent)) { - if (node.getPreviousSibling() === null) { - parent.insertBefore(linkNode); - } else { - parent.insertAfter(linkNode); - } - } else { - node.insertBefore(linkNode); - } - } - - if ($isLinkNode(node)) { - if (node.is(linkNode)) { - return; + if ($isElementNode(node)) { + if (!node.isInline()) { + // Ignore block nodes, if there are any children we will see them + // later and wrap in a new LinkNode + continue; } - if (linkNode !== null) { - const children = node.getChildren(); - - for (let i = 0; i < children.length; i++) { - linkNode.append(children[i]); + if ($isLinkNode(node)) { + // If it's not an autolink node and we don't already have a LinkNode + // in this block then we can update it and re-use it + if ( + !$isAutoLinkNode(node) && + (linkNode === null || !linkNode.getParentOrThrow().isParentOf(node)) + ) { + updateLinkNode(node); + linkNode = node; + continue; + } + // Unwrap LinkNode, we already have one or it's an AutoLinkNode + for (const child of node.getChildren()) { + node.insertBefore(child); } + node.remove(); + continue; } - - node.remove(); - return; } - - if (linkNode !== null) { - linkNode.append(node); + const prevLinkNode = node.getPreviousSibling(); + if ($isLinkNode(prevLinkNode) && prevLinkNode.is(linkNode)) { + prevLinkNode.append(node); + continue; } - }); - } + linkNode = $createLinkNode(url, {rel, target, title}); + node.insertAfter(linkNode); + linkNode.append(node); + } + }); } /** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */ export const toggleLink = $toggleLink;