mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 04:09:22 +00:00

# Description of Changes A new universal file context rather than the splintered ones for the main views, tools and manager we had before (manager still has its own but its better integreated with the core context) File context has been split it into a handful of different files managing various file related issues separately to reduce the monolith - FileReducer.ts - State management fileActions.ts - File operations fileSelectors.ts - Data access patterns lifecycle.ts - Resource cleanup and memory management fileHooks.ts - React hooks interface contexts.ts - Context providers Improved thumbnail generation Improved indexxedb handling Stopped handling files as blobs were not necessary to improve performance A new library handling drag and drop https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes but I broke the old one with the new filecontext and it needed doing so it was a might as well) A new library handling virtualisation on page editor @tanstack/react-virtual, as above. Quickly ripped out the last remnants of the old URL params stuff and replaced with the beginnings of what will later become the new URL navigation system (for now it just restores the tool name in url behavior) Fixed selected file not regestered when opening a tool Fixed png thumbnails Closes #(issue_number) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Reece Browne <you@example.com>
694 lines
22 KiB
TypeScript
694 lines
22 KiB
TypeScript
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";
|
||
import { useTranslation } from "react-i18next";
|
||
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
||
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
|
||
import CloseIcon from "@mui/icons-material/Close";
|
||
import { useLocalStorage } from "@mantine/hooks";
|
||
import { fileStorage } from "../../services/fileStorage";
|
||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||
|
||
|
||
// 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);
|
||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||
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]);
|
||
|
||
// Update local state when pageImages changes (from preloading)
|
||
useEffect(() => {
|
||
if (pageImages[pageIndex]) {
|
||
setImageUrl(pageImages[pageIndex]);
|
||
}
|
||
}, [pageImages, pageIndex]);
|
||
|
||
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;
|
||
onClose?: () => void;
|
||
previewFile: File | null; // For preview mode - bypasses context
|
||
}
|
||
|
||
const Viewer = ({
|
||
sidebarsVisible,
|
||
setSidebarsVisible,
|
||
onClose,
|
||
previewFile,
|
||
}: ViewerProps) => {
|
||
const { t } = useTranslation();
|
||
const theme = useMantineTheme();
|
||
|
||
// Get current file from FileContext
|
||
const { selectors } = useFileState();
|
||
const { actions } = useFileActions();
|
||
const currentFile = useCurrentFile();
|
||
|
||
const getCurrentFile = () => currentFile.file;
|
||
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
|
||
const clearAllFiles = actions.clearAllFiles;
|
||
const addFiles = actions.addFiles;
|
||
const activeFiles = selectors.getFiles();
|
||
|
||
// Tab management for multiple files
|
||
const [activeTab, setActiveTab] = useState<string>("0");
|
||
|
||
// Reset PDF state when switching tabs
|
||
const handleTabChange = (newTab: string) => {
|
||
setActiveTab(newTab);
|
||
setNumPages(0);
|
||
setPageImages([]);
|
||
setCurrentPage(null);
|
||
setLoading(true);
|
||
};
|
||
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)[]>([]);
|
||
|
||
// Memoize setPageRef to prevent infinite re-renders
|
||
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
|
||
pageRefs.current[index] = ref;
|
||
}, []);
|
||
|
||
// 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]);
|
||
|
||
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
|
||
if (!(previewFile instanceof File)) {
|
||
return null;
|
||
}
|
||
|
||
if (previewFile.size === 0) {
|
||
return null;
|
||
}
|
||
|
||
return { file: previewFile, url: null };
|
||
} else {
|
||
// Use the file from the active tab
|
||
const tabIndex = parseInt(activeTab);
|
||
return filesWithUrls[tabIndex] || null;
|
||
}
|
||
}, [previewFile, filesWithUrls, activeTab]);
|
||
|
||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||
const pdfDocRef = useRef<any>(null);
|
||
const renderingPagesRef = useRef<Set<number>>(new Set());
|
||
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
|
||
const preloadingRef = useRef<boolean>(false);
|
||
|
||
// Function to render a specific page on-demand
|
||
const renderPage = async (pageIndex: number): Promise<string | null> => {
|
||
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
|
||
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;
|
||
};
|
||
|
||
// Progressive preloading function
|
||
const startProgressivePreload = async () => {
|
||
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
|
||
|
||
preloadingRef.current = true;
|
||
|
||
// Start with first few pages for immediate viewing
|
||
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
|
||
|
||
// 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));
|
||
}
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
}
|
||
|
||
preloadingRef.current = false;
|
||
};
|
||
|
||
// Initialize current page when PDF loads
|
||
useEffect(() => {
|
||
if (numPages > 0 && !currentPage) {
|
||
setCurrentPage(1);
|
||
}
|
||
}, [numPages, currentPage]);
|
||
|
||
// Function to scroll to a specific page
|
||
const scrollToPage = (pageNumber: number) => {
|
||
const el = pageRefs.current[pageNumber - 1];
|
||
const scrollArea = scrollAreaRef.current;
|
||
|
||
if (el && scrollArea) {
|
||
const scrollAreaRect = scrollArea.getBoundingClientRect();
|
||
const elRect = el.getBoundingClientRect();
|
||
const currentScrollTop = scrollArea.scrollTop;
|
||
|
||
// Position page near top of viewport with some padding
|
||
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
|
||
|
||
scrollArea.scrollTo({
|
||
top: targetScrollTop,
|
||
behavior: "smooth"
|
||
});
|
||
}
|
||
};
|
||
|
||
// Throttled scroll handler to prevent jerky updates
|
||
const handleScrollThrottled = useCallback(() => {
|
||
const scrollArea = scrollAreaRef.current;
|
||
if (!scrollArea || !pageRefs.current.length) return;
|
||
|
||
const areaRect = scrollArea.getBoundingClientRect();
|
||
const viewportCenter = areaRect.top + areaRect.height / 2;
|
||
let closestIdx = 0;
|
||
let minDist = Infinity;
|
||
|
||
pageRefs.current.forEach((img, idx) => {
|
||
if (img) {
|
||
const imgRect = img.getBoundingClientRect();
|
||
const imgCenter = imgRect.top + imgRect.height / 2;
|
||
const dist = Math.abs(imgCenter - viewportCenter);
|
||
if (dist < minDist) {
|
||
minDist = dist;
|
||
closestIdx = idx;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update page number display only if changed
|
||
if (currentPage !== closestIdx + 1) {
|
||
setCurrentPage(closestIdx + 1);
|
||
}
|
||
}, [currentPage]);
|
||
|
||
// Throttle scroll events to reduce jerkiness
|
||
const handleScroll = useCallback(() => {
|
||
if (window.requestAnimationFrame) {
|
||
window.requestAnimationFrame(handleScrollThrottled);
|
||
} else {
|
||
handleScrollThrottled();
|
||
}
|
||
}, [handleScrollThrottled]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
async function loadPdfInfo() {
|
||
if (!effectiveFile) {
|
||
setNumPages(0);
|
||
setPageImages([]);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
let pdfData;
|
||
|
||
// For preview files, use ArrayBuffer directly to avoid blob URL issues
|
||
if (previewFile && effectiveFile.file === previewFile) {
|
||
const arrayBuffer = await previewFile.arrayBuffer();
|
||
pdfData = { data: arrayBuffer };
|
||
}
|
||
// Handle special IndexedDB URLs for large files
|
||
else if (effectiveFile.url?.startsWith('indexeddb:')) {
|
||
const fileId = effectiveFile.url.replace('indexeddb:', '');
|
||
|
||
// 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;
|
||
pdfData = { data: arrayBuffer };
|
||
} else if (effectiveFile.url) {
|
||
// Standard blob URL or regular URL
|
||
pdfData = effectiveFile.url;
|
||
} else {
|
||
throw new Error('No valid PDF source available');
|
||
}
|
||
|
||
const pdf = await pdfWorkerManager.createDocument(pdfData);
|
||
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);
|
||
}
|
||
} catch (error) {
|
||
if (!cancelled) {
|
||
setPageImages([]);
|
||
setNumPages(0);
|
||
}
|
||
}
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
loadPdfInfo();
|
||
return () => {
|
||
cancelled = true;
|
||
// Stop any ongoing preloading
|
||
preloadingRef.current = false;
|
||
// Cleanup PDF document using worker manager
|
||
if (pdfDocRef.current) {
|
||
pdfWorkerManager.destroyDocument(pdfDocRef.current);
|
||
pdfDocRef.current = null;
|
||
}
|
||
// Cleanup ArrayBuffer reference to help garbage collection
|
||
currentArrayBufferRef.current = null;
|
||
};
|
||
}, [effectiveFile, previewFile]);
|
||
|
||
useEffect(() => {
|
||
const viewport = scrollAreaRef.current;
|
||
if (!viewport) return;
|
||
const handler = () => {
|
||
handleScroll();
|
||
};
|
||
viewport.addEventListener("scroll", handler);
|
||
return () => viewport.removeEventListener("scroll", handler);
|
||
}, [pageImages]);
|
||
|
||
return (
|
||
<Box style={{ position: 'relative', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||
{/* 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>
|
||
)}
|
||
|
||
{!effectiveFile ? (
|
||
<Center style={{ flex: 1 }}>
|
||
<Text c="red">Error: No file provided to viewer</Text>
|
||
</Center>
|
||
) : (
|
||
<>
|
||
{/* Tabs for multiple files */}
|
||
{activeFiles.length > 1 && !previewFile && (
|
||
<Box
|
||
style={{
|
||
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>
|
||
{activeFiles.map((file: any, index: number) => (
|
||
<Tabs.Tab key={index} value={index.toString()}>
|
||
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
|
||
</Tabs.Tab>
|
||
))}
|
||
</Tabs.List>
|
||
</Tabs>
|
||
</Box>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div style={{ flex: 1, padding: '1rem' }}>
|
||
<SkeletonLoader type="viewer" />
|
||
</div>
|
||
) : (
|
||
<ScrollArea
|
||
style={{ flex: 1, position: "relative"}}
|
||
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}
|
||
setPageRef={setPageRef}
|
||
/>
|
||
{i * 2 + 1 < numPages && (
|
||
<LazyPageImage
|
||
pageIndex={i * 2 + 1}
|
||
zoom={zoom}
|
||
theme={theme}
|
||
isFirst={i === 0}
|
||
renderPage={renderPage}
|
||
pageImages={pageImages}
|
||
setPageRef={setPageRef}
|
||
/>
|
||
)}
|
||
</Group>
|
||
))
|
||
: Array.from({ length: numPages }).map((_, idx) => (
|
||
<LazyPageImage
|
||
key={idx}
|
||
pageIndex={idx}
|
||
zoom={zoom}
|
||
theme={theme}
|
||
isFirst={idx === 0}
|
||
renderPage={renderPage}
|
||
pageImages={pageImages}
|
||
setPageRef={setPageRef}
|
||
/>
|
||
))}
|
||
</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",
|
||
}}
|
||
>
|
||
<Paper
|
||
radius="xl xl 0 0"
|
||
shadow="sm"
|
||
p={12}
|
||
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={() => {
|
||
scrollToPage(1);
|
||
}}
|
||
disabled={currentPage === 1}
|
||
style={{ minWidth: 36 }}
|
||
>
|
||
<FirstPageIcon fontSize="small" />
|
||
</Button>
|
||
<Button
|
||
variant="subtle"
|
||
color="blue"
|
||
size="md"
|
||
px={8}
|
||
radius="xl"
|
||
onClick={() => {
|
||
const prevPage = Math.max(1, (currentPage || 1) - 1);
|
||
scrollToPage(prevPage);
|
||
}}
|
||
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) {
|
||
scrollToPage(page);
|
||
}
|
||
}}
|
||
min={1}
|
||
max={numPages}
|
||
hideControls
|
||
styles={{
|
||
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16},
|
||
}}
|
||
/>
|
||
<span style={{ fontWeight: 500, fontSize: 16 }}>
|
||
/ {numPages}
|
||
</span>
|
||
<Button
|
||
variant="subtle"
|
||
color="blue"
|
||
size="md"
|
||
px={8}
|
||
radius="xl"
|
||
onClick={() => {
|
||
const nextPage = Math.min(numPages, (currentPage || 1) + 1);
|
||
scrollToPage(nextPage);
|
||
}}
|
||
disabled={currentPage === numPages}
|
||
style={{ minWidth: 36 }}
|
||
>
|
||
<ArrowForwardIosIcon fontSize="small" />
|
||
</Button>
|
||
<Button
|
||
variant="subtle"
|
||
color="blue"
|
||
size="md"
|
||
px={8}
|
||
radius="xl"
|
||
onClick={() => {
|
||
scrollToPage(numPages);
|
||
}}
|
||
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>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default Viewer;
|