diff --git a/packages/lexical-link/src/__tests__/unit/LexicalAutoLinkNode.test.ts b/packages/lexical-link/src/__tests__/unit/LexicalAutoLinkNode.test.ts new file mode 100644 index 000000000..8ef2aa051 --- /dev/null +++ b/packages/lexical-link/src/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -0,0 +1,506 @@ +/** + * 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 { + $createAutoLinkNode, + $isAutoLinkNode, + $toggleLink, + AutoLinkNode, + SerializedAutoLinkNode, +} from '@lexical/link'; +import { + $getRoot, + $selectAll, + ParagraphNode, + SerializedParagraphNode, + TextNode, +} from 'lexical/src'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + link: 'my-autolink-class', + text: { + bold: 'my-bold-class', + code: 'my-code-class', + hashtag: 'my-hashtag-class', + italic: 'my-italic-class', + strikethrough: 'my-strikethrough-class', + underline: 'my-underline-class', + underlineStrikethrough: 'my-underline-strikethrough-class', + }, + }, +}); + +describe('LexicalAutoAutoLinkNode tests', () => { + initializeUnitTest((testEnv) => { + test('AutoAutoLinkNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const actutoLinkNode = new AutoLinkNode('/'); + + expect(actutoLinkNode.__type).toBe('autolink'); + expect(actutoLinkNode.__url).toBe('/'); + expect(actutoLinkNode.__isUnlinked).toBe(false); + }); + + expect(() => new AutoLinkNode('')).toThrow(); + }); + + test('AutoAutoLinkNode.constructor with isUnlinked param set to true', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const actutoLinkNode = new AutoLinkNode('/', { + isUnlinked: true, + }); + + expect(actutoLinkNode.__type).toBe('autolink'); + expect(actutoLinkNode.__url).toBe('/'); + expect(actutoLinkNode.__isUnlinked).toBe(true); + }); + + expect(() => new AutoLinkNode('')).toThrow(); + }); + + /// + + test('LineBreakNode.clone()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('/'); + + const clone = AutoLinkNode.clone(autoLinkNode); + + expect(clone).not.toBe(autoLinkNode); + expect(clone).toStrictEqual(autoLinkNode); + }); + }); + + test('AutoLinkNode.getURL()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.getURL()).toBe('https://example.com/foo'); + }); + }); + + test('AutoLinkNode.setURL()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.getURL()).toBe('https://example.com/foo'); + + autoLinkNode.setURL('https://example.com/bar'); + + expect(autoLinkNode.getURL()).toBe('https://example.com/bar'); + }); + }); + + test('AutoLinkNode.getTarget()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + target: '_blank', + }); + + expect(autoLinkNode.getTarget()).toBe('_blank'); + }); + }); + + test('AutoLinkNode.setTarget()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + target: '_blank', + }); + + expect(autoLinkNode.getTarget()).toBe('_blank'); + + autoLinkNode.setTarget('_self'); + + expect(autoLinkNode.getTarget()).toBe('_self'); + }); + }); + + test('AutoLinkNode.getRel()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + }); + + expect(autoLinkNode.getRel()).toBe('noopener noreferrer'); + }); + }); + + test('AutoLinkNode.setRel()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener', + target: '_blank', + }); + + expect(autoLinkNode.getRel()).toBe('noopener'); + + autoLinkNode.setRel('noopener noreferrer'); + + expect(autoLinkNode.getRel()).toBe('noopener noreferrer'); + }); + }); + + test('AutoLinkNode.getTitle()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + title: 'Hello world', + }); + + expect(autoLinkNode.getTitle()).toBe('Hello world'); + }); + }); + + test('AutoLinkNode.setTitle()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + title: 'Hello world', + }); + + expect(autoLinkNode.getTitle()).toBe('Hello world'); + + autoLinkNode.setTitle('World hello'); + + expect(autoLinkNode.getTitle()).toBe('World hello'); + }); + }); + + test('AutoLinkNode.getIsUnlinked()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('/', { + isUnlinked: true, + }); + expect(autoLinkNode.getIsUnlinked()).toBe(true); + }); + }); + + test('AutoLinkNode.setIsUnlinked()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('/'); + expect(autoLinkNode.getIsUnlinked()).toBe(false); + autoLinkNode.setIsUnlinked(true); + expect(autoLinkNode.getIsUnlinked()).toBe(true); + }); + }); + + test('AutoLinkNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + expect( + autoLinkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe(''); + }); + }); + + test('AutoLinkNode.createDOM() for unlinked', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + isUnlinked: true, + }); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + `${autoLinkNode.getTextContent()}`, + ); + }); + }); + + test('AutoLinkNode.createDOM() with target, rel and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + expect( + autoLinkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => { + const {editor} = testEnv; + + await editor.update(() => { + // eslint-disable-next-line no-script-url + const autoLinkNode = new AutoLinkNode('javascript:alert(0)'); + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + const domElement = autoLinkNode.createDOM(editorConfig); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar'); + const result = newAutoLinkNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM() with target, rel and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const domElement = autoLinkNode.createDOM(editorConfig); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', { + rel: 'noopener', + target: '_self', + title: 'World hello', + }); + const result = newAutoLinkNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const domElement = autoLinkNode.createDOM(editorConfig); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newNode = new AutoLinkNode('https://example.com/bar'); + const result = newNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM() with isUnlinked "true"', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + isUnlinked: false, + }); + + const domElement = autoLinkNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', { + isUnlinked: true, + }); + const newDomElement = newAutoLinkNode.createDOM(editorConfig); + expect(newDomElement.outerHTML).toBe( + `${newAutoLinkNode.getTextContent()}`, + ); + + const result = newAutoLinkNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + expect(result).toBe(true); + }); + }); + + test('AutoLinkNode.canInsertTextBefore()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.canInsertTextBefore()).toBe(false); + }); + }); + + test('AutoLinkNode.canInsertTextAfter()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + expect(autoLinkNode.canInsertTextAfter()).toBe(false); + }); + }); + + test('$createAutoLinkNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + const createdAutoLinkNode = $createAutoLinkNode( + 'https://example.com/foo', + ); + + expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type); + expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent); + expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url); + expect(autoLinkNode.__isUnlinked).toEqual( + createdAutoLinkNode.__isUnlinked, + ); + expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key); + }); + }); + + test('$createAutoLinkNode() with target, rel, isUnlinked and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const createdAutoLinkNode = $createAutoLinkNode( + 'https://example.com/foo', + { + isUnlinked: true, + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }, + ); + + expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type); + expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent); + expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url); + expect(autoLinkNode.__target).toEqual(createdAutoLinkNode.__target); + expect(autoLinkNode.__rel).toEqual(createdAutoLinkNode.__rel); + expect(autoLinkNode.__title).toEqual(createdAutoLinkNode.__title); + expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key); + expect(autoLinkNode.__isUnlinked).not.toEqual( + createdAutoLinkNode.__isUnlinked, + ); + }); + }); + + test('$isAutoLinkNode()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const autoLinkNode = new AutoLinkNode(''); + expect($isAutoLinkNode(autoLinkNode)).toBe(true); + }); + }); + + 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')); + $getRoot().append(p); + }); + + await editor.update(() => { + $selectAll(); + $toggleLink('https://lexical.dev/', {title: 'Lexical Website'}); + }); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + const link = paragraph.children[0] as SerializedAutoLinkNode; + expect(link.title).toBe('Lexical Website'); + }); + }); +}); diff --git a/packages/lexical-link/src/index.ts b/packages/lexical-link/src/index.ts index 194b6b5d8..076f2f7e0 100644 --- a/packages/lexical-link/src/index.ts +++ b/packages/lexical-link/src/index.ts @@ -35,6 +35,10 @@ export type LinkAttributes = { title?: null | string; }; +export type AutoLinkAttributes = Partial< + Spread +>; + export type SerializedLinkNode = Spread< { url: string; @@ -42,6 +46,8 @@ export type SerializedLinkNode = Spread< Spread >; +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]); }