RoyLiu vor 5 Jahren
Ursprung
Commit
155009865f
11 geänderte Dateien mit 375 neuen und 46 gelöschten Zeilen
  1. 6 1
      apis/index.ts
  2. 13 6
      global/otherStyled.ts
  3. 7 4
      global/toolStyled.ts
  4. 192 0
      helpers/annotation.ts
  5. 3 2
      helpers/apiHelpers.ts
  6. 6 0
      helpers/dom.ts
  7. 116 19
      helpers/pdf.ts
  8. 25 7
      helpers/utility.ts
  9. 3 4
      pages/_app.js
  10. 1 2
      pages/_document.js
  11. 3 1
      pages/index.tsx

+ 6 - 1
apis/index.ts

@@ -1,5 +1,4 @@
 import invokeApi from '../helpers/apiHelpers';
-
 import apiPath from '../constants/apiPath';
 
 export const initialPdfFile = async (token: string): Promise<any> => invokeApi({
@@ -10,4 +9,10 @@ export const initialPdfFile = async (token: string): Promise<any> => invokeApi({
   },
 });
 
+export const saveFile = async (token: string, data: any): Promise<any> => invokeApi({
+  path: `${apiPath.saveFile}?f=${token}`,
+  method: 'POST',
+  data,
+});
+
 export default {};

+ 13 - 6
global/otherStyled.ts

@@ -1,16 +1,23 @@
-import styled from 'styled-components';
+import styled, { css } from 'styled-components';
 
 export const Separator = styled.div`
   flex: 1 1 auto;
 `;
 
-export const SidebarWrapper = styled.div`
+export const SidebarWrapper = styled('div')<{isHidden: boolean}>`
   position: fixed;
   top: 60px;
-  left: 0;
-  bottom: 0;
-  right: auto;
+  bottom: 0px;
   width: 267px;
-  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.24);
   background-color: white;
+  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12);
+  z-index: 1;
+
+  transition: left 225ms cubic-bezier(0, 0, 0.2, 1) 0ms;
+
+  ${props => (props.isHidden ? css`
+    left: -267px;
+  ` : css`
+    left: 0;
+  `)}
 `;

+ 7 - 4
global/toolStyled.ts

@@ -1,4 +1,4 @@
-import styled from 'styled-components';
+import styled, { css } from 'styled-components';
 
 import { color } from '../constants/style';
 
@@ -14,10 +14,9 @@ export const Group = styled.div`
   display: flex;
   justify-content: space-around;
   align-items: center;
-  margin-bottom: 7px;
 `;
 
-export const Item = styled('div')<{size?: string}>`
+export const Item = styled('div')<{size?: string; selected?: boolean}>`
   width: ${props => (props.size === 'small' ? '30px' : '40px')};
   height: ${props => (props.size === 'small' ? '30px' : '40px')};
   border-radius: 4px;
@@ -26,6 +25,10 @@ export const Item = styled('div')<{size?: string}>`
   align-items: center;
   cursor: pointer;
 
+  ${props => (props.selected ? css`
+    background-color: ${color['light-primary']};
+  ` : '')}
+
   :hover {
     background-color: ${color['light-primary']};
   }
@@ -35,7 +38,7 @@ export const Circle = styled('div')<{color: string}>`
   width: 16px;
   height: 16px;
   border-radius: 8px;
-  background-color: ${props => color[props.color]};
+  background-color: ${props => props.color};
 `;
 
 export const SliderWrapper = styled.div`

+ 192 - 0
helpers/annotation.ts

@@ -0,0 +1,192 @@
+/* eslint-disable @typescript-eslint/camelcase */
+import fetch from 'isomorphic-unfetch';
+
+import config from '../config';
+import apiPath from '../constants/apiPath';
+import { LINE_TYPE } from '../constants';
+import { AnnotationType, Position } from '../constants/type';
+import { xmlParser } from './dom';
+import { chunk } from './utility';
+
+type Props = {
+  color: string;
+  type: string;
+  opacity: number;
+  scale: number;
+}
+
+const resetPositionValue = (
+  {
+    top, left, bottom, right,
+  }: Position,
+  scale: number,
+): Position => ({
+  top: top / scale,
+  left: left / scale,
+  bottom: bottom / scale,
+  right: right / scale,
+});
+
+export const getAnnotationWithSelection = ({
+  color, type, opacity, scale,
+}: Props): AnnotationType[] | null => {
+  const selection: any = document.getSelection();
+  if (!selection.rangeCount) return [];
+
+  const {
+    startContainer,
+    startOffset,
+    endContainer,
+    endOffset,
+  } = selection.getRangeAt(0);
+  const appendInfo: AnnotationType[] = [];
+  const startElement = startContainer.parentNode as HTMLElement;
+  const endElement = endContainer.parentNode as HTMLElement;
+  const startPage = startElement?.parentNode?.parentNode as HTMLElement;
+  const endPage = endElement?.parentNode?.parentNode as HTMLElement;
+  const startPageNum = parseInt(startPage.getAttribute('data-page-num') as string, 10);
+  const endPageNum = parseInt(endPage.getAttribute('data-page-num') as string, 10);
+  const textLayer = startPage.querySelector('[data-id="text-layer"]') as HTMLElement;
+
+  if (startPageNum !== endPageNum) return null;
+  if (startOffset === endOffset && startOffset === endOffset) return null;
+
+  const startEle = startElement.cloneNode(true) as HTMLElement;
+  const endEle = endElement.cloneNode(true) as HTMLElement;
+
+  const startText = startElement.innerText.substring(0, startOffset);
+  const endText = endEle.innerText.substring(endOffset);
+
+  startEle.innerText = startText;
+  endEle.innerText = endText;
+  textLayer.appendChild(startEle);
+  textLayer.appendChild(endEle);
+
+  const startEleWidth = startEle.offsetWidth;
+  const endEleWidth = endEle.offsetWidth;
+
+  textLayer.removeChild(startEle);
+  textLayer.removeChild(endEle);
+
+  const info: AnnotationType = {};
+  const position: Position[] = [];
+
+  // left to right and up to down select
+  let startX = startElement.offsetLeft + startEleWidth;
+  let startY = startElement.offsetTop;
+  let endX = endElement.offsetLeft + endElement.offsetWidth - endEleWidth;
+  let endY = endElement.offsetTop + endElement.offsetHeight;
+
+  if (startX > endX && startY >= endY) {
+    // right to left and down to up select
+    startX = endElement.offsetLeft + startEleWidth;
+    startY = endElement.offsetTop;
+    endX = startElement.offsetLeft + startElement.offsetWidth - endEleWidth;
+    endY = startElement.offsetTop + startElement.offsetHeight;
+  }
+
+  textLayer.childNodes.forEach((ele: any) => {
+    const {
+      offsetTop, offsetLeft, offsetHeight, offsetWidth,
+    } = ele;
+    const offsetRight = offsetLeft + offsetWidth;
+    const offsetBottom = offsetTop + offsetHeight;
+    let coords = {
+      top: 0, left: 0, right: 0, bottom: 0,
+    };
+
+    if (offsetTop >= startY && offsetBottom <= endY) {
+      if (startElement === endElement) {
+        // start and end same element
+        coords = {
+          top: offsetTop,
+          bottom: offsetBottom,
+          left: startX,
+          right: endX,
+        };
+      } else if (
+        (offsetTop > startY && offsetBottom < endY) || (offsetLeft >= startX && offsetRight <= endX)
+      ) {
+        // middle element
+        coords = {
+          top: offsetTop,
+          bottom: offsetBottom,
+          left: offsetLeft,
+          right: offsetRight,
+        };
+      } else if (offsetTop === startY) {
+        // start line element
+        coords = {
+          top: offsetTop,
+          bottom: offsetBottom,
+          left: offsetLeft <= startX ? startX : offsetLeft,
+          right: offsetRight,
+        };
+      } else if (offsetBottom === endY) {
+        // end line element
+        coords = {
+          top: offsetTop,
+          bottom: offsetBottom,
+          left: offsetLeft,
+          right: offsetRight >= endX ? endX : offsetRight,
+        };
+      }
+      position.push(resetPositionValue(coords, scale));
+    }
+  });
+
+  info.obj_type = LINE_TYPE[type];
+  info.obj_attr = {
+    page: startPageNum,
+    bdcolor: color,
+    position,
+    transparency: opacity * 0.01,
+  };
+  appendInfo.push(info);
+
+  return appendInfo;
+};
+
+export const fetchXfdf = (token: string): Promise<any> => (
+  fetch(`${config.API_HOST}${apiPath.getXfdf}?f=${token}`).then(res => res.text())
+);
+
+export const parseAnnotationFromXml = (xmlString: string): AnnotationType[] => {
+  const xmlDoc = xmlParser(xmlString);
+  let annotations = xmlDoc.firstElementChild.children[0].children;
+  annotations = Array.prototype.slice.call(annotations);
+  const filterAnnotations = annotations.reduce((acc: any[], cur: any) => {
+    if (
+      cur.tagName === 'highlight'
+      || cur.tagName === 'underline'
+      || cur.tagName === 'strikeout'
+      || cur.tagName === 'squiggly'
+    ) {
+      let tempArray = [];
+      if (cur.attributes.coords) {
+        const coords = cur.attributes.coords.value.split(',');
+        tempArray = chunk(coords, 8);
+      }
+
+      const position = tempArray.map((ele: string[]) => ({
+        top: parseInt(ele[5] as string, 10),
+        bottom: parseInt(ele[1], 10),
+        left: parseInt(ele[0], 10),
+        right: parseInt(ele[2], 10),
+      }));
+
+      acc.push({
+        obj_type: LINE_TYPE[cur.tagName],
+        obj_attr: {
+          page: parseInt(cur.attributes.page.value, 10),
+          bdcolor: cur.attributes.color.value,
+          position,
+          transparency: parseFloat(cur.attributes.opacity.value),
+        },
+      });
+    }
+    return acc;
+  }, []);
+
+  return filterAnnotations;
+};

+ 3 - 2
helpers/apiHelpers.ts

@@ -1,5 +1,6 @@
 import fetch from 'isomorphic-unfetch';
 import queryString from 'query-string';
+
 import config from '../config';
 
 type Props = {
@@ -20,7 +21,7 @@ export default ({ path, method, data }: Props): Promise<any> => {
 
   if (data) {
     if (method === 'GET') {
-      stringified = queryString.stringify(data);
+      stringified = `?${queryString.stringify(data)}`;
     } else {
       options.body = JSON.stringify(data);
     }
@@ -28,7 +29,7 @@ export default ({ path, method, data }: Props): Promise<any> => {
 
   const apiHost = config.API_HOST;
 
-  return fetch(`${apiHost}${path}?${stringified}`, options)
+  return fetch(`${apiHost}${path}${stringified}`, options)
     .then(res => res.json())
     .catch(error => ({ error: true, message: error.message }));
 };

+ 6 - 0
helpers/dom.ts

@@ -2,4 +2,10 @@ export const canUseDOM = (): boolean => (
   !!(typeof window !== 'undefined' && window.document)
 );
 
+export const xmlParser = (xmlString: string): any => {
+  const parser = new window.DOMParser();
+  const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
+  return xmlDoc;
+};
+
 export default canUseDOM;

+ 116 - 19
helpers/pdf.ts

@@ -1,33 +1,46 @@
 // @ts-ignore
 import pdfjs from 'pdfjs-dist';
-// @ts-ignore
-import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';
 import { ProgressType, ViewportType } from '../constants/type';
 import { objIsEmpty } from './utility';
 
-const CMAP_URL = '../../node_modules/pdfjs-dist/cmaps/';
-const CMAP_PACKED = true;
+pdfjs.GlobalWorkerOptions.workerSrc = '../static/pdf.worker.js';
 
-pdfjs.GlobalWorkerOptions.workerSrc = '../../static/pdf.worker.min.js';
+let normalizationRegex: any = null;
+const CHARACTERS_TO_NORMALIZE: {[index: string]: any} = {
+  '\u2018': '\'', // Left single quotation mark
+  '\u2019': '\'', // Right single quotation mark
+  '\u201A': '\'', // Single low-9 quotation mark
+  '\u201B': '\'', // Single high-reversed-9 quotation mark
+  '\u201C': '"', // Left double quotation mark
+  '\u201D': '"', // Right double quotation mark
+  '\u201E': '"', // Double low-9 quotation mark
+  '\u201F': '"', // Double high-reversed-9 quotation mark
+  '\u00BC': '1/4', // Vulgar fraction one quarter
+  '\u00BD': '1/2', // Vulgar fraction one half
+  '\u00BE': '3/4', // Vulgar fraction three quarters
+};
 
 export const fetchPdf = async (
   src: string, cb?: (progress: ProgressType) => void,
 ): Promise<any> => {
-  const loadingTask = pdfjs.getDocument({
-    url: src,
-    cMapUrl: CMAP_URL,
-    cMapPacked: CMAP_PACKED,
-  });
-
-  if (cb) {
-    loadingTask.onProgress = (progress: ProgressType): void => {
-      cb(progress);
-    };
-  }
+  try {
+    const loadingTask = pdfjs.getDocument({
+      url: src,
+    });
+
+    if (cb) {
+      loadingTask.onProgress = (progress: ProgressType): void => {
+        cb(progress);
+      };
+    }
 
-  const pdf = await loadingTask.promise;
+    const pdf = await loadingTask.promise;
+    return pdf;
+  } catch (e) {
+    console.log(e);
+  }
 
-  return pdf;
+  return {};
 };
 
 export const renderPdfPage = async ({
@@ -35,12 +48,13 @@ export const renderPdfPage = async ({
   page,
   viewport,
 }: {
-  rootEle: HTMLDivElement;
+  rootEle: HTMLElement;
   page: any;
   viewport: ViewportType;
 }): Promise<any> => {
   if (rootEle) {
     const canvas: HTMLCanvasElement = rootEle.querySelectorAll('canvas')[0] as HTMLCanvasElement;
+    const textLayer: HTMLDivElement = rootEle.querySelector('[data-id="text-layer"]') as HTMLDivElement;
 
     if (canvas) {
       const context: CanvasRenderingContext2D = canvas.getContext('2d') as CanvasRenderingContext2D;
@@ -54,9 +68,92 @@ export const renderPdfPage = async ({
 
       if (!objIsEmpty(page)) {
         const renderTask = page.render(renderContext);
+        textLayer.innerHTML = '';
+
+        page.getTextContent().then((textContent: any) => {
+          pdfjs.renderTextLayer({
+            textContent,
+            container: textLayer,
+            viewport,
+            textDivs: [],
+          });
+        });
 
         await renderTask.promise;
       }
     }
   }
 };
+
+export const normalize = (text: string): string => {
+  if (!normalizationRegex) {
+    // Compile the regular expression for text normalization once.
+    const replace = Object.keys(CHARACTERS_TO_NORMALIZE).join('');
+    normalizationRegex = new RegExp(`[${replace}]`, 'g');
+  }
+  return text.replace(normalizationRegex, ch => CHARACTERS_TO_NORMALIZE[ch]);
+};
+
+export const calcFindPhraseMatch = (pageContent: string, query: string): number[] => {
+  const matches = [];
+  const queryLen = query.length;
+  let matchIdx = -queryLen;
+
+  while (query) {
+    matchIdx = pageContent.indexOf(query, matchIdx + queryLen);
+    if (matchIdx === -1) break;
+    matches.push(matchIdx);
+  }
+  return matches;
+};
+
+export const convertMatches = (
+  queryString: string,
+  matchIndex: number,
+  textContentItem: any[],
+): Record<string, any> => {
+  let i = 0;
+  let iIndex = 0;
+  const end = textContentItem.length - 1;
+  const queryLen = queryString.length;
+
+  // Loop over the divIdxs.
+  while (i !== end && matchIndex >= (iIndex + textContentItem[i].length)) {
+    iIndex += textContentItem[i].length;
+    i += 1;
+  }
+
+  if (i === textContentItem.length) {
+    console.error('Could not find a matching mapping');
+  }
+
+  const match: Record<string, any> = {
+    begin: {
+      divIdx: i,
+      offset: matchIndex - iIndex,
+    },
+  };
+
+  // Calculate the end position.
+  // eslint-disable-next-line no-param-reassign
+  matchIndex += queryLen;
+
+  // Somewhat the same array as above, but use > instead of >= to get
+  // the end position right.
+  while (i !== end && matchIndex > (iIndex + textContentItem[i].length)) {
+    iIndex += textContentItem[i].length;
+    i += 1;
+  }
+
+  match.end = {
+    divIdx: i,
+    offset: matchIndex - iIndex,
+  };
+
+  return match;
+};
+
+export const getPdfPage = async (pdf: any, pageNum: number): Promise<any> => {
+  const page = await pdf.getPage(pageNum);
+  return page;
+};

+ 25 - 7
helpers/utility.ts

@@ -10,14 +10,15 @@ import { ScrollStateType } from '../constants/type';
 export const objIsEmpty = (obj: Record<string, any>): boolean => !Object.keys(obj).length;
 
 export const watchScroll = (
-  viewAreaElement: HTMLElement, cb: (state: ScrollStateType) => void,
+  viewAreaElement: HTMLElement | null, cb: (state: ScrollStateType) => void,
 ): ScrollStateType => {
   let rAF: number | null = null;
+  const element = viewAreaElement as HTMLElement;
   const state = {
     right: true,
     down: true,
-    lastX: viewAreaElement.scrollLeft,
-    lastY: viewAreaElement.scrollTop,
+    lastX: element.scrollLeft,
+    lastY: element.scrollTop,
   };
 
   const debounceScroll = (): void => {
@@ -28,13 +29,13 @@ export const watchScroll = (
     rAF = window.requestAnimationFrame(() => {
       rAF = null;
 
-      const currentX = viewAreaElement.scrollLeft;
+      const currentX = element.scrollLeft;
       const { lastX } = state;
       if (currentX !== lastX) {
         state.right = currentX > lastX;
       }
       state.lastX = currentX;
-      const currentY = viewAreaElement.scrollTop;
+      const currentY = element.scrollTop;
       const { lastY } = state;
       if (currentY !== lastY) {
         state.down = currentY > lastY;
@@ -44,9 +45,9 @@ export const watchScroll = (
     });
   };
 
-  fromEvent(viewAreaElement, 'scroll').pipe(
-    auditTime(300),
+  fromEvent(element, 'scroll').pipe(
     throttleTime(200),
+    auditTime(300),
   ).subscribe(debounceScroll);
 
   return state;
@@ -93,3 +94,20 @@ export const scaleCheck = (scale: number): number => {
   }
   return 2.5;
 };
+
+export const hexToRgb = (hex: string): any => {
+  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+  return result ? {
+    r: parseInt(result[1], 16),
+    g: parseInt(result[2], 16),
+    b: parseInt(result[3], 16),
+  } : null;
+};
+
+export const chunk = (arr: any[], chunkSize: number): any[] => {
+  const R = [];
+  for (let i = 0, len = arr.length; i < len; i += chunkSize) {
+    R.push(arr.slice(i, i + chunkSize));
+  }
+  return R;
+};

+ 3 - 4
pages/_app.js

@@ -1,11 +1,10 @@
 import App from 'next/app';
 import React from 'react';
 import { SnackbarProvider } from 'notistack';
-import loadable from '@loadable/component';
-import { i18n, appWithTranslation } from '../i18n';
+import { appWithTranslation } from '../i18n';
 import { StoreProvider } from '../store';
 
-const GlobalStyle = loadable(() => import('../global/styled'));
+import { GlobalStyle } from '../global/styled';
 
 class MainApp extends App {
   static async getInitialProps({ Component, ctx }) {
@@ -27,7 +26,7 @@ class MainApp extends App {
     return (
       <StoreProvider>
         <SnackbarProvider maxSnack={3}>
-          <GlobalStyle lang={i18n.language} />
+          <GlobalStyle lang="en" />
           <Component {...pageProps} />
         </SnackbarProvider>
       </StoreProvider>

+ 1 - 2
pages/_document.js

@@ -1,7 +1,6 @@
 import React from 'react';
 import Document, { Head, Main, NextScript } from 'next/document';
 import { ServerStyleSheet } from 'styled-components';
-import { i18n } from '../i18n';
 
 class MyDocument extends Document {
   static getInitialProps({ renderPage }) {
@@ -13,7 +12,7 @@ class MyDocument extends Document {
 
   render() {
     return (
-      <html lang={i18n.language}>
+      <html lang="en">
         <Head>
           <link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" />
           {this.props.styleTags}

+ 3 - 1
pages/index.tsx

@@ -6,14 +6,16 @@ import Sidebar from '../containers/Sidebar';
 import Toolbar from '../containers/Toolbar';
 import Placeholder from '../containers/Placeholder';
 import PdfViewer from '../containers/PdfViewer';
+import AutoSave from '../containers/AutoSave';
 
 const index: NextPage = () => (
   <>
-    <Sidebar />
     <Navbar />
+    <Sidebar />
     <Toolbar />
     <Placeholder />
     <PdfViewer />
+    <AutoSave />
   </>
 );