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;