react-pdf-highlighter
Version:
Set of React components for PDF annotation
504 lines (503 loc) • 17.7 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { jsx, jsxs } from "react/jsx-runtime";
import React, { PureComponent } from "react";
import { createRoot } from "react-dom/client";
import { debounce as r } from "../../node_modules/ts-debounce/dist/src/index.esm.js";
import { scaledToViewport, viewportToScaled } from "../lib/coordinates.js";
import { getAreaAsPNG } from "../lib/get-area-as-png.js";
import { getBoundingRect } from "../lib/get-bounding-rect.js";
import { getClientRects } from "../lib/get-client-rects.js";
import { findOrCreateContainerLayer, getWindow, isHTMLElement, getPagesFromRange, getPageFromElement } from "../lib/pdfjs-dom.js";
import styles from "../style/PdfHighlighter.module.css.js";
import { HighlightLayer } from "./HighlightLayer.js";
import { MouseSelection } from "./MouseSelection.js";
import { TipContainer } from "./TipContainer.js";
const EMPTY_ID = "empty-id";
class PdfHighlighter extends PureComponent {
constructor(props) {
super(props);
__publicField(this, "state", {
ghostHighlight: null,
isCollapsed: true,
range: null,
scrolledToHighlightId: EMPTY_ID,
isAreaSelectionInProgress: false,
tip: null,
tipPosition: null,
tipChildren: null
});
__publicField(this, "viewer");
__publicField(this, "resizeObserver", null);
__publicField(this, "containerNode", null);
__publicField(this, "containerNodeRef");
__publicField(this, "highlightRoots", {});
__publicField(this, "unsubscribe", () => {
});
__publicField(this, "attachRef", (eventBus) => {
var _a;
const { resizeObserver: observer } = this;
this.containerNode = this.containerNodeRef.current;
this.unsubscribe();
if (this.containerNode) {
const { ownerDocument: doc } = this.containerNode;
eventBus.on("textlayerrendered", this.onTextLayerRendered);
eventBus.on("pagesinit", this.onDocumentReady);
doc.addEventListener("selectionchange", this.onSelectionChange);
doc.addEventListener("keydown", this.handleKeyDown);
(_a = doc.defaultView) == null ? void 0 : _a.addEventListener("resize", this.debouncedScaleValue);
if (observer) observer.observe(this.containerNode);
this.unsubscribe = () => {
var _a2;
eventBus.off("pagesinit", this.onDocumentReady);
eventBus.off("textlayerrendered", this.onTextLayerRendered);
doc.removeEventListener("selectionchange", this.onSelectionChange);
doc.removeEventListener("keydown", this.handleKeyDown);
(_a2 = doc.defaultView) == null ? void 0 : _a2.removeEventListener(
"resize",
this.debouncedScaleValue
);
if (observer) observer.disconnect();
};
}
});
__publicField(this, "hideTipAndSelection", () => {
this.setState({
tipPosition: null,
tipChildren: null
});
this.setState(
{ ghostHighlight: null, tip: null },
() => this.renderHighlightLayers()
);
});
__publicField(this, "renderTip", () => {
const { tipPosition, tipChildren } = this.state;
if (!tipPosition) return null;
const { boundingRect, pageNumber } = tipPosition;
const page = {
node: this.viewer.getPageView((boundingRect.pageNumber || pageNumber) - 1).div,
pageNumber: boundingRect.pageNumber || pageNumber
};
const pageBoundingClientRect = page.node.getBoundingClientRect();
const pageBoundingRect = {
bottom: pageBoundingClientRect.bottom,
height: pageBoundingClientRect.height,
left: pageBoundingClientRect.left,
right: pageBoundingClientRect.right,
top: pageBoundingClientRect.top,
width: pageBoundingClientRect.width,
x: pageBoundingClientRect.x,
y: pageBoundingClientRect.y,
pageNumber: page.pageNumber
};
return /* @__PURE__ */ jsx(
TipContainer,
{
scrollTop: this.viewer.container.scrollTop,
pageBoundingRect,
style: {
left: page.node.offsetLeft + boundingRect.left + boundingRect.width / 2,
top: boundingRect.top + page.node.offsetTop,
bottom: boundingRect.top + page.node.offsetTop + boundingRect.height
},
children: tipChildren
}
);
});
__publicField(this, "onTextLayerRendered", () => {
this.renderHighlightLayers();
});
__publicField(this, "scrollTo", (highlight) => {
const { pageNumber, boundingRect, usePdfCoordinates } = highlight.position;
this.viewer.container.removeEventListener("scroll", this.onScroll);
const pageViewport = this.viewer.getPageView(pageNumber - 1).viewport;
const scrollMargin = 10;
this.viewer.scrollPageIntoView({
pageNumber,
destArray: [
null,
{ name: "XYZ" },
...pageViewport.convertToPdfPoint(
0,
scaledToViewport(boundingRect, pageViewport, usePdfCoordinates).top - scrollMargin
),
0
]
});
this.setState(
{
scrolledToHighlightId: highlight.id
},
() => this.renderHighlightLayers()
);
setTimeout(() => {
this.viewer.container.addEventListener("scroll", this.onScroll);
}, 100);
});
__publicField(this, "onDocumentReady", () => {
const { scrollRef } = this.props;
this.handleScaleValue();
scrollRef(this.scrollTo);
});
__publicField(this, "onSelectionChange", () => {
const container = this.containerNode;
if (!container) {
return;
}
const selection = getWindow(container).getSelection();
if (!selection) {
return;
}
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
if (selection.isCollapsed) {
this.setState({ isCollapsed: true });
return;
}
if (!range || !container || !container.contains(range.commonAncestorContainer)) {
return;
}
this.setState({
isCollapsed: false,
range
});
this.debouncedAfterSelection();
});
__publicField(this, "onScroll", () => {
const { onScrollChange } = this.props;
onScrollChange();
this.setState(
{
scrolledToHighlightId: EMPTY_ID
},
() => this.renderHighlightLayers()
);
this.viewer.container.removeEventListener("scroll", this.onScroll);
});
__publicField(this, "onMouseDown", (event) => {
if (!(event.target instanceof Element) || !isHTMLElement(event.target)) {
return;
}
if (event.target.closest("#PdfHighlighter__tip-container")) {
return;
}
this.hideTipAndSelection();
});
__publicField(this, "handleKeyDown", (event) => {
if (event.code === "Escape") {
this.hideTipAndSelection();
}
});
__publicField(this, "afterSelection", () => {
const { onSelectionFinished } = this.props;
const { isCollapsed, range } = this.state;
if (!range || isCollapsed) {
return;
}
const pages = getPagesFromRange(range);
if (!pages || pages.length === 0) {
return;
}
const rects = getClientRects(range, pages);
if (rects.length === 0) {
return;
}
const boundingRect = getBoundingRect(rects);
const viewportPosition = {
boundingRect,
rects,
pageNumber: pages[0].number
};
const content = {
text: range.toString()
};
const scaledPosition = this.viewportPositionToScaled(viewportPosition);
this.setTip(
viewportPosition,
onSelectionFinished(
scaledPosition,
content,
() => this.hideTipAndSelection(),
() => this.setState(
{
ghostHighlight: { position: scaledPosition }
},
() => this.renderHighlightLayers()
)
)
);
});
__publicField(this, "debouncedAfterSelection", r(this.afterSelection, 500));
__publicField(this, "handleScaleValue", () => {
if (this.viewer) {
this.viewer.currentScaleValue = this.props.pdfScaleValue;
}
});
__publicField(this, "debouncedScaleValue", r(this.handleScaleValue, 500));
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(this.debouncedScaleValue);
}
this.containerNodeRef = React.createRef();
}
componentDidMount() {
this.init();
}
componentDidUpdate(prevProps) {
if (prevProps.pdfDocument !== this.props.pdfDocument) {
this.init();
return;
}
if (prevProps.highlights !== this.props.highlights) {
this.renderHighlightLayers();
}
}
async init() {
const { pdfDocument } = this.props;
const pdfjs = await import("../../node_modules/pdfjs-dist/web/pdf_viewer.js");
const eventBus = new pdfjs.EventBus();
const linkService = new pdfjs.PDFLinkService({
eventBus,
externalLinkTarget: 2
});
if (!this.containerNodeRef.current) {
throw new Error("!");
}
this.viewer = this.viewer || new pdfjs.PDFViewer({
container: this.containerNodeRef.current,
eventBus,
// enhanceTextSelection: true, // deprecated. https://github.com/mozilla/pdf.js/issues/9943#issuecomment-409369485
textLayerMode: 2,
removePageBorders: true,
linkService
});
linkService.setDocument(pdfDocument);
linkService.setViewer(this.viewer);
this.viewer.setDocument(pdfDocument);
this.attachRef(eventBus);
}
componentWillUnmount() {
this.unsubscribe();
}
findOrCreateHighlightLayer(page) {
const { textLayer } = this.viewer.getPageView(page - 1) || {};
if (!textLayer) {
return null;
}
return findOrCreateContainerLayer(
textLayer.div,
`PdfHighlighter__highlight-layer ${styles.highlightLayer}`,
".PdfHighlighter__highlight-layer"
);
}
groupHighlightsByPage(highlights) {
const { ghostHighlight } = this.state;
const allHighlights = [...highlights, ghostHighlight].filter(
Boolean
);
const pageNumbers = /* @__PURE__ */ new Set();
for (const highlight of allHighlights) {
pageNumbers.add(highlight.position.pageNumber);
for (const rect of highlight.position.rects) {
if (rect.pageNumber) {
pageNumbers.add(rect.pageNumber);
}
}
}
const groupedHighlights = {};
for (const pageNumber of pageNumbers) {
groupedHighlights[pageNumber] = groupedHighlights[pageNumber] || [];
for (const highlight of allHighlights) {
const pageSpecificHighlight = {
...highlight,
position: {
pageNumber,
boundingRect: highlight.position.boundingRect,
rects: [],
usePdfCoordinates: highlight.position.usePdfCoordinates
}
};
let anyRectsOnPage = false;
for (const rect of highlight.position.rects) {
if (pageNumber === (rect.pageNumber || highlight.position.pageNumber)) {
pageSpecificHighlight.position.rects.push(rect);
anyRectsOnPage = true;
}
}
if (anyRectsOnPage || pageNumber === highlight.position.pageNumber) {
groupedHighlights[pageNumber].push(pageSpecificHighlight);
}
}
}
return groupedHighlights;
}
showTip(highlight, content) {
const { isCollapsed, ghostHighlight, isAreaSelectionInProgress } = this.state;
const highlightInProgress = !isCollapsed || ghostHighlight;
if (highlightInProgress || isAreaSelectionInProgress) {
return;
}
this.setTip(highlight.position, content);
}
scaledPositionToViewport({
pageNumber,
boundingRect,
rects,
usePdfCoordinates
}) {
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
}) {
const viewport = this.viewer.getPageView(pageNumber - 1).viewport;
return {
boundingRect: viewportToScaled(boundingRect, viewport),
rects: (rects || []).map((rect) => viewportToScaled(rect, viewport)),
pageNumber
};
}
screenshot(position, pageNumber) {
const canvas = this.viewer.getPageView(pageNumber - 1).canvas;
return getAreaAsPNG(canvas, position);
}
setTip(position, inner) {
this.setState({
tipPosition: position,
tipChildren: inner
});
}
toggleTextSelection(flag) {
if (!this.viewer.viewer) {
return;
}
this.viewer.viewer.classList.toggle(styles.disableSelection, flag);
}
render() {
const { onSelectionFinished, enableAreaSelection } = this.props;
return /* @__PURE__ */ jsx("div", { onPointerDown: this.onMouseDown, children: /* @__PURE__ */ jsxs(
"div",
{
ref: this.containerNodeRef,
className: styles.container,
onContextMenu: (e) => e.preventDefault(),
children: [
/* @__PURE__ */ jsx("div", { className: "pdfViewer" }),
this.renderTip(),
typeof enableAreaSelection === "function" ? /* @__PURE__ */ jsx(
MouseSelection,
{
onDragStart: () => this.toggleTextSelection(true),
onDragEnd: () => this.toggleTextSelection(false),
onChange: (isVisible) => this.setState({ isAreaSelectionInProgress: isVisible }),
shouldStart: (event) => enableAreaSelection(event) && event.target instanceof Element && isHTMLElement(event.target) && Boolean(event.target.closest(".page")),
onSelection: (startTarget, boundingRect, resetSelection) => {
const page = getPageFromElement(startTarget);
if (!page) {
return;
}
const pageBoundingRect = {
...boundingRect,
top: boundingRect.top - page.node.offsetTop,
left: boundingRect.left - page.node.offsetLeft,
pageNumber: page.number
};
const viewportPosition = {
boundingRect: pageBoundingRect,
rects: [],
pageNumber: page.number
};
const scaledPosition = this.viewportPositionToScaled(viewportPosition);
const image = this.screenshot(
pageBoundingRect,
pageBoundingRect.pageNumber
);
this.setTip(
viewportPosition,
onSelectionFinished(
scaledPosition,
{ image },
() => this.hideTipAndSelection(),
() => {
console.log("setting ghost highlight", scaledPosition);
this.setState(
{
ghostHighlight: {
position: scaledPosition,
content: { image }
}
},
() => {
resetSelection();
this.renderHighlightLayers();
}
);
}
)
);
}
}
) : null
]
}
) });
}
renderHighlightLayers() {
const { pdfDocument } = this.props;
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber++) {
const highlightRoot = this.highlightRoots[pageNumber];
if (highlightRoot == null ? void 0 : highlightRoot.container.isConnected) {
this.renderHighlightLayer(highlightRoot.reactRoot, pageNumber);
} else {
const highlightLayer = this.findOrCreateHighlightLayer(pageNumber);
if (highlightLayer) {
const reactRoot = createRoot(highlightLayer);
this.highlightRoots[pageNumber] = {
reactRoot,
container: highlightLayer
};
this.renderHighlightLayer(reactRoot, pageNumber);
}
}
}
}
renderHighlightLayer(root, pageNumber) {
const { highlightTransform, highlights } = this.props;
const { tip, scrolledToHighlightId } = this.state;
root.render(
/* @__PURE__ */ jsx(
HighlightLayer,
{
highlightsByPage: this.groupHighlightsByPage(highlights),
pageNumber: pageNumber.toString(),
scrolledToHighlightId,
highlightTransform,
tip,
scaledPositionToViewport: this.scaledPositionToViewport.bind(this),
hideTipAndSelection: this.hideTipAndSelection.bind(this),
viewer: this.viewer,
screenshot: this.screenshot.bind(this),
showTip: this.showTip.bind(this),
setTip: (tip2) => {
this.setState({ tip: tip2 });
}
}
)
);
}
}
__publicField(PdfHighlighter, "defaultProps", {
pdfScaleValue: "auto"
});
export {
PdfHighlighter
};