Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[lexical][auto-link] Fix auto link crash editor #6433

Merged
merged 1 commit into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading