foundation.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. // Modified version based on 'highlight-words-core'
  2. import { isString } from 'lodash';
  3. import BaseFoundation, { DefaultAdapter } from '../base/foundation';
  4. interface HighlightAdapter extends Partial<DefaultAdapter> {}
  5. interface ChunkQuery {
  6. autoEscape?: boolean;
  7. caseSensitive?: boolean;
  8. searchWords: SearchWords;
  9. sourceString: string
  10. }
  11. export interface Chunk {
  12. start: number;
  13. end: number;
  14. highlight: boolean;
  15. className: string;
  16. style: Record<string, string>
  17. }
  18. export interface ComplexSearchWord {
  19. text: string;
  20. className?: string;
  21. style?: Record<string, string>
  22. }
  23. export type SearchWord = string | ComplexSearchWord | undefined;
  24. export type SearchWords = SearchWord[];
  25. const escapeRegExpFn = (string: string) => string.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
  26. export default class HighlightFoundation extends BaseFoundation<HighlightAdapter> {
  27. constructor(adapter?: HighlightAdapter) {
  28. super({
  29. ...adapter,
  30. });
  31. }
  32. /**
  33. * Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word.
  34. *
  35. findAll ['z'], 'aaazaaazaaa'
  36. result #=> [
  37. { start: 0, end: 3, highlight: false }
  38. { start: 3, end: 4, highlight: true }
  39. { start: 4, end: 7, highlight: false }
  40. { start: 7, end: 8, highlight: true }
  41. { start: 8, end: 11, highlight: false }
  42. ]
  43. findAll ['do', 'dollar'], 'aaa do dollar aaa'
  44. #=> chunks: [
  45. { start: 4, end: 6 },
  46. { start: 7, end: 9 },
  47. { start: 7, end: 13 },
  48. ]
  49. #=> chunksToHight: [
  50. { start: 4, end: 6 },
  51. { start: 7, end: 13 },
  52. ]
  53. #=> result: [
  54. { start: 0, end: 4, highlight: false },
  55. { start: 4, end: 6, highlight: true },
  56. { start: 6, end: 7, highlight: false },
  57. { start: 7, end: 13, highlight: true },
  58. { start: 13, end: 17, highlight: false },
  59. ]
  60. * @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean })
  61. */
  62. findAll = ({
  63. autoEscape = true,
  64. caseSensitive = false,
  65. searchWords,
  66. sourceString
  67. }: ChunkQuery) => {
  68. if (isString(searchWords)) {
  69. searchWords = [searchWords];
  70. }
  71. const chunks = this.findChunks({
  72. autoEscape,
  73. caseSensitive,
  74. searchWords,
  75. sourceString
  76. });
  77. const chunksToHighlight = this.combineChunks({ chunks });
  78. const result = this.fillInChunks({
  79. chunksToHighlight,
  80. totalLength: sourceString ? sourceString.length : 0
  81. });
  82. return result;
  83. };
  84. /**
  85. * Examine text for any matches.
  86. * If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}).
  87. * @return { start:number, end:number }[]
  88. */
  89. findChunks = ({
  90. autoEscape,
  91. caseSensitive,
  92. searchWords,
  93. sourceString
  94. }: ChunkQuery): Chunk[] => (
  95. searchWords
  96. .map(searchWord => typeof searchWord === 'string' ? { text: searchWord } : searchWord)
  97. .filter(searchWord => searchWord.text) // Remove empty words
  98. .reduce((chunks, searchWord) => {
  99. let searchText = searchWord.text;
  100. if (autoEscape) {
  101. searchText = escapeRegExpFn(searchText);
  102. }
  103. const regex = new RegExp(searchText, caseSensitive ? 'g' : 'gi');
  104. let match;
  105. while ((match = regex.exec(sourceString))) {
  106. const start = match.index;
  107. const end = regex.lastIndex;
  108. if (end > start) {
  109. chunks.push({
  110. highlight: true,
  111. start,
  112. end,
  113. className: searchWord.className,
  114. style: searchWord.style
  115. });
  116. }
  117. if (match.index === regex.lastIndex) {
  118. regex.lastIndex++;
  119. }
  120. }
  121. return chunks;
  122. }, [])
  123. );
  124. /**
  125. * Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks.
  126. * @return {start:number, end:number}[]
  127. */
  128. combineChunks = ({ chunks }: { chunks: Chunk[] }): Chunk[] => {
  129. return chunks
  130. .sort((first, second) => first.start - second.start)
  131. .reduce((processedChunks, nextChunk) => {
  132. // First chunk just goes straight in the array...
  133. if (processedChunks.length === 0) {
  134. return [nextChunk];
  135. } else {
  136. // ... subsequent chunks get checked to see if they overlap...
  137. const prevChunk = processedChunks.pop();
  138. if (nextChunk.start <= prevChunk.end) {
  139. // It may be the case that prevChunk completely surrounds nextChunk, so take the
  140. // largest of the end indeces.
  141. const endIndex = Math.max(prevChunk.end, nextChunk.end);
  142. processedChunks.push({
  143. highlight: true,
  144. start: prevChunk.start,
  145. end: endIndex,
  146. className: prevChunk.className || nextChunk.className,
  147. style: { ...prevChunk.style, ...nextChunk.style }
  148. });
  149. } else {
  150. processedChunks.push(prevChunk, nextChunk);
  151. }
  152. return processedChunks;
  153. }
  154. }, []);
  155. };
  156. /**
  157. * Given a set of chunks to highlight, create an additional set of chunks
  158. * to represent the bits of text between the highlighted text.
  159. * @param chunksToHighlight {start:number, end:number}[]
  160. * @param totalLength number
  161. * @return {start:number, end:number, highlight:boolean}[]
  162. */
  163. fillInChunks = ({ chunksToHighlight, totalLength }: { chunksToHighlight: Chunk[]; totalLength: number }): Chunk[] => {
  164. const allChunks: Chunk[] = [];
  165. const append = (start: number, end: number, highlight: boolean, className?: string, style?: Record<string, string>) => {
  166. if (end - start > 0) {
  167. allChunks.push({
  168. start,
  169. end,
  170. highlight,
  171. className,
  172. style
  173. });
  174. }
  175. };
  176. if (chunksToHighlight.length === 0) {
  177. append(0, totalLength, false);
  178. } else {
  179. let lastIndex = 0;
  180. chunksToHighlight.forEach(chunk => {
  181. append(lastIndex, chunk.start, false);
  182. append(chunk.start, chunk.end, true, chunk.className, chunk.style);
  183. lastIndex = chunk.end;
  184. });
  185. append(lastIndex, totalLength, false);
  186. }
  187. return allChunks;
  188. };
  189. }