- Viewer overhaul

-Dark mode toggle
-URL params improvements
-app.js set up fix
- UI clean up
This commit is contained in:
Reece
2025-05-27 19:22:26 +01:00
parent 41c82b15da
commit d216811317
9 changed files with 740 additions and 348 deletions

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from "react";
import { Card, Group, Text, Stack, Image, Badge, Button, Box, Flex } from "@mantine/core";
import { Card, Group, Text, Stack, Image, Badge, Button, Box, Flex, ThemeIcon } from "@mantine/core";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { GlobalWorkerOptions, getDocument } from "pdfjs-dist";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
GlobalWorkerOptions.workerSrc =
(import.meta as any).env?.PUBLIC_URL
@@ -93,7 +94,15 @@ function FileCard({ file, onRemove, onDoubleClick }: FileCardProps) {
{thumb ? (
<Image src={thumb} alt="PDF thumbnail" height={110} width={80} fit="contain" radius="sm" />
) : (
<Image src="/images/pdf-placeholder.svg" alt="PDF" height={60} width={60} fit="contain" radius="sm" />
<ThemeIcon
variant="light"
color="red"
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
@@ -145,21 +154,22 @@ const FileManager: React.FC<FileManagerProps> = ({
};
return (
<div style={{ width: "100%", margin: "0 auto" }}>
<div style={{ width: "100%", margin: "0 auto", justifyContent: "center", display: "flex", flexDirection: "column", alignItems: "center", padding: "20px" }}>
<Dropzone
onDrop={handleDrop}
accept={[MIME_TYPES.pdf]}
multiple={allowMultiple}
maxSize={20 * 1024 * 1024}
style={{
marginTop: 16,
marginBottom: 16,
border: "2px dashed rgb(202, 202, 202)",
background: "#f8fafc",
borderRadius: 8,
minHeight: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
width:"90%"
}}
>
<Group justify="center" gap="xl" style={{ pointerEvents: "none" }}>

View File

@@ -27,7 +27,6 @@ const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, tool
<Box
style={{
width: 220,
background: "#f8f9fa",
borderRight: "1px solid #e9ecef",
minHeight: "100vh",
padding: 16,

View File

@@ -1,18 +1,121 @@
import React, { useEffect, useState } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group } from "@mantine/core";
import React, { useEffect, useState, useRef } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core";
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
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 { useLocalStorage } from "@mantine/hooks";
GlobalWorkerOptions.workerSrc = `${process.env.PUBLIC_URL}/pdf.worker.js`;
export interface ViewerProps {
pdfFile: { file: File; url: string } | null;
setPdfFile: (file: { file: File; url: string } | null) => void;
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
}
const Viewer: React.FC<ViewerProps> = ({ pdfFile, setPdfFile }) => {
const Viewer: React.FC<ViewerProps> = ({
pdfFile,
setPdfFile,
sidebarsVisible,
setSidebarsVisible,
}) => {
const theme = useMantineTheme();
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)[]>([]);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const userInitiatedRef = useRef(false);
const suppressScrollRef = useRef(false);
// Listen for hash changes and update currentPage
useEffect(() => {
function handleHashChange() {
if (window.location.hash.startsWith("#page=")) {
const page = parseInt(window.location.hash.replace("#page=", ""), 10);
if (!isNaN(page) && page >= 1 && page <= numPages) {
setCurrentPage(page);
}
}
userInitiatedRef.current = false;
}
window.addEventListener("hashchange", handleHashChange);
handleHashChange(); // Run on mount
return () => window.removeEventListener("hashchange", handleHashChange);
}, [numPages]);
// Scroll to the current page when it changes
useEffect(() => {
if (currentPage && pageRefs.current[currentPage - 1]) {
suppressScrollRef.current = true;
const el = pageRefs.current[currentPage - 1];
el?.scrollIntoView({ behavior: "smooth", block: "center" });
// Try to use scrollend if supported
const viewport = scrollAreaRef.current;
let timeout: NodeJS.Timeout | null = null;
let scrollEndHandler: (() => void) | null = null;
if (viewport && "onscrollend" in viewport) {
scrollEndHandler = () => {
suppressScrollRef.current = false;
viewport.removeEventListener("scrollend", scrollEndHandler!);
};
viewport.addEventListener("scrollend", scrollEndHandler);
} else {
// Fallback for non-Chromium browsers
timeout = setTimeout(() => {
suppressScrollRef.current = false;
}, 1000);
}
return () => {
if (viewport && scrollEndHandler) {
viewport.removeEventListener("scrollend", scrollEndHandler);
}
if (timeout) clearTimeout(timeout);
};
}
}, [currentPage, pageImages]);
// Detect visible page on scroll and update hash
const handleScroll = () => {
if (suppressScrollRef.current) return;
const scrollArea = scrollAreaRef.current;
if (!scrollArea || !pageRefs.current.length) return;
const areaRect = scrollArea.getBoundingClientRect();
let closestIdx = 0;
let minDist = Infinity;
pageRefs.current.forEach((img, idx) => {
if (img) {
const imgRect = img.getBoundingClientRect();
const dist = Math.abs(imgRect.top - areaRect.top);
if (dist < minDist) {
minDist = dist;
closestIdx = idx;
}
}
});
if (currentPage !== closestIdx + 1) {
setCurrentPage(closestIdx + 1);
if (window.location.hash !== `#page=${closestIdx + 1}`) {
window.location.hash = `#page=${closestIdx + 1}`;
}
}
};
useEffect(() => {
let cancelled = false;
@@ -49,12 +152,31 @@ const Viewer: React.FC<ViewerProps> = ({ pdfFile, setPdfFile }) => {
return () => { cancelled = true; };
}, [pdfFile]);
useEffect(() => {
const viewport = scrollAreaRef.current;
if (!viewport) return;
const handler = () => {
handleScroll();
};
viewport.addEventListener("scroll", handler);
return () => viewport.removeEventListener("scroll", handler);
}, [pageImages]);
return (
<Paper shadow="xs" radius="md" p="md" style={{ height: "100%", minHeight: 400, display: "flex", flexDirection: "column" }}>
<Paper
shadow="xs"
radius="md"
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
position: "relative",
}}
>
{!pdfFile ? (
<Center style={{ flex: 1 }}>
<Stack align="center">
<Text color="dimmed">No PDF loaded. Click to upload a PDF.</Text>
<Text c="dimmed">No PDF loaded. Click to upload a PDF.</Text>
<Button
component="label"
variant="outline"
@@ -81,39 +203,222 @@ const Viewer: React.FC<ViewerProps> = ({ pdfFile, setPdfFile }) => {
<Loader size="lg" />
</Center>
) : (
<ScrollArea style={{ flex: 1, height: "100%" }}>
<Stack gap="xl" align="center">
<ScrollArea
style={{ flex: 1, height: "100%", position: "relative"}}
viewportRef={scrollAreaRef}
>
<Stack gap="xl" align="center" >
{pageImages.length === 0 && (
<Text color="dimmed">No pages to display.</Text>
)}
{pageImages.map((img, idx) => (
<img
key={idx}
src={img}
alt={`Page ${idx + 1}`}
style={{
width: "100%",
maxWidth: 700,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
background: "#fff"
{dualPage
? Array.from({ length: Math.ceil(pageImages.length / 2) }).map((_, i) => (
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
<img
ref={el => { pageRefs.current[i * 2] = el; }}
src={pageImages[i * 2]}
alt={`Page ${i * 2 + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
}}
/>
{pageImages[i * 2 + 1] && (
<img
ref={el => { pageRefs.current[i * 2 + 1] = el; }}
src={pageImages[i * 2 + 1]}
alt={`Page ${i * 2 + 2}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
}}
/>
)}
</Group>
))
: pageImages.map((img, idx) => (
<img
key={idx}
ref={el => { pageRefs.current[idx] = el; }}
src={img}
alt={`Page ${idx + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: idx === 0 ? theme.spacing.xl : 0, // <-- add gap to first page
}}
/>
))}
</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={() => {
window.location.hash = `#page=1`;
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<FirstPageIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${Math.max(1, (currentPage || 1) - 1)}`;
}}
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) {
window.location.hash = `#page=${page}`;
}
}}
min={1}
max={numPages}
hideControls
styles={{
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16},
}}
/>
))}
</Stack>
<span style={{ fontWeight: 500, fontSize: 16 }}>
/ {numPages}
</span>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${Math.min(numPages, (currentPage || 1) + 1)}`;
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<ArrowForwardIosIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${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 ? "Single Page View" : "Dual Page View"}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setSidebarsVisible(!sidebarsVisible)}
style={{ minWidth: 36 }}
title={sidebarsVisible ? "Hide Sidebars" : "Show Sidebars"}
>
<ViewSidebarIcon
fontSize="small"
style={{
transform: sidebarsVisible ? "none" : "scaleX(-1)",
transition: "transform 0.2s"
}}
/>
</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="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="Zoom in"
>+</Button>
</Group>
</Paper>
</div>
</ScrollArea>
)}
{pdfFile && (
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="red"
onClick={() => setPdfFile(null)}
>
Close PDF
</Button>
</Group>
)}
</Paper>
);
};