Skip to content

Commit a6f04e9

Browse files
Directly bring code from highlight-words-core until [PR#21] is merged
Also enable highlighting dangerouslySetHtml in order to highlight in Markdown components. By default, it will not accept HTML input (or at least will escape it) [PR#21](bvaughn/highlight-words-core#21)
1 parent 5d1de64 commit a6f04e9

File tree

6 files changed

+364
-27
lines changed

6 files changed

+364
-27
lines changed

react/javascript/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,10 @@
2828
"@fortawesome/free-solid-svg-icons": "^5.13.0",
2929
"@fortawesome/react-fontawesome": "^0.1.9",
3030
"@types/elasticlunr": "^0.9.0",
31-
"@types/react-highlight-words": "^0.16.1",
3231
"color": "^3.1.2",
3332
"elasticlunr": "^0.9.5",
3433
"marked": "^0.8.2",
3534
"react-accessible-accordion": "^3.0.1",
36-
"react-highlight-words": "^0.16.0",
3735
"sanitize-html": "^1.23.0"
3836
},
3937
"peerDependencies": {
Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,71 @@
11
import React from 'react'
2-
import Highlighter from 'react-highlight-words'
2+
import { findAll, Chunk } from '../highlight-words'
33
import SearchQueryContext from '../../SearchQueryContext'
44
import elasticlunr from 'elasticlunr'
55

66
interface IProps {
77
text: string
8+
htmlText?: boolean
89
}
910

10-
const HighLight: React.FunctionComponent<IProps> = ({ text }) => {
11-
const searchQueryContext = React.useContext(SearchQueryContext)
12-
const queryWords = searchQueryContext.query
13-
? searchQueryContext.query.split(' ')
14-
: []
15-
const searchWords: string[] = []
16-
17-
for (const word of queryWords) {
11+
const allQueryWords = (queryWords: string[]): string[] => {
12+
return queryWords.reduce((allWords, word) => {
1813
const stem = elasticlunr.stemmer(word)
19-
searchWords.push(word)
14+
allWords.push(word)
2015

2116
if (stem !== word) {
22-
searchWords.push(stem)
17+
allWords.push(stem)
2318
}
24-
}
19+
return allWords
20+
}, [])
21+
}
2522

26-
elasticlunr.stemmer
23+
const highlightText = (text: string, chunks: Chunk[]): string => {
24+
return chunks.reduce((highlighted, chunk) => {
25+
const chunkText = text.slice(chunk.start, chunk.end)
26+
if (chunk.highlight) {
27+
return `${highlighted}<mark>${chunkText}</mark>`
28+
}
29+
return `${highlighted}${chunkText}`
30+
}, '')
31+
}
2732

28-
return (
29-
<Highlighter
30-
className="highlight"
31-
highlightClassName="YourHighlightClass"
32-
searchWords={searchWords}
33-
autoEscape={true}
34-
textToHighlight={text}
35-
/>
33+
const highlightElements = (text: string, chunks: Chunk[]): JSX.Element[] => {
34+
return chunks.reduce((elements: JSX.Element[], chunk) => {
35+
const chunkText = text.slice(chunk.start, chunk.end)
36+
if (chunk.highlight) {
37+
elements.push(<mark>{chunkText}</mark>)
38+
} else {
39+
elements.push(<span>{chunkText}</span>)
40+
}
41+
return elements
42+
}, [])
43+
}
44+
45+
const HighLight: React.FunctionComponent<IProps> = ({
46+
text,
47+
htmlText = false,
48+
}) => {
49+
const searchQueryContext = React.useContext(SearchQueryContext)
50+
const queryWords = allQueryWords(
51+
searchQueryContext.query ? searchQueryContext.query.split(' ') : []
3652
)
53+
const chunks = findAll({
54+
searchWords: queryWords,
55+
textToHighlight: text,
56+
htmlText,
57+
})
58+
59+
if (htmlText) {
60+
return (
61+
<span
62+
className="highlight"
63+
dangerouslySetInnerHTML={{ __html: highlightText(text, chunks) }}
64+
/>
65+
)
66+
}
67+
68+
return <span className="highlight">{highlightElements(text, chunks)}</span>
3769
}
3870

3971
export default HighLight
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react'
22
import marked from 'marked'
33
import sanitizeHtml from 'sanitize-html'
4+
import HighLight from '../app/HighLight'
45

56
interface IProps {
67
description: string
@@ -9,7 +10,8 @@ interface IProps {
910
const Description: React.FunctionComponent<IProps> = ({ description }) => {
1011
const html = marked(description)
1112
const sanitizedHtml = sanitizeHtml(html)
12-
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
13+
14+
return <HighLight text={sanitizedHtml} htmlText={true} />
1315
}
1416

1517
export default Description
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Taken from: https://www.npmjs.com/package/highlight-words-core
2+
// If the PR for handling HTML element is merged, we may use the lib directly
3+
4+
export type Chunk = {
5+
highlight: boolean
6+
start: number
7+
end: number
8+
}
9+
10+
type HTMLTagLocation = {
11+
start: number
12+
end: number
13+
}
14+
15+
/**
16+
* Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word.
17+
* @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean })
18+
*/
19+
export const findAll = ({
20+
autoEscape,
21+
caseSensitive = false,
22+
findChunks = defaultFindChunks,
23+
sanitize,
24+
searchWords,
25+
textToHighlight,
26+
htmlText,
27+
}: {
28+
autoEscape?: boolean
29+
caseSensitive?: boolean
30+
findChunks?: typeof defaultFindChunks
31+
sanitize?: typeof defaultSanitize
32+
searchWords: Array<string>
33+
textToHighlight: string
34+
htmlText?: boolean
35+
}): Array<Chunk> =>
36+
fillInChunks({
37+
chunksToHighlight: combineChunks({
38+
chunks: findChunks({
39+
autoEscape,
40+
caseSensitive,
41+
sanitize,
42+
searchWords,
43+
textToHighlight,
44+
htmlText,
45+
}),
46+
}),
47+
totalLength: textToHighlight ? textToHighlight.length : 0,
48+
})
49+
50+
/**
51+
* Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks.
52+
* @return {start:number, end:number}[]
53+
*/
54+
export const combineChunks = ({
55+
chunks,
56+
}: {
57+
chunks: Array<Chunk>
58+
}): Array<Chunk> => {
59+
chunks = chunks
60+
.sort((first, second) => first.start - second.start)
61+
.reduce((processedChunks, nextChunk) => {
62+
// First chunk just goes straight in the array...
63+
if (processedChunks.length === 0) {
64+
return [nextChunk]
65+
} else {
66+
// ... subsequent chunks get checked to see if they overlap...
67+
const prevChunk = processedChunks.pop()
68+
if (nextChunk.start <= prevChunk.end) {
69+
// It may be the case that prevChunk completely surrounds nextChunk, so take the
70+
// largest of the end indeces.
71+
const endIndex = Math.max(prevChunk.end, nextChunk.end)
72+
processedChunks.push({
73+
highlight: false,
74+
start: prevChunk.start,
75+
end: endIndex,
76+
})
77+
} else {
78+
processedChunks.push(prevChunk, nextChunk)
79+
}
80+
return processedChunks
81+
}
82+
}, [])
83+
84+
return chunks
85+
}
86+
87+
/**
88+
* Examine text for any matches.
89+
* If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}).
90+
* @return {start:number, end:number}[]
91+
*/
92+
const defaultFindChunks = ({
93+
autoEscape,
94+
caseSensitive,
95+
sanitize = defaultSanitize,
96+
searchWords,
97+
textToHighlight,
98+
htmlText,
99+
}: {
100+
autoEscape?: boolean
101+
caseSensitive?: boolean
102+
sanitize?: typeof defaultSanitize
103+
searchWords: Array<string>
104+
textToHighlight: string
105+
htmlText?: boolean
106+
}): Array<Chunk> => {
107+
textToHighlight = sanitize(textToHighlight)
108+
const htmlTagLocation = htmlText ? findHtmlTagLocations(textToHighlight) : []
109+
110+
return searchWords
111+
.filter((searchWord) => searchWord) // Remove empty words
112+
.reduce((chunks, searchWord) => {
113+
searchWord = sanitize(searchWord)
114+
115+
if (autoEscape) {
116+
searchWord = escapeRegExpFn(searchWord)
117+
}
118+
119+
const regex = new RegExp(searchWord, caseSensitive ? 'g' : 'gi')
120+
121+
let match
122+
while ((match = regex.exec(textToHighlight))) {
123+
const start = match.index
124+
const end = regex.lastIndex
125+
// We do not return zero-length matches
126+
if (end > start) {
127+
if (htmlText) {
128+
if (!isInTag(start, end, htmlTagLocation)) {
129+
chunks.push({ highlight: false, start, end })
130+
}
131+
} else {
132+
chunks.push({ highlight: false, start, end })
133+
}
134+
}
135+
136+
// Prevent browsers like Firefox from getting stuck in an infinite loop
137+
// See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/
138+
if (match.index === regex.lastIndex) {
139+
regex.lastIndex++
140+
}
141+
}
142+
143+
return chunks
144+
}, [])
145+
}
146+
// Allow the findChunks to be overridden in findAll,
147+
// but for backwards compatibility we export as the old name
148+
export { defaultFindChunks as findChunks }
149+
150+
/**
151+
* Given a set of chunks to highlight, create an additional set of chunks
152+
* to represent the bits of text between the highlighted text.
153+
* @param chunksToHighlight {start:number, end:number}[]
154+
* @param totalLength number
155+
* @return {start:number, end:number, highlight:boolean}[]
156+
*/
157+
export const fillInChunks = ({
158+
chunksToHighlight,
159+
totalLength,
160+
}: {
161+
chunksToHighlight: Array<Chunk>
162+
totalLength: number
163+
}): Array<Chunk> => {
164+
const allChunks: Chunk[] = []
165+
const append = (start: number, end: number, highlight: boolean) => {
166+
if (end - start > 0) {
167+
allChunks.push({
168+
start,
169+
end,
170+
highlight,
171+
})
172+
}
173+
}
174+
175+
if (chunksToHighlight.length === 0) {
176+
append(0, totalLength, false)
177+
} else {
178+
let lastIndex = 0
179+
chunksToHighlight.forEach((chunk) => {
180+
append(lastIndex, chunk.start, false)
181+
append(chunk.start, chunk.end, true)
182+
lastIndex = chunk.end
183+
})
184+
append(lastIndex, totalLength, false)
185+
}
186+
return allChunks
187+
}
188+
189+
function defaultSanitize(string: string): string {
190+
return string
191+
}
192+
193+
function escapeRegExpFn(string: string): string {
194+
return string.replace(/[\-\[\]/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&') // eslint-disable-line
195+
}
196+
197+
function findHtmlTagLocations(text: string): HTMLTagLocation[] {
198+
// Stolen from here: https://haacked.com/archive/2004/10/25/usingregularexpressionstomatchhtml.aspx/
199+
const tagExp = new RegExp(
200+
/<\/?\w+((\s+\w+(\s*=\s*(?:".*?"|'.*?'|[\^'">\s]+))?)+\s*|\s*)\/?>/,
201+
'g'
202+
)
203+
const locations: HTMLTagLocation[] = []
204+
205+
let match
206+
while ((match = tagExp.exec(text))) {
207+
locations.push({ start: match.index, end: tagExp.lastIndex })
208+
209+
// Prevent browsers like Firefox from getting stuck in an infinite loop
210+
// See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/
211+
if (match.index === tagExp.lastIndex) {
212+
tagExp.lastIndex++
213+
}
214+
}
215+
216+
return locations
217+
}
218+
219+
function isInTag(
220+
start: number,
221+
end: number,
222+
htmlTagLocation: HTMLTagLocation[]
223+
): boolean {
224+
for (const location of htmlTagLocation) {
225+
if (start > location.start && end < location.end) {
226+
return true
227+
}
228+
}
229+
return false
230+
}

0 commit comments

Comments
 (0)