UNPKG

diagram-js

Version:

A toolbox for displaying and modifying diagrams on the web

399 lines (332 loc) 8.04 kB
import { isArray } from 'min-dash'; /** * @typedef { { * index: number; * value: string, * match?: boolean; * start?: boolean; * end?: boolean; * wordStart?: boolean; * wordEnd?: boolean; * all?: boolean; * } } BaseToken * * @typedef { BaseToken | BaseToken[] } Token */ /** * @template R * * @typedef { { * item: R, * tokens: Record<string, Token[]> * } } SearchResult */ /** * @typedef {Record<string, string | string[]>} SearchItem */ /** * Search items by query. * * @template { SearchItem } T * * @param { T[] } items elements to search in * @param { string } pattern pattern to search for * @param { { * keys: string[]; * } } options * * @returns { SearchResult<T>[] } */ export default function search(items, pattern, options) { const { keys } = options; pattern = pattern.trim().toLowerCase(); if (!pattern) { throw new Error('<pattern> must not be empty'); } const words = pattern.trim().toLowerCase().split(/\s+/); return items.flatMap((/** @type {T} */ item) => { const tokens = matchItem(item, words, keys); if (!tokens) { return []; } return { item, tokens }; }).sort(createResultSorter(keys)); } /** * Match an item and return tokens in case of a match. * * @param { SearchItem } item element to be matched * @param { string[] } words words from search pattern to find * @param { string[] } keys keys to search in the item * * @returns { Record<string, Token[]> | null } */ function matchItem(item, words, keys) { const { matchedWords, tokens } = keys.reduce((result, key) => { const itemValue = item[ key ]; const { tokens, matchedWords } = isArray(itemValue) ? ( itemValue.reduce( (result, itemString) => { const { tokens, matchedWords } = matchString(itemString, words); return { tokens: [ ...result.tokens, tokens ], matchedWords: { ...result.matchedWords, ...matchedWords } }; }, { matchedWords: /** @type { Record<string, boolean> } */ ({}), tokens: /** @type { Token[] } */ ([]) } ) ) : ( matchString(itemValue, words) ); return { tokens: { ...result.tokens, [ key ]: tokens, }, matchedWords: { ...result.matchedWords, ...matchedWords } }; }, { matchedWords: {}, tokens: {} }); // only return result if every word got matched if (Object.keys(matchedWords).length !== words.length) { return null; } return tokens; } /** * Creates a compare function that can be used in Array.sort() based on a custom scoring function * * @template {SearchItem} T * * @param { string[] } keys * * @returns { (resultA: SearchResult<T>, resultB: SearchResult<T>) => number } */ function createResultSorter(keys) { /** * @param { SearchResult<T> } resultA * @param { SearchResult<T> } resultB */ return (resultA, resultB) => { let comparison = 0; // used to assign some priority to earlier keys let modifier = 1; for (const key of keys) { const tokenComparison = compareTokens( resultA.tokens[key], resultB.tokens[key] ); if (tokenComparison !== 0) { comparison += tokenComparison * modifier; modifier *= DECAY_MODIFIER; continue; } const stringComparison = compareStrings( resultA.item[ key ], resultB.item[ key ] ); if (stringComparison !== 0) { comparison += stringComparison * modifier; modifier *= DECAY_MODIFIER; continue; } } return comparison; }; } /** * Compares two token arrays. * * @param { Token[] } tokensA * @param { Token[] } tokensB * * @returns { number } */ function compareTokens(tokensA, tokensB) { return scoreTokens(tokensB) - scoreTokens(tokensA); } const DECAY_MODIFIER = 0.5; const TOKEN_MATCH_SCORES = { FULL: 131.9, START_FULL_WORD: 8.0, START_WORD_PART: 7.87, WORD_START: 2.19, WORD_PART: 1, NO_MATCH: -0.07 }; /** * @param { Token[] } tokens * @returns { number } */ function scoreTokens(tokens) { const baseScore = tokens.reduce((sum, token) => sum + scoreToken(token), 0); const flatTokens = tokens.flat(); const totalLength = flatTokens.reduce((sum, token) => sum + token.value.length, 0); const matchedLength = flatTokens.reduce((sum, token) => sum + (token.match ? token.value.length : 0), 0); const density = totalLength ? matchedLength / totalLength : 0; const score = baseScore * (1 + density); return score; } /** * Score a token based on its characteristics * and the length of the matched content. * * @param { Token } token * * @returns { number } */ function scoreToken(token) { if (isArray(token)) { return Math.max(...token.map(scoreToken)); } const modifier = Math.log(token.value.length); if (!token.match) { return TOKEN_MATCH_SCORES.NO_MATCH * modifier; } return ( token.start ? ( token.end ? TOKEN_MATCH_SCORES.FULL : token.wordEnd ? TOKEN_MATCH_SCORES.START_FULL_WORD : TOKEN_MATCH_SCORES.START_WORD_PART ) : ( token.wordStart ? TOKEN_MATCH_SCORES.WORD_START : TOKEN_MATCH_SCORES.WORD_PART ) ) * modifier; } /** * @param { string|string[] } [str=''] * * @return { string } */ function stringJoin(str = '') { return isArray(str) ? str.join(', ') : str; } /** * Compares two strings. also supports string arrays, which will be joined * * @param { string|string[] } [a] * @param { string|string[] } [b] * * @returns { number } */ function compareStrings(a, b) { return stringJoin(a).localeCompare(stringJoin(b)); } /** * Match a given string against a set of words, * and return the result. * * @param { string } string * @param { string[] } words * * @return { { * tokens: BaseToken[], * matchedWords: Record<string, boolean> * } } */ function matchString(string, words) { if (!string) { return { tokens: [], matchedWords: {} }; } /** * @type { BaseToken[] } */ const tokens = []; /** * @type { Record<string, boolean> } */ const matchedWords = {}; const wordsEscaped = words.map(escapeRegexp); const regexpString = [ `(?<all>${wordsEscaped.join('\\s+')})`, ...wordsEscaped ].join('|'); const regexp = new RegExp(regexpString, 'ig'); let match; let lastIndex = 0; while ((match = regexp.exec(string))) { const [ value ] = match; const startIndex = match.index; const endIndex = match.index + value.length; const start = startIndex === 0; const end = endIndex === string.length; const all = !!match.groups?.all; const wordStart = start || /\s/.test(string.charAt(startIndex - 1)); const wordEnd = end || /\s/.test(string.charAt(endIndex)); if (match.index > lastIndex) { // add previous token (NO match) tokens.push({ value: string.slice(lastIndex, match.index), index: lastIndex }); } // add current token (match) tokens.push({ value, index: match.index, match: true, wordStart, wordEnd, start, end, all }); const newMatchedWords = all ? words : [ value ]; for (const word of newMatchedWords) { matchedWords[word.toLowerCase()] = true; } lastIndex = match.index + value.length; } // add after token (NO match) if (lastIndex < string.length) { tokens.push({ value: string.slice(lastIndex), index: lastIndex }); } return { tokens, matchedWords }; } /** * Escape string to not contain illegal regexp chars. * * @param { string } string * * @return { string } */ function escapeRegexp(string) { return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); }