Skip to content

Commit

Permalink
[lexical][auto-link] Fix auto link crash editor (#6433)
Browse files Browse the repository at this point in the history
Co-authored-by: Maksym Plavinskyi <Maksym_Plavinskyi@epam.com>
  • Loading branch information
MaxPlav and Maksym Plavinskyi authored Jul 22, 2024
1 parent 0b58faf commit b88ce57
Show file tree
Hide file tree
Showing 3 changed files with 211 additions and 5 deletions.
10 changes: 10 additions & 0 deletions packages/lexical-link/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,16 @@ export class LinkNode extends ElementNode {
selection.getTextContent().length > 0
);
}

isEmailURI(): boolean {
return this.__url.startsWith('mailto:');
}

isWebSiteURI(): boolean {
return (
this.__url.startsWith('https://') || this.__url.startsWith('http://')
);
}
}

function $convertAnchorElement(domNode: Node): DOMConversionOutput {
Expand Down
188 changes: 186 additions & 2 deletions packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,35 @@ test.describe('Auto Links', () => {
);
});

test('Can convert url-like text into links for email', async ({
page,
isPlainText,
}) => {
test.skip(isPlainText);
await focusEditor(page);
await page.keyboard.type(
'Hello name@example.com and anothername@test.example.uk !',
);
await assertHTML(
page,
html`
<p dir="ltr">
<span data-lexical-text="true">Hello</span>
<a dir="ltr" href="mailto:name@example.com">
<span data-lexical-text="true">name@example.com</span>
</a>
<span data-lexical-text="true">and</span>
<a dir="ltr" href="mailto:anothername@test.example.uk">
<span data-lexical-text="true">anothername@test.example.uk</span>
</a>
<span data-lexical-text="true">!</span>
</p>
`,
undefined,
{ignoreClasses: true},
);
});

test('Can destruct links if add non-spacing text in front or right after it', async ({
page,
isPlainText,
Expand Down Expand Up @@ -159,6 +188,40 @@ test.describe('Auto Links', () => {
);
});

test('Can create link for email when pasting text with urls', async ({
page,
isPlainText,
}) => {
test.skip(isPlainText);
await focusEditor(page);
await pasteFromClipboard(page, {
'text/plain':
'Hello name@example.com and anothername@test.example.uk and www.example.com !',
});
await assertHTML(
page,
html`
<p dir="ltr">
<span data-lexical-text="true">Hello</span>
<a dir="ltr" href="mailto:name@example.com">
<span data-lexical-text="true">name@example.com</span>
</a>
<span data-lexical-text="true">and</span>
<a dir="ltr" href="mailto:anothername@test.example.uk">
<span data-lexical-text="true">anothername@test.example.uk</span>
</a>
<span data-lexical-text="true">and</span>
<a dir="ltr" href="https://www.example.com">
<span data-lexical-text="true">www.example.com</span>
</a>
<span data-lexical-text="true">!</span>
</p>
`,
undefined,
{ignoreClasses: true},
);
});

test('Does not create redundant auto-link', async ({page, isPlainText}) => {
test.skip(isPlainText);
await focusEditor(page);
Expand Down Expand Up @@ -204,7 +267,8 @@ test.describe('Auto Links', () => {
test.skip(isPlainText);
await focusEditor(page);
await pasteFromClipboard(page, {
'text/plain': 'https://1.com/,https://2.com/;;;https://3.com',
'text/plain':
'https://1.com/,https://2.com/;;;https://3.com;name@domain.uk;',
});
await assertHTML(
page,
Expand All @@ -221,6 +285,11 @@ test.describe('Auto Links', () => {
<a dir="ltr" href="https://3.com">
<span data-lexical-text="true">https://3.com</span>
</a>
<span data-lexical-text="true">;</span>
<a dir="ltr" href="mailto:name@domain.uk">
<span data-lexical-text="true">name@domain.uk</span>
</a>
<span data-lexical-text="true">;</span>
</p>
`,
undefined,
Expand All @@ -233,7 +302,7 @@ test.describe('Auto Links', () => {
await focusEditor(page);
await pasteFromClipboard(page, {
'text/plain':
'https://1.com/ https://2.com/ https://3.com/ https://4.com/',
'https://1.com/ https://2.com/ https://3.com/ https://4.com/ name-lastname@meta.com',
});
await assertHTML(
page,
Expand All @@ -254,6 +323,10 @@ test.describe('Auto Links', () => {
<a dir="ltr" href="https://4.com/">
<span data-lexical-text="true">https://4.com/</span>
</a>
<span data-lexical-text="true"></span>
<a dir="ltr" href="mailto:name-lastname@meta.com">
<span data-lexical-text="true">name-lastname@meta.com</span>
</a>
</p>
`,
undefined,
Expand Down Expand Up @@ -284,6 +357,37 @@ test.describe('Auto Links', () => {
);
});

test('Handles autolink following an invalid autolink to email', async ({
page,
isPlainText,
}) => {
test.skip(isPlainText);
await focusEditor(page);
await page.keyboard.type(
'Hello name@example.c name@example.1 name-lastname@example.com name.lastname@meta.com',
);

await assertHTML(
page,
html`
<p dir="ltr">
<span data-lexical-text="true">
Hello name@example.c name@example.1
</span>
<a dir="ltr" href="mailto:name-lastname@example.com">
<span data-lexical-text="true">name-lastname@example.com</span>
</a>
<span data-lexical-text="true"></span>
<a dir="ltr" href="mailto:name.lastname@meta.com">
<span data-lexical-text="true">name.lastname@meta.com</span>
</a>
</p>
`,
undefined,
{ignoreClasses: true},
);
});

test('Can convert url-like text with formatting into links', async ({
page,
isPlainText,
Expand Down Expand Up @@ -467,6 +571,47 @@ test.describe('Auto Links', () => {
);
});

test('Can convert URLs into email links', async ({page, isPlainText}) => {
const testUrls = [
// Email usecases
'email@domain.com',
'firstname.lastname@domain.com',
'email@subdomain.domain.com',
'firstname+lastname@domain.com',
'email@[123.123.123.123]',
'"email"@domain.com',
'1234567890@domain.com',
'email@domain-one.com',
'_______@domain.com',
'email@domain.name',
'email@domain.co.uk',
'firstname-lastname@domain.com',
];

test.skip(isPlainText);
await focusEditor(page);
await page.keyboard.type(testUrls.join(' ') + ' ');

let expectedHTML = '';
for (const url of testUrls) {
expectedHTML += `
<a href='mailto:${url}' dir="ltr">
<span data-lexical-text="true">${url}</span>
</a>
<span data-lexical-text="true"></span>
`;
}

await assertHTML(
page,
html`
<p dir="ltr">${expectedHTML}</p>
`,
undefined,
{ignoreClasses: true},
);
});

test(`Can not convert bad URLs into links`, async ({page, isPlainText}) => {
const testUrls = [
// Missing Protocol
Expand Down Expand Up @@ -515,6 +660,45 @@ test.describe('Auto Links', () => {
);
});

test(`Can not convert bad URLs into email links`, async ({
page,
isPlainText,
}) => {
const testUrls = [
'@domain.com',
'@subdomain.domain.com',

// Invalid Characters
'email@domain!.com', // Invalid character in domain
'email@domain.c', // Invalid top level domain

// Missing Domain
'email@.com',
'email@.org',

// Incomplete URLs
'email@', // Incomplete URL

// Just Text
'not_an_email', // Plain text
];

test.skip(isPlainText);
await focusEditor(page);
await page.keyboard.type(testUrls.join(' '));

await assertHTML(
page,
html`
<p dir="ltr">
<span data-lexical-text="true">${testUrls.join(' ')}</span>
</p>
`,
undefined,
{ignoreClasses: true},
);
});

test('Can unlink the autolink and then make it link again', async ({
page,
isPlainText,
Expand Down
18 changes: 15 additions & 3 deletions packages/lexical-react/src/LexicalAutoLinkPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,19 @@ function startsWithSeparator(textContent: string): boolean {
return isSeparator(textContent[0]);
}

function startsWithFullStop(textContent: string): boolean {
return /^\.[a-zA-Z0-9]{1,}/.test(textContent);
/**
* Check if the text content starts with a fullstop followed by a top-level domain.
* Meaning if the text content can be a beginning of a top level domain.
* @param textContent
* @param isEmail
* @returns boolean
*/
function startsWithTLD(textContent: string, isEmail: boolean): boolean {
if (isEmail) {
return /^\.[a-zA-Z]{2,}/.test(textContent);
} else {
return /^\.[a-zA-Z0-9]{1,}/.test(textContent);
}
}

function isPreviousNodeValid(node: LexicalNode): boolean {
Expand Down Expand Up @@ -385,7 +396,8 @@ function handleBadNeighbors(
if (
$isAutoLinkNode(previousSibling) &&
!previousSibling.getIsUnlinked() &&
(!startsWithSeparator(text) || startsWithFullStop(text))
(!startsWithSeparator(text) ||
startsWithTLD(text, previousSibling.isEmailURI()))
) {
previousSibling.append(textNode);
handleLinkEdit(previousSibling, matchers, onChange);
Expand Down

0 comments on commit b88ce57

Please sign in to comment.