UNPKG

@wordpress/components

Version:
137 lines (117 loc) 3.76 kB
/** * External dependencies */ import removeAccents from 'remove-accents'; /** * Internal dependencies */ import type { WPCompleter } from './types'; type AutocompleteMatch = { completer: WPCompleter; filterValue: string; }; type AutocompleteMatchOptions = { matchCount: number; isBackspacing: boolean; getTextAfterSelection: () => string; lastCompletion?: { name: string; value: string } | null; }; export function getAutocompleteMatch( textContent: string, completers: WPCompleter[], options: AutocompleteMatchOptions ): AutocompleteMatch | null { const { matchCount, isBackspacing, getTextAfterSelection, lastCompletion } = options; if ( ! textContent ) { return null; } // Find the completer whose trigger prefix ends closest to the cursor // (rightmost end position). Comparing end positions instead of start // positions correctly resolves overlapping prefixes like "@" and "@@". let completer: WPCompleter | null = null; let triggerIndex = -1; let matchedEndIndex = -1; let matchedPrefixLength = 0; for ( const currentCompleter of completers ) { const currentIndex = textContent.lastIndexOf( currentCompleter.triggerPrefix ); if ( currentIndex < 0 ) { continue; } const currentEndIndex = currentIndex + currentCompleter.triggerPrefix.length; if ( currentEndIndex > matchedEndIndex || ( currentEndIndex === matchedEndIndex && currentCompleter.triggerPrefix.length > matchedPrefixLength ) ) { completer = currentCompleter; triggerIndex = currentIndex; matchedEndIndex = currentEndIndex; matchedPrefixLength = currentCompleter.triggerPrefix.length; } } if ( ! completer ) { return null; } const { allowContext, triggerPrefix } = completer; const textWithoutTrigger = textContent.slice( triggerIndex + triggerPrefix.length ); // Prevent matching with an extremely long string, which causes // the editor to slow-down significantly. This could happen, for // example, if `matchingWhileBackspacing` is true and one of the // "words" ends up being too long. Returning null here intentionally // resets the autocompleter state in the caller. if ( textWithoutTrigger.length > 50 ) { return null; } const mismatch = matchCount === 0; const wordsFromTrigger = textWithoutTrigger.split( /\s/ ); // Allow matching when typing a trigger + the match string or when // clicking in an existing trigger word on the page. // E.g. "Some text @a" — "@a" is detected as a trigger word. const hasOneTriggerWord = wordsFromTrigger.length === 1; // Allow matching when backspacing near a trigger word (up to 3 // words from the trigger character). This lets us recover from a // mismatch when backspacing while still imposing sane limits. // E.g. "Some text @marcelo sekkkk" — backspacing "kkkk" re-shows // the popup once the text matches again. const matchingWhileBackspacing = isBackspacing && wordsFromTrigger.length <= 3; if ( mismatch && ! ( matchingWhileBackspacing || hasOneTriggerWord ) ) { return null; } if ( allowContext && ! allowContext( textContent.slice( 0, triggerIndex ), getTextAfterSelection() ) ) { return null; } if ( /^\s/.test( textWithoutTrigger ) || /\s\s+$/.test( textWithoutTrigger ) ) { return null; } // After a completion whose value starts with the trigger prefix // (e.g. @username), the trigger remains in the text and would // re-activate the autocompleter. Suppress the match when the // filter value still corresponds to the recently completed text. if ( lastCompletion && lastCompletion.name === completer.name && textWithoutTrigger.trimEnd() === lastCompletion.value ) { return null; } return { completer, filterValue: removeAccents( textWithoutTrigger ), }; }