From 89c4d545cb5de2b5d52d5fda21621e5c9f1fab8e Mon Sep 17 00:00:00 2001 From: marcoSven Date: Mon, 25 Jul 2022 05:58:23 +0200 Subject: [PATCH] fix(ext-link): not auto detecting adjacent text Before this change: Auto link detection did not detect adjacent text. Example: Insert "window.confirm" results in [window.co](window.co)nfirm After this change: Adjacent text is detected and possibly removes invalid link or creates a link Example: Insert "window.confirm" results in window.confirm Added "adjacentPunctuations" option to configure which adjacent punctuation are excluded from links Closes #1732 --- .changeset/sharp-melons-switch.md | 25 + .../__tests__/link-extension.spec.ts | 895 ++++++++++++++++-- .../src/link-extension-utils.ts | 56 ++ .../src/link-extension.ts | 247 +++-- 4 files changed, 1105 insertions(+), 118 deletions(-) create mode 100644 .changeset/sharp-melons-switch.md diff --git a/.changeset/sharp-melons-switch.md b/.changeset/sharp-melons-switch.md new file mode 100644 index 0000000000..2fc42b48ef --- /dev/null +++ b/.changeset/sharp-melons-switch.md @@ -0,0 +1,25 @@ +--- +'@remirror/extension-link': patch +--- + +# fix(ext-link): not auto detecting adjacent text + +Closes issue [1732](https://github.com/remirror/remirror/issues/1732) + +## Before this change: + +Auto link detection did not detect adjacent text. + +### Example: + +Insert "window.confirm" results in "[window.co](//window.co)nfirm" + +## After this change: + +Adjacent text is detected and possibly removes invalid link or creates a link + +### Example: + +Insert "window.confirm" results in "window.confirm" + +Added "adjacentPunctuations" option to configure which adjacent punctuation are excluded from links diff --git a/packages/remirror__extension-link/__tests__/link-extension.spec.ts b/packages/remirror__extension-link/__tests__/link-extension.spec.ts index 5f20e85bd3..e09b1163ef 100644 --- a/packages/remirror__extension-link/__tests__/link-extension.spec.ts +++ b/packages/remirror__extension-link/__tests__/link-extension.spec.ts @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { pmBuild } from 'jest-prosemirror'; import { extensionValidityTest, renderEditor } from 'jest-remirror'; import { @@ -150,7 +151,7 @@ function create(options: LinkOptions = {}) { } describe('commands', () => { - let onUpdateLink = jest.fn(() => {}); + let onUpdateLink: any = jest.fn(() => {}); let { add, attributeMarks: { link }, @@ -434,7 +435,7 @@ describe('commands', () => { }); describe('keys', () => { - const onShortcut = jest.fn(() => {}); + const onShortcut: any = jest.fn(() => {}); it('responds to Mod-k', () => { const { @@ -490,7 +491,9 @@ describe('plugin', () => { } = create({ openLinkOnClick: true }); const testLink = link({ href }); - jest.spyOn(global, 'open').mockImplementation(); + jest.spyOn(global, 'open').mockImplementation(() => { + return null; + }); add(doc(p(testLink('Link')))) .fire({ event: 'click' }) @@ -501,7 +504,7 @@ describe('plugin', () => { }); describe('autolinking', () => { - let onUpdateLink = jest.fn(() => {}); + let onUpdateLink: any = jest.fn(() => {}); let editor = create({ autoLink: true, onUpdateLink }); let { link } = editor.attributeMarks; let { doc, p, nomark } = editor.nodes; @@ -622,6 +625,12 @@ describe('autolinking', () => { }); it('calls the onUpdateLink handler when auto linking', () => { + jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((requestCallback: FrameRequestCallback) => { + requestCallback(1); + return 1; + }); let editorText = 'test.co'; editor.add(doc(p(''))).insertText(editorText); @@ -696,6 +705,72 @@ describe('autolinking', () => { ); }); + it('should create auto link if href equals text content', () => { + const editor = renderEditor([new LinkExtension({ autoLink: true })]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.chain.insertHtml('test.com').run(); + + editor.selectText(5).insertText('er'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//tester.com' })('tester.com'))), + ); + }); + + it('should not create auto link if href does not equal text content', () => { + const editor = renderEditor([new LinkExtension({ autoLink: true })]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.chain.insertHtml('test').run(); + + editor.selectText(5).insertText('er'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: false, href: '//test.com' })('test'), 'er')), + ); + }); + + it('detects separating two links by entering a space', () => { + editor.add(doc(p(''))).insertText('github.comremirror.io'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//github.comremirror.io' })('github.comremirror.io'))), + ); + + editor.selectText(11).insertText(' '); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//github.com' })('github.com'), + ' ', + link({ auto: true, href: '//remirror.io' })('remirror.io'), + ), + ), + ); + }); + + it('detects separating two links by `Enter` key press', () => { + editor + .add(doc(p(link({ auto: true, href: '//test.coremirror.io' })('test.coremirror.io')))) + .selectText(8) + .press('Enter'); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p(link({ auto: true, href: '//test.co' })('test.co')), + p(link({ auto: true, href: '//remirror.io' })('remirror.io')), + ), + ); + }); + it('supports deleting selected to to invalidate the match', () => { editor .add(doc(p(''))) @@ -723,6 +798,217 @@ describe('autolinking', () => { ); }); + it('should only update first link', () => { + jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((requestCallback: FrameRequestCallback) => { + requestCallback(1); + return 1; + }); + editor.add( + doc( + p( + link({ auto: true, href: '//first.co' })('first.co'), + ' ', + link({ auto: true, href: '//second.com' })('second.com'), + ), + ), + ); + + editor.selectText(9).insertText('m'); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//first.com' })('first.com'), + ' ', + link({ auto: true, href: '//second.com' })('second.com'), + ), + ), + ); + }); + + it('should update first link once after stepping out of link node', () => { + jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((requestCallback: FrameRequestCallback) => { + requestCallback(1); + return 1; + }); + editor.add( + doc( + p( + link({ auto: true, href: '//first.co' })('first.co'), + ' ', + link({ auto: true, href: '//second.com' })('second.com'), + ), + ), + ); + + editor.selectText(9).insertText('m'); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + editor.insertText('!'); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + editor.insertText('!'); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//first.com' })('first.com'), + '!! ', + link({ auto: true, href: '//second.com' })('second.com'), + ), + ), + ); + }); + + it('should only update second link', () => { + editor.add( + doc( + p( + link({ auto: true, href: '//first.co' })('first.co'), + ' ', + link({ auto: true, href: '//second.com' })('second.com'), + ), + ), + ); + + editor.backspace(); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//first.co' })('first.co'), + ' ', + link({ auto: true, href: '//second.co' })('second.co'), + ), + ), + ); + }); + + it('should update second link once after stepping out of link node', () => { + editor.add( + doc( + p( + link({ auto: true, href: '//first.co' })('first.co'), + ' ', + link({ auto: true, href: '//second.com' })('second.com'), + ), + ), + ); + + editor.backspace(); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + editor.insertText(' '); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + editor.insertText('Test'); + + expect(onUpdateLink).toHaveBeenCalledTimes(1); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//first.co' })('first.co'), + ' ', + link({ auto: true, href: '//second.co' })('second.co'), + ' Test', + ), + ), + ); + }); + + it('supports detecting changed TLD in qoutes', () => { + editor.add(doc(p(''))).insertText('"test.co"'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('"', link({ auto: true, href: '//test.co' })('test.co'), '"')), + ); + + editor.selectText(9).insertText('m'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('"', link({ auto: true, href: '//test.com' })('test.com'), '"')), + ); + }); + + it('supports detecting removing and creating links inside string', () => { + editor.add(doc(p(''))).insertText('Test"test.co"Test'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('Test"', link({ auto: true, href: '//test.co' })('test.co'), '"Test')), + ); + + editor.selectText(13).backspace(2).insertText('io'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('Test"', link({ auto: true, href: '//test.io' })('test.io'), '"Test')), + ); + }); + + it('supports detecting added adjacent text nodes', () => { + editor.add(doc(p('window.com'))); + + expect(editor.doc).toEqualRemirrorDocument(doc(p('window.com'))); + + editor.backspace(); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//window.co' })('window.co'))), + ); + + editor.insertText('nfirm'); + + expect(editor.doc).toEqualRemirrorDocument(doc(p('window.confirm'))); + }); + + it('supports detecting removed adjacent text nodes', () => { + editor.add(doc(p('window.confirm'))); + + expect(editor.doc).toEqualRemirrorDocument(doc(p('window.confirm'))); + + editor.backspace(5); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//window.co' })('window.co'))), + ); + }); + + it('can respond to inserted space separating the link', () => { + editor + .add(doc(p(''))) + .insertText('test.co/istesting') + .selectText(11) + .insertText(' '); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//test.co/is' })('test.co/is'), ' testing')), + ); + }); + + it('responds to joining text', () => { + editor.add(doc(p(link({ auto: true, href: '//test.co/is' })('test.co/is'), ' testing'))); + + editor.selectText(12).backspace(); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//test.co/istesting' })('test.co/istesting'))), + ); + }); + it('can respond to `Enter` key presses', () => { editor .add(doc(p(''))) @@ -738,8 +1024,7 @@ describe('autolinking', () => { it('can respond to `Enter` key presses with only one letter', () => { editor - .add(doc(p(''))) - .insertText('test.coo') + .add(doc(p('test.coo'))) .selectText(8) .press('Enter') .insertText('co'); @@ -814,73 +1099,202 @@ describe('autolinking', () => { ); }); - it('detects emails as auto link', () => { - editor.add(doc(p(''))).insertText('user@example.com'); + it('does not create a link if not in selection range - edit text after', () => { + editor.add(doc(p('remirror.io tester'))).backspace(2); - expect(editor.doc).toEqualRemirrorDocument( - doc(p(link({ auto: true, href: 'mailto:user@example.com' })('user@example.com'))), - ); + expect(editor.doc).toEqualRemirrorDocument(doc(p('remirror.io', ' test'))); }); - it('works with other input rules', () => { - editor.add(doc(p('__bold_'))).insertText('_'); + it('does not create a link if not in selection range - edit text before', () => { + editor + .add(doc(p('tester remirror.io'))) + .selectText(7) + .backspace(2); - expect(editor.doc).toEqualRemirrorDocument(doc(p(bold('bold')))); + expect(editor.doc).toEqualRemirrorDocument(doc(p('test', ' remirror.io'))); }); - it('does not interfere with input rules for ordered lists', () => { - const editor = renderEditor([ - new LinkExtension({ autoLink: true }), - new OrderedListExtension(), - ]); - const { doc, p, orderedList: ol, listItem: li } = editor.nodes; + it('does not create a link if not in selection range - create link after', () => { + editor + .add(doc(p('remirror.io test '))) + .backspace() + .insertText('.com'); - editor.add(doc(p('Hello there friend and partner.'))).insertText('1.'); + expect(editor.doc).toEqualRemirrorDocument( + doc(p('remirror.io ', link({ auto: true, href: '//test.com' })('test.com'))), + ); + }); - expect(editor.doc).toEqualRemirrorDocument(doc(p('1.Hello there friend and partner.'))); + it('does not create a link if not in selection range - create link before', () => { + editor + .add(doc(p('test remirror.io'))) + .selectText(5) + .insertText('.com'); - editor.insertText(' '); + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//test.com' })('test.com'), ' remirror.io')), + ); + }); - expect(editor.doc).toEqualRemirrorDocument(doc(ol(li(p('Hello there friend and partner.'))))); + it('should create links with the same URL without a space between links', () => { + editor.add(doc(p('test"'))).insertText('test.com"test.com"test'); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + 'test"', + link({ auto: true, href: '//test.com' })('test.com'), + '"', + link({ auto: true, href: '//test.com' })('test.com'), + '"test', + ), + ), + ); }); -}); -describe('more auto link cases', () => { - let editor = create({ autoLink: true }); - let { link } = editor.attributeMarks; - let { doc, p } = editor.nodes; + it('should create links with different URLs without a space between links', () => { + editor.add(doc(p('test"'))).insertText('tester.com"test.com"test'); - beforeEach(() => { - editor = create({ autoLink: true }); - ({ - attributeMarks: { link }, - nodes: { doc, p }, - } = editor); + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + 'test"', + link({ auto: true, href: '//tester.com' })('tester.com'), + '"', + link({ auto: true, href: '//test.com' })('test.com'), + '"test', + ), + ), + ); }); - it.each([ - { input: 'google.com', expected: '//google.com' }, - { input: 'www.google.com', expected: '//www.google.com' }, - { input: 'http://github.com' }, - { input: 'https://github.com/remirror/remirror' }, + it('should only update edited link when multiple links are not separated by a space - first link', () => { + editor + .add(doc(p('test"test.com"remirror.io"test'))) + .selectText(14) + .backspace(2); - // with port - { input: 'time.google.com:123', expected: '//time.google.com:123' }, + expect(editor.doc).toEqualRemirrorDocument(doc(p('test"test.c"remirror.io"test'))); - // with "#" - { input: "https://en.wikipedia.org/wiki/The_Power_of_the_Powerless#Havel's_greengrocer" }, + editor.insertText('om'); - // with '(' and ')' - { input: 'https://en.wikipedia.org/wiki/Specials_(Unicode_block)' }, + expect(editor.doc).toEqualRemirrorDocument( + doc(p('test"', link({ auto: true, href: '//test.com' })('test.com'), '"remirror.io"test')), + ); - // with "?" - { input: 'https://www.google.com/search?q=test' }, + editor.backspace(2); - // with everything - { - input: - 'https://github.com:443/remirror/remirror/commits/main?after=4dc93317d4b62f2d155865f7d2e721f05ddfdd61+34&branch=main&path%5B%5D=readme.md#repo-content-pjax-container', - }, + expect(editor.doc).toEqualRemirrorDocument(doc(p('test"test.c"remirror.io"test'))); + }); + + it('should only update edited link when multiple links are not separated by a space - second link', () => { + editor.add(doc(p('test"testy.com"', link({ auto: true, href: '//test.co' })('test.co')))); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('test"testy.com"', link({ auto: true, href: '//test.co' })('test.co'))), + ); + + editor.insertText('m'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('test"testy.com"', link({ auto: true, href: '//test.com' })('test.com'))), + ); + + editor.backspace(2); + + expect(editor.doc).toEqualRemirrorDocument(doc(p('test"testy.com"test.c'))); + }); + + it('allows creating identical links in different parent nodes', () => { + editor + .add(doc(p(link({ auto: true, href: '//test.com' })('test.com')))) + .press('Enter') + .insertText('test.com test.com'); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p(link({ auto: true, href: '//test.com' })('test.com')), + p( + link({ auto: true, href: '//test.com' })('test.com'), + ' ', + link({ auto: true, href: '//test.com' })('test.com'), + ), + ), + ); + }); + + it('detects emails as auto link', () => { + editor.add(doc(p(''))).insertText('user@example.com'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: 'mailto:user@example.com' })('user@example.com'))), + ); + }); + + it('works with other input rules', () => { + editor.add(doc(p('__bold_'))).insertText('_'); + + expect(editor.doc).toEqualRemirrorDocument(doc(p(bold('bold')))); + }); + + it('does not interfere with input rules for ordered lists', () => { + const editor = renderEditor([ + new LinkExtension({ autoLink: true }), + new OrderedListExtension(), + ]); + const { doc, p, orderedList: ol, listItem: li } = editor.nodes; + + editor.add(doc(p('Hello there friend and partner.'))).insertText('1.'); + + expect(editor.doc).toEqualRemirrorDocument(doc(p('1.Hello there friend and partner.'))); + + editor.insertText(' '); + + expect(editor.doc).toEqualRemirrorDocument(doc(ol(li(p('Hello there friend and partner.'))))); + }); +}); + +describe('more auto link cases', () => { + let editor = create({ autoLink: true }); + let { link } = editor.attributeMarks; + let { doc, p } = editor.nodes; + + beforeEach(() => { + editor = create({ autoLink: true }); + ({ + attributeMarks: { link }, + nodes: { doc, p }, + } = editor); + }); + + it.each([ + { input: 'google.com', expected: '//google.com' }, + { input: 'www.google.com', expected: '//www.google.com' }, + { input: 'http://github.com' }, + { input: 'https://github.com/remirror/remirror' }, + + // with port + { input: 'time.google.com:123', expected: '//time.google.com:123' }, + + // with "#" + { input: "https://en.wikipedia.org/wiki/The_Power_of_the_Powerless#Havel's_greengrocer" }, + + // with '(' and ')' + { input: 'https://en.wikipedia.org/wiki/Specials_(Unicode_block)' }, + + { + input: 'en.wikipedia.org/wiki/Specials_(Unicode_block)', + expected: '//en.wikipedia.org/wiki/Specials_(Unicode_block)', + }, + + // with "?" + { input: 'https://www.google.com/search?q=test' }, + + // with everything + { + input: + 'https://github.com:443/remirror/remirror/commits/main?after=4dc93317d4b62f2d155865f7d2e721f05ddfdd61+34&branch=main&path%5B%5D=readme.md#repo-content-pjax-container', + }, ])('can auto link $input', ({ input, expected }) => { editor.add(doc(p(''))).insertText(input); @@ -1002,7 +1416,7 @@ describe('autolinking with allowed TLDs', () => { describe('onClick', () => { it('responds to clicks', () => { - const onClick = jest.fn(() => false); + const onClick: any = jest.fn(() => false); const { view, add, @@ -1032,7 +1446,7 @@ describe('onClick', () => { it('can override further options when false is returned', () => { let returnValue = true; - const onClick = jest.fn(() => returnValue); + const onClick: any = jest.fn(() => returnValue); const { view, add, @@ -1074,8 +1488,9 @@ describe('spanning', () => { expect(view.dom.innerHTML).toMatchInlineSnapshot(`

Paragraph with - a @@ -1139,3 +1554,369 @@ describe('target', () => { expect(editor.doc).toEqualRemirrorDocument(doc(p(link('test')))); }); }); + +describe('adjacent punctuations', () => { + it('should not include trailing `?` in the link', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io?test=true'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io?test=true' })('remirror.io?test=true'))), + ); + + editor.selectText(23).backspace(10).insertText('?'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io' })('remirror.io'), '?')), + ); + }); + + it('should include trailing `?` in the link', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + adjacentPunctuations: ['.'], + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io?test=true'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io?test=true' })('remirror.io?test=true'))), + ); + + editor.selectText(23).backspace(10).insertText('?'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io?' })('remirror.io?'))), + ); + }); + + it('should not remove link if "." is added before link', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor + .add(doc(p(''))) + .insertText('remirror.io') + .selectText(0) + .insertText('.'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('.', link({ auto: true, href: '//remirror.io' })('remirror.io'))), + ); + }); + + it('should not include surrounding `"` in the link', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('"remirror.io"'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('"', link({ auto: true, href: '//remirror.io' })('remirror.io'), '"')), + ); + }); + + it('should not include surrounding `((("")))` in the link', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('((("remirror.io")))'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('((("', link({ auto: true, href: '//remirror.io' })('remirror.io'), '")))')), + ); + }); + + it('should not include surrounding `((("")))` in the link - URL path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('((("remirror.io/test")))'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('((("', link({ auto: true, href: '//remirror.io/test' })('remirror.io/test'), '")))')), + ); + }); + + it('should not include surrounding `,` in the link - URL path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText(',remirror.io/test,'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(',', link({ auto: true, href: '//remirror.io/test' })('remirror.io/test'), ',')), + ); + }); + + it('should not include trailing `"` in the link - URL path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io/test"'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io/test' })('remirror.io/test'), '"')), + ); + }); + + it('should not include trailing `"""` in the link - URL path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io/test"""'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io/test' })('remirror.io/test'), '"""')), + ); + }); + + it('should not include trailing `"` in the link', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io"'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io' })('remirror.io'), '"')), + ); + }); + + it('should check for balanced braces - URL path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io/test(balance))'); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//remirror.io/test(balance)' })('remirror.io/test(balance)'), + ')', + ), + ), + ); + }); + + it('should handle unbalanced path - URL path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor + .add(doc(p(''))) + .insertText('remirror.io/test(balance))') + .backspace(2); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//remirror.io/test(balance' })('remirror.io/test(balance'))), + ); + }); + + it('should check for balanced braces - URL query', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io?test=(balance))'); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//remirror.io?test=(balance)' })('remirror.io?test=(balance)'), + ')', + ), + ), + ); + }); + + it('should handle unbalanced path parts - URL query', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor + .add(doc(p(''))) + .insertText('remirror.io?test=(balance))') + .backspace(2); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p(link({ auto: true, href: '//remirror.io?test=(balance' })('remirror.io?test=(balance')), + ), + ); + }); + + it('should only exclude trailing quotation marks fr0m link path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io?test=quotation"(ba"lance"""'); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//remirror.io?test=quotation"(ba"lance' })( + 'remirror.io?test=quotation"(ba"lance', + ), + '"""', + ), + ), + ); + }); + + it('should allow a path that is not balanced', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor + .add(doc(p(''))) + .insertText('remirror.io?test=((balance))') + .backspace(); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//remirror.io?test=((balance)' })( + 'remirror.io?test=((balance)', + ), + ), + ), + ); + }); + + it('should exclude ")" and following punctuation after balanced path', () => { + const editor = renderEditor([ + new LinkExtension({ + autoLink: true, + }), + ]); + const { + attributeMarks: { link }, + nodes: { doc, p }, + } = editor; + + editor.add(doc(p(''))).insertText('remirror.io?test=((balance)))"'); + + expect(editor.doc).toEqualRemirrorDocument( + doc( + p( + link({ auto: true, href: '//remirror.io?test=((balance))' })( + 'remirror.io?test=((balance))', + ), + ')"', + ), + ), + ); + }); +}); diff --git a/packages/remirror__extension-link/src/link-extension-utils.ts b/packages/remirror__extension-link/src/link-extension-utils.ts index 676e5b578a..7d927fb56d 100644 --- a/packages/remirror__extension-link/src/link-extension-utils.ts +++ b/packages/remirror__extension-link/src/link-extension-utils.ts @@ -53,3 +53,59 @@ export const TOP_50_TLDS = [ 'no', 'cyou', ]; + +/** Including single and double quotation */ +export const SENTENCE_PUNCTUATIONS = ['!', '?', "'", ',', '.', ':', ';', '"']; + +export const DEFAULT_ADJACENT_PUNCTUATIONS = [...SENTENCE_PUNCTUATIONS, '(', ')', '[', ']']; + +const PAIR_PUNCTUATIONS = '[]{}()'; + +export const isBalanced = (input: string): boolean => { + const stack = []; + + for (const character of input) { + const index = PAIR_PUNCTUATIONS.indexOf(character); + + if (index === -1) { + continue; + } + + if (index % 2 === 0) { + stack.push(index + 1); + + continue; + } + + if (stack.length === 0 || stack.pop() !== index) { + return false; + } + } + + return stack.length === 0; +}; + +export const getBalancedIndex = (input: string, index: number): number => + !isBalanced(input.slice(0, index)) ? getBalancedIndex(input, --index) : index; + +export const getTrailingPunctuationIndex = (input: string, index: number): number => + SENTENCE_PUNCTUATIONS.includes(input.slice(0, index).slice(-1)) + ? getTrailingPunctuationIndex(input, --index) + : index; + +export const getTrailingCharIndex = ({ + adjacentPunctuations, + input, + url = '', +}: { + adjacentPunctuations: typeof DEFAULT_ADJACENT_PUNCTUATIONS; + input: string; + url: string; +}): number | undefined => + input.slice(input.indexOf(url) + url.length).length * -1 || + (adjacentPunctuations.includes(input.slice(-1)) ? -1 : undefined); + +export const addProtocol = (input: string, defaultProtocol?: string): string => + ['http://', 'https://', 'ftp://'].some((protocol) => input.startsWith(protocol)) + ? input + : `${defaultProtocol && defaultProtocol.length > 0 ? defaultProtocol : 'https:'}//${input}`; diff --git a/packages/remirror__extension-link/src/link-extension.ts b/packages/remirror__extension-link/src/link-extension.ts index 8f0c522de7..2e47255390 100644 --- a/packages/remirror__extension-link/src/link-extension.ts +++ b/packages/remirror__extension-link/src/link-extension.ts @@ -37,7 +37,6 @@ import { removeMark, Static, updateMark, - within, } from '@remirror/core'; import type { CreateEventHandlers } from '@remirror/extension-events'; import { undoDepth } from '@remirror/pm/history'; @@ -45,7 +44,16 @@ import { MarkPasteRule } from '@remirror/pm/paste-rules'; import { Selection, TextSelection } from '@remirror/pm/state'; import { ReplaceAroundStep, ReplaceStep } from '@remirror/pm/transform'; -import { TOP_50_TLDS } from './link-extension-utils'; +import { + addProtocol, + DEFAULT_ADJACENT_PUNCTUATIONS, + getBalancedIndex, + getTrailingCharIndex, + getTrailingPunctuationIndex, + isBalanced, + SENTENCE_PUNCTUATIONS, + TOP_50_TLDS, +} from './link-extension-utils'; const UPDATE_LINK = 'updateLink'; @@ -58,12 +66,7 @@ const DEFAULT_AUTO_LINK_REGEX = */ export type DefaultProtocol = 'http:' | 'https:' | '' | string; -interface FoundAutoLink { - href: string; - text: string; - start: number; - end: number; -} +interface FoundAutoLinks extends Array<{ text: string; startIndex: number } | undefined> {} interface EventMeta { selection: Selection; @@ -185,6 +188,23 @@ export interface LinkOptions { */ autoLinkAllowedTLDs?: Static; + /** + * Adjacent punctuations that are excluded from a link + * + * To extend the default list you could + * + * ```ts + * import { LinkExtension, DEFAULT_ADJACENT_PUNCTUATIONS } from 'remirror/extensions'; + * const extensions = () => [ + * new LinkExtension({ adjacentPunctuations: [...DEFAULT_ADJACENT_PUNCTUATIONS, ')'] }) + * ]; + * ``` + * + * @default + * [ ',', '.', '!', '?', ':', ';', "'", '"', '(', ')', '[', ']' ] + */ + adjacentPunctuations?: string[]; + /** * The default protocol to use when it can't be inferred. * @@ -238,6 +258,7 @@ export type LinkAttributes = ProsemirrorAttributes<{ openLinkOnClick: false, autoLinkRegex: DEFAULT_AUTO_LINK_REGEX, autoLinkAllowedTLDs: TOP_50_TLDS, + adjacentPunctuations: DEFAULT_ADJACENT_PUNCTUATIONS, defaultTarget: null, supportedTargets: [], extractHref, @@ -290,7 +311,17 @@ export class LinkExtension extends MarkExtension { } const href = node.getAttribute('href'); - const auto = node.hasAttribute(AUTO_ATTRIBUTE); + const text = node.textContent; + + // If link text content equals href value we "auto link" + // e.g [test](//test.com) - not "auto link" + // e.g [test.com](//test.com) - "auto link" + const auto = + this.options.autoLink && + (node.hasAttribute(AUTO_ATTRIBUTE) || + href === text || + href?.replace(`${this.options.defaultProtocol}//`, '') === text); + return { ...extra.parse(node), href, @@ -437,9 +468,7 @@ export class LinkExtension extends MarkExtension { return false; } - const href = this.buildHref(url); - - if (!this.isValidTLD(href)) { + if (!this.isValidUrl(url)) { return false; } @@ -516,7 +545,7 @@ export class LinkExtension extends MarkExtension { if (this.options.selectTextOnClick) { const $start = doc.resolve(range.from); const $end = doc.resolve(range.to); - const transaction = tr.setSelection(new TextSelection($start, $end)); + const transaction = tr.setSelection(TextSelection.between($start, $end)); view.dispatch(transaction); } @@ -573,8 +602,8 @@ export class LinkExtension extends MarkExtension { const newMarks = this.getLinkMarksInRange(doc, newFrom, newTo, true); newMarks.forEach((newMark) => { - const wasLink = this._autoLinkRegexNonGlobal?.test(prevMark.text); - const isLink = this._autoLinkRegexNonGlobal?.test(newMark.text); + const wasLink = this.isValidUrl(prevMark.text); + const isLink = this.isValidUrl(newMark.text); if (wasLink && !isLink) { removeLink({ from: newMark.from, to: newMark.to }).tr(); @@ -593,38 +622,83 @@ export class LinkExtension extends MarkExtension { } const nodeText = tr.doc.textBetween(pos, pos + node.nodeSize, undefined, ' '); - this.findAutoLinks(nodeText) - // calculate link position - .map((link) => ({ - ...link, - from: pos + link.start + 1, - to: pos + link.end + 1, - })) - .filter((link) => { - // Determine if found link is within the changed range - return ( - within(link.from, from, to) || - within(link.to, from, to) || - within(from, link.from, link.to) || - within(to, link.from, link.to) - ); - }) - .filter((link) => { - // Avoid overwriting manually created links - const marks = this.getLinkMarksInRange(tr.doc, link.from, link.to, false); - return marks.length === 0; - }) - .forEach(({ from, to, href, text }) => { - const attrs = { href, auto: true }; - const range = { from, to }; - updateLink(attrs, range).tr(); - onUpdateCallbacks.push({ attrs, range, text }); - }); + + this.findAutoLinks(nodeText).forEach((link) => { + const positionStart = pos; + const positionEnd = positionStart + node.nodeSize; + + // If a string is separated into two nodes by `Enter` key press, + // we consider both to be in the changed range. + const hasNewNode = to - from === 2; + + const linkMarkInRange = this.getLinkMarksInRange( + doc, + positionStart + (link?.startIndex || 0), + positionEnd, + true, + ); + const lastLinkMarkInRange = linkMarkInRange[linkMarkInRange.length - 1]; + + const text = link?.text || ''; + const href = this.buildHref(text); + const start = positionStart + 1 + (link?.startIndex || 0); + const end = start + text.length; + + const attrs = { auto: true, href }; + const range = { from: start, to: end }; + + if (!link) { + // If we have an existing link we can assume the link is not valid and remove It, + lastLinkMarkInRange && + removeLink({ + from: lastLinkMarkInRange.from, + to: lastLinkMarkInRange.to, + }).tr(); + + return; + } + + // Avoid overwriting manually created links + if (this.getLinkMarksInRange(tr.doc, start, end, false).length > 0) { + return; + } + + if ( + // Don't update or create a link outside the changed range, + (end < to || start > from) && + // unless link is not in the same node, + !hasNewNode && + // or the existing link is not a valid "auto link". + !linkMarkInRange.some(({ mark, text: markText, from, to }) => { + return ( + !( + // Outside changed range and + ( + end < from && + // found auto link is not an updated existing link. + !text.includes(markText) + ) + ) && + // Existing link `href` matches the existing link text or + (mark.attrs.href !== this.buildHref(markText) || + // the existing link matches found auto link href. + (end === to && start === from && mark.attrs.href !== href)) + ); + }) + ) { + return; + } + + updateLink(attrs, range).tr(); + onUpdateCallbacks.push({ attrs, range, text }); + }); }); - onUpdateCallbacks.forEach(({ range, attrs, text }) => { - const { doc, selection } = tr; - this.options.onUpdateLink(text, { doc, selection, range, attrs }); + window.requestAnimationFrame(() => { + onUpdateCallbacks.forEach(({ attrs, range, text }) => { + const { doc, selection } = tr; + this.options.onUpdateLink(text, { attrs, doc, range, selection }); + }); }); }); @@ -682,31 +756,33 @@ export class LinkExtension extends MarkExtension { return linkMarks; } - private findAutoLinks(str: string): FoundAutoLink[] { - const toAutoLink: FoundAutoLink[] = []; + private findAutoLinks(input: string): FoundAutoLinks { + const foundLinks: FoundAutoLinks = []; + let temp = input; - for (const match of findMatches(str, this.options.autoLinkRegex)) { - const text = getMatchString(match); + // Fuzzy match valid links + for (const match of findMatches(temp, this.options.autoLinkRegex)) { + const link = getMatchString(match); - if (!text) { - continue; - } + // Slice adjacent characters and spaces of the link text + const text = input.slice(match.index, this.getLinkEndIndex(temp, link)).split(' ')[0]; - const href = this.buildHref(text); + // Remove previous links from input string + temp = temp.slice(temp.indexOf(link) + link.length); - if (!this.isValidTLD(href)) { + // Test if link text is a valid URL + if (!this.isValidUrl(text)) { continue; } - toAutoLink.push({ - text, - href, - start: match.index, - end: match.index + text.length, - }); + foundLinks.push({ text, startIndex: match.index }); + } + + if (foundLinks.length === 0) { + foundLinks.push(undefined); } - return toAutoLink; + return foundLinks; } private isValidTLD(str: string): boolean { @@ -727,6 +803,55 @@ export class LinkExtension extends MarkExtension { return autoLinkAllowedTLDs.includes(tld); } + + private isValidUrl(url: string) { + const href = this.buildHref(url); + + return this.isValidTLD(href) && this._autoLinkRegexNonGlobal?.test(url); + } + + private getLinkEndIndex(input: string, url: string) { + const domain = extractDomain(addProtocol(input, this.options.defaultProtocol)); + + if (domain.length === 0) { + return; + } + + const path = input.slice(domain.length + input.indexOf(domain)).slice(1); + + // URL API could better determine a valid path but using extractDomain appears to do the job + // Need to make sure that path does not include "." + if (!path || path.includes('.')) { + // Return index to remove adjacent punctuation + return getTrailingCharIndex({ + adjacentPunctuations: this.options.adjacentPunctuations, + input, + url, + }); + } + + // A URL with a path or search query is slightly more ambiguous + const index = -1; + const balancedPathIndex = !isBalanced(path) ? getBalancedIndex(path, index) : 0; + + // Balanced part of path + const balanced = path.slice(0, balancedPathIndex); + // Imbalanced part of path + const imbalanced = path.replace(balanced, ''); + + const balanceIndex = ![ + ...imbalanced.slice((!isBalanced(imbalanced) ? getBalancedIndex(imbalanced, index) : 0) + 1), + ].some((char) => ![')', ']', '}'].includes(char) && !SENTENCE_PUNCTUATIONS.includes(char)) + ? balancedPathIndex + : undefined; + + const balancedPath = balanceIndex ? balanced : balanced + imbalanced; + + // Return index to remove trailing characters + return SENTENCE_PUNCTUATIONS.includes(balancedPath.slice(-1)) + ? getTrailingPunctuationIndex(balancedPath, index) + (balanceIndex || 0) + : balanceIndex; + } } /**