Search.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /* eslint-disable @typescript-eslint/no-use-before-define */
  2. import React, { useState, useEffect, useRef } from 'react';
  3. import useActions from '../actions';
  4. import useStore from '../store';
  5. import SearchComponent from '../components/Search';
  6. import { normalize, calcFindPhraseMatch, convertMatches } from '../helpers/pdf';
  7. import { scrollIntoView } from '../helpers/utility';
  8. import { delay } from '../helpers/time';
  9. const Search: React.FunctionComponent = () => {
  10. const queryString = useRef('');
  11. const pageIndex = useRef(0);
  12. const matchIndex = useRef(-1);
  13. const pageMatches = useRef<any[]>([]);
  14. const textContentItems = useRef<any[]>([]);
  15. const textDiv = useRef<any[]>([]);
  16. const pageContents: string[] = [];
  17. const [matchesTotal, setMatchesTotal] = useState(0);
  18. const [selected, setSelected] = useState({
  19. last: -1,
  20. current: -1,
  21. });
  22. const [{ navbarState, pdf, totalPage }, dispatch] = useStore();
  23. const { setNavbar } = useActions(dispatch);
  24. const extractPageText = async (pageIdx: number): Promise<any> => {
  25. const page = await pdf.getPage(pageIdx + 1);
  26. const textContent = await page.getTextContent({
  27. normalizeWhitespace: true,
  28. });
  29. const textItems = textContent.items;
  30. const strBuf = [];
  31. for (let j = 0, jj = textItems.length; j < jj; j += 1) {
  32. if (textItems[j].str.match(/[^\s]/)) {
  33. strBuf.push(textItems[j].str);
  34. }
  35. }
  36. pageContents[pageIdx] = normalize(strBuf.join('').toLowerCase());
  37. textContentItems.current[pageIdx] = strBuf;
  38. };
  39. const extractPdfText = async (): Promise<any> => {
  40. const extractTextPromises = [];
  41. for (let i = 0; i < totalPage; i += 1) {
  42. extractTextPromises.push(extractPageText(i));
  43. }
  44. await Promise.all(extractTextPromises);
  45. };
  46. const appendTextToDiv = (
  47. divIdx: number,
  48. fromOffset: number,
  49. toOffset: number | null,
  50. highlight: boolean,
  51. ): void => {
  52. const textContentItem = textContentItems.current[pageIndex.current];
  53. const div = textDiv.current[divIdx];
  54. const content = textContentItem[divIdx].substring(fromOffset, toOffset);
  55. const node = document.createTextNode(content);
  56. const span = document.createElement('span');
  57. if (highlight) {
  58. span.style.backgroundColor = 'rgba(255, 211, 0, 0.5)';
  59. span.appendChild(node);
  60. div.appendChild(span);
  61. } else {
  62. div.appendChild(node);
  63. }
  64. };
  65. const cleanMatch = (): void => {
  66. if (queryString.current) {
  67. const pageMatch = pageMatches.current[pageIndex.current][matchIndex.current];
  68. const textContentItem = textContentItems.current[pageIndex.current];
  69. const { begin, end } = convertMatches(queryString.current, pageMatch, textContentItem);
  70. const len = begin.divIdx + (end.divIdx - begin.divIdx);
  71. for (let i = begin.divIdx; i <= len; i += 1) {
  72. const offset = {
  73. divIdx: i,
  74. offset: null,
  75. };
  76. startText(offset);
  77. }
  78. }
  79. };
  80. const startText = (begin: Record<string, any>): void => {
  81. textDiv.current[begin.divIdx].textContent = '';
  82. appendTextToDiv(begin.divIdx, 0, begin.offset, false);
  83. };
  84. const renderMatches = async (): Promise<any> => {
  85. const pageDiv: HTMLDivElement = document.getElementById(`page_${pageIndex.current + 1}`) as HTMLDivElement;
  86. scrollIntoView(pageDiv);
  87. await delay(500);
  88. const textLayer: HTMLDivElement = pageDiv.querySelector('[data-id="text-layer"]') as HTMLDivElement;
  89. textDiv.current = Array.from(textLayer.children);
  90. await delay(200);
  91. const pageMatch = pageMatches.current[pageIndex.current][matchIndex.current];
  92. const textContentItem = textContentItems.current[pageIndex.current];
  93. const { begin, end } = convertMatches(queryString.current, pageMatch, textContentItem);
  94. startText(begin);
  95. if (begin.divIdx === end.divIdx) {
  96. appendTextToDiv(begin.divIdx, begin.offset, end.offset, true);
  97. } else {
  98. const len = begin.divIdx + (end.divIdx - begin.divIdx);
  99. for (let i = begin.divIdx; i <= len; i += 1) {
  100. switch (i) {
  101. case begin.divIdx:
  102. appendTextToDiv(i, begin.offset, null, true);
  103. break;
  104. case end.divIdx:
  105. appendTextToDiv(end.divIdx, 0, end.offset, true);
  106. break;
  107. default: {
  108. startText(begin);
  109. appendTextToDiv(begin.divIdx, 0, null, true);
  110. break;
  111. }
  112. }
  113. }
  114. // append end text
  115. appendTextToDiv(end.divIdx, end.offset, null, true);
  116. }
  117. };
  118. const advanceOffsetPage = (previous: boolean): void => {
  119. if (previous && pageIndex.current > 0) {
  120. pageIndex.current -= 1;
  121. const numPageMatches = pageMatches.current[pageIndex.current].length;
  122. matchIndex.current = numPageMatches;
  123. prevMatch();
  124. } else if (!previous && pageIndex.current < totalPage) {
  125. pageIndex.current += 1;
  126. matchIndex.current = -1;
  127. nextMatch();
  128. }
  129. };
  130. const prevMatch = (): void => {
  131. const numPageMatches = pageMatches.current[pageIndex.current].length;
  132. if (numPageMatches && matchIndex.current - 1 >= 0) {
  133. matchIndex.current -= 1;
  134. renderMatches();
  135. } else {
  136. advanceOffsetPage(true);
  137. }
  138. };
  139. const nextMatch = (): void => {
  140. const numPageMatches = pageMatches.current[pageIndex.current].length;
  141. if (numPageMatches && matchIndex.current + 1 < numPageMatches) {
  142. matchIndex.current += 1;
  143. renderMatches();
  144. } else {
  145. advanceOffsetPage(false);
  146. }
  147. };
  148. const clickPrev = (): void => {
  149. cleanMatch();
  150. setSelected((prev) => {
  151. if (prev.current - 1 >= 0) {
  152. return {
  153. last: prev.current,
  154. current: prev.current - 1,
  155. };
  156. }
  157. return prev;
  158. });
  159. };
  160. const clickNext = (): void => {
  161. cleanMatch();
  162. setSelected((prev) => {
  163. if (prev.current + 1 < matchesTotal) {
  164. return {
  165. last: prev.current,
  166. current: prev.current + 1,
  167. };
  168. }
  169. return prev;
  170. });
  171. };
  172. const handleSearch = async (val: string): Promise<any> => {
  173. if (!pageContents.length) {
  174. await extractPdfText();
  175. }
  176. const query = val.toLowerCase();
  177. queryString.current = query;
  178. for (let i = 0; i < totalPage; i += 1) {
  179. const matches = calcFindPhraseMatch(pageContents[i], query);
  180. pageMatches.current[i] = matches;
  181. }
  182. if (pageMatches.current.length) {
  183. const total = pageMatches.current.reduce((prev, curr) => prev + curr.length, 0);
  184. setMatchesTotal(total);
  185. setSelected({
  186. last: -1,
  187. current: 0,
  188. });
  189. }
  190. };
  191. const handleClose = (): void => {
  192. cleanMatch();
  193. setNavbar('');
  194. };
  195. useEffect(() => {
  196. if (selected.current > selected.last) {
  197. nextMatch();
  198. } else if (selected.current < selected.last) {
  199. prevMatch();
  200. }
  201. }, [selected]);
  202. return (
  203. <SearchComponent
  204. matchesTotal={matchesTotal}
  205. matchIndex={selected.current}
  206. onPrev={clickPrev}
  207. onNext={clickNext}
  208. onEnter={handleSearch}
  209. isActive={navbarState === 'search'}
  210. close={handleClose}
  211. />
  212. );
  213. };
  214. export default Search;