@arbidocs/react 0.3.185 → 0.4.0

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.
Files changed (41) hide show
  1. package/dist/{chunk-YJAYST7S.js → chunk-3CLGHHME.js} +880 -240
  2. package/dist/chunk-3CLGHHME.js.map +1 -0
  3. package/dist/chunk-6ALMBER7.js +176 -0
  4. package/dist/chunk-6ALMBER7.js.map +1 -0
  5. package/dist/chunk-BXP6S34V.cjs +284 -0
  6. package/dist/chunk-BXP6S34V.cjs.map +1 -0
  7. package/dist/chunk-EJ2BMHYK.js +272 -0
  8. package/dist/chunk-EJ2BMHYK.js.map +1 -0
  9. package/dist/{chunk-IZPXHVPJ.cjs → chunk-OU2QLIKS.cjs} +969 -344
  10. package/dist/chunk-OU2QLIKS.cjs.map +1 -0
  11. package/dist/chunk-RZQACYNQ.cjs +218 -0
  12. package/dist/chunk-RZQACYNQ.cjs.map +1 -0
  13. package/dist/{index-4WwLZsQO.d.cts → index-CcbVSNSP.d.cts} +40 -2
  14. package/dist/{index-4WwLZsQO.d.ts → index-CcbVSNSP.d.ts} +40 -2
  15. package/dist/index.cjs +589 -1070
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +11 -155
  18. package/dist/index.d.ts +11 -155
  19. package/dist/index.js +223 -725
  20. package/dist/index.js.map +1 -1
  21. package/dist/shell/index.cjs +13 -4
  22. package/dist/shell/index.d.cts +1 -1
  23. package/dist/shell/index.d.ts +1 -1
  24. package/dist/shell/index.js +2 -1
  25. package/dist/ui/index.cjs +211 -190
  26. package/dist/ui/index.d.cts +204 -3
  27. package/dist/ui/index.d.ts +204 -3
  28. package/dist/ui/index.js +2 -1
  29. package/dist/viewers/index.cjs +327 -76
  30. package/dist/viewers/index.cjs.map +1 -1
  31. package/dist/viewers/index.d.cts +90 -4
  32. package/dist/viewers/index.d.ts +90 -4
  33. package/dist/viewers/index.js +316 -78
  34. package/dist/viewers/index.js.map +1 -1
  35. package/package.json +4 -3
  36. package/dist/chunk-3MLQUKT4.cjs +0 -97
  37. package/dist/chunk-3MLQUKT4.cjs.map +0 -1
  38. package/dist/chunk-IZPXHVPJ.cjs.map +0 -1
  39. package/dist/chunk-J3XEKOV5.js +0 -87
  40. package/dist/chunk-J3XEKOV5.js.map +0 -1
  41. package/dist/chunk-YJAYST7S.js.map +0 -1
@@ -24,6 +24,10 @@ interface PdfViewerProps {
24
24
  onLoadSuccess?: (numPages: number) => void;
25
25
  /** Incremented to trigger scroll to currentPage */
26
26
  scrollTrigger?: number;
27
+ /** Zoom as a multiplier of the fit-width size (1 = fit width). Default 1. */
28
+ zoom?: number;
29
+ /** Called by pinch and double-tap; the host owns the value (its buttons change it too). */
30
+ onZoomChange?: (zoom: number) => void;
27
31
  /**
28
32
  * Called as the user scrolls, with the page occupying most of the viewport.
29
33
  * This is the reusable form of the monolith's `setViewingDocumentPage`.
@@ -38,7 +42,7 @@ interface PdfViewerProps {
38
42
  /** Where the app serves the pdf.js worker, cmaps and standard fonts from. */
39
43
  assets?: PdfViewerAssets;
40
44
  }
41
- declare function PdfViewer({ file, currentPage, onLoadSuccess, scrollTrigger, onPageChange, disableFontFace, assets, }: PdfViewerProps): React.JSX.Element;
45
+ declare function PdfViewer({ file, currentPage, onLoadSuccess, scrollTrigger, zoom, onZoomChange, onPageChange, disableFontFace, assets, }: PdfViewerProps): React.JSX.Element;
42
46
 
43
47
  type DocResponse = components['schemas']['DocResponse'];
44
48
  /** Which native renderer a file needs; `none` when there is no native view. */
@@ -129,6 +133,78 @@ interface VideoViewerProps {
129
133
  /** Renders a video blob with the browser's native transport controls. */
130
134
  declare function VideoViewer({ blob, title }: VideoViewerProps): React.JSX.Element | null;
131
135
 
136
+ /**
137
+ * Zoom for page-stacked viewers (PDF + parsed/HTML).
138
+ *
139
+ * Browser pinch-zoom only magnifies the bitmap the page already painted: a PDF canvas rendered at
140
+ * fit-width stays blurry however far you pinch. The viewers therefore own the zoom: `zoom` is a
141
+ * multiplier of the fit-width size (1 = fit width, 2 = twice as wide). The PDF viewer re-renders
142
+ * its canvases at the new scale; the parsed viewer reflows its text. This module holds the pure
143
+ * math and the touch gesture hook (pinch, double-tap) both use.
144
+ */
145
+
146
+ declare const MIN_ZOOM = 0.5;
147
+ declare const MAX_ZOOM = 4;
148
+ declare function clampZoom(zoom: number): number;
149
+ /** One zoom button step in (`1`) or out (`-1`). */
150
+ declare function stepZoom(zoom: number, direction: 1 | -1): number;
151
+ /** Double-tap toggles between fit width and a comfortable reading zoom. */
152
+ declare function doubleTapZoom(zoom: number): number;
153
+ declare function zoomLabel(zoom: number): string;
154
+ /** The zoom a pinch has reached: the start zoom scaled by how far the fingers moved apart. */
155
+ declare function pinchZoom(startZoom: number, startDistance: number, distance: number): number;
156
+ /**
157
+ * New scroll offset that keeps the content point under `focal` (a viewport coordinate) still while
158
+ * the zoom changes from `oldZoom` to `newZoom`.
159
+ */
160
+ declare function anchoredScroll(scroll: number, focal: number, oldZoom: number, newZoom: number): number;
161
+ /**
162
+ * Device pixel ratio to rasterise a page of `cssWidth` x `cssHeight` at: the device's own, capped,
163
+ * and lowered further when the page would otherwise exceed the canvas pixel budget.
164
+ */
165
+ declare function renderDpr(cssWidth: number, cssHeight: number, devicePixelRatio: number, maxPixels?: number): number;
166
+ interface PinchZoomOptions {
167
+ containerRef: RefObject<HTMLElement | null>;
168
+ zoom: number;
169
+ onZoomChange?: (zoom: number) => void;
170
+ }
171
+ /**
172
+ * Pinch and double-tap zoom on a scroller, plus focal-point preservation for EVERY zoom change
173
+ * (gesture, buttons, props): after the new zoom has laid out, the scroll offset is moved so the
174
+ * content under the pinch centre (or the viewport centre) stays where it was.
175
+ *
176
+ * The scroller must carry `touch-action: pan-x pan-y` so the browser leaves two-finger gestures
177
+ * to this hook instead of zooming the whole app.
178
+ */
179
+ declare function usePinchZoom({ containerRef, zoom, onZoomChange }: PinchZoomOptions): void;
180
+
181
+ /**
182
+ * Scroll stability for page-stacked viewers (PDF + parsed/HTML).
183
+ *
184
+ * Pages are lazy: their height can change after the user is already looking at something further
185
+ * down (a placeholder replaced by the real page, a late canvas). Browsers that implement scroll
186
+ * anchoring (Chromium) hide that; Safari/WebKit does not, so the content visibly jumps under the
187
+ * finger and momentum scrolling is cut short. The viewers therefore do their own anchoring, the
188
+ * same way on every engine: native anchoring is switched off and a page that changes height
189
+ * entirely ABOVE the viewport shifts `scrollTop` by exactly that change.
190
+ */
191
+
192
+ /** One page of a stacked viewer (both viewers mark pages like this). */
193
+ declare const PAGE_SELECTOR = "[role=\"article\"][data-page-number]";
194
+ /**
195
+ * How far to move `scrollTop` after a page changed height from `previousHeight` to `nextHeight`.
196
+ * Only a page that lay completely above the visible top edge (`containerTop`) pushes the content
197
+ * the user is reading; anything at or below it just grows or shrinks away from the viewport.
198
+ *
199
+ * @param pageTop viewport-relative top of the page (unchanged by its own resize)
200
+ */
201
+ declare function anchorDelta(previousHeight: number, nextHeight: number, pageTop: number, containerTop: number): number;
202
+ /**
203
+ * Keep the reading position when pages above it change height. `key` re-scans for pages when the
204
+ * page set changes (page count, first layout).
205
+ */
206
+ declare function useScrollAnchor(containerRef: RefObject<HTMLElement | null>, key: unknown): void;
207
+
132
208
  interface UsePageIntersectionObserverOptions {
133
209
  /** Container element that scrolls */
134
210
  containerRef: RefObject<HTMLElement | null>;
@@ -144,12 +220,22 @@ interface UsePageIntersectionObserverOptions {
144
220
  onPageChange: (pageNumber: number) => void;
145
221
  /** Optional callback with visibility data for custom logic (e.g., lazy loading) */
146
222
  onVisibilityUpdate?: (visibilityMap: Map<number, number>) => void;
223
+ /**
224
+ * Re-subscribe when this changes (the set of page elements: page count, first layout). Callers
225
+ * may pass fresh inline callbacks every render: they are read through refs and never cause a
226
+ * re-subscribe (a re-subscribe re-delivers every page and, with a state-setting callback, loops).
227
+ */
228
+ observeKey?: unknown;
147
229
  }
148
230
  /**
149
- * Custom hook to track visible pages using IntersectionObserver.
231
+ * Custom hook to track the visible page of a page-stacked viewer.
150
232
  * Used for bidirectional page synchronization in document viewers.
233
+ *
234
+ * IntersectionObserver wakes it when pages enter or leave the viewport; a passive scroll listener
235
+ * (at most one measurement per frame) keeps it current in between. The page itself is chosen from
236
+ * live geometry (see `measureVisiblePages`).
151
237
  */
152
- declare function usePageIntersectionObserver({ containerRef, scrollCompleteRef, isEnabled, getPageElements, getPageNumber, onPageChange, onVisibilityUpdate, }: UsePageIntersectionObserverOptions): void;
238
+ declare function usePageIntersectionObserver({ containerRef, scrollCompleteRef, isEnabled, getPageElements, getPageNumber, onPageChange, onVisibilityUpdate, observeKey, }: UsePageIntersectionObserverOptions): void;
153
239
 
154
240
  /** Pages to render before the visible area (canvas pre-load buffer) */
155
241
  declare const LAZY_LOAD_BUFFER_BEFORE = 2;
@@ -165,4 +251,4 @@ declare const INITIAL_LAZY_LOAD_BUFFER: {
165
251
  after: number;
166
252
  };
167
253
 
168
- export { AudioViewer, type AudioViewerProps, DocumentViewer, type DocumentViewerProps, DocxViewer, type DocxViewerProps, INITIAL_LAZY_LOAD_BUFFER, ImageViewer, type ImageViewerProps, LAZY_LOAD_BUFFER_AFTER, LAZY_LOAD_BUFFER_BEFORE, type NativeViewerKind, PdfViewer, type PdfViewerAssets, type PdfViewerProps, VideoViewer, type VideoViewerProps, XlsxViewer, type XlsxViewerProps, nativeViewerKind, usePageIntersectionObserver };
254
+ export { AudioViewer, type AudioViewerProps, DocumentViewer, type DocumentViewerProps, DocxViewer, type DocxViewerProps, INITIAL_LAZY_LOAD_BUFFER, ImageViewer, type ImageViewerProps, LAZY_LOAD_BUFFER_AFTER, LAZY_LOAD_BUFFER_BEFORE, MAX_ZOOM, MIN_ZOOM, type NativeViewerKind, PAGE_SELECTOR, PdfViewer, type PdfViewerAssets, type PdfViewerProps, VideoViewer, type VideoViewerProps, XlsxViewer, type XlsxViewerProps, anchorDelta, anchoredScroll, clampZoom, doubleTapZoom, nativeViewerKind, pinchZoom, renderDpr, stepZoom, usePageIntersectionObserver, usePinchZoom, useScrollAnchor, zoomLabel };
@@ -1,11 +1,64 @@
1
1
  import { useArbi, VideoViewer, AudioViewer, ImageViewer } from '../chunk-JKO3NR5L.js';
2
2
  export { AudioViewer, ImageViewer, VideoViewer } from '../chunk-JKO3NR5L.js';
3
- import { useEffect, useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
3
+ import { useEffect, useRef, useCallback, useLayoutEffect, useState, useMemo } from 'react';
4
4
  import { Document, Page, pdfjs } from 'react-pdf';
5
5
  import 'react-pdf/dist/Page/AnnotationLayer.css';
6
6
  import 'react-pdf/dist/Page/TextLayer.css';
7
7
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
8
8
 
9
+ var PAGE_SELECTOR = '[role="article"][data-page-number]';
10
+ function anchorDelta(previousHeight, nextHeight, pageTop, containerTop) {
11
+ const delta = nextHeight - previousHeight;
12
+ if (delta === 0) return 0;
13
+ return pageTop + previousHeight <= containerTop + 1 ? delta : 0;
14
+ }
15
+ function measureVisiblePages(pages, viewTop, viewBottom) {
16
+ const visibility = /* @__PURE__ */ new Map();
17
+ let page = null;
18
+ let bestPx = 0;
19
+ let score = 0;
20
+ const viewHeight = viewBottom - viewTop;
21
+ for (const p of pages) {
22
+ const height = p.bottom - p.top;
23
+ const px = Math.min(p.bottom, viewBottom) - Math.max(p.top, viewTop);
24
+ if (px <= 0 || height <= 0) continue;
25
+ visibility.set(p.n, px / height);
26
+ if (px > bestPx) {
27
+ bestPx = px;
28
+ page = p.n;
29
+ score = px / Math.min(height, viewHeight);
30
+ }
31
+ }
32
+ return { page, score, visibility };
33
+ }
34
+ function useScrollAnchor(containerRef, key) {
35
+ useEffect(() => {
36
+ const container = containerRef.current;
37
+ if (!container || typeof ResizeObserver === "undefined") return;
38
+ container.style.overflowAnchor = "none";
39
+ const heights = /* @__PURE__ */ new Map();
40
+ const observer = new ResizeObserver((entries) => {
41
+ let shift = 0;
42
+ const containerTop = container.getBoundingClientRect().top;
43
+ for (const entry of entries) {
44
+ const el = entry.target;
45
+ const height = el.getBoundingClientRect().height;
46
+ const previous = heights.get(el);
47
+ heights.set(el, height);
48
+ if (previous === void 0) continue;
49
+ shift += anchorDelta(previous, height, el.getBoundingClientRect().top, containerTop);
50
+ }
51
+ if (shift !== 0 && !container.dataset.zooming) container.scrollTop += shift;
52
+ });
53
+ container.querySelectorAll(PAGE_SELECTOR).forEach((el) => {
54
+ heights.set(el, el.getBoundingClientRect().height);
55
+ observer.observe(el);
56
+ });
57
+ return () => observer.disconnect();
58
+ }, [containerRef, key]);
59
+ }
60
+
61
+ // src/viewers/usePageIntersectionObserver.ts
9
62
  var PAGE_UPDATE_DEBOUNCE_MS = 10;
10
63
  var MIN_VISIBILITY_THRESHOLD = 0.15;
11
64
  var INTERSECTION_THRESHOLDS = [0, 0.25, 0.5, 0.75, 1];
@@ -16,66 +69,194 @@ function usePageIntersectionObserver({
16
69
  getPageElements,
17
70
  getPageNumber,
18
71
  onPageChange,
19
- onVisibilityUpdate
72
+ onVisibilityUpdate,
73
+ observeKey
20
74
  }) {
75
+ const latest = useRef({ getPageElements, getPageNumber, onPageChange, onVisibilityUpdate });
76
+ latest.current = { getPageElements, getPageNumber, onPageChange, onVisibilityUpdate };
21
77
  useEffect(() => {
22
78
  if (!isEnabled || !containerRef.current) return;
23
79
  const container = containerRef.current;
24
- const visibilityMap = /* @__PURE__ */ new Map();
25
80
  let lastReportedPage = null;
26
81
  let updateTimeout = null;
27
- const updatePageNumber = () => {
28
- let mostVisiblePage = 1;
29
- let maxVisibility = 0;
30
- visibilityMap.forEach((ratio, pageNum) => {
31
- if (ratio > maxVisibility) {
32
- maxVisibility = ratio;
33
- mostVisiblePage = pageNum;
34
- }
35
- });
36
- if (scrollCompleteRef.current && mostVisiblePage !== lastReportedPage && maxVisibility > MIN_VISIBILITY_THRESHOLD) {
37
- lastReportedPage = mostVisiblePage;
38
- onPageChange(mostVisiblePage);
82
+ let frame = 0;
83
+ const evaluate = () => {
84
+ const root = container.getBoundingClientRect();
85
+ const pages = [];
86
+ for (const el of latest.current.getPageElements()) {
87
+ const n = latest.current.getPageNumber(el);
88
+ if (n <= 0) continue;
89
+ const r = el.getBoundingClientRect();
90
+ pages.push({ n, top: r.top, bottom: r.bottom });
39
91
  }
40
- if (onVisibilityUpdate) {
41
- onVisibilityUpdate(new Map(visibilityMap));
92
+ const { page, score, visibility } = measureVisiblePages(pages, root.top, root.bottom);
93
+ if (scrollCompleteRef.current && page !== null && page !== lastReportedPage && score > MIN_VISIBILITY_THRESHOLD) {
94
+ lastReportedPage = page;
95
+ latest.current.onPageChange(page);
42
96
  }
97
+ latest.current.onVisibilityUpdate?.(visibility);
43
98
  };
44
99
  const observer = new IntersectionObserver(
45
- (entries) => {
46
- entries.forEach((entry) => {
47
- const pageNum = getPageNumber(entry.target);
48
- if (pageNum > 0) {
49
- visibilityMap.set(pageNum, entry.intersectionRatio);
50
- }
51
- });
52
- if (updateTimeout) {
53
- clearTimeout(updateTimeout);
54
- }
55
- updateTimeout = setTimeout(updatePageNumber, PAGE_UPDATE_DEBOUNCE_MS);
100
+ () => {
101
+ if (updateTimeout) clearTimeout(updateTimeout);
102
+ updateTimeout = setTimeout(evaluate, PAGE_UPDATE_DEBOUNCE_MS);
56
103
  },
57
- {
58
- root: container,
59
- threshold: INTERSECTION_THRESHOLDS
60
- }
104
+ { root: container, threshold: INTERSECTION_THRESHOLDS }
61
105
  );
62
- const elements = getPageElements();
63
- elements.forEach((element) => observer.observe(element));
106
+ const onScroll = () => {
107
+ if (!frame) {
108
+ frame = requestAnimationFrame(() => {
109
+ frame = 0;
110
+ evaluate();
111
+ });
112
+ }
113
+ };
114
+ container.addEventListener("scroll", onScroll, { passive: true });
115
+ latest.current.getPageElements().forEach((element) => observer.observe(element));
64
116
  return () => {
65
117
  observer.disconnect();
66
- if (updateTimeout) {
67
- clearTimeout(updateTimeout);
118
+ container.removeEventListener("scroll", onScroll);
119
+ if (updateTimeout) clearTimeout(updateTimeout);
120
+ if (frame) cancelAnimationFrame(frame);
121
+ };
122
+ }, [containerRef, scrollCompleteRef, isEnabled, observeKey]);
123
+ }
124
+ var MIN_ZOOM = 0.5;
125
+ var MAX_ZOOM = 4;
126
+ var ZOOM_STEP = 1.25;
127
+ var DOUBLE_TAP_ZOOM = 2;
128
+ var MAX_CANVAS_PIXELS = 8e6;
129
+ var MAX_RENDER_DPR = 2;
130
+ function clampZoom(zoom) {
131
+ if (!Number.isFinite(zoom)) return 1;
132
+ return Math.round(Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)) * 100) / 100;
133
+ }
134
+ function stepZoom(zoom, direction) {
135
+ return clampZoom(direction === 1 ? zoom * ZOOM_STEP : zoom / ZOOM_STEP);
136
+ }
137
+ function doubleTapZoom(zoom) {
138
+ return zoom > 1.05 ? 1 : DOUBLE_TAP_ZOOM;
139
+ }
140
+ function zoomLabel(zoom) {
141
+ return `${Math.round(zoom * 100)}%`;
142
+ }
143
+ function pinchZoom(startZoom, startDistance, distance2) {
144
+ if (startDistance <= 0) return startZoom;
145
+ return clampZoom(startZoom * distance2 / startDistance);
146
+ }
147
+ function anchoredScroll(scroll, focal, oldZoom, newZoom) {
148
+ if (oldZoom <= 0) return scroll;
149
+ return (scroll + focal) * newZoom / oldZoom - focal;
150
+ }
151
+ function renderDpr(cssWidth, cssHeight, devicePixelRatio, maxPixels = MAX_CANVAS_PIXELS) {
152
+ const wanted = Math.min(Math.max(devicePixelRatio || 1, 1), MAX_RENDER_DPR);
153
+ const area = cssWidth * cssHeight;
154
+ if (area <= 0) return wanted;
155
+ const fits = Math.sqrt(maxPixels / area);
156
+ return Math.max(1, Math.min(wanted, fits));
157
+ }
158
+ var distance = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
159
+ var INTERACTIVE = 'button, a, input, textarea, select, [role="button"]';
160
+ var DOUBLE_TAP_MS = 300;
161
+ var DOUBLE_TAP_SLOP_PX = 30;
162
+ function usePinchZoom({ containerRef, zoom, onZoomChange }) {
163
+ const zoomRef = useRef(zoom);
164
+ const onChangeRef = useRef(onZoomChange);
165
+ onChangeRef.current = onZoomChange;
166
+ const focalRef = useRef(null);
167
+ const appliedZoomRef = useRef(zoom);
168
+ const request = useCallback((next, focal) => {
169
+ const clamped = clampZoom(next);
170
+ if (clamped === zoomRef.current) return;
171
+ focalRef.current = focal;
172
+ zoomRef.current = clamped;
173
+ onChangeRef.current?.(clamped);
174
+ }, []);
175
+ useLayoutEffect(() => {
176
+ zoomRef.current = zoom;
177
+ const container = containerRef.current;
178
+ const previous = appliedZoomRef.current;
179
+ appliedZoomRef.current = zoom;
180
+ if (!container || previous === zoom) return;
181
+ const rect = container.getBoundingClientRect();
182
+ const focal = focalRef.current ?? { x: rect.width / 2, y: rect.height / 2 };
183
+ focalRef.current = null;
184
+ container.dataset.zooming = "true";
185
+ container.scrollLeft = anchoredScroll(container.scrollLeft, focal.x, previous, zoom);
186
+ container.scrollTop = anchoredScroll(container.scrollTop, focal.y, previous, zoom);
187
+ requestAnimationFrame(
188
+ () => requestAnimationFrame(() => {
189
+ delete container.dataset.zooming;
190
+ })
191
+ );
192
+ }, [zoom, containerRef]);
193
+ useEffect(() => {
194
+ const el = containerRef.current;
195
+ if (!el) return;
196
+ let pinch = null;
197
+ let lastTap = null;
198
+ let frame = 0;
199
+ let pending = null;
200
+ const focalOf = (a, b) => {
201
+ const rect = el.getBoundingClientRect();
202
+ return {
203
+ x: (a.clientX + b.clientX) / 2 - rect.left,
204
+ y: (a.clientY + b.clientY) / 2 - rect.top
205
+ };
206
+ };
207
+ const flush = () => {
208
+ frame = 0;
209
+ if (pending) request(pending.zoom, pending.focal);
210
+ pending = null;
211
+ };
212
+ const onStart = (e) => {
213
+ if (e.touches.length === 2) {
214
+ pinch = { startDistance: distance(e.touches[0], e.touches[1]), startZoom: zoomRef.current };
215
+ lastTap = null;
68
216
  }
69
217
  };
70
- }, [
71
- containerRef,
72
- scrollCompleteRef,
73
- isEnabled,
74
- getPageElements,
75
- getPageNumber,
76
- onPageChange,
77
- onVisibilityUpdate
78
- ]);
218
+ const onMove = (e) => {
219
+ if (!pinch || e.touches.length !== 2) return;
220
+ e.preventDefault();
221
+ pending = {
222
+ zoom: pinchZoom(pinch.startZoom, pinch.startDistance, distance(e.touches[0], e.touches[1])),
223
+ focal: focalOf(e.touches[0], e.touches[1])
224
+ };
225
+ if (!frame) frame = requestAnimationFrame(flush);
226
+ };
227
+ const onEnd = (e) => {
228
+ if (pinch && e.touches.length < 2) {
229
+ pinch = null;
230
+ return;
231
+ }
232
+ if (e.touches.length !== 0 || e.changedTouches.length !== 1) return;
233
+ const t = e.changedTouches[0];
234
+ if (e.target?.closest?.(INTERACTIVE)) return;
235
+ const now = e.timeStamp;
236
+ if (lastTap && now - lastTap.time < DOUBLE_TAP_MS && Math.hypot(t.clientX - lastTap.x, t.clientY - lastTap.y) < DOUBLE_TAP_SLOP_PX) {
237
+ e.preventDefault();
238
+ const rect = el.getBoundingClientRect();
239
+ request(doubleTapZoom(zoomRef.current), {
240
+ x: t.clientX - rect.left,
241
+ y: t.clientY - rect.top
242
+ });
243
+ lastTap = null;
244
+ } else {
245
+ lastTap = { time: now, x: t.clientX, y: t.clientY };
246
+ }
247
+ };
248
+ el.addEventListener("touchstart", onStart, { passive: true });
249
+ el.addEventListener("touchmove", onMove, { passive: false });
250
+ el.addEventListener("touchend", onEnd, { passive: false });
251
+ el.addEventListener("touchcancel", onEnd, { passive: false });
252
+ return () => {
253
+ el.removeEventListener("touchstart", onStart);
254
+ el.removeEventListener("touchmove", onMove);
255
+ el.removeEventListener("touchend", onEnd);
256
+ el.removeEventListener("touchcancel", onEnd);
257
+ if (frame) cancelAnimationFrame(frame);
258
+ };
259
+ }, [containerRef, request]);
79
260
  }
80
261
 
81
262
  // src/viewers/pdf-viewer-constants.ts
@@ -93,14 +274,28 @@ function ensurePdfWorker(workerSrc) {
93
274
  pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
94
275
  }
95
276
  }
96
- var CONTAINER_PADDING_PX = 32;
97
- var PAGE_ASPECT_RATIO = 1.414;
277
+ var CONTAINER_PADDING_PX = 34;
278
+ var DEFAULT_PAGE_ASPECT_RATIO = 1.414;
279
+ var PAGE_BORDER_PX = 2;
280
+ var RERENDER_DEBOUNCE_MS = 250;
281
+ var ZOOMED_BUFFER_THRESHOLD = 1.5;
282
+ function sameSet(a, b) {
283
+ if (a.size !== b.size) return false;
284
+ for (const v of a) if (!b.has(v)) return false;
285
+ return true;
286
+ }
98
287
  var TEXT_LAYER_BUFFER = 1;
288
+ var ratioOf = (page) => {
289
+ const { width, height } = page.getViewport({ scale: 1 });
290
+ return width > 0 && height > 0 ? height / width : DEFAULT_PAGE_ASPECT_RATIO;
291
+ };
99
292
  function PdfViewer({
100
293
  file,
101
294
  currentPage,
102
295
  onLoadSuccess,
103
296
  scrollTrigger,
297
+ zoom = 1,
298
+ onZoomChange,
104
299
  onPageChange,
105
300
  disableFontFace = false,
106
301
  assets
@@ -112,6 +307,8 @@ function PdfViewer({
112
307
  ensurePdfWorker(workerSrc);
113
308
  const [numPages, setNumPages] = useState(0);
114
309
  const [containerWidth, setContainerWidth] = useState(0);
310
+ const [defaultRatio, setDefaultRatio] = useState(DEFAULT_PAGE_ASPECT_RATIO);
311
+ const [pageRatios, setPageRatios] = useState(/* @__PURE__ */ new Map());
115
312
  const [loadError, setLoadError] = useState(null);
116
313
  const [documentKey, setDocumentKey] = useState(() => `pdf-${Date.now()}`);
117
314
  const initialPage = currentPage || 1;
@@ -131,7 +328,10 @@ function PdfViewer({
131
328
  const containerRef = useRef(null);
132
329
  const pageRefs = useRef(/* @__PURE__ */ new Map());
133
330
  const onDocumentLoadSuccess = useCallback(
134
- ({ numPages: n }) => {
331
+ (pdf) => {
332
+ const n = pdf.numPages;
333
+ void pdf.getPage?.(1).then((page) => setDefaultRatio(ratioOf(page))).catch(() => {
334
+ });
135
335
  setNumPages(n);
136
336
  setLoadError(null);
137
337
  const expand = () => {
@@ -210,45 +410,51 @@ function PdfViewer({
210
410
  });
211
411
  }
212
412
  }, [numPages]);
413
+ const currentPageRef = useRef(currentPage);
414
+ currentPageRef.current = currentPage;
213
415
  useEffect(() => {
214
- if (!scrollTrigger || numPages === 0 || !currentPage) return;
416
+ const currentPage2 = currentPageRef.current;
417
+ if (!scrollTrigger || numPages === 0 || !currentPage2) return;
215
418
  scrollCompleteRef.current = false;
216
419
  setRenderedPages((prev) => {
217
420
  const newSet = new Set(prev);
218
- for (let i = Math.max(1, currentPage - LAZY_LOAD_BUFFER_BEFORE); i <= Math.min(numPages, currentPage + LAZY_LOAD_BUFFER_AFTER); i++) {
421
+ for (let i = Math.max(1, currentPage2 - LAZY_LOAD_BUFFER_BEFORE); i <= Math.min(numPages, currentPage2 + LAZY_LOAD_BUFFER_AFTER); i++) {
219
422
  newSet.add(i);
220
423
  }
221
424
  return newSet;
222
425
  });
223
426
  requestAnimationFrame(() => {
224
427
  requestAnimationFrame(() => {
225
- const pageElement = pageRefs.current.get(currentPage);
428
+ const pageElement = pageRefs.current.get(currentPage2);
226
429
  if (pageElement) {
227
430
  pageElement.scrollIntoView({ behavior: "instant", block: "start" });
228
431
  }
229
432
  scrollCompleteRef.current = true;
230
433
  });
231
434
  });
232
- }, [scrollTrigger, currentPage, numPages]);
435
+ }, [scrollTrigger, numPages]);
233
436
  const handleVisibilityUpdate = useCallback(
234
437
  (visibilityMap) => {
235
438
  const visiblePageNums = Array.from(visibilityMap.entries()).filter(([, ratio]) => ratio > 0).map(([pageNum]) => pageNum);
236
439
  if (visiblePageNums.length > 0) {
237
440
  const minVisible = Math.min(...visiblePageNums);
238
441
  const maxVisible = Math.max(...visiblePageNums);
442
+ const zoomed = zoom > ZOOMED_BUFFER_THRESHOLD;
443
+ const before = zoomed ? 1 : LAZY_LOAD_BUFFER_BEFORE;
444
+ const after = zoomed ? 2 : LAZY_LOAD_BUFFER_AFTER;
239
445
  const pagesToRender = /* @__PURE__ */ new Set();
240
- for (let i = Math.max(1, minVisible - LAZY_LOAD_BUFFER_BEFORE); i <= Math.min(numPages, maxVisible + LAZY_LOAD_BUFFER_AFTER); i++) {
446
+ for (let i = Math.max(1, minVisible - before); i <= Math.min(numPages, maxVisible + after); i++) {
241
447
  pagesToRender.add(i);
242
448
  }
243
- setRenderedPages(pagesToRender);
449
+ setRenderedPages((prev) => sameSet(prev, pagesToRender) ? prev : pagesToRender);
244
450
  const nearViewport = /* @__PURE__ */ new Set();
245
451
  for (let i = Math.max(1, minVisible - TEXT_LAYER_BUFFER); i <= Math.min(numPages, maxVisible + TEXT_LAYER_BUFFER); i++) {
246
452
  nearViewport.add(i);
247
453
  }
248
- setVisiblePages(nearViewport);
454
+ setVisiblePages((prev) => sameSet(prev, nearViewport) ? prev : nearViewport);
249
455
  }
250
456
  },
251
- [numPages]
457
+ [numPages, zoom]
252
458
  );
253
459
  const documentOptions = useMemo(
254
460
  () => ({
@@ -271,19 +477,30 @@ function PdfViewer({
271
477
  getPageElements: () => Array.from(pageRefs.current.values()),
272
478
  getPageNumber: (element) => parseInt(element.getAttribute("data-page-number") ?? "0"),
273
479
  onPageChange: handlePageChange,
274
- onVisibilityUpdate: handleVisibilityUpdate
480
+ onVisibilityUpdate: handleVisibilityUpdate,
481
+ observeKey: `${numPages}:${containerWidth > 0}`
275
482
  });
483
+ useScrollAnchor(containerRef, `${numPages}:${containerWidth > 0}`);
484
+ usePinchZoom({ containerRef, zoom, onZoomChange });
485
+ const [renderZoom, setRenderZoom] = useState(zoom);
486
+ useEffect(() => {
487
+ if (zoom === renderZoom) return;
488
+ const timer = setTimeout(() => setRenderZoom(zoom), RERENDER_DEBOUNCE_MS);
489
+ return () => clearTimeout(timer);
490
+ }, [zoom, renderZoom]);
276
491
  return /* @__PURE__ */ jsx(
277
492
  "div",
278
493
  {
279
494
  ref: containerRef,
280
- className: "w-full h-full overflow-y-auto overflow-x-hidden bg-muted flex flex-col items-center p-4",
495
+ className: "w-full h-full overflow-auto bg-muted flex flex-col items-start p-4 pb-20",
496
+ style: { touchAction: "pan-x pan-y" },
281
497
  role: "region",
282
498
  "aria-label": "PDF document viewer",
283
499
  "data-testid": "pdf-viewer",
284
500
  children: /* @__PURE__ */ jsx(
285
501
  Document,
286
502
  {
503
+ className: "flex w-max min-w-full flex-col",
287
504
  file,
288
505
  onLoadSuccess: onDocumentLoadSuccess,
289
506
  onLoadError: onDocumentLoadError,
@@ -300,7 +517,11 @@ function PdfViewer({
300
517
  children: containerWidth > 0 && Array.from(new Array(numPages), (_, index) => {
301
518
  const pageNumber = index + 1;
302
519
  const shouldRender = renderedPages.has(pageNumber);
303
- const pageHeight = containerWidth * PAGE_ASPECT_RATIO;
520
+ const ratio = pageRatios.get(pageNumber) ?? defaultRatio;
521
+ const pageWidth = containerWidth * zoom;
522
+ const pageHeight = pageWidth * ratio;
523
+ const renderWidth = containerWidth * renderZoom;
524
+ const scale = zoom / renderZoom;
304
525
  const shouldRenderTextLayer = visiblePages.has(pageNumber);
305
526
  return /* @__PURE__ */ jsx(
306
527
  "div",
@@ -313,28 +534,45 @@ function PdfViewer({
313
534
  }
314
535
  },
315
536
  "data-page-number": pageNumber,
316
- className: "mb-6 shadow-xl bg-white border border-border",
317
- style: !shouldRender ? { minHeight: pageHeight } : void 0,
537
+ className: "mx-auto mb-6 shrink-0 shadow-xl bg-white border border-border",
538
+ style: { width: pageWidth + PAGE_BORDER_PX, height: pageHeight + PAGE_BORDER_PX },
318
539
  role: "article",
319
540
  "aria-label": `Page ${pageNumber} of ${numPages}`,
320
541
  children: shouldRender ? /* @__PURE__ */ jsx(
321
- Page,
542
+ "div",
322
543
  {
323
- pageNumber,
324
- width: containerWidth,
325
- renderTextLayer: shouldRenderTextLayer,
326
- renderAnnotationLayer: false,
327
- devicePixelRatio: Math.min(window.devicePixelRatio || 1, 2),
328
- loading: /* @__PURE__ */ jsx(
329
- "div",
544
+ "data-testid": `pdf-page-scaler-${pageNumber}`,
545
+ style: scale === 1 ? void 0 : { transform: `scale(${scale})`, transformOrigin: "0 0" },
546
+ children: /* @__PURE__ */ jsx(
547
+ Page,
330
548
  {
331
- className: "flex items-center justify-center bg-muted",
332
- style: { width: containerWidth, height: pageHeight },
333
- children: /* @__PURE__ */ jsxs("div", { className: "text-sm text-muted-foreground", children: [
334
- "Loading page ",
335
- pageNumber,
336
- "..."
337
- ] })
549
+ pageNumber,
550
+ width: renderWidth,
551
+ renderTextLayer: shouldRenderTextLayer,
552
+ renderAnnotationLayer: false,
553
+ onLoadSuccess: (page) => {
554
+ const ratio2 = page.originalHeight / page.originalWidth;
555
+ setPageRatios(
556
+ (prev) => Math.abs((prev.get(pageNumber) ?? defaultRatio) - ratio2) < 1e-3 ? prev : new Map(prev).set(pageNumber, ratio2)
557
+ );
558
+ },
559
+ devicePixelRatio: renderDpr(
560
+ renderWidth,
561
+ renderWidth * ratio,
562
+ window.devicePixelRatio || 1
563
+ ),
564
+ loading: /* @__PURE__ */ jsx(
565
+ "div",
566
+ {
567
+ className: "flex items-center justify-center bg-muted",
568
+ style: { width: renderWidth, height: renderWidth * ratio },
569
+ children: /* @__PURE__ */ jsxs("div", { className: "text-sm text-muted-foreground", children: [
570
+ "Loading page ",
571
+ pageNumber,
572
+ "..."
573
+ ] })
574
+ }
575
+ )
338
576
  }
339
577
  )
340
578
  }
@@ -342,7 +580,7 @@ function PdfViewer({
342
580
  "div",
343
581
  {
344
582
  className: "flex items-center justify-center bg-muted",
345
- style: { width: containerWidth, height: pageHeight },
583
+ style: { width: pageWidth, height: pageHeight },
346
584
  children: /* @__PURE__ */ jsxs("div", { className: "text-sm text-muted-foreground", children: [
347
585
  "Page ",
348
586
  pageNumber
@@ -647,6 +885,6 @@ function DocumentViewer({
647
885
  return className ? /* @__PURE__ */ jsx("div", { className, "data-testid": "document-viewer", children: body }) : body;
648
886
  }
649
887
 
650
- export { DocumentViewer, DocxViewer, INITIAL_LAZY_LOAD_BUFFER, LAZY_LOAD_BUFFER_AFTER, LAZY_LOAD_BUFFER_BEFORE, PdfViewer, XlsxViewer, nativeViewerKind, usePageIntersectionObserver };
888
+ export { DocumentViewer, DocxViewer, INITIAL_LAZY_LOAD_BUFFER, LAZY_LOAD_BUFFER_AFTER, LAZY_LOAD_BUFFER_BEFORE, MAX_ZOOM, MIN_ZOOM, PAGE_SELECTOR, PdfViewer, XlsxViewer, anchorDelta, anchoredScroll, clampZoom, doubleTapZoom, nativeViewerKind, pinchZoom, renderDpr, stepZoom, usePageIntersectionObserver, usePinchZoom, useScrollAnchor, zoomLabel };
651
889
  //# sourceMappingURL=index.js.map
652
890
  //# sourceMappingURL=index.js.map