@anaralabs/lector
Version:
A headless PDF viewer for React
1,522 lines (1,507 loc) • 230 kB
JavaScript
'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)