@@ -18,24 +18,73 @@ interface PdfViewerProps {
1818 isPublic ?: boolean ;
1919}
2020
21+ interface PageDimensions {
22+ width : number ;
23+ height : number ;
24+ }
25+
2126const ZOOM_STEP = 0.15 ;
2227const MIN_ZOOM = 0.5 ;
2328const MAX_ZOOM = 3 ;
2429const PAGE_GAP = 12 ;
30+ const SCROLL_DEBOUNCE_MS = 30 ;
31+ const BUFFER_PAGES = 1 ;
2532
2633export function PdfViewer ( { pdfUrl, isPublic = false } : PdfViewerProps ) {
2734 const [ numPages , setNumPages ] = useState ( 0 ) ;
2835 const [ scale , setScale ] = useState ( 1 ) ;
2936 const [ loading , setLoading ] = useState ( true ) ;
3037 const [ loadError , setLoadError ] = useState < string | null > ( null ) ;
31- const [ currentPage , setCurrentPage ] = useState ( 1 ) ;
3238
3339 const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
34- const pagesContainerRef = useRef < HTMLDivElement > ( null ) ;
3540 const pdfDocRef = useRef < PDFDocumentProxy | null > ( null ) ;
3641 const canvasRefs = useRef < Map < number , HTMLCanvasElement > > ( new Map ( ) ) ;
3742 const renderTasksRef = useRef < Map < number , RenderTask > > ( new Map ( ) ) ;
3843 const renderedScalesRef = useRef < Map < number , number > > ( new Map ( ) ) ;
44+ const pageDimsRef = useRef < PageDimensions [ ] > ( [ ] ) ;
45+ const visiblePagesRef = useRef < Set < number > > ( new Set ( ) ) ;
46+ const scrollTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
47+
48+ const getScaledHeight = useCallback (
49+ ( pageIndex : number ) => {
50+ const dims = pageDimsRef . current [ pageIndex ] ;
51+ return dims ? Math . floor ( dims . height * scale ) : 0 ;
52+ } ,
53+ [ scale ] ,
54+ ) ;
55+
56+ const getVisibleRange = useCallback ( ( ) => {
57+ const container = scrollContainerRef . current ;
58+ if ( ! container || pageDimsRef . current . length === 0 ) return { first : 1 , last : 1 } ;
59+
60+ const scrollTop = container . scrollTop ;
61+ const viewportHeight = container . clientHeight ;
62+ const scrollBottom = scrollTop + viewportHeight ;
63+
64+ let cumTop = 16 ;
65+ let first = 1 ;
66+ let last = pageDimsRef . current . length ;
67+
68+ for ( let i = 0 ; i < pageDimsRef . current . length ; i ++ ) {
69+ const pageHeight = getScaledHeight ( i ) ;
70+ const pageBottom = cumTop + pageHeight ;
71+
72+ if ( pageBottom >= scrollTop && first === 1 ) {
73+ first = i + 1 ;
74+ }
75+ if ( cumTop > scrollBottom ) {
76+ last = i ;
77+ break ;
78+ }
79+
80+ cumTop = pageBottom + PAGE_GAP ;
81+ }
82+
83+ first = Math . max ( 1 , first - BUFFER_PAGES ) ;
84+ last = Math . min ( pageDimsRef . current . length , last + BUFFER_PAGES ) ;
85+
86+ return { first, last } ;
87+ } , [ getScaledHeight ] ) ;
3988
4089 const renderPage = useCallback ( async ( pageNum : number , currentScale : number ) => {
4190 const pdf = pdfDocRef . current ;
@@ -71,20 +120,59 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
71120 await renderTask . promise ;
72121 renderTasksRef . current . delete ( pageNum ) ;
73122 renderedScalesRef . current . set ( pageNum , currentScale ) ;
123+ page . cleanup ( ) ;
74124 } catch ( err : unknown ) {
75125 if ( err instanceof Error && err . message ?. includes ( "cancelled" ) ) return ;
76126 console . error ( `Failed to render page ${ pageNum } :` , err ) ;
77127 }
78128 } , [ ] ) ;
79129
130+ const cleanupPage = useCallback ( ( pageNum : number ) => {
131+ const existing = renderTasksRef . current . get ( pageNum ) ;
132+ if ( existing ) {
133+ existing . cancel ( ) ;
134+ renderTasksRef . current . delete ( pageNum ) ;
135+ }
136+
137+ const canvas = canvasRefs . current . get ( pageNum ) ;
138+ if ( canvas ) {
139+ const ctx = canvas . getContext ( "2d" ) ;
140+ if ( ctx ) ctx . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
141+ canvas . width = 0 ;
142+ canvas . height = 0 ;
143+ }
144+
145+ renderedScalesRef . current . delete ( pageNum ) ;
146+ } , [ ] ) ;
147+
148+ const renderVisiblePages = useCallback ( ( ) => {
149+ if ( ! pdfDocRef . current || pageDimsRef . current . length === 0 ) return ;
150+
151+ const { first, last } = getVisibleRange ( ) ;
152+ const newVisible = new Set < number > ( ) ;
153+
154+ for ( let i = first ; i <= last ; i ++ ) {
155+ newVisible . add ( i ) ;
156+ renderPage ( i , scale ) ;
157+ }
158+
159+ for ( const pageNum of visiblePagesRef . current ) {
160+ if ( ! newVisible . has ( pageNum ) ) {
161+ cleanupPage ( pageNum ) ;
162+ }
163+ }
164+
165+ visiblePagesRef . current = newVisible ;
166+ } , [ getVisibleRange , renderPage , cleanupPage , scale ] ) ;
167+
80168 useEffect ( ( ) => {
81169 let cancelled = false ;
82170
83171 const loadDocument = async ( ) => {
84172 setLoading ( true ) ;
85173 setLoadError ( null ) ;
86174 setNumPages ( 0 ) ;
87- setCurrentPage ( 1 ) ;
175+ pageDimsRef . current = [ ] ;
88176
89177 try {
90178 const loadingTask = pdfjsLib . getDocument ( {
@@ -98,7 +186,21 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
98186 return ;
99187 }
100188
189+ const dims : PageDimensions [ ] = [ ] ;
190+ for ( let i = 1 ; i <= pdf . numPages ; i ++ ) {
191+ const page = await pdf . getPage ( i ) ;
192+ const viewport = page . getViewport ( { scale : 1 } ) ;
193+ dims . push ( { width : viewport . width , height : viewport . height } ) ;
194+ page . cleanup ( ) ;
195+ }
196+
197+ if ( cancelled ) {
198+ pdf . destroy ( ) ;
199+ return ;
200+ }
201+
101202 pdfDocRef . current = pdf ;
203+ pageDimsRef . current = dims ;
102204 setNumPages ( pdf . numPages ) ;
103205 setLoading ( false ) ;
104206 } catch ( err : unknown ) {
@@ -118,52 +220,42 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
118220 }
119221 renderTasksRef . current . clear ( ) ;
120222 renderedScalesRef . current . clear ( ) ;
223+ visiblePagesRef . current . clear ( ) ;
121224 pdfDocRef . current ?. destroy ( ) ;
122225 pdfDocRef . current = null ;
123226 } ;
124227 } , [ pdfUrl ] ) ;
125228
126229 useEffect ( ( ) => {
127- if ( ! pdfDocRef . current || numPages === 0 ) return ;
230+ if ( numPages === 0 ) return ;
128231
129232 renderedScalesRef . current . clear ( ) ;
233+ visiblePagesRef . current . clear ( ) ;
130234
131- for ( let i = 1 ; i <= numPages ; i ++ ) {
132- renderPage ( i , scale ) ;
133- }
134- } , [ scale , numPages , renderPage ] ) ;
235+ const frame = requestAnimationFrame ( ( ) => {
236+ renderVisiblePages ( ) ;
237+ } ) ;
238+
239+ return ( ) => cancelAnimationFrame ( frame ) ;
240+ } , [ numPages , renderVisiblePages ] ) ;
135241
136242 useEffect ( ( ) => {
137243 const container = scrollContainerRef . current ;
138- if ( ! container || numPages <= 1 ) return ;
244+ if ( ! container || numPages === 0 ) return ;
139245
140246 const handleScroll = ( ) => {
141- const canvases = canvasRefs . current ;
142- const containerTop = container . scrollTop ;
143- const containerMid = containerTop + container . clientHeight / 2 ;
144-
145- let closest = 1 ;
146- let closestDist = Number . POSITIVE_INFINITY ;
147-
148- for ( let i = 1 ; i <= numPages ; i ++ ) {
149- const canvas = canvases . get ( i ) ;
150- if ( ! canvas ) continue ;
151- const rect = canvas . getBoundingClientRect ( ) ;
152- const containerRect = container . getBoundingClientRect ( ) ;
153- const canvasMid = rect . top - containerRect . top + containerTop + rect . height / 2 ;
154- const dist = Math . abs ( canvasMid - containerMid ) ;
155- if ( dist < closestDist ) {
156- closestDist = dist ;
157- closest = i ;
158- }
159- }
160-
161- setCurrentPage ( closest ) ;
247+ if ( scrollTimerRef . current ) clearTimeout ( scrollTimerRef . current ) ;
248+ scrollTimerRef . current = setTimeout ( ( ) => {
249+ renderVisiblePages ( ) ;
250+ } , SCROLL_DEBOUNCE_MS ) ;
162251 } ;
163252
164253 container . addEventListener ( "scroll" , handleScroll , { passive : true } ) ;
165- return ( ) => container . removeEventListener ( "scroll" , handleScroll ) ;
166- } , [ numPages ] ) ;
254+ return ( ) => {
255+ container . removeEventListener ( "scroll" , handleScroll ) ;
256+ if ( scrollTimerRef . current ) clearTimeout ( scrollTimerRef . current ) ;
257+ } ;
258+ } , [ numPages , renderVisiblePages ] ) ;
167259
168260 const setCanvasRef = useCallback ( ( pageNum : number , el : HTMLCanvasElement | null ) => {
169261 if ( el ) {
@@ -184,7 +276,7 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
184276 if ( loadError ) {
185277 return (
186278 < div className = "flex flex-col items-center justify-center h-full gap-3 p-6 text-center" >
187- < p className = "font-medium text-foreground" > Failed to load resume preview </ p >
279+ < p className = "font-medium text-foreground" > Failed to load PDF </ p >
188280 < p className = "text-sm text-muted-foreground" > { loadError } </ p >
189281 </ div >
190282 ) ;
@@ -194,14 +286,6 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
194286 < div className = "flex flex-col h-full" >
195287 { numPages > 0 && (
196288 < div className = { `flex items-center justify-center gap-2 px-4 py-2 border-b shrink-0 ${ isPublic ? "bg-main-panel" : "bg-sidebar" } ` } >
197- { numPages > 1 && (
198- < >
199- < span className = "text-xs text-muted-foreground tabular-nums min-w-[60px] text-center" >
200- { currentPage } / { numPages }
201- </ span >
202- < div className = "w-px h-4 bg-border mx-1" />
203- </ >
204- ) }
205289 < Button variant = "ghost" size = "icon" onClick = { zoomOut } disabled = { scale <= MIN_ZOOM } className = "size-7" >
206290 < ZoomOutIcon className = "size-4" />
207291 </ Button >
@@ -223,18 +307,29 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
223307 < Spinner size = "md" />
224308 </ div >
225309 ) : (
226- < div
227- ref = { pagesContainerRef }
228- className = "flex flex-col items-center py-4"
229- style = { { gap : `${ PAGE_GAP } px` } }
230- >
231- { Array . from ( { length : numPages } , ( _ , i ) => i + 1 ) . map ( ( pageNum ) => (
232- < canvas
233- key = { pageNum }
234- ref = { ( el ) => setCanvasRef ( pageNum , el ) }
235- className = "shadow-lg"
236- />
237- ) ) }
310+ < div className = "flex flex-col items-center py-4" style = { { gap : `${ PAGE_GAP } px` } } >
311+ { pageDimsRef . current . map ( ( dims , i ) => {
312+ const pageNum = i + 1 ;
313+ const scaledWidth = Math . floor ( dims . width * scale ) ;
314+ const scaledHeight = Math . floor ( dims . height * scale ) ;
315+ return (
316+ < div
317+ key = { pageNum }
318+ className = "relative shrink-0"
319+ style = { { width : scaledWidth , height : scaledHeight } }
320+ >
321+ < canvas
322+ ref = { ( el ) => setCanvasRef ( pageNum , el ) }
323+ className = "shadow-lg absolute inset-0"
324+ />
325+ { numPages > 1 && (
326+ < span className = "absolute bottom-2 right-3 text-[10px] tabular-nums text-white/80 bg-black/50 px-1.5 py-0.5 rounded pointer-events-none" >
327+ Page { pageNum } /{ numPages }
328+ </ span >
329+ ) }
330+ </ div >
331+ ) ;
332+ } ) }
238333 </ div >
239334 ) }
240335 </ div >
0 commit comments