From 260daa7eb6d21f4a89d7afbeef9917c5bfc419b1 Mon Sep 17 00:00:00 2001 From: Maksym Plavinskyi Date: Fri, 19 Jul 2024 14:08:03 -0500 Subject: [PATCH] [lexical][auto-link] Fix auto link crash editor --- packages/lexical-link/src/index.ts | 10 + .../__tests__/e2e/AutoLinks.spec.mjs | 188 +++++++++++++++++- .../src/LexicalAutoLinkPlugin.ts | 18 +- 3 files changed, 211 insertions(+), 5 deletions(-) diff --git a/packages/lexical-link/src/index.ts b/packages/lexical-link/src/index.ts index 076f2f7e08a..fe2b9757048 100644 --- a/packages/lexical-link/src/index.ts +++ b/packages/lexical-link/src/index.ts @@ -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 { diff --git a/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs b/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs index 450fc8f61ff..1c63ec4f020 100644 --- a/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs @@ -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` +

+ Hello + + name@example.com + + and + + anothername@test.example.uk + + ! +

+ `, + undefined, + {ignoreClasses: true}, + ); + }); + test('Can destruct links if add non-spacing text in front or right after it', async ({ page, isPlainText, @@ -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` +

+ Hello + + name@example.com + + and + + anothername@test.example.uk + + and + + www.example.com + + ! +

+ `, + undefined, + {ignoreClasses: true}, + ); + }); + test('Does not create redundant auto-link', async ({page, isPlainText}) => { test.skip(isPlainText); await focusEditor(page); @@ -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, @@ -221,6 +285,11 @@ test.describe('Auto Links', () => { https://3.com + ; + + name@domain.uk + + ;

`, undefined, @@ -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, @@ -254,6 +323,10 @@ test.describe('Auto Links', () => { https://4.com/ + + + name-lastname@meta.com +

`, undefined, @@ -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` +

+ + Hello name@example.c name@example.1 + + + name-lastname@example.com + + + + name.lastname@meta.com + +

+ `, + undefined, + {ignoreClasses: true}, + ); + }); + test('Can convert url-like text with formatting into links', async ({ page, isPlainText, @@ -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 += ` + + ${url} + + + `; + } + + await assertHTML( + page, + html` +

${expectedHTML}

+ `, + undefined, + {ignoreClasses: true}, + ); + }); + test(`Can not convert bad URLs into links`, async ({page, isPlainText}) => { const testUrls = [ // Missing Protocol @@ -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` +

+ ${testUrls.join(' ')} +

+ `, + undefined, + {ignoreClasses: true}, + ); + }); + test('Can unlink the autolink and then make it link again', async ({ page, isPlainText, diff --git a/packages/lexical-react/src/LexicalAutoLinkPlugin.ts b/packages/lexical-react/src/LexicalAutoLinkPlugin.ts index 5eaa8e85300..bb859ca82c6 100644 --- a/packages/lexical-react/src/LexicalAutoLinkPlugin.ts +++ b/packages/lexical-react/src/LexicalAutoLinkPlugin.ts @@ -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 { @@ -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);