UNPKG

@anaralabs/lector

Version:

A headless PDF viewer for React

1,522 lines (1,507 loc) 230 kB
'use client'; import React, { createContext, memo, forwardRef, useMemo, cloneElement, useEffect, useRef, useState, useCallback, useContext, useLayoutEffect, createRef } from 'react'; import { createPortal } from 'react-dom'; import { useFloating, autoUpdate, offset, shift, useDismiss, useInteractions, inline, flip } from '@floating-ui/react'; import { useStore, createStore } from 'zustand'; import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { useDebounce } from 'use-debounce'; import { v4 } from 'uuid'; import { Slot } from '@radix-ui/react-slot'; import { useVirtualizer, elementScroll, debounce } from '@tanstack/react-virtual'; // src/lib/clamp.ts var clamp = (value, minimum, maximum) => { return Math.min(Math.max(value, minimum), maximum); }; // src/lib/dark-mode.ts var DEFAULT_DARK_MODE_COLORS = { background: "#141210", foreground: "#eae6e0" }; var NAMED_COLORS = { white: [1, 1, 1, 1], black: [0, 0, 0, 1], transparent: [0, 0, 0, 0] }; function clamp01(value) { return value < 0 ? 0 : value > 1 ? 1 : value; } function parseColor(input) { const str = input.trim().toLowerCase(); const named = Object.hasOwn(NAMED_COLORS, str) ? NAMED_COLORS[str] : void 0; if (named) return named; if (str.startsWith("#")) { const hex = str.slice(1); if (!/^[0-9a-f]{3,8}$/.test(hex)) return null; if (hex.length === 3 || hex.length === 4) { const r2 = Number.parseInt(hex[0], 16) / 15; const g = Number.parseInt(hex[1], 16) / 15; const b = Number.parseInt(hex[2], 16) / 15; const a = hex.length === 4 ? Number.parseInt(hex[3], 16) / 15 : 1; return [r2, g, b, a]; } if (hex.length === 6 || hex.length === 8) { const r2 = Number.parseInt(hex.slice(0, 2), 16) / 255; const g = Number.parseInt(hex.slice(2, 4), 16) / 255; const b = Number.parseInt(hex.slice(4, 6), 16) / 255; const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1; return [r2, g, b, a]; } return null; } const fn = /^rgba?\(([^)]+)\)$/.exec(str); if (fn) { const parts = fn[1].split(/[,\s/]+/).filter(Boolean); if (parts.length < 3) return null; const channel = (part) => part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part) / 255; const alpha = (part) => part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part); const r2 = channel(parts[0]); const g = channel(parts[1]); const b = channel(parts[2]); const a = parts[3] !== void 0 ? alpha(parts[3]) : 1; if (Number.isNaN(r2) || Number.isNaN(g) || Number.isNaN(b) || Number.isNaN(a)) return null; return [clamp01(r2), clamp01(g), clamp01(b), clamp01(a)]; } const normalized = normalizeCssColor(str); if (normalized !== null && normalized !== str) return parseColor(normalized); return null; } var normalizeCtx; function normalizeCssColor(input) { if (normalizeCtx === void 0) { normalizeCtx = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d", { willReadFrequently: true }); } const ctx = normalizeCtx; if (!ctx) return null; ctx.fillStyle = "#000000"; ctx.fillStyle = input; const first = ctx.fillStyle; ctx.fillStyle = "#ffffff"; ctx.fillStyle = input; return first === ctx.fillStyle ? first : null; } function srgbToLinear(c) { return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; } function linearToSrgb(c) { return c <= 31308e-7 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; } function rgbToOklab(r2, g, b) { const lr = srgbToLinear(r2); const lg = srgbToLinear(g); const lb = srgbToLinear(b); const l = Math.cbrt( 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb ); const m = Math.cbrt( 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb ); const s = Math.cbrt( 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb ); return [ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s ]; } function oklabToLinearRgb(L, a, b) { const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; return [ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s ]; } var GAMUT_EPSILON = 5e-4; function oklabToSrgbExact(L, a, b) { const [lr, lg, lb] = oklabToLinearRgb(L, a, b); if (lr < -GAMUT_EPSILON || lr > 1 + GAMUT_EPSILON || lg < -GAMUT_EPSILON || lg > 1 + GAMUT_EPSILON || lb < -GAMUT_EPSILON || lb > 1 + GAMUT_EPSILON) { return null; } return [ linearToSrgb(clamp01(lr)), linearToSrgb(clamp01(lg)), linearToSrgb(clamp01(lb)) ]; } function oklabToSrgbGamutMapped(L, a, b) { const exact = oklabToSrgbExact(L, a, b); if (exact) return exact; let lo = 0; let hi = 1; for (let i = 0; i < 12; i++) { const mid = (lo + hi) / 2; if (oklabToSrgbExact(L, a * mid, b * mid)) lo = mid; else hi = mid; } const [lr, lg, lb] = oklabToLinearRgb(L, a * lo, b * lo); return [ linearToSrgb(clamp01(lr)), linearToSrgb(clamp01(lg)), linearToSrgb(clamp01(lb)) ]; } function toHexByte(channel) { return Math.round(clamp01(channel) * 255).toString(16).padStart(2, "0"); } function toCssColor(r2, g, b, a) { const rgb = `#${toHexByte(r2)}${toHexByte(g)}${toHexByte(b)}`; return a >= 1 ? rgb : `${rgb}${toHexByte(a)}`; } var NEUTRAL_CHROMA_THRESHOLD = 0.04; var COLOR_CACHE_MAX = 4096; var mapInstances = /* @__PURE__ */ new Map(); function createDarkModeColorMap(colors) { const background = colors?.background ?? DEFAULT_DARK_MODE_COLORS.background; const foreground = colors?.foreground ?? DEFAULT_DARK_MODE_COLORS.foreground; const instanceKey = `${background}|${foreground}`; const existing = mapInstances.get(instanceKey); if (existing) { mapInstances.delete(instanceKey); mapInstances.set(instanceKey, existing); return existing; } const bgRgba = parseColor(background) ?? parseColor(DEFAULT_DARK_MODE_COLORS.background); const fgRgba = parseColor(foreground) ?? parseColor(DEFAULT_DARK_MODE_COLORS.foreground); const bgLab = rgbToOklab(bgRgba[0], bgRgba[1], bgRgba[2]); const fgLab = rgbToOklab(fgRgba[0], fgRgba[1], fgRgba[2]); const cache = /* @__PURE__ */ new Map(); const backgroundCss = toCssColor(bgRgba[0], bgRgba[1], bgRgba[2], 1); const foregroundCss = toCssColor(fgRgba[0], fgRgba[1], fgRgba[2], 1); const seedPoles = () => { for (const white of ["#ffffff", "#fff", "white"]) cache.set(white, backgroundCss); for (const black of ["#000000", "#000", "black"]) cache.set(black, foregroundCss); }; seedPoles(); const transform = (input) => { const parsed = parseColor(input); if (!parsed || parsed[3] === 0) return input; const [r2, g, b, alpha] = parsed; const [, labA, labB] = rgbToOklab(r2, g, b); const luma = 0.2126 * r2 + 0.7152 * g + 0.0722 * b; const rampL = fgLab[0] + (bgLab[0] - fgLab[0]) * luma; const rampA = fgLab[1] + (bgLab[1] - fgLab[1]) * luma; const rampB = fgLab[2] + (bgLab[2] - fgLab[2]) * luma; const chroma = Math.hypot(labA, labB); const hueWeight = Math.min(1, chroma / NEUTRAL_CHROMA_THRESHOLD); const outA = rampA + (labA - rampA) * hueWeight; const outB = rampB + (labB - rampB) * hueWeight; const [sr, sg, sb] = oklabToSrgbGamutMapped(rampL, outA, outB); return toCssColor(sr, sg, sb, alpha); }; const map = (input) => { const hit = cache.get(input); if (hit !== void 0) return hit; const out = transform(input); if (cache.size >= COLOR_CACHE_MAX) { cache.clear(); seedPoles(); } cache.set(input, out); return out; }; if (mapInstances.size >= 16) { const oldest = mapInstances.keys().next().value; if (oldest !== void 0) mapInstances.delete(oldest); } mapInstances.set(instanceKey, map); return map; } // 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) => { const initialColorScheme = initialState.colorScheme ?? "light"; const initialDarkModeColors = { ...DEFAULT_DARK_MODE_COLORS, ...initialState.darkModeColors }; const renderColorMapRef = initialState.renderColorMapRef ?? { current: null }; renderColorMapRef.current = initialColorScheme === "dark" ? createDarkModeColorMap(initialDarkModeColors) : null; 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 }); }, isResizing: false, setIsResizing: (val) => { set({ isResizing: 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 ) })), colorScheme: initialColorScheme, darkModeColors: initialDarkModeColors, renderColorMapRef, setColorScheme: (colorScheme, colors) => { const prev = get(); const darkModeColors = { background: colors?.background ?? prev.darkModeColors.background, foreground: colors?.foreground ?? prev.darkModeColors.foreground }; if (prev.colorScheme === colorScheme && prev.darkModeColors.background === darkModeColors.background && prev.darkModeColors.foreground === darkModeColors.foreground) { return; } prev.renderColorMapRef.current = colorScheme === "dark" ? createDarkModeColorMap(darkModeColors) : null; set({ colorScheme, darkModeColors }); }, renderedPages: {}, markPageRendered: (pageNumber) => { const current = get().renderedPages; if (current[pageNumber]) return; set({ renderedPages: { ...current, [pageNumber]: true } }); }, unmarkPageRendered: (pageNumber) => { const current = get().renderedPages; if (!current[pageNumber]) return; const { [pageNumber]: _, ...rest } = current; set({ renderedPages: rest }); } })); } ); 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]); 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, annotation.id, focusedHoverOpenId, focusedOpenId ]); 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; } }, [annotation.id, focusedOpenId, focusedHoverOpenId]); 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 createAnnotationStore = () => createStore((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 AnnotationsStore = createZustandContext( () => createAnnotationStore() ); var fallbackStore = null; var getFallbackStore = () => { if (!fallbackStore) fallbackStore = createAnnotationStore(); return fallbackStore; }; var AnnotationsStoreProvider = ({ children }) => React.createElement( AnnotationsStore.Provider, { initialValue: void 0 }, children ); var useAnnotations = () => { const ctx = AnnotationsStore.useContext(); return useStore(ctx ?? getFallbackStore()); }; 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 isPageRendered = usePdf((state) => !!state.renderedPages[pageNumber]); const pageAnnotations = useMemo( () => annotations.filter( (a) => a.pageNumber === pageNumber || a.highlights.some((h) => h.pageNumber === pageNumber) || a.underlines?.some((u) => u.pageNumber === pageNumber) ), [annotations, pageNumber] ); if (!isPageRendered) return null; const getCommentIconPosition = (highlights) => { if (!highlights.length) return { top: 0, right: 10 }; const sortedHighlights = highlights.toSorted((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) => { const pageHighlights = annotation.highlights.filter( (h) => h.pageNumber === pageNumber ); const pageUnderlines = annotation.underlines?.filter( (u) => u.pageNumber === pageNumber ); if (pageHighlights.length === 0 && (!pageUnderlines || pageUnderlines.length === 0)) { return null; } const minPage = (rects) => rects?.reduce( (m, r2) => m === null || r2.pageNumber < m ? r2.pageNumber : m, null ) ?? null; const minHighlightPage = minPage(annotation.highlights); const minUnderlinePage = minPage(annotation.underlines); const firstPageWithRects = minHighlightPage === null ? minUnderlinePage : minUnderlinePage === null ? minHighlightPage : Math.min(minHighlightPage, minUnderlinePage); const isPrimaryPage = firstPageWithRects === pageNumber; const showCommentIcon = isPrimaryPage; const rectsContent = /* @__PURE__ */ jsxs( "div", { style: { cursor: "pointer" }, onClick: () => onAnnotationClick?.(annotation), children: [ pageHighlights.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-${// biome-ignore lint/suspicious/noArrayIndexKey: <index> index}` )), annotation.comment && pageUnderlines?.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-${// biome-ignore lint/suspicious/noArrayIndexKey: <index> index}` )), (() => { if (!annotation.comment || !commmentIcon || !showCommentIcon) { return null; } const anchorRects = pageHighlights.length > 0 ? pageHighlights : pageUnderlines; if (!anchorRects || anchorRects.length === 0) return null; return /* @__PURE__ */ jsx( "div", { className: commentIconClassName, style: { position: "absolute", ...getCommentIconPosition(anchorRects), color: "gray", cursor: "pointer", zIndex: 10 }, "data-comment-icon-id": annotation.id, children: commmentIcon } ); })() ] } ); if (!isPrimaryPage) { return /* @__PURE__ */ jsx("div", { children: rectsContent }, annotation.id); } 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: rectsContent }, 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/annotation-layer-styles.ts var ANNOTATION_LAYER_STYLE_ID = "lector-annotation-layer-styles"; var annotationLayerStyles = ` .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); } `; var ensureAnnotationLayerStyles = () => { if (typeof document === "undefined") { return; } if (document.getElementById(ANNOTATION_LAYER_STYLE_ID)) { return; } const style = document.createElement("style"); style.id = ANNOTATION_LAYER_STYLE_ID; style.textContent = annotationLayerStyles; document.head.appendChild(style); }; var usePdfJump = () => { const virtualizer = usePdf((state) => state.virtualizer); const setHighlight = usePdf((state) => state.setHighlight); const jumpToPage = useCallback( (pageIndex, options) => { if (!virtualizer) return; const defaultOptions = { align: "start", behavior: "smooth" }; const finalOptions = { ...defaultOptions, ...options }; virtualizer.scrollToIndex(pageIndex - 1, finalOptions); }, [virtualizer] ); const jumpToOffset = useCallback( (offset3) => { if (!virtualizer) return; virtualizer.scrollToOffset(offset3, { align: "start", behavior: "smooth" }); }, [virtualizer] ); const scrollToHighlightRects = useCallback( (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" }); }, [virtualizer] ); const jumpToHighlightRects = useCallback( (rects, type, align = "start", additionalOffset = 0) => { if (!virtualizer) return; setHighlight(rects); scrollToHighlightRects(rects, type, align, additionalOffset); }, [virtualizer, setHighlight, scrollToHighlightRects] ); 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; } 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(_page_valuer) { } setHash(hash) { if (hash.startsWith("#page=")) { const pageNumber = parseInt(hash.substring(6), 10); if (!Number.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 useCreatePDFLinkService = (pdfDocumentProxy) => { const linkService = useRef(new LinkService()); useEffect(() => { if (pdfDocumentProxy) { linkService.current._pdfDocumentProxy = pdfDocumentProxy; linkService.current.setDocument(pdfDocumentProxy); } }, [pdfDocumentProxy]); return linkService.current; }; 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 = useMemo(() => { return { ...defaultAnnotationLayerParams, ...params }; }, [ params.renderForms, params.externalLinksEnabled, params.jumpOptions?.behavior, params.jumpOptions?.align ]); 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(() => { ensureAnnotationLayerStyles(); }, []); 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 (!Number.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); } const container = annotationLayerRef.current; let cancelled = false; container.replaceChildren(); container.className = "annotationLayer"; const viewport = pdfPageProxy.getViewport({ scale: 1 }).clone({ dontFlip: true }); const annotationLayerConfig = { div: container, viewport, page: pdfPageProxy, linkService, annotationStorage: void 0, accessibilityManager: void 0, annotationCanvasMap: void 0, annotationEditorUIManager: void 0, structTreeLayer: void 0, commentManager: void 0 }; (async () => { try { if (cancelled) return; const { AnnotationLayer: AnnotationLayer2 } = await import('pdfjs-dist/legacy/build/pdf.mjs'); if (cancelled) return; const annotationLayer = new AnnotationLayer2(annotationLayerConfig); annotationLayerObjectRef.current = annotationLayer; const annotations = await pdfPageProxy.getAnnotations(); if (cancelled) return; await annotationLayer.render({ ...annotationLayerConfig, ...mergedParams, annotations, linkService }); } catch (_error) { } })(); return () => { cancelled = true; }; }, [pdfPageProxy, pdfDocumentProxy, mergedParams, linkService]); return { annotationLayerRef }; }; var AnnotationLayer = memo( ({ 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 } ); } ); AnnotationLayer.displayName = "AnnotationLayer"; // src/lib/canvas-utils.ts var MAX_CANVAS_PIXELS = 16777216; var MAX_CANVAS_DIMENSION = 32767; var CANVAS_SUPERSAMPLE = 1.3; function computeTargetScale(dpr, zoom) { const safeZoom = Math.max(Number.isFinite(zoom) ? zoom : 0, 0.01); return dpr * safeZoom * (safeZoom > 1 ? CANVAS_SUPERSAMPLE : 1); } var IS_MOBILE_DEVICE = typeof navigator !== "undefined" && (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); var CANVAS_AREA_FACTOR = IS_MOBILE_DEVICE ? 1.5 : 3; function getCanvasPixelBudget() { if (typeof window === "undefined" || typeof screen === "undefined") { return MAX_CANVAS_PIXELS; } const dpr = window.devicePixelRatio || 1; const screenArea = (screen.width || 0) * (screen.height || 0) * dpr * dpr; if (!Number.isFinite(screenArea) || screenArea <= 0) { return MAX_CANVAS_PIXELS; } return Math.min(MAX_CANVAS_PIXELS, screenArea * CANVAS_AREA_FACTOR); } function clampScaleForPage(targetScale, pageWidth, pageHeight, maxPixels = MAX_CANVAS_PIXELS) { if (!targetScale) { return 0; } const areaLimit = Math.sqrt(maxPixels / Math.max(pageWidth * pageHeight, 1)); const widthLimit = MAX_CANVAS_DIMENSION / Math.max(pageWidth, 1); const heightLimit = MAX_CANVAS_DIMENSION / Math.max(pageHeight, 1); const safeScale = Math.min( targetScale, Number.isFinite(areaLimit) ? areaLimit : targetScale, Number.isFinite(widthLimit) ? widthLimit : targetScale, Number.isFinite(heightLimit) ? heightLimit : targetScale ); return Math.max(safeScale, 0); } function computeBaseScale(dpr, zoom, pageWidth, pageHeight) { return clampScaleForPage( computeTargetScale(dpr, zoom), pageWidth, pageHeight, getCanvasPixelBudget() ); } // src/lib/recolor-context.ts var RECOLOR_CLEANUP = Symbol("lectorRecolorCleanup"); var RECOLOR_PAINTED = Symbol("lectorRecolorPainted"); var FILL_METHODS = ["fill", "fillRect", "fillText"]; var STROKE_METHODS = ["stroke", "strokeRect", "strokeText"]; var GRADIENT_METHODS = [ "createLinearGradient", "createRadialGradient" ]; function ntscLuma(r2, g, b) { return 0.3 * r2 + 0.59 * g + 0.11 * b; } function parseHexLuma(color) { if (!/^#[0-9a-f]{6}$/i.test(color)) return null; return ntscLuma( Number.parseInt(color.slice(1, 3), 16), Number.parseInt(color.slice(3, 5), 16), Number.parseInt(color.slice(5, 7), 16) ); } function correctLuminosityMask(source, mappedWhiteLuma, mappedBlackLuma) { if (typeof document === "undefined") return null; if (!(typeof HTMLCanvasElement !== "undefined" && source instanceof HTMLCanvasElement) && !(typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas)) { return null; } const width = source.width; const height = source.height; if (!width || !height) return null; const span = mappedWhiteLuma - mappedBlackLuma; if (Math.abs(span) < 1) return null; const tmp = document.createElement("canvas"); tmp.width = width; tmp.height = height; const tmpCtx = tmp.getContext("2d", { willReadFrequently: true }); if (!tmpCtx) return null; tmpCtx.drawImage(source, 0, 0); let image; try { image = tmpCtx.getImageData(0, 0, width, height); } catch { return null; } const data = image.data; for (let i = 0; i < data.length; i += 4) { const luma = ntscLuma(data[i], data[i + 1], data[i + 2]); const t = (luma - mappedBlackLuma) / span; const gray = t <= 0 ? 0 : t >= 1 ? 255 : Math.round(t * 255); data[i] = data[i + 1] = data[i + 2] = gray; } tmpCtx.putImageData(image, 0, 0); return tmp; } function applyContextRecolor(ctx, map) { const target = ctx; target[RECOLOR_CLEANUP]?.(); const mappedWhiteLuma = parseHexLuma(map("#ffffff")); const mappedBlackLuma = parseHexLuma(map("#000000")); const withMaskCorrection = (original) => function(...args) { if (mappedWhiteLuma !== null && mappedBlackLuma !== null && this.globalCompositeOperation === "destination-in" && typeof this.filter === "string" && this.filter.includes("luminosity")) { const source = args[0]; const sourcePainted = (typeof HTMLCanvasElement !== "undefined" && source instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas) && source.getContext("2d")?.[RECOLOR_PAINTED] === true; const corrected = sourcePainted ? correctLuminosityMask(source, mappedWhiteLuma, mappedBlackLuma) : null; if (corrected) { const next = [corrected, ...args.slice(1)]; return original.apply(this, next); } } return original.apply(this, args); }; const withMappedStyle = (original, styleProp) => function(...args) { const style = this[styleProp]; if (typeof style === "string") { const mapped = map(style); if (mapped !== style) { this[RECOLOR_PAINTED] = true; this[styleProp] = mapped; try { return original.apply(this, args); } finally { this[styleProp] = style; } } } return original.apply(this, args); }; const withMappedStops = (original) => function(...args) { const gradient = original.apply(this, args); const addColorStop = gradient.addColorStop.bind(gradient); const context = this; gradient.addColorStop = (offset3, color) => { const mapped = map(color); if (mapped !== color) context[RECOLOR_PAINTED] = true; addColorStop(offset3, mapped); }; return gradient; }; const record = target; for (const name of FILL_METHODS) record[name] = withMappedStyle(target[name], "fillStyle"); for (const name of STROKE_METHODS) record[name] = withMappedStyle(target[name], "strokeStyle"); for (const name of GRADIENT_METHODS)