@anaralabs/lector 3.13.0 → 3.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -168,6 +168,8 @@ export declare const cancellable: <T extends Promise<unknown>>(promise: T) => {
168
168
  cancel: () => void;
169
169
  };
170
170
 
171
+ export declare const CANVAS_SUPERSAMPLE = 1.3;
172
+
171
173
  declare interface CanvasAndContext {
172
174
  canvas: HTMLCanvasElement | null;
173
175
  context: CanvasRenderingContext2D | null;
@@ -179,7 +181,7 @@ background?: string;
179
181
 
180
182
  export declare const clamp: (value: number, minimum: number, maximum: number) => number;
181
183
 
182
- export declare function clampScaleForPage(targetScale: number, pageWidth: number, pageHeight: number): number;
184
+ export declare function clampScaleForPage(targetScale: number, pageWidth: number, pageHeight: number, maxPixels?: number): number;
183
185
 
184
186
  declare type CollapsibleSelection = {
185
187
  highlights: HighlightRect[];
@@ -208,6 +210,10 @@ declare type ColorScheme = "light" | "dark";
208
210
  export { ColorScheme }
209
211
  export { ColorScheme as ColorScheme_alias_1 }
210
212
 
213
+ export declare function computeBaseScale(dpr: number, zoom: number, pageWidth: number, pageHeight: number): number;
214
+
215
+ export declare function computeTargetScale(dpr: number, zoom: number): number;
216
+
211
217
  /**
212
218
  * Builds a memoized color map that flips perceived lightness onto the
213
219
  * background<->foreground ramp while preserving hue and chroma (OKLab).
@@ -272,6 +278,8 @@ export declare const ensureAnnotationLayerStyles: () => void;
272
278
 
273
279
  export declare const firstMemo: <T>(first: boolean, memo: unknown, initializer: () => T) => T;
274
280
 
281
+ export declare function getCanvasPixelBudget(): number;
282
+
275
283
  export declare const getDefaultPdfJsAssetUrls: (version: string) => PdfJsAssetUrls;
276
284
 
277
285
  export declare const getEndOfHighlight: (selection: ColoredHighlight) => number;
@@ -321,6 +329,8 @@ export declare interface InitialPDFState {
321
329
  renderColorMapRef?: RenderColorMapRef;
322
330
  }
323
331
 
332
+ export declare const IS_MOBILE_DEVICE: boolean;
333
+
324
334
  export declare class LinkService {
325
335
  _pdfDocumentProxy?: PDFDocumentProxy;
326
336
  externalLinkEnabled: boolean;
package/dist/index.js CHANGED
@@ -1375,13 +1375,29 @@ AnnotationLayer.displayName = "AnnotationLayer";
1375
1375
  // src/lib/canvas-utils.ts
1376
1376
  var MAX_CANVAS_PIXELS = 16777216;
1377
1377
  var MAX_CANVAS_DIMENSION = 32767;
1378
- function clampScaleForPage(targetScale, pageWidth, pageHeight) {
1378
+ var CANVAS_SUPERSAMPLE = 1.3;
1379
+ function computeTargetScale(dpr, zoom) {
1380
+ const safeZoom = Math.max(Number.isFinite(zoom) ? zoom : 0, 0.01);
1381
+ return dpr * safeZoom * (safeZoom > 1 ? CANVAS_SUPERSAMPLE : 1);
1382
+ }
1383
+ var IS_MOBILE_DEVICE = typeof navigator !== "undefined" && (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
1384
+ var CANVAS_AREA_FACTOR = IS_MOBILE_DEVICE ? 1.5 : 3;
1385
+ function getCanvasPixelBudget() {
1386
+ if (typeof window === "undefined" || typeof screen === "undefined") {
1387
+ return MAX_CANVAS_PIXELS;
1388
+ }
1389
+ const dpr = window.devicePixelRatio || 1;
1390
+ const screenArea = (screen.width || 0) * (screen.height || 0) * dpr * dpr;
1391
+ if (!Number.isFinite(screenArea) || screenArea <= 0) {
1392
+ return MAX_CANVAS_PIXELS;
1393
+ }
1394
+ return Math.min(MAX_CANVAS_PIXELS, screenArea * CANVAS_AREA_FACTOR);
1395
+ }
1396
+ function clampScaleForPage(targetScale, pageWidth, pageHeight, maxPixels = MAX_CANVAS_PIXELS) {
1379
1397
  if (!targetScale) {
1380
1398
  return 0;
1381
1399
  }
1382
- const areaLimit = Math.sqrt(
1383
- MAX_CANVAS_PIXELS / Math.max(pageWidth * pageHeight, 1)
1384
- );
1400
+ const areaLimit = Math.sqrt(maxPixels / Math.max(pageWidth * pageHeight, 1));
1385
1401
  const widthLimit = MAX_CANVAS_DIMENSION / Math.max(pageWidth, 1);
1386
1402
  const heightLimit = MAX_CANVAS_DIMENSION / Math.max(pageHeight, 1);
1387
1403
  const safeScale = Math.min(
@@ -1392,6 +1408,14 @@ function clampScaleForPage(targetScale, pageWidth, pageHeight) {
1392
1408
  );
1393
1409
  return Math.max(safeScale, 0);
1394
1410
  }
1411
+ function computeBaseScale(dpr, zoom, pageWidth, pageHeight) {
1412
+ return clampScaleForPage(
1413
+ computeTargetScale(dpr, zoom),
1414
+ pageWidth,
1415
+ pageHeight,
1416
+ getCanvasPixelBudget()
1417
+ );
1418
+ }
1395
1419
 
1396
1420
  // src/lib/recolor-context.ts
1397
1421
  var RECOLOR_CLEANUP = Symbol("lectorRecolorCleanup");
@@ -1515,47 +1539,89 @@ function applyContextRecolor(ctx, map) {
1515
1539
  function removeContextRecolor(ctx) {
1516
1540
  ctx[RECOLOR_CLEANUP]?.();
1517
1541
  }
1542
+ var DPR_CAP = 3;
1543
+ var readDpr = () => typeof window === "undefined" ? 1 : Math.min(window.devicePixelRatio || 1, DPR_CAP);
1518
1544
  var useDpr = () => {
1519
- const [dpr, setDPR] = useState(
1520
- typeof window === "undefined" ? 1 : Math.min(window.devicePixelRatio, 2)
1521
- );
1545
+ const [dpr, setDpr] = useState(readDpr);
1522
1546
  useEffect(() => {
1523
1547
  if (typeof window === "undefined") {
1524
1548
  return;
1525
1549
  }
1526
- const handleDPRChange = () => {
1527
- setDPR(Math.min(window.devicePixelRatio, 2));
1550
+ let mediaQuery = null;
1551
+ let cancelled = false;
1552
+ const handleChange = () => {
1553
+ if (cancelled) {
1554
+ return;
1555
+ }
1556
+ setDpr(readDpr());
1557
+ subscribe();
1528
1558
  };
1529
- const windowMatch = window.matchMedia(
1530
- `screen and (min-resolution: ${dpr}dppx)`
1531
- );
1532
- windowMatch.addEventListener("change", handleDPRChange);
1559
+ const unlisten = (mql) => {
1560
+ if (!mql) return;
1561
+ if (typeof mql.removeEventListener === "function") {
1562
+ mql.removeEventListener("change", handleChange);
1563
+ } else {
1564
+ mql.removeListener(handleChange);
1565
+ }
1566
+ };
1567
+ const subscribe = () => {
1568
+ unlisten(mediaQuery);
1569
+ const ratio = window.devicePixelRatio || 1;
1570
+ mediaQuery = window.matchMedia(
1571
+ `(resolution: ${ratio}dppx), (-webkit-device-pixel-ratio: ${ratio})`
1572
+ );
1573
+ if (typeof mediaQuery.addEventListener === "function") {
1574
+ mediaQuery.addEventListener("change", handleChange, { once: true });
1575
+ } else {
1576
+ mediaQuery.addListener(handleChange);
1577
+ }
1578
+ };
1579
+ setDpr(readDpr());
1580
+ subscribe();
1533
1581
  return () => {
1534
- windowMatch.removeEventListener("change", handleDPRChange);
1582
+ cancelled = true;
1583
+ unlisten(mediaQuery);
1535
1584
  };
1536
- }, [dpr]);
1585
+ }, []);
1537
1586
  return dpr;
1538
1587
  };
1539
1588
 
1540
1589
  // src/hooks/layers/useCanvasLayer.tsx
1541
- var CACHE_MAX = 60;
1590
+ var CACHE_MAX_ENTRIES = 60;
1591
+ var CACHE_MAX_BYTES = (IS_MOBILE_DEVICE ? 64 : 192) * 1024 * 1024;
1592
+ var CACHE_MAX_ENTRY_BYTES = IS_MOBILE_DEVICE ? CACHE_MAX_BYTES / 3 : CACHE_MAX_BYTES / 4;
1593
+ var MAX_VARIANTS_PER_PROXY = 2;
1542
1594
  var cacheEntries = [];
1543
1595
  var canvasBitmapCache = /* @__PURE__ */ new WeakMap();
1596
+ var cacheBytes = 0;
1597
+ var documentIdFallbacks = /* @__PURE__ */ new WeakMap();
1598
+ var documentIdCounter = 0;
1599
+ function getDocumentCacheId(pdfDocumentProxy) {
1600
+ const fingerprint = pdfDocumentProxy.fingerprints?.[0];
1601
+ if (fingerprint) return fingerprint;
1602
+ let id = documentIdFallbacks.get(pdfDocumentProxy);
1603
+ if (!id) {
1604
+ documentIdCounter += 1;
1605
+ id = `anonymous-document-${documentIdCounter}`;
1606
+ documentIdFallbacks.set(pdfDocumentProxy, id);
1607
+ }
1608
+ return id;
1609
+ }
1610
+ function evictEntryAt(index) {
1611
+ const [entry] = cacheEntries.splice(index, 1);
1612
+ if (!entry) return;
1613
+ cacheBytes -= entry.bytes;
1614
+ const map = canvasBitmapCache.get(entry.proxy);
1615
+ if (map?.get(entry.key) === entry.bitmap) {
1616
+ map.delete(entry.key);
1617
+ }
1618
+ entry.bitmap.close();
1619
+ }
1544
1620
  function clearBitmapCache(documentId) {
1545
- if (documentId === void 0) {
1546
- for (const entry of cacheEntries) {
1547
- entry.bitmap.close();
1548
- canvasBitmapCache.get(entry.proxy)?.delete(entry.key);
1549
- }
1550
- cacheEntries.length = 0;
1551
- return;
1552
- }
1553
1621
  for (let i = cacheEntries.length - 1; i >= 0; i--) {
1554
- const entry = cacheEntries[i];
1555
- if (entry.docId !== documentId) continue;
1556
- entry.bitmap.close();
1557
- canvasBitmapCache.get(entry.proxy)?.delete(entry.key);
1558
- cacheEntries.splice(i, 1);
1622
+ if (documentId === void 0 || cacheEntries[i].docId === documentId) {
1623
+ evictEntryAt(i);
1624
+ }
1559
1625
  }
1560
1626
  }
1561
1627
  function cacheKey(scale, background, recolorKey) {
@@ -1578,40 +1644,49 @@ function getCachedBitmap(proxy, scale, background, recolorKey) {
1578
1644
  return bitmap;
1579
1645
  }
1580
1646
  function setCachedBitmap(docId, proxy, scale, background, recolorKey, bitmap) {
1647
+ const bytes = bitmap.width * bitmap.height * 4;
1648
+ if (bytes > CACHE_MAX_ENTRY_BYTES) {
1649
+ bitmap.close();
1650
+ return;
1651
+ }
1581
1652
  const key = cacheKey(scale, background, recolorKey);
1653
+ const existingIdx = cacheEntries.findIndex(
1654
+ (e) => e.proxy === proxy && e.key === key
1655
+ );
1656
+ if (existingIdx !== -1) {
1657
+ if (cacheEntries[existingIdx].bitmap === bitmap) return;
1658
+ evictEntryAt(existingIdx);
1659
+ }
1582
1660
  let map = canvasBitmapCache.get(proxy);
1583
1661
  if (!map) {
1584
1662
  map = /* @__PURE__ */ new Map();
1585
1663
  canvasBitmapCache.set(proxy, map);
1586
1664
  }
1587
- const existing = map.get(key);
1588
- if (existing && existing !== bitmap) {
1589
- existing.close();
1590
- const idx = cacheEntries.findIndex(
1591
- (e) => e.proxy === proxy && e.key === key
1592
- );
1593
- if (idx !== -1) cacheEntries.splice(idx, 1);
1594
- }
1595
1665
  map.set(key, bitmap);
1596
- cacheEntries.push({ docId, proxy, key, bitmap });
1597
- while (cacheEntries.length > CACHE_MAX) {
1598
- const evicted = cacheEntries.shift();
1599
- const evictedMap = canvasBitmapCache.get(evicted.proxy);
1600
- if (evictedMap?.get(evicted.key) === evicted.bitmap) {
1601
- evictedMap.delete(evicted.key);
1602
- evicted.bitmap.close();
1666
+ cacheEntries.push({ docId, proxy, key, bitmap, bytes });
1667
+ cacheBytes += bytes;
1668
+ let variants = 0;
1669
+ for (let i = cacheEntries.length - 1; i >= 0; i--) {
1670
+ if (cacheEntries[i].proxy !== proxy) continue;
1671
+ variants++;
1672
+ if (variants > MAX_VARIANTS_PER_PROXY) {
1673
+ evictEntryAt(i);
1603
1674
  }
1604
1675
  }
1676
+ while (cacheEntries.length > 0 && (cacheEntries.length > CACHE_MAX_ENTRIES || cacheBytes > CACHE_MAX_BYTES)) {
1677
+ evictEntryAt(0);
1678
+ }
1605
1679
  }
1606
1680
  var useCanvasLayer = ({ background }) => {
1607
1681
  const canvasRef = useRef(null);
1608
- const paintedKeyRef = useRef(null);
1682
+ const paintedRef = useRef(null);
1609
1683
  const pageNumber = usePDFPageNumber();
1610
1684
  const store = PDFStore.useContext();
1611
1685
  const dpr = useDpr();
1612
1686
  const bouncyZoom = usePdf((state) => state.zoom);
1613
1687
  const isResizing = usePdf((state) => state.isResizing);
1614
- const docId = usePdf((state) => state.pdfDocumentProxy.fingerprints[0] ?? "");
1688
+ const isPinching = usePdf((state) => state.isPinching);
1689
+ const docId = usePdf((state) => getDocumentCacheId(state.pdfDocumentProxy));
1615
1690
  const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
1616
1691
  const markPageRendered = usePdf((state) => state.markPageRendered);
1617
1692
  const unmarkPageRendered = usePdf((state) => state.unmarkPageRendered);
@@ -1627,29 +1702,52 @@ var useCanvasLayer = ({ background }) => {
1627
1702
  const pageWidth = baseViewport.width;
1628
1703
  const pageHeight = baseViewport.height;
1629
1704
  const contentKey = `${background ?? "white"}|${recolorKey ?? ""}`;
1630
- if (isResizing && paintedKeyRef.current === contentKey) {
1705
+ const baseScale = computeBaseScale(dpr, zoom, pageWidth, pageHeight);
1706
+ const hasCurrentFrame = paintedRef.current?.proxy === pdfPageProxy && paintedRef.current?.key === contentKey;
1707
+ const geometryZoom = Math.max(Number.isFinite(zoom) ? zoom : 0, 0.01);
1708
+ const applyGeometry = () => {
1709
+ if (geometryZoom === 1) {
1710
+ baseCanvas.style.width = `${pageWidth}px`;
1711
+ baseCanvas.style.height = `${pageHeight}px`;
1712
+ baseCanvas.style.transform = "translate(0px, 0px)";
1713
+ } else {
1714
+ baseCanvas.style.width = `${pageWidth * geometryZoom}px`;
1715
+ baseCanvas.style.height = `${pageHeight * geometryZoom}px`;
1716
+ baseCanvas.style.transform = `scale3d(${1 / geometryZoom},${1 / geometryZoom},1)`;
1717
+ baseCanvas.style.transformOrigin = "0 0";
1718
+ }
1719
+ };
1720
+ if (hasCurrentFrame && paintedRef.current?.scale === baseScale) {
1721
+ applyGeometry();
1722
+ return;
1723
+ }
1724
+ if ((isResizing || isPinching) && hasCurrentFrame) {
1631
1725
  baseCanvas.style.visibility = "";
1632
- baseCanvas.style.width = `${pageWidth}px`;
1633
- baseCanvas.style.height = `${pageHeight}px`;
1634
1726
  return;
1635
1727
  }
1636
- paintedKeyRef.current = null;
1637
- const targetBaseScale = dpr * Math.min(zoom, 1);
1638
- const baseScale = clampScaleForPage(targetBaseScale, pageWidth, pageHeight);
1639
- baseCanvas.width = Math.floor(pageWidth * baseScale);
1640
- baseCanvas.height = Math.floor(pageHeight * baseScale);
1641
1728
  baseCanvas.style.position = "absolute";
1642
1729
  baseCanvas.style.top = "0";
1643
1730
  baseCanvas.style.left = "0";
1644
- baseCanvas.style.width = `${pageWidth}px`;
1645
- baseCanvas.style.height = `${pageHeight}px`;
1646
- baseCanvas.style.transform = "translate(0px, 0px)";
1731
+ applyGeometry();
1647
1732
  baseCanvas.style.zIndex = "0";
1648
1733
  baseCanvas.style.pointerEvents = "none";
1649
1734
  baseCanvas.style.backgroundColor = recolor ? recolor(background ?? "#ffffff") : background ?? "white";
1650
- baseCanvas.style.visibility = "";
1735
+ const backingWidth = Math.floor(pageWidth * baseScale);
1736
+ const backingHeight = Math.floor(pageHeight * baseScale);
1651
1737
  const context = baseCanvas.getContext("2d");
1652
- if (!context) return;
1738
+ if (!context) {
1739
+ if (!hasCurrentFrame) {
1740
+ paintedRef.current = null;
1741
+ baseCanvas.width = backingWidth;
1742
+ baseCanvas.height = backingHeight;
1743
+ baseCanvas.style.visibility = "hidden";
1744
+ }
1745
+ return;
1746
+ }
1747
+ const applyBackingSize = () => {
1748
+ baseCanvas.width = backingWidth;
1749
+ baseCanvas.height = backingHeight;
1750
+ };
1653
1751
  const cached = getCachedBitmap(
1654
1752
  pdfPageProxy,
1655
1753
  baseScale,
@@ -1657,23 +1755,41 @@ var useCanvasLayer = ({ background }) => {
1657
1755
  recolorKey
1658
1756
  );
1659
1757
  if (cached) {
1758
+ applyBackingSize();
1660
1759
  context.drawImage(cached, 0, 0);
1760
+ baseCanvas.style.visibility = "";
1661
1761
  markPageRendered(pageNumber);
1662
- paintedKeyRef.current = contentKey;
1762
+ paintedRef.current = {
1763
+ proxy: pdfPageProxy,
1764
+ key: contentKey,
1765
+ scale: baseScale
1766
+ };
1663
1767
  return;
1664
1768
  }
1665
- context.setTransform(1, 0, 0, 1, 0, 0);
1666
- context.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
1667
- baseCanvas.style.visibility = "hidden";
1769
+ const hideUntilPainted = () => {
1770
+ paintedRef.current = null;
1771
+ applyBackingSize();
1772
+ baseCanvas.style.visibility = "hidden";
1773
+ };
1774
+ if (!hasCurrentFrame) {
1775
+ hideUntilPainted();
1776
+ }
1668
1777
  let cancelled = false;
1669
1778
  const viewport = pdfPageProxy.getViewport({ scale: baseScale });
1670
1779
  const buffer = document.createElement("canvas");
1671
- buffer.width = baseCanvas.width;
1672
- buffer.height = baseCanvas.height;
1780
+ buffer.width = backingWidth;
1781
+ buffer.height = backingHeight;
1673
1782
  const bufferCtx = buffer.getContext("2d");
1674
1783
  const useBuffer = bufferCtx !== null;
1784
+ if (!useBuffer && hasCurrentFrame) {
1785
+ hideUntilPainted();
1786
+ }
1675
1787
  const renderCanvas = useBuffer ? buffer : baseCanvas;
1676
1788
  const renderCtx = bufferCtx ?? context;
1789
+ const releaseBuffer = () => {
1790
+ buffer.width = 0;
1791
+ buffer.height = 0;
1792
+ };
1677
1793
  let restoreRecolor = null;
1678
1794
  if (recolor) {
1679
1795
  restoreRecolor = applyContextRecolor(renderCtx, recolor);
@@ -1690,12 +1806,9 @@ var useCanvasLayer = ({ background }) => {
1690
1806
  });
1691
1807
  } catch (error) {
1692
1808
  restoreRecolor?.();
1809
+ releaseBuffer();
1693
1810
  throw error;
1694
1811
  }
1695
- const releaseBuffer = () => {
1696
- buffer.width = 0;
1697
- buffer.height = 0;
1698
- };
1699
1812
  renderingTask.promise.finally(() => {
1700
1813
  restoreRecolor?.();
1701
1814
  }).then(() => {
@@ -1710,13 +1823,17 @@ var useCanvasLayer = ({ background }) => {
1710
1823
  return;
1711
1824
  }
1712
1825
  if (useBuffer) {
1713
- context.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
1826
+ applyBackingSize();
1714
1827
  context.drawImage(buffer, 0, 0);
1715
1828
  }
1716
1829
  baseCanvas.style.visibility = "";
1717
1830
  markPageRendered(pageNumber);
1718
- paintedKeyRef.current = contentKey;
1719
- if (typeof createImageBitmap !== "undefined") {
1831
+ paintedRef.current = {
1832
+ proxy: pdfPageProxy,
1833
+ key: contentKey,
1834
+ scale: baseScale
1835
+ };
1836
+ if (typeof createImageBitmap !== "undefined" && renderCanvas.width * renderCanvas.height * 4 <= CACHE_MAX_ENTRY_BYTES) {
1720
1837
  createImageBitmap(renderCanvas).then((bitmap) => {
1721
1838
  if (cancelled) {
1722
1839
  bitmap.close();
@@ -1752,6 +1869,7 @@ var useCanvasLayer = ({ background }) => {
1752
1869
  dpr,
1753
1870
  zoom,
1754
1871
  isResizing,
1872
+ isPinching,
1755
1873
  pageNumber,
1756
1874
  markPageRendered,
1757
1875
  docId,
@@ -1767,6 +1885,7 @@ var useCanvasLayer = ({ background }) => {
1767
1885
  canvas.width = 0;
1768
1886
  canvas.height = 0;
1769
1887
  }
1888
+ paintedRef.current = null;
1770
1889
  };
1771
1890
  }, [pageNumber, unmarkPageRendered]);
1772
1891
  return {
@@ -1839,6 +1958,12 @@ var subscribeToViewportInvalidation = (viewport, callback) => {
1839
1958
 
1840
1959
  // src/hooks/layers/useDetailCanvasLayer.tsx
1841
1960
  var DETAIL_RENDER_IDLE_MS = 80;
1961
+ var SCROLL_POLL_MS = 160;
1962
+ var ZOOM_DEBOUNCE_MS = 200;
1963
+ var ZOOM_SETTLE_MS = ZOOM_DEBOUNCE_MS + 50;
1964
+ var OVERSCAN_X = 0.25;
1965
+ var OVERSCAN_Y = 0.5;
1966
+ var RELEASE_HIDDEN_MS = 2e3;
1842
1967
  var useDetailCanvasLayer = ({
1843
1968
  background,
1844
1969
  baseCanvasRef
@@ -1849,15 +1974,23 @@ var useDetailCanvasLayer = ({
1849
1974
  const dpr = useDpr();
1850
1975
  const store = PDFStore.useContext();
1851
1976
  const bouncyZoom = usePdf((state) => state.zoom);
1852
- const isPinching = usePdf((state) => state.isPinching);
1853
1977
  const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
1854
1978
  const viewportRef = usePdf((state) => state.viewportRef);
1855
1979
  const colorScheme = usePdf((state) => state.colorScheme);
1856
1980
  const darkModeColors = usePdf((state) => state.darkModeColors);
1857
1981
  const recolor = colorScheme === "dark" ? createDarkModeColorMap(darkModeColors) : null;
1858
1982
  const recolorKey = recolor ? `dark:${darkModeColors.background},${darkModeColors.foreground}` : "light";
1859
- const paintedKeyRef = useRef(null);
1860
- const [zoom] = useDebounce(bouncyZoom, 200);
1983
+ const paintedRef = useRef(null);
1984
+ const releaseTimerRef = useRef(null);
1985
+ const [zoom] = useDebounce(bouncyZoom, ZOOM_DEBOUNCE_MS);
1986
+ const lastZoomChangeRef = useRef(
1987
+ store.getState().isPinching ? performance.now() : 0
1988
+ );
1989
+ const prevBouncyZoomRef = useRef(bouncyZoom);
1990
+ if (prevBouncyZoomRef.current !== bouncyZoom) {
1991
+ prevBouncyZoomRef.current = bouncyZoom;
1992
+ lastZoomChangeRef.current = performance.now();
1993
+ }
1861
1994
  const pageDimsRef = useRef(null);
1862
1995
  if (!pageDimsRef.current || pageDimsRef.current.proxy !== pdfPageProxy) {
1863
1996
  const vp = pdfPageProxy.getViewport({ scale: 1 });
@@ -1879,35 +2012,52 @@ var useDetailCanvasLayer = ({
1879
2012
  }
1880
2013
  let renderingTask = null;
1881
2014
  let renderTimeoutId = null;
1882
- const bgColor = recolor ? recolor(background ?? "#ffffff") : background ?? "white";
1883
2015
  const contentKey = `${background ?? "white"}|${recolorKey}`;
1884
- const detailBaseStyle = `position:absolute;top:0;left:0;pointer-events:none;z-index:1;background-color:${bgColor}`;
2016
+ const detailBaseStyle = "position:absolute;top:0;left:0;pointer-events:none;z-index:1";
1885
2017
  const hideDetailCanvas = () => {
1886
2018
  renderingTask?.cancel();
2019
+ renderingTask = null;
1887
2020
  detailCanvas.style.cssText = `${detailBaseStyle};display:block;opacity:0`;
1888
2021
  container.style.cssText = "";
2022
+ paintedRef.current = null;
2023
+ if (releaseTimerRef.current === null) {
2024
+ releaseTimerRef.current = setTimeout(() => {
2025
+ releaseTimerRef.current = null;
2026
+ detailCanvas.width = 1;
2027
+ detailCanvas.height = 1;
2028
+ }, RELEASE_HIDDEN_MS);
2029
+ }
1889
2030
  };
2031
+ const pageDims = pageDimsRef.current;
2032
+ if (!pageDims) {
2033
+ hideDetailCanvas();
2034
+ return;
2035
+ }
2036
+ const { width: pageWidth, height: pageHeight } = pageDims;
2037
+ const targetDetailScale = computeTargetScale(dpr, zoom);
2038
+ const baseScale = computeBaseScale(dpr, zoom, pageWidth, pageHeight);
2039
+ const needsDetail = targetDetailScale - baseScale > 1e-3;
1890
2040
  const renderDetailCanvas = () => {
2041
+ if (targetDetailScale - computeBaseScale(dpr, zoom, pageWidth, pageHeight) <= 1e-3) {
2042
+ hideDetailCanvas();
2043
+ return;
2044
+ }
1891
2045
  const virtualizer = store.getState().virtualizer;
1892
2046
  if (virtualizer?.isScrolling) {
1893
- scheduleRender(160);
2047
+ scheduleRender(SCROLL_POLL_MS);
1894
2048
  return;
1895
2049
  }
1896
- const pageContainer = baseCanvasRef.current?.parentElement ?? null;
1897
- if (!pageContainer) {
1898
- hideDetailCanvas();
2050
+ const settleElapsed = performance.now() - lastZoomChangeRef.current;
2051
+ if (store.getState().isPinching || settleElapsed < ZOOM_SETTLE_MS) {
2052
+ scheduleRender(Math.max(50, ZOOM_SETTLE_MS - settleElapsed + 10));
1899
2053
  return;
1900
2054
  }
1901
- const { width: pageWidth, height: pageHeight } = pageDimsRef.current;
1902
- const targetDetailScale = dpr * zoom * 1.3;
1903
- const baseTargetScale = dpr * Math.min(zoom, 1);
1904
- const baseScale = clampScaleForPage(
1905
- baseTargetScale,
1906
- pageWidth,
1907
- pageHeight
1908
- );
1909
- const needsDetail = zoom > 1 && targetDetailScale - baseScale > 1e-3;
1910
- if (isPinching || !needsDetail) {
2055
+ if (store.getState().zoom !== zoom) {
2056
+ scheduleRender(ZOOM_SETTLE_MS);
2057
+ return;
2058
+ }
2059
+ const pageContainer = baseCanvasRef.current?.parentElement ?? null;
2060
+ if (!pageContainer) {
1911
2061
  hideDetailCanvas();
1912
2062
  return;
1913
2063
  }
@@ -1935,17 +2085,72 @@ var useDetailCanvasLayer = ({
1935
2085
  hideDetailCanvas();
1936
2086
  return;
1937
2087
  }
2088
+ const budget = getCanvasPixelBudget();
2089
+ let effectiveScale = clampScaleForPage(
2090
+ targetDetailScale,
2091
+ visibleWidth,
2092
+ visibleHeight,
2093
+ budget
2094
+ );
2095
+ const padX = OVERSCAN_X * viewportWidth;
2096
+ const padY = OVERSCAN_Y * viewportHeight;
2097
+ const maxRectWidth = Math.min(pageWidth, visibleWidth + 2 * padX);
2098
+ const maxRectHeight = Math.min(pageHeight, visibleHeight + 2 * padY);
2099
+ effectiveScale = Math.min(
2100
+ effectiveScale,
2101
+ MAX_CANVAS_DIMENSION / Math.max(maxRectWidth, 1),
2102
+ MAX_CANVAS_DIMENSION / Math.max(maxRectHeight, 1)
2103
+ );
2104
+ const eps = 1 / effectiveScale;
2105
+ const painted = paintedRef.current;
2106
+ if (painted && painted.proxy === pdfPageProxy && painted.key === contentKey && painted.scale === effectiveScale && visibleLeft >= painted.left - eps && visibleTop >= painted.top - eps && visibleRight <= painted.left + painted.width + eps && visibleBottom <= painted.top + painted.height + eps) {
2107
+ renderingTask?.cancel();
2108
+ renderingTask = null;
2109
+ return;
2110
+ }
2111
+ const maxArea = budget / (effectiveScale * effectiveScale);
2112
+ const a = 4 * padX * padY;
2113
+ const b = 2 * (visibleWidth * padY + visibleHeight * padX);
2114
+ const c = visibleWidth * visibleHeight - maxArea;
2115
+ let padFactor = 1;
2116
+ if (a > 0) {
2117
+ padFactor = Math.min(
2118
+ 1,
2119
+ Math.max(
2120
+ 0,
2121
+ (-b + Math.sqrt(Math.max(b * b - 4 * a * c, 0))) / (2 * a)
2122
+ )
2123
+ );
2124
+ } else if (b > 0) {
2125
+ padFactor = Math.min(1, Math.max(0, -c / b));
2126
+ }
2127
+ const rectLeft = Math.max(0, visibleLeft - padX * padFactor);
2128
+ const rectTop = Math.max(0, visibleTop - padY * padFactor);
2129
+ const rectRight = Math.min(pageWidth, visibleRight + padX * padFactor);
2130
+ const rectBottom = Math.min(pageHeight, visibleBottom + padY * padFactor);
2131
+ const rectWidth = rectRight - rectLeft;
2132
+ const rectHeight = rectBottom - rectTop;
1938
2133
  renderingTask?.cancel();
1939
- const pdfOffsetX = visibleLeft;
1940
- const pdfOffsetY = visibleTop;
1941
- const effectiveScale = targetDetailScale;
1942
- const actualWidth = visibleWidth * effectiveScale;
1943
- const actualHeight = visibleHeight * effectiveScale;
2134
+ if (releaseTimerRef.current !== null) {
2135
+ clearTimeout(releaseTimerRef.current);
2136
+ releaseTimerRef.current = null;
2137
+ }
2138
+ const actualWidth = Math.max(1, Math.floor(rectWidth * effectiveScale));
2139
+ const actualHeight = Math.max(1, Math.floor(rectHeight * effectiveScale));
1944
2140
  const buffer = document.createElement("canvas");
1945
2141
  buffer.width = actualWidth;
1946
2142
  buffer.height = actualHeight;
1947
2143
  const bufferCtx = buffer.getContext("2d");
1948
2144
  if (!bufferCtx) {
2145
+ buffer.width = 0;
2146
+ buffer.height = 0;
2147
+ hideDetailCanvas();
2148
+ if (releaseTimerRef.current !== null) {
2149
+ clearTimeout(releaseTimerRef.current);
2150
+ releaseTimerRef.current = null;
2151
+ }
2152
+ detailCanvas.width = 1;
2153
+ detailCanvas.height = 1;
1949
2154
  return;
1950
2155
  }
1951
2156
  bufferCtx.setTransform(1, 0, 0, 1, 0, 0);
@@ -1954,21 +2159,35 @@ var useDetailCanvasLayer = ({
1954
2159
  0,
1955
2160
  0,
1956
2161
  1,
1957
- -pdfOffsetX * effectiveScale,
1958
- -pdfOffsetY * effectiveScale
2162
+ -rectLeft * effectiveScale,
2163
+ -rectTop * effectiveScale
1959
2164
  ];
1960
2165
  const detailViewport = pdfPageProxy.getViewport({
1961
2166
  scale: effectiveScale
1962
2167
  });
1963
2168
  if (recolor) applyContextRecolor(bufferCtx, recolor);
1964
- const currentRenderingTask = pdfPageProxy.render({
1965
- canvas: buffer,
1966
- canvasContext: bufferCtx,
1967
- viewport: detailViewport,
1968
- background,
1969
- transform
1970
- });
2169
+ const releaseBuffer = () => {
2170
+ buffer.width = 0;
2171
+ buffer.height = 0;
2172
+ };
2173
+ let currentRenderingTask;
2174
+ try {
2175
+ currentRenderingTask = pdfPageProxy.render({
2176
+ canvas: buffer,
2177
+ canvasContext: bufferCtx,
2178
+ viewport: detailViewport,
2179
+ background,
2180
+ transform
2181
+ });
2182
+ } catch (error) {
2183
+ releaseBuffer();
2184
+ hideDetailCanvas();
2185
+ console.error("PDF detail render error:", error);
2186
+ return;
2187
+ }
1971
2188
  renderingTask = currentRenderingTask;
2189
+ const cssWidth = actualWidth / effectiveScale * zoom;
2190
+ const cssHeight = actualHeight / effectiveScale * zoom;
1972
2191
  currentRenderingTask.promise.then(() => {
1973
2192
  if (renderingTask !== currentRenderingTask) return;
1974
2193
  const state = store.getState();
@@ -1976,13 +2195,36 @@ var useDetailCanvasLayer = ({
1976
2195
  if (currentRecolorKey !== recolorKey) return;
1977
2196
  detailCanvas.width = actualWidth;
1978
2197
  detailCanvas.height = actualHeight;
1979
- detailCanvas.style.cssText = `${detailBaseStyle};display:block;opacity:1;width:${visibleWidth * zoom}px;height:${visibleHeight * zoom}px;transform-origin:center center;transform:translate(${visibleLeft * zoom}px,${visibleTop * zoom}px)`;
2198
+ detailCanvas.style.cssText = `${detailBaseStyle};display:block;opacity:1;width:${cssWidth}px;height:${cssHeight}px;transform-origin:center center;transform:translate(${rectLeft * zoom}px,${rectTop * zoom}px)`;
1980
2199
  container.style.cssText = `transform:scale3d(${1 / zoom},${1 / zoom},1);transform-origin:0 0`;
1981
2200
  const ctx = detailCanvas.getContext("2d");
1982
- if (ctx) {
2201
+ if (!ctx) {
2202
+ hideDetailCanvas();
2203
+ return;
2204
+ }
2205
+ try {
1983
2206
  ctx.drawImage(buffer, 0, 0);
2207
+ } catch (error) {
2208
+ hideDetailCanvas();
2209
+ console.error("PDF detail canvas blit error:", error);
2210
+ return;
2211
+ }
2212
+ paintedRef.current = {
2213
+ proxy: pdfPageProxy,
2214
+ key: contentKey,
2215
+ scale: effectiveScale,
2216
+ left: rectLeft,
2217
+ top: rectTop,
2218
+ // The truly painted extent comes from the floored backing,
2219
+ // not the requested rect.
2220
+ width: actualWidth / effectiveScale,
2221
+ height: actualHeight / effectiveScale
2222
+ };
2223
+ if (releaseTimerRef.current !== null) {
2224
+ clearTimeout(releaseTimerRef.current);
2225
+ releaseTimerRef.current = null;
1984
2226
  }
1985
- paintedKeyRef.current = contentKey;
2227
+ scheduleRender();
1986
2228
  }).catch((error) => {
1987
2229
  if (error.name === "RenderingCancelledException") {
1988
2230
  return;
@@ -1992,15 +2234,13 @@ var useDetailCanvasLayer = ({
1992
2234
  if (renderingTask === currentRenderingTask) {
1993
2235
  renderingTask = null;
1994
2236
  }
1995
- buffer.width = 0;
1996
- buffer.height = 0;
2237
+ releaseBuffer();
1997
2238
  });
1998
2239
  };
1999
2240
  const scheduleRender = (delay = DETAIL_RENDER_IDLE_MS) => {
2000
2241
  if (renderTimeoutId !== null) {
2001
2242
  clearTimeout(renderTimeoutId);
2002
2243
  }
2003
- renderingTask?.cancel();
2004
2244
  renderTimeoutId = setTimeout(() => {
2005
2245
  renderTimeoutId = null;
2006
2246
  renderDetailCanvas();
@@ -2010,12 +2250,11 @@ var useDetailCanvasLayer = ({
2010
2250
  scrollContainer,
2011
2251
  scheduleRender
2012
2252
  );
2013
- if (zoom <= 1 || isPinching) {
2253
+ if (!needsDetail) {
2014
2254
  hideDetailCanvas();
2015
2255
  } else {
2016
- if (paintedKeyRef.current !== null && paintedKeyRef.current !== contentKey) {
2256
+ if (paintedRef.current !== null && (paintedRef.current.key !== contentKey || paintedRef.current.proxy !== pdfPageProxy)) {
2017
2257
  hideDetailCanvas();
2018
- paintedKeyRef.current = null;
2019
2258
  }
2020
2259
  scheduleRender(0);
2021
2260
  }
@@ -2029,7 +2268,6 @@ var useDetailCanvasLayer = ({
2029
2268
  }, [
2030
2269
  pdfPageProxy,
2031
2270
  zoom,
2032
- isPinching,
2033
2271
  background,
2034
2272
  dpr,
2035
2273
  viewportRef,
@@ -2041,10 +2279,15 @@ var useDetailCanvasLayer = ({
2041
2279
  useEffect(() => {
2042
2280
  const canvas = detailCanvasRef.current;
2043
2281
  return () => {
2282
+ if (releaseTimerRef.current !== null) {
2283
+ clearTimeout(releaseTimerRef.current);
2284
+ releaseTimerRef.current = null;
2285
+ }
2044
2286
  if (canvas) {
2045
2287
  canvas.width = 1;
2046
2288
  canvas.height = 1;
2047
2289
  }
2290
+ paintedRef.current = null;
2048
2291
  };
2049
2292
  }, []);
2050
2293
  return {
@@ -3080,6 +3323,7 @@ var useTextLayer = () => {
3080
3323
  const pageNumber = usePDFPageNumber();
3081
3324
  const pdfPageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
3082
3325
  const viewportRef = usePdf((state) => state.viewportRef);
3326
+ const store = PDFStore.useContext();
3083
3327
  useEffect(() => {
3084
3328
  const textContainer = textContainerRef.current;
3085
3329
  if (!textContainer) {
@@ -3184,11 +3428,22 @@ var useTextLayer = () => {
3184
3428
  const show = () => {
3185
3429
  textContainer.style.visibility = "";
3186
3430
  };
3187
- const onScroll = () => {
3431
+ const restoreWhenSettled = () => {
3432
+ settleTimer = null;
3433
+ if (store.getState().isPinching) {
3434
+ settleTimer = setTimeout(restoreWhenSettled, TEXT_SCROLL_SETTLE_MS);
3435
+ return;
3436
+ }
3437
+ show();
3438
+ };
3439
+ const hideAndDebounce = () => {
3188
3440
  if (selecting) return;
3189
3441
  textContainer.style.visibility = "hidden";
3190
3442
  if (settleTimer) clearTimeout(settleTimer);
3191
- settleTimer = setTimeout(show, TEXT_SCROLL_SETTLE_MS);
3443
+ settleTimer = setTimeout(restoreWhenSettled, TEXT_SCROLL_SETTLE_MS);
3444
+ };
3445
+ const onScroll = () => {
3446
+ hideAndDebounce();
3192
3447
  };
3193
3448
  const onPointerDown = () => {
3194
3449
  pointerDown = true;
@@ -3220,6 +3475,19 @@ var useTextLayer = () => {
3220
3475
  unsubscribe = subscribeToViewportInvalidation(scrollViewport, onScroll);
3221
3476
  };
3222
3477
  attachScrollSubscription();
3478
+ const initialState = store.getState();
3479
+ let prevZoom = initialState.zoom;
3480
+ let prevPinching = initialState.isPinching;
3481
+ if (prevPinching) hideAndDebounce();
3482
+ const unsubscribeStore = store.subscribe(
3483
+ (state) => {
3484
+ const zoomChanged = state.zoom !== prevZoom;
3485
+ const pinchStarted = state.isPinching && !prevPinching;
3486
+ prevZoom = state.zoom;
3487
+ prevPinching = state.isPinching;
3488
+ if (zoomChanged || pinchStarted) hideAndDebounce();
3489
+ }
3490
+ );
3223
3491
  document.addEventListener("pointerdown", onPointerDown, true);
3224
3492
  document.addEventListener("pointerup", clearPointer, true);
3225
3493
  document.addEventListener("pointercancel", clearPointer, true);
@@ -3228,6 +3496,7 @@ var useTextLayer = () => {
3228
3496
  return () => {
3229
3497
  if (attachRaf !== null) cancelAnimationFrame(attachRaf);
3230
3498
  unsubscribe?.();
3499
+ unsubscribeStore();
3231
3500
  document.removeEventListener("pointerdown", onPointerDown, true);
3232
3501
  document.removeEventListener("pointerup", clearPointer, true);
3233
3502
  document.removeEventListener("pointercancel", clearPointer, true);
@@ -3236,7 +3505,7 @@ var useTextLayer = () => {
3236
3505
  if (settleTimer) clearTimeout(settleTimer);
3237
3506
  show();
3238
3507
  };
3239
- }, [viewportRef]);
3508
+ }, [viewportRef, store]);
3240
3509
  return {
3241
3510
  textContainerRef,
3242
3511
  pageNumber: pdfPageProxy.pageNumber
@@ -5600,10 +5869,10 @@ var useViewportContainer = ({
5600
5869
  const transformations = useRef({
5601
5870
  translateX: 0,
5602
5871
  translateY: 0,
5603
- zoom
5872
+ zoom: 1
5604
5873
  });
5605
5874
  const zoomRafRef = useRef(null);
5606
- const lastPushedZoomRef = useRef(zoom);
5875
+ const lastPushedZoomRef = useRef(null);
5607
5876
  const updateTransform = useCallback(
5608
5877
  (zoomUpdate) => {
5609
5878
  if (!elementRef.current || !containerRef.current || !elementWrapperRef.current) {
@@ -5640,8 +5909,14 @@ var useViewportContainer = ({
5640
5909
  return;
5641
5910
  }
5642
5911
  if (zoom === lastPushedZoomRef.current) {
5912
+ lastPushedZoomRef.current = null;
5643
5913
  return;
5644
5914
  }
5915
+ if (zoomRafRef.current !== null) {
5916
+ cancelAnimationFrame(zoomRafRef.current);
5917
+ zoomRafRef.current = null;
5918
+ }
5919
+ lastPushedZoomRef.current = null;
5645
5920
  const prevZoom = transformations.current.zoom;
5646
5921
  if (!prevZoom || !Number.isFinite(prevZoom)) {
5647
5922
  transformations.current = {
@@ -5871,7 +6146,13 @@ var Pages = ({
5871
6146
  overscan: virtualizerOptions?.overscan ?? 0,
5872
6147
  scrollToFn,
5873
6148
  gap,
5874
- initialOffset
6149
+ initialOffset,
6150
+ // Never trust scrollend alone to reset isScrolling: a single missed
6151
+ // event (a documented browser flake — TanStack flipped this default to
6152
+ // false in later 3.x) would pin isScrolling=true forever, and every
6153
+ // consumer gated on it (text layer, detail canvas) would stop painting.
6154
+ // false keeps the isScrollingResetDelay debounce active as a fallback.
6155
+ useScrollendEvent: false
5875
6156
  });
5876
6157
  useEffect(() => {
5877
6158
  if (onOffsetChange && virtualizer.scrollOffset)
@@ -6298,7 +6579,8 @@ var Root = forwardRef(
6298
6579
  const linkService = useCreatePDFLinkService(
6299
6580
  initialState?.pdfDocumentProxy ?? null
6300
6581
  );
6301
- const documentId = initialState?.pdfDocumentProxy?.fingerprints?.[0];
6582
+ const pdfDocumentProxy = initialState?.pdfDocumentProxy ?? null;
6583
+ const documentId = pdfDocumentProxy ? getDocumentCacheId(pdfDocumentProxy) : null;
6302
6584
  useEffect(() => {
6303
6585
  if (!documentId) return;
6304
6586
  return () => {
@@ -6357,6 +6639,7 @@ var useThumbnail = (pageNumber, config = {}) => {
6357
6639
  const containerRef = useRef(null);
6358
6640
  const canvasRef = useRef(null);
6359
6641
  const renderTaskRef = useRef(null);
6642
+ const renderedProxyRef = useRef(null);
6360
6643
  const pageProxy = usePdf((state) => state.getPdfPageProxy(pageNumber));
6361
6644
  const { visible } = useVisibility({ elementRef: containerRef });
6362
6645
  const [debouncedVisible] = useDebounce(visible, 50);
@@ -6366,6 +6649,8 @@ var useThumbnail = (pageNumber, config = {}) => {
6366
6649
  const recolor = colorScheme === "dark" ? createDarkModeColorMap(darkModeColors) : null;
6367
6650
  const isVisible = isFirstPage || debouncedVisible;
6368
6651
  useEffect(() => {
6652
+ if (!isVisible && renderedProxyRef.current !== pageProxy) return;
6653
+ renderedProxyRef.current = pageProxy;
6369
6654
  const renderThumbnail = async () => {
6370
6655
  const canvas2 = canvasRef.current;
6371
6656
  if (!canvas2 || !pageProxy) return;
@@ -6374,7 +6659,11 @@ var useThumbnail = (pageNumber, config = {}) => {
6374
6659
  renderTaskRef.current.cancel();
6375
6660
  }
6376
6661
  const viewport = pageProxy.getViewport({ scale: 1 });
6377
- const scale = Math.min(maxWidth / viewport.width, maxHeight / viewport.height) * (isVisible ? dpr : 0.5);
6662
+ const scale = clampScaleForPage(
6663
+ Math.min(maxWidth / viewport.width, maxHeight / viewport.height) * (isVisible ? dpr : 0.5),
6664
+ viewport.width,
6665
+ viewport.height
6666
+ );
6378
6667
  const scaledViewport = pageProxy.getViewport({ scale });
6379
6668
  canvas2.width = scaledViewport.width;
6380
6669
  canvas2.height = scaledViewport.height;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anaralabs/lector",
3
- "version": "3.13.0",
3
+ "version": "3.13.2",
4
4
  "description": "A headless PDF viewer for React",
5
5
  "author": "andrewdr (https://github.com/andrewdoro)",
6
6
  "license": "MIT",