From 2f9c88b000a5a98d6be6a0ac5adec70887339a07 Mon Sep 17 00:00:00 2001 From: Reece Date: Fri, 4 Jul 2025 11:17:10 +0100 Subject: [PATCH] Optimize PageEditor performance and fix view switching issues Major performance improvements and UX enhancements: PERFORMANCE FIXES: - Add merged document caching in FileContext to prevent re-processing on view switches - Implement hash-based cache fallback for File object identity preservation - Add pagination for large documents (>200 pages) to prevent DOM overload - Convert synchronous operations to async chunked processing - Optimize effect dependencies to prevent unnecessary re-renders - Remove aggressive cleanup on view switches that destroyed caches UI/UX IMPROVEMENTS: - Add immediate visual feedback with loading spinners in view switching buttons - Implement skeleton loaders for smooth transitions during processing - Add progress indicators with real-time percentages - Defer heavy operations using requestAnimationFrame to allow UI updates - Create reusable SkeletonLoader component for consistent loading states CACHE SYSTEM: - Move processed file cache management to FileContext for persistence - Add stable cache key generation based on file combinations - Implement smart cache invalidation only when files are actually removed - Preserve thumbnails and page data across view switches BUG FIXES: - Fix infinite loop errors caused by circular hook dependencies - Resolve File object recreation breaking Map lookups - Fix thumbnail loading issues in PageEditor - Prevent UI thread blocking during PDF merging operations TECHNICAL DEBT: - Centralize memory management in FileContext - Add proper cleanup for removed files while preserving active caches - Implement async/await patterns for better error handling - Add performance debugging with console timing Result: PageEditor now loads instantly when returning to cached files, large documents render smoothly with pagination, and view switching provides immediate feedback even during heavy operations. --- frontend/src/App.tsx | 5 +- .../{pageEditor => fileEditor}/FileEditor.tsx | 351 ++++---- .../src/components/pageEditor/PageEditor.tsx | 492 +++++++---- .../components/pageEditor/PageThumbnail.tsx | 19 +- .../src/components/shared/SkeletonLoader.tsx | 104 +++ .../src/components/shared/TopControls.tsx | 50 +- frontend/src/components/viewer/Viewer.tsx | 7 +- frontend/src/contexts/FileContext.tsx | 766 ++++++++++++++++++ .../src/hooks/useEnhancedProcessedFiles.ts | 49 +- frontend/src/hooks/useMemoryManagement.ts | 30 + frontend/src/pages/HomePage.tsx | 100 +-- .../services/enhancedPDFProcessingService.ts | 21 + frontend/src/styles/skeleton.css | 30 + frontend/src/types/fileContext.ts | 138 ++++ frontend/src/utils/thumbnailUtils.ts | 60 +- 15 files changed, 1805 insertions(+), 417 deletions(-) rename frontend/src/components/{pageEditor => fileEditor}/FileEditor.tsx (61%) create mode 100644 frontend/src/components/shared/SkeletonLoader.tsx create mode 100644 frontend/src/contexts/FileContext.tsx create mode 100644 frontend/src/hooks/useMemoryManagement.ts create mode 100644 frontend/src/styles/skeleton.css create mode 100644 frontend/src/types/fileContext.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8083f37fd..de5001850 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; +import { FileContextProvider } from './contexts/FileContext'; import HomePage from './pages/HomePage'; // Import global styles @@ -9,7 +10,9 @@ import './index.css'; export default function App() { return ( - + + + ); } diff --git a/frontend/src/components/pageEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx similarity index 61% rename from frontend/src/components/pageEditor/FileEditor.tsx rename to frontend/src/components/fileEditor/FileEditor.tsx index 7224f6453..db5779a2a 100644 --- a/frontend/src/components/pageEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -6,13 +6,15 @@ import { import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; +import { useFileContext } from '../../contexts/FileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; -import styles from './PageEditor.module.css'; -import FileThumbnail from './FileThumbnail'; -import BulkSelectionPanel from './BulkSelectionPanel'; -import DragDropGrid from './DragDropGrid'; +import styles from '../pageEditor/PageEditor.module.css'; +import FileThumbnail from '../pageEditor/FileThumbnail'; +import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel'; +import DragDropGrid from '../pageEditor/DragDropGrid'; import FilePickerModal from '../shared/FilePickerModal'; +import SkeletonLoader from '../shared/SkeletonLoader'; interface FileItem { id: string; @@ -27,27 +29,31 @@ interface FileItem { interface FileEditorProps { onOpenPageEditor?: (file: File) => void; onMergeFiles?: (files: File[]) => void; - activeFiles?: File[]; - setActiveFiles?: (files: File[]) => void; - preSelectedFiles?: { file: File; url: string }[]; - onClearPreSelection?: () => void; } const FileEditor = ({ onOpenPageEditor, - onMergeFiles, - activeFiles = [], - setActiveFiles, - preSelectedFiles = [], - onClearPreSelection + onMergeFiles }: FileEditorProps) => { const { t } = useTranslation(); + // Get file context + const fileContext = useFileContext(); + const { + activeFiles, + processedFiles, + selectedFileIds, + setSelectedFiles: setContextSelectedFiles, + isProcessing, + addFiles, + removeFiles, + setCurrentView + } = fileContext; + const [files, setFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [localLoading, setLocalLoading] = useState(false); const [csvInput, setCsvInput] = useState(''); const [selectionMode, setSelectionMode] = useState(false); const [draggedFile, setDraggedFile] = useState(null); @@ -56,8 +62,14 @@ const FileEditor = ({ const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); const [isAnimating, setIsAnimating] = useState(false); const [showFilePickerModal, setShowFilePickerModal] = useState(false); + const [conversionProgress, setConversionProgress] = useState(0); const fileRefs = useRef>(new Map()); + // Map context selected file names to local file IDs + const localSelectedFiles = files + .filter(file => selectedFileIds.includes(file.name)) + .map(file => file.id); + // Convert shared files to FileEditor format const convertToFileItem = useCallback(async (sharedFile: any): Promise => { // Generate thumbnail if not already available @@ -73,146 +85,124 @@ const FileEditor = ({ }; }, []); - // Convert activeFiles to FileItem format + // Convert activeFiles to FileItem format using context (async to avoid blocking) useEffect(() => { const convertActiveFiles = async () => { if (activeFiles.length > 0) { - setLoading(true); + setLocalLoading(true); try { - const convertedFiles = await Promise.all( - activeFiles.map(async (file) => { - const thumbnail = await generateThumbnailForFile(file); - return { - id: `file-${Date.now()}-${Math.random()}`, - name: file.name.replace(/\.pdf$/i, ''), - pageCount: Math.floor(Math.random() * 20) + 1, // Mock for now - thumbnail, - size: file.size, - file, - }; - }) - ); + // Process files in chunks to avoid blocking UI + const convertedFiles: FileItem[] = []; + + for (let i = 0; i < activeFiles.length; i++) { + const file = activeFiles[i]; + + // Try to get thumbnail from processed file first + const processedFile = processedFiles.get(file); + let thumbnail = processedFile?.pages?.[0]?.thumbnail; + + // If no thumbnail from processed file, try to generate one + if (!thumbnail) { + try { + thumbnail = await generateThumbnailForFile(file); + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + thumbnail = undefined; // Use placeholder + } + } + + const convertedFile = { + id: `file-${Date.now()}-${Math.random()}`, + name: file.name.replace(/\.pdf$/i, ''), + pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1, + thumbnail, + size: file.size, + file, + }; + + convertedFiles.push(convertedFile); + + // Update progress + setConversionProgress(((i + 1) / activeFiles.length) * 100); + + // Yield to main thread between files + if (i < activeFiles.length - 1) { + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } + + setFiles(convertedFiles); } catch (err) { console.error('Error converting active files:', err); } finally { - setLoading(false); + setLocalLoading(false); + setConversionProgress(0); } } else { setFiles([]); + setLocalLoading(false); + setConversionProgress(0); } }; convertActiveFiles(); - }, [activeFiles]); + }, [activeFiles, processedFiles]); - // Only load shared files when explicitly passed (not on mount) - useEffect(() => { - const loadSharedFiles = async () => { - // Only load if we have pre-selected files (coming from FileManager) - if (preSelectedFiles.length > 0) { - setLoading(true); - try { - const convertedFiles = await Promise.all( - preSelectedFiles.map(convertToFileItem) - ); - if (setActiveFiles) { - const updatedActiveFiles = convertedFiles.map(fileItem => fileItem.file); - setActiveFiles(updatedActiveFiles); - } - } catch (err) { - console.error('Error converting pre-selected files:', err); - } finally { - setLoading(false); - } - } - }; - loadSharedFiles(); - }, [preSelectedFiles, convertToFileItem]); - - // Handle pre-selected files - useEffect(() => { - if (preSelectedFiles.length > 0) { - const preSelectedIds = preSelectedFiles.map(f => f.id || f.name); - setSelectedFiles(preSelectedIds); - onClearPreSelection?.(); - } - }, [preSelectedFiles, onClearPreSelection]); - - // Process uploaded files + // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { - setLoading(true); setError(null); try { - const newFiles: FileItem[] = []; - - for (const file of uploadedFiles) { + const validFiles = uploadedFiles.filter(file => { if (file.type !== 'application/pdf') { setError('Please upload only PDF files'); - continue; + return false; } + return true; + }); - // Generate thumbnail and get page count - const thumbnail = await generateThumbnailForFile(file); - - const fileItem: FileItem = { - id: `file-${Date.now()}-${Math.random()}`, - name: file.name.replace(/\.pdf$/i, ''), - pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count - thumbnail, - size: file.size, - file, - }; - - newFiles.push(fileItem); - - // Store in IndexedDB - await fileStorage.storeFile(file, thumbnail); + if (validFiles.length > 0) { + // Add files to context (they will be processed automatically) + await addFiles(validFiles); + setStatus(`Added ${validFiles.length} files`); } - - if (setActiveFiles) { - setActiveFiles(prev => [...prev, ...newFiles.map(f => f.file)]); - } - - setStatus(`Added ${newFiles.length} files`); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; setError(errorMessage); console.error('File processing error:', err); - } finally { - setLoading(false); } - }, [setActiveFiles]); + }, [addFiles]); const selectAll = useCallback(() => { - setSelectedFiles(files.map(f => f.id)); - }, [files]); + setContextSelectedFiles(files.map(f => f.name)); // Use file name as ID for context + }, [files, setContextSelectedFiles]); - const deselectAll = useCallback(() => setSelectedFiles([]), []); + const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); const toggleFile = useCallback((fileId: string) => { - setSelectedFiles(prev => - prev.includes(fileId) - ? prev.filter(id => id !== fileId) - : [...prev, fileId] + const fileName = files.find(f => f.id === fileId)?.name || fileId; + setContextSelectedFiles(prev => + prev.includes(fileName) + ? prev.filter(id => id !== fileName) + : [...prev, fileName] ); - }, []); + }, [files, setContextSelectedFiles]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { - setSelectedFiles([]); + setContextSelectedFiles([]); setCsvInput(''); } return newMode; }); - }, []); + }, [setContextSelectedFiles]); const parseCSVInput = useCallback((csv: string) => { - const fileIds: string[] = []; + const fileNames: string[] = []; const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); ranges.forEach(range => { @@ -221,39 +211,39 @@ const FileEditor = ({ for (let i = start; i <= end && i <= files.length; i++) { if (i > 0) { const file = files[i - 1]; - if (file) fileIds.push(file.id); + if (file) fileNames.push(file.name); } } } else { const fileIndex = parseInt(range); if (fileIndex > 0 && fileIndex <= files.length) { const file = files[fileIndex - 1]; - if (file) fileIds.push(file.id); + if (file) fileNames.push(file.name); } } }); - return fileIds; + return fileNames; }, [files]); const updateFilesFromCSV = useCallback(() => { - const fileIds = parseCSVInput(csvInput); - setSelectedFiles(fileIds); - }, [csvInput, parseCSVInput]); + const fileNames = parseCSVInput(csvInput); + setContextSelectedFiles(fileNames); + }, [csvInput, parseCSVInput, setContextSelectedFiles]); // Drag and drop handlers const handleDragStart = useCallback((fileId: string) => { setDraggedFile(fileId); - if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) { + if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) { setMultiFileDrag({ - fileIds: selectedFiles, - count: selectedFiles.length + fileIds: localSelectedFiles, + count: localSelectedFiles.length }); } else { setMultiFileDrag(null); } - }, [selectionMode, selectedFiles]); + }, [selectionMode, localSelectedFiles]); const handleDragEnd = useCallback(() => { setDraggedFile(null); @@ -314,37 +304,33 @@ const FileEditor = ({ if (targetIndex === -1) return; } - const filesToMove = selectionMode && selectedFiles.includes(draggedFile) - ? selectedFiles + const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile) + ? localSelectedFiles : [draggedFile]; - if (setActiveFiles) { - // Update the local files state and sync with activeFiles - setFiles(prev => { - const newFiles = [...prev]; - const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean); + // Update the local files state and sync with activeFiles + setFiles(prev => { + const newFiles = [...prev]; + const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean); - // Remove moved files - filesToMove.forEach(id => { - const index = newFiles.findIndex(f => f.id === id); - if (index !== -1) newFiles.splice(index, 1); - }); - - // Insert at target position - newFiles.splice(targetIndex, 0, ...movedFiles); - - // Update activeFiles with the reordered File objects - setActiveFiles(newFiles.map(f => f.file)); - - return newFiles; + // Remove moved files + filesToMove.forEach(id => { + const index = newFiles.findIndex(f => f.id === id); + if (index !== -1) newFiles.splice(index, 1); }); - } + + // Insert at target position + newFiles.splice(targetIndex, 0, ...movedFiles); + + // TODO: Update context with reordered files (need to implement file reordering in context) + // For now, just return the reordered local state + return newFiles; + }); const moveCount = multiFileDrag ? multiFileDrag.count : 1; setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - handleDragEnd(); - }, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setActiveFiles]); + }, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]); const handleEndZoneDragEnter = useCallback(() => { if (draggedFile) { @@ -352,25 +338,26 @@ const FileEditor = ({ } }, [draggedFile]); - // File operations + // File operations using context const handleDeleteFile = useCallback((fileId: string) => { - if (setActiveFiles) { - // Remove from local files and sync with activeFiles - setFiles(prev => { - const newFiles = prev.filter(f => f.id !== fileId); - setActiveFiles(newFiles.map(f => f.file)); - return newFiles; - }); + const file = files.find(f => f.id === fileId); + if (file) { + // Remove from context + removeFiles([file.name]); + // Remove from context selections + setContextSelectedFiles(prev => prev.filter(id => id !== file.name)); } - setSelectedFiles(prev => prev.filter(id => id !== fileId)); - }, [setActiveFiles]); + }, [files, removeFiles, setContextSelectedFiles]); const handleViewFile = useCallback((fileId: string) => { const file = files.find(f => f.id === fileId); - if (file && onOpenPageEditor) { - onOpenPageEditor(file.file); + if (file) { + // Set the file as selected in context and switch to page editor view + setContextSelectedFiles([file.name]); + setCurrentView('pageEditor'); + onOpenPageEditor?.(file.file); } - }, [files, onOpenPageEditor]); + }, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]); const handleMergeFromHere = useCallback((fileId: string) => { const startIndex = files.findIndex(f => f.id === fileId); @@ -392,7 +379,7 @@ const FileEditor = ({ const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => { if (selectedFiles.length === 0) return; - setLoading(true); + setLocalLoading(true); try { const convertedFiles = await Promise.all( selectedFiles.map(convertToFileItem) @@ -403,14 +390,14 @@ const FileEditor = ({ console.error('Error loading files from storage:', err); setError('Failed to load some files from storage'); } finally { - setLoading(false); + setLocalLoading(false); } }, [convertToFileItem]); return ( - + @@ -462,16 +449,53 @@ const FileEditor = ({ )} - + + 📁 + No files loaded + Upload files or load from storage to get started + + + ) : files.length === 0 && localLoading ? ( + + + + {/* Processing indicator */} + + + Loading files... + {Math.round(conversionProgress)}% + +
+
+
+ + + + + ) : ( + )} /> + )} {/* File Picker Modal */} diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 295dbb416..c97afd10e 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -5,7 +5,7 @@ import { Stack, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useEnhancedProcessedFiles } from "../../hooks/useEnhancedProcessedFiles"; +import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { useUndoRedo } from "../../hooks/useUndoRedo"; @@ -18,17 +18,14 @@ import { } from "../../commands/pageCommands"; import { pdfExportService } from "../../services/pdfExportService"; import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; +import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; import './pageEditor.module.css'; import PageThumbnail from './PageThumbnail'; import BulkSelectionPanel from './BulkSelectionPanel'; import DragDropGrid from './DragDropGrid'; +import SkeletonLoader from '../shared/SkeletonLoader'; export interface PageEditorProps { - activeFiles: File[]; - setActiveFiles: (files: File[]) => void; - downloadUrl?: string | null; - setDownloadUrl?: (url: string | null) => void; - // Optional callbacks to expose internal functions for PageEditorControls onFunctionsReady?: (functions: { handleUndo: () => void; @@ -49,32 +46,42 @@ export interface PageEditorProps { } const PageEditor = ({ - activeFiles, - setActiveFiles, onFunctionsReady, }: PageEditorProps) => { const { t } = useTranslation(); - // Enhanced processing with intelligent strategies + // Get file context + const fileContext = useFileContext(); + const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile(); + + // Use file context state const { - processedFiles: enhancedProcessedFiles, - processingStates, + activeFiles, + processedFiles, + selectedPageIds, + setSelectedPages, isProcessing: globalProcessing, - hasProcessingErrors, processingProgress, - actions: processingActions - } = useEnhancedProcessedFiles(activeFiles, { - strategy: 'priority_pages', // Process first pages immediately - thumbnailQuality: 'low', // Low quality for page editor navigation - priorityPageCount: 10 + clearAllFiles, + getCurrentMergedDocument, + setCurrentMergedDocument + } = fileContext; + + // Use cached merged document from context instead of local state + const [filename, setFilename] = useState(""); + const [isMerging, setIsMerging] = useState(false); + + // Get merged document from cache + const mergedPdfDocument = getCurrentMergedDocument(); + + // Debug render performance + console.time('PageEditor: Component render'); + + useEffect(() => { + console.timeEnd('PageEditor: Component render'); }); - // Single merged document state - const [mergedPdfDocument, setMergedPdfDocument] = useState(null); - const [filename, setFilename] = useState(""); - - // Page editor state - const [selectedPages, setSelectedPages] = useState([]); + // Page editor state (use context for selectedPages) const [status, setStatus] = useState(null); const [csvInput, setCsvInput] = useState(""); const [selectionMode, setSelectionMode] = useState(false); @@ -115,44 +122,85 @@ const PageEditor = ({ }; }, []); - // Merge multiple PDF documents into one - const mergeAllPDFs = useCallback(() => { + // Merge multiple PDF documents into one (async to avoid blocking UI) + const mergeAllPDFs = useCallback(async () => { if (activeFiles.length === 0) { - setMergedPdfDocument(null); + setIsMerging(false); return; } + + console.time('PageEditor: mergeAllPDFs'); + + // Check if we already have this combination cached + const cached = getCurrentMergedDocument(); + if (cached) { + console.log('PageEditor: Using cached merged document with', cached.pages.length, 'pages'); + setFilename(cached.name); + setIsMerging(false); + console.timeEnd('PageEditor: mergeAllPDFs'); + return; + } + + console.log('PageEditor: Creating new merged document (not cached)'); + setIsMerging(true); if (activeFiles.length === 1) { - // Single file - use enhanced processed file - const enhancedFile = enhancedProcessedFiles.get(activeFiles[0]); - if (enhancedFile) { - const pdfDoc = convertToPageEditorFormat(enhancedFile, activeFiles[0].name, activeFiles[0]); - setMergedPdfDocument(pdfDoc); + // Single file - use processed file from context + const processedFile = processedFiles.get(activeFiles[0]); + if (processedFile) { + // Defer to next frame to avoid blocking + await new Promise(resolve => requestAnimationFrame(resolve)); + const pdfDoc = convertToPageEditorFormat(processedFile, activeFiles[0].name, activeFiles[0]); + + // Cache the merged document + setCurrentMergedDocument(pdfDoc); setFilename(activeFiles[0].name.replace(/\.pdf$/i, '')); } } else { - // Multiple files - merge them + // Multiple files - merge them with chunked processing const allPages: PDFPage[] = []; let totalPages = 0; const filenames: string[] = []; - activeFiles.forEach((file, fileIndex) => { - const enhancedFile = enhancedProcessedFiles.get(file); - if (enhancedFile) { + // Process files in chunks to avoid blocking UI + for (let i = 0; i < activeFiles.length; i++) { + const file = activeFiles[i]; + const processedFile = processedFiles.get(file); + + if (processedFile) { filenames.push(file.name.replace(/\.pdf$/i, '')); - enhancedFile.pages.forEach((page, pageIndex) => { - // Create new page with updated IDs and page numbers for merged document - const newPage: PDFPage = { - ...page, - id: `${fileIndex}-${page.id}`, // Unique ID across all files - pageNumber: totalPages + pageIndex + 1, - splitBefore: page.splitBefore || false - }; - allPages.push(newPage); - }); - totalPages += enhancedFile.pages.length; + + // Process pages in chunks to avoid blocking + const pages = processedFile.pages; + const chunkSize = 50; // Process 50 pages at a time + + for (let j = 0; j < pages.length; j += chunkSize) { + const chunk = pages.slice(j, j + chunkSize); + + chunk.forEach((page, pageIndex) => { + const newPage: PDFPage = { + ...page, + id: `${i}-${page.id}`, // Unique ID across all files + pageNumber: totalPages + j + pageIndex + 1, + splitBefore: page.splitBefore || false + }; + allPages.push(newPage); + }); + + // Yield to main thread after each chunk + if (j + chunkSize < pages.length) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + totalPages += processedFile.pages.length; } - }); + + // Yield between files + if (i < activeFiles.length - 1) { + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } if (allPages.length > 0) { const mergedDocument: PDFDocument = { @@ -163,36 +211,57 @@ const PageEditor = ({ totalPages: totalPages }; - setMergedPdfDocument(mergedDocument); + // Cache the merged document + setCurrentMergedDocument(mergedDocument); setFilename(filenames.join('_')); } } - }, [activeFiles, enhancedProcessedFiles, convertToPageEditorFormat]); + + setIsMerging(false); + console.timeEnd('PageEditor: mergeAllPDFs'); + }, [activeFiles, processedFiles]); // Removed function dependencies to prevent unnecessary re-runs - // Handle file upload from FileUploadSelector - const handleMultipleFileUpload = useCallback((uploadedFiles: File[]) => { + // Handle file upload from FileUploadSelector (now using context) + const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => { if (!uploadedFiles || uploadedFiles.length === 0) { setStatus('No files provided'); return; } - // Simply set the activeFiles to the selected files (same as existing approach) - setActiveFiles(uploadedFiles); + // Add files to context + await fileContext.addFiles(uploadedFiles); setStatus(`Added ${uploadedFiles.length} file(s) for processing`); - }, [setActiveFiles]); + }, [fileContext]); - // Auto-merge documents when enhanced processing completes + // Store mergeAllPDFs in ref to avoid effect dependency + const mergeAllPDFsRef = useRef(mergeAllPDFs); + mergeAllPDFsRef.current = mergeAllPDFs; + + // Auto-merge documents when processing completes (async) useEffect(() => { - if (activeFiles.length > 0) { - const allProcessed = activeFiles.every(file => enhancedProcessedFiles.has(file)); + const doMerge = async () => { + console.time('PageEditor: doMerge effect'); + + if (activeFiles.length > 0) { + const allProcessed = activeFiles.every(file => processedFiles.has(file)); - if (allProcessed) { - mergeAllPDFs(); + if (allProcessed) { + console.log('PageEditor: All files processed, calling mergeAllPDFs'); + await mergeAllPDFsRef.current(); + } else { + console.log('PageEditor: Not all files processed yet'); + } + } else { + console.log('PageEditor: No active files'); } - } else { - setMergedPdfDocument(null); - } - }, [activeFiles, enhancedProcessedFiles, mergeAllPDFs]); + + console.timeEnd('PageEditor: doMerge effect'); + }; + + doMerge(); + }, [activeFiles, processedFiles]); // Stable dependencies only + + // PageEditor no longer handles cleanup - it's centralized in FileContext // Shared PDF instance for thumbnail generation const [sharedPdfInstance, setSharedPdfInstance] = useState(null); @@ -209,7 +278,9 @@ const PageEditor = ({ // Start thumbnail generation process (separate from document loading) const startThumbnailGeneration = useCallback(() => { - if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) return; + if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) { + return; + } const file = activeFiles[0]; const totalPages = mergedPdfDocument.totalPages; @@ -225,12 +296,15 @@ const PageEditor = ({ // Generate all page numbers const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); + // Calculate quality scale based on file size + const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2; + // Start parallel thumbnail generation WITHOUT blocking the main thread generateThumbnails( arrayBuffer, pageNumbers, { - scale: 0.2, // Low quality for page editor + scale, // Dynamic quality based on file size quality: 0.8, batchSize: 15, // Smaller batches per worker for smoother UI parallelBatches: 3 // Use 3 Web Workers in parallel @@ -269,14 +343,20 @@ const PageEditor = ({ // Start thumbnail generation after document loads and UI settles useEffect(() => { - if (mergedPdfDocument && !thumbnailGenerationStarted) { + if (mergedPdfDocument && !thumbnailGenerationStarted && !isMerging) { + // Check if pages already have thumbnails from processed files + const hasExistingThumbnails = mergedPdfDocument.pages.some(page => page.thumbnail); + + if (hasExistingThumbnails) { + return; // Skip generation if thumbnails already exist + } // Small delay to let document render, then start thumbnail generation - const timer = setTimeout(startThumbnailGeneration, 1000); + const timer = setTimeout(startThumbnailGeneration, 500); // Reduced delay return () => clearTimeout(timer); } - }, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]); + }, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted, isMerging]); - // Cleanup shared PDF instance when files change (but keep thumbnails cached) + // Cleanup shared PDF instance when component unmounts (but preserve cache) useEffect(() => { return () => { if (sharedPdfInstance) { @@ -284,17 +364,17 @@ const PageEditor = ({ setSharedPdfInstance(null); } setThumbnailGenerationStarted(false); - // Stop generation but keep cache and workers alive for cross-tool persistence - stopGeneration(); + // DON'T stop generation on file changes - preserve cache for view switching + // stopGeneration(); }; - }, [activeFiles, stopGeneration]); + }, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles // Clear selections when files change useEffect(() => { setSelectedPages([]); setCsvInput(""); setSelectionMode(false); - }, [activeFiles]); + }, [activeFiles, setSelectedPages]); useEffect(() => { const handleGlobalDragEnd = () => { @@ -325,9 +405,9 @@ const PageEditor = ({ if (mergedPdfDocument) { setSelectedPages(mergedPdfDocument.pages.map(p => p.id)); } - }, [mergedPdfDocument]); + }, [mergedPdfDocument, setSelectedPages]); - const deselectAll = useCallback(() => setSelectedPages([]), []); + const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]); const togglePage = useCallback((pageId: string) => { setSelectedPages(prev => @@ -335,7 +415,7 @@ const PageEditor = ({ ? prev.filter(id => id !== pageId) : [...prev, pageId] ); - }, []); + }, [setSelectedPages]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { @@ -385,15 +465,15 @@ const PageEditor = ({ setDraggedPage(pageId); // Check if this is a multi-page drag in selection mode - if (selectionMode && selectedPages.includes(pageId) && selectedPages.length > 1) { + if (selectionMode && selectedPageIds.includes(pageId) && selectedPageIds.length > 1) { setMultiPageDrag({ - pageIds: selectedPages, - count: selectedPages.length + pageIds: selectedPageIds, + count: selectedPageIds.length }); } else { setMultiPageDrag(null); } - }, [selectionMode, selectedPages]); + }, [selectionMode, selectedPageIds]); const handleDragEnd = useCallback(() => { // Clean up drag state regardless of where the drop happened @@ -450,17 +530,18 @@ const PageEditor = ({ // Create setPdfDocument wrapper for merged document const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { - setMergedPdfDocument(updatedDoc); + // Update the cached merged document + setCurrentMergedDocument(updatedDoc); // Return the updated document for immediate use in animations return updatedDoc; - }, []); + }, [setCurrentMergedDocument]); const animateReorder = useCallback((pageId: string, targetIndex: number) => { if (!mergedPdfDocument || isAnimating) return; // In selection mode, if the dragged page is selected, move all selected pages - const pagesToMove = selectionMode && selectedPages.includes(pageId) - ? selectedPages + const pagesToMove = selectionMode && selectedPageIds.includes(pageId) + ? selectedPageIds : [pageId]; const originalIndex = mergedPdfDocument.pages.findIndex(p => p.id === pageId); @@ -567,7 +648,7 @@ const PageEditor = ({ }); }); }, 10); // Small delay to allow state update - }, [mergedPdfDocument, isAnimating, executeCommand, selectionMode, selectedPages, setPdfDocument]); + }, [mergedPdfDocument, isAnimating, executeCommand, selectionMode, selectedPageIds, setPdfDocument]); const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => { e.preventDefault(); @@ -603,10 +684,10 @@ const PageEditor = ({ const rotation = direction === 'left' ? -90 : 90; const pagesToRotate = selectionMode - ? selectedPages + ? selectedPageIds : mergedPdfDocument.pages.map(p => p.id); - if (selectionMode && selectedPages.length === 0) return; + if (selectionMode && selectedPageIds.length === 0) return; const command = new RotatePagesCommand( mergedPdfDocument, @@ -616,18 +697,18 @@ const PageEditor = ({ ); executeCommand(command); - const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length; + const pageCount = selectionMode ? selectedPageIds.length : mergedPdfDocument.pages.length; setStatus(`Rotated ${pageCount} pages ${direction}`); - }, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); + }, [mergedPdfDocument, selectedPageIds, selectionMode, executeCommand, setPdfDocument]); const handleDelete = useCallback(() => { if (!mergedPdfDocument) return; const pagesToDelete = selectionMode - ? selectedPages + ? selectedPageIds : mergedPdfDocument.pages.map(p => p.id); - if (selectionMode && selectedPages.length === 0) return; + if (selectionMode && selectedPageIds.length === 0) return; const command = new DeletePagesCommand( mergedPdfDocument, @@ -639,18 +720,18 @@ const PageEditor = ({ if (selectionMode) { setSelectedPages([]); } - const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length; + const pageCount = selectionMode ? selectedPageIds.length : mergedPdfDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); - }, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); + }, [mergedPdfDocument, selectedPageIds, selectionMode, executeCommand, setPdfDocument, setSelectedPages]); const handleSplit = useCallback(() => { if (!mergedPdfDocument) return; const pagesToSplit = selectionMode - ? selectedPages + ? selectedPageIds : mergedPdfDocument.pages.map(p => p.id); - if (selectionMode && selectedPages.length === 0) return; + if (selectionMode && selectedPageIds.length === 0) return; const command = new ToggleSplitCommand( mergedPdfDocument, @@ -659,25 +740,25 @@ const PageEditor = ({ ); executeCommand(command); - const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length; + const pageCount = selectionMode ? selectedPageIds.length : mergedPdfDocument.pages.length; setStatus(`Split markers toggled for ${pageCount} pages`); - }, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); + }, [mergedPdfDocument, selectedPageIds, selectionMode, executeCommand, setPdfDocument]); const showExportPreview = useCallback((selectedOnly: boolean = false) => { if (!mergedPdfDocument) return; - const exportPageIds = selectedOnly ? selectedPages : []; + const exportPageIds = selectedOnly ? selectedPageIds : []; const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); setExportPreview(preview); setShowExportModal(true); - }, [mergedPdfDocument, selectedPages]); + }, [mergedPdfDocument, selectedPageIds]); const handleExport = useCallback(async (selectedOnly: boolean = false) => { if (!mergedPdfDocument) return; setExportLoading(true); try { - const exportPageIds = selectedOnly ? selectedPages : []; + const exportPageIds = selectedOnly ? selectedPageIds : []; const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); if (errors.length > 0) { setError(errors.join(', ')); @@ -715,7 +796,7 @@ const PageEditor = ({ } finally { setExportLoading(false); } - }, [mergedPdfDocument, selectedPages, filename]); + }, [mergedPdfDocument, selectedPageIds, filename]); const handleUndo = useCallback(() => { if (undo()) { @@ -730,13 +811,9 @@ const PageEditor = ({ }, [redo]); const closePdf = useCallback(() => { - setActiveFiles([]); - setMergedPdfDocument(null); + clearAllFiles(); // This now handles all cleanup centrally (including merged docs) setSelectedPages([]); - - // Only destroy thumbnails and workers on explicit PDF close - destroyThumbnails(); - }, [setActiveFiles, destroyThumbnails]); + }, [clearAllFiles, setSelectedPages]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); @@ -758,7 +835,7 @@ const PageEditor = ({ onExportAll, exportLoading, selectionMode, - selectedPages, + selectedPages: selectedPageIds, closePdf, }); } @@ -776,67 +853,98 @@ const PageEditor = ({ onExportAll, exportLoading, selectionMode, - selectedPages, + selectedPageIds, closePdf ]); - // Return early if no merged document - Homepage handles file selection - if (!mergedPdfDocument) { - return ( -
- - {globalProcessing ? ( - Processing PDF files... - ) : ( - Waiting for PDF files... - )} -
- ); - } + // Show loading or empty state instead of blocking + const showLoading = !mergedPdfDocument && (globalProcessing || isMerging || activeFiles.length > 0); + const showEmpty = !mergedPdfDocument && !globalProcessing && !isMerging && activeFiles.length === 0; + + // For large documents, implement pagination to avoid rendering too many components + const isLargeDocument = mergedPdfDocument && mergedPdfDocument.pages.length > 200; + const [currentPageRange, setCurrentPageRange] = useState({ start: 0, end: 200 }); + + // Reset pagination when document changes + useEffect(() => { + setCurrentPageRange({ start: 0, end: 200 }); + }, [mergedPdfDocument]); + + const displayedPages = isLargeDocument + ? mergedPdfDocument.pages.slice(currentPageRange.start, currentPageRange.end) + : mergedPdfDocument?.pages || []; return ( + {showEmpty && ( +
+ + 📄 + No PDF files loaded + Add files to start editing pages + +
+ )} + {showLoading && ( + + + + {/* Progress indicator */} + + + + {isMerging ? "Merging PDF documents..." : "Processing PDF files..."} + + + {isMerging ? "" : `${Math.round(processingProgress || 0)}%`} + + +
+
+
+ + + + + )} + + {mergedPdfDocument && ( {/* Enhanced Processing Status */} - {(globalProcessing || hasProcessingErrors) && ( + {globalProcessing && processingProgress < 100 && ( - {globalProcessing && ( - - Processing files... - {Math.round(processingProgress.overall)}% - - )} - - {Array.from(processingStates.values()).map(state => ( - - {state.fileName} - - {state.progress}% - {state.error && ( - - )} - - - ))} - - {hasProcessingErrors && ( - - Some files failed to process. Check individual file status above. - - )} + + Processing thumbnails... + {Math.round(processingProgress || 0)}% + +
+
+
)} @@ -874,14 +982,49 @@ const PageEditor = ({ )} + {isLargeDocument && ( + + + Large document detected ({mergedPdfDocument.pages.length} pages) + + + + {currentPageRange.start + 1}-{Math.min(currentPageRange.end, mergedPdfDocument.pages.length)} of {mergedPdfDocument.pages.length} + + + + + + )} + - + )} - setShowExportModal(false)} title="Export Preview" @@ -998,17 +1142,17 @@ const PageEditor = ({ - {status && ( - setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} - > - {status} - - )} - + {status && ( + setStatus(null)} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} + > + {status} + + )} + ); }; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index a24da022a..a89a21510 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -79,12 +79,21 @@ const PageThumbnail = React.memo(({ }: PageThumbnailProps) => { const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false); - - // Listen for ready thumbnails from Web Workers (optimized) + + // Update thumbnail URL when page prop changes useEffect(() => { + if (page.thumbnail && page.thumbnail !== thumbnailUrl) { + setThumbnailUrl(page.thumbnail); + } + }, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]); + + // Listen for ready thumbnails from Web Workers (only if no existing thumbnail) + useEffect(() => { + if (thumbnailUrl) return; // Skip if we already have a thumbnail + const handleThumbnailReady = (event: CustomEvent) => { const { pageNumber, thumbnail, pageId } = event.detail; - if (pageNumber === page.pageNumber && pageId === page.id && !thumbnailUrl) { + if (pageNumber === page.pageNumber && pageId === page.id) { setThumbnailUrl(thumbnail); } }; @@ -194,8 +203,8 @@ const PageThumbnail = React.memo(({ src={thumbnailUrl} alt={`Page ${page.pageNumber}`} style={{ - maxWidth: '100%', - maxHeight: '100%', + width: '100%', + height: '100%', objectFit: 'contain', borderRadius: 2, transform: `rotate(${page.rotation}deg)`, diff --git a/frontend/src/components/shared/SkeletonLoader.tsx b/frontend/src/components/shared/SkeletonLoader.tsx new file mode 100644 index 000000000..63c4bd22a --- /dev/null +++ b/frontend/src/components/shared/SkeletonLoader.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Box, Group, Stack } from '@mantine/core'; + +interface SkeletonLoaderProps { + type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer'; + count?: number; + animated?: boolean; +} + +const SkeletonLoader: React.FC = ({ + type, + count = 8, + animated = true +}) => { + const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {}; + + const renderPageGridSkeleton = () => ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); + + const renderFileGridSkeleton = () => ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); + + const renderControlsSkeleton = () => ( + + + + + + ); + + const renderViewerSkeleton = () => ( + + {/* Toolbar skeleton */} + + + + + + + {/* Main content skeleton */} + + + ); + + switch (type) { + case 'pageGrid': + return renderPageGridSkeleton(); + case 'fileGrid': + return renderFileGridSkeleton(); + case 'controls': + return renderControlsSkeleton(); + case 'viewer': + return renderViewerSkeleton(); + default: + return null; + } +}; + +export default SkeletonLoader; \ No newline at end of file diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index cab50337a..41b261673 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Button, SegmentedControl } from "@mantine/core"; +import React, { useState, useCallback } from "react"; +import { Button, SegmentedControl, Loader } from "@mantine/core"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import LanguageSelector from "./LanguageSelector"; import rainbowStyles from '../../styles/rainbow.module.css'; @@ -11,11 +11,16 @@ import EditNoteIcon from "@mui/icons-material/EditNote"; import FolderIcon from "@mui/icons-material/Folder"; import { Group } from "@mantine/core"; -const VIEW_OPTIONS = [ +// This will be created inside the component to access switchingTo +const createViewOptions = (switchingTo: string | null) => [ { label: ( - + {switchingTo === "viewer" ? ( + + ) : ( + + )} ), value: "viewer", @@ -23,7 +28,11 @@ const VIEW_OPTIONS = [ { label: ( - + {switchingTo === "pageEditor" ? ( + + ) : ( + + )} ), value: "pageEditor", @@ -31,7 +40,11 @@ const VIEW_OPTIONS = [ { label: ( - + {switchingTo === "fileEditor" ? ( + + ) : ( + + )} ), value: "fileEditor", @@ -48,6 +61,23 @@ const TopControls = ({ setCurrentView, }: TopControlsProps) => { const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext(); + const [switchingTo, setSwitchingTo] = useState(null); + + const handleViewChange = useCallback((view: string) => { + // Show immediate feedback + setSwitchingTo(view); + + // Defer the heavy view change to next frame so spinner can render + requestAnimationFrame(() => { + // Give the spinner one more frame to show + requestAnimationFrame(() => { + setCurrentView(view); + + // Clear the loading state after view change completes + setTimeout(() => setSwitchingTo(null), 300); + }); + }); + }, [setCurrentView]); const getThemeIcon = () => { if (isRainbowMode) return ; @@ -80,14 +110,18 @@ const TopControls = ({
diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index bbf218016..91d7bacf3 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -11,6 +11,7 @@ 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"; import { fileStorage } from "../../services/fileStorage"; +import SkeletonLoader from '../shared/SkeletonLoader'; GlobalWorkerOptions.workerSrc = "/pdf.worker.js"; @@ -414,9 +415,9 @@ const Viewer = ({ ) : loading ? ( -
- -
+
+ +
) : ( } + | { type: 'SET_MERGED_DOCUMENT'; payload: { key: string; document: PDFDocument } } + | { type: 'CLEAR_MERGED_DOCUMENTS' } + | { type: 'SET_CURRENT_VIEW'; payload: ViewType } + | { type: 'SET_CURRENT_TOOL'; payload: ToolType } + | { type: 'SET_SELECTED_FILES'; payload: string[] } + | { type: 'SET_SELECTED_PAGES'; payload: string[] } + | { type: 'CLEAR_SELECTIONS' } + | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } + | { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial } + | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } } + | { type: 'ADD_FILE_OPERATION'; payload: FileOperation } + | { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] } + | { type: 'RESET_CONTEXT' } + | { type: 'LOAD_STATE'; payload: Partial }; + +// Reducer +function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { + switch (action.type) { + case 'SET_ACTIVE_FILES': + return { + ...state, + activeFiles: action.payload, + selectedFileIds: [], // Clear selections when files change + selectedPageIds: [] + }; + + case 'ADD_FILES': + return { + ...state, + activeFiles: [...state.activeFiles, ...action.payload] + }; + + case 'REMOVE_FILES': + const remainingFiles = state.activeFiles.filter(file => + !action.payload.includes(file.name) // Simple ID for now, could use file.name or generate IDs + ); + return { + ...state, + activeFiles: remainingFiles, + selectedFileIds: state.selectedFileIds.filter(id => !action.payload.includes(id)) + }; + + case 'SET_PROCESSED_FILES': + return { + ...state, + processedFiles: action.payload + }; + + case 'SET_MERGED_DOCUMENT': + const newMergedDocuments = new Map(state.mergedDocuments); + newMergedDocuments.set(action.payload.key, action.payload.document); + return { + ...state, + mergedDocuments: newMergedDocuments + }; + + case 'CLEAR_MERGED_DOCUMENTS': + return { + ...state, + mergedDocuments: new Map() + }; + + case 'SET_CURRENT_VIEW': + return { + ...state, + currentView: action.payload, + // Clear tool when switching views + currentTool: null + }; + + case 'SET_CURRENT_TOOL': + return { + ...state, + currentTool: action.payload + }; + + case 'SET_SELECTED_FILES': + return { + ...state, + selectedFileIds: action.payload + }; + + case 'SET_SELECTED_PAGES': + return { + ...state, + selectedPageIds: action.payload + }; + + case 'CLEAR_SELECTIONS': + return { + ...state, + selectedFileIds: [], + selectedPageIds: [] + }; + + case 'SET_PROCESSING': + return { + ...state, + isProcessing: action.payload.isProcessing, + processingProgress: action.payload.progress + }; + + case 'UPDATE_VIEWER_CONFIG': + return { + ...state, + viewerConfig: { + ...state.viewerConfig, + ...action.payload + } + }; + + case 'ADD_PAGE_OPERATIONS': + const newHistory = new Map(state.fileEditHistory); + const existing = newHistory.get(action.payload.fileId); + newHistory.set(action.payload.fileId, { + fileId: action.payload.fileId, + pageOperations: existing ? + [...existing.pageOperations, ...action.payload.operations] : + action.payload.operations, + lastModified: Date.now() + }); + return { + ...state, + fileEditHistory: newHistory + }; + + case 'ADD_FILE_OPERATION': + return { + ...state, + globalFileOperations: [...state.globalFileOperations, action.payload] + }; + + case 'SET_EXPORT_CONFIG': + return { + ...state, + lastExportConfig: action.payload + }; + + case 'RESET_CONTEXT': + return { + ...initialState, + mergedDocuments: new Map() // Ensure clean state + }; + + case 'LOAD_STATE': + return { + ...state, + ...action.payload + }; + + default: + return state; + } +} + +// Context +const FileContext = createContext(undefined); + +// Provider component +export function FileContextProvider({ + children, + enableUrlSync = true, + enablePersistence = true, + maxCacheSize = 1024 * 1024 * 1024 // 1GB +}: FileContextProviderProps) { + const [state, dispatch] = useReducer(fileContextReducer, initialState); + const [searchParams, setSearchParams] = useSearchParams(); + + // Cleanup timers and refs + const cleanupTimers = useRef>(new Map()); + const blobUrls = useRef>(new Set()); + const pdfDocuments = useRef>(new Map()); + + // Enhanced file processing hook + const { + processedFiles, + processingStates, + isProcessing: globalProcessing, + processingProgress, + actions: processingActions + } = useEnhancedProcessedFiles(state.activeFiles, { + strategy: 'progressive_chunked', + thumbnailQuality: 'medium', + chunkSize: 5, // Process 5 pages at a time for smooth progress + priorityPageCount: 0 // No special priority pages + }); + + // Update processed files when they change + useEffect(() => { + dispatch({ type: 'SET_PROCESSED_FILES', payload: processedFiles }); + dispatch({ + type: 'SET_PROCESSING', + payload: { + isProcessing: globalProcessing, + progress: processingProgress.overall + } + }); + }, [processedFiles, globalProcessing, processingProgress.overall]); + + // URL synchronization + const syncUrlParams = useCallback(() => { + if (!enableUrlSync) return; + + const params: FileContextUrlParams = {}; + + if (state.currentView !== 'fileEditor') params.view = state.currentView; + if (state.currentTool) params.tool = state.currentTool; + if (state.selectedFileIds.length > 0) params.fileIds = state.selectedFileIds; + if (state.selectedPageIds.length > 0) params.pageIds = state.selectedPageIds; + if (state.viewerConfig.zoom !== 1.0) params.zoom = state.viewerConfig.zoom; + if (state.viewerConfig.currentPage !== 1) params.page = state.viewerConfig.currentPage; + + // Update URL params without causing navigation + const newParams = new URLSearchParams(searchParams); + Object.entries(params).forEach(([key, value]) => { + if (Array.isArray(value)) { + newParams.set(key, value.join(',')); + } else if (value !== undefined) { + newParams.set(key, value.toString()); + } + }); + + // Remove empty params + Object.keys(params).forEach(key => { + if (!params[key as keyof FileContextUrlParams]) { + newParams.delete(key); + } + }); + + setSearchParams(newParams, { replace: true }); + }, [state, searchParams, setSearchParams, enableUrlSync]); + + // Load from URL params on mount + useEffect(() => { + if (!enableUrlSync) return; + + const view = searchParams.get('view') as ViewType; + const tool = searchParams.get('tool') as ToolType; + const zoom = searchParams.get('zoom'); + const page = searchParams.get('page'); + + if (view && view !== state.currentView) { + dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); + } + if (tool && tool !== state.currentTool) { + dispatch({ type: 'SET_CURRENT_TOOL', payload: tool }); + } + if (zoom || page) { + dispatch({ + type: 'UPDATE_VIEWER_CONFIG', + payload: { + ...(zoom && { zoom: parseFloat(zoom) }), + ...(page && { currentPage: parseInt(page) }) + } + }); + } + }, []); + + // Sync URL when state changes + useEffect(() => { + syncUrlParams(); + }, [syncUrlParams]); + + // Centralized memory management + const trackBlobUrl = useCallback((url: string) => { + blobUrls.current.add(url); + }, []); + + const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => { + // Clean up existing document for this file if any + const existing = pdfDocuments.current.get(fileId); + if (existing && existing.destroy) { + try { + existing.destroy(); + } catch (error) { + console.warn('Error destroying existing PDF document:', error); + } + } + pdfDocuments.current.set(fileId, pdfDoc); + }, []); + + const cleanupFile = useCallback(async (fileId: string) => { + console.log('Cleaning up file:', fileId); + + try { + // Cancel any pending cleanup timer + const timer = cleanupTimers.current.get(fileId); + if (timer) { + clearTimeout(timer); + cleanupTimers.current.delete(fileId); + } + + // Cleanup PDF document instances (but preserve processed file cache) + const pdfDoc = pdfDocuments.current.get(fileId); + if (pdfDoc && pdfDoc.destroy) { + pdfDoc.destroy(); + pdfDocuments.current.delete(fileId); + } + + // IMPORTANT: Don't cancel processing or clear cache during normal view switches + // Only do this when file is actually being removed + // enhancedPDFProcessingService.cancelProcessing(fileId); + // thumbnailGenerationService.stopGeneration(); + + } catch (error) { + console.warn('Error during file cleanup:', error); + } + }, []); + + const cleanupAllFiles = useCallback(() => { + console.log('Cleaning up all files'); + + try { + // Clear all timers + cleanupTimers.current.forEach(timer => clearTimeout(timer)); + cleanupTimers.current.clear(); + + // Destroy all PDF documents + pdfDocuments.current.forEach((pdfDoc, fileId) => { + if (pdfDoc && pdfDoc.destroy) { + try { + pdfDoc.destroy(); + } catch (error) { + console.warn(`Error destroying PDF document for ${fileId}:`, error); + } + } + }); + pdfDocuments.current.clear(); + + // Revoke all blob URLs + blobUrls.current.forEach(url => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Error revoking blob URL:', error); + } + }); + blobUrls.current.clear(); + + // Clear all processing + enhancedPDFProcessingService.clearAllProcessing(); + + // Destroy thumbnails + thumbnailGenerationService.destroy(); + + // Force garbage collection hint + if (typeof window !== 'undefined' && window.gc) { + setTimeout(() => window.gc(), 100); + } + + } catch (error) { + console.warn('Error during cleanup all files:', error); + } + }, []); + + const scheduleCleanup = useCallback((fileId: string, delay: number = 30000) => { + // Cancel existing timer + const existingTimer = cleanupTimers.current.get(fileId); + if (existingTimer) { + clearTimeout(existingTimer); + cleanupTimers.current.delete(fileId); + } + + // If delay is negative, just cancel (don't reschedule) + if (delay < 0) { + return; + } + + // Schedule new cleanup + const timer = setTimeout(() => { + cleanupFile(fileId); + }, delay); + + cleanupTimers.current.set(fileId, timer); + }, [cleanupFile]); + + // Action implementations + const addFiles = useCallback(async (files: File[]) => { + dispatch({ type: 'ADD_FILES', payload: files }); + + // Auto-save to IndexedDB if persistence enabled + if (enablePersistence) { + for (const file of files) { + try { + await fileStorage.storeFile(file); + } catch (error) { + console.error('Failed to store file:', error); + } + } + } + }, [enablePersistence]); + + const removeFiles = useCallback((fileIds: string[]) => { + // FULL cleanup for actually removed files (including cache) + fileIds.forEach(fileId => { + // Cancel processing and clear caches when file is actually removed + enhancedPDFProcessingService.cancelProcessing(fileId); + cleanupFile(fileId); + }); + + dispatch({ type: 'REMOVE_FILES', payload: fileIds }); + + // Clear merged documents that included removed files + // This is a simple approach - clear all merged docs when any file is removed + // Could be optimized to only clear affected merged documents + dispatch({ type: 'CLEAR_MERGED_DOCUMENTS' }); + + // Remove from IndexedDB + if (enablePersistence) { + fileIds.forEach(async (fileId) => { + try { + await fileStorage.removeFile(fileId); + } catch (error) { + console.error('Failed to remove file from storage:', error); + } + }); + } + }, [enablePersistence, cleanupFile]); + + const replaceFile = useCallback(async (oldFileId: string, newFile: File) => { + // Remove old file and add new one + removeFiles([oldFileId]); + await addFiles([newFile]); + }, [removeFiles, addFiles]); + + const clearAllFiles = useCallback(() => { + // Cleanup all memory before clearing files + cleanupAllFiles(); + + dispatch({ type: 'SET_ACTIVE_FILES', payload: [] }); + dispatch({ type: 'CLEAR_SELECTIONS' }); + dispatch({ type: 'CLEAR_MERGED_DOCUMENTS' }); + }, [cleanupAllFiles]); + + const setCurrentView = useCallback((view: ViewType) => { + // Update view immediately for instant UI response + dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); + + // REMOVED: Aggressive cleanup on view switch + // This was destroying cached processed files and causing re-processing + // We should only cleanup when files are actually removed or app closes + + // Optional: Light memory pressure relief only for very large docs + if (state.currentView !== view && state.activeFiles.length > 0) { + // Only hint at garbage collection, don't destroy caches + if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { + window.requestIdleCallback(() => { + // Very light cleanup - just GC hint, no cache destruction + window.gc(); + }, { timeout: 5000 }); + } + } + }, [state.currentView, state.activeFiles]); + + const setCurrentTool = useCallback((tool: ToolType) => { + dispatch({ type: 'SET_CURRENT_TOOL', payload: tool }); + }, []); + + const setSelectedFiles = useCallback((fileIds: string[]) => { + dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds }); + }, []); + + const setSelectedPages = useCallback((pageIds: string[]) => { + dispatch({ type: 'SET_SELECTED_PAGES', payload: pageIds }); + }, []); + + const clearSelections = useCallback(() => { + dispatch({ type: 'CLEAR_SELECTIONS' }); + }, []); + + const applyPageOperations = useCallback((fileId: string, operations: PageOperation[]) => { + dispatch({ + type: 'ADD_PAGE_OPERATIONS', + payload: { fileId, operations } + }); + }, []); + + const applyFileOperation = useCallback((operation: FileOperation) => { + dispatch({ type: 'ADD_FILE_OPERATION', payload: operation }); + }, []); + + const undoLastOperation = useCallback((fileId?: string) => { + // TODO: Implement undo logic + console.warn('Undo not yet implemented'); + }, []); + + const updateViewerConfig = useCallback((config: Partial) => { + dispatch({ type: 'UPDATE_VIEWER_CONFIG', payload: config }); + }, []); + + const setExportConfig = useCallback((config: FileContextState['lastExportConfig']) => { + dispatch({ type: 'SET_EXPORT_CONFIG', payload: config }); + }, []); + + // Utility functions + const getFileById = useCallback((fileId: string): File | undefined => { + return state.activeFiles.find(file => file.name === fileId); // Simple ID matching + }, [state.activeFiles]); + + const getProcessedFileById = useCallback((fileId: string): ProcessedFile | undefined => { + const file = getFileById(fileId); + return file ? state.processedFiles.get(file) : undefined; + }, [getFileById, state.processedFiles]); + + const getCurrentFile = useCallback((): File | undefined => { + if (state.selectedFileIds.length > 0) { + return getFileById(state.selectedFileIds[0]); + } + return state.activeFiles[0]; // Default to first file + }, [state.selectedFileIds, state.activeFiles, getFileById]); + + const getCurrentProcessedFile = useCallback((): ProcessedFile | undefined => { + const file = getCurrentFile(); + return file ? state.processedFiles.get(file) : undefined; + }, [getCurrentFile, state.processedFiles]); + + // Context persistence + const saveContext = useCallback(async () => { + if (!enablePersistence) return; + + try { + const contextData = { + currentView: state.currentView, + currentTool: state.currentTool, + selectedFileIds: state.selectedFileIds, + selectedPageIds: state.selectedPageIds, + viewerConfig: state.viewerConfig, + lastExportConfig: state.lastExportConfig, + timestamp: Date.now() + }; + + localStorage.setItem('fileContext', JSON.stringify(contextData)); + } catch (error) { + console.error('Failed to save context:', error); + } + }, [state, enablePersistence]); + + const loadContext = useCallback(async () => { + if (!enablePersistence) return; + + try { + const saved = localStorage.getItem('fileContext'); + if (saved) { + const contextData = JSON.parse(saved); + dispatch({ type: 'LOAD_STATE', payload: contextData }); + } + } catch (error) { + console.error('Failed to load context:', error); + } + }, [enablePersistence]); + + const resetContext = useCallback(() => { + dispatch({ type: 'RESET_CONTEXT' }); + if (enablePersistence) { + localStorage.removeItem('fileContext'); + } + }, [enablePersistence]); + + // Merged document management functions + const generateMergedDocumentKey = useCallback((files: File[]): string => { + // Create stable key from file names and sizes + const fileDescriptors = files + .map(file => `${file.name}-${file.size}-${file.lastModified}`) + .sort() // Sort for consistent key regardless of order + .join('|'); + return `merged:${fileDescriptors}`; + }, []); + + const getMergedDocument = useCallback((fileIds: string[]): PDFDocument | undefined => { + // Convert fileIds to actual files to generate proper key + const files = fileIds.map(id => getFileById(id)).filter((f): f is File => f !== undefined); + if (files.length === 0) return undefined; + + const key = generateMergedDocumentKey(files); + return state.mergedDocuments.get(key); + }, [state.mergedDocuments, getFileById, generateMergedDocumentKey]); + + const setMergedDocument = useCallback((fileIds: string[], document: PDFDocument) => { + const files = fileIds.map(id => getFileById(id)).filter((f): f is File => f !== undefined); + if (files.length === 0) return; + + const key = generateMergedDocumentKey(files); + dispatch({ type: 'SET_MERGED_DOCUMENT', payload: { key, document } }); + }, [getFileById, generateMergedDocumentKey]); + + const clearMergedDocuments = useCallback(() => { + dispatch({ type: 'CLEAR_MERGED_DOCUMENTS' }); + }, []); + + // Helper to get merged document from current active files + const getCurrentMergedDocument = useCallback((): PDFDocument | undefined => { + if (state.activeFiles.length === 0) return undefined; + const key = generateMergedDocumentKey(state.activeFiles); + return state.mergedDocuments.get(key); + }, [state.activeFiles, state.mergedDocuments, generateMergedDocumentKey]); + + // Helper to set merged document for current active files + const setCurrentMergedDocument = useCallback((document: PDFDocument) => { + if (state.activeFiles.length === 0) return; + const key = generateMergedDocumentKey(state.activeFiles); + dispatch({ type: 'SET_MERGED_DOCUMENT', payload: { key, document } }); + }, [state.activeFiles, generateMergedDocumentKey]); + + // Auto-save context when it changes + useEffect(() => { + saveContext(); + }, [saveContext]); + + // Load context on mount + useEffect(() => { + loadContext(); + }, [loadContext]); + + // Cleanup on unmount + useEffect(() => { + return () => { + console.log('FileContext unmounting - cleaning up all resources'); + cleanupAllFiles(); + }; + }, [cleanupAllFiles]); + + const contextValue: FileContextValue = { + // State + ...state, + + // Actions + addFiles, + removeFiles, + replaceFile, + clearAllFiles, + setCurrentView, + setCurrentTool, + setSelectedFiles, + setSelectedPages, + clearSelections, + applyPageOperations, + applyFileOperation, + undoLastOperation, + updateViewerConfig, + setExportConfig, + getFileById, + getProcessedFileById, + getCurrentFile, + getCurrentProcessedFile, + saveContext, + loadContext, + resetContext, + + // Merged document management + getMergedDocument, + setMergedDocument, + clearMergedDocuments, + getCurrentMergedDocument, + setCurrentMergedDocument, + + // Memory management + trackBlobUrl, + trackPdfDocument, + cleanupFile, + scheduleCleanup + }; + + return ( + + {children} + + ); +} + +// Custom hook to use the context +export function useFileContext(): FileContextValue { + const context = useContext(FileContext); + if (!context) { + throw new Error('useFileContext must be used within a FileContextProvider'); + } + return context; +} + +// Helper hooks for specific aspects +export function useCurrentFile() { + const { getCurrentFile, getCurrentProcessedFile } = useFileContext(); + return { + file: getCurrentFile(), + processedFile: getCurrentProcessedFile() + }; +} + +export function useFileSelection() { + const { + selectedFileIds, + selectedPageIds, + setSelectedFiles, + setSelectedPages, + clearSelections + } = useFileContext(); + + return { + selectedFileIds, + selectedPageIds, + setSelectedFiles, + setSelectedPages, + clearSelections + }; +} + +export function useViewerState() { + const { viewerConfig, updateViewerConfig } = useFileContext(); + return { + config: viewerConfig, + updateConfig: updateViewerConfig + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useEnhancedProcessedFiles.ts b/frontend/src/hooks/useEnhancedProcessedFiles.ts index ac122500e..69b7788ee 100644 --- a/frontend/src/hooks/useEnhancedProcessedFiles.ts +++ b/frontend/src/hooks/useEnhancedProcessedFiles.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { ProcessedFile, ProcessingState, ProcessingConfig } from '../types/processing'; import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; import { FileHasher } from '../utils/fileHash'; @@ -37,6 +37,7 @@ export function useEnhancedProcessedFiles( config?: Partial ): UseEnhancedProcessedFilesResult { const [processedFiles, setProcessedFiles] = useState>(new Map()); + const fileHashMapRef = useRef>(new Map()); // Use ref to avoid state update loops const [processingStates, setProcessingStates] = useState>(new Map()); // Subscribe to processing state changes once @@ -47,8 +48,13 @@ export function useEnhancedProcessedFiles( // Process files when activeFiles changes useEffect(() => { + console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files'); + if (activeFiles.length === 0) { + console.log('useEnhancedProcessedFiles: No active files, clearing processed cache'); setProcessedFiles(new Map()); + // Clear any ongoing processing when no files + enhancedPDFProcessingService.clearAllProcessing(); return; } @@ -56,38 +62,47 @@ export function useEnhancedProcessedFiles( const newProcessedFiles = new Map(); for (const file of activeFiles) { - // Check if we already have this file processed - const existing = processedFiles.get(file); + // Generate hash for this file + const fileHash = await FileHasher.generateHybridHash(file); + fileHashMapRef.current.set(file, fileHash); + + // First, check if we have this exact File object cached + let existing = processedFiles.get(file); + + // If not found by File object, try to find by hash in case File was recreated + if (!existing) { + for (const [cachedFile, processed] of processedFiles.entries()) { + const cachedHash = fileHashMapRef.current.get(cachedFile); + if (cachedHash === fileHash) { + existing = processed; + break; + } + } + } + if (existing) { newProcessedFiles.set(file, existing); continue; } try { - // Generate proper file key matching the service - const fileKey = await FileHasher.generateHybridHash(file); - console.log('Processing file:', file.name); - const processed = await enhancedPDFProcessingService.processFile(file, config); if (processed) { - console.log('Got processed file for:', file.name); newProcessedFiles.set(file, processed); - } else { - console.log('Processing started for:', file.name, '- waiting for completion'); } } catch (error) { console.error(`Failed to start processing for ${file.name}:`, error); } } - // Update processed files if we have any - if (newProcessedFiles.size > 0) { + // Update processed files (hash mapping is updated via ref) + if (newProcessedFiles.size > 0 || processedFiles.size > 0) { setProcessedFiles(newProcessedFiles); } }; processFiles(); - }, [activeFiles]); + }, [activeFiles]); // Only depend on activeFiles to avoid infinite loops // Listen for processing completion useEffect(() => { @@ -114,7 +129,6 @@ export function useEnhancedProcessedFiles( try { const processed = await enhancedPDFProcessingService.processFile(file, config); if (processed) { - console.log('Processing completed for:', file.name); updatedFiles.set(file, processed); hasNewFiles = true; } @@ -189,6 +203,13 @@ export function useEnhancedProcessedFiles( } }; + // Cleanup on unmount + useEffect(() => { + return () => { + enhancedPDFProcessingService.clearAllProcessing(); + }; + }, []); + return { processedFiles, processingStates, diff --git a/frontend/src/hooks/useMemoryManagement.ts b/frontend/src/hooks/useMemoryManagement.ts new file mode 100644 index 000000000..d27e5ed56 --- /dev/null +++ b/frontend/src/hooks/useMemoryManagement.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; +import { useFileContext } from '../contexts/FileContext'; + +/** + * Hook for components that need to register resources with centralized memory management + */ +export function useMemoryManagement() { + const { trackBlobUrl, trackPdfDocument, scheduleCleanup } = useFileContext(); + + const registerBlobUrl = useCallback((url: string) => { + trackBlobUrl(url); + return url; + }, [trackBlobUrl]); + + const registerPdfDocument = useCallback((fileId: string, pdfDoc: any) => { + trackPdfDocument(fileId, pdfDoc); + return pdfDoc; + }, [trackPdfDocument]); + + const cancelCleanup = useCallback((fileId: string) => { + // Cancel scheduled cleanup (user is actively using the file) + scheduleCleanup(fileId, -1); // -1 cancels the timer + }, [scheduleCleanup]); + + return { + registerBlobUrl, + registerPdfDocument, + cancelCleanup + }; +} \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 1660b6544..d33d81a83 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useSearchParams } from "react-router-dom"; import { useToolParams } from "../hooks/useToolParams"; import { useFileWithUrl } from "../hooks/useFileWithUrl"; +import { useFileContext } from "../contexts/FileContext"; import { fileStorage } from "../services/fileStorage"; import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; import ContentCutIcon from "@mui/icons-material/ContentCut"; @@ -13,7 +14,7 @@ import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; import TopControls from "../components/shared/TopControls"; -import FileEditor from "../components/pageEditor/FileEditor"; +import FileEditor from "../components/fileEditor/FileEditor"; import PageEditor from "../components/pageEditor/PageEditor"; import PageEditorControls from "../components/pageEditor/PageEditorControls"; import Viewer from "../components/viewer/Viewer"; @@ -47,14 +48,16 @@ export default function HomePage() { const [searchParams] = useSearchParams(); const theme = useMantineTheme(); const { isRainbowMode } = useRainbowThemeContext(); + + // Get file context + const fileContext = useFileContext(); + const { activeFiles, currentView, setCurrentView, addFiles } = fileContext; // Core app state const [selectedToolKey, setSelectedToolKey] = useState(searchParams.get("t") || "split"); - const [currentView, setCurrentView] = useState(searchParams.get("v") || "viewer"); // File state separation const [storedFiles, setStoredFiles] = useState([]); // IndexedDB files (FileManager) - const [activeFiles, setActiveFiles] = useState([]); // Active working set (persisted) const [preSelectedFiles, setPreSelectedFiles] = useState([]); const [downloadUrl, setDownloadUrl] = useState(null); @@ -123,7 +126,7 @@ export default function HomePage() { setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected setReaderMode(false); // Exit reader mode when selecting a tool }, - [toolRegistry] + [toolRegistry, setCurrentView] ); // Handle quick access actions @@ -138,33 +141,31 @@ export default function HomePage() { // Update URL when view changes const handleViewChange = useCallback((view: string) => { - setCurrentView(view); + setCurrentView(view as any); const params = new URLSearchParams(window.location.search); params.set('view', view); const newUrl = `${window.location.pathname}?${params.toString()}`; window.history.replaceState({}, '', newUrl); - }, []); + }, [setCurrentView]); - // Active file management - const addToActiveFiles = useCallback((file: File) => { - setActiveFiles(prev => { - // Avoid duplicates based on name and size - const exists = prev.some(f => f.name === file.name && f.size === file.size); - if (exists) return prev; - return [file, ...prev]; - }); - }, []); + // Active file management using context + const addToActiveFiles = useCallback(async (file: File) => { + // Check if file already exists + const exists = activeFiles.some(f => f.name === file.name && f.size === file.size); + if (!exists) { + await addFiles([file]); + } + }, [activeFiles, addFiles]); const removeFromActiveFiles = useCallback((file: File) => { - setActiveFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); - }, []); + fileContext.removeFiles([file.name]); + }, [fileContext]); - const setCurrentActiveFile = useCallback((file: File) => { - setActiveFiles(prev => { - const filtered = prev.filter(f => !(f.name === file.name && f.size === file.size)); - return [file, ...filtered]; - }); - }, []); + const setCurrentActiveFile = useCallback(async (file: File) => { + // Remove if exists, then add to front + const filtered = activeFiles.filter(f => !(f.name === file.name && f.size === file.size)); + await addFiles([file, ...filtered]); + }, [activeFiles, addFiles]); // Handle file selection from upload (adds to active files) const handleFileSelect = useCallback((file: File) => { @@ -213,13 +214,13 @@ export default function HomePage() { // Filter out nulls and add to activeFiles const validFiles = convertedFiles.filter((f): f is File => f !== null); - setActiveFiles(validFiles); + await addFiles(validFiles); setPreSelectedFiles([]); // Clear preselected since we're using activeFiles now handleViewChange("fileEditor"); } catch (error) { console.error('Error converting selected files:', error); } - }, [handleViewChange, setActiveFiles]); + }, [handleViewChange, addFiles]); // Handle opening page editor with selected files const handleOpenPageEditor = useCallback(async (selectedFiles) => { @@ -262,12 +263,12 @@ export default function HomePage() { // Filter out nulls and add to activeFiles const validFiles = convertedFiles.filter((f): f is File => f !== null); - setActiveFiles(validFiles); + await addFiles(validFiles); handleViewChange("pageEditor"); } catch (error) { console.error('Error converting selected files for page editor:', error); } - }, [handleViewChange, setActiveFiles]); + }, [handleViewChange, addFiles]); const selectedTool = toolRegistry[selectedToolKey]; @@ -374,7 +375,12 @@ export default function HomePage() { setCurrentView={handleViewChange} /> {/* Main content area */} - + {!activeFiles[0] ? ( ) : currentView === "fileEditor" ? ( setPreSelectedFiles([])} onOpenPageEditor={(file) => { setCurrentActiveFile(file); handleViewChange("pageEditor"); @@ -419,7 +421,7 @@ export default function HomePage() { if (fileObj) { setCurrentActiveFile(fileObj.file); } else { - setActiveFiles([]); + fileContext.clearAllFiles(); } }} sidebarsVisible={sidebarsVisible} @@ -428,14 +430,9 @@ export default function HomePage() { ) : currentView === "pageEditor" ? ( <> - {activeFiles[0] && pageEditorFunctions && ( + {pageEditorFunctions && ( ) : ( - + + { + addToActiveFiles(file); + }} + onFilesSelect={(files) => { + files.forEach(addToActiveFiles); + }} + accept={["application/pdf"]} + loading={false} + showRecentFiles={true} + maxRecentFiles={8} + /> + )} diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index d630acdeb..ea825e353 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -504,6 +504,27 @@ export class EnhancedPDFProcessingService { this.notifyListeners(); } + /** + * Clear all processing for view switches + */ + clearAllProcessing(): void { + // Cancel all ongoing processing + this.processing.forEach((state, key) => { + if (state.cancellationToken) { + state.cancellationToken.abort(); + } + }); + + // Clear processing states + this.processing.clear(); + this.notifyListeners(); + + // Force memory cleanup hint + if (typeof window !== 'undefined' && window.gc) { + setTimeout(() => window.gc(), 100); + } + } + /** * Get cache statistics */ diff --git a/frontend/src/styles/skeleton.css b/frontend/src/styles/skeleton.css new file mode 100644 index 000000000..ad3e7091b --- /dev/null +++ b/frontend/src/styles/skeleton.css @@ -0,0 +1,30 @@ +/* Pulse animation for skeleton loaders */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* Shimmer animation for premium feel */ +@keyframes shimmer { + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.skeleton-shimmer { + background: linear-gradient( + 90deg, + var(--mantine-color-gray-1) 25%, + var(--mantine-color-gray-0) 50%, + var(--mantine-color-gray-1) 75% + ); + background-size: 200px 100%; + animation: shimmer 2s infinite linear; +} \ No newline at end of file diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts new file mode 100644 index 000000000..72c319b57 --- /dev/null +++ b/frontend/src/types/fileContext.ts @@ -0,0 +1,138 @@ +/** + * Types for global file context management across views and tools + */ + +import { ProcessedFile } from './processing'; +import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; + +export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor'; + +export type ToolType = 'merge' | 'split' | 'compress' | null; + +export interface FileOperation { + id: string; + type: 'merge' | 'add' | 'remove' | 'replace'; + timestamp: number; + fileIds: string[]; + data?: any; +} + +export interface ViewerConfig { + zoom: number; + currentPage: number; + viewMode: 'single' | 'continuous' | 'facing'; + sidebarOpen: boolean; +} + +export interface FileEditHistory { + fileId: string; + pageOperations: PageOperation[]; + lastModified: number; +} + +export interface FileContextState { + // Core file management + activeFiles: File[]; + processedFiles: Map; + + // Cached merged documents (for PageEditor performance) + mergedDocuments: Map; + + // Current navigation state + currentView: ViewType; + currentTool: ToolType; + + // Edit history and state + fileEditHistory: Map; + globalFileOperations: FileOperation[]; + + // UI state that persists across views + selectedFileIds: string[]; + selectedPageIds: string[]; + viewerConfig: ViewerConfig; + + // Processing state + isProcessing: boolean; + processingProgress: number; + + // Export state + lastExportConfig?: { + filename: string; + selectedOnly: boolean; + splitDocuments: boolean; + }; +} + +export interface FileContextActions { + // File management + addFiles: (files: File[]) => Promise; + removeFiles: (fileIds: string[]) => void; + replaceFile: (oldFileId: string, newFile: File) => Promise; + clearAllFiles: () => void; + + // Navigation + setCurrentView: (view: ViewType) => void; + setCurrentTool: (tool: ToolType) => void; + + // Selection management + setSelectedFiles: (fileIds: string[]) => void; + setSelectedPages: (pageIds: string[]) => void; + clearSelections: () => void; + + // Edit operations + applyPageOperations: (fileId: string, operations: PageOperation[]) => void; + applyFileOperation: (operation: FileOperation) => void; + undoLastOperation: (fileId?: string) => void; + + // Viewer state + updateViewerConfig: (config: Partial) => void; + + // Export configuration + setExportConfig: (config: FileContextState['lastExportConfig']) => void; + + // Merged document management + getMergedDocument: (fileIds: string[]) => PDFDocument | undefined; + setMergedDocument: (fileIds: string[], document: PDFDocument) => void; + clearMergedDocuments: () => void; + + // Utility + getFileById: (fileId: string) => File | undefined; + getProcessedFileById: (fileId: string) => ProcessedFile | undefined; + getCurrentFile: () => File | undefined; + getCurrentProcessedFile: () => ProcessedFile | undefined; + + // Context persistence + saveContext: () => Promise; + loadContext: () => Promise; + resetContext: () => void; + + // Memory management + trackBlobUrl: (url: string) => void; + trackPdfDocument: (fileId: string, pdfDoc: any) => void; + cleanupFile: (fileId: string) => Promise; + scheduleCleanup: (fileId: string, delay?: number) => void; +} + +export interface FileContextValue extends FileContextState, FileContextActions {} + +export interface FileContextProviderProps { + children: React.ReactNode; + enableUrlSync?: boolean; + enablePersistence?: boolean; + maxCacheSize?: number; +} + +// Helper types for component props +export interface WithFileContext { + fileContext: FileContextValue; +} + +// URL parameter types for deep linking +export interface FileContextUrlParams { + view?: ViewType; + tool?: ToolType; + fileIds?: string[]; + pageIds?: string[]; + zoom?: number; + page?: number; +} \ No newline at end of file diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index 1bc9bf069..f0c28631a 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -1,5 +1,19 @@ import { getDocument } from "pdfjs-dist"; +/** + * Calculate thumbnail scale based on file size + * Smaller files get higher quality, larger files get lower quality + */ +export function calculateScaleFromFileSize(fileSize: number): number { + const MB = 1024 * 1024; + + if (fileSize < 1 * MB) return 0.6; // < 1MB: High quality + if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality + if (fileSize < 15 * MB) return 0.3; // 5-15MB: Medium quality + if (fileSize < 30 * MB) return 0.2; // 15-30MB: Low-medium quality + return 0.15; // 30MB+: Low quality +} + /** * Generate thumbnail for a PDF file during upload * Returns base64 data URL or undefined if generation fails @@ -14,6 +28,10 @@ export async function generateThumbnailForFile(file: File): Promise