|
@@ -0,0 +1,245 @@
|
|
|
|
+/* eslint-disable @typescript-eslint/no-use-before-define */
|
|
|
|
+import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
+
|
|
|
|
+import useActions from '../actions';
|
|
|
|
+import useStore from '../store';
|
|
|
|
+import SearchComponent from '../components/Search';
|
|
|
|
+import { normalize, calcFindPhraseMatch, convertMatches } from '../helpers/pdf';
|
|
|
|
+import { scrollIntoView } from '../helpers/utility';
|
|
|
|
+import { delay } from '../helpers/time';
|
|
|
|
+
|
|
|
|
+const Search: React.FunctionComponent = () => {
|
|
|
|
+ const queryString = useRef('');
|
|
|
|
+ const pageIndex = useRef(0);
|
|
|
|
+ const matchIndex = useRef(-1);
|
|
|
|
+ const pageMatches = useRef<any[]>([]);
|
|
|
|
+ const textContentItems = useRef<any[]>([]);
|
|
|
|
+ const textDiv = useRef<any[]>([]);
|
|
|
|
+ const pageContents: string[] = [];
|
|
|
|
+ const [matchesTotal, setMatchesTotal] = useState(0);
|
|
|
|
+ const [selected, setSelected] = useState({
|
|
|
|
+ last: -1,
|
|
|
|
+ current: -1,
|
|
|
|
+ });
|
|
|
|
+ const [{ navbarState, pdf, totalPage }, dispatch] = useStore();
|
|
|
|
+ const { setNavbar } = useActions(dispatch);
|
|
|
|
+
|
|
|
|
+ const extractPageText = async (pageIdx: number): Promise<any> => {
|
|
|
|
+ const page = await pdf.getPage(pageIdx + 1);
|
|
|
|
+ const textContent = await page.getTextContent({
|
|
|
|
+ normalizeWhitespace: true,
|
|
|
|
+ });
|
|
|
|
+ const textItems = textContent.items;
|
|
|
|
+ const strBuf = [];
|
|
|
|
+
|
|
|
|
+ for (let j = 0, jj = textItems.length; j < jj; j += 1) {
|
|
|
|
+ if (textItems[j].str.match(/[^\s]/)) {
|
|
|
|
+ strBuf.push(textItems[j].str);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ pageContents[pageIdx] = normalize(strBuf.join('').toLowerCase());
|
|
|
|
+ textContentItems.current[pageIdx] = strBuf;
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const extractPdfText = async (): Promise<any> => {
|
|
|
|
+ const extractTextPromises = [];
|
|
|
|
+ for (let i = 0; i < totalPage; i += 1) {
|
|
|
|
+ extractTextPromises.push(extractPageText(i));
|
|
|
|
+ }
|
|
|
|
+ await Promise.all(extractTextPromises);
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const appendTextToDiv = (
|
|
|
|
+ divIdx: number,
|
|
|
|
+ fromOffset: number,
|
|
|
|
+ toOffset: number | null,
|
|
|
|
+ highlight: boolean,
|
|
|
|
+ ): void => {
|
|
|
|
+ const textContentItem = textContentItems.current[pageIndex.current];
|
|
|
|
+ const div = textDiv.current[divIdx];
|
|
|
|
+ const content = textContentItem[divIdx].substring(fromOffset, toOffset);
|
|
|
|
+ const node = document.createTextNode(content);
|
|
|
|
+ const span = document.createElement('span');
|
|
|
|
+ if (highlight) {
|
|
|
|
+ span.style.backgroundColor = 'rgba(255, 211, 0, 0.5)';
|
|
|
|
+ span.appendChild(node);
|
|
|
|
+ div.appendChild(span);
|
|
|
|
+ } else {
|
|
|
|
+ div.appendChild(node);
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const cleanMatch = (): void => {
|
|
|
|
+ if (queryString.current) {
|
|
|
|
+ const pageMatch = pageMatches.current[pageIndex.current][matchIndex.current];
|
|
|
|
+ const textContentItem = textContentItems.current[pageIndex.current];
|
|
|
|
+ const { begin, end } = convertMatches(queryString.current, pageMatch, textContentItem);
|
|
|
|
+ const len = begin.divIdx + (end.divIdx - begin.divIdx);
|
|
|
|
+
|
|
|
|
+ for (let i = begin.divIdx; i <= len; i += 1) {
|
|
|
|
+ const offset = {
|
|
|
|
+ divIdx: i,
|
|
|
|
+ offset: null,
|
|
|
|
+ };
|
|
|
|
+ startText(offset);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const startText = (begin: Record<string, any>): void => {
|
|
|
|
+ textDiv.current[begin.divIdx].textContent = '';
|
|
|
|
+ appendTextToDiv(begin.divIdx, 0, begin.offset, false);
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const renderMatches = async (): Promise<any> => {
|
|
|
|
+ const pageDiv: HTMLDivElement = document.getElementById(`page_${pageIndex.current + 1}`) as HTMLDivElement;
|
|
|
|
+ scrollIntoView(pageDiv);
|
|
|
|
+ await delay(500);
|
|
|
|
+
|
|
|
|
+ const textLayer: HTMLDivElement = pageDiv.querySelector('[data-id="text-layer"]') as HTMLDivElement;
|
|
|
|
+ textDiv.current = Array.from(textLayer.children);
|
|
|
|
+ await delay(200);
|
|
|
|
+
|
|
|
|
+ const pageMatch = pageMatches.current[pageIndex.current][matchIndex.current];
|
|
|
|
+ const textContentItem = textContentItems.current[pageIndex.current];
|
|
|
|
+ const { begin, end } = convertMatches(queryString.current, pageMatch, textContentItem);
|
|
|
|
+
|
|
|
|
+ startText(begin);
|
|
|
|
+
|
|
|
|
+ if (begin.divIdx === end.divIdx) {
|
|
|
|
+ appendTextToDiv(begin.divIdx, begin.offset, end.offset, true);
|
|
|
|
+ } else {
|
|
|
|
+ const len = begin.divIdx + (end.divIdx - begin.divIdx);
|
|
|
|
+ for (let i = begin.divIdx; i <= len; i += 1) {
|
|
|
|
+ switch (i) {
|
|
|
|
+ case begin.divIdx:
|
|
|
|
+ appendTextToDiv(i, begin.offset, null, true);
|
|
|
|
+ break;
|
|
|
|
+ case end.divIdx:
|
|
|
|
+ appendTextToDiv(end.divIdx, 0, end.offset, true);
|
|
|
|
+ break;
|
|
|
|
+ default: {
|
|
|
|
+ startText(begin);
|
|
|
|
+ appendTextToDiv(begin.divIdx, 0, null, true);
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ // append end text
|
|
|
|
+ appendTextToDiv(end.divIdx, end.offset, null, true);
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const advanceOffsetPage = (previous: boolean): void => {
|
|
|
|
+ if (previous && pageIndex.current > 0) {
|
|
|
|
+ pageIndex.current -= 1;
|
|
|
|
+ const numPageMatches = pageMatches.current[pageIndex.current].length;
|
|
|
|
+ matchIndex.current = numPageMatches;
|
|
|
|
+ prevMatch();
|
|
|
|
+ } else if (!previous && pageIndex.current < totalPage) {
|
|
|
|
+ pageIndex.current += 1;
|
|
|
|
+ matchIndex.current = -1;
|
|
|
|
+ nextMatch();
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const prevMatch = (): void => {
|
|
|
|
+ const numPageMatches = pageMatches.current[pageIndex.current].length;
|
|
|
|
+
|
|
|
|
+ if (numPageMatches && matchIndex.current - 1 >= 0) {
|
|
|
|
+ matchIndex.current -= 1;
|
|
|
|
+ renderMatches();
|
|
|
|
+ } else {
|
|
|
|
+ advanceOffsetPage(true);
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const nextMatch = (): void => {
|
|
|
|
+ const numPageMatches = pageMatches.current[pageIndex.current].length;
|
|
|
|
+
|
|
|
|
+ if (numPageMatches && matchIndex.current + 1 < numPageMatches) {
|
|
|
|
+ matchIndex.current += 1;
|
|
|
|
+ renderMatches();
|
|
|
|
+ } else {
|
|
|
|
+ advanceOffsetPage(false);
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const clickPrev = (): void => {
|
|
|
|
+ cleanMatch();
|
|
|
|
+
|
|
|
|
+ setSelected((prev) => {
|
|
|
|
+ if (prev.current - 1 >= 0) {
|
|
|
|
+ return {
|
|
|
|
+ last: prev.current,
|
|
|
|
+ current: prev.current - 1,
|
|
|
|
+ };
|
|
|
|
+ }
|
|
|
|
+ return prev;
|
|
|
|
+ });
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const clickNext = (): void => {
|
|
|
|
+ cleanMatch();
|
|
|
|
+
|
|
|
|
+ setSelected((prev) => {
|
|
|
|
+ if (prev.current + 1 < matchesTotal) {
|
|
|
|
+ return {
|
|
|
|
+ last: prev.current,
|
|
|
|
+ current: prev.current + 1,
|
|
|
|
+ };
|
|
|
|
+ }
|
|
|
|
+ return prev;
|
|
|
|
+ });
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const handleSearch = async (val: string): Promise<any> => {
|
|
|
|
+ if (!pageContents.length) {
|
|
|
|
+ await extractPdfText();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const query = val.toLowerCase();
|
|
|
|
+ queryString.current = query;
|
|
|
|
+ for (let i = 0; i < totalPage; i += 1) {
|
|
|
|
+ const matches = calcFindPhraseMatch(pageContents[i], query);
|
|
|
|
+ pageMatches.current[i] = matches;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (pageMatches.current.length) {
|
|
|
|
+ const total = pageMatches.current.reduce((prev, curr) => prev + curr.length, 0);
|
|
|
|
+ setMatchesTotal(total);
|
|
|
|
+ setSelected({
|
|
|
|
+ last: -1,
|
|
|
|
+ current: 0,
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const handleClose = (): void => {
|
|
|
|
+ cleanMatch();
|
|
|
|
+ setNavbar('');
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ if (selected.current > selected.last) {
|
|
|
|
+ nextMatch();
|
|
|
|
+ } else if (selected.current < selected.last) {
|
|
|
|
+ prevMatch();
|
|
|
|
+ }
|
|
|
|
+ }, [selected]);
|
|
|
|
+
|
|
|
|
+ return (
|
|
|
|
+ <SearchComponent
|
|
|
|
+ matchesTotal={matchesTotal}
|
|
|
|
+ matchIndex={selected.current}
|
|
|
|
+ onPrev={clickPrev}
|
|
|
|
+ onNext={clickNext}
|
|
|
|
+ onEnter={handleSearch}
|
|
|
|
+ isActive={navbarState === 'search'}
|
|
|
|
+ close={handleClose}
|
|
|
|
+ />
|
|
|
|
+ );
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+export default Search;
|