UNPKG

react-pdf-annotations

Version:
877 lines (748 loc) 25.8 kB
// @flow import React, { PureComponent } from "react"; import ReactDom from "react-dom"; import Pointable from "react-pointable"; import isEqual from "lodash.isequal"; import debounce from "lodash.debounce"; // import { EventBus, PDFViewer, PDFLinkService } from "pdfjs-dist/web/pdf_viewer"; //$FlowFixMe import "pdfjs-dist/web/pdf_viewer.css"; import "../style/pdf_viewer.css"; import "../style/PdfHighlighter.css"; import getBoundingRect from "../lib/get-bounding-rect"; import getClientRects from "../lib/get-client-rects"; import getAreaAsPng from "../lib/get-area-as-png"; import { asElement, getPageFromRange, getPageFromElement, getWindow, findOrCreateContainerLayer, isHTMLElement } from "../lib/pdfjs-dom"; import TipContainer from "./TipContainer"; import MouseSelection from "./MouseSelection"; import { scaledToViewport, viewportToScaled } from "../lib/coordinates"; import type { T_Position, T_ScaledPosition, T_Highlight, T_Scaled, T_LTWH, T_EventBus, T_PDFJS_Viewer, T_PDFJS_Document, T_PDFJS_LinkService } from "../types"; import getClientRectsNew from '../lib/get-client-rects-new'; import getClientRectsNew1 from '../lib/get-client-rects-new1'; type T_ViewportHighlight<T_HT> = { position: T_Position } & T_HT; type State<T_HT> = { ghostHighlight: ?{ position: T_ScaledPosition }, isCollapsed: boolean, range: ?Range, tip: ?{ highlight: T_ViewportHighlight<T_HT>, callback: (highlight: T_ViewportHighlight<T_HT>) => React$Element<*> }, tipPosition: T_Position | null, tipChildren: ?React$Element<*> | null, isAreaSelectionInProgress: boolean, scrolledToHighlightId: string, pdfDocument: T_PDFJS_Document, }; type Props<T_HT> = { url: string, workerUrl: string, beforeLoad: React$Element<*>, selectionTransform: ( definition: T_ViewportHighlight<T_HT>, index: number ) => React$Element<*>, preSelectionTransform: ( definition: T_ViewportHighlight<T_HT>, index: number ) => React$Element<*>, definitionTransform: ( definition: T_ViewportHighlight<T_HT>, index: number ) => React$Element<*>, noteTransform: ( definition: T_ViewportHighlight<T_HT>, index: number ) => React$Element<*>, labelTransform: ( highlight: T_ViewportHighlight<T_HT>, index: number ) => React$Element<*>, highlightTransform: ( highlight: T_ViewportHighlight<T_HT>, index: number, isScrolledTo: boolean ) => React$Element<*>, highlights: Array<T_HT>, definitions: Array<T_HT>, preSelections: Array<T_HT>, notes: Array<T_HT>, onScrollChange: () => void, scrollRef: (scrollTo: (highlight: T_Highlight) => void) => void, pdfScaleValue: string, withoutSelection: boolean, onSelectionFinished: ( position: T_ScaledPosition, content: { text?: string, image?: string }, hideTipAndSelection: () => void, transformSelection: () => void ) => ?React$Element<*>, enableAreaSelection: (event: MouseEvent) => boolean, onInit: (pdfHighlighter: PdfHighlighter) => void }; const EMPTY_ID = "empty-id"; class PdfHighlighter<T_HT: T_Highlight> extends PureComponent< Props<T_HT>, State<T_HT> > { static defaultProps = { pdfScaleValue: "auto", withoutSelection: false, labelTransform: () => null, definitionTransform: () => null, noteTransform: () => null, selectionTransform: () => null, onInit: () => null, definitions: [], notes: [], }; state: State<T_HT> = { pdfDocument: null, ghostHighlight: null, selection: null, isCollapsed: true, range: null, scrolledToHighlightId: EMPTY_ID, isAreaSelectionInProgress: false, tip: null, tipPosition: null, tipChildren: null }; // eventBus: T_EventBus = new EventBus(); // linkService: T_PDFJS_LinkService = new PDFLinkService({ // eventBus: this.eventBus, // externalLinkTarget: 2 // }); viewer: T_PDFJS_Viewer; resizeObserver = null; containerNode: ?HTMLDivElement = null; unsubscribe = () => {}; constructor(props: Props<T_HT>) { super(props); if (typeof ResizeObserver !== "undefined") { this.resizeObserver = new ResizeObserver(this.debouncedScaleValue); } } componentDidMount() { // this.init(); } attachRef = (ref: ?HTMLDivElement) => { this.containerNode = ref; this.init(); }; afterInit = () => { const { eventBus, resizeObserver: observer } = this; const { ownerDocument: doc } = this.containerNode; this.unsubscribe(); console.log('PdfHighlighter, ', 'afterInit --- ', this.containerNode); if (this.containerNode) { // const { ownerDocument: doc } = ref; eventBus.on("textlayerrendered", this.onTextLayerRendered); eventBus.on("pagesinit", this.onDocumentReady); doc.addEventListener("click", this.onSelectionChange); doc.addEventListener("keydown", this.handleKeyDown); doc.defaultView.addEventListener("resize", this.debouncedScaleValue); if (observer) observer.observe(this.containerNode); this.unsubscribe = () => { eventBus.off("pagesinit", this.onDocumentReady); eventBus.off("textlayerrendered", this.onTextLayerRendered); doc.removeEventListener("click", this.onSelectionChange); doc.removeEventListener("keydown", this.handleKeyDown); doc.defaultView.removeEventListener("resize", this.debouncedScaleValue); if (observer) observer.disconnect(); }; } }; componentDidUpdate(prevProps: Props<T_HT>) { if (this.props.url !== prevProps.url) { this.init(); } // if (prevProps.pdfDocument !== this.props.pdfDocument) { // this.init(); // return; // } if (this.state.pdfDocument && prevProps.highlights !== this.props.highlights) { this.renderHighlights(this.props); } if (this.state.pdfDocument && prevProps.definitions !== this.props.definitions) { this.renderDefinitions(this.props); } if (this.state.pdfDocument && prevProps.notes !== this.props.notes) { this.renderNotes(this.props); } if (this.state.pdfDocument && prevProps.preSelections !== this.props.preSelections) { this.renderPreSelections(this.props); } if (this.state.pdfDocument && prevProps.pdfScaleValue !== this.props.pdfScaleValue) { this.handleScaleValue(); } if (this.state.pdfDocument && !isEqual(prevProps.highlightTransform, this.props.highlightTransform)) { this.onTextLayerRendered(); } } componentDidCatch(error: Error, info?: any) { const { onError } = this.props; if (onError) { onError(error); } this.setState({ pdfDocument: null, error }); } init() { const { url, workerUrl, cMapUrl, cMapPacked, enableXfa = false, headers: httpHeaders = {} } = this.props; const { pdfDocument } = this.state; this.setState({ pdfDocument: null, error: null }); Promise.resolve() .then(() => pdfDocument && pdfDocument.destroy()) .then( async () => { if (url) { const pdfjsViewer = await import('pdfjs-dist/web/pdf_viewer'); this.eventBus = new pdfjsViewer.EventBus(); this.linkService = new pdfjsViewer.PDFLinkService({ eventBus: this.eventBus, externalLinkTarget: 2 }); this.viewer = new pdfjsViewer.PDFViewer({ container: this.containerNode, eventBus: this.eventBus, linkService: this.linkService, enhanceTextSelection: true, removePageBorders: true, textLayerMode: 2, }); console.log('viewer', this.viewer); const pdfJS = await import('pdfjs-dist/build/pdf'); pdfJS.GlobalWorkerOptions.workerSrc = workerUrl; await pdfJS.getDocument({ url, cMapUrl, cMapPacked, enableXfa, httpHeaders, withCredentials: true }).promise .then((pdfDocument) => { // this.pdfDocument = pdfDocument; this.linkService.setDocument(pdfDocument); this.linkService.setViewer(this.viewer); this.viewer.setDocument(pdfDocument); this.props.onInit(this); this.setState({ pdfDocument }, () => { this.afterInit(); }); }) } // getDocument({ url, ownerDocument, cMapUrl, cMapPacked, httpHeaders, withCredentials: true }).promise.then( // pdfDocument => { // this.setState({ pdfDocument }); // } // ) } ) .catch(e => this.componentDidCatch(e)); } // init() { // const { pdfDocument } = this.props; // // this.viewer = // this.viewer || // new PDFViewer({ // container: this.containerNode, // eventBus: this.eventBus, // enhanceTextSelection: true, // removePageBorders: true, // linkService: this.linkService, // textLayerMode: 2, // // useOnlyCssZoom: true, // }); // // this.linkService.setDocument(pdfDocument); // this.linkService.setViewer(this.viewer); // this.viewer.setDocument(pdfDocument); // this.props.onInit(this); // // // debug // window.PdfViewer = this; // } componentWillUnmount() { this.unsubscribe(); } findOrCreateHighlightLayer(page: number) { const { textLayer } = this.viewer.getPageView(page - 1) || {}; if (!textLayer) { return null; } return findOrCreateContainerLayer( textLayer.textLayerDiv, "PdfHighlighter__highlight-layer" ); } findOrCreateLabelLayer(page: number) { const { textLayer } = this.viewer.getPageView(page - 1) || {}; if (!textLayer) { return null; } return findOrCreateContainerLayer( textLayer.textLayerDiv.parentNode, "PdfHighlighter__labels-layer" ); } findOrCreateDefinitionLayer(page: number) { const { textLayer } = this.viewer.getPageView(page - 1) || {}; if (!textLayer) { return null; } return findOrCreateContainerLayer( textLayer.textLayerDiv.parentNode, "PdfHighlighter__definitions-layer" ); } findOrCreateNotesLayer(page: number) { const { textLayer } = this.viewer.getPageView(page - 1) || {}; if (!textLayer) { return null; } return findOrCreateContainerLayer( textLayer.textLayerDiv.parentNode, "PdfHighlighter__notes-layer" ); } findOrCreateSelectionLayer(page: number) { const { textLayer } = this.viewer.getPageView(page - 1) || {}; if (!textLayer) { return null; } return findOrCreateContainerLayer( textLayer.textLayerDiv.parentNode, "PdfHighlighter__selections-layer" ); } findOrCreatePreSelectionLayer(page: number) { const { textLayer } = this.viewer.getPageView(page - 1) || {}; if (!textLayer) { return null; } return findOrCreateContainerLayer( textLayer.textLayerDiv.parentNode, "PdfHighlighter__pre-selections-layer" ); } groupHighlightsByPage(highlights: Array<T_HT>): { [pageNumber: string]: Array<T_HT> } { return highlights .filter(Boolean) .reduce((res, highlight) => { highlight.pages.forEach(page => { res[page] = res[page] || []; res[page].push(highlight); }); return res; }, {}); } scaledPositionToViewport = ({ pageNumber, boundingRect, rects, usePdfCoordinates }: T_ScaledPosition): T_Position => { const viewport = this.viewer.getPageView(pageNumber - 1).viewport; return { boundingRect: scaledToViewport(boundingRect, viewport, usePdfCoordinates), rects: (rects || []).map(rect => scaledToViewport(rect, viewport, usePdfCoordinates) ), pageNumber }; }; viewportPositionToScaled({ pageNumber, boundingRect, rects }: T_Position): T_ScaledPosition { const viewport = this.viewer.getPageView(pageNumber - 1).viewport; return { boundingRect: viewportToScaled(boundingRect, viewport), rects: (rects || []).map(rect => viewportToScaled(rect, viewport)), pageNumber }; } viewportRectsToScaled = (rects: T_Position, pageNumber) => { const viewport = this.viewer.getPageView(pageNumber).viewport; return (rects || []).map(i => { const points = [i.points[0], i.points[1], i.points[0] + i.points[2], i.points[1] + i.points[3]]; const bounds = viewport.convertToViewportRectangle(points); return { left: Math.min(bounds[0], bounds[2]), top: Math.min(bounds[1], bounds[3]) - (i.isUnderline ? 0 : 2.5), width: Math.abs(bounds[0] - bounds[2]), height: Math.abs(bounds[1] - bounds[3]) + (i.isUnderline ? 0 : 4.5), page: i.page, }; }); // return (rects || []).map(rect => scaledToViewport(rect, viewport)); } viewportSelectionRectsToScaled(rects : T_Position) { return [ viewportToScaled(rects[0], this.viewer.getPageView(rects[0].page - 1).viewport), viewportToScaled(rects[1], this.viewer.getPageView(rects[1].page - 1).viewport) ]; } // screenshot(position: T_LTWH, pageNumber: number) { // const canvas = this.viewer.getPageView(pageNumber - 1).canvas; // // return getAreaAsPng(canvas, position); // } renderHighlights(nextProps?: Props<T_HT>) { const { highlightTransform, labelTransform, highlights } = nextProps || this.props; const { pdfDocument } = this.state; const { scrolledToHighlightId } = this.state; const highlightsByPage = this.groupHighlightsByPage(highlights); console.log('renderHighlights, ', pdfDocument.numPages); for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber++) { const highlightLayer = this.findOrCreateHighlightLayer(pageNumber); const labelLayer = this.findOrCreateLabelLayer(pageNumber); console.log('renderHighlights1, ', highlightLayer, labelLayer, highlightsByPage); if (highlightLayer) { ReactDom.render( <div> {(highlightsByPage[String(pageNumber)] || []).map( ({ rects, id, ...highlight }, index) => { const viewportHighlight: T_ViewportHighlight<T_HT> = { id, rects: this.viewportRectsToScaled(rects, pageNumber - 1), actualPage: pageNumber - 1, ...highlight }; const isScrolledTo = Boolean(scrolledToHighlightId === id); return highlightTransform( viewportHighlight, index, isScrolledTo ); } )} </div>, highlightLayer ); } if (labelLayer) { ReactDom.render( <div> {(highlightsByPage[String(pageNumber)] || []).map( ({ rects, id, ...highlight }, index) => { const viewportHighlight: T_ViewportHighlight<T_HT> = { id, rects: this.viewportRectsToScaled(rects, pageNumber - 1), actualPage: pageNumber - 1, ...highlight }; return labelTransform(viewportHighlight, index); } )} </div>, labelLayer ); } } } renderDefinitions(nextProps?: Props<T_HT>) { const { definitionTransform, definitions } = nextProps || this.props; const { pdfDocument } = this.props; const definitionsByPage = this.groupHighlightsByPage(definitions); for (let pageNumber = 1; pageNumber <= pdfDocument ? pdfDocument.numPages : 0; pageNumber++) { const definitionLayer = this.findOrCreateDefinitionLayer(pageNumber); if (definitionLayer) { ReactDom.render( <div> {(definitionsByPage[String(pageNumber)] || []).map( ({ rects, id, ...highlight }, index) => { const viewportHighlight: T_ViewportHighlight<T_HT> = { id, rects: this.viewportRectsToScaled(rects, pageNumber - 1), actualPage: pageNumber - 1, ...highlight }; return definitionTransform(viewportHighlight, index); } )} </div>, definitionLayer ); } } } renderNotes(nextProps?: Props<T_HT>) { const { noteTransform, notes } = nextProps || this.props; const { pdfDocument } = this.state; const notesByPage = this.groupHighlightsByPage(notes); for (let pageNumber = 1; pageNumber <= pdfDocument ? pdfDocument.numPages : 0; pageNumber++) { const notesLayer = this.findOrCreateNotesLayer(pageNumber); if (notesLayer) { ReactDom.render( <div> {(notesByPage[String(pageNumber)] || []).map( ({ rects, id, ...highlight }, index) => { const viewportHighlight: T_ViewportHighlight<T_HT> = { id, rects: this.viewportRectsToScaled(rects, pageNumber - 1), actualPage: pageNumber - 1, ...highlight }; return noteTransform(viewportHighlight, index); } )} </div>, notesLayer ); } } } renderSelections(nextProps?: Props<T_HT>, range, page, rects) { const { selectionTransform } = nextProps || this.props; const selectionLayer = this.findOrCreateSelectionLayer(page); if (selectionLayer) { const sTransform = range ? selectionTransform({ range, page, rects }) : []; ReactDom.render( <div> {sTransform} </div>, selectionLayer ); } } renderPreSelections(nextProps?: Props<T_HT>) { const { preSelectionTransform, preSelections } = nextProps || this.props; const { pdfDocument } = this.state; const preSelectionsByPage = this.groupHighlightsByPage(preSelections); for (let pageNumber = 1; pageNumber <= pdfDocument ? pdfDocument.numPages : 0; pageNumber++) { const preSelectionLayer = this.findOrCreatePreSelectionLayer(pageNumber); if (preSelectionLayer) { ReactDom.render( <div> {(preSelectionsByPage[String(pageNumber)] || []).map( ({ rects, id, ...highlight }, index) => { const viewportHighlight: T_ViewportHighlight<T_HT> = { id, rects: this.viewportRectsToScaled(rects, pageNumber - 1), actualPage: pageNumber - 1, ...highlight }; return preSelectionTransform(viewportHighlight, index); } )} </div>, preSelectionLayer ); } } } clearSelection = (range, startPage) => { // this.setState({ selection: null }, this.renderSelections); this.renderSelections(this.props, range, startPage); }; hideTipAndSelection = () => { this.setState({ tipPosition: null, tipChildren: null }); this.setState({ ghostHighlight: null, tip: null }, () => this.renderHighlights() ); }; onTextLayerRendered = () => { this.renderNotes(); this.renderDefinitions(); this.renderHighlights(); this.renderPreSelections(); }; scrollTo = (highlight: T_Highlight) => { const { pages, rects } = highlight; const pageNumber = pages[0]; this.viewer.container.removeEventListener("scroll", this.onScroll); this.viewer.scrollPageIntoView({ pageNumber, destArray: [ null, { name: "XYZ" }, rects[0].points[0], rects[0].points[1], 0 ] }); this.setState( { scrolledToHighlightId: highlight.id }, () => this.renderHighlights() ); // wait for scrolling to finish setTimeout(() => { this.viewer.container.addEventListener("scroll", this.onScroll); }, 100); }; onDocumentReady = () => { const { scrollRef } = this.props; this.handleScaleValue(); scrollRef(this.scrollTo); }; onSelectionChange = () => { const container = this.containerNode; const selection: Selection = getWindow(container).getSelection(); const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null; if (selection && selection.baseNode && selection.baseNode.parentNode && ( selection.baseNode.parentNode.className.includes("definition") || selection.baseNode.parentNode.className.includes("body") || selection.baseNode.parentNode.className.includes("body-title") || selection.baseNode.parentNode.className.includes("body-description") || selection.baseNode.parentNode.className.includes("popover") )) { return null; } if (selection.isCollapsed) { this.setState({ isCollapsed: true }); return; } if ( !range || !container || !container.contains(range.commonAncestorContainer) ) { return; } this.setState({ isCollapsed: false, range }); this.afterSelection(); // this.debouncedAfterSelection(); }; onScroll = () => { const { onScrollChange } = this.props; onScrollChange(); this.setState( { scrolledToHighlightId: EMPTY_ID }, () => this.renderHighlights() ); this.viewer.container.removeEventListener("scroll", this.onScroll); }; // onMouseDown = (event: MouseEvent) => { // if (!isHTMLElement(event.target)) { // return; // } // // if (asElement(event.target).closest(".PdfHighlighter__tip-container")) { // return; // } // // this.hideTipAndSelection(); // }; handleKeyDown = (event: KeyboardEvent) => { if (event.code === "Escape") { this.hideTipAndSelection(); } }; afterSelection = () => { const { onSelectionFinished, withoutSelection } = this.props; const { isCollapsed, range } = this.state; if (!range || isCollapsed) { return; } const [startPage, endPage] = getPageFromRange(range); if (!startPage || !endPage) { return; } const rects = getClientRectsNew(range, [startPage, endPage], this); const rectsPdf = getClientRectsNew1(range, [startPage, endPage], this); const scaledRects = this.viewportSelectionRectsToScaled(rects); // // const p1_1 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(rects[0].top, rects[0].left); // const p1_2 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(rects[0].top + rects[0].height, rects[0].left + rects[0].width); // const p2_1 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(rects[1].top, rects[1].left); // const p2_2 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(rects[0].top + rects[0].height, rects[1].left + rects[1].width); // const w1 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(p1_2[0] - p1_1[0], 0); // const h1 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(p1_2[1] - p1_1[1], 0); // const w2 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(p2_2[0] - p2_1[0], 0); // const h2 = this.viewer.getPageView(rects[0].page - 1).viewport.convertToPdfPoint(p2_2[1] - p2_1[1], 0); // const rectsPdf = [ // { // x1: +p1_1[0].toFixed(2), // y1: +p1_1[1].toFixed(2), // x2: +p1_2[0].toFixed(2), // y2: +p1_2[1].toFixed(2), // w: +w1[1].toFixed(2), // h: +h1[1].toFixed(2), // page: rects[0].page // }, // { // x1: +p2_1[0].toFixed(2), // y1: +p2_1[1].toFixed(2), // x2: +p2_2[0].toFixed(2), // y2: +p2_2[1].toFixed(2), // w: +w2[1].toFixed(2), // h: +h2[1].toFixed(2), // page: rects[1].page // } // ]; // const rects = getClientRects(range, fromPage, true); // const rects = getClientRectsNew(range, page, true, this); // // if (rects.length === 0) { // return; // } // // const boundingRect = getBoundingRect(rects); // const viewportPosition = { boundingRect, rects, pageNumber: page.number }; // // console.log("test, ", "afterSelection --- ", boundingRect, viewportPosition); // // const content = { // text: range.toString() // }; // const scaledPosition = this.viewportPositionToScaled(viewportPosition); // // if (!withoutSelection) { // this.setState({ selection: { position: scaledPosition } }, this.renderSelections); // window.getSelection().removeAllRanges(); // } onSelectionFinished({ range, startPage, endPage, rects: scaledRects, rectsPdf }); this.renderSelections(this.props, range, startPage.number, scaledRects); }; debouncedAfterSelection: () => void = debounce(this.afterSelection, 500); toggleTextSelection(flag: boolean) { this.viewer.viewer.classList.toggle( "PdfHighlighter--disable-selection", flag ); } handleScaleValue = () => { if (this.viewer && this.props.pdfScaleValue) { this.viewer.currentScaleValue = this.props.pdfScaleValue; //"page-width"; } }; debouncedScaleValue: () => void = debounce(this.handleScaleValue, 500); render() { return ( <div ref={this.attachRef} className="PdfHighlighter" onContextMenu={e => e.preventDefault()} > <div className="pdfViewer" /> </div> ); } } export default PdfHighlighter;