155 lines
5 KiB
TypeScript
155 lines
5 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
MatchHandler,
|
|
AsyncSearch,
|
|
AsyncSearchHandler,
|
|
AsyncSearchOption,
|
|
MatchQueryOption,
|
|
NormalizeOption,
|
|
normalize,
|
|
matchQuery,
|
|
ResultHandler,
|
|
} from '../utils/AsyncSearch';
|
|
import { sanitizeForRegex } from '../utils/regex';
|
|
|
|
export type UseAsyncSearchOptions = AsyncSearchOption & {
|
|
matchOptions?: MatchQueryOption;
|
|
normalizeOptions?: NormalizeOption;
|
|
};
|
|
|
|
export type SearchItemStrGetter<TSearchItem extends object | string | number> = (
|
|
searchItem: TSearchItem,
|
|
query: string
|
|
) => string | string[];
|
|
|
|
export type UseAsyncSearchResult<TSearchItem extends object | string | number> = {
|
|
query: string;
|
|
items: TSearchItem[];
|
|
};
|
|
|
|
export type SearchResetHandler = () => void;
|
|
|
|
const performMatch = (
|
|
target: string | string[],
|
|
query: string,
|
|
options?: UseAsyncSearchOptions
|
|
): string | undefined => {
|
|
if (Array.isArray(target)) {
|
|
const matchTarget = target.find((i) =>
|
|
matchQuery(normalize(i, options?.normalizeOptions), query, options?.matchOptions)
|
|
);
|
|
return matchTarget ? normalize(matchTarget, options?.normalizeOptions) : undefined;
|
|
}
|
|
|
|
const normalizedTargetStr = normalize(target, options?.normalizeOptions);
|
|
const matches = matchQuery(normalizedTargetStr, query, options?.matchOptions);
|
|
return matches ? normalizedTargetStr : undefined;
|
|
};
|
|
|
|
export const orderSearchItems = <TSearchItem extends object | string | number>(
|
|
query: string,
|
|
items: TSearchItem[],
|
|
getItemStr: SearchItemStrGetter<TSearchItem>,
|
|
options?: UseAsyncSearchOptions
|
|
): TSearchItem[] => {
|
|
const orderedItems: TSearchItem[] = Array.from(items);
|
|
|
|
// we will consider "_" as word boundary char.
|
|
// because in more use-cases it is used. (like: emojishortcode)
|
|
const boundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}`);
|
|
const perfectBoundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}(\\b|_)`);
|
|
|
|
orderedItems.sort((i1, i2) => {
|
|
const str1 = performMatch(getItemStr(i1, query), query, options);
|
|
const str2 = performMatch(getItemStr(i2, query), query, options);
|
|
|
|
if (str1 === undefined && str2 === undefined) return 0;
|
|
if (str1 === undefined) return 1;
|
|
if (str2 === undefined) return -1;
|
|
|
|
let points1 = 0;
|
|
let points2 = 0;
|
|
|
|
// short string should score more
|
|
const pointsToSmallStr = (points: number) => {
|
|
if (str1.length < str2.length) points1 += points;
|
|
else if (str2.length < str1.length) points2 += points;
|
|
};
|
|
pointsToSmallStr(1);
|
|
|
|
// closes query match should score more
|
|
const indexIn1 = str1.indexOf(query);
|
|
const indexIn2 = str2.indexOf(query);
|
|
if (indexIn1 < indexIn2) points1 += 2;
|
|
else if (indexIn2 < indexIn1) points2 += 2;
|
|
else pointsToSmallStr(2);
|
|
|
|
// query match word start on boundary should score more
|
|
const boundaryIn1 = str1.match(boundaryRegex);
|
|
const boundaryIn2 = str2.match(boundaryRegex);
|
|
if (boundaryIn1 && boundaryIn2) pointsToSmallStr(4);
|
|
else if (boundaryIn1) points1 += 4;
|
|
else if (boundaryIn2) points2 += 4;
|
|
|
|
// query match word start and end on boundary should score more
|
|
const perfectBoundaryIn1 = str1.match(perfectBoundaryRegex);
|
|
const perfectBoundaryIn2 = str2.match(perfectBoundaryRegex);
|
|
if (perfectBoundaryIn1 && perfectBoundaryIn2) pointsToSmallStr(8);
|
|
else if (perfectBoundaryIn1) points1 += 8;
|
|
else if (perfectBoundaryIn2) points2 += 8;
|
|
|
|
return points2 - points1;
|
|
});
|
|
|
|
return orderedItems;
|
|
};
|
|
|
|
export const useAsyncSearch = <TSearchItem extends object | string | number>(
|
|
list: TSearchItem[],
|
|
getItemStr: SearchItemStrGetter<TSearchItem>,
|
|
options?: UseAsyncSearchOptions
|
|
): [UseAsyncSearchResult<TSearchItem> | undefined, AsyncSearchHandler, SearchResetHandler] => {
|
|
const [result, setResult] = useState<UseAsyncSearchResult<TSearchItem>>();
|
|
|
|
const [searchCallback, terminateSearch] = useMemo(() => {
|
|
setResult(undefined);
|
|
|
|
const handleMatch: MatchHandler<TSearchItem> = (item, query) => {
|
|
const itemStr = getItemStr(item, query);
|
|
|
|
const strWithMatch = performMatch(itemStr, query, options);
|
|
return typeof strWithMatch === 'string';
|
|
};
|
|
|
|
const handleResult: ResultHandler<TSearchItem> = (results, query) =>
|
|
setResult({
|
|
query,
|
|
items: orderSearchItems(query, results, getItemStr, options),
|
|
});
|
|
|
|
return AsyncSearch(list, handleMatch, handleResult, options);
|
|
}, [list, options, getItemStr]);
|
|
|
|
const searchHandler: AsyncSearchHandler = useCallback(
|
|
(query) => {
|
|
const normalizedQuery = normalize(query, options?.normalizeOptions);
|
|
searchCallback(normalizedQuery);
|
|
},
|
|
[searchCallback, options?.normalizeOptions]
|
|
);
|
|
|
|
const resetHandler: SearchResetHandler = useCallback(() => {
|
|
terminateSearch();
|
|
setResult(undefined);
|
|
}, [terminateSearch]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
// terminate any ongoing search request on unmount.
|
|
terminateSearch();
|
|
},
|
|
[terminateSearch]
|
|
);
|
|
|
|
return [result, searchHandler, resetHandler];
|
|
};
|