diff --git a/frontend/index.html b/frontend/index.html
index 31f1b3008..6773fd7dc 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -13,6 +13,7 @@
void;
+ onClose?: () => void;
+ previewFile?: File | null;
+}
+
+const EmbedPdfViewer = ({
+ sidebarsVisible,
+ setSidebarsVisible,
+ onClose,
+ previewFile,
+}: EmbedPdfViewerProps) => {
+ const { t } = useTranslation();
+ const theme = useMantineTheme();
+ const { colorScheme } = useMantineColorScheme();
+
+ // Get current file from FileContext
+ const { selectors } = useFileState();
+ const activeFiles = selectors.getFiles();
+
+ // Determine which file to display
+ const currentFile = React.useMemo(() => {
+ if (previewFile) {
+ return previewFile;
+ } else if (activeFiles.length > 0) {
+ return activeFiles[0]; // Use first file for simplicity
+ }
+ return null;
+ }, [previewFile, activeFiles]);
+
+ // Get file with URL for rendering
+ const fileWithUrl = useFileWithUrl(currentFile);
+
+ // Determine the effective file to display
+ const effectiveFile = React.useMemo(() => {
+ if (previewFile) {
+ // In preview mode, show the preview file
+ if (previewFile.size === 0) {
+ return null;
+ }
+ return { file: previewFile, url: null };
+ } else {
+ return fileWithUrl;
+ }
+ }, [previewFile, fileWithUrl]);
+
+ return (
+
+ {/* Close Button - Only show in preview mode */}
+ {onClose && previewFile && (
+
+
+
+ )}
+
+ {!effectiveFile ? (
+
+ Error: No file provided to viewer
+
+ ) : (
+ <>
+ {/* Tabs for multiple files */}
+ {activeFiles.length > 1 && !previewFile && (
+
+
+ Multiple files loaded - showing first file for now
+
+
+ )}
+
+ {/* EmbedPDF Viewer with Toolbar Overlay */}
+
+
+
+ {/* Bottom Toolbar Overlay */}
+
+
+
{
+ // Placeholder - will implement page navigation later
+ console.log('Navigate to page:', page);
+ }}
+ dualPage={false}
+ onDualPageToggle={() => {
+ // Placeholder - will implement dual page toggle later
+ console.log('Toggle dual page view');
+ }}
+ currentZoom={100}
+ />
+
+
+
+ >
+ )}
+
+ );
+};
+
+export default EmbedPdfViewer;
\ No newline at end of file
diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx
new file mode 100644
index 000000000..d354fadcf
--- /dev/null
+++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx
@@ -0,0 +1,158 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { createPluginRegistration } from '@embedpdf/core';
+import { EmbedPDF } from '@embedpdf/core/react';
+import { usePdfiumEngine } from '@embedpdf/engines/react';
+
+// Import the essential plugins
+import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react';
+import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
+import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
+import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react';
+import { ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react';
+import { InteractionManagerPluginPackage } from '@embedpdf/plugin-interaction-manager/react';
+import { ZoomControlsExporter } from './ZoomControlsExporter';
+import { ScrollControlsExporter } from './ScrollControlsExporter';
+
+interface LocalEmbedPDFProps {
+ file?: File | Blob;
+ url?: string | null;
+ colorScheme: 'light' | 'dark' | 'auto';
+}
+
+export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
+ const [pdfUrl, setPdfUrl] = useState
(null);
+
+ // Convert color scheme (handle 'auto' mode by defaulting to 'light')
+ const actualColorScheme = colorScheme === 'auto' ? 'light' : colorScheme;
+
+ // Convert File to URL if needed
+ useEffect(() => {
+ if (file) {
+ const objectUrl = URL.createObjectURL(file);
+ setPdfUrl(objectUrl);
+ return () => URL.revokeObjectURL(objectUrl);
+ } else if (url) {
+ setPdfUrl(url);
+ }
+ }, [file, url]);
+
+ // Create plugins configuration
+ const plugins = useMemo(() => {
+ if (!pdfUrl) return [];
+
+ return [
+ createPluginRegistration(LoaderPluginPackage, {
+ loadingOptions: {
+ type: 'url',
+ pdfFile: {
+ id: 'stirling-pdf-viewer',
+ url: pdfUrl,
+ },
+ },
+ }),
+ createPluginRegistration(ViewportPluginPackage, {
+ viewportGap: 10,
+ }),
+ createPluginRegistration(ScrollPluginPackage, {
+ strategy: ScrollStrategy.Vertical,
+ initialPage: 0,
+ }),
+ createPluginRegistration(RenderPluginPackage),
+
+ // Register interaction manager (required for zoom features)
+ createPluginRegistration(InteractionManagerPluginPackage),
+
+ // Register zoom plugin with configuration
+ createPluginRegistration(ZoomPluginPackage, {
+ defaultZoomLevel: ZoomMode.FitPage,
+ minZoom: 0.2,
+ maxZoom: 5.0,
+ }),
+ ];
+ }, [pdfUrl]);
+
+ // Initialize the engine with the React hook
+ const { engine, isLoading, error } = usePdfiumEngine();
+
+
+ // Early return if no file or URL provided
+ if (!file && !url) {
+ return (
+
+ );
+ }
+
+ if (isLoading || !engine || !pdfUrl) {
+ return (
+
+
+
⏳
+
Loading PDF Engine...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
❌
+
Error loading PDF engine: {error.message}
+
+
+ );
+ }
+
+ // Wrap your UI with the provider
+ return (
+
+
+
+
+
+ (
+
+
+
+ )}
+ />
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/viewer/OldViewer.tsx b/frontend/src/components/viewer/OldViewer.tsx
new file mode 100644
index 000000000..dfcd5dc7d
--- /dev/null
+++ b/frontend/src/components/viewer/OldViewer.tsx
@@ -0,0 +1,687 @@
+import React, { useEffect, useState, useRef, useCallback } from "react";
+import { Paper, Stack, Text, ScrollArea, 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 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 { fileStorage } from "../../services/fileStorage";
+import SkeletonLoader from '../shared/SkeletonLoader';
+import { useFileState } from "../../contexts/FileContext";
+import { useFileWithUrl } from "../../hooks/useFileWithUrl";
+import { isFileObject } from "../../types/fileContext";
+import { FileId } from "../../types/file";
+
+
+// Lazy loading page image component
+interface LazyPageImageProps {
+ pageIndex: number;
+ zoom: number;
+ theme: any;
+ isFirst: boolean;
+ renderPage: (pageIndex: number) => Promise;
+ 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(null);
+ const imgRef = useRef(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 (
+
+ );
+ }
+
+ // Placeholder while loading
+ return (
+
+ {isVisible ? (
+
+
+
Loading page {pageIndex + 1}...
+
+ ) : (
+
Page {pageIndex + 1}
+ )}
+
+ );
+};
+
+export interface ViewerProps {
+ sidebarsVisible: boolean;
+ setSidebarsVisible: (v: boolean) => void;
+ onClose?: () => void;
+ previewFile: File | null; // For preview mode - bypasses context
+}
+
+const Viewer = ({
+ onClose,
+ previewFile,
+}: ViewerProps) => {
+ const { t } = useTranslation();
+ const theme = useMantineTheme();
+
+ // Get current file from FileContext
+ const { selectors } = useFileState();
+
+ const activeFiles = selectors.getFiles();
+
+ // Tab management for multiple files
+ const [activeTab, setActiveTab] = useState("0");
+
+ // Reset PDF state when switching tabs
+ const handleTabChange = (newTab: string) => {
+ setActiveTab(newTab);
+ setNumPages(0);
+ setPageImages([]);
+ setCurrentPage(null);
+ setLoading(true);
+ };
+ 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)[]>([]);
+
+ // 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 (!isFileObject(previewFile)) {
+ 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(null);
+ const pdfDocRef = useRef(null);
+ const renderingPagesRef = useRef>(new Set());
+ const currentArrayBufferRef = useRef(null);
+ const preloadingRef = useRef(false);
+
+ // Function to render a specific page on-demand
+ const renderPage = async (pageIndex: number): Promise => {
+ 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:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
+
+ // 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 {
+ 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 (
+
+ {/* Close Button - Only show in preview mode */}
+ {onClose && previewFile && (
+
+
+
+ )}
+
+ {!effectiveFile ? (
+
+ Error: No file provided to viewer
+
+ ) : (
+ <>
+ {/* Tabs for multiple files */}
+ {activeFiles.length > 1 && !previewFile && (
+
+ handleTabChange(value || "0")}>
+
+ {activeFiles.map((file: any, index: number) => (
+
+ {file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
+
+ ))}
+
+
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : (
+
+
+ {numPages === 0 && (
+ {t("viewer.noPagesToDisplay", "No pages to display.")}
+ )}
+ {dualPage
+ ? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
+
+
+ {i * 2 + 1 < numPages && (
+
+ )}
+
+ ))
+ : Array.from({ length: numPages }).map((_, idx) => (
+
+ ))}
+
+ {/* Navigation bar overlays the scroll area */}
+
+
+
+
+ {
+ 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},
+ }}
+ />
+
+ / {numPages}
+
+
+
+
+
+
+ {Math.round(zoom * 100)}%
+
+
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+};
+
+export default Viewer;
diff --git a/frontend/src/components/viewer/PdfViewerToolbar.tsx b/frontend/src/components/viewer/PdfViewerToolbar.tsx
new file mode 100644
index 000000000..1c24d752b
--- /dev/null
+++ b/frontend/src/components/viewer/PdfViewerToolbar.tsx
@@ -0,0 +1,264 @@
+import React, { useState, useEffect } from 'react';
+import { Button, Paper, Group, NumberInput } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import FirstPageIcon from '@mui/icons-material/FirstPage';
+import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
+import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
+import LastPageIcon from '@mui/icons-material/LastPage';
+import DescriptionIcon from '@mui/icons-material/Description';
+import ViewWeekIcon from '@mui/icons-material/ViewWeek';
+
+interface PdfViewerToolbarProps {
+ // Page navigation props (placeholders for now)
+ currentPage?: number;
+ totalPages?: number;
+ onPageChange?: (page: number) => void;
+
+ // Dual page toggle (placeholder for now)
+ dualPage?: boolean;
+ onDualPageToggle?: () => void;
+
+ // Zoom controls (will connect to window.embedPdfZoom)
+ currentZoom?: number;
+}
+
+export function PdfViewerToolbar({
+ currentPage = 1,
+ totalPages = 1,
+ onPageChange,
+ dualPage = false,
+ onDualPageToggle,
+ currentZoom = 100,
+}: PdfViewerToolbarProps) {
+ const { t } = useTranslation();
+ const [pageInput, setPageInput] = useState(currentPage);
+ const [dynamicZoom, setDynamicZoom] = useState(currentZoom);
+ const [dynamicPage, setDynamicPage] = useState(currentPage);
+ const [dynamicTotalPages, setDynamicTotalPages] = useState(totalPages);
+
+ // Update zoom and scroll state from EmbedPDF APIs
+ useEffect(() => {
+ const updateState = () => {
+ // Update zoom
+ if ((window as any).embedPdfZoom) {
+ const zoomPercent = (window as any).embedPdfZoom.zoomPercent || currentZoom;
+ setDynamicZoom(zoomPercent);
+ }
+
+ // Update scroll/page state
+ if ((window as any).embedPdfScroll) {
+ const currentPageNum = (window as any).embedPdfScroll.currentPage || currentPage;
+ const totalPagesNum = (window as any).embedPdfScroll.totalPages || totalPages;
+ setDynamicPage(currentPageNum);
+ setDynamicTotalPages(totalPagesNum);
+ setPageInput(currentPageNum);
+ }
+ };
+
+ // Update state immediately
+ updateState();
+
+ // Set up periodic updates to keep state in sync
+ const interval = setInterval(updateState, 200);
+
+ return () => clearInterval(interval);
+ }, [currentZoom, currentPage, totalPages]);
+
+ const handleZoomOut = () => {
+ if ((window as any).embedPdfZoom) {
+ (window as any).embedPdfZoom.zoomOut();
+ }
+ };
+
+ const handleZoomIn = () => {
+ if ((window as any).embedPdfZoom) {
+ (window as any).embedPdfZoom.zoomIn();
+ }
+ };
+
+ const handlePageNavigation = (page: number) => {
+ if ((window as any).embedPdfScroll) {
+ (window as any).embedPdfScroll.scrollToPage(page);
+ } else if (onPageChange) {
+ onPageChange(page);
+ }
+ setPageInput(page);
+ };
+
+ const handleFirstPage = () => {
+ if ((window as any).embedPdfScroll) {
+ (window as any).embedPdfScroll.scrollToFirstPage();
+ } else {
+ handlePageNavigation(1);
+ }
+ };
+
+ const handlePreviousPage = () => {
+ if ((window as any).embedPdfScroll) {
+ (window as any).embedPdfScroll.scrollToPreviousPage();
+ } else {
+ handlePageNavigation(Math.max(1, dynamicPage - 1));
+ }
+ };
+
+ const handleNextPage = () => {
+ if ((window as any).embedPdfScroll) {
+ (window as any).embedPdfScroll.scrollToNextPage();
+ } else {
+ handlePageNavigation(Math.min(dynamicTotalPages, dynamicPage + 1));
+ }
+ };
+
+ const handleLastPage = () => {
+ if ((window as any).embedPdfScroll) {
+ (window as any).embedPdfScroll.scrollToLastPage();
+ } else {
+ handlePageNavigation(dynamicTotalPages);
+ }
+ };
+
+ return (
+
+ {/* First Page Button */}
+
+
+ {/* Previous Page Button */}
+
+
+ {/* Page Input */}
+ {
+ const page = Number(value);
+ setPageInput(page);
+ if (!isNaN(page) && page >= 1 && page <= dynamicTotalPages) {
+ handlePageNavigation(page);
+ }
+ }}
+ min={1}
+ max={dynamicTotalPages}
+ hideControls
+ styles={{
+ input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 },
+ }}
+ />
+
+
+ / {dynamicTotalPages}
+
+
+ {/* Next Page Button */}
+
+
+ {/* Last Page Button */}
+
+
+ {/* Dual Page Toggle */}
+
+
+ {/* Zoom Controls */}
+
+
+
+ {dynamicZoom}%
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/viewer/ScrollControlsExporter.tsx b/frontend/src/components/viewer/ScrollControlsExporter.tsx
new file mode 100644
index 000000000..e2aa8d214
--- /dev/null
+++ b/frontend/src/components/viewer/ScrollControlsExporter.tsx
@@ -0,0 +1,27 @@
+import { useEffect } from 'react';
+import { useScroll } from '@embedpdf/plugin-scroll/react';
+
+/**
+ * Component that runs inside EmbedPDF context and exports scroll controls globally
+ */
+export function ScrollControlsExporter() {
+ const { provides: scroll, state: scrollState } = useScroll();
+
+ useEffect(() => {
+ if (scroll && scrollState) {
+ // Export scroll controls to global window for toolbar access
+ (window as any).embedPdfScroll = {
+ scrollToPage: (page: number) => scroll.scrollToPage({ pageNumber: page }),
+ scrollToNextPage: () => scroll.scrollToNextPage(),
+ scrollToPreviousPage: () => scroll.scrollToPreviousPage(),
+ scrollToFirstPage: () => scroll.scrollToPage({ pageNumber: 1 }),
+ scrollToLastPage: () => scroll.scrollToPage({ pageNumber: scrollState.totalPages }),
+ currentPage: scrollState.currentPage,
+ totalPages: scrollState.totalPages,
+ };
+
+ }
+ }, [scroll, scrollState]);
+
+ return null; // This component doesn't render anything
+}
\ No newline at end of file
diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx
index dfcd5dc7d..49d3a5854 100644
--- a/frontend/src/components/viewer/Viewer.tsx
+++ b/frontend/src/components/viewer/Viewer.tsx
@@ -1,687 +1,16 @@
-import React, { useEffect, useState, useRef, useCallback } from "react";
-import { Paper, Stack, Text, ScrollArea, 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 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 { fileStorage } from "../../services/fileStorage";
-import SkeletonLoader from '../shared/SkeletonLoader';
-import { useFileState } from "../../contexts/FileContext";
-import { useFileWithUrl } from "../../hooks/useFileWithUrl";
-import { isFileObject } from "../../types/fileContext";
-import { FileId } from "../../types/file";
-
-
-// Lazy loading page image component
-interface LazyPageImageProps {
- pageIndex: number;
- zoom: number;
- theme: any;
- isFirst: boolean;
- renderPage: (pageIndex: number) => Promise;
- 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(null);
- const imgRef = useRef(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 (
-
- );
- }
-
- // Placeholder while loading
- return (
-
- {isVisible ? (
-
-
-
Loading page {pageIndex + 1}...
-
- ) : (
-
Page {pageIndex + 1}
- )}
-
- );
-};
+import React from 'react';
+import EmbedPdfViewer from './EmbedPdfViewer';
export interface ViewerProps {
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
- previewFile: File | null; // For preview mode - bypasses context
+ previewFile?: File | null;
}
-const Viewer = ({
- onClose,
- previewFile,
-}: ViewerProps) => {
- const { t } = useTranslation();
- const theme = useMantineTheme();
-
- // Get current file from FileContext
- const { selectors } = useFileState();
-
- const activeFiles = selectors.getFiles();
-
- // Tab management for multiple files
- const [activeTab, setActiveTab] = useState("0");
-
- // Reset PDF state when switching tabs
- const handleTabChange = (newTab: string) => {
- setActiveTab(newTab);
- setNumPages(0);
- setPageImages([]);
- setCurrentPage(null);
- setLoading(true);
- };
- 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)[]>([]);
-
- // 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 (!isFileObject(previewFile)) {
- 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(null);
- const pdfDocRef = useRef(null);
- const renderingPagesRef = useRef>(new Set());
- const currentArrayBufferRef = useRef(null);
- const preloadingRef = useRef(false);
-
- // Function to render a specific page on-demand
- const renderPage = async (pageIndex: number): Promise => {
- 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:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
-
- // 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 {
- 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 (
-
- {/* Close Button - Only show in preview mode */}
- {onClose && previewFile && (
-
-
-
- )}
-
- {!effectiveFile ? (
-
- Error: No file provided to viewer
-
- ) : (
- <>
- {/* Tabs for multiple files */}
- {activeFiles.length > 1 && !previewFile && (
-
- handleTabChange(value || "0")}>
-
- {activeFiles.map((file: any, index: number) => (
-
- {file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
-
- ))}
-
-
-
- )}
-
- {loading ? (
-
-
-
- ) : (
-
-
- {numPages === 0 && (
- {t("viewer.noPagesToDisplay", "No pages to display.")}
- )}
- {dualPage
- ? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
-
-
- {i * 2 + 1 < numPages && (
-
- )}
-
- ))
- : Array.from({ length: numPages }).map((_, idx) => (
-
- ))}
-
- {/* Navigation bar overlays the scroll area */}
-
-
-
-
- {
- 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},
- }}
- />
-
- / {numPages}
-
-
-
-
-
-
- {Math.round(zoom * 100)}%
-
-
-
-
-
- )}
- >
- )}
-
-
- );
+const Viewer = (props: ViewerProps) => {
+ // Default to EmbedPDF viewer
+ return ;
};
-export default Viewer;
+export default Viewer;
\ No newline at end of file
diff --git a/frontend/src/components/viewer/ZoomControlsExporter.tsx b/frontend/src/components/viewer/ZoomControlsExporter.tsx
new file mode 100644
index 000000000..8ca61e7c2
--- /dev/null
+++ b/frontend/src/components/viewer/ZoomControlsExporter.tsx
@@ -0,0 +1,26 @@
+import { useEffect } from 'react';
+import { useZoom } from '@embedpdf/plugin-zoom/react';
+
+/**
+ * Component that runs inside EmbedPDF context and exports zoom controls globally
+ */
+export function ZoomControlsExporter() {
+ const { provides: zoom, state: zoomState } = useZoom();
+
+ useEffect(() => {
+ if (zoom) {
+ // Export zoom controls to global window for right rail access
+ (window as any).embedPdfZoom = {
+ zoomIn: () => zoom.zoomIn(),
+ zoomOut: () => zoom.zoomOut(),
+ toggleMarqueeZoom: () => zoom.toggleMarqueeZoom(),
+ requestZoom: (level: any) => zoom.requestZoom(level),
+ currentZoom: zoomState?.currentZoomLevel || 1,
+ zoomPercent: Math.round((zoomState?.currentZoomLevel || 1) * 100),
+ };
+
+ }
+ }, [zoom, zoomState]);
+
+ return null; // This component doesn't render anything
+}
\ No newline at end of file
diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts
index 5511059a8..87f0613ab 100644
--- a/frontend/src/global.d.ts
+++ b/frontend/src/global.d.ts
@@ -15,4 +15,8 @@ declare module '../assets/material-symbols-icons.json' {
height?: number;
};
export default value;
-}
\ No newline at end of file
+}
+
+// TODO: Add proper EmbedPDF types for local submodule integration
+
+export {};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 6886183a1..38151b5b3 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -28,9 +28,11 @@
/* Modules */
"module": "esnext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
- "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
- // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
- // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
+ "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "@framework": ["vendor/embed-pdf-viewer/packages/core/src/react/adapter.ts"]
+ },
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */