[lexical-link] Fix bug when can't remove link formatting from autolink (#6306)

Co-authored-by: Maksym Plavinskyi <Maksym_Plavinskyi@epam.com>
This commit is contained in:
Maksym Plavinskyi
2024-06-19 08:33:44 -05:00
committed by GitHub
parent 169b020f68
commit 7725205093
6 changed files with 702 additions and 36 deletions

View File

@ -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(
'<a href="https://example.com/foo" class="my-autolink-class"></a>',
);
expect(
autoLinkNode.createDOM({
namespace: '',
theme: {},
}).outerHTML,
).toBe('<a href="https://example.com/foo"></a>');
});
});
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(
`<span>${autoLinkNode.getTextContent()}</span>`,
);
});
});
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(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
);
expect(
autoLinkNode.createDOM({
namespace: '',
theme: {},
}).outerHTML,
).toBe(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world"></a>',
);
});
});
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(
'<a href="about:blank" class="my-autolink-class"></a>',
);
});
});
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(
'<a href="https://example.com/foo" class="my-autolink-class"></a>',
);
const newAutoLinkNode = new AutoLinkNode('https://example.com/bar');
const result = newAutoLinkNode.updateDOM(
autoLinkNode,
domElement,
editorConfig,
);
expect(result).toBe(false);
expect(domElement.outerHTML).toBe(
'<a href="https://example.com/bar" class="my-autolink-class"></a>',
);
});
});
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(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
);
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(
'<a href="https://example.com/bar" target="_self" rel="noopener" title="World hello" class="my-autolink-class"></a>',
);
});
});
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(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
);
const newNode = new AutoLinkNode('https://example.com/bar');
const result = newNode.updateDOM(
autoLinkNode,
domElement,
editorConfig,
);
expect(result).toBe(false);
expect(domElement.outerHTML).toBe(
'<a href="https://example.com/bar" class="my-autolink-class"></a>',
);
});
});
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(
'<a href="https://example.com/foo" class="my-autolink-class"></a>',
);
const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', {
isUnlinked: true,
});
const newDomElement = newAutoLinkNode.createDOM(editorConfig);
expect(newDomElement.outerHTML).toBe(
`<span>${newAutoLinkNode.getTextContent()}</span>`,
);
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');
});
});
});

View File

@ -35,6 +35,10 @@ export type LinkAttributes = {
title?: null | string;
};
export type AutoLinkAttributes = Partial<
Spread<LinkAttributes, {isUnlinked?: boolean}>
>;
export type SerializedLinkNode = Spread<
{
url: string;
@ -42,6 +46,8 @@ export type SerializedLinkNode = Spread<
Spread<LinkAttributes, SerializedElementNode>
>;
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 (target !== prevNode.__target) {
if (target) {
anchor.target = target;
} else {
anchor.removeAttribute('target');
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 (rel !== prevNode.__rel) {
if (rel) {
anchor.rel = rel;
} else {
anchor.removeAttribute('rel');
if (target !== prevNode.__target) {
if (target) {
anchor.target = target;
} else {
anchor.removeAttribute('target');
}
}
}
if (title !== prevNode.__title) {
if (title) {
anchor.title = title;
} else {
anchor.removeAttribute('title');
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');
}
}
}
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++) {

View File

@ -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`
<p dir="ltr">
<span data-lexical-text="true">Hello</span>
<a href="http://www.example.com" dir="ltr">
<span data-lexical-text="true">http://www.example.com</span>
</a>
<span data-lexical-text="true">test</span>
</p>
`,
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`
<p dir="ltr">
<span data-lexical-text="true">Hello</span>
<span class="PlaygroundEditorTheme__ltr" dir="ltr">
<span data-lexical-text="true">http://www.example.com</span>
</span>
<span data-lexical-text="true">test</span>
</p>
`,
undefined,
{ignoreClasses: true},
);
await click(page, 'span:has-text("http://www.example.com")');
pressInsertLinkButton(page);
await assertHTML(
page,
html`
<p dir="ltr">
<span data-lexical-text="true">Hello</span>
<a href="http://www.example.com" dir="ltr">
<span data-lexical-text="true">http://www.example.com</span>
</a>
<span data-lexical-text="true">test</span>
</p>
`,
undefined,
{ignoreClasses: true},
);
});
});

View File

@ -957,3 +957,7 @@ export async function dragDraggableMenuTo(
positionEnd,
);
}
export async function pressInsertLinkButton(page) {
await click(page, '.toolbar-item[aria-label="Insert link"]');
}

View File

@ -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) {

View File

@ -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]);
}