react-pdf-annotations
Version:
Set of React components for PDF annotation
877 lines (748 loc) • 25.8 kB
JavaScript
// @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;