Skip to content

Commit 8c95bfe

Browse files
committed
feat: swapped the pdf viewer with embedPDF
1 parent a601d24 commit 8c95bfe

7 files changed

Lines changed: 772 additions & 26 deletions

File tree

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
"@dnd-kit/core": "^6.3.1",
1515
"@dnd-kit/sortable": "^10.0.0",
1616
"@dnd-kit/utilities": "^3.2.2",
17+
"@embedpdf/core": "^2.14.0",
18+
"@embedpdf/engines": "^2.14.0",
19+
"@embedpdf/plugin-document-manager": "^2.14.0",
20+
"@embedpdf/plugin-export": "^2.14.0",
21+
"@embedpdf/plugin-render": "^2.14.0",
22+
"@embedpdf/plugin-scroll": "^2.14.0",
23+
"@embedpdf/plugin-viewport": "^2.14.0",
24+
"@embedpdf/plugin-zoom": "^2.14.0",
1725
"@google-cloud/storage": "^7.17.1",
1826
"@google/genai": "^0.7.0",
1927
"@radix-ui/react-accordion": "^1.2.4",
@@ -56,7 +64,7 @@
5664
"prettier": "^3.5.3",
5765
"prettier-plugin-tailwindcss": "^0.6.11",
5866
"raw-loader": "^4.0.2",
59-
"react": "^18.3.1",
67+
"react": "link:@embedpdf/plugin-viewport/react",
6068
"react-beautiful-dnd": "^13.1.1",
6169
"react-dom": "^18.3.1",
6270
"react-dropzone": "^14.3.8",

pnpm-lock.yaml

Lines changed: 496 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/paper/[id]/page.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import axios, { type AxiosResponse } from "axios";
88
import { type Metadata } from "next";
99
import { redirect } from "next/navigation";
1010
import { PaperProvider } from "@/context/PaperContext";
11+
import PDFViewer from "@/components/newPdfViewer";
1112

1213
export async function generateMetadata({
1314
params,
@@ -174,10 +175,16 @@ const PaperPage = async ({ params }: { params: { id: string } }) => {
174175
year: paper.year,
175176
}}
176177
>
177-
<PdfViewer
178+
179+
<PDFViewer
180+
url={paper.file_url}
181+
name={`${extractBracketContent(paper.subject)}-${paper.exam}-${paper.slot}-${paper.year}`}/>
182+
183+
{/* <PdfViewer
178184
url={paper.file_url}
179185
name={`${extractBracketContent(paper.subject)}-${paper.exam}-${paper.slot}-${paper.year}`}
180-
></PdfViewer>
186+
></PdfViewer> */}
187+
181188
</PaperProvider>
182189
</center>
183190
<RelatedPapers />

src/components/ReportButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function ReportButton(){
1313
<>
1414
<Button
1515
onClick={() => setOpen(true)}
16-
className="h-10 w-10 rounded p-0 text-white transition hover:bg-red-600 bg-red-500"
16+
className="h-12 w-12 rounded p-0 text-white transition hover:bg-red-600 bg-red-500"
1717
>
1818
<FaFlag className="text-sm" />
1919
</Button>

src/components/ShareButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default function ShareButton() {
3030
return (
3131
<Dialog>
3232
<DialogTrigger asChild>
33-
<Button className="aspect-square h-10 w-10 p-0">
33+
<Button className="aspect-square h-12 w-12 p-0 rounded text-white bg-[#6536c1] transition hover:bg-[#7d4fc7]">
3434
<FaShare />
3535
</Button>
3636
</DialogTrigger>

src/components/newPdfViewer.tsx

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
}

src/components/pdfViewer.tsx

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -72,22 +72,6 @@ export default function PdfViewer({ url, name }: PdfViewerProps) {
7272
}
7373
}, [handleScroll]);
7474

75-
const goToPreviousPage = () => {
76-
setPageNumber((prev) => {
77-
const newPage = Math.max(1, prev - 1);
78-
scrollToPage(newPage);
79-
return newPage;
80-
});
81-
};
82-
83-
const goToNextPage = () => {
84-
setPageNumber((prev) => {
85-
const newPage = Math.min(numPages ?? 1, prev + 1);
86-
scrollToPage(newPage);
87-
return newPage;
88-
});
89-
};
90-
9175
const handlePageChange = (e: React.KeyboardEvent<HTMLInputElement>) => {
9276
if (e.key === "Enter") {
9377
const target = e.target as HTMLInputElement;
@@ -107,7 +91,7 @@ export default function PdfViewer({ url, name }: PdfViewerProps) {
10791
};
10892

10993
const zoomIn = () => {
110-
setScale((prev) => Math.min(prev + 0.1, 3));
94+
setScale((prev) => Math.min(prev*1.1, 3));
11195
};
11296

11397
const zoomOut = () => {
@@ -219,12 +203,9 @@ export default function PdfViewer({ url, name }: PdfViewerProps) {
219203
>
220204
<ZoomIn />
221205
</Button>
222-
223206
<span className="w-10 text-center text-sm font-medium">
224207
{(scale * 100).toFixed(0)}%
225208
</span>
226-
227-
228209
<Button
229210
onClick={zoomOut}
230211
disabled={scale <= 0.25}

0 commit comments

Comments
 (0)