@anaralabs/lector
Version:
Headless PDF viewer for React
1,696 lines (1,679 loc) • 142 kB
JavaScript
'use client';
import React, { createContext, forwardRef, useRef, useState, useCallback, useContext, useEffect, cloneElement, createRef, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { useFloating, autoUpdate, offset, shift, useDismiss, useInteractions, inline, flip } from '@floating-ui/react';
import { useVirtualizer, elementScroll, debounce } from '@tanstack/react-virtual';
import { create, useStore, createStore } from 'zustand';
import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
import { AnnotationLayer, TextLayer, getDocument } from 'pdfjs-dist';
import { useDebounce } from 'use-debounce';
import { v4 } from 'uuid';
import { Slot } from '@radix-ui/react-slot';
// src/components/annotation-tooltip.tsx
// src/lib/clamp.ts
var clamp = (value, min, max) => {
return Math.min(Math.max(value, min), max);
};
// src/lib/zoom.ts
var getFitWidthZoom = (containerWidth, viewports, zoomOptions) => {
const { minZoom, maxZoom } = zoomOptions;
const maxPageWidth = Math.max(...viewports.map((viewport) => viewport.width));
const targetZoom = containerWidth / maxPageWidth;
const clampedZoom = Math.min(Math.max(targetZoom, minZoom), maxZoom);
return clampedZoom;
};
var createZustandContext = (getStore) => {
const Context = React.createContext(null);
const Provider = (props) => {
const [store] = React.useState(() => getStore(props.initialValue));
return /* @__PURE__ */ jsx(Context.Provider, { value: store, children: props.children });
};
return {
useContext: () => React.useContext(Context),
Context,
Provider
};
};
// src/internal.ts
var PDFStore = createZustandContext(
(initialState) => {
return createStore((set, get) => ({
pdfDocumentProxy: initialState.pdfDocumentProxy,
zoom: initialState.zoom,
isZoomFitWidth: initialState.isZoomFitWidth ?? false,
zoomOptions: {
minZoom: initialState.zoomOptions?.minZoom ?? 0.5,
maxZoom: initialState.zoomOptions?.maxZoom ?? 10
},
viewportRef: createRef(),
viewports: initialState.viewports,
updateZoom: (zoom, isZoomFitWidth = false) => {
const { minZoom, maxZoom } = get().zoomOptions;
set((state) => {
if (typeof zoom === "function") {
const newZoom2 = clamp(zoom(state.zoom), minZoom, maxZoom);
return { zoom: newZoom2, isZoomFitWidth };
}
const newZoom = clamp(zoom, minZoom, maxZoom);
return { zoom: newZoom, isZoomFitWidth };
});
},
zoomFitWidth: () => {
const { viewportRef, zoomOptions, viewports } = get();
if (!viewportRef.current) return;
const clampedZoom = getFitWidthZoom(
viewportRef.current.clientWidth,
viewports,
zoomOptions
);
set({
zoom: clampedZoom,
isZoomFitWidth: true
});
return clampedZoom;
},
currentPage: 1,
setCurrentPage: (val) => {
set({
currentPage: val
});
},
isPinching: false,
setIsPinching: (val) => {
set({
isPinching: val
});
},
virtualizer: null,
setVirtualizer: (val) => {
set({
virtualizer: val
});
},
pageProxies: initialState.pageProxies,
getPdfPageProxy: (pageNumber) => {
const proxy = get().pageProxies[pageNumber - 1];
if (!proxy) throw new Error(`Page ${pageNumber} does not exist`);
return proxy;
},
textContent: [],
setTextContent: (val) => {
set({
textContent: val
});
},
highlights: [],
setHighlight: (val) => {
set({
highlights: val
});
},
customSelectionRects: [],
setCustomSelectionRects: (val) => {
set({
customSelectionRects: val
});
},
coloredHighlights: [],
addColoredHighlight: (value) => set((prevState) => ({
coloredHighlights: [...prevState.coloredHighlights, value]
})),
deleteColoredHighlight: (uuid) => set((prevState) => ({
coloredHighlights: prevState.coloredHighlights.filter(
(rect) => rect.uuid !== uuid
)
}))
}));
}
);
var usePdf = (selector) => useStore(PDFStore.useContext(), selector);
// src/hooks/useAnnotationTooltip.ts
var useAnnotationTooltip = ({
annotation,
onOpenChange,
position = "top",
isOpen: controlledIsOpen
}) => {
const isNewAnnotation = Date.now() - new Date(annotation.createdAt).getTime() < 1e3;
const [isPositionCalculated, setIsPositionCalculated] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const viewportRef = usePdf((state) => state.viewportRef);
const scale = usePdf((state) => state.zoom);
const effectiveIsOpen = (isOpen && isPositionCalculated || controlledIsOpen) ?? false;
const {
refs,
floatingStyles,
context
} = useFloating({
placement: position,
open: effectiveIsOpen,
onOpenChange: (open) => {
setIsOpen(open);
onOpenChange?.(open);
},
whileElementsMounted: autoUpdate,
middleware: [
inline(),
offset(10),
flip({
crossAxis: false,
fallbackAxisSideDirection: "end"
}),
shift({ padding: 8 })
]
});
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
const updateTooltipPosition = useCallback(() => {
if (!annotation.highlights.length) {
setIsPositionCalculated(false);
return;
}
const highlightRects = annotation.highlights;
let minLeft = Infinity;
let maxRight = -Infinity;
let minTop = Infinity;
let maxBottom = -Infinity;
const viewportElement = viewportRef.current;
if (!viewportElement) {
setIsPositionCalculated(false);
return;
}
const pageElement = viewportElement.querySelector(`[data-page-number="${annotation.pageNumber}"]`);
if (!pageElement) {
setIsPositionCalculated(false);
return;
}
refs.setReference({
getBoundingClientRect() {
const pageRect = pageElement.getBoundingClientRect();
highlightRects.forEach((highlight) => {
const scaledLeft = highlight.left * scale;
const scaledWidth = highlight.width * scale;
const scaledTop = highlight.top * scale;
const scaledHeight = highlight.height * scale;
const left = pageRect.left + scaledLeft;
const right = left + scaledWidth;
const top = pageRect.top + scaledTop;
const bottom = top + scaledHeight;
minLeft = Math.min(minLeft, left);
maxRight = Math.max(maxRight, right);
minTop = Math.min(minTop, top);
maxBottom = Math.max(maxBottom, bottom);
});
const width = maxRight - minLeft;
const height = maxBottom - minTop;
const centerX = minLeft + width / 2;
const centerY = minTop + height / 2;
const rect = {
width,
height,
x: centerX - width / 2,
y: centerY - height / 2,
top: centerY - height / 2,
right: centerX + width / 2,
bottom: centerY + height / 2,
left: centerX - width / 2
};
return rect;
},
getClientRects() {
return [this.getBoundingClientRect()];
},
contextElement: viewportRef.current || void 0
});
setIsPositionCalculated(true);
if (isNewAnnotation) {
setIsOpen(true);
}
}, [annotation.highlights, annotation.pageNumber, refs, viewportRef, scale, isNewAnnotation]);
useEffect(() => {
const viewport = viewportRef.current;
setIsPositionCalculated(false);
requestAnimationFrame(() => {
updateTooltipPosition();
});
const handleScroll = () => {
requestAnimationFrame(updateTooltipPosition);
};
const handleResize = () => {
requestAnimationFrame(updateTooltipPosition);
};
if (viewport) {
viewport.addEventListener("scroll", handleScroll, {
passive: true
});
}
window.addEventListener("resize", handleResize, { passive: true });
return () => {
if (viewport) {
viewport.removeEventListener("scroll", handleScroll);
}
window.removeEventListener("resize", handleResize);
};
}, [updateTooltipPosition, viewportRef, scale, controlledIsOpen]);
return {
isOpen: effectiveIsOpen,
setIsOpen,
refs,
floatingStyles,
getFloatingProps,
getReferenceProps
};
};
var AnnotationTooltip = ({
annotation,
children,
renderTooltipContent,
hoverTooltipContent,
onOpenChange,
className,
hoverClassName,
isOpen: controlledIsOpen,
tooltipBubbleSize,
hoverIsOpen: controlledHoverIsOpen
}) => {
const viewportRef = usePdf((state) => state.viewportRef);
const closeTimeoutRef = useRef(null);
const isMouseInTooltipRef = useRef(false);
const [triggeredPosition, setTriggeredPosition] = useState();
const {
isOpen: uncontrolledIsOpen,
setIsOpen,
refs,
floatingStyles,
getFloatingProps,
getReferenceProps
} = useAnnotationTooltip({
annotation,
onOpenChange,
position: triggeredPosition,
isOpen: controlledIsOpen
});
const {
isOpen: uncontrolledHoverIsOpen,
setIsOpen: setHoverIsOpen,
refs: hoverRefs,
floatingStyles: hoverFloatingStyles,
getFloatingProps: getHoverFloatingProps,
getReferenceProps: getHoverReferenceProps
} = useAnnotationTooltip({
position: "bottom",
annotation,
isOpen: controlledHoverIsOpen
});
const isOpen = controlledIsOpen ?? uncontrolledIsOpen;
const hoverIsOpen = controlledHoverIsOpen || uncontrolledHoverIsOpen;
const handleClick = useCallback(() => {
if (controlledIsOpen === void 0) {
setIsOpen(!isOpen);
}
}, [controlledIsOpen, isOpen, setIsOpen]);
const handleMouseEnter = useCallback(() => {
if (hoverTooltipContent) {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setHoverIsOpen(true);
}
}, [hoverTooltipContent, setHoverIsOpen]);
const closeTooltip = useCallback(() => {
if (!isMouseInTooltipRef.current) {
setHoverIsOpen(false);
}
}, [setHoverIsOpen]);
const handleMouseLeave = useCallback(() => {
if (!hoverTooltipContent) return;
closeTimeoutRef.current = setTimeout(closeTooltip, 100);
}, [hoverTooltipContent, closeTooltip]);
const handleTooltipMouseEnter = useCallback(() => {
isMouseInTooltipRef.current = true;
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
}, []);
const handleTooltipMouseLeave = useCallback(() => {
isMouseInTooltipRef.current = false;
setHoverIsOpen(false);
}, [setHoverIsOpen]);
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsxs(
"div",
{
ref: (node) => {
refs.setReference(node);
hoverRefs.setReference(node);
},
onClick: handleClick,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
...getReferenceProps(),
...getHoverReferenceProps(),
children: [
!isOpen && !hoverIsOpen && annotation.comment && /* @__PURE__ */ jsx("div", { className: "rounded-full", style: {
position: "absolute",
top: annotation.highlights[0]?.top ? annotation.highlights[0]?.top - tooltipBubbleSize / 2 : 0,
left: annotation.highlights[0]?.left ? annotation.highlights[0]?.left - tooltipBubbleSize / 2 : 0,
backgroundColor: annotation.borderColor,
height: `${tooltipBubbleSize}px`,
width: `${tooltipBubbleSize}px`,
zIndex: 50
} }),
children
]
}
),
isOpen && viewportRef.current && createPortal(
/* @__PURE__ */ jsx(
"div",
{
ref: refs.setFloating,
className,
"data-annotation-tooltip": "click",
style: {
...floatingStyles,
position: "absolute",
pointerEvents: "auto",
zIndex: 50
},
...getFloatingProps(),
children: renderTooltipContent({
annotation,
onClose: () => setIsOpen(false),
setPosition: (position) => setTriggeredPosition(position)
})
}
),
viewportRef.current
),
!isOpen && hoverIsOpen && annotation.comment && hoverTooltipContent && viewportRef.current && createPortal(
/* @__PURE__ */ jsx(
"div",
{
ref: hoverRefs.setFloating,
className: hoverClassName,
"data-annotation-tooltip": "hover",
style: {
...hoverFloatingStyles,
position: "absolute",
pointerEvents: "auto",
zIndex: 51
},
onMouseEnter: handleTooltipMouseEnter,
onMouseLeave: handleTooltipMouseLeave,
...getHoverFloatingProps(),
children: hoverTooltipContent
}
),
viewportRef.current
)
] });
};
var useAnnotations = create((set) => ({
annotations: [],
addAnnotation: (annotation) => set((state) => ({
annotations: [
...state.annotations,
annotation
]
})),
updateAnnotation: (id, updates) => set((state) => ({
annotations: state.annotations.map(
(annotation) => annotation.id === id ? {
...annotation,
...updates
} : annotation
)
})),
deleteAnnotation: (id) => set((state) => ({
annotations: state.annotations.filter((annotation) => annotation.id !== id)
})),
setAnnotations: (annotations) => set({ annotations })
}));
var PDFPageNumberContext = createContext(0);
var usePDFPageNumber = () => {
return useContext(PDFPageNumberContext);
};
var AnnotationHighlightLayer = ({
className,
style,
renderTooltipContent,
renderHoverTooltipContent,
tooltipClassName,
highlightClassName,
focusedAnnotationId,
focusedHoverAnnotationId,
onAnnotationClick,
hoverTooltipClassName,
tooltipBubbleSize = 6
}) => {
const { annotations } = useAnnotations();
const pageNumber = usePDFPageNumber();
const pageAnnotations = annotations.filter(
(annotation) => annotation.pageNumber === pageNumber
);
return /* @__PURE__ */ jsx("div", { className, style, children: pageAnnotations.map((annotation) => /* @__PURE__ */ jsx(
AnnotationTooltip,
{
annotation,
className: tooltipClassName,
hoverClassName: hoverTooltipClassName,
isOpen: focusedAnnotationId === annotation.id,
tooltipBubbleSize,
hoverIsOpen: focusedHoverAnnotationId === annotation.id,
onOpenChange: (open) => {
if (open && onAnnotationClick) {
onAnnotationClick(annotation);
}
},
renderTooltipContent,
hoverTooltipContent: renderHoverTooltipContent({
annotation,
onClose: () => {
}
}),
children: /* @__PURE__ */ jsx(
"div",
{
style: { cursor: "pointer" },
onClick: () => onAnnotationClick?.(annotation),
children: annotation.highlights.map((highlight, index) => /* @__PURE__ */ jsx(
"div",
{
className: highlightClassName,
style: {
position: "absolute",
top: highlight.top,
left: highlight.left,
width: highlight.width,
height: highlight.height,
backgroundColor: annotation.color
},
"data-highlight-id": annotation.id
},
index
))
}
)
},
annotation.id
)) });
};
// ../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
function r(e) {
var t, f, n = "";
if ("string" == typeof e || "number" == typeof e) n += e;
else if ("object" == typeof e) if (Array.isArray(e)) {
var o = e.length;
for (t = 0; t < o; t++) e[t] && (f = r(e[t])) && (n && (n += " "), n += f);
} else for (f in e) e[f] && (n && (n += " "), n += f);
return n;
}
function clsx() {
for (var e, t, f = 0, n = "", o = arguments.length; f < o; f++) (e = arguments[f]) && (t = r(e)) && (n && (n += " "), n += t);
return n;
}
var clsx_default = clsx;
// src/lib/cancellable.ts
var cancellable = (promise) => {
let isCancelled = false;
const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
(value) => {
if (!isCancelled) {
resolve(value);
}
},
(error) => {
if (!isCancelled) {
reject(error);
}
}
);
});
return {
promise: wrappedPromise,
cancel() {
isCancelled = true;
}
};
};
// src/hooks/pages/usePdfJump.tsx
var usePdfJump = () => {
const virtualizer = usePdf((state) => state.virtualizer);
const setHighlight = usePdf((state) => state.setHighlight);
const jumpToPage = (pageIndex, options) => {
if (!virtualizer) return;
const defaultOptions = {
align: "start",
behavior: "smooth"
};
const finalOptions = { ...defaultOptions, ...options };
virtualizer.scrollToIndex(pageIndex - 1, finalOptions);
};
const jumpToOffset = (offset3) => {
if (!virtualizer) return;
virtualizer.scrollToOffset(offset3, {
align: "start",
behavior: "smooth"
});
};
const scrollToHighlightRects = (rects, type, align = "start", additionalOffset = 0) => {
if (!virtualizer) return;
const firstPage = Math.min(...rects.map((rect) => rect.pageNumber));
const pageOffset = virtualizer.getOffsetForIndex(firstPage - 1, "start");
if (pageOffset === null) return;
const targetRect = rects.find((rect) => rect.pageNumber === firstPage);
if (!targetRect) return;
const isNumber = pageOffset?.[0] != null;
if (!isNumber) return;
const pageStart = pageOffset[0] ?? 0;
let rectTop;
let rectHeight;
if (type === "percent") {
const estimatePageHeight = virtualizer.options.estimateSize(
firstPage - 1
);
rectTop = targetRect.top / 100 * estimatePageHeight;
rectHeight = targetRect.height / 100 * estimatePageHeight;
} else {
rectTop = targetRect.top;
rectHeight = targetRect.height;
}
let scrollOffset;
if (align === "center") {
const viewportHeight = virtualizer.scrollElement?.clientHeight || 0;
const rectCenter = pageStart + rectTop + rectHeight / 2;
scrollOffset = rectCenter - viewportHeight / 2;
} else {
scrollOffset = pageStart + rectTop;
}
scrollOffset += additionalOffset;
const adjustedOffset = Math.max(0, scrollOffset);
virtualizer.scrollToOffset(adjustedOffset, {
align: "start",
// Always use start when we've calculated our own centering
behavior: "smooth"
});
};
const jumpToHighlightRects = (rects, type, align = "start", additionalOffset = 0) => {
if (!virtualizer) return;
setHighlight(rects);
scrollToHighlightRects(rects, type, align, additionalOffset);
};
return { jumpToPage, jumpToOffset, jumpToHighlightRects, scrollToHighlightRects };
};
var LinkService = class {
_pdfDocumentProxy;
externalLinkEnabled = true;
isInPresentationMode = false;
_currentPageNumber = 0;
_pageNavigationCallback;
get pdfDocumentProxy() {
if (!this._pdfDocumentProxy) {
throw new Error("pdfDocumentProxy is not set");
}
return this._pdfDocumentProxy;
}
constructor() {
}
get pagesCount() {
return this._pdfDocumentProxy?.numPages || 0;
}
get page() {
return this._currentPageNumber;
}
set page(value) {
this._currentPageNumber = value;
if (this._pageNavigationCallback) {
this._pageNavigationCallback(value);
}
}
// Required for link annotations to work
setDocument(pdfDocument) {
this._pdfDocumentProxy = pdfDocument;
}
setViewer() {
}
getDestinationHash(dest) {
if (!dest) return "";
const destRef = dest[0];
if (dest.length > 1 && typeof dest[1] === "object" && dest[1] !== null && "url" in dest[1]) {
const urlDest = dest[1];
return urlDest.url;
}
if (destRef && typeof destRef === "object") {
if ("num" in destRef) {
const numRef = destRef;
return `#page=${numRef.num + 1}`;
}
if ("gen" in destRef) {
const genRef = destRef;
const refNum = genRef.num ?? 0;
return `#page=${refNum + 1}`;
}
}
if (typeof destRef === "number") {
return `#page=${destRef + 1}`;
}
return `#dest-${String(dest)}`;
}
getAnchorUrl(hash) {
if (hash.startsWith("http://") || hash.startsWith("https://")) {
return hash;
}
return `#${hash}`;
}
addLinkAttributes(link, url, newWindow) {
if (!link) return;
const isExternalLink = url.startsWith("http://") || url.startsWith("https://");
if (isExternalLink && this.externalLinkEnabled) {
link.href = url;
link.target = newWindow === false ? "" : "_blank";
link.rel = "noopener noreferrer";
} else if (!isExternalLink) {
link.href = url;
link.target = "";
} else {
link.href = "#";
link.target = "";
}
}
async goToDestination(dest) {
let explicitDest;
if (typeof dest === "string") {
explicitDest = await this.pdfDocumentProxy.getDestination(dest);
} else if (Array.isArray(dest)) {
explicitDest = dest;
} else {
explicitDest = await dest;
}
if (!explicitDest) {
return;
}
if (explicitDest.length > 1 && typeof explicitDest[1] === "object" && explicitDest[1] !== null && "url" in explicitDest[1]) {
return;
}
const destRef = explicitDest[0];
let pageIndex;
if (destRef && typeof destRef === "object") {
if ("num" in destRef) {
try {
const refProxy = destRef;
pageIndex = await this.pdfDocumentProxy.getPageIndex(refProxy);
} catch (_error) {
return;
}
} else {
return;
}
} else if (typeof destRef === "number") {
pageIndex = destRef;
} else {
return;
}
const pageNumber = pageIndex + 1;
if (this._pageNavigationCallback) {
this._pageNavigationCallback(pageNumber);
}
}
executeNamedAction() {
}
navigateTo(dest) {
this.goToDestination(dest);
}
get rotation() {
return 0;
}
set rotation(value) {
}
goToPage(pageNumber) {
if (pageNumber >= 1 && pageNumber <= this.pagesCount) {
if (this._pageNavigationCallback) {
this._pageNavigationCallback(pageNumber);
}
}
}
setHash(hash) {
if (hash.startsWith("#page=")) {
const pageNumber = parseInt(hash.substring(6), 10);
if (!isNaN(pageNumber)) {
this.goToPage(pageNumber);
}
}
}
executeSetOCGState() {
}
// Method to register navigation callback
registerPageNavigationCallback(callback) {
this._pageNavigationCallback = callback;
}
// Method to unregister navigation callback
unregisterPageNavigationCallback() {
this._pageNavigationCallback = void 0;
}
};
var defaultLinkService = new LinkService();
var PDFLinkServiceContext = createContext(defaultLinkService);
var usePDFLinkService = () => {
return useContext(PDFLinkServiceContext);
};
var useVisibility = ({
elementRef
}) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!elementRef.current) {
return;
}
const observer = new IntersectionObserver(([entry]) => {
setVisible(entry?.isIntersecting ?? false);
});
observer.observe(elementRef.current);
return () => {
observer.disconnect();
};
}, [elementRef]);
return { visible };
};
// src/hooks/layers/useAnnotationLayer.tsx
var defaultAnnotationLayerParams = {
renderForms: true,
externalLinksEnabled: true,
jumpOptions: { behavior: "smooth", align: "start" }
};
var useAnnotationLayer = (params) => {
const mergedParams = { ...defaultAnnotationLayerParams, ...params };
const annotationLayerRef = useRef(null);
const annotationLayerObjectRef = useRef(null);
const linkService = usePDFLinkService();
const { visible } = useVisibility({
elementRef: annotationLayerRef
});
const pageNumber = usePDFPageNumber();
const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
useEffect(() => {
linkService.externalLinkEnabled = mergedParams.externalLinksEnabled;
}, [linkService, mergedParams.externalLinksEnabled]);
const { jumpToPage } = usePdfJump();
useEffect(() => {
if (!jumpToPage) return;
const handlePageNavigation = (pageNumber2) => {
jumpToPage(pageNumber2, mergedParams.jumpOptions);
};
linkService.registerPageNavigationCallback(handlePageNavigation);
return () => {
linkService.unregisterPageNavigationCallback();
};
}, [jumpToPage, linkService, mergedParams.jumpOptions]);
useEffect(() => {
const style = document.createElement("style");
style.textContent = `
.annotationLayer {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
opacity: 1;
z-index: 3;
}
.annotationLayer section {
position: absolute;
}
.annotationLayer .linkAnnotation > a,
.annotationLayer .buttonWidgetAnnotation.pushButton > a {
position: absolute;
font-size: 1em;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") 0 0 repeat;
cursor: pointer;
}
.annotationLayer .linkAnnotation > a:hover,
.annotationLayer .buttonWidgetAnnotation.pushButton > a:hover {
opacity: 0.2;
background: rgba(255, 255, 0, 1);
box-shadow: 0 2px 10px rgba(255, 255, 0, 1);
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
useEffect(() => {
if (!annotationLayerRef.current) return;
const element = annotationLayerRef.current;
const handleLinkClick = (e) => {
if (!e.target || !(e.target instanceof HTMLAnchorElement)) return;
const target = e.target;
const href = target.getAttribute("href") || "";
if (href.startsWith("#page=")) {
e.preventDefault();
const pageNumber2 = parseInt(href.substring(6), 10);
if (!isNaN(pageNumber2)) {
linkService.goToPage(pageNumber2);
}
}
};
element.addEventListener("click", handleLinkClick);
return () => {
element.removeEventListener("click", handleLinkClick);
};
}, [linkService]);
useEffect(() => {
if (!annotationLayerRef.current) {
return;
}
if (visible) {
annotationLayerRef.current.style.contentVisibility = "visible";
} else {
annotationLayerRef.current.style.contentVisibility = "hidden";
}
}, [visible]);
useEffect(() => {
if (!annotationLayerRef.current || !pdfPageProxy || !pdfDocumentProxy) {
return;
}
if (linkService._pdfDocumentProxy !== pdfDocumentProxy) {
linkService.setDocument(pdfDocumentProxy);
}
annotationLayerRef.current.innerHTML = "";
annotationLayerRef.current.className = "annotationLayer";
const viewport = pdfPageProxy.getViewport({ scale: 1 });
const annotationLayerConfig = {
div: annotationLayerRef.current,
viewport,
page: pdfPageProxy,
linkService,
accessibilityManager: void 0,
annotationCanvasMap: void 0,
annotationEditorUIManager: void 0,
structTreeLayer: void 0
};
const annotationLayer = new AnnotationLayer(annotationLayerConfig);
annotationLayerObjectRef.current = annotationLayer;
const { cancel } = cancellable(
(async () => {
try {
const annotations = await pdfPageProxy.getAnnotations();
await annotationLayer.render({
...annotationLayerConfig,
...mergedParams,
annotations,
linkService
});
} catch (_error) {
}
})()
);
return () => {
cancel();
};
}, [pdfPageProxy, pdfDocumentProxy, mergedParams, linkService]);
return {
annotationLayerRef
};
};
var AnnotationLayer2 = ({
renderForms = true,
externalLinksEnabled = true,
jumpOptions = { behavior: "smooth", align: "start" },
className,
style,
...props
}) => {
const { annotationLayerRef } = useAnnotationLayer({
renderForms,
externalLinksEnabled,
jumpOptions
});
return /* @__PURE__ */ jsx(
"div",
{
className: clsx_default("annotationLayer", className),
style: {
...style,
position: "absolute",
top: 0,
left: 0
},
...props,
ref: annotationLayerRef
}
);
};
var useDpr = () => {
const [dpr, setDPR] = useState(
!window ? 1 : Math.min(window.devicePixelRatio, 2)
);
useEffect(() => {
if (!window) {
return;
}
const handleDPRChange = () => {
setDPR(window.devicePixelRatio);
};
const windowMatch = window.matchMedia(
`screen and (min-resolution: ${dpr}dppx)`
);
windowMatch.addEventListener("change", handleDPRChange);
return () => {
windowMatch.removeEventListener("change", handleDPRChange);
};
}, []);
return dpr;
};
// src/hooks/layers/useCanvasLayer.tsx
var useCanvasLayer = ({ background }) => {
const canvasRef = useRef(null);
const pageNumber = usePDFPageNumber();
const dpr = useDpr();
const bouncyZoom = usePdf((state) => state.zoom);
const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
const [zoom] = useDebounce(bouncyZoom, 100);
useLayoutEffect(() => {
if (!canvasRef.current) {
return;
}
const viewport = pdfPageProxy.getViewport({ scale: 1 });
const canvas = canvasRef.current;
const scale = dpr * zoom;
canvas.height = viewport.height * scale;
canvas.width = viewport.width * scale;
canvas.style.height = `${viewport.height}px`;
canvas.style.width = `${viewport.width}px`;
const canvasContext = canvas.getContext("2d");
canvasContext.scale(scale, scale);
const renderingTask = pdfPageProxy.render({
canvasContext,
viewport,
background
});
renderingTask.promise.catch((error) => {
if (error.name === "RenderingCancelledException") {
return;
}
throw error;
});
return () => {
void renderingTask.cancel();
};
}, [pdfPageProxy, dpr, zoom]);
return {
canvasRef
};
};
var CanvasLayer = ({
style,
background,
...props
}) => {
const { canvasRef } = useCanvasLayer({ background });
return /* @__PURE__ */ jsx(
"canvas",
{
style: {
...style,
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%"
},
...props,
ref: canvasRef
}
);
};
// src/hooks/useSelectionDimensions.tsx
var MERGE_THRESHOLD = 5;
var shouldMergeRects = (rect1, rect2) => {
const verticalOverlap = !(rect1.top > rect2.top + rect2.height || rect2.top > rect1.top + rect1.height);
const horizontallyClose = Math.abs(rect1.left + rect1.width - rect2.left) < MERGE_THRESHOLD || Math.abs(rect2.left + rect2.width - rect1.left) < MERGE_THRESHOLD || rect1.left < rect2.left + rect2.width && rect2.left < rect1.left + rect1.width;
return verticalOverlap && horizontallyClose;
};
var mergeRects = (rect1, rect2) => {
return {
left: Math.min(rect1.left, rect2.left),
top: Math.min(rect1.top, rect2.top),
width: Math.max(rect1.left + rect1.width, rect2.left + rect2.width) - Math.min(rect1.left, rect2.left),
height: Math.max(rect1.top + rect1.height, rect2.top + rect2.height) - Math.min(rect1.top, rect2.top),
pageNumber: rect1.pageNumber
};
};
var consolidateRects = (rects) => {
if (rects.length <= 1) return rects;
const sortedRects = [...rects].sort((a, b) => a.top - b.top);
let hasChanges;
do {
hasChanges = false;
let currentRect = sortedRects[0];
const tempResult = [];
for (let i = 1; i < sortedRects.length; i++) {
const sorted = sortedRects[i];
if (!currentRect || !sorted) continue;
if (shouldMergeRects(currentRect, sorted)) {
currentRect = mergeRects(currentRect, sorted);
hasChanges = true;
} else {
tempResult.push(currentRect);
currentRect = sorted;
}
}
if (currentRect) tempResult.push(currentRect);
sortedRects.length = 0;
sortedRects.push(...tempResult);
} while (hasChanges);
return sortedRects;
};
var useSelectionDimensions = () => {
const store = PDFStore.useContext();
const getDimension = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const highlights = [];
const textLayerMap = /* @__PURE__ */ new Map();
const clientRects = Array.from(range.getClientRects()).filter(
(rect) => rect.width > 2 && rect.height > 2
);
clientRects.forEach((clientRect) => {
const element = document.elementFromPoint(
clientRect.left + 1,
clientRect.top + clientRect.height / 2
);
const textLayer = element?.closest(".textLayer");
if (!textLayer) return;
const pageNumber = parseInt(
textLayer.getAttribute("data-page-number") || "1",
10
);
const textLayerRect = textLayer.getBoundingClientRect();
const zoom = store.getState().zoom;
const rect = {
width: clientRect.width / zoom,
height: clientRect.height / zoom,
top: (clientRect.top - textLayerRect.top) / zoom,
left: (clientRect.left - textLayerRect.left) / zoom,
pageNumber
};
if (!textLayerMap.has(pageNumber)) {
textLayerMap.set(pageNumber, []);
}
textLayerMap.get(pageNumber)?.push(rect);
});
textLayerMap.forEach((rects) => {
if (rects.length > 0) {
highlights.push(...consolidateRects(rects));
}
});
return {
highlights: highlights.sort((a, b) => a.pageNumber - b.pageNumber),
text: range.toString().trim(),
isCollapsed: false
};
};
const getSelection = () => getDimension();
return { getDimension, getSelection };
};
var SelectionTooltip = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const lastSelectionRef = useRef(null);
const viewportRef = usePdf((state) => state.viewportRef);
const { refs, floatingStyles, context } = useFloating({
placement: "bottom",
open: isOpen,
onOpenChange: setIsOpen,
whileElementsMounted: autoUpdate,
middleware: [offset(10), shift({ padding: 8 })]
});
const dismiss = useDismiss(context);
const { getFloatingProps } = useInteractions([dismiss]);
const updateTooltipPosition = useCallback(() => {
const selection = document.getSelection();
if (!selection || selection.isCollapsed) {
setIsOpen(false);
lastSelectionRef.current = null;
return;
}
const range = selection.getRangeAt(0);
if (!range) return;
const rects = range.getClientRects();
const lastRect = rects[rects.length - 1];
lastSelectionRef.current = range;
if (lastRect) {
refs.setReference({
getBoundingClientRect: () => ({
width: lastRect.width,
height: lastRect.height,
x: lastRect.left,
y: lastRect.bottom,
// Position below the last line of selection
top: lastRect.bottom,
right: lastRect.right,
bottom: lastRect.bottom + lastRect.height,
left: lastRect.left
}),
getClientRects: () => [lastRect]
});
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [refs]);
useEffect(() => {
const handleSelectionChange = () => {
const selection = document.getSelection();
if (selection && viewportRef.current?.contains(selection.anchorNode)) {
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
const isInUnselectableArea = (node) => {
if (!node) return false;
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
while (element) {
if (element.getAttribute("data-annotation-tooltip")) {
return true;
}
if (element.hasAttribute("data-floating-ui-portal")) {
return true;
}
element = element.parentElement;
}
return false;
};
if (!isInUnselectableArea(anchorNode) && !isInUnselectableArea(focusNode)) {
requestAnimationFrame(updateTooltipPosition);
} else {
setIsOpen(false);
}
} else {
setIsOpen(false);
}
};
const handleScroll = () => {
if (!isOpen || !lastSelectionRef.current) return;
requestAnimationFrame(updateTooltipPosition);
};
document.addEventListener("selectionchange", handleSelectionChange);
if (viewportRef.current) {
viewportRef.current.addEventListener("scroll", handleScroll, {
passive: true
});
}
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
if (viewportRef.current) {
viewportRef.current.removeEventListener("scroll", handleScroll);
}
};
}, [refs, isOpen, viewportRef, updateTooltipPosition]);
useEffect(() => {
const handleFloatingClick = (e) => {
if (refs.floating.current?.contains(e.target)) {
e.stopPropagation();
}
};
document.addEventListener("click", handleFloatingClick);
return () => document.removeEventListener("click", handleFloatingClick);
}, [refs.floating]);
return /* @__PURE__ */ jsx(Fragment, { children: isOpen && /* @__PURE__ */ jsx(
"div",
{
ref: refs.setFloating,
style: {
...floatingStyles
},
...getFloatingProps(),
children
}
) });
};
var defaultColors = [
{
color: "#e3b127",
localization: {
id: "yellow",
defaultMessage: "Yellow"
}
},
{
color: "#419931",
localization: {
id: "green",
defaultMessage: "Green"
}
},
{
color: "#4286c9",
localization: {
id: "blue",
defaultMessage: "Blue"
}
},
{
color: "#f246b6",
localization: {
id: "pink",
defaultMessage: "Pink"
}
},
{
color: "#a53dd1",
localization: {
id: "purple",
defaultMessage: "Purple"
}
},
{
color: "#f09037",
localization: {
id: "orange",
defaultMessage: "Orange"
}
},
{
color: "#37f0d4",
localization: {
id: "teal",
defaultMessage: "Teal"
}
},
{
color: "#3d0ff5",
localization: {
id: "purple",
defaultMessage: "Purple"
}
},
{
color: "#f50f26",
localization: {
id: "red",
defaultMessage: "Red"
}
}
];
var ColorSelectionTool = ({
highlighterColors = defaultColors,
onColorSelection
}) => {
return /* @__PURE__ */ jsx(SelectionTooltip, { children: /* @__PURE__ */ jsx(
"div",
{
style: {
display: "flex",
gap: "0.5rem",
padding: "0.5rem",
backgroundColor: "#363636",
borderRadius: "0.5rem"
},
children: highlighterColors.map((colorItem, index) => /* @__PURE__ */ jsx(
"button",
{
onClick: () => onColorSelection(colorItem),
title: colorItem.localization.defaultMessage,
"aria-label": colorItem.localization.defaultMessage,
style: {
width: "1.25rem",
height: "1.25rem",
borderRadius: "0.25rem",
cursor: "pointer",
backgroundColor: colorItem.color
}
},
index
))
}
) });
};
// src/utils/selectionUtils.ts
var getEndOfHighlight = (selection) => {
const lastRectangle = selection.rectangles[selection.rectangles.length - 1];
return lastRectangle.left + lastRectangle.width + 10;
};
var getMidHeightOfHighlightLine = (selection) => {
const lastRectangle = selection.rectangles[selection.rectangles.length - 1];
return lastRectangle.top + lastRectangle.height / 2;
};
var ColoredHighlightComponent = ({
selection
}) => {
const deleteColoredHighlight = usePdf(
(state) => state.deleteColoredHighlight
);
const [showButton, setShowButton] = useState(false);
return /* @__PURE__ */ jsxs("div", { className: "colored-highlight", children: [
selection.rectangles.map((rect, index) => /* @__PURE__ */ jsx(
"span",
{
onClick: () => setShowButton(!showButton),
style: {
position: "absolute",
top: rect.top,
left: rect.left,
height: rect.height,
width: rect.width,
cursor: "pointer",
zIndex: 30,
backgroundColor: selection.color,
// mixBlendMode: "lighten", // changes the color of the text
mixBlendMode: "darken",
// best results
// mixBlendMode: "multiply", // works but coloring has some inconsistencies
borderRadius: "0.2rem"
}
},
`${selection.uuid}-${index}`
)),
showButton && /* @__PURE__ */ jsx(
"button",
{
style: {
backgroundColor: "white",
color: "white",
borderRadius: "5px",
padding: "5px",
cursor: "pointer",
boxShadow: "2px 2px 5px black",
position: "absolute",
top: getMidHeightOfHighlightLine(selection),
left: getEndOfHighlight(selection),
zIndex: 30,
transform: "translateY(-50%)"
},
onClick: () => deleteColoredHighlight(selection.uuid),
children: /* @__PURE__ */ jsx(
"svg",
{
fill: "#000000",
version: "1.1",
id: "Capa_1",
xmlns: "http://www.w3.org/2000/svg",
width: "15px",
height: "15px",
viewBox: "0 0 485 485",
children: /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsxs("g", { children: [
/* @__PURE__ */ jsx("rect", { x: "67.224", width: "350.535", height: "71.81" }),
/* @__PURE__ */ jsx(
"path",
{
d: "M417.776,92.829H67.237V485h350.537V92.829H417.776z M165.402,431.447h-28.362V146.383h28.362V431.447z M256.689,431.447\n h-28.363V146.383h28.363V431.447z M347.97,431.447h-28.361V146.383h28.361V431.447z"
}
)
] }) })
}
)
},
`${selection.uuid}-delete-button`
)
] });
};
var ColoredHighlightLayer = ({
onHighlight
}) => {
const pageNumber = usePDFPageNumber();
const { getDimension } = useSelectionDimensions();
const highlights = usePdf(
(state) => state.coloredHighlights
);
const addColoredHighlight = usePdf((state) => state.addColoredHighlight);
const handleHighlighting = useCallback((color) => {
const dimension = getDimension();
if (!dimension) return;
const { highlights: highlights2, text } = dimension;
if (highlights2[0]) {
const highlight = {
uuid: v4(),
pageNumber: highlights2[0].pageNumber,
// usePDFPageNumber() doesn't return the correct page number, so i'm getting the number directly from the first highlight
color,
rectangles: highlights2,
text
};
addColoredHighlight(highlight);
if (onHighlight) onHighlight(highlight);
}
}, []);
return /* @__PURE__ */ jsxs("div", { className: "colored-highlights-layer", children: [
highlights.filter((selection) => selection.pageNumber === pageNumber).map((selection) => /* @__PURE__ */ jsx(
ColoredHighlightComponent,
{
selection
},
selection.uuid
)),
/* @__PURE__ */ jsx(
ColorSelectionTool,
{
onColorSelection: (colorItem) => handleHighlighting(colorItem.color)
}
)
] });
};
// src/components/layers/custom-layer.tsx
var CustomLayer = ({
children
}) => {
const pageNumber = usePDFPageNumber();
return children(pageNumber);
};
var convertToPercentString = (rect) => {
return {
top: `${rect.top}%`,
left: `${rect.left}%`,
height: `${rect.height}%`,
width: `${rect.width}%`
};
};
var HighlightLayer = forwardRef(({ asChild, className, style, ...props }, ref) => {
const pageNumber = usePDFPageNumber();
const highlights = usePdf((state) => state.highlights);
const Comp = asChild ? Slot : "div";
const rects = highlights.filter((area) => area.pageNumber === pageNumber);
if (!rects?.length) return null;
return /* @__PURE__ */ jsx(Fragment, { children: rects.map((rect, index) => {
const { pageNumber: pageNumber2, type, ...coordinates } = rect;
let dimensions = coordinates;
if (type === "percent") {
dimensions = convertToPercentString(rect);
}
return /* @__PURE__ */ jsx(
Comp,
{
ref,
className,
style: {
position: "absolute",
...dimensions,
pointerEvents: "none",
zIndex: 30,
...style
},
...props,
children: props.children
},
`highlight-${pageNumber2}-${index}`
);
}) });
});
HighlightLayer.displayName = "HighlightLayer";
var useTextLayer = () => {
const textContainerRef = useRef(null);
const pageNumber = usePDFPageNumber();
const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
useEffect(() => {
if (!textContainerRef.current) {
return;
}
const textLayer = new TextLayer({
textContentSource: pdfPageProxy.streamTextContent(),
container: textContainerRef.current,
viewport: pdfPageProxy.getViewport({ scale: 1 })
});
void textLayer.render();
return () => {
textLayer.cancel();
};
}, [pdfPageProxy]);
return {
textContainerRef,
pageNumber: pdfPageProxy.pageNumber
};
};
var TextLayer2 = ({
className,
style,
...props
}) => {
const { textContainerRef, pageNumber } = useTextLayer();
return /* @__PURE__ */ jsx(
"div",
{
className: clsx_default("textLayer", className),
style: {
...style,
position: "absolute",
top: 0,
left: 0
},
...props,
...{
["data-page-number"]: pageNumber
},
ref: textContainerRef
}
);
};
var usePDFOutline = () => {
const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
const [outline, setOutline] = useState();
useEffect(() => {
const { promise: outline2, cancel } = cancellable(
pdfDocumentProxy.getOutline()
);
outline2.then(
(result) => {
setOutline(result);
},
() => {
}
);
return () => {
cancel();
};
}, [pdfDocumentProxy]);
return outline;
};
var HTMLTags = [
"a",
"button",
"div",
"aside",
"section",
"main",
"ul",
"li",
"input",
"canvas"
];
var makePrimitive = (htmlTag) => {
const primitive = forwardRef(
(props, ref) => {
const Renderer = htmlTag;
return /* @__PURE__ */ jsx(Renderer, { ...props, ref });
}
);
primitive.displayName = `PDFReader.${htmlTag}`;
return primitive;
};
var Primitive = HTMLTags.reduce((acc, tag) => {
acc[tag] = makePrimitive(tag);
return acc;
}, {});
var OutlineChildItems = ({
...props
}) => {
return /* @__PURE__ */ jsx(Primitive.ul, { ...props });
};
var OutlineItem = ({
level = 0,
item,
children,
outlineItem,
...props
}) => {
if (!item || !outlineItem || !children) {
throw new Error("Outline item is required");
}
const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
const { jumpToPage } = usePdfJump();
const getDestinationPage = useCallback(
async (dest) => {
let explicitDest;
if (typeof dest === "string") {
explicitDest = await pdfDocumentProxy.getDestination(dest);
} else if (Array.isArray(dest)) {
explicitDest = dest;
} else {
explicitDest = await dest;
}
if (!explicitDest) {
return;
}
const explicitRef = explicitDest[0];
const page = await pdfDocumentProxy.getPageIndex(explicitRef);
return page;
},
[pdfDocumentProxy]
);
const navigate = useCallback(() => {
if (!item.dest) {
return;
}
getDestinationPage(item.dest).then((page) => {
if (!page) {
return;
}
jumpToPage(page, { behavior: "smooth" });
});
}, [item.dest, jumpToPage, getDestinationPage]);
return /* @__PURE__ */ jsxs(Primitive.li, { ...props, children: [
/* @__PURE__ */ jsx(
"a",
{
role: "button",
tabIndex: 0,
onClick: navigate,
onKeyUp: (e) => {
if (e.key === "Enter") {
navigate();
}
},
"data-level": level,
children: item.title
}
),
item.items && item.items.length > 0 && cloneElement(children, {
// @ts-expect-error we are missing the corect props types
children: item.items.map(
(item2, index) => cloneElement(outlineItem, {
// @ts-expect-error we are missing the corect props types
level: level + 1,
item: item2,
outlineItem,
key: index
})
)
})
] });
};
var Outline = ({
chi