-
Notifications
You must be signed in to change notification settings - Fork 721
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: highlight searchWords support object props (#2600)
* feat: highlight searchWords support object props * docs: update demo
- Loading branch information
Showing
11 changed files
with
374 additions
and
300 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,211 @@ | ||
// Modified version based on 'highlight-words-core' | ||
import { isString } from 'lodash'; | ||
import BaseFoundation, { DefaultAdapter } from '../base/foundation'; | ||
|
||
interface HighlightAdapter extends Partial<DefaultAdapter> {} | ||
|
||
interface ChunkQuery { | ||
autoEscape?: boolean; | ||
caseSensitive?: boolean; | ||
searchWords: SearchWords; | ||
sourceString: string | ||
} | ||
export interface Chunk { | ||
start: number; | ||
end: number; | ||
highlight: boolean; | ||
className: string; | ||
style: Record<string, string> | ||
} | ||
|
||
export interface ComplexSearchWord { | ||
text: string; | ||
className?: string; | ||
style?: Record<string, string> | ||
} | ||
|
||
export type SearchWord = string | ComplexSearchWord | undefined; | ||
export type SearchWords = SearchWord[]; | ||
|
||
const escapeRegExpFn = (string: string) => string.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); | ||
|
||
export default class HighlightFoundation extends BaseFoundation<HighlightAdapter> { | ||
|
||
constructor(adapter?: HighlightAdapter) { | ||
super({ | ||
...adapter, | ||
}); | ||
} | ||
|
||
/** | ||
* Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word. | ||
* | ||
findAll ['z'], 'aaazaaazaaa' | ||
result #=> [ | ||
{ start: 0, end: 3, highlight: false } | ||
{ start: 3, end: 4, highlight: true } | ||
{ start: 4, end: 7, highlight: false } | ||
{ start: 7, end: 8, highlight: true } | ||
{ start: 8, end: 11, highlight: false } | ||
] | ||
findAll ['do', 'dollar'], 'aaa do dollar aaa' | ||
#=> chunks: [ | ||
{ start: 4, end: 6 }, | ||
{ start: 7, end: 9 }, | ||
{ start: 7, end: 13 }, | ||
] | ||
#=> chunksToHight: [ | ||
{ start: 4, end: 6 }, | ||
{ start: 7, end: 13 }, | ||
] | ||
#=> result: [ | ||
{ start: 0, end: 4, highlight: false }, | ||
{ start: 4, end: 6, highlight: true }, | ||
{ start: 6, end: 7, highlight: false }, | ||
{ start: 7, end: 13, highlight: true }, | ||
{ start: 13, end: 17, highlight: false }, | ||
] | ||
* @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean }) | ||
*/ | ||
findAll = ({ | ||
autoEscape = true, | ||
caseSensitive = false, | ||
searchWords, | ||
sourceString | ||
}: ChunkQuery) => { | ||
if (isString(searchWords)) { | ||
searchWords = [searchWords]; | ||
} | ||
|
||
const chunks = this.findChunks({ | ||
autoEscape, | ||
caseSensitive, | ||
searchWords, | ||
sourceString | ||
}); | ||
const chunksToHighlight = this.combineChunks({ chunks }); | ||
const result = this.fillInChunks({ | ||
chunksToHighlight, | ||
totalLength: sourceString ? sourceString.length : 0 | ||
}); | ||
return result; | ||
}; | ||
|
||
/** | ||
* 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 }[] | ||
*/ | ||
findChunks = ({ | ||
autoEscape, | ||
caseSensitive, | ||
searchWords, | ||
sourceString | ||
}: ChunkQuery): Chunk[] => ( | ||
searchWords | ||
.map(searchWord => typeof searchWord === 'string' ? { text: searchWord } : searchWord) | ||
.filter(searchWord => searchWord.text) // Remove empty words | ||
.reduce((chunks, searchWord) => { | ||
let searchText = searchWord.text; | ||
if (autoEscape) { | ||
searchText = escapeRegExpFn(searchText); | ||
} | ||
const regex = new RegExp(searchText, caseSensitive ? 'g' : 'gi'); | ||
|
||
let match; | ||
while ((match = regex.exec(sourceString))) { | ||
const start = match.index; | ||
const end = regex.lastIndex; | ||
if (end > start) { | ||
chunks.push({ | ||
highlight: true, | ||
start, | ||
end, | ||
className: searchWord.className, | ||
style: searchWord.style | ||
}); | ||
} | ||
if (match.index === regex.lastIndex) { | ||
regex.lastIndex++; | ||
} | ||
} | ||
return chunks; | ||
}, []) | ||
); | ||
|
||
/** | ||
* Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks. | ||
* @return {start:number, end:number}[] | ||
*/ | ||
combineChunks = ({ chunks }: { chunks: Chunk[] }): Chunk[] => { | ||
return chunks | ||
.sort((first, second) => first.start - second.start) | ||
.reduce((processedChunks, nextChunk) => { | ||
// First chunk just goes straight in the array... | ||
if (processedChunks.length === 0) { | ||
return [nextChunk]; | ||
} else { | ||
// ... 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 indeces. | ||
const endIndex = Math.max(prevChunk.end, nextChunk.end); | ||
processedChunks.push({ | ||
highlight: true, | ||
start: prevChunk.start, | ||
end: endIndex, | ||
className: prevChunk.className || nextChunk.className, | ||
style: { ...prevChunk.style, ...nextChunk.style } | ||
}); | ||
} else { | ||
processedChunks.push(prevChunk, nextChunk); | ||
} | ||
return processedChunks; | ||
} | ||
}, []); | ||
}; | ||
|
||
/** | ||
* 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}[] | ||
*/ | ||
fillInChunks = ({ chunksToHighlight, totalLength }: { chunksToHighlight: Chunk[]; totalLength: number }): Chunk[] => { | ||
const allChunks: Chunk[] = []; | ||
const append = (start: number, end: number, highlight: boolean, className?: string, style?: Record<string, string>) => { | ||
if (end - start > 0) { | ||
allChunks.push({ | ||
start, | ||
end, | ||
highlight, | ||
className, | ||
style | ||
}); | ||
} | ||
}; | ||
|
||
if (chunksToHighlight.length === 0) { | ||
append(0, totalLength, false); | ||
} else { | ||
let lastIndex = 0; | ||
chunksToHighlight.forEach(chunk => { | ||
append(lastIndex, chunk.start, false); | ||
append(chunk.start, chunk.end, true, chunk.className, chunk.style); | ||
lastIndex = chunk.end; | ||
}); | ||
append(lastIndex, totalLength, false); | ||
} | ||
return allChunks; | ||
}; | ||
|
||
} | ||
|
||
|
||
|
||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.