mirror of
https://github.com/facebook/lexical.git
synced 2025-05-17 23:26:16 +08:00
[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:

committed by
GitHub

parent
169b020f68
commit
7725205093
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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++) {
|
||||
|
@ -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},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -957,3 +957,7 @@ export async function dragDraggableMenuTo(
|
||||
positionEnd,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pressInsertLinkButton(page) {
|
||||
await click(page, '.toolbar-item[aria-label="Insert link"]');
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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]);
|
||||
}
|
||||
|
Reference in New Issue
Block a user