From 984eae4105d18ba32203c8fae77ce6830ce6640b Mon Sep 17 00:00:00 2001 From: marcoSven Date: Tue, 19 Jul 2022 17:58:58 +0200 Subject: [PATCH] refactor --- .../__tests__/link-extension.spec.ts | 113 +++++++- .../src/link-extension-utils.ts | 58 ++-- .../src/link-extension.ts | 269 +++++------------- 3 files changed, 207 insertions(+), 233 deletions(-) diff --git a/packages/remirror__extension-link/__tests__/link-extension.spec.ts b/packages/remirror__extension-link/__tests__/link-extension.spec.ts index 0474d56e22..ebfc0dd2a4 100644 --- a/packages/remirror__extension-link/__tests__/link-extension.spec.ts +++ b/packages/remirror__extension-link/__tests__/link-extension.spec.ts @@ -737,7 +737,7 @@ describe('autolinking', () => { ); }); - it('detects seperating two links', () => { + it('detects seperating two links by entering a space', () => { editor.add(doc(p(''))).insertText('github.comremirror.io'); expect(editor.doc).toEqualRemirrorDocument( @@ -757,6 +757,20 @@ describe('autolinking', () => { ); }); + it('detects seperating two links by `Enter` key press', () => { + editor + .add(doc(p('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(''))) @@ -1082,6 +1096,61 @@ describe('autolinking', () => { ); }); + 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('remirror.io', ' test'))); + }); + + 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('test', ' remirror.io'))); + }); + + it('does not create a link if not in selection range - create link after', () => { + editor + .add(doc(p('remirror.io test '))) + .backspace() + .insertText('.com'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p('remirror.io ', link({ auto: true, href: '//test.com' })('test.com'))), + ); + }); + + 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'); + + expect(editor.doc).toEqualRemirrorDocument( + doc(p(link({ auto: true, href: '//test.com' })('test.com'), ' remirror.io')), + ); + }); + + it('allows creating identical links', () => { + 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'); @@ -1635,6 +1704,27 @@ describe('adjacent punctuations', () => { ); }); + it('should remove unbalanced parts - 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' })('remirror.io/test'), '(balance')), + ); + }); + it('should check for balanced braces - URL query', () => { const editor = renderEditor([ new LinkExtension({ @@ -1657,4 +1747,25 @@ describe('adjacent punctuations', () => { ), ); }); + + it('should remove unbalanced 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=' })('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 d33c24b3fb..0e137408cc 100644 --- a/packages/remirror__extension-link/src/link-extension-utils.ts +++ b/packages/remirror__extension-link/src/link-extension-utils.ts @@ -85,53 +85,27 @@ export const isBalanced = (input: string): boolean => { return stack.length === 0; }; -export const getBalancedIndex = (input: string, index: number): number => { - const newString = input.slice(0, index); +export const getBalancedIndex = (input: string, index: number): number => + !isBalanced(input.slice(0, index)) ? getBalancedIndex(input, --index) : index; - if (!isBalanced(newString)) { - return getBalancedIndex(input, --index); - } - - return index; -}; - -export const getTrailingPunctuationIndex = (input: string, index: number): number => { - const newString = input.slice(0, index); - - if (SENTENCE_PUNCTUATIONS.includes(newString.slice(-1))) { - return getTrailingPunctuationIndex(input, --index); - } - - return index; -}; +export const getTrailingPunctuationIndex = (input: string, index: number): number => + SENTENCE_PUNCTUATIONS.includes(input.slice(0, index).slice(-1)) + ? getTrailingPunctuationIndex(input, --index) + : index; -export const getAdjacentCharCount = ({ - direction, +export const getTrailingCharIndex = ({ + adjacentPunctuations, input, - url, + url = '', }: { - direction: 0 | 1; + adjacentPunctuations: typeof DEFAULT_ADJACENT_PUNCTUATIONS; input: string; - url: string | undefined; -}) => { - const length = ((url && input.split(url)[direction]) || '').length; + url: string; +}): number | undefined => + (input.split(url)[1] || '').length * -1 || + (adjacentPunctuations.includes(input.slice(-1)) ? -1 : undefined); - if (length > 1) { - return Math.abs(length) * (direction === 1 ? -1 : 1); - } - - return; -}; - -export const addProtocol = (input: string) => +export const addProtocol = (input: string, defaultProtocol?: string): string => ['http://', 'https://', 'ftp://'].some((protocol) => input.startsWith(protocol)) ? input - : `https://${input}`; - -export const createNewURL = (input: string) => { - try { - return new URL(addProtocol(input)); - } catch { - return; - } -}; + : `${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 aa3d031acf..7ba1645c9c 100644 --- a/packages/remirror__extension-link/src/link-extension.ts +++ b/packages/remirror__extension-link/src/link-extension.ts @@ -46,10 +46,10 @@ import { Selection, TextSelection } from '@remirror/pm/state'; import { ReplaceAroundStep, ReplaceStep } from '@remirror/pm/transform'; import { - createNewURL, + addProtocol, DEFAULT_ADJACENT_PUNCTUATIONS, - getAdjacentCharCount, getBalancedIndex, + getTrailingCharIndex, getTrailingPunctuationIndex, isBalanced, SENTENCE_PUNCTUATIONS, @@ -60,6 +60,8 @@ const UPDATE_LINK = 'updateLink'; const MIN_LINK_LENGTH = 4; +const NON_WHITESPACE_REGEX = /\S+/gm; + // Based on https://gist.github.com/dperini/729294 const DEFAULT_AUTO_LINK_REGEX = /(?:(?:(?:https?|ftp):)?\/\/)?(?:\S+(?::\S*)?@)?(?:(?:[\da-z\u00A1-\uFFFF][\w\u00A1-\uFFFF-]{0,62})?[\da-z\u00A1-\uFFFF]\.)*(?:(?:\d(?!\.)|[a-z\u00A1-\uFFFF])(?:[\da-z\u00A1-\uFFFF][\w\u00A1-\uFFFF-]{0,62})?[\da-z\u00A1-\uFFFF]\.)+[a-z\u00A1-\uFFFF]{2,}(?::\d{2,5})?(?:[#/?]\S*)?/gi; @@ -597,65 +599,11 @@ export class LinkExtension extends MarkExtension { const composedTransaction = composeTransactionSteps(transactions, prevState); const changes = getChangedRanges(composedTransaction, [ReplaceAroundStep, ReplaceStep]); - const { mapping } = composedTransaction; const { tr, doc } = state; const { updateLink, removeLink } = this.store.chain(tr); - const removeLinkInRange = ({ - end, - nodeText, - start, - }: { - start: number; - end: number; - nodeText: string; - }) => { - const linkInRange = this.getLinkMarksInRange(doc, start, end, true)[0]; - - if (!linkInRange) { - return; - } - - // If the tail of a link has been separated by a space we keep the head part - const split = linkInRange.text.split(' '); - const { from, to } = - split.length === 2 - ? { - from: linkInRange.from, - to: linkInRange.to - (split?.[1]?.length || 0 + 1), - } - : { - from: linkInRange.from, - to: linkInRange.to, - }; - - // Don't remove link if moved into anoter node - if (!nodeText.includes(linkInRange.text)) { - return; - } - - removeLink({ from, to: to }).tr(); - }; - - changes.forEach(({ from, prevFrom, prevTo, to }) => { - // Remove auto links that are no longer valid after they have been split - this.getLinkMarksInRange(prevState.doc, prevFrom, prevTo, true).forEach((prevMark) => { - const newFrom = mapping.map(prevMark.from); - const newTo = mapping.map(prevMark.to); - const newMarks = this.getLinkMarksInRange(doc, newFrom, newTo, true); - newMarks.forEach((newMark) => { - const wasLink = this.isValidUrl(prevMark.text); - const isLink = this.isValidUrl( - this.getTrimmedUrl({ text: newMark.text, url: newMark.text }).url, - ); - - if (wasLink && !isLink) { - removeLink({ from: newMark.from, to: newMark.to }).tr(); - } - }); - }); - + changes.forEach(({ from, to }) => { // Store all the callbacks we need to make const onUpdateCallbacks: Array & { text: string }> = []; @@ -670,7 +618,7 @@ export class LinkExtension extends MarkExtension { const foundAutoLinks: FoundAutoLink[] = []; - for (const match of findMatches(nodeText, new RegExp(/(\S)+/, 'gm'))) { + for (const match of findMatches(nodeText, NON_WHITESPACE_REGEX)) { const matchedText = getMatchString(match); // Skip if URL doesn't have minimum length @@ -681,53 +629,60 @@ export class LinkExtension extends MarkExtension { const matchStart = match.index; const matchEnd = matchStart + matchedText.length; - const urlInMatchRange = this.findURL(matchedText); - const linkMarkInRange = this.getLinkMarksInRange(doc, matchStart, matchEnd, true)[0]; - - // Don't update existing links that are not in selection range - if ( + const urlInMatchedText = this.findURLInString(matchedText); + const linkMarkInRange = this.getLinkMarksInRange( + doc, + pos + matchStart, + pos + matchEnd, + true, + )[0]; + const href = (urlInMatchedText && this.buildHref(urlInMatchedText.url)) || ''; + const positionStart = pos + matchStart + (urlInMatchedText?.startIndex || 0); + const positionEnd = positionStart + href.length; + + const unbalancedLinkUpdated = urlInMatchedText + ? linkMarkInRange && + !isBalanced(linkMarkInRange.text) && + isBalanced(linkMarkInRange.mark.attrs.href) + : false; + + const outsideSelectionRange = urlInMatchedText + ? positionEnd < to + (from === to ? 1 : -1) || + positionStart > from - (from === to ? 1 : 0) || + (linkMarkInRange && + (linkMarkInRange.to >= to || + !within(from, linkMarkInRange.from, linkMarkInRange.to))) + : false; + + const isExistingLinkTextNotEqualHref = + urlInMatchedText && linkMarkInRange + ? linkMarkInRange.mark.attrs.href !== href || + linkMarkInRange.mark.attrs.href !== this.buildHref(linkMarkInRange.text) + : false; + + if (!urlInMatchedText) { linkMarkInRange && - urlInMatchRange && - (linkMarkInRange.to >= to || - !within(from, linkMarkInRange.from, linkMarkInRange.to)) && - linkMarkInRange.mark.attrs.href === this.buildHref(urlInMatchRange) - ) { + removeLink({ from: linkMarkInRange.from, to: linkMarkInRange.to }).tr(); continue; } - // Skip if no valid link is in the match - if (!urlInMatchRange && !new RegExp('^[0-9|-]+$').test(matchedText)) { - // Remove invalid link - removeLinkInRange({ - end: matchEnd, - nodeText, - start: matchStart, - }); - - continue; + if (isExistingLinkTextNotEqualHref) { + removeLink({ from: linkMarkInRange.from, to: linkMarkInRange.to }).tr(); } - const { sliceStart, url: text } = this.getTrimmedUrl({ - text: matchedText, - url: urlInMatchRange, - }); - - // Remove link that is no longer valid in the match - if (!this.isValidUrl(text)) { - removeLinkInRange({ - end: matchEnd, - nodeText, - start: matchStart, - }); - + if ( + outsideSelectionRange && + !isExistingLinkTextNotEqualHref && + !unbalancedLinkUpdated + ) { continue; } foundAutoLinks.push({ - end: match.index + text.length + sliceStart, - href: this.buildHref(text), - start: matchStart + sliceStart, - text, + end: match.index + urlInMatchedText.url.length + urlInMatchedText.startIndex, + href, + start: matchStart + urlInMatchedText.startIndex, + text: urlInMatchedText.url, }); } @@ -838,119 +793,53 @@ export class LinkExtension extends MarkExtension { return autoLinkAllowedTLDs.includes(tld); } - private findURL(input: string): string | undefined { - for (const match of findMatches(input, this.options.autoLinkRegex)) { - const urlMatch = getMatchString(match); - - if (!this.isValidUrl(urlMatch)) { - continue; - } + private findURLInString(input: string): { url: string; startIndex: number } | undefined { + const urlMatch = findMatches(input, this.options.autoLinkRegex)[0]; - return urlMatch; + if (!urlMatch) { + return; } - return; - } + const url = input.slice(urlMatch.index, this.getURLEndIndex(input, getMatchString(urlMatch))); - private hasAdjacentPunctuation(input = '') { - return { - head: this.options.adjacentPunctuations.includes(input[0] || ''), - tail: this.options.adjacentPunctuations.includes(input.slice(-1)), - }; - } - - /** Slice leading and trailing non URL parts */ - private getTrimmedUrl({ url, text }: { url?: string; text: string }) { - const sliceStart = url ? this.getURLStartIndex({ url, text }) : 0; - const sliceEnd = url ? this.getURLEndIndex({ url, startIndex: sliceStart, text }) : undefined; + if (!this.isValidUrl(url)) { + return; + } - return { url: text.slice(sliceStart, sliceEnd), sliceStart, sliceEnd }; + return { url, startIndex: urlMatch.index }; } - private getURLStartIndex({ url, text }: { url?: string; text: string }) { - const adjacentCharCount = getAdjacentCharCount({ - direction: 0, - input: text, - url, - }); + private getURLEndIndex(input: string, url: string) { + const domain = extractDomain(addProtocol(input, this.options.defaultProtocol)); - if (adjacentCharCount && !this.isValidUrl(text)) { - return adjacentCharCount; + if (domain.length === 0) { + return; } - // Check for leading punctuation - return this.hasAdjacentPunctuation(text).head ? 1 : 0; - } + const path = input.slice(domain.length + input.indexOf(domain)).slice(1); - private getURLEndIndex({ - url, - startIndex = 0, - text, - }: { - url?: string; - startIndex?: number; - text: string; - }) { - const newUrl = createNewURL(text); - - // Check for trailing punctuation and adjacent characters - if (newUrl) { - const endsWithPunctuation = (input: string) => this.hasAdjacentPunctuation(input).tail; - - const adjacentCharCount = getAdjacentCharCount({ - direction: 1, - input: text, + if (!path) { + return getTrailingCharIndex({ + adjacentPunctuations: this.options.adjacentPunctuations, + input, url, }); + } - const { pathname, search } = newUrl; - - let decoded; - - try { - decoded = decodeURI(pathname.slice(1) + search); - } catch { - decoded = pathname.slice(1) + search; - } - - if (decoded && endsWithPunctuation(decoded)) { - const index = -1; - const balancedIndex = !isBalanced(decoded) ? getBalancedIndex(decoded, index) : 0; - const trailingPunctuationIndex = SENTENCE_PUNCTUATIONS.includes(decoded.slice(-1)) - ? getTrailingPunctuationIndex(decoded, index) - : 0; - - if (balancedIndex < 0) { - const balanced = decoded.slice(0, balancedIndex); - - if (SENTENCE_PUNCTUATIONS.includes(balanced.slice(-1))) { - return getTrailingPunctuationIndex(balanced, index) + balancedIndex; - } - - return balancedIndex; - } - - if (trailingPunctuationIndex < 0) { - return trailingPunctuationIndex; - } - - return; - } + const index = -1; + const balancedIndex = !isBalanced(path) ? getBalancedIndex(path, index) : 0; - if (adjacentCharCount && !this.isValidUrl(text.slice(startIndex))) { - return ( - adjacentCharCount + - // Adjust index when switching to longer TLD e.g '.co' to '.com' - (this.isValidUrl(text.slice(startIndex, adjacentCharCount + 1)) ? 1 : 0) - ); - } + if (balancedIndex < 0) { + const balanced = path.slice(0, balancedIndex); - if (endsWithPunctuation(text)) { - return -1; - } + return SENTENCE_PUNCTUATIONS.includes(balanced.slice(-1)) + ? getTrailingPunctuationIndex(balanced, index) + balancedIndex + : balancedIndex; } - return; + return SENTENCE_PUNCTUATIONS.includes(path.slice(-1)) + ? getTrailingPunctuationIndex(path, index) + : undefined; } }