1+ "use client"
2+ import { createPluginRegistration } from '@embedpdf/core' ;
3+ import { EmbedPDF } from '@embedpdf/core/react' ;
4+ import { usePdfiumEngine } from '@embedpdf/engines/react' ;
5+
6+ import { Download , ZoomIn , ZoomOut , Maximize2 , Minimize2 } from "lucide-react" ;
7+ import { memo , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
8+ import { Button } from "./ui/button" ;
9+ import { downloadFile } from "../lib/utils/download" ;
10+
11+ import ShareButton from "./ShareButton" ;
12+ import ReportButton from "./ReportButton" ;
13+
14+ import { Viewport , ViewportPluginPackage } from '@embedpdf/plugin-viewport/react' ;
15+ import { useScroll , Scroller , ScrollPluginPackage } from '@embedpdf/plugin-scroll/react' ;
16+ import { DocumentContent , DocumentManagerPluginPackage } from '@embedpdf/plugin-document-manager/react' ;
17+ import { useZoom , ZoomPluginPackage , ZoomMode } from '@embedpdf/plugin-zoom/react' ;
18+ import { RenderLayer , RenderPluginPackage } from '@embedpdf/plugin-render/react' ;
19+ import { ExportPluginPackage } from '@embedpdf/plugin-export/react' ;
20+
21+ interface ControlProps {
22+ documentId : string ;
23+ toggleFullscreen : ( ) => void ;
24+ isFullscreen : boolean ;
25+ onDownload : ( ) => Promise < void > ;
26+ }
27+
28+ interface PdfViewerProps {
29+ url : string ;
30+ name : string ;
31+ }
32+
33+ // memo — only re-renders when documentId / isFullscreen / onDownload / toggleFullscreen refs change.
34+ // All function props must be stable (useCallback) in the parent for this to be effective.
35+ const Controls = memo ( function Controls ( {
36+ documentId,
37+ toggleFullscreen,
38+ isFullscreen,
39+ onDownload,
40+ } : ControlProps ) {
41+ const { provides : zoomProv , state : zoomState } = useZoom ( documentId ) ;
42+ const { provides : scrollProv , state : scrollState } = useScroll ( documentId ) ;
43+ const [ pageNo , setPageNo ] = useState ( "1" ) ;
44+
45+ useEffect ( ( ) => {
46+ if ( ! scrollProv ) return ;
47+ // Subscribe to page changes and keep the input in sync.
48+ const unsub = scrollProv . onPageChange ( ( ) =>
49+ setPageNo ( String ( scrollProv . getCurrentPage ( ) ) )
50+ ) ;
51+ return ( ) => unsub ( ) ;
52+ } , [ scrollProv ] ) ;
53+
54+ // pageNo and totalPages are reactive values read inside the handler,
55+ // so they must be listed as deps to avoid a stale closure.
56+ const pageChange = useCallback (
57+ ( e : React . KeyboardEvent < HTMLInputElement > ) => {
58+ if ( e . key !== "Enter" ) return ;
59+ const page = parseInt ( pageNo , 10 ) ;
60+ if ( ! isNaN ( page ) && page >= 1 && page <= ( scrollState ?. totalPages ?? 1 ) ) {
61+ scrollProv ?. scrollToPage ( { pageNumber : page , behavior : "smooth" } ) ;
62+ }
63+ } ,
64+ [ pageNo , scrollState ?. totalPages , scrollProv ]
65+ ) ;
66+
67+ if ( ! zoomProv || ! scrollProv ) return null ;
68+
69+ const zoomIn = ( ) => zoomProv . zoomIn ( ) ;
70+ const zoomOut = ( ) => zoomProv . zoomOut ( ) ;
71+ const { zoomLevel } = zoomState ;
72+ const { totalPages } = scrollState ;
73+
74+ return (
75+ < div
76+ style = { {
77+ position : "absolute" ,
78+ ...( isFullscreen ? {
79+ bottom : 20 ,
80+ left : "50%" ,
81+ transform : "translateX(-50%)" ,
82+ flexDirection : "row" ,
83+ } : {
84+ top : "40%" ,
85+ right : 20 ,
86+ transform : "translateY(-50%)" ,
87+ flexDirection : "column" ,
88+ } ) ,
89+ zIndex : 1 ,
90+ padding : "20px 12px" ,
91+ display : "flex" ,
92+ gap : 16 ,
93+ alignItems : "center" ,
94+ alignSelf : "center" ,
95+ width : isFullscreen ? "auto" : "96px" ,
96+ background : "#262635" ,
97+ borderRadius : 8 ,
98+ backdropFilter : "blur(6px)" ,
99+ } }
100+ >
101+ < Button
102+ onClick = { toggleFullscreen }
103+ className = "h-12 w-12 rounded p-0 text-white bg-[#6536c1] transition hover:bg-[#7d4fc7]"
104+ >
105+ { isFullscreen ? < Minimize2 size = { 24 } /> : < Maximize2 size = { 24 } /> }
106+ </ Button >
107+
108+ < Button
109+ onClick = { onDownload }
110+ className = "h-12 w-12 rounded p-0 text-white bg-[#6536c1] transition hover:bg-[#7d4fc7]"
111+ >
112+ < Download size = { 24 } />
113+ </ Button >
114+
115+ < ShareButton />
116+
117+ < Button
118+ onClick = { zoomOut }
119+ disabled = { typeof zoomLevel === "number" && zoomLevel <= 0.25 }
120+ className = "h-12 w-12 rounded p-0 text-white bg-[#6536c1] transition hover:bg-[#7d4fc7] disabled:bg-gray-400"
121+ >
122+ < ZoomOut size = { 24 } />
123+ </ Button >
124+
125+ < span className = "text-xs text-[16px] py-2" >
126+ { typeof zoomLevel === "number"
127+ ? `${ Math . round ( zoomLevel * 100 ) } %`
128+ : zoomLevel }
129+ </ span >
130+
131+ < Button
132+ onClick = { zoomIn }
133+ disabled = { typeof zoomLevel === "number" && zoomLevel >= 3 }
134+ className = "h-12 w-12 rounded p-0 text-white bg-[#6536c1] transition hover:bg-[#7d4fc7] disabled:bg-gray-400"
135+ >
136+ < ZoomIn size = { 24 } />
137+ </ Button >
138+
139+ < div className = { isFullscreen ? "flex flex-row items-center gap-2" : "flex flex-col items-center gap-2" } >
140+ < input
141+ type = "text"
142+ value = { pageNo }
143+ onChange = { ( e ) => setPageNo ( e . target . value ) }
144+ onKeyDown = { pageChange }
145+ onFocus = { ( ) => setPageNo ( "" ) }
146+ className = "h-9 w-14 rounded border bg-[#e7e9ff] p-1 text-center text-sm [appearance:textfield] dark:bg-[#1f1f2a] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
147+ />
148+ < span className = "text-xs font-medium" > of { totalPages ?? 1 } </ span >
149+ </ div >
150+
151+ < ReportButton />
152+ </ div >
153+ ) ;
154+ } ) ;
155+
156+ export default function PDFViewer ( { url, name } : PdfViewerProps ) {
157+ const { engine, isLoading } = usePdfiumEngine ( ) ;
158+ const [ isFullscreen , setIsFullscreen ] = useState ( false ) ;
159+ const viewerRef = useRef < HTMLDivElement > ( null ) ;
160+
161+ // Stable reference — only changes if url/name change (which should be never
162+ // during a viewer session). Avoids re-creating the fn on every PDFViewer render.
163+ const handleDownload = useCallback ( async ( ) => {
164+ window . dataLayer ?. push ( {
165+ event : "pdf_download_start" ,
166+ paper_title : name ,
167+ paper_url : url ,
168+ } ) ;
169+ await downloadFile ( url , `${ name } .pdf` ) ;
170+ } , [ url , name ] ) ;
171+
172+ // toggleFullscreen reads only from a ref and DOM APIs — no React state
173+ // is captured, so an empty dep array is correct and the reference never
174+ // needs to change, keeping memo(Controls) happy.
175+ const toggleFullscreen = useCallback ( ( ) => {
176+ if ( ! document . fullscreenElement ) {
177+ void viewerRef . current ?. requestFullscreen ( ) ;
178+ } else {
179+ void document . exitFullscreen ( ) ;
180+ }
181+ } , [ ] ) ;
182+
183+ useEffect ( ( ) => {
184+ const handleFullscreenChange = ( ) =>
185+ setIsFullscreen ( ! ! document . fullscreenElement ) ;
186+ document . addEventListener ( "fullscreenchange" , handleFullscreenChange ) ;
187+ return ( ) => document . removeEventListener ( "fullscreenchange" , handleFullscreenChange ) ;
188+ } , [ ] ) ;
189+
190+ // useMemo prevents the plugin array from being recreated on every render.
191+ // url is the only real dep — if the url changes, a fresh plugin set is correct.
192+ const plugins = useMemo ( ( ) => [
193+ createPluginRegistration ( DocumentManagerPluginPackage , {
194+ initialDocuments : [ { url } ] ,
195+ } ) ,
196+ createPluginRegistration ( ViewportPluginPackage ) ,
197+ createPluginRegistration ( ScrollPluginPackage ) ,
198+ createPluginRegistration ( RenderPluginPackage ) ,
199+ createPluginRegistration ( ZoomPluginPackage , {
200+ defaultZoomLevel : ZoomMode . FitPage ,
201+ } ) ,
202+ createPluginRegistration ( ExportPluginPackage , {
203+ defaultFileName : `${ name } .pdf` ,
204+ } ) ,
205+ ] , [ url , name ] ) ;
206+
207+ if ( isLoading || ! engine ) {
208+ return < div > Loading PDF Engine...</ div > ;
209+ }
210+
211+ return (
212+ < div
213+ ref = { viewerRef }
214+ style = { { height : "100vh" , width : "100%" , position : "relative" , backgroundColor : "#070114" } }
215+ >
216+ < EmbedPDF engine = { engine } plugins = { plugins } >
217+ { ( { activeDocumentId } ) =>
218+ activeDocumentId && (
219+ < >
220+ < DocumentContent documentId = { activeDocumentId } >
221+ { ( { isLoaded } ) =>
222+ isLoaded && (
223+ < Viewport
224+ documentId = { activeDocumentId }
225+ style = { { backgroundColor : "#070114" } }
226+ >
227+ < Scroller
228+ documentId = { activeDocumentId }
229+ renderPage = { ( { width, height, pageIndex } ) => (
230+ < div style = { { width, height } } >
231+ < RenderLayer
232+ documentId = { activeDocumentId }
233+ pageIndex = { pageIndex }
234+ />
235+ </ div >
236+ ) }
237+ />
238+ </ Viewport >
239+ )
240+ }
241+ </ DocumentContent >
242+
243+ < Controls
244+ documentId = { activeDocumentId }
245+ toggleFullscreen = { toggleFullscreen }
246+ isFullscreen = { isFullscreen }
247+ onDownload = { handleDownload }
248+ />
249+ </ >
250+ )
251+ }
252+ </ EmbedPDF >
253+ </ div >
254+ ) ;
255+ }
0 commit comments