getHighlight.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. // Modified version based on 'highlight-words-core'
  2. import { isString } from 'lodash-es';
  3. const escapeRegExpFn = (string: string) => string.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
  4. interface ChunkQuery {
  5. autoEscape?: boolean;
  6. caseSensitive?: boolean;
  7. searchWords: string[];
  8. sourceString: string;
  9. }
  10. interface Chunk {
  11. start: number;
  12. end: number;
  13. highlight: boolean;
  14. }
  15. /**
  16. * Examine text for any matches.
  17. * If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}).
  18. * @return { start:number, end:number }[]
  19. */
  20. const findChunks = ({
  21. autoEscape,
  22. caseSensitive,
  23. searchWords,
  24. sourceString
  25. }: ChunkQuery): Chunk[] => (
  26. searchWords
  27. .filter(searchWord => searchWord) // Remove empty words
  28. .reduce((chunks, searchWord) => {
  29. if (autoEscape) {
  30. searchWord = escapeRegExpFn(searchWord);
  31. }
  32. const regex = new RegExp(searchWord, caseSensitive ? 'g' : 'gi');
  33. let match;
  34. while ((match = regex.exec(sourceString))) {
  35. const start = match.index;
  36. const end = regex.lastIndex;
  37. // We do not return zero-length matches
  38. if (end > start) {
  39. chunks.push({ highlight: false, start, end });
  40. }
  41. // Prevent browsers like Firefox from getting stuck in an infinite loop
  42. // See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/
  43. if (match.index === regex.lastIndex) {
  44. regex.lastIndex++;
  45. }
  46. }
  47. return chunks;
  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. const combineChunks = ({ chunks }: { chunks: Chunk[] }) => {
  55. chunks = chunks
  56. .sort((first, second) => first.start - second.start)
  57. .reduce((processedChunks, nextChunk) => {
  58. // First chunk just goes straight in the array...
  59. if (processedChunks.length === 0) {
  60. return [nextChunk];
  61. } else {
  62. // ... subsequent chunks get checked to see if they overlap...
  63. const prevChunk = processedChunks.pop();
  64. if (nextChunk.start <= prevChunk.end) {
  65. // It may be the case that prevChunk completely surrounds nextChunk, so take the
  66. // largest of the end indeces.
  67. const endIndex = Math.max(prevChunk.end, nextChunk.end);
  68. processedChunks.push({
  69. highlight: false,
  70. start: prevChunk.start,
  71. end: endIndex
  72. });
  73. } else {
  74. processedChunks.push(prevChunk, nextChunk);
  75. }
  76. return processedChunks;
  77. }
  78. }, []);
  79. return chunks;
  80. };
  81. /**
  82. * Given a set of chunks to highlight, create an additional set of chunks
  83. * to represent the bits of text between the highlighted text.
  84. * @param chunksToHighlight {start:number, end:number}[]
  85. * @param totalLength number
  86. * @return {start:number, end:number, highlight:boolean}[]
  87. */
  88. const fillInChunks = ({ chunksToHighlight, totalLength }: { chunksToHighlight: Chunk[]; totalLength: number }) => {
  89. const allChunks: Chunk[] = [];
  90. const append = (start: number, end: number, highlight: boolean) => {
  91. if (end - start > 0) {
  92. allChunks.push({
  93. start,
  94. end,
  95. highlight
  96. });
  97. }
  98. };
  99. if (chunksToHighlight.length === 0) {
  100. append(0, totalLength, false);
  101. } else {
  102. let lastIndex = 0;
  103. chunksToHighlight.forEach(chunk => {
  104. append(lastIndex, chunk.start, false);
  105. append(chunk.start, chunk.end, true);
  106. lastIndex = chunk.end;
  107. });
  108. append(lastIndex, totalLength, false);
  109. }
  110. return allChunks;
  111. };
  112. /**
  113. * Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word.
  114. *
  115. findAll ['z'], 'aaazaaazaaa'
  116. result #=> [
  117. { start: 0, end: 3, highlight: false }
  118. { start: 3, end: 4, highlight: true }
  119. { start: 4, end: 7, highlight: false }
  120. { start: 7, end: 8, highlight: true }
  121. { start: 8, end: 11, highlight: false }
  122. ]
  123. findAll ['do', 'dollar'], 'aaa do dollar aaa'
  124. #=> chunks: [
  125. { start: 4, end: 6 },
  126. { start: 7, end: 9 },
  127. { start: 7, end: 13 },
  128. ]
  129. #=> chunksToHight: [
  130. { start: 4, end: 6 },
  131. { start: 7, end: 13 },
  132. ]
  133. #=> result: [
  134. { start: 0, end: 4, highlight: false },
  135. { start: 4, end: 6, highlight: true },
  136. { start: 6, end: 7, highlight: false },
  137. { start: 7, end: 13, highlight: true },
  138. { start: 13, end: 17, highlight: false },
  139. ]
  140. * @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean })
  141. */
  142. const findAll = ({
  143. autoEscape,
  144. caseSensitive = false,
  145. searchWords,
  146. sourceString
  147. }: ChunkQuery) => {
  148. if (isString(searchWords)) {
  149. searchWords = [searchWords];
  150. }
  151. const chunks = findChunks({
  152. autoEscape,
  153. caseSensitive,
  154. searchWords,
  155. sourceString
  156. });
  157. const chunksToHighlight = combineChunks({ chunks });
  158. const result = fillInChunks({
  159. chunksToHighlight,
  160. totalLength: sourceString ? sourceString.length : 0
  161. });
  162. return result;
  163. };
  164. export { findAll };