@wordpress/components
Version:
UI components for WordPress.
137 lines (117 loc) • 3.76 kB
text/typescript
/**
* 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 ),
};
}