Search.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import React, { useState, useEffect } from 'react';
  2. import useActions from '../actions';
  3. import useStore from '../store';
  4. import SearchComponent from '../components/Search';
  5. import {
  6. normalize,
  7. calculatePhraseMatch,
  8. convertMatches,
  9. } from '../helpers/pdf';
  10. import { scrollIntoView } from '../helpers/utility';
  11. type MatchType = {
  12. page: number;
  13. index: number;
  14. };
  15. let readyScroll = false;
  16. const Search: React.FC = () => {
  17. const [queryString, setQueryString] = useState('');
  18. const [matchesMap, setMatchesMap] = useState<MatchType[]>([]);
  19. const [matchTotal, setMatchTotal] = useState(0);
  20. const [prevIndex, setPrevIndex] = useState(-1);
  21. const [currentIndex, setCurrentIndex] = useState(-1);
  22. const [
  23. { navbarState, pdf, totalPage, textDivs, currentPage },
  24. dispatch,
  25. ] = useStore();
  26. const { setNavbar } = useActions(dispatch);
  27. const getTextContent = async (pageNum: number): Promise<any[]> => {
  28. const page = await pdf.getPage(pageNum);
  29. const textContent = await page.getTextContent({
  30. normalizeWhitespace: true,
  31. });
  32. return textContent.items;
  33. };
  34. const extractTextItems = async (pageNum: number): Promise<string[]> => {
  35. const textContent = await getTextContent(pageNum);
  36. const strBuf = [];
  37. for (let j = 0, len = textContent.length; j < len; j += 1) {
  38. // add whitespace in front if start character is Uppercase
  39. if (
  40. textContent[j].str.match(/^[A-Z]/) &&
  41. j > 0 &&
  42. textContent[j - 1].str !== ' '
  43. ) {
  44. strBuf.push(` ${textContent[j].str}`);
  45. } else {
  46. strBuf.push(textContent[j].str);
  47. }
  48. }
  49. return strBuf;
  50. };
  51. const getMatchTextIndex = async (
  52. pageNum: number,
  53. queryStr: string
  54. ): Promise<void> => {
  55. const contentItems = await extractTextItems(pageNum);
  56. const content = normalize(contentItems.join('').toLowerCase());
  57. const matches = calculatePhraseMatch(content, queryStr);
  58. if (matches.length) {
  59. matches.forEach(ele => {
  60. setMatchesMap((prevState: MatchType[]) => {
  61. prevState.push({
  62. page: pageNum,
  63. index: ele,
  64. });
  65. return prevState;
  66. });
  67. });
  68. setMatchTotal(cur => cur + matches.length);
  69. }
  70. if (pageNum < totalPage) {
  71. await getMatchTextIndex(pageNum + 1, queryStr);
  72. }
  73. };
  74. const startSearchPdf = async (queryStr: string): Promise<void> => {
  75. getMatchTextIndex(1, queryStr);
  76. };
  77. const appendTextToDiv = async (
  78. pageNum: number,
  79. divIdx: number,
  80. fromOffset: number,
  81. toOffset: number | undefined,
  82. highlight: boolean
  83. ): Promise<any> => {
  84. const textContentItem = await extractTextItems(pageNum);
  85. if (textDivs[pageNum]) {
  86. const domElements = textDivs[pageNum][divIdx];
  87. const content = textContentItem[divIdx].substring(fromOffset, toOffset);
  88. const node = document.createTextNode(content);
  89. const span = document.createElement('span');
  90. if (highlight) {
  91. span.style.backgroundColor = 'rgba(255, 211, 0, 0.7)';
  92. span.appendChild(node);
  93. domElements.appendChild(span);
  94. scrollIntoView(domElements, { top: -120 });
  95. } else {
  96. domElements.textContent = '';
  97. domElements.appendChild(node);
  98. }
  99. }
  100. };
  101. const beginText = async (
  102. pageNum: number,
  103. begin: Record<string, any>
  104. ): Promise<any> => {
  105. const { divIdx } = begin;
  106. const domElements = textDivs[pageNum][divIdx];
  107. if (domElements) {
  108. domElements.textContent = '';
  109. appendTextToDiv(pageNum, divIdx, 0, begin.offset, false);
  110. }
  111. };
  112. const cleanMatches = async (
  113. pageNum: number,
  114. matchIndex: number,
  115. queryStr: string
  116. ): Promise<any> => {
  117. const textContentItem = await extractTextItems(pageNum);
  118. const { begin, end } = convertMatches(
  119. queryStr,
  120. matchIndex,
  121. textContentItem
  122. );
  123. for (let i = begin.divIdx; i <= end.divIdx; i += 1) {
  124. appendTextToDiv(pageNum, i, 0, undefined, false);
  125. }
  126. };
  127. const reset = (): void => {
  128. setMatchTotal(0);
  129. setCurrentIndex(-1);
  130. setPrevIndex(-1);
  131. setMatchesMap([]);
  132. };
  133. const renderMatches = async (
  134. pageNum: number,
  135. matchIndex: number,
  136. queryStr: string
  137. ): Promise<any> => {
  138. const textContentItem = await extractTextItems(pageNum);
  139. const { begin, end } = convertMatches(
  140. queryStr,
  141. matchIndex,
  142. textContentItem
  143. );
  144. beginText(pageNum, begin);
  145. if (begin.divIdx === end.divIdx) {
  146. appendTextToDiv(pageNum, begin.divIdx, begin.offset, end.offset, true);
  147. } else {
  148. for (let i = begin.divIdx; i <= end.divIdx; i += 1) {
  149. switch (i) {
  150. case begin.divIdx:
  151. appendTextToDiv(
  152. pageNum,
  153. begin.divIdx,
  154. begin.offset,
  155. undefined,
  156. true
  157. );
  158. break;
  159. case end.divIdx:
  160. beginText(pageNum, { divIdx: end.divIdx, offset: 0 });
  161. appendTextToDiv(pageNum, end.divIdx, 0, end.offset, true);
  162. break;
  163. default: {
  164. beginText(pageNum, { divIdx: i, offset: 0 });
  165. appendTextToDiv(pageNum, i, 0, undefined, true);
  166. break;
  167. }
  168. }
  169. }
  170. }
  171. };
  172. const handleSearch = (val: string): void => {
  173. if (!val) return;
  174. const queryStr = normalize(val.toLowerCase());
  175. if (queryStr !== queryString) {
  176. readyScroll = true;
  177. reset();
  178. setQueryString(queryStr);
  179. startSearchPdf(queryStr);
  180. }
  181. };
  182. const clickPrev = (): void => {
  183. readyScroll = true;
  184. setCurrentIndex(cur => {
  185. setPrevIndex(cur);
  186. if (cur > 0) {
  187. return cur - 1;
  188. }
  189. setPrevIndex(cur - 1);
  190. return cur;
  191. });
  192. };
  193. const clickNext = (): void => {
  194. readyScroll = true;
  195. setCurrentIndex(cur => {
  196. setPrevIndex(cur);
  197. if (cur + 1 < matchTotal) {
  198. return cur + 1;
  199. }
  200. return cur;
  201. });
  202. };
  203. const handleClose = (): void => {
  204. setNavbar('');
  205. reset();
  206. const currentMatches = matchesMap[currentIndex];
  207. if (currentMatches) {
  208. cleanMatches(currentMatches.page, currentMatches.index, queryString);
  209. }
  210. };
  211. useEffect(() => {
  212. if (matchTotal >= 1 && currentIndex === -1) {
  213. clickNext();
  214. }
  215. }, [matchTotal, currentIndex]);
  216. useEffect(() => {
  217. if (currentIndex >= 0) {
  218. const indexObj = matchesMap[currentIndex];
  219. if (indexObj.page !== currentPage && readyScroll) {
  220. const pageDiv: HTMLDivElement = document.getElementById(
  221. `page_${indexObj.page}`
  222. ) as HTMLDivElement;
  223. scrollIntoView(pageDiv);
  224. readyScroll = false;
  225. }
  226. }
  227. }, [currentIndex, matchesMap, currentPage]);
  228. useEffect(() => {
  229. if (currentIndex >= 0) {
  230. const currentMatches = matchesMap[currentIndex];
  231. if (textDivs[currentMatches.page]) {
  232. renderMatches(currentMatches.page, currentMatches.index, queryString);
  233. }
  234. if (currentIndex !== prevIndex && prevIndex >= 0) {
  235. const prevMatches = matchesMap[prevIndex];
  236. if (prevMatches) {
  237. cleanMatches(prevMatches.page, prevMatches.index, queryString);
  238. }
  239. }
  240. }
  241. }, [currentIndex, prevIndex, matchesMap, queryString, textDivs]);
  242. return (
  243. <SearchComponent
  244. matchesTotal={matchTotal}
  245. current={currentIndex + 1}
  246. onPrev={clickPrev}
  247. onNext={clickNext}
  248. onEnter={handleSearch}
  249. isActive={navbarState === 'search'}
  250. close={handleClose}
  251. />
  252. );
  253. };
  254. export default Search;