From 662449d297559499901da493fe2ebd0c21312ebc Mon Sep 17 00:00:00 2001 From: Rithish-2914 Date: Thu, 13 Aug 2026 00:25:06 +0530 Subject: [PATCH 1/2] Improve catalogue and PDF viewer UX and light mode uiux --- src/components/Card.tsx | 6 +- src/components/CatalogueContent.tsx | 23 +++-- src/components/SelectionToolbar.tsx | 89 +++++++++++++++++++ src/components/newPdfViewer.tsx | 132 ++++++++++++++++++++++++++-- src/components/ui/dropdown-menu.tsx | 2 +- src/context/filterContext.tsx | 115 ++++++++++++++++-------- src/styles/globals.css | 16 ++-- 7 files changed, 315 insertions(+), 68 deletions(-) create mode 100644 src/components/SelectionToolbar.tsx diff --git a/src/components/Card.tsx b/src/components/Card.tsx index ff0646d6..c11f7df0 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -60,8 +60,8 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => { window.open(paperLink, "_blank"); }} className={cn( - "cursor-pointer overflow-hidden rounded-sm border-2 border-[#734DFF] bg-[#FFFFFF] font-play transition-all duration-150 hover:bg-[#EFEAFF] dark:border-[#36266D] dark:bg-[#171720] hover:dark:bg-[#262635]", - isSelected && "ring-2 ring-[#7480FF] bg-[#EFEAFF]" + "cursor-pointer overflow-hidden rounded-sm border-2 border-[#734DFF] bg-[#FFFFFF] font-play shadow-sm transition-all duration-150 hover:-translate-y-0.5 hover:bg-[#EFEAFF] hover:shadow-md dark:border-[#36266D] dark:bg-[#171720] dark:shadow-none hover:dark:bg-[#262635]", + isSelected && "-translate-y-0.5 bg-[#EFEAFF] shadow-md ring-2 ring-[#7480FF]" )} > { name={generateFileName(paper).replace(/\.[^.]+$/, "")} className="h-full overflow-hidden" height="100%" - hideControls={true} + hideControls={false} backgroundColor="transparent" hideScrollbar={true} /> diff --git a/src/components/CatalogueContent.tsx b/src/components/CatalogueContent.tsx index 2b26c66e..967a8671 100644 --- a/src/components/CatalogueContent.tsx +++ b/src/components/CatalogueContent.tsx @@ -17,7 +17,7 @@ import Link from "next/link"; import { useCourses } from "@/context/courseContext"; import { FilterProvider, useFilters } from "@/context/filterContext"; import EmptyState from "./ui/EmptyState"; -import SidebarButton from "./SidebarButton"; +import SelectionToolbar from "./SelectionToolbar"; import SortComponent from "./ui/sorting"; const CatalogueContentInner = ({ subject }: { subject: string | null }) => { @@ -39,6 +39,7 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => { selectedCampuses, selectedAnswerKeyIncluded, papers, + filteredPapers, appliedFilters, filtersPulled, currentPage, @@ -60,6 +61,7 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => { handleSelectAll, handleDeselectAll, handleDownloadSelected, + isDownloading, } = useFilters(); useEffect(() => { @@ -316,17 +318,14 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => { currentSort={sortOption} /> - - Select All - - - - Deselect All - - - - Download Selected - + void handleDownloadSelected()} + isDownloading={isDownloading} + /> {relatedSubjects.length > 0 && ( diff --git a/src/components/SelectionToolbar.tsx b/src/components/SelectionToolbar.tsx new file mode 100644 index 00000000..8803bb78 --- /dev/null +++ b/src/components/SelectionToolbar.tsx @@ -0,0 +1,89 @@ +"use client"; + +import React from "react"; +import { CheckSquare, Square, Download, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SelectionToolbarProps { + selectedCount: number; + totalCount: number; + onSelectAll: () => void; + onDeselectAll: () => void; + onDownload: () => void; + isDownloading: boolean; +} + +const SelectionToolbar: React.FC = ({ + selectedCount, + totalCount, + onSelectAll, + onDeselectAll, + onDownload, + isDownloading, +}) => { + const allSelected = totalCount > 0 && selectedCount === totalCount; + const hasSelection = selectedCount > 0; + + return ( +
+ + {selectedCount} / {totalCount} selected + + +
+ + + + +
+ ); +}; + +export default SelectionToolbar; diff --git a/src/components/newPdfViewer.tsx b/src/components/newPdfViewer.tsx index 61895ba8..4980703c 100644 --- a/src/components/newPdfViewer.tsx +++ b/src/components/newPdfViewer.tsx @@ -9,7 +9,7 @@ import { useZoom, ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin- import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; -import { Download, ZoomIn, ZoomOut, Maximize2, Minimize2 } from "lucide-react"; +import { Download, ZoomIn, ZoomOut, Maximize2, Minimize2, BookOpenText, X } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { downloadFile } from "../lib/utils/download"; import { Button } from "./ui/button"; @@ -26,6 +26,10 @@ interface ControlProps { isMobile: boolean; isSmall: boolean; viewerRef: React.RefObject; + isReadingMode: boolean; + toggleReadingMode: () => void; + showReadingCoachmark: boolean; + dismissReadingCoachmark: () => void; } interface PdfViewerProps { @@ -37,6 +41,7 @@ interface PdfViewerProps { backgroundColor?: string; hideScrollbar?: boolean; isolateFromTheme?: boolean; + onReadingModeChange?: (isReadingMode: boolean) => void; } interface WheelZoomProps { @@ -58,7 +63,8 @@ function useBreakpoint() { } const Controls = memo(function Controls({documentId, toggleFullscreen, isFullscreen, onDownload, - forceMobile, isMobile, isSmall, viewerRef}: ControlProps) { + forceMobile, isMobile, isSmall, viewerRef, isReadingMode, toggleReadingMode, + showReadingCoachmark, dismissReadingCoachmark}: ControlProps) { const { provides: zoomProv, state: zoomState } = useZoom(documentId); const { provides: scrollProv, state: scrollState } = useScroll(documentId); @@ -110,6 +116,23 @@ const pageChange = useCallback( if (!zoomProv || !scrollProv) return null; + if (isReadingMode) { + return ( + + ); + } + const zoomIn = () => zoomProv.zoomIn(); const zoomOut = () => zoomProv.zoomOut(); const { zoomLevel } = zoomState; @@ -146,6 +169,35 @@ const pageChange = useCallback( const toolSet = ( <> +
+ {showReadingCoachmark && ( +
+
+ New: Reading Mode. Hides + everything but the paper — lighter than Fullscreen, your tabs + stay put. + +
+
+
+ )} + {showReadingCoachmark && ( + + )} + +
+
); -} +} \ No newline at end of file diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index b6212439..d7c482f2 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -87,7 +87,7 @@ const DropdownMenuItem = React.forwardRef< svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-default select-none items-center gap-2 rounded-xl px-2 py-1.5 text-sm text-black outline-none transition-colors hover:bg-black/[0.06] focus:bg-black/[0.06] dark:text-white dark:hover:bg-[#1F2A3D] dark:focus:bg-[#1F2A3D] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", inset && "pl-8", className, )} diff --git a/src/context/filterContext.tsx b/src/context/filterContext.tsx index 9ac343cb..84f17f48 100644 --- a/src/context/filterContext.tsx +++ b/src/context/filterContext.tsx @@ -34,6 +34,7 @@ interface FilterState { filtersPulled: boolean; currentPage: number; papersPerPage: number; + isDownloading: boolean; } interface FilterActions { @@ -104,6 +105,7 @@ export const FilterProvider: React.FC = ({ const [appliedFilters, setAppliedFilters] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [papersPerPage] = useState(12); + const [isDownloading, setIsDownloading] = useState(false); const filtersNotPulled = useCallback(() => { setFiltersPulled(false); @@ -141,50 +143,84 @@ export const FilterProvider: React.FC = ({ toast.error("No papers selected for download."); return; } - - const zip = new JSZip(); - const uniquePapers = Array.from( - new Set(selectedPapers.map((paper) => paper._id)), - ).map((id) => selectedPapers.find((paper) => paper._id === id)) as IPaper[]; - - await Promise.all( - uniquePapers.map(async (paper) => { - try { - const response = await fetch(getSecureUrl(paper.file_url)); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const blob = await response.blob(); - const filename = generateFileName(paper); - zip.file(filename, blob); + if (isDownloading) return; + + setIsDownloading(true); + const toastId = toast.loading( + `Preparing ${selectedPapers.length} paper${selectedPapers.length > 1 ? "s" : ""} for download…`, + ); + + try { + const zip = new JSZip(); + const uniquePapers = Array.from( + new Set(selectedPapers.map((paper) => paper._id)), + ).map((id) => selectedPapers.find((paper) => paper._id === id)) as IPaper[]; + + let failedCount = 0; + + await Promise.all( + uniquePapers.map(async (paper) => { + try { + const response = await fetch(getSecureUrl(paper.file_url)); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const blob = await response.blob(); + const filename = generateFileName(paper); + zip.file(filename, blob); } catch (err) { + failedCount += 1; console.error(`Failed to fetch ${paper.file_url}`, err); } }), - ); - - function getDownloadName( - params: ReadonlyURLSearchParams, - key: string, - fallback = "download", - ): string { - const value = params.get(key); - if (!value) return fallback; - return value.split(" [")[0]?.trim() ?? fallback; - } - const zipBlob = await zip.generateAsync({ type: "blob" }); - const url = URL.createObjectURL(zipBlob); - const a = document.createElement("a"); - a.href = url; - - a.download = getDownloadName(searchParams, "subject"); + ); - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - toast.success("Download Initiated"); - }, [searchParams, selectedPapers]); + if (failedCount === uniquePapers.length) { + toast.error("Couldn't prepare the download. Please try again.", { + id: toastId, + }); + return; + } + + function getDownloadName( + params: ReadonlyURLSearchParams, + key: string, + fallback = "download", + ): string { + const value = params.get(key); + if (!value) return fallback; + return value.split(" [")[0]?.trim() ?? fallback; + } + + const zipBlob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(zipBlob); + const a = document.createElement("a"); + a.href = url; + + a.download = getDownloadName(searchParams, "subject"); + + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + + if (failedCount > 0) { + toast.success( + `Downloaded ${uniquePapers.length - failedCount} of ${uniquePapers.length} papers (${failedCount} failed).`, + { id: toastId }, + ); + } else { + toast.success("Download ready!", { id: toastId }); + } + } catch (err) { + console.error("Failed to prepare zip", err); + toast.error("Something went wrong while zipping your files.", { + id: toastId, + }); + } finally { + setIsDownloading(false); + } + }, [searchParams, selectedPapers, isDownloading]); const handleApplyFilters = useCallback( ( @@ -256,6 +292,7 @@ export const FilterProvider: React.FC = ({ filtersPulled, currentPage, papersPerPage, + isDownloading, setSelectedExams, setSelectedSlots, diff --git a/src/styles/globals.css b/src/styles/globals.css index 7c47acf5..6dfef59a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -19,7 +19,7 @@ @layer base { :root { - --background: 0 0% 100%; + --background: 233 60% 98%; --foreground: 224 71.4% 4.1%; --card: 0 0% 100%; --card-foreground: 224 71.4% 4.1%; @@ -27,16 +27,16 @@ --popover-foreground: 224 71.4% 4.1%; --primary: 262.1 83.3% 57.8%; --primary-foreground: 210 20% 98%; - --secondary: 220 14.3% 95.9%; + --secondary: 233 45% 95%; --secondary-foreground: 220.9 39.3% 11%; - --muted: 220 14.3% 95.9%; + --muted: 233 35% 95%; --muted-foreground: 220 8.9% 46.1%; - --accent: 220 14.3% 95.9%; + --accent: 233 50% 94%; --accent-foreground: 220.9 39.3% 11%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 20% 98%; - --border: 220 13% 91%; - --input: 220 13% 91%; + --border: 233 30% 90%; + --input: 233 30% 90%; --ring: 262.1 83.3% 57.8%; --radius: 0.5rem; --chart-1: 12 76% 61%; @@ -130,13 +130,13 @@ } ::-webkit-scrollbar-track { - background: #ffffff; + background: #f3f5ff; border-radius: 0.6rem; z-index: -99; } ::-webkit-scrollbar-thumb { - background: #929292; + background: #b9bde0; border-radius: 1rem; } From 169992e509daa20ccb822037ed4f7043e525abed Mon Sep 17 00:00:00 2001 From: Rithish-2914 Date: Thu, 13 Aug 2026 17:56:34 +0530 Subject: [PATCH 2/2] Improve PDF viewer reading mode --- src/components/newPdfViewer.tsx | 1334 +++++++++++++++---------------- 1 file changed, 667 insertions(+), 667 deletions(-) diff --git a/src/components/newPdfViewer.tsx b/src/components/newPdfViewer.tsx index 4980703c..6a00c2fd 100644 --- a/src/components/newPdfViewer.tsx +++ b/src/components/newPdfViewer.tsx @@ -1,699 +1,644 @@ -"use client" -import { createPluginRegistration } from '@embedpdf/core'; -import { EmbedPDF } from '@embedpdf/core/react'; -import { usePdfiumEngine } from '@embedpdf/engines/react'; -import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react'; -import { useScroll, useScrollCapability, Scroller, ScrollPluginPackage } from '@embedpdf/plugin-scroll/react'; -import { DocumentContent, DocumentManagerPluginPackage } from '@embedpdf/plugin-document-manager/react'; -import { useZoom, ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react'; -import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; -import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; - -import { Download, ZoomIn, ZoomOut, Maximize2, Minimize2, BookOpenText, X } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { downloadFile } from "../lib/utils/download"; -import { Button } from "./ui/button"; -import ShareButton from "./ShareButton"; -import ReportButton from "./ReportButton"; -import { useTheme } from "next-themes"; - -interface ControlProps { - documentId: string; - toggleFullscreen: () => void; - isFullscreen: boolean; - onDownload: () => Promise; - forceMobile?: boolean; - isMobile: boolean; - isSmall: boolean; - viewerRef: React.RefObject; - isReadingMode: boolean; - toggleReadingMode: () => void; - showReadingCoachmark: boolean; - dismissReadingCoachmark: () => void; -} - -interface PdfViewerProps { - url: string; - name: string; - className?: string; - height?: string; - hideControls?: boolean; - backgroundColor?: string; - hideScrollbar?: boolean; - isolateFromTheme?: boolean; - onReadingModeChange?: (isReadingMode: boolean) => void; -} - -interface WheelZoomProps { - documentId: string; - viewerRef: React.RefObject; -} - -function useBreakpoint() { - const [width, setWidth] = useState(null); - - useEffect(() => { - setWidth(window.innerWidth); - const handler = () => setWidth(window.innerWidth); - window.addEventListener("resize", handler); - return () => window.removeEventListener("resize", handler); - }, []); - - return {isMobile: width !== null && width < 768, isSmall: width !== null && width < 640}; -} - -const Controls = memo(function Controls({documentId, toggleFullscreen, isFullscreen, onDownload, - forceMobile, isMobile, isSmall, viewerRef, isReadingMode, toggleReadingMode, - showReadingCoachmark, dismissReadingCoachmark}: ControlProps) { - - const { provides: zoomProv, state: zoomState } = useZoom(documentId); - const { provides: scrollProv, state: scrollState } = useScroll(documentId); - const { provides: scrollCapability } = useScrollCapability(); - const [pageNo, setPageNo] = useState("1"); - const [totalPages, setTotalPages] = useState(0); - const editingRef = useRef(false); - - useEffect(() => { - if (!scrollCapability) return; - const unsub = scrollCapability.onLayoutReady((event) => { - if (event.documentId === documentId) { - setTotalPages(event.totalPages); - } - }); - return () => { - unsub(); - setTotalPages(0); - }; - }, [scrollCapability, documentId]); - - useEffect(() => { - if (!scrollProv) return; - const unsub = scrollProv.onPageChange((event) => { - setTotalPages(event.totalPages); - if (!editingRef.current) { - setPageNo(String(event.pageNumber)); - } - }); - return () => unsub(); - }, [scrollProv]); - - const effectiveTotalPages = - totalPages > 0 ? totalPages : (scrollState?.totalPages ?? 0); - -const pageChange = useCallback( - (e: React.KeyboardEvent) => { - if (e.key !== "Enter") return; - const page = parseInt(pageNo, 10); - if (isNaN(page)) return; - if (page >= 1 && page <= effectiveTotalPages) { - scrollProv?.scrollToPage({ pageNumber: page, behavior: "smooth" }); - } else { - setPageNo(String(scrollProv?.getCurrentPage() ?? 1)); - } - }, - [pageNo, effectiveTotalPages, scrollProv] - ); + "use client" + import { createPluginRegistration } from '@embedpdf/core'; + import { EmbedPDF } from '@embedpdf/core/react'; + import { usePdfiumEngine } from '@embedpdf/engines/react'; + import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react'; + import { useScroll, useScrollCapability, Scroller, ScrollPluginPackage } from '@embedpdf/plugin-scroll/react'; + import { DocumentContent, DocumentManagerPluginPackage } from '@embedpdf/plugin-document-manager/react'; + import { useZoom, ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react'; + import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; + import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; + + import { Download, ZoomIn, ZoomOut, Maximize2, Minimize2, BookOpenText, X } from "lucide-react"; + import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; + import { downloadFile } from "../lib/utils/download"; + import { Button } from "./ui/button"; + import ShareButton from "./ShareButton"; + import ReportButton from "./ReportButton"; + import { useTheme } from "next-themes"; + + interface ControlProps { + documentId: string; + toggleFullscreen: () => void; + isFullscreen: boolean; + onDownload: () => Promise; + forceMobile?: boolean; + isMobile: boolean; + isSmall: boolean; + viewerRef: React.RefObject; + isReadingMode: boolean; + toggleReadingMode: () => void; + showReadingCoachmark: boolean; + dismissReadingCoachmark: () => void; + } - if (!zoomProv || !scrollProv) return null; + interface PdfViewerProps { + url: string; + name: string; + className?: string; + height?: string; + hideControls?: boolean; + backgroundColor?: string; + hideScrollbar?: boolean; + isolateFromTheme?: boolean; + onReadingModeChange?: (isReadingMode: boolean) => void; + } - if (isReadingMode) { - return ( - - ); + interface WheelZoomProps { + documentId: string; + viewerRef: React.RefObject; } - const zoomIn = () => zoomProv.zoomIn(); - const zoomOut = () => zoomProv.zoomOut(); - const { zoomLevel } = zoomState; - const fullScreenStyle = { - bottom: 20, - left: "50%", - transform: "translateX(-50%)", + function useBreakpoint() { + const [width, setWidth] = useState(null); + + useEffect(() => { + setWidth(window.innerWidth); + const handler = () => setWidth(window.innerWidth); + window.addEventListener("resize", handler); + return () => window.removeEventListener("resize", handler); + }, []); + + return {isMobile: width !== null && width < 768, isSmall: width !== null && width < 640}; } - const pageInput = ( -
- { - editingRef.current = true; - setPageNo(e.target.value.replace(/[^0-9]/g, "")); - }} - onKeyDown={pageChange} - onFocus={(e) => { - editingRef.current = true; - e.target.select(); - }} - onBlur={() => { - editingRef.current = false; - setPageNo(String(scrollProv?.getCurrentPage() ?? 1)); - }} - inputMode="numeric" - 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" - /> - of {totalPages ?? 1} -
- ) - - const toolSet = ( - <> -
- {showReadingCoachmark && ( -
-
- New: Reading Mode. Hides - everything but the paper — lighter than Fullscreen, your tabs - stay put. - -
-
-
- )} - {showReadingCoachmark && ( - - )} - -
+ + Reading Mode + + + + + ); + } - + const zoomIn = () => zoomProv.zoomIn(); + const zoomOut = () => zoomProv.zoomOut(); + const { zoomLevel } = zoomState; + const fullScreenStyle = { + bottom: 20, + left: "50%", + transform: "translateX(-50%)", + } + + const pageInput = ( +
+ { + editingRef.current = true; + setPageNo(e.target.value.replace(/[^0-9]/g, "")); + }} + onKeyDown={pageChange} + onFocus={(e) => { + editingRef.current = true; + e.target.select(); + }} + onBlur={() => { + editingRef.current = false; + setPageNo(String(scrollProv?.getCurrentPage() ?? 1)); + }} + inputMode="numeric" + 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" + /> + of {totalPages ?? 1} +
+ ) + + const toolSet = ( + <> +
+ {showReadingCoachmark && ( +
+
+ New: Reading Mode. Hides + everything but the paper — lighter than Fullscreen, your tabs + stay put. + +
+
+
+ )} + {showReadingCoachmark && ( + + )} + +
- + title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} + > + {isFullscreen ? : } + - + - - - - {typeof zoomLevel === "number" ? `${Math.round(zoomLevel * 100)}%` : "100%"} - - - + - {isSmall && } + - - ) + + {typeof zoomLevel === "number" ? `${Math.round(zoomLevel * 100)}%` : "100%"} + - if (!forceMobile) { - return ( - <> - {!isSmall ? -
= 3} + className="h-10 w-10 rounded p-0 text-white bg-[#6536c1] transition hover:bg-[#7d4fc7] disabled:bg-gray-400" + title="Zoom in" > - {toolSet} - {pageInput} - {!isSmall && } -
: -
- {pageInput} + + + + {isSmall && } + + + ) + + if (!forceMobile) { + return ( + <> + {!isSmall ?
{toolSet} -
- -
} - - + {pageInput} + {!isSmall && } +
: +
+ {pageInput} +
+ {toolSet} +
+ +
} + + + ); + } + + return( +
+ {isSmall ? ( + <> + {pageInput} +
+ {toolSet} +
+ + ) : ( + <> + {toolSet} + {pageInput} + + + )} +
+ ) + }); + + function WheelZoom({ documentId, viewerRef }: WheelZoomProps) { + const { provides: zoomProv } = useZoom(documentId); + const accumulatedDelta = useRef(0); + const rafId = useRef(null); + const curZoom = useRef(1); + + useEffect(() => { + if (!zoomProv) return; + curZoom.current = zoomProv.getState().currentZoomLevel; + const unsub = zoomProv.onZoomChange((e) => { + curZoom.current = e.newZoom; + }); + return unsub; + }, [zoomProv]); + + const handleWheel = useCallback( + (e: WheelEvent) => { + if (!e.ctrlKey || !zoomProv) return; + e.preventDefault(); + + const isTrackpad = Math.abs(e.deltaY) < 50; + const scaleFactor = isTrackpad ? 0.008 : 0.08; + accumulatedDelta.current += -e.deltaY * scaleFactor; + + if (rafId.current !== null) cancelAnimationFrame(rafId.current); + rafId.current = requestAnimationFrame(() => { + const clamped = Math.max(-0.15, Math.min(accumulatedDelta.current, 0.15)); + + if (isTrackpad) { + const target = Math.max(0.25, Math.min(curZoom.current + clamped, 4)); + zoomProv.requestZoom(target) + curZoom.current = target; + } else { + zoomProv.requestZoomBy(clamped); + } + + accumulatedDelta.current = 0; + rafId.current = null; + }); + }, + [zoomProv] ); + + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer) return; + viewer.addEventListener("wheel", handleWheel, { passive: false }); + return () => { + viewer.removeEventListener("wheel", handleWheel); + if (rafId.current !== null) cancelAnimationFrame(rafId.current); + }; + }, [handleWheel, viewerRef]); + + return null; } - return( -
- {isSmall ? ( - <> - {pageInput} -
- {toolSet} -
- - ) : ( - <> - {toolSet} - {pageInput} - - - )} -
- ) -}); - -function WheelZoom({ documentId, viewerRef }: WheelZoomProps) { - const { provides: zoomProv } = useZoom(documentId); - const accumulatedDelta = useRef(0); - const rafId = useRef(null); - const curZoom = useRef(1); - - useEffect(() => { - if (!zoomProv) return; - curZoom.current = zoomProv.getState().currentZoomLevel; - const unsub = zoomProv.onZoomChange((e) => { - curZoom.current = e.newZoom; - }); - return unsub; - }, [zoomProv]); - - const handleWheel = useCallback( - (e: WheelEvent) => { - if (!e.ctrlKey || !zoomProv) return; - e.preventDefault(); - - const isTrackpad = Math.abs(e.deltaY) < 50; - const scaleFactor = isTrackpad ? 0.008 : 0.08; - accumulatedDelta.current += -e.deltaY * scaleFactor; - - if (rafId.current !== null) cancelAnimationFrame(rafId.current); - rafId.current = requestAnimationFrame(() => { - const clamped = Math.max(-0.15, Math.min(accumulatedDelta.current, 0.15)); - - if (isTrackpad) { - const target = Math.max(0.25, Math.min(curZoom.current + clamped, 4)); - zoomProv.requestZoom(target) - curZoom.current = target; - } else { - zoomProv.requestZoomBy(clamped); - } + function useLoadingMessage(messages: string[], interval = 2200) { + const [index, setIndex] = useState(0); + const [visible, setVisible] = useState(true); + + useEffect(() => { + const timer = setInterval(() => { + setVisible(false); + setTimeout(() => { + setIndex(i => (i + 1) % messages.length); + setVisible(true); + }, 400); + }, interval); + return () => clearInterval(timer); + }, [messages, interval]); + + return { message: messages[index], visible }; + } - accumulatedDelta.current = 0; - rafId.current = null; - }); - }, - [zoomProv] - ); - - useEffect(() => { - const viewer = viewerRef.current; - if (!viewer) return; - viewer.addEventListener("wheel", handleWheel, { passive: false }); - return () => { - viewer.removeEventListener("wheel", handleWheel); - if (rafId.current !== null) cancelAnimationFrame(rafId.current); - }; - }, [handleWheel, viewerRef]); - - return null; -} - -function useLoadingMessage(messages: string[], interval = 2200) { - const [index, setIndex] = useState(0); - const [visible, setVisible] = useState(true); - - useEffect(() => { - const timer = setInterval(() => { - setVisible(false); - setTimeout(() => { - setIndex(i => (i + 1) % messages.length); - setVisible(true); - }, 400); - }, interval); - return () => clearInterval(timer); - }, [messages, interval]); - - return { message: messages[index], visible }; -} - -const LOADING_MESSAGES = [ - "Loading document", - "Preparing pages", - "Almost there", -]; - -export function Loader({ - backgroundColor = "#070114", - textColor = "rgba(255,255,255,0.5)", -}: { - backgroundColor?: string; - textColor?: string; -}) { - const { message, visible } = useLoadingMessage(LOADING_MESSAGES); - return ( -
-
- - {message} - -
- ); -} -export default function PDFViewer({ - url, - name, - className, - height = "100dvh", - hideControls = false, - backgroundColor, - hideScrollbar = false, - isolateFromTheme = true, - onReadingModeChange, -}: PdfViewerProps) { - const { engine, isLoading } = usePdfiumEngine(); - const { isMobile, isSmall } = useBreakpoint(); - const { resolvedTheme } = useTheme(); - const [isFullscreen, setIsFullscreen] = useState(false); - const [isReadingMode, setIsReadingMode] = useState(false); - const [showReadingCoachmark, setShowReadingCoachmark] = useState(false); - const viewerRef = useRef(null); - const effectiveBackgroundColor = - backgroundColor ?? (resolvedTheme === "light" ? "#F3F5FF" : "#070114"); - const loaderTextColor = - resolvedTheme === "light" ? "rgba(17,24,39,0.6)" : "rgba(255,255,255,0.5)"; - - const handleDownload = useCallback(async () => { - window.dataLayer?.push({ - event: "pdf_download_start", - paper_title: name, - paper_url: url, - }); - await downloadFile(url, `${name}.pdf`); - }, [url, name]); - - const toggleFullscreen = useCallback(() => { - if (!document.fullscreenElement) { - void viewerRef.current?.requestFullscreen(); - } else { - void document.exitFullscreen(); - } - }, []); - - useEffect(() => { - const handleFullscreenChange = () => - setIsFullscreen(!!document.fullscreenElement); - document.addEventListener("fullscreenchange", handleFullscreenChange); - return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); - }, []); - - const dismissReadingCoachmark = useCallback(() => { - setShowReadingCoachmark(false); - try { - window.localStorage.setItem("pdfReadingModeSeen", "1"); - } catch { - // localStorage unavailable (private mode, etc) — coachmark just won't persist - } - }, []); - - const toggleReadingMode = useCallback(() => { - setIsReadingMode((r) => { - const next = !r; - onReadingModeChange?.(next); - return next; - }); - dismissReadingCoachmark(); - }, [dismissReadingCoachmark, onReadingModeChange]); - - useEffect(() => { - try { - if (!window.localStorage.getItem("pdfReadingModeSeen")) { - setShowReadingCoachmark(true); +
+ + {message} + +
+ ); + } + export default function PDFViewer({ + url, + name, + className, + height = "100dvh", + hideControls = false, + backgroundColor, + hideScrollbar = false, + isolateFromTheme = true, + onReadingModeChange, + }: PdfViewerProps) { + const { engine, isLoading } = usePdfiumEngine(); + const { isMobile, isSmall } = useBreakpoint(); + const { resolvedTheme } = useTheme(); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isReadingMode, setIsReadingMode] = useState(false); + const [showReadingCoachmark, setShowReadingCoachmark] = useState(false); + const viewerRef = useRef(null); + const effectiveBackgroundColor = + backgroundColor ?? (resolvedTheme === "light" ? "#F3F5FF" : "#070114"); + const loaderTextColor = + resolvedTheme === "light" ? "rgba(17,24,39,0.6)" : "rgba(255,255,255,0.5)"; + + const handleDownload = useCallback(async () => { + window.dataLayer?.push({ + event: "pdf_download_start", + paper_title: name, + paper_url: url, + }); + await downloadFile(url, `${name}.pdf`); + }, [url, name]); + + const toggleFullscreen = useCallback(() => { + if (!document.fullscreenElement) { + void viewerRef.current?.requestFullscreen(); + } else { + void document.exitFullscreen(); } - } catch { - // ignore - } - }, []); - - useEffect(() => { - if (!isReadingMode) return; - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = prevOverflow; - }; - }, [isReadingMode]); - - useEffect(() => { - const handleKeydown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement | null; - if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return; - - if (e.key === "r" || e.key === "R") { - toggleReadingMode(); - } else if (e.key === "Escape" && isReadingMode) { - setIsReadingMode(false); - onReadingModeChange?.(false); + }, []); + + useEffect(() => { + const handleFullscreenChange = () => + setIsFullscreen(!!document.fullscreenElement); + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + + const dismissReadingCoachmark = useCallback(() => { + setShowReadingCoachmark(false); + try { + window.localStorage.setItem("pdfReadingModeSeen", "1"); + } catch { + // localStorage unavailable (private mode, etc) — coachmark just won't persist } - }; - window.addEventListener("keydown", handleKeydown); - return () => window.removeEventListener("keydown", handleKeydown); - }, [isReadingMode, toggleReadingMode, onReadingModeChange]); - - const plugins = useMemo(() => [ - createPluginRegistration(DocumentManagerPluginPackage, { - initialDocuments: [{ url }], - }), - createPluginRegistration(ViewportPluginPackage), - createPluginRegistration(ScrollPluginPackage), - createPluginRegistration(RenderPluginPackage), - createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: ZoomMode.FitPage, - }), - createPluginRegistration(ExportPluginPackage, { - defaultFileName: `${name}.pdf`, - }), - ], [url, name]); - -if (isLoading || !engine) { - return ( - - ); -} - - return ( - <> - {hideScrollbar && ( - - )} - {isolateFromTheme && ( - - )} -
- - {({ activeDocumentId }) => - activeDocumentId && ( - <> - - {!hideControls && (isMobile && !isFullscreen) && - } - - {({ isLoaded }) => ( - <> -
- -
- - ( -
e.stopPropagation()} - > - -
- )} - /> -
- - )} -
- - {!hideControls && (!isMobile || isFullscreen) && ( + useEffect(() => { + try { + if (!window.localStorage.getItem("pdfReadingModeSeen")) { + setShowReadingCoachmark(true); + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + if (!isReadingMode) return; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prevOverflow; + }; + }, [isReadingMode]); + + useEffect(() => { + const handleKeydown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return; + + if (e.key === "r" || e.key === "R") { + toggleReadingMode(); + } else if (e.key === "Escape" && isReadingMode) { + setIsReadingMode(false); + onReadingModeChange?.(false); + } + }; + window.addEventListener("keydown", handleKeydown); + return () => window.removeEventListener("keydown", handleKeydown); + }, [isReadingMode, toggleReadingMode, onReadingModeChange]); + + const plugins = useMemo(() => [ + createPluginRegistration(DocumentManagerPluginPackage, { + initialDocuments: [{ url }], + }), + createPluginRegistration(ViewportPluginPackage), + createPluginRegistration(ScrollPluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(ExportPluginPackage, { + defaultFileName: `${name}.pdf`, + }), + ], [url, name]); + + if (isLoading || !engine) { + return ( + + ); + } + + return ( + <> + {hideScrollbar && ( + + )} + {isolateFromTheme && ( + + )} +
+ + {({ activeDocumentId }) => + activeDocumentId && ( + <> + + {!hideControls && (isMobile && !isFullscreen) && - )} - - ) - } - -
- - ); -} \ No newline at end of file + />} + + {({ isLoaded }) => ( + <> +
+ +
+ + ( +
e.stopPropagation()} + > + +
+ )} + /> +
+ + )} +
+ + {!hideControls && (!isMobile || isFullscreen) && ( + + )} + + ) + } +
+
+ + ); + } \ No newline at end of file