/** * This component is passed some search strings and a string. It will go through the string and "highlight" any words that matches the array of searches passed in. */ import React from "react"; /** * Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks. * @return {start:number, end:number}[] */ export const combineChunks = ({ chunks }: any): any => { chunks = chunks .sort((first: any, second: any) => first.start - second.start) .reduce((processedChunks: any, nextChunk: any) => { // First chunk just goes straight in the array... if (processedChunks.length === 0) { return [nextChunk]; } // ... subsequent chunks get checked to see if they overlap... const prevChunk = processedChunks.pop(); if (nextChunk.start <= prevChunk.end) { // It may be the case that prevChunk completely surrounds nextChunk, so take the // largest of the end indexes. const endIndex = Math.max(prevChunk.end, nextChunk.end); processedChunks.push({ start: prevChunk.start, end: endIndex, rawSearch: [nextChunk.rawSearch, prevChunk.rawSearch], searchWord: [nextChunk.searchWord, prevChunk.searchWord], }); } else { processedChunks.push(prevChunk, nextChunk); } return processedChunks; }, []); return chunks; }; function identity(value: any) { return value; } function escapeRegExpFn(str: string) { return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); } /** * Examine text for any matches. * If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}). * @return {start:number, end:number}[] */ const defaultFindChunks = ({ autoEscape = true, caseSensitive = false, sanitize = identity, searchWords, textToHighlight, }: any) => { textToHighlight = sanitize(textToHighlight); return searchWords .filter((searchWord: any) => searchWord) // Remove empty words .reduce((chunks: any, searchWord: any) => { if (Array.isArray(searchWord)) { searchWord = searchWord.join(" "); } const rawSearch = searchWord; searchWord = sanitize(searchWord); if (autoEscape) { searchWord = escapeRegExpFn(searchWord); } searchWord = searchWord.replace(/\s/g, "|"); const regex = new RegExp(searchWord, caseSensitive ? "g" : "gi"); let match: any; while ((match = regex.exec(textToHighlight))) { const start = match.index; const end = regex.lastIndex; // We do not return zero-length matches if (end > start) { chunks.push({ start, end, searchWord, rawSearch, }); } // Prevent browsers like Firefox from getting stuck in an infinite loop // See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/ if (match.index == regex.lastIndex) { regex.lastIndex++; } } return chunks; }, []); }; // Allow the findChunks to be overridden in findAll, // but for backwards compatibility we export as the old name export { defaultFindChunks as findChunks }; /** * Given a set of chunks to highlight, create an additional set of chunks * to represent the bits of text between the highlighted text. * @param chunksToHighlight {start:number, end:number}[] * @param totalLength number * @return {start:number, end:number, highlight:boolean}[] */ export const fillInChunks = ({ chunksToHighlight, totalLength }: any): any => { const allChunks = [] as any[]; const append = (start: any, end: any, highlight: any, searchWord?: any, rawSearch?: any) => { if (end - start > 0) { allChunks.push({ start, end, highlight, searchWord, rawSearch, }); } }; if (chunksToHighlight.length === 0) { append(0, totalLength, false); } else { let lastIndex = 0; chunksToHighlight.forEach((chunk) => { append(lastIndex, chunk.start, false, chunk.searchWord, chunk.rawSearch); append(chunk.start, chunk.end, true, chunk.searchWord, chunk.rawSearch); lastIndex = chunk.end; }); append(lastIndex, totalLength, false); } return allChunks; }; /** * Creates an array of chunk objects representing both highlight-able and non highlight-able pieces of text that match each search word. * @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean }) */ export const findAll = ({ autoEscape = true, caseSensitive = false, findChunks = defaultFindChunks, sanitize = (str: any) => str, searchWords, textToHighlight, }) => fillInChunks({ chunksToHighlight: combineChunks({ chunks: findChunks({ autoEscape, caseSensitive, sanitize, searchWords, textToHighlight, }), }), totalLength: textToHighlight ? textToHighlight.length : 0, }); export default class Highlighter extends React.PureComponent<{ str: string; searchBoxText: string; filterStrings?: string[]; ind?: number; className?: string; onMatchFound?: Function; }> { constructor(props: any) { super(props); } render() { let { searchBoxText, filterStrings = [], str } = this.props; const logText = str?.toString() ?? "undefined"; if (!filterStrings) filterStrings = []; const keywords = [searchBoxText].concat(filterStrings).filter((kw) => kw !== ""); const kwMap = { searchBox: searchBoxText, }; filterStrings = filterStrings.map((_str) => { if (Array.isArray(_str)) { // we split them on input so that we can search through each individual word (instead of a sentence) for matches. So here, we join them back for mapping. return _str.join(" "); } return _str; }); Object.keys(filterStrings).forEach((filter) => { kwMap[filter] = filterStrings[filter]; }); const chunks = findAll({ autoEscape: true, caseSensitive: false, searchWords: keywords, textToHighlight: logText, }); return ( {chunks.map((chunk: any, index: any) => { const text = logText.substr(chunk.start, chunk.end - chunk.start); let highlightClassNames = ""; // If a chunk matches multiple filters, we want multiple classes added on. We force all chunks to come in with an array of searchWords to make the forEach (below) easier to understand). if (!Array.isArray(chunk.rawSearch)) { chunk.rawSearch = [chunk.rawSearch]; } if (chunk.highlight) { Object.keys(kwMap).forEach((kw) => { if (chunk.rawSearch.includes(kwMap[kw])) { highlightClassNames = `${highlightClassNames} match-${kw}`; } }); } if (highlightClassNames.trim().includes(" ")) { highlightClassNames = "multiple-matches"; } return ( {text} ); })} ); } }