>;
+type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
+
const SUPPORTED_URL_PROTOCOLS = new Set([
'http:',
'https:',
@@ -82,7 +88,7 @@ export class LinkNode extends ElementNode {
this.__title = title;
}
- createDOM(config: EditorConfig): HTMLAnchorElement {
+ createDOM(config: EditorConfig): LinkHTMLElementType {
const element = document.createElement('a');
element.href = this.sanitizeUrl(this.__url);
if (this.__target !== null) {
@@ -100,38 +106,40 @@ export class LinkNode extends ElementNode {
updateDOM(
prevNode: LinkNode,
- anchor: HTMLAnchorElement,
+ anchor: LinkHTMLElementType,
config: EditorConfig,
): boolean {
- const url = this.__url;
- const target = this.__target;
- const rel = this.__rel;
- const title = this.__title;
- if (url !== prevNode.__url) {
- anchor.href = url;
- }
+ if (anchor instanceof HTMLAnchorElement) {
+ const url = this.__url;
+ const target = this.__target;
+ const rel = this.__rel;
+ const title = this.__title;
+ if (url !== prevNode.__url) {
+ anchor.href = url;
+ }
- if (target !== prevNode.__target) {
- if (target) {
- anchor.target = target;
- } else {
- anchor.removeAttribute('target');
+ if (target !== prevNode.__target) {
+ if (target) {
+ anchor.target = target;
+ } else {
+ anchor.removeAttribute('target');
+ }
}
- }
- if (rel !== prevNode.__rel) {
- if (rel) {
- anchor.rel = rel;
- } else {
- anchor.removeAttribute('rel');
+ if (rel !== prevNode.__rel) {
+ if (rel) {
+ anchor.rel = rel;
+ } else {
+ anchor.removeAttribute('rel');
+ }
}
- }
- if (title !== prevNode.__title) {
- if (title) {
- anchor.title = title;
- } else {
- anchor.removeAttribute('title');
+ if (title !== prevNode.__title) {
+ if (title) {
+ anchor.title = title;
+ } else {
+ anchor.removeAttribute('title');
+ }
}
}
return false;
@@ -309,11 +317,28 @@ export function $isLinkNode(
return node instanceof LinkNode;
}
-export type SerializedAutoLinkNode = SerializedLinkNode;
+export type SerializedAutoLinkNode = Spread<
+ {
+ isUnlinked: boolean;
+ },
+ SerializedLinkNode
+>;
// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
export class AutoLinkNode extends LinkNode {
+ /** @internal */
+ /** Indicates whether the autolink was ever unlinked. **/
+ __isUnlinked: boolean;
+
+ constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
+ super(url, attributes, key);
+ this.__isUnlinked =
+ attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
+ ? attributes.isUnlinked
+ : false;
+ }
+
static getType(): string {
return 'autolink';
}
@@ -321,13 +346,48 @@ export class AutoLinkNode extends LinkNode {
static clone(node: AutoLinkNode): AutoLinkNode {
return new AutoLinkNode(
node.__url,
- {rel: node.__rel, target: node.__target, title: node.__title},
+ {
+ isUnlinked: node.__isUnlinked,
+ rel: node.__rel,
+ target: node.__target,
+ title: node.__title,
+ },
node.__key,
);
}
+ getIsUnlinked(): boolean {
+ return this.__isUnlinked;
+ }
+
+ setIsUnlinked(value: boolean) {
+ const self = this.getWritable();
+ self.__isUnlinked = value;
+ return self;
+ }
+
+ createDOM(config: EditorConfig): LinkHTMLElementType {
+ if (this.__isUnlinked) {
+ return document.createElement('span');
+ } else {
+ return super.createDOM(config);
+ }
+ }
+
+ updateDOM(
+ prevNode: AutoLinkNode,
+ anchor: LinkHTMLElementType,
+ config: EditorConfig,
+ ): boolean {
+ return (
+ super.updateDOM(prevNode, anchor, config) ||
+ prevNode.__isUnlinked !== this.__isUnlinked
+ );
+ }
+
static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
const node = $createAutoLinkNode(serializedNode.url, {
+ isUnlinked: serializedNode.isUnlinked,
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title,
@@ -346,6 +406,7 @@ export class AutoLinkNode extends LinkNode {
exportJSON(): SerializedAutoLinkNode {
return {
...super.exportJSON(),
+ isUnlinked: this.__isUnlinked,
type: 'autolink',
version: 1,
};
@@ -361,6 +422,7 @@ export class AutoLinkNode extends LinkNode {
);
if ($isElementNode(element)) {
const linkNode = $createAutoLinkNode(this.__url, {
+ isUnlinked: this.__isUnlinked,
rel: this.__rel,
target: this.__target,
title: this.__title,
@@ -381,7 +443,7 @@ export class AutoLinkNode extends LinkNode {
*/
export function $createAutoLinkNode(
url: string,
- attributes?: LinkAttributes,
+ attributes?: AutoLinkAttributes,
): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode(url, attributes));
}
@@ -425,7 +487,7 @@ export function $toggleLink(
nodes.forEach((node) => {
const parent = node.getParent();
- if ($isLinkNode(parent)) {
+ if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
const children = parent.getChildren();
for (let i = 0; i < children.length; i++) {
diff --git a/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs b/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs
index e7d0688fc..9b0090d96 100644
--- a/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs
@@ -22,6 +22,7 @@ import {
html,
initialize,
pasteFromClipboard,
+ pressInsertLinkButton,
test,
} from '../utils/index.mjs';
@@ -513,4 +514,66 @@ test.describe('Auto Links', () => {
{ignoreClasses: true},
);
});
+
+ test('Can unlink the autolink and then make it link again', async ({
+ page,
+ isPlainText,
+ }) => {
+ test.skip(isPlainText);
+ await focusEditor(page);
+
+ await page.keyboard.type('Hello http://www.example.com test');
+ await assertHTML(
+ page,
+ html`
+
+ Hello
+
+ http://www.example.com
+
+ test
+
+ `,
+ undefined,
+ {ignoreClasses: true},
+ );
+
+ await focusEditor(page);
+ await click(page, 'a[href="http://www.example.com"]');
+ await click(page, 'div.link-editor div.link-trash');
+
+ await assertHTML(
+ page,
+ html`
+
+ Hello
+
+ http://www.example.com
+
+ test
+
+ `,
+ undefined,
+ {ignoreClasses: true},
+ );
+
+ await click(page, 'span:has-text("http://www.example.com")');
+
+ pressInsertLinkButton(page);
+
+ await assertHTML(
+ page,
+ html`
+
+ Hello
+
+ http://www.example.com
+
+ test
+
+ `,
+ undefined,
+ {ignoreClasses: true},
+ );
+ });
});
diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs
index 990e1e997..f2755a485 100644
--- a/packages/lexical-playground/__tests__/utils/index.mjs
+++ b/packages/lexical-playground/__tests__/utils/index.mjs
@@ -957,3 +957,7 @@ export async function dragDraggableMenuTo(
positionEnd,
);
}
+
+export async function pressInsertLinkButton(page) {
+ await click(page, '.toolbar-item[aria-label="Insert link"]');
+}
diff --git a/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx b/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx
index 49773b761..4dd0cb2fc 100644
--- a/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx
+++ b/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx
@@ -315,7 +315,9 @@ function useFloatingLinkEditorToolbar(
(focusLinkNode && !focusLinkNode.is(linkNode)) ||
(linkNode && !linkNode.is(focusLinkNode)) ||
(focusAutoLinkNode && !focusAutoLinkNode.is(autoLinkNode)) ||
- (autoLinkNode && !autoLinkNode.is(focusAutoLinkNode))
+ (autoLinkNode &&
+ (!autoLinkNode.is(focusAutoLinkNode) ||
+ autoLinkNode.getIsUnlinked()))
);
});
if (!badNode) {
diff --git a/packages/lexical-react/src/LexicalAutoLinkPlugin.ts b/packages/lexical-react/src/LexicalAutoLinkPlugin.ts
index f246d6300..5eaa8e853 100644
--- a/packages/lexical-react/src/LexicalAutoLinkPlugin.ts
+++ b/packages/lexical-react/src/LexicalAutoLinkPlugin.ts
@@ -6,7 +6,7 @@
*
*/
-import type {LinkAttributes} from '@lexical/link';
+import type {AutoLinkAttributes} from '@lexical/link';
import type {ElementNode, LexicalEditor, LexicalNode} from 'lexical';
import {
@@ -14,6 +14,7 @@ import {
$isAutoLinkNode,
$isLinkNode,
AutoLinkNode,
+ TOGGLE_LINK_COMMAND,
} from '@lexical/link';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {mergeRegister} from '@lexical/utils';
@@ -25,6 +26,7 @@ import {
$isNodeSelection,
$isRangeSelection,
$isTextNode,
+ COMMAND_PRIORITY_LOW,
TextNode,
} from 'lexical';
import {useEffect} from 'react';
@@ -33,7 +35,7 @@ import invariant from 'shared/invariant';
type ChangeHandler = (url: string | null, prevUrl: string | null) => void;
type LinkMatcherResult = {
- attributes?: LinkAttributes;
+ attributes?: AutoLinkAttributes;
index: number;
length: number;
text: string;
@@ -382,6 +384,7 @@ function handleBadNeighbors(
if (
$isAutoLinkNode(previousSibling) &&
+ !previousSibling.getIsUnlinked() &&
(!startsWithSeparator(text) || startsWithFullStop(text))
) {
previousSibling.append(textNode);
@@ -389,7 +392,11 @@ function handleBadNeighbors(
onChange(null, previousSibling.getURL());
}
- if ($isAutoLinkNode(nextSibling) && !endsWithSeparator(text)) {
+ if (
+ $isAutoLinkNode(nextSibling) &&
+ !nextSibling.getIsUnlinked() &&
+ !endsWithSeparator(text)
+ ) {
replaceWithChildren(nextSibling);
handleLinkEdit(nextSibling, matchers, onChange);
onChange(null, nextSibling.getURL());
@@ -449,7 +456,7 @@ function useAutoLink(
editor.registerNodeTransform(TextNode, (textNode: TextNode) => {
const parent = textNode.getParentOrThrow();
const previous = textNode.getPreviousSibling();
- if ($isAutoLinkNode(parent)) {
+ if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
handleLinkEdit(parent, matchers, onChangeWrapped);
} else if (!$isLinkNode(parent)) {
if (
@@ -464,6 +471,28 @@ function useAutoLink(
handleBadNeighbors(textNode, matchers, onChangeWrapped);
}
}),
+ editor.registerCommand(
+ TOGGLE_LINK_COMMAND,
+ (payload) => {
+ const selection = $getSelection();
+ if (payload !== null || !$isRangeSelection(selection)) {
+ return false;
+ }
+ const nodes = selection.extract();
+ nodes.forEach((node) => {
+ const parent = node.getParent();
+
+ if ($isAutoLinkNode(parent)) {
+ // invert the value
+ parent.setIsUnlinked(!parent.getIsUnlinked());
+ parent.markDirty();
+ return true;
+ }
+ });
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
);
}, [editor, matchers, onChange]);
}