2025-07-16 17:53:50 +01:00
import React , { useEffect , useState , useRef , useCallback } from "react" ;
import { Paper , Stack , Text , ScrollArea , Loader , Center , Button , Group , NumberInput , useMantineTheme , ActionIcon , Box , Tabs } from "@mantine/core" ;
2025-06-24 12:20:31 +01:00
import { useTranslation } from "react-i18next" ;
2025-08-21 17:30:26 +01:00
import { pdfWorkerManager } from "../../services/pdfWorkerManager" ;
2025-06-24 12:20:31 +01:00
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew" ;
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos" ;
import FirstPageIcon from "@mui/icons-material/FirstPage" ;
import LastPageIcon from "@mui/icons-material/LastPage" ;
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar" ;
import ViewWeekIcon from "@mui/icons-material/ViewWeek" ; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description" ; // for single page
2025-07-16 17:53:50 +01:00
import CloseIcon from "@mui/icons-material/Close" ;
2025-06-24 12:20:31 +01:00
import { useLocalStorage } from "@mantine/hooks" ;
import { fileStorage } from "../../services/fileStorage" ;
2025-07-16 17:53:50 +01:00
import SkeletonLoader from '../shared/SkeletonLoader' ;
2025-08-21 17:30:26 +01:00
import { useFileState , useFileActions , useCurrentFile } from "../../contexts/FileContext" ;
2025-07-16 17:53:50 +01:00
import { useFileWithUrl } from "../../hooks/useFileWithUrl" ;
2025-09-05 11:33:03 +01:00
import { isFileObject } from "../../types/fileContext" ;
2025-08-28 10:56:07 +01:00
import { FileId } from "../../types/file" ;
2025-06-24 12:20:31 +01:00
// Lazy loading page image component
interface LazyPageImageProps {
pageIndex : number ;
zoom : number ;
theme : any ;
isFirst : boolean ;
renderPage : ( pageIndex : number ) = > Promise < string | null > ;
pageImages : ( string | null ) [ ] ;
setPageRef : ( index : number , ref : HTMLImageElement | null ) = > void ;
}
const LazyPageImage = ( {
pageIndex , zoom , theme , isFirst , renderPage , pageImages , setPageRef
} : LazyPageImageProps ) = > {
const [ isVisible , setIsVisible ] = useState ( false ) ;
2025-07-16 17:53:50 +01:00
const [ imageUrl , setImageUrl ] = useState < string | null > ( null ) ;
2025-06-24 12:20:31 +01:00
const imgRef = useRef < HTMLImageElement > ( null ) ;
useEffect ( ( ) = > {
const observer = new IntersectionObserver (
( entries ) = > {
entries . forEach ( ( entry ) = > {
if ( entry . isIntersecting && ! imageUrl ) {
setIsVisible ( true ) ;
}
} ) ;
} ,
{
rootMargin : '200px' , // Start loading 200px before visible
threshold : 0.1
}
) ;
if ( imgRef . current ) {
observer . observe ( imgRef . current ) ;
}
return ( ) = > observer . disconnect ( ) ;
} , [ imageUrl ] ) ;
2025-07-16 17:53:50 +01:00
// Update local state when pageImages changes (from preloading)
useEffect ( ( ) = > {
if ( pageImages [ pageIndex ] ) {
setImageUrl ( pageImages [ pageIndex ] ) ;
}
} , [ pageImages , pageIndex ] ) ;
2025-06-24 12:20:31 +01:00
useEffect ( ( ) = > {
if ( isVisible && ! imageUrl ) {
renderPage ( pageIndex ) . then ( ( url ) = > {
if ( url ) setImageUrl ( url ) ;
} ) ;
}
} , [ isVisible , imageUrl , pageIndex , renderPage ] ) ;
useEffect ( ( ) = > {
if ( imgRef . current ) {
setPageRef ( pageIndex , imgRef . current ) ;
}
} , [ pageIndex , setPageRef ] ) ;
if ( imageUrl ) {
return (
< img
ref = { imgRef }
src = { imageUrl }
alt = { ` Page ${ pageIndex + 1 } ` }
style = { {
width : ` ${ 100 * zoom } % ` ,
maxWidth : 700 * zoom ,
boxShadow : "0 2px 8px rgba(0,0,0,0.08)" ,
borderRadius : 8 ,
marginTop : isFirst ? theme.spacing.xl : 0 ,
} }
/ >
) ;
}
// Placeholder while loading
return (
< div
ref = { imgRef }
style = { {
width : ` ${ 100 * zoom } % ` ,
maxWidth : 700 * zoom ,
height : 800 * zoom , // Estimated height
backgroundColor : '#f5f5f5' ,
display : 'flex' ,
alignItems : 'center' ,
justifyContent : 'center' ,
borderRadius : 8 ,
marginTop : isFirst ? theme.spacing.xl : 0 ,
border : '1px dashed #ccc'
} }
>
{ isVisible ? (
< div style = { { textAlign : 'center' } } >
< div style = { {
width : 20 ,
height : 20 ,
border : '2px solid #ddd' ,
borderTop : '2px solid #666' ,
borderRadius : '50%' ,
animation : 'spin 1s linear infinite' ,
margin : '0 auto 8px'
} } / >
< Text size = "sm" c = "dimmed" > Loading page { pageIndex + 1 } . . . < / Text >
< / div >
) : (
< Text size = "sm" c = "dimmed" > Page { pageIndex + 1 } < / Text >
) }
< / div >
) ;
} ;
export interface ViewerProps {
sidebarsVisible : boolean ;
setSidebarsVisible : ( v : boolean ) = > void ;
2025-07-16 17:53:50 +01:00
onClose ? : ( ) = > void ;
2025-08-11 09:16:16 +01:00
previewFile : File | null ; // For preview mode - bypasses context
2025-06-24 12:20:31 +01:00
}
const Viewer = ( {
sidebarsVisible ,
setSidebarsVisible ,
2025-07-16 17:53:50 +01:00
onClose ,
previewFile ,
2025-06-24 12:20:31 +01:00
} : ViewerProps ) = > {
const { t } = useTranslation ( ) ;
const theme = useMantineTheme ( ) ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// Get current file from FileContext
2025-08-21 17:30:26 +01:00
const { selectors } = useFileState ( ) ;
const { actions } = useFileActions ( ) ;
const currentFile = useCurrentFile ( ) ;
2025-08-28 10:56:07 +01:00
2025-08-21 17:30:26 +01:00
const getCurrentFile = ( ) = > currentFile . file ;
const getCurrentProcessedFile = ( ) = > currentFile . record ? . processedFile || undefined ;
const clearAllFiles = actions . clearAllFiles ;
const addFiles = actions . addFiles ;
const activeFiles = selectors . getFiles ( ) ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// Tab management for multiple files
const [ activeTab , setActiveTab ] = useState < string > ( "0" ) ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// Reset PDF state when switching tabs
const handleTabChange = ( newTab : string ) = > {
setActiveTab ( newTab ) ;
setNumPages ( 0 ) ;
setPageImages ( [ ] ) ;
setCurrentPage ( null ) ;
setLoading ( true ) ;
} ;
2025-06-24 12:20:31 +01:00
const [ numPages , setNumPages ] = useState < number > ( 0 ) ;
const [ pageImages , setPageImages ] = useState < string [ ] > ( [ ] ) ;
const [ loading , setLoading ] = useState < boolean > ( false ) ;
const [ currentPage , setCurrentPage ] = useState < number | null > ( null ) ;
const [ dualPage , setDualPage ] = useState ( false ) ;
const [ zoom , setZoom ] = useState ( 1 ) ; // 1 = 100%
const pageRefs = useRef < ( HTMLImageElement | null ) [ ] > ( [ ] ) ;
2025-07-16 17:53:50 +01:00
2025-08-21 17:30:26 +01:00
// Memoize setPageRef to prevent infinite re-renders
const setPageRef = useCallback ( ( index : number , ref : HTMLImageElement | null ) = > {
pageRefs . current [ index ] = ref ;
} , [ ] ) ;
2025-07-16 17:53:50 +01:00
// Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl ( activeFiles [ 0 ] ) ;
const file1WithUrl = useFileWithUrl ( activeFiles [ 1 ] ) ;
const file2WithUrl = useFileWithUrl ( activeFiles [ 2 ] ) ;
const file3WithUrl = useFileWithUrl ( activeFiles [ 3 ] ) ;
const file4WithUrl = useFileWithUrl ( activeFiles [ 4 ] ) ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
const filesWithUrls = React . useMemo ( ( ) = > {
return [ file0WithUrl , file1WithUrl , file2WithUrl , file3WithUrl , file4WithUrl ]
. slice ( 0 , activeFiles . length )
. filter ( Boolean ) ;
} , [ file0WithUrl , file1WithUrl , file2WithUrl , file3WithUrl , file4WithUrl , activeFiles . length ] ) ;
// Use preview file if available, otherwise use active tab file
const effectiveFile = React . useMemo ( ( ) = > {
if ( previewFile ) {
// Validate the preview file
2025-09-05 11:33:03 +01:00
if ( ! isFileObject ( previewFile ) ) {
2025-07-16 17:53:50 +01:00
return null ;
}
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
if ( previewFile . size === 0 ) {
return null ;
}
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
return { file : previewFile , url : null } ;
} else {
// Use the file from the active tab
const tabIndex = parseInt ( activeTab ) ;
return filesWithUrls [ tabIndex ] || null ;
}
} , [ previewFile , filesWithUrls , activeTab ] ) ;
2025-06-24 12:20:31 +01:00
const scrollAreaRef = useRef < HTMLDivElement > ( null ) ;
const pdfDocRef = useRef < any > ( null ) ;
const renderingPagesRef = useRef < Set < number > > ( new Set ( ) ) ;
const currentArrayBufferRef = useRef < ArrayBuffer | null > ( null ) ;
2025-07-16 17:53:50 +01:00
const preloadingRef = useRef < boolean > ( false ) ;
2025-06-24 12:20:31 +01:00
// Function to render a specific page on-demand
const renderPage = async ( pageIndex : number ) : Promise < string | null > = > {
2025-07-16 17:53:50 +01:00
if ( ! pdfDocRef . current || renderingPagesRef . current . has ( pageIndex ) ) {
2025-06-24 12:20:31 +01:00
return null ;
}
const pageNum = pageIndex + 1 ;
if ( pageImages [ pageIndex ] ) {
return pageImages [ pageIndex ] ; // Already rendered
}
renderingPagesRef . current . add ( pageIndex ) ;
try {
const page = await pdfDocRef . current . getPage ( pageNum ) ;
const viewport = page . getViewport ( { scale : 1.2 } ) ;
const canvas = document . createElement ( "canvas" ) ;
canvas . width = viewport . width ;
canvas . height = viewport . height ;
const ctx = canvas . getContext ( "2d" ) ;
if ( ctx ) {
await page . render ( { canvasContext : ctx , viewport } ) . promise ;
const dataUrl = canvas . toDataURL ( ) ;
// Update the pageImages array
setPageImages ( prev = > {
const newImages = [ . . . prev ] ;
newImages [ pageIndex ] = dataUrl ;
return newImages ;
} ) ;
renderingPagesRef . current . delete ( pageIndex ) ;
return dataUrl ;
}
} catch ( error ) {
console . error ( ` Failed to render page ${ pageNum } : ` , error ) ;
}
renderingPagesRef . current . delete ( pageIndex ) ;
return null ;
} ;
2025-07-16 17:53:50 +01:00
// Progressive preloading function
const startProgressivePreload = async ( ) = > {
if ( ! pdfDocRef . current || preloadingRef . current || numPages === 0 ) return ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
preloadingRef . current = true ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// Start with first few pages for immediate viewing
const priorityPages = [ 0 , 1 , 2 , 3 , 4 ] ; // First 5 pages
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// Render priority pages first
for ( const pageIndex of priorityPages ) {
if ( pageIndex < numPages && ! pageImages [ pageIndex ] ) {
await renderPage ( pageIndex ) ;
// Small delay to allow UI to update
await new Promise ( resolve = > setTimeout ( resolve , 50 ) ) ;
}
}
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// Then render remaining pages in background
for ( let pageIndex = 5 ; pageIndex < numPages ; pageIndex ++ ) {
if ( ! pageImages [ pageIndex ] ) {
await renderPage ( pageIndex ) ;
// Longer delay for background loading to not block UI
await new Promise ( resolve = > setTimeout ( resolve , 100 ) ) ;
2025-06-24 12:20:31 +01:00
}
}
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
preloadingRef . current = false ;
} ;
2025-06-24 12:20:31 +01:00
2025-07-16 17:53:50 +01:00
// Initialize current page when PDF loads
2025-06-24 12:20:31 +01:00
useEffect ( ( ) = > {
2025-07-16 17:53:50 +01:00
if ( numPages > 0 && ! currentPage ) {
setCurrentPage ( 1 ) ;
}
} , [ numPages , currentPage ] ) ;
2025-06-24 12:20:31 +01:00
2025-07-16 17:53:50 +01:00
// Function to scroll to a specific page
const scrollToPage = ( pageNumber : number ) = > {
const el = pageRefs . current [ pageNumber - 1 ] ;
const scrollArea = scrollAreaRef . current ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
if ( el && scrollArea ) {
const scrollAreaRect = scrollArea . getBoundingClientRect ( ) ;
const elRect = el . getBoundingClientRect ( ) ;
const currentScrollTop = scrollArea . scrollTop ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// Position page near top of viewport with some padding
const targetScrollTop = currentScrollTop + ( elRect . top - scrollAreaRect . top ) - 20 ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
scrollArea . scrollTo ( {
top : targetScrollTop ,
behavior : "smooth"
} ) ;
2025-06-24 12:20:31 +01:00
}
2025-07-16 17:53:50 +01:00
} ;
2025-06-24 12:20:31 +01:00
2025-07-16 17:53:50 +01:00
// Throttled scroll handler to prevent jerky updates
const handleScrollThrottled = useCallback ( ( ) = > {
2025-06-24 12:20:31 +01:00
const scrollArea = scrollAreaRef . current ;
if ( ! scrollArea || ! pageRefs . current . length ) return ;
const areaRect = scrollArea . getBoundingClientRect ( ) ;
2025-07-16 17:53:50 +01:00
const viewportCenter = areaRect . top + areaRect . height / 2 ;
2025-06-24 12:20:31 +01:00
let closestIdx = 0 ;
let minDist = Infinity ;
pageRefs . current . forEach ( ( img , idx ) = > {
if ( img ) {
const imgRect = img . getBoundingClientRect ( ) ;
2025-07-16 17:53:50 +01:00
const imgCenter = imgRect . top + imgRect . height / 2 ;
const dist = Math . abs ( imgCenter - viewportCenter ) ;
2025-06-24 12:20:31 +01:00
if ( dist < minDist ) {
minDist = dist ;
closestIdx = idx ;
}
}
} ) ;
2025-07-16 17:53:50 +01:00
// Update page number display only if changed
2025-06-24 12:20:31 +01:00
if ( currentPage !== closestIdx + 1 ) {
setCurrentPage ( closestIdx + 1 ) ;
}
2025-07-16 17:53:50 +01:00
} , [ currentPage ] ) ;
// Throttle scroll events to reduce jerkiness
const handleScroll = useCallback ( ( ) = > {
if ( window . requestAnimationFrame ) {
window . requestAnimationFrame ( handleScrollThrottled ) ;
} else {
handleScrollThrottled ( ) ;
}
} , [ handleScrollThrottled ] ) ;
2025-06-24 12:20:31 +01:00
useEffect ( ( ) = > {
let cancelled = false ;
async function loadPdfInfo() {
2025-07-16 17:53:50 +01:00
if ( ! effectiveFile ) {
2025-06-24 12:20:31 +01:00
setNumPages ( 0 ) ;
setPageImages ( [ ] ) ;
return ;
}
setLoading ( true ) ;
try {
2025-07-16 17:53:50 +01:00
let pdfData ;
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
// For preview files, use ArrayBuffer directly to avoid blob URL issues
if ( previewFile && effectiveFile . file === previewFile ) {
const arrayBuffer = await previewFile . arrayBuffer ( ) ;
pdfData = { data : arrayBuffer } ;
}
2025-06-24 12:20:31 +01:00
// Handle special IndexedDB URLs for large files
2025-07-16 17:53:50 +01:00
else if ( effectiveFile . url ? . startsWith ( 'indexeddb:' ) ) {
2025-08-28 10:56:07 +01:00
const fileId = effectiveFile . url . replace ( 'indexeddb:' , '' ) as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */ ;
2025-06-24 12:20:31 +01:00
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage . getFileData ( fileId ) ;
if ( ! arrayBuffer ) {
throw new Error ( 'File not found in IndexedDB - may have been purged by browser' ) ;
}
// Store reference for cleanup
currentArrayBufferRef . current = arrayBuffer ;
2025-07-16 17:53:50 +01:00
pdfData = { data : arrayBuffer } ;
} else if ( effectiveFile . url ) {
2025-06-24 12:20:31 +01:00
// Standard blob URL or regular URL
2025-07-16 17:53:50 +01:00
pdfData = effectiveFile . url ;
} else {
throw new Error ( 'No valid PDF source available' ) ;
}
2025-08-21 17:30:26 +01:00
const pdf = await pdfWorkerManager . createDocument ( pdfData ) ;
2025-07-16 17:53:50 +01:00
pdfDocRef . current = pdf ;
setNumPages ( pdf . numPages ) ;
if ( ! cancelled ) {
setPageImages ( new Array ( pdf . numPages ) . fill ( null ) ) ;
// Start progressive preloading after a short delay
setTimeout ( ( ) = > startProgressivePreload ( ) , 100 ) ;
2025-06-24 12:20:31 +01:00
}
} catch ( error ) {
if ( ! cancelled ) {
setPageImages ( [ ] ) ;
setNumPages ( 0 ) ;
}
}
if ( ! cancelled ) setLoading ( false ) ;
}
loadPdfInfo ( ) ;
return ( ) = > {
cancelled = true ;
2025-07-16 17:53:50 +01:00
// Stop any ongoing preloading
preloadingRef . current = false ;
2025-08-21 17:30:26 +01:00
// Cleanup PDF document using worker manager
if ( pdfDocRef . current ) {
pdfWorkerManager . destroyDocument ( pdfDocRef . current ) ;
pdfDocRef . current = null ;
}
2025-06-24 12:20:31 +01:00
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef . current = null ;
} ;
2025-07-16 17:53:50 +01:00
} , [ effectiveFile , previewFile ] ) ;
2025-06-24 12:20:31 +01:00
useEffect ( ( ) = > {
const viewport = scrollAreaRef . current ;
if ( ! viewport ) return ;
const handler = ( ) = > {
handleScroll ( ) ;
} ;
viewport . addEventListener ( "scroll" , handler ) ;
return ( ) = > viewport . removeEventListener ( "scroll" , handler ) ;
} , [ pageImages ] ) ;
return (
2025-08-29 14:01:46 +01:00
< Box style = { { position : 'relative' , height : '100%' , display : 'flex' , flexDirection : 'column' } } >
2025-07-16 17:53:50 +01:00
{ /* Close Button - Only show in preview mode */ }
{ onClose && previewFile && (
< ActionIcon
variant = "filled"
color = "gray"
size = "lg"
style = { {
position : 'absolute' ,
top : '1rem' ,
right : '1rem' ,
zIndex : 1000 ,
borderRadius : '50%' ,
} }
onClick = { onClose }
>
< CloseIcon / >
< / ActionIcon >
) }
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
{ ! effectiveFile ? (
2025-06-24 12:20:31 +01:00
< Center style = { { flex : 1 } } >
2025-07-16 17:53:50 +01:00
< Text c = "red" > Error : No file provided to viewer < / Text >
2025-06-24 12:20:31 +01:00
< / Center >
) : (
2025-07-16 17:53:50 +01:00
< >
{ /* Tabs for multiple files */ }
{ activeFiles . length > 1 && ! previewFile && (
2025-08-11 09:16:16 +01:00
< Box
style = { {
2025-07-16 17:53:50 +01:00
borderBottom : '1px solid var(--mantine-color-gray-3)' ,
backgroundColor : 'var(--mantine-color-body)' ,
position : 'relative' ,
zIndex : 100 ,
marginTop : '60px' // Push tabs below TopControls
} }
>
< Tabs value = { activeTab } onChange = { ( value ) = > handleTabChange ( value || "0" ) } >
< Tabs.List >
2025-08-21 17:30:26 +01:00
{ activeFiles . map ( ( file : any , index : number ) = > (
2025-07-16 17:53:50 +01:00
< Tabs.Tab key = { index } value = { index . toString ( ) } >
{ file . name . length > 20 ? ` ${ file . name . substring ( 0 , 20 ) } ... ` : file . name }
< / Tabs.Tab >
) ) }
< / Tabs.List >
< / Tabs >
< / Box >
) }
2025-08-11 09:16:16 +01:00
2025-07-16 17:53:50 +01:00
{ loading ? (
< div style = { { flex : 1 , padding : '1rem' } } >
< SkeletonLoader type = "viewer" / >
< / div >
) : (
2025-06-24 12:20:31 +01:00
< ScrollArea
2025-07-16 17:53:50 +01:00
style = { { flex : 1 , position : "relative" } }
2025-06-24 12:20:31 +01:00
viewportRef = { scrollAreaRef }
>
< Stack gap = "xl" align = "center" >
{ numPages === 0 && (
< Text color = "dimmed" > { t ( "viewer.noPagesToDisplay" , "No pages to display." ) } < / Text >
) }
{ dualPage
? Array . from ( { length : Math.ceil ( numPages / 2 ) } ) . map ( ( _ , i ) = > (
< Group key = { i } gap = "md" align = "flex-start" style = { { width : "100%" , justifyContent : "center" } } >
< LazyPageImage
pageIndex = { i * 2 }
zoom = { zoom }
theme = { theme }
isFirst = { i === 0 }
renderPage = { renderPage }
pageImages = { pageImages }
2025-08-21 17:30:26 +01:00
setPageRef = { setPageRef }
2025-06-24 12:20:31 +01:00
/ >
{ i * 2 + 1 < numPages && (
< LazyPageImage
pageIndex = { i * 2 + 1 }
zoom = { zoom }
theme = { theme }
isFirst = { i === 0 }
renderPage = { renderPage }
pageImages = { pageImages }
2025-08-21 17:30:26 +01:00
setPageRef = { setPageRef }
2025-06-24 12:20:31 +01:00
/ >
) }
< / Group >
) )
: Array . from ( { length : numPages } ) . map ( ( _ , idx ) = > (
< LazyPageImage
key = { idx }
pageIndex = { idx }
zoom = { zoom }
theme = { theme }
isFirst = { idx === 0 }
renderPage = { renderPage }
pageImages = { pageImages }
2025-08-21 17:30:26 +01:00
setPageRef = { setPageRef }
2025-06-24 12:20:31 +01:00
/ >
) ) }
< / Stack >
{ /* Navigation bar overlays the scroll area */ }
< div
style = { {
position : "absolute" ,
left : 0 ,
right : 0 ,
bottom : 0 ,
zIndex : 50 ,
display : "flex" ,
justifyContent : "center" ,
pointerEvents : "none" ,
background : "transparent" ,
2025-08-26 16:31:20 +01:00
2025-06-24 12:20:31 +01:00
} }
>
< Paper
radius = "xl xl 0 0"
shadow = "sm"
p = { 12 }
2025-08-29 14:01:46 +01:00
pb = { 12 }
2025-06-24 12:20:31 +01:00
style = { {
display : "flex" ,
alignItems : "center" ,
gap : 10 ,
borderTopLeftRadius : 16 ,
borderTopRightRadius : 16 ,
borderBottomLeftRadius : 0 ,
borderBottomRightRadius : 0 ,
boxShadow : "0 -2px 8px rgba(0,0,0,0.04)" ,
pointerEvents : "auto" ,
minWidth : 420 ,
maxWidth : 700 ,
flexWrap : "wrap" ,
} }
>
< Button
variant = "subtle"
color = "blue"
size = "md"
px = { 8 }
radius = "xl"
onClick = { ( ) = > {
2025-07-16 17:53:50 +01:00
scrollToPage ( 1 ) ;
2025-06-24 12:20:31 +01:00
} }
disabled = { currentPage === 1 }
style = { { minWidth : 36 } }
>
< FirstPageIcon fontSize = "small" / >
< / Button >
< Button
variant = "subtle"
color = "blue"
size = "md"
px = { 8 }
radius = "xl"
onClick = { ( ) = > {
2025-07-16 17:53:50 +01:00
const prevPage = Math . max ( 1 , ( currentPage || 1 ) - 1 ) ;
scrollToPage ( prevPage ) ;
2025-06-24 12:20:31 +01:00
} }
disabled = { currentPage === 1 }
style = { { minWidth : 36 } }
>
< ArrowBackIosNewIcon fontSize = "small" / >
< / Button >
< NumberInput
value = { currentPage || 1 }
onChange = { value = > {
const page = Number ( value ) ;
if ( ! isNaN ( page ) && page >= 1 && page <= numPages ) {
2025-07-16 17:53:50 +01:00
scrollToPage ( page ) ;
2025-06-24 12:20:31 +01:00
}
} }
min = { 1 }
max = { numPages }
hideControls
styles = { {
input : { width : 48 , textAlign : "center" , fontWeight : 500 , fontSize : 16 } ,
} }
/ >
< span style = { { fontWeight : 500 , fontSize : 16 } } >
/ { n u m P a g e s }
< / span >
< Button
variant = "subtle"
color = "blue"
size = "md"
px = { 8 }
radius = "xl"
onClick = { ( ) = > {
2025-07-16 17:53:50 +01:00
const nextPage = Math . min ( numPages , ( currentPage || 1 ) + 1 ) ;
scrollToPage ( nextPage ) ;
2025-06-24 12:20:31 +01:00
} }
disabled = { currentPage === numPages }
style = { { minWidth : 36 } }
>
< ArrowForwardIosIcon fontSize = "small" / >
< / Button >
< Button
variant = "subtle"
color = "blue"
size = "md"
px = { 8 }
radius = "xl"
onClick = { ( ) = > {
2025-07-16 17:53:50 +01:00
scrollToPage ( numPages ) ;
2025-06-24 12:20:31 +01:00
} }
disabled = { currentPage === numPages }
style = { { minWidth : 36 } }
>
< LastPageIcon fontSize = "small" / >
< / Button >
< Button
variant = { dualPage ? "filled" : "light" }
color = "blue"
size = "md"
radius = "xl"
onClick = { ( ) = > setDualPage ( v = > ! v ) }
style = { { minWidth : 36 } }
title = { dualPage ? t ( "viewer.singlePageView" , "Single Page View" ) : t ( "viewer.dualPageView" , "Dual Page View" ) }
>
{ dualPage ? < DescriptionIcon fontSize = "small" / > : < ViewWeekIcon fontSize = "small" / > }
< / Button >
< Group gap = { 4 } align = "center" style = { { marginLeft : 16 } } >
< Button
variant = "subtle"
color = "blue"
size = "md"
radius = "xl"
onClick = { ( ) = > setZoom ( z = > Math . max ( 0.1 , z - 0.1 ) ) }
style = { { minWidth : 32 , padding : 0 } }
title = { t ( "viewer.zoomOut" , "Zoom out" ) }
> − < / Button >
< span style = { { minWidth : 40 , textAlign : "center" } } > { Math . round ( zoom * 100 ) } % < / span >
< Button
variant = "subtle"
color = "blue"
size = "md"
radius = "xl"
onClick = { ( ) = > setZoom ( z = > Math . min ( 5 , z + 0.1 ) ) }
style = { { minWidth : 32 , padding : 0 } }
title = { t ( "viewer.zoomIn" , "Zoom in" ) }
> + < / Button >
< / Group >
< / Paper >
< / div >
< / ScrollArea >
2025-07-16 17:53:50 +01:00
) }
< / >
2025-06-24 12:20:31 +01:00
) }
2025-07-16 17:53:50 +01:00
< / Box >
2025-06-24 12:20:31 +01:00
) ;
} ;
export default Viewer ;