diff --git a/frontend/src/App.js b/frontend/src/App.js
index 19f2719f3..9a98599e2 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -1,31 +1,6 @@
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import { BrowserRouter, Routes, Route } from 'react-router-dom';
-import { ColorSchemeScript, MantineProvider } from '@mantine/core';
import './index.css';
import HomePage from './pages/HomePage';
-import SplitPdfPanel from './tools/Split';
-import reportWebVitals from './reportWebVitals';
-export default function App() {
- return (
-
- } />
- } />
-
- );
+export default function App({ colorScheme, toggleColorScheme }) {
+ return ;
}
-
-const root = ReactDOM.createRoot(document.getElementById('root'));
-root.render(
-
-
-
-
-
-
-
-
-);
-
-reportWebVitals();
diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx
index f4a05e704..c3cdc1775 100644
--- a/frontend/src/components/FileManager.tsx
+++ b/frontend/src/components/FileManager.tsx
@@ -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 ? (
) : (
-
+
+
+
)}
@@ -145,21 +154,22 @@ const FileManager: React.FC = ({
};
return (
-
+
diff --git a/frontend/src/components/ToolPicker.tsx b/frontend/src/components/ToolPicker.tsx
index 5ece14018..dfc7573c1 100644
--- a/frontend/src/components/ToolPicker.tsx
+++ b/frontend/src/components/ToolPicker.tsx
@@ -27,7 +27,6 @@ const ToolPicker: React.FC = ({ selectedToolKey, onSelect, tool
void;
+ sidebarsVisible: boolean;
+ setSidebarsVisible: (v: boolean) => void;
}
-const Viewer: React.FC = ({ pdfFile, setPdfFile }) => {
+const Viewer: React.FC = ({
+ pdfFile,
+ setPdfFile,
+ sidebarsVisible,
+ setSidebarsVisible,
+}) => {
+ const theme = useMantineTheme();
const [numPages, setNumPages] = useState(0);
const [pageImages, setPageImages] = useState([]);
const [loading, setLoading] = useState(false);
+ const [currentPage, setCurrentPage] = useState(null);
+ const [dualPage, setDualPage] = useState(false);
+ const [zoom, setZoom] = useState(1); // 1 = 100%
+ const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
+ const scrollAreaRef = useRef(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 = ({ 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 (
-
+
{!pdfFile ? (
- No PDF loaded. Click to upload a PDF.
+ No PDF loaded. Click to upload a PDF.
) : (
-
-
+
+
{pageImages.length === 0 && (
No pages to display.
)}
- {pageImages.map((img, idx) => (
-
(
+
+
{ 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] && (
+
{ 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
+ }}
+ />
+ )}
+
+ ))
+ : pageImages.map((img, idx) => (
+
{ 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
+ }}
+ />
+ ))}
+
+ {/* Navigation bar overlays the scroll area */}
+
+
+
+
+ {
+ 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},
}}
/>
- ))}
-
+
+ / {numPages}
+
+
+
+
+
+
+
+ {Math.round(zoom * 100)}%
+
+
+
+
)}
- {pdfFile && (
-
-
-
- )}
+
);
};
diff --git a/frontend/src/index.js b/frontend/src/index.js
index 9915027bf..0735f589f 100644
--- a/frontend/src/index.js
+++ b/frontend/src/index.js
@@ -7,6 +7,7 @@ import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
+
const root = ReactDOM.createRoot(document.getElementById('root')); // Finds the root DOM element
root.render(
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index baebfbc46..a7487086b 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -6,7 +6,7 @@ import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
-import { Group, SegmentedControl, Paper, Center, Box } from "@mantine/core";
+import { Group, SegmentedControl, Paper, Center, Box, Button, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import ToolPicker from "../components/ToolPicker";
import FileManager from "../components/FileManager";
@@ -15,6 +15,8 @@ import CompressPdfPanel from "../tools/Compress";
import MergePdfPanel from "../tools/Merge";
import PageEditor from "../components/PageEditor";
import Viewer from "../components/Viewer";
+import DarkModeIcon from '@mui/icons-material/DarkMode';
+import LightModeIcon from '@mui/icons-material/LightMode';
type ToolRegistryEntry = {
icon: React.ReactNode;
@@ -60,8 +62,98 @@ const VIEW_OPTIONS = [
},
];
+// Utility to extract params for a tool from searchParams
+function getToolParams(toolKey: string, searchParams: URLSearchParams) {
+ switch (toolKey) {
+ case "split":
+ return {
+ mode: searchParams.get("splitMode") || "byPages",
+ pages: searchParams.get("pages") || "",
+ hDiv: searchParams.get("hDiv") || "0",
+ vDiv: searchParams.get("vDiv") || "1",
+ merge: searchParams.get("merge") === "true",
+ splitType: searchParams.get("splitType") || "size",
+ splitValue: searchParams.get("splitValue") || "",
+ bookmarkLevel: searchParams.get("bookmarkLevel") || "0",
+ includeMetadata: searchParams.get("includeMetadata") === "true",
+ allowDuplicates: searchParams.get("allowDuplicates") === "true",
+ };
+ case "compress":
+ return {
+ level: searchParams.get("compressLevel") || "medium",
+ keepQuality: searchParams.get("keepQuality") === "true",
+ };
+ case "merge":
+ return {
+ order: searchParams.get("mergeOrder") || "default",
+ removeDuplicates: searchParams.get("removeDuplicates") === "true",
+ };
+ // Add more tools here as needed
+ default:
+ return {};
+ }
+}
+
+// Utility to update params for a tool
+function updateToolParams(toolKey: string, searchParams: URLSearchParams, setSearchParams: any, newParams: any) {
+ const params = new URLSearchParams(searchParams);
+
+ // Clear tool-specific params
+ if (toolKey === "split") {
+ [
+ "splitMode", "pages", "hDiv", "vDiv", "merge",
+ "splitType", "splitValue", "bookmarkLevel", "includeMetadata", "allowDuplicates"
+ ].forEach((k) => params.delete(k));
+ // Set new split params
+ const merged = { ...getToolParams("split", searchParams), ...newParams };
+ params.set("splitMode", merged.mode);
+ if (merged.mode === "byPages") params.set("pages", merged.pages);
+ else if (merged.mode === "bySections") {
+ params.set("hDiv", merged.hDiv);
+ params.set("vDiv", merged.vDiv);
+ params.set("merge", String(merged.merge));
+ } else if (merged.mode === "bySizeOrCount") {
+ params.set("splitType", merged.splitType);
+ params.set("splitValue", merged.splitValue);
+ } else if (merged.mode === "byChapters") {
+ params.set("bookmarkLevel", merged.bookmarkLevel);
+ params.set("includeMetadata", String(merged.includeMetadata));
+ params.set("allowDuplicates", String(merged.allowDuplicates));
+ }
+ } else if (toolKey === "compress") {
+ ["compressLevel", "keepQuality"].forEach((k) => params.delete(k));
+ const merged = { ...getToolParams("compress", searchParams), ...newParams };
+ params.set("compressLevel", merged.level);
+ params.set("keepQuality", String(merged.keepQuality));
+ } else if (toolKey === "merge") {
+ ["mergeOrder", "removeDuplicates"].forEach((k) => params.delete(k));
+ const merged = { ...getToolParams("merge", searchParams), ...newParams };
+ params.set("mergeOrder", merged.order);
+ params.set("removeDuplicates", String(merged.removeDuplicates));
+ }
+ // Add more tools as needed
+
+ setSearchParams(params, { replace: true });
+}
+
+// List of all tool-specific params
+const TOOL_PARAMS = {
+ split: [
+ "splitMode", "pages", "hDiv", "vDiv", "merge",
+ "splitType", "splitValue", "bookmarkLevel", "includeMetadata", "allowDuplicates"
+ ],
+ compress: [
+ "compressLevel", "keepQuality"
+ ],
+ merge: [
+ "mergeOrder", "removeDuplicates"
+ ]
+ // Add more tools as needed
+};
+
export default function HomePage() {
const [searchParams, setSearchParams] = useSearchParams();
+ const theme = useMantineTheme();
// Core app state
const [selectedToolKey, setSelectedToolKey] = useState(searchParams.get("tool") || "split");
@@ -69,28 +161,37 @@ export default function HomePage() {
const [pdfFile, setPdfFile] = useState(null);
const [files, setFiles] = useState([]);
const [downloadUrl, setDownloadUrl] = useState(null);
+ const [sidebarsVisible, setSidebarsVisible] = useState(true);
- // Tool-specific parameters
- const [splitParams, setSplitParams] = useState({
- mode: searchParams.get("splitMode") || "byPages",
- pages: searchParams.get("pages") || "",
- hDiv: searchParams.get("hDiv") || "0",
- vDiv: searchParams.get("vDiv") || "1",
- merge: searchParams.get("merge") === "true",
- splitType: searchParams.get("splitType") || "size",
- splitValue: searchParams.get("splitValue") || "",
- bookmarkLevel: searchParams.get("bookmarkLevel") || "0",
- includeMetadata: searchParams.get("includeMetadata") === "true",
- allowDuplicates: searchParams.get("allowDuplicates") === "true",
- });
+ const toolParams = getToolParams(selectedToolKey, searchParams);
+
+ const updateParams = (newParams: any) =>
+ updateToolParams(selectedToolKey, searchParams, setSearchParams, newParams);
// Update URL when core state changes
useEffect(() => {
const params = new URLSearchParams(searchParams);
- params.set("tool", selectedToolKey);
- params.set("view", currentView);
- setSearchParams(params, { replace: true });
- }, [selectedToolKey, currentView, setSearchParams]);
+
+ // Remove all tool-specific params except for the current tool
+ Object.entries(TOOL_PARAMS).forEach(([tool, keys]) => {
+ if (tool !== selectedToolKey) {
+ keys.forEach((k) => params.delete(k));
+ }
+ });
+
+ // Collect all params except 'view'
+ const entries = Array.from(params.entries()).filter(([key]) => key !== "view");
+
+ // Rebuild params with 'view' first
+ const newParams = new URLSearchParams();
+ newParams.set("view", currentView);
+ newParams.set("tool", selectedToolKey);
+ entries.forEach(([key, value]) => {
+ if (key !== "tool") newParams.set(key, value);
+ });
+
+ setSearchParams(newParams, { replace: true });
+ }, [selectedToolKey, currentView, setSearchParams, searchParams]);
// Handle tool selection
const handleToolSelect = useCallback(
@@ -101,51 +202,6 @@ export default function HomePage() {
[toolRegistry]
);
- // Handle split parameter updates
- const updateSplitParams = useCallback((newParams: Partial) => {
- setSplitParams(prev => {
- const updated = { ...prev, ...newParams };
-
- // Update URL with split params
- const params = new URLSearchParams(searchParams);
-
- // Clear old parameters when mode changes
- if (newParams.mode && newParams.mode !== prev.mode) {
- params.delete("pages");
- params.delete("hDiv");
- params.delete("vDiv");
- params.delete("merge");
- params.delete("splitType");
- params.delete("splitValue");
- params.delete("bookmarkLevel");
- params.delete("includeMetadata");
- params.delete("allowDuplicates");
- }
-
- // Set the mode
- params.set("splitMode", updated.mode);
-
- // Set mode-specific parameters
- if (updated.mode === "byPages" && updated.pages) {
- params.set("pages", updated.pages);
- } else if (updated.mode === "bySections") {
- params.set("hDiv", updated.hDiv);
- params.set("vDiv", updated.vDiv);
- params.set("merge", String(updated.merge));
- } else if (updated.mode === "bySizeOrCount") {
- params.set("splitType", updated.splitType);
- if (updated.splitValue) params.set("splitValue", updated.splitValue);
- } else if (updated.mode === "byChapters") {
- params.set("bookmarkLevel", updated.bookmarkLevel);
- params.set("includeMetadata", String(updated.includeMetadata));
- params.set("allowDuplicates", String(updated.allowDuplicates));
- }
-
- setSearchParams(params, { replace: true });
- return updated;
- });
- }, [searchParams, setSearchParams]);
-
const selectedTool = toolRegistry[selectedToolKey];
// Tool component rendering
@@ -154,130 +210,144 @@ export default function HomePage() {
return Tool not found
;
}
- // Pass appropriate props based on tool type
- if (selectedToolKey === "split") {
- return React.createElement(selectedTool.component, {
- file: pdfFile,
- setPdfFile,
- downloadUrl,
- setDownloadUrl,
- // Tool-specific params and update function
- params: splitParams,
- updateParams: updateSplitParams
- });
- }
-
- // For other tools, pass standard props
+ // Pass only the necessary props
return React.createElement(selectedTool.component, {
- file: pdfFile,
- setPdfFile,
files,
- setFiles,
- downloadUrl,
setDownloadUrl,
+ params: toolParams,
+ updateParams,
});
};
-
return (
{/* Left: Tool Picker */}
-
+ {sidebarsVisible && (
+
+ )}
{/* Middle: Main View (Viewer, Editor, Manager) */}
-
-
-
-
-
-
- {(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? (
-
- ) : currentView === "viewer" ? (
-
- ) : currentView === "pageEditor" ? (
-
- ) : (
-
- )}
-
+ {/* Overlayed View Switcher */}
+
+ {/* Main content area with matching Paper */}
+
+
+ {(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? (
+
+ ) : currentView === "viewer" ? (
+
+ ) : currentView === "pageEditor" ? (
+
+ ) : (
+
+ )}
+
+
{/* Right: Tool Interaction */}
-
+ {selectedTool && selectedTool.component && (
+ <>
+ {renderTool()}
+ >
+ )}
+
+ )}
+
+ {sidebarsVisible ? "Hide Sidebars" : "Show Sidebars"}
+
);
}
diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx
index 2701bb64a..c3897cb7a 100644
--- a/frontend/src/tools/Compress.tsx
+++ b/frontend/src/tools/Compress.tsx
@@ -1,4 +1,5 @@
import React, { useState } from "react";
+import { useSearchParams } from "react-router-dom";
import { Stack, Slider, Group, Text, Button, Checkbox, TextInput, Paper } from "@mantine/core";
export interface CompressProps {
@@ -12,6 +13,9 @@ const CompressPdfPanel: React.FC = ({
setDownloadUrl,
setLoading,
}) => {
+ const [searchParams] = useSearchParams();
+
+
const [selected, setSelected] = useState(files.map(() => false));
const [compressionLevel, setCompressionLevel] = useState(5);
const [grayscale, setGrayscale] = useState(false);
@@ -56,8 +60,8 @@ const CompressPdfPanel: React.FC = ({
}
};
+
return (
-
Select files to compress:
@@ -118,7 +122,6 @@ const CompressPdfPanel: React.FC = ({
Compress Selected PDF{selected.filter(Boolean).length > 1 ? "s" : ""}
-
);
};
diff --git a/frontend/src/tools/Merge.tsx b/frontend/src/tools/Merge.tsx
index 9cc969a0f..d0da12e47 100644
--- a/frontend/src/tools/Merge.tsx
+++ b/frontend/src/tools/Merge.tsx
@@ -1,12 +1,24 @@
import React, { useState, useEffect } from "react";
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
+import { useSearchParams } from "react-router-dom";
export interface MergePdfPanelProps {
files: File[];
setDownloadUrl: (url: string) => void;
+ params: {
+ order: string;
+ removeDuplicates: boolean;
+ };
+ updateParams: (newParams: Partial) => void;
}
-const MergePdfPanel: React.FC = ({ files, setDownloadUrl }) => {
+const MergePdfPanel: React.FC = ({
+ files,
+ setDownloadUrl,
+ params,
+ updateParams,
+}) => {
+ const [searchParams] = useSearchParams();
const [selectedFiles, setSelectedFiles] = useState([]);
const [downloadUrl, setLocalDownloadUrl] = useState(null);
const [isLoading, setIsLoading] = useState(false);
@@ -59,8 +71,9 @@ const MergePdfPanel: React.FC = ({ files, setDownloadUrl })
const selectedCount = selectedFiles.filter(Boolean).length;
+ const { order, removeDuplicates } = params;
+
return (
-
Merge PDFs
@@ -104,8 +117,12 @@ const MergePdfPanel: React.FC = ({ files, setDownloadUrl })
Download Merged PDF
)}
+ updateParams({ removeDuplicates: !removeDuplicates })}
+ />
-
);
};
diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx
index 2e4d3a7c9..abac174c5 100644
--- a/frontend/src/tools/Split.tsx
+++ b/frontend/src/tools/Split.tsx
@@ -7,7 +7,9 @@ import {
Checkbox,
Notification,
Stack,
+ Paper,
} from "@mantine/core";
+import { useSearchParams } from "react-router-dom";
import DownloadIcon from "@mui/icons-material/Download";
export interface SplitPdfPanelProps {
@@ -26,7 +28,7 @@ export interface SplitPdfPanelProps {
includeMetadata: boolean;
allowDuplicates: boolean;
};
- updateParams: (newParams: Partial) => void;
+ updateParams: (newParams: Partial) => void;
}
const SplitPdfPanel: React.FC = ({
@@ -36,16 +38,26 @@ const SplitPdfPanel: React.FC = ({
params,
updateParams,
}) => {
+ const [searchParams] = useSearchParams();
+
const [status, setStatus] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
const {
- mode, pages, hDiv, vDiv, merge,
- splitType, splitValue, bookmarkLevel,
- includeMetadata, allowDuplicates
+ mode,
+ pages,
+ hDiv,
+ vDiv,
+ merge,
+ splitType,
+ splitValue,
+ bookmarkLevel,
+ includeMetadata,
+ allowDuplicates,
} = params;
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
@@ -109,123 +121,123 @@ const SplitPdfPanel: React.FC = ({
};
return (
-
);
};