@anaralabs/lector
Version:
Headless PDF viewer for React
1,542 lines (1,527 loc) • 161 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,
focusedOpenId,
focusedHoverOpenId,
hoverClassName,
isOpen: controlledIsOpen,
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 (focusedOpenId && focusedOpenId !== annotation.id) return;
if (focusedHoverOpenId && focusedHoverOpenId !== annotation.id) return;
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(() => {
if (focusedOpenId && focusedOpenId !== annotation.id) return;
if (focusedHoverOpenId && focusedHoverOpenId !== annotation.id) return;
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__ */ jsx(
"div",
{
ref: (node) => {
refs.setReference(node);
hoverRefs.setReference(node);
},
onClick: handleClick,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
...getReferenceProps(),
...getHoverReferenceProps(),
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,
underlineClassName,
commentIconPosition,
commmentIcon,
commentIconClassName,
focusedAnnotationId,
focusedHoverAnnotationId,
onAnnotationClick,
onAnnotationTooltipClose,
hoverTooltipClassName
}) => {
const { annotations } = useAnnotations();
const pageNumber = usePDFPageNumber();
const pageAnnotations = annotations.filter(
(annotation) => annotation.pageNumber === pageNumber
);
const getCommentIconPosition = (highlights) => {
if (!highlights.length) return { top: 0, right: 10 };
const sortedHighlights = [...highlights].sort((a, b) => {
const topDiff = a.top - b.top;
return Math.abs(topDiff) < 3 ? a.left - b.left : topDiff;
});
const lines = [];
let currentLine = [];
sortedHighlights.forEach((highlight) => {
if (currentLine.length === 0) {
currentLine.push(highlight);
} else {
const firstInLine = currentLine[0];
if (Math.abs(highlight.top - firstInLine.top) <= 3) {
currentLine.push(highlight);
} else {
lines.push([...currentLine]);
currentLine = [highlight];
}
}
});
if (currentLine.length > 0) {
lines.push(currentLine);
}
const PAGE_WIDTH = 600;
const hasLongLine = lines.some((line) => {
if (line.length === 0) return false;
const rightmost2 = Math.max(...line.map((h) => h.left + h.width));
return rightmost2 > PAGE_WIDTH * 0.8;
});
const firstHighlight = highlights[0];
const firstLine = lines[0] || [];
const leftmost = Math.min(...firstLine.map((h) => h.left));
const rightmost = Math.max(...firstLine.map((h) => h.left + h.width));
const lineCenter = leftmost + (rightmost - leftmost) / 2;
const shouldPositionRight = hasLongLine || lineCenter > PAGE_WIDTH * 0.5;
const rightPosition = commentIconPosition === "highlight" ? { left: rightmost + 8 } : { right: 10 };
const leftPosition = commentIconPosition === "highlight" ? { left: leftmost - 18 } : { left: 20 };
return {
top: firstHighlight.top + firstHighlight.height / 2 - 6,
...shouldPositionRight ? rightPosition : leftPosition
};
};
return /* @__PURE__ */ jsx("div", { className, style, children: pageAnnotations.map((annotation) => {
return /* @__PURE__ */ jsx(
AnnotationTooltip,
{
annotation,
className: tooltipClassName,
hoverClassName: hoverTooltipClassName,
focusedOpenId: focusedAnnotationId,
focusedHoverOpenId: focusedHoverAnnotationId,
isOpen: focusedAnnotationId === annotation.id,
hoverIsOpen: focusedHoverAnnotationId === annotation.id,
onOpenChange: (open) => {
if (open && onAnnotationClick) {
onAnnotationClick(annotation);
} else if (!open && onAnnotationTooltipClose) {
onAnnotationTooltipClose(annotation);
}
},
renderTooltipContent,
hoverTooltipContent: renderHoverTooltipContent({
annotation,
onClose: () => {
}
}),
children: /* @__PURE__ */ jsxs(
"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
},
`highlight-${index}`
)),
annotation.comment && annotation.underlines?.map((rect, index) => /* @__PURE__ */ jsx(
"div",
{
className: underlineClassName,
style: {
position: "absolute",
top: rect.top,
left: rect.left,
width: rect.width,
height: 1.1,
backgroundColor: annotation.borderColor
},
"data-comment-id": annotation.id
},
`underline-${index}`
)),
annotation.comment && commmentIcon && /* @__PURE__ */ jsx(
"div",
{
className: commentIconClassName,
style: {
position: "absolute",
...getCommentIconPosition(annotation.highlights),
color: "gray",
cursor: "pointer",
zIndex: 10
},
"data-comment-icon-id": annotation.id,
children: commmentIcon
}
)
]
}
)
},
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 = 2;
var shouldMergeRects = (rect1, rect2) => {
const verticalOverlap = !(rect1.top > rect2.top + rect2.height || rect2.top > rect1.top + rect1.height);
const horizontallyConnected = 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 && horizontallyConnected;
};
var consolidateHighlightRects = (rects) => {
if (rects.length <= 1) return rects;
const consolidated = [];
const sorted = [...rects].sort((a, b) => {
const pageCompare = a.pageNumber - b.pageNumber;
if (pageCompare !== 0) return pageCompare;
const topDiff = a.top - b.top;
return Math.abs(topDiff) < 2 ? a.left - b.left : topDiff;
});
let current = sorted[0];
if (!current) return rects;
for (let i = 1; i < sorted.length; i++) {
const next = sorted[i];
if (!next) continue;
const samePageAndLine = current.pageNumber === next.pageNumber && Math.abs(current.top - next.top) < Math.max(current.height, next.height) * 0.5;
const horizontallyConnected = samePageAndLine && // Adjacent (touching or very close)
(Math.abs(current.left + current.width - next.left) <= MERGE_THRESHOLD || Math.abs(next.left + next.width - current.left) <= MERGE_THRESHOLD || // Overlapping
current.left < next.left + next.width && next.left < current.left + current.width || // Very close (small gap)
Math.abs(current.left + current.width - next.left) <= current.height * 0.2);
if (horizontallyConnected) {
const newLeft = Math.min(current.left, next.left);
const newRight = Math.max(current.left + current.width, next.left + next.width);
const newTop = Math.min(current.top, next.top);
const newBottom = Math.max(current.top + current.height, next.top + next.height);
current = {
left: newLeft,
top: newTop,
width: newRight - newLeft,
height: newBottom - newTop,
pageNumber: current.pageNumber
};
} else {
consolidated.push(current);
current = next;
}
}
if (current) {
consolidated.push(current);
}
return consolidated;
};
var consolidateRects = (rects) => {
if (rects.length <= 1) return rects;
const result = [];
const visited = /* @__PURE__ */ new Set();
for (let i = 0; i < rects.length; i++) {
if (visited.has(i)) continue;
const currentRect = rects[i];
if (!currentRect) continue;
const currentGroup = [currentRect];
visited.add(i);
let foundNew = true;
while (foundNew) {
foundNew = false;
for (let j = 0; j < rects.length; j++) {
if (visited.has(j)) continue;
const candidateRect = rects[j];
if (!candidateRect) continue;
const shouldMergeWithGroup = currentGroup.some(
(groupRect) => doRectsOverlap(groupRect, candidateRect)
);
if (shouldMergeWithGroup) {
currentGroup.push(candidateRect);
visited.add(j);
foundNew = true;
}
}
}
result.push(mergeRectGroup(currentGroup));
}
return result;
};
var doRectsOverlap = (rect1, rect2) => {
const horizontalOverlap = rect1.left < rect2.left + rect2.width && rect2.left < rect1.left + rect1.width;
const verticalOverlap = rect1.top < rect2.top + rect2.height && rect2.top < rect1.top + rect1.height;
const closeEnough = shouldMergeRects(rect1, rect2);
return horizontalOverlap && verticalOverlap || closeEnough;
};
var mergeRectGroup = (rects) => {
if (rects.length === 1) {
const rect = rects[0];
if (!rect) throw new Error("Invalid rect in group");
return rect;
}
const firstRect = rects[0];
if (!firstRect) throw new Error("Invalid first rect in group");
let minLeft = firstRect.left;
let minTop = firstRect.top;
let maxRight = firstRect.left + firstRect.width;
let maxBottom = firstRect.top + firstRect.height;
rects.forEach((rect) => {
if (!rect) return;
minLeft = Math.min(minLeft, rect.left);
minTop = Math.min(minTop, rect.top);
maxRight = Math.max(maxRight, rect.left + rect.width);
maxBottom = Math.max(maxBottom, rect.top + rect.height);
});
return {
left: minLeft,
top: minTop,
width: maxRight - minLeft,
height: maxBottom - minTop,
pageNumber: firstRect.pageNumber
};
};
var useSelectionDimensions = () => {
const store = PDFStore.useContext();
const getAnnotationDimension = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const highlightRects = [];
const underlineRects = [];
const textLayerMapHighlight = /* @__PURE__ */ new Map();
const textLayerMapUnderline = /* @__PURE__ */ new Map();
const clientRects = Array.from(range.getClientRects()).filter(
(rect) => rect.width > 2 && rect.height > 2
);
clientRects.forEach((clientRect) => {
let element = document.elementFromPoint(
clientRect.left + 1,
clientRect.top + clientRect.height / 2
);
if (!element) {
element = document.elementFromPoint(
clientRect.left + clientRect.width / 2,
clientRect.top + clientRect.height / 2
);
}
if (!element) {
element = document.elementFromPoint(
clientRect.right - 1,
clientRect.top + clientRect.height / 2
);
}
if (!element) {
element = document.elementFromPoint(
clientRect.left + clientRect.width / 2,
clientRect.top + 1
);
}
if (!element) {
element = document.elementFromPoint(
clientRect.left + clientRect.width / 2,
clientRect.bottom - 1
);
}
const textLayer = element?.closest(".textLayer");
if (!textLayer) return;
const isSuperOrSubScript = (el) => {
if (!el) return false;
if (el.tagName.toLowerCase() === "sup" || el.tagName.toLowerCase() === "sub") {
return true;
}
const classes = el.className;
if (typeof classes === "string") {
const superSubClasses = ["superscript", "subscript", "sup", "sub"];
if (superSubClasses.some((c) => classes.includes(c))) {
return true;
}
}
const elementRect = el.getBoundingClientRect();
if (elementRect.height < 6 && elementRect.width < 15) {
const textContent = el.textContent?.trim() || "";
if (textContent.length <= 2 && /^[\d\w]{1,2}$/.test(textContent)) {
const parentRect = el.parentElement?.getBoundingClientRect();
if (parentRect && parentRect.height > elementRect.height * 2) {
const elementCenter = elementRect.top + elementRect.height / 2;
const parentCenter = parentRect.top + parentRect.height / 2;
const verticalOffset = Math.abs(elementCenter - parentCenter);
if (verticalOffset > parentRect.height * 0.4) {
return true;
}
}
}
}
return false;
};
const pageNumber = parseInt(
textLayer.getAttribute("data-page-number") || "1",
10
);
const textLayerRect = textLayer.getBoundingClientRect();
const zoom = store.getState().zoom;
const highlightRect = {
width: clientRect.width / zoom,
height: clientRect.height / zoom,
top: (clientRect.top - textLayerRect.top) / zoom,
left: (clientRect.left - textLayerRect.left) / zoom,
pageNumber
};
if (!textLayerMapHighlight.has(pageNumber)) {
textLayerMapHighlight.set(pageNumber, []);
}
textLayerMapHighlight.get(pageNumber)?.push(highlightRect);
const shouldCreateUnderline = !isSuperOrSubScript(element);
if (shouldCreateUnderline) {
const baselineOffset = clientRect.height * 0.85;
const underlineHeight = 2;
const underlineRect = {
width: clientRect.width / zoom,
height: underlineHeight / zoom,
top: (clientRect.top - textLayerRect.top + baselineOffset) / zoom,
left: (clientRect.left - textLayerRect.left) / zoom,
pageNumber
};
if (!textLayerMapUnderline.has(pageNumber)) {
textLayerMapUnderline.set(pageNumber, []);
}
textLayerMapUnderline.get(pageNumber)?.push(underlineRect);
}
});
textLayerMapHighlight.forEach((rects) => {
if (rects.length > 0) {
if (rects.length === 1) {
highlightRects.push(...rects);
} else {
const consolidated = consolidateHighlightRects(rects);
highlightRects.push(...consolidated);
}
}
});
textLayerMapUnderline.forEach((rects) => {
if (rects.length > 0) {
const lineGroups = groupRectsByLine(rects);
lineGroups.forEach((group) => {
if (group.length === 0) return;
group.sort((a, b) => a.left - b.left);
let i = 0;
while (i < group.length) {
const startRect = group[i];
if (!startRect) {
i++;
continue;
}
let endIndex = i;
while (endIndex + 1 < group.length) {
const currentRect = group[endIndex];
const nextRect = group[endIndex + 1];
if (!currentRect || !nextRect) break;
const gap = nextRect.left - (currentRect.left + currentRect.width);
const maxGapAllowed = Math.max(MERGE_THRESHOLD, currentRect.height * 0.3);
if (gap <= maxGapAllowed) {
endIndex++;
} else {
break;
}
}
const endRect = group[endIndex];
if (!endRect) {
i++;
continue;
}
const lineRect = {
width: endRect.left + endRect.width - startRect.left,
height: 1.5,
top: startRect.top,
left: startRect.left,
pageNumber: startRect.pageNumber
};
underlineRects.push(lineRect);
i = endIndex + 1;
}
});
}
});
if (underlineRects.length === 0 && highlightRects.length > 0) {
highlightRects.forEach((highlightRect) => {
const baselineOffset = highlightRect.height * 0.85;
const underlineHeight = 1.5;
const underlineRect = {
width: highlightRect.width,
height: underlineHeight,
top: highlightRect.top + baselineOffset,
left: highlightRect.left,
pageNumber: highlightRect.pageNumber
};
underlineRects.push(underlineRect);
});
}
return {
highlights: highlightRects.sort((a, b) => a.pageNumber - b.pageNumber),
underlines: consolidateUnderlines(underlineRects).sort((a, b) => a.pageNumber - b.pageNumber),
text: range.toString().trim(),
isCollapsed: false
};
};
const groupRectsByLine = (rects) => {
const VERTICAL_TOLERANCE = 3;
const groups = [];
rects.forEach((rect) => {
const centerY = rect.top + rect.height / 2;
let foundGroup = false;
for (const group of groups) {
if (group.length === 0) continue;
const firstRect = group[0];
if (!firstRect) continue;
const groupCenterY = firstRect.top + firstRect.height / 2;
if (Math.abs(centerY - groupCenterY) <= VERTICAL_TOLERANCE) {
group.push(rect);
foundGroup = true;
break;
}
}
if (!foundGroup) {
groups.push([rect]);
}
});
return groups;
};
const consolidateUnderlines = (underlines) => {
if (underlines.length <= 1) return underlines;
const consolidated = [];
const sorted = [...underlines].sort((a, b) => {
const pageCompare = a.pageNumber - b.pageNumber;
if (pageCompare !== 0) return pageCompare;
const topCompare = a.top - b.top;
return Math.abs(topCompare) < 1 ? a.left - b.left : topCompare;
});
let current = sorted[0];
for (let i = 1; i < sorted.length; i++) {
const next = sorted[i];
const samePageAndLine = current.pageNumber === next.pageNumber && Math.abs(current.top - next.top) < 1;
const horizontallyConnected = samePageAndLine && (Math.abs(current.left + current.width - next.left) <= MERGE_THRESHOLD || current.left < next.left + next.width && next.left < current.left + current.width);
if (horizontallyConnected) {
const newWidth = Math.max(current.left + current.width, next.left + next.width) - Math.min(current.left, next.left);
current = {
...current,
left: Math.min(current.left, next.left),
width: newWidth
};
} else {
consolidated.push(current);
current = next;
}
}
consolidated.push(current);
return consolidated;
};
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) {
const consolidated = consolidateRects(rects);
highlights.push(...consolidated);
}
});
return {
highlights: highlights.sort((a, b) => a.pageNumber - b.pageNumber),
text: range.toString().trim(),
isCollapsed: false
};
};
const getSelection = () => getDimension();
return { getDimension, getSelection, getAnnotationDimension };
};
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);
lastSelecti