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.
This commit is contained in:
Reece 2025-07-04 11:17:10 +01:00
parent 759055a96d
commit 2f9c88b000
15 changed files with 1805 additions and 417 deletions

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
// Import global styles // Import global styles
@ -9,7 +10,9 @@ import './index.css';
export default function App() { export default function App() {
return ( return (
<RainbowThemeProvider> <RainbowThemeProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<HomePage /> <HomePage />
</FileContextProvider>
</RainbowThemeProvider> </RainbowThemeProvider>
); );
} }

View File

@ -6,13 +6,15 @@ import {
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile'; import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import styles from './PageEditor.module.css'; import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from './FileThumbnail'; import FileThumbnail from '../pageEditor/FileThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel'; import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import DragDropGrid from './DragDropGrid'; import DragDropGrid from '../pageEditor/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal'; import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
interface FileItem { interface FileItem {
id: string; id: string;
@ -27,27 +29,31 @@ interface FileItem {
interface FileEditorProps { interface FileEditorProps {
onOpenPageEditor?: (file: File) => void; onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void; onMergeFiles?: (files: File[]) => void;
activeFiles?: File[];
setActiveFiles?: (files: File[]) => void;
preSelectedFiles?: { file: File; url: string }[];
onClearPreSelection?: () => void;
} }
const FileEditor = ({ const FileEditor = ({
onOpenPageEditor, onOpenPageEditor,
onMergeFiles, onMergeFiles
activeFiles = [],
setActiveFiles,
preSelectedFiles = [],
onClearPreSelection
}: FileEditorProps) => { }: FileEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Get file context
const fileContext = useFileContext();
const {
activeFiles,
processedFiles,
selectedFileIds,
setSelectedFiles: setContextSelectedFiles,
isProcessing,
addFiles,
removeFiles,
setCurrentView
} = fileContext;
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [localLoading, setLocalLoading] = useState(false);
const [csvInput, setCsvInput] = useState<string>(''); const [csvInput, setCsvInput] = useState<string>('');
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const [draggedFile, setDraggedFile] = useState<string | null>(null); const [draggedFile, setDraggedFile] = useState<string | null>(null);
@ -56,8 +62,14 @@ const FileEditor = ({
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false); const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [conversionProgress, setConversionProgress] = useState(0);
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const fileRefs = useRef<Map<string, HTMLDivElement>>(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 // Convert shared files to FileEditor format
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => { const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Generate thumbnail if not already available // 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(() => { useEffect(() => {
const convertActiveFiles = async () => { const convertActiveFiles = async () => {
if (activeFiles.length > 0) { if (activeFiles.length > 0) {
setLoading(true); setLocalLoading(true);
try { try {
const convertedFiles = await Promise.all( // Process files in chunks to avoid blocking UI
activeFiles.map(async (file) => { const convertedFiles: FileItem[] = [];
const thumbnail = await generateThumbnailForFile(file);
return { 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()}`, id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''), name: file.name.replace(/\.pdf$/i, ''),
pageCount: Math.floor(Math.random() * 20) + 1, // Mock for now pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
thumbnail, thumbnail,
size: file.size, size: file.size,
file, 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); setFiles(convertedFiles);
} catch (err) { } catch (err) {
console.error('Error converting active files:', err); console.error('Error converting active files:', err);
} finally { } finally {
setLoading(false); setLocalLoading(false);
setConversionProgress(0);
} }
} else { } else {
setFiles([]); setFiles([]);
setLocalLoading(false);
setConversionProgress(0);
} }
}; };
convertActiveFiles(); 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(); // Process uploaded files using context
}, [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
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setLoading(true);
setError(null); setError(null);
try { try {
const newFiles: FileItem[] = []; const validFiles = uploadedFiles.filter(file => {
for (const file of uploadedFiles) {
if (file.type !== 'application/pdf') { if (file.type !== 'application/pdf') {
setError('Please upload only PDF files'); setError('Please upload only PDF files');
continue; return false;
} }
return true;
});
// Generate thumbnail and get page count if (validFiles.length > 0) {
const thumbnail = await generateThumbnailForFile(file); // Add files to context (they will be processed automatically)
await addFiles(validFiles);
const fileItem: FileItem = { setStatus(`Added ${validFiles.length} files`);
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 (setActiveFiles) {
setActiveFiles(prev => [...prev, ...newFiles.map(f => f.file)]);
}
setStatus(`Added ${newFiles.length} files`);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage); setError(errorMessage);
console.error('File processing error:', err); console.error('File processing error:', err);
} finally {
setLoading(false);
} }
}, [setActiveFiles]); }, [addFiles]);
const selectAll = useCallback(() => { const selectAll = useCallback(() => {
setSelectedFiles(files.map(f => f.id)); setContextSelectedFiles(files.map(f => f.name)); // Use file name as ID for context
}, [files]); }, [files, setContextSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), []); const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
const toggleFile = useCallback((fileId: string) => { const toggleFile = useCallback((fileId: string) => {
setSelectedFiles(prev => const fileName = files.find(f => f.id === fileId)?.name || fileId;
prev.includes(fileId) setContextSelectedFiles(prev =>
? prev.filter(id => id !== fileId) prev.includes(fileName)
: [...prev, fileId] ? prev.filter(id => id !== fileName)
: [...prev, fileName]
); );
}, []); }, [files, setContextSelectedFiles]);
const toggleSelectionMode = useCallback(() => { const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => { setSelectionMode(prev => {
const newMode = !prev; const newMode = !prev;
if (!newMode) { if (!newMode) {
setSelectedFiles([]); setContextSelectedFiles([]);
setCsvInput(''); setCsvInput('');
} }
return newMode; return newMode;
}); });
}, []); }, [setContextSelectedFiles]);
const parseCSVInput = useCallback((csv: string) => { const parseCSVInput = useCallback((csv: string) => {
const fileIds: string[] = []; const fileNames: string[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => { ranges.forEach(range => {
@ -221,39 +211,39 @@ const FileEditor = ({
for (let i = start; i <= end && i <= files.length; i++) { for (let i = start; i <= end && i <= files.length; i++) {
if (i > 0) { if (i > 0) {
const file = files[i - 1]; const file = files[i - 1];
if (file) fileIds.push(file.id); if (file) fileNames.push(file.name);
} }
} }
} else { } else {
const fileIndex = parseInt(range); const fileIndex = parseInt(range);
if (fileIndex > 0 && fileIndex <= files.length) { if (fileIndex > 0 && fileIndex <= files.length) {
const file = files[fileIndex - 1]; const file = files[fileIndex - 1];
if (file) fileIds.push(file.id); if (file) fileNames.push(file.name);
} }
} }
}); });
return fileIds; return fileNames;
}, [files]); }, [files]);
const updateFilesFromCSV = useCallback(() => { const updateFilesFromCSV = useCallback(() => {
const fileIds = parseCSVInput(csvInput); const fileNames = parseCSVInput(csvInput);
setSelectedFiles(fileIds); setContextSelectedFiles(fileNames);
}, [csvInput, parseCSVInput]); }, [csvInput, parseCSVInput, setContextSelectedFiles]);
// Drag and drop handlers // Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => { const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId); setDraggedFile(fileId);
if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) { if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) {
setMultiFileDrag({ setMultiFileDrag({
fileIds: selectedFiles, fileIds: localSelectedFiles,
count: selectedFiles.length count: localSelectedFiles.length
}); });
} else { } else {
setMultiFileDrag(null); setMultiFileDrag(null);
} }
}, [selectionMode, selectedFiles]); }, [selectionMode, localSelectedFiles]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDraggedFile(null); setDraggedFile(null);
@ -314,11 +304,10 @@ const FileEditor = ({
if (targetIndex === -1) return; if (targetIndex === -1) return;
} }
const filesToMove = selectionMode && selectedFiles.includes(draggedFile) const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile)
? selectedFiles ? localSelectedFiles
: [draggedFile]; : [draggedFile];
if (setActiveFiles) {
// Update the local files state and sync with activeFiles // Update the local files state and sync with activeFiles
setFiles(prev => { setFiles(prev => {
const newFiles = [...prev]; const newFiles = [...prev];
@ -333,18 +322,15 @@ const FileEditor = ({
// Insert at target position // Insert at target position
newFiles.splice(targetIndex, 0, ...movedFiles); newFiles.splice(targetIndex, 0, ...movedFiles);
// Update activeFiles with the reordered File objects // TODO: Update context with reordered files (need to implement file reordering in context)
setActiveFiles(newFiles.map(f => f.file)); // For now, just return the reordered local state
return newFiles; return newFiles;
}); });
}
const moveCount = multiFileDrag ? multiFileDrag.count : 1; const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
handleDragEnd(); }, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]);
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setActiveFiles]);
const handleEndZoneDragEnter = useCallback(() => { const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) { if (draggedFile) {
@ -352,25 +338,26 @@ const FileEditor = ({
} }
}, [draggedFile]); }, [draggedFile]);
// File operations // File operations using context
const handleDeleteFile = useCallback((fileId: string) => { const handleDeleteFile = useCallback((fileId: string) => {
if (setActiveFiles) { const file = files.find(f => f.id === fileId);
// Remove from local files and sync with activeFiles if (file) {
setFiles(prev => { // Remove from context
const newFiles = prev.filter(f => f.id !== fileId); removeFiles([file.name]);
setActiveFiles(newFiles.map(f => f.file)); // Remove from context selections
return newFiles; setContextSelectedFiles(prev => prev.filter(id => id !== file.name));
});
} }
setSelectedFiles(prev => prev.filter(id => id !== fileId)); }, [files, removeFiles, setContextSelectedFiles]);
}, [setActiveFiles]);
const handleViewFile = useCallback((fileId: string) => { const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId); const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) { if (file) {
onOpenPageEditor(file.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 handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId); const startIndex = files.findIndex(f => f.id === fileId);
@ -392,7 +379,7 @@ const FileEditor = ({
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => { const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return; if (selectedFiles.length === 0) return;
setLoading(true); setLocalLoading(true);
try { try {
const convertedFiles = await Promise.all( const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem) selectedFiles.map(convertToFileItem)
@ -403,14 +390,14 @@ const FileEditor = ({
console.error('Error loading files from storage:', err); console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage'); setError('Failed to load some files from storage');
} finally { } finally {
setLoading(false); setLocalLoading(false);
} }
}, [convertToFileItem]); }, [convertToFileItem]);
return ( return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}> <Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading} /> <LoadingOverlay visible={false} />
<Box p="md" pt="xl"> <Box p="md" pt="xl">
<Group mb="md"> <Group mb="md">
@ -462,14 +449,51 @@ const FileEditor = ({
<BulkSelectionPanel <BulkSelectionPanel
csvInput={csvInput} csvInput={csvInput}
setCsvInput={setCsvInput} setCsvInput={setCsvInput}
selectedPages={selectedFiles} selectedPages={localSelectedFiles}
onUpdatePagesFromCSV={updateFilesFromCSV} onUpdatePagesFromCSV={updateFilesFromCSV}
/> />
)} )}
{files.length === 0 && !localLoading ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">Upload files or load from storage to get started</Text>
</Stack>
</Center>
) : files.length === 0 && localLoading ? (
<Box>
<SkeletonLoader type="controls" />
{/* Processing indicator */}
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Loading files...</Text>
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(conversionProgress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-blue-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<DragDropGrid <DragDropGrid
items={files} items={files}
selectedItems={selectedFiles} selectedItems={localSelectedFiles}
selectionMode={selectionMode} selectionMode={selectionMode}
isAnimating={isAnimating} isAnimating={isAnimating}
onDragStart={handleDragStart} onDragStart={handleDragStart}
@ -488,7 +512,7 @@ const FileEditor = ({
file={file} file={file}
index={index} index={index}
totalFiles={files.length} totalFiles={files.length}
selectedFiles={selectedFiles} selectedFiles={localSelectedFiles}
selectionMode={selectionMode} selectionMode={selectionMode}
draggedFile={draggedFile} draggedFile={draggedFile}
dropTarget={dropTarget} dropTarget={dropTarget}
@ -522,6 +546,7 @@ const FileEditor = ({
/> />
)} )}
/> />
)}
</Box> </Box>
{/* File Picker Modal */} {/* File Picker Modal */}

View File

@ -5,7 +5,7 @@ import {
Stack, Group Stack, Group
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEnhancedProcessedFiles } from "../../hooks/useEnhancedProcessedFiles"; import { useFileContext, useCurrentFile } from "../../contexts/FileContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo"; import { useUndoRedo } from "../../hooks/useUndoRedo";
@ -18,17 +18,14 @@ import {
} from "../../commands/pageCommands"; } from "../../commands/pageCommands";
import { pdfExportService } from "../../services/pdfExportService"; import { pdfExportService } from "../../services/pdfExportService";
import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration";
import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils";
import './pageEditor.module.css'; import './pageEditor.module.css';
import PageThumbnail from './PageThumbnail'; import PageThumbnail from './PageThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel'; import BulkSelectionPanel from './BulkSelectionPanel';
import DragDropGrid from './DragDropGrid'; import DragDropGrid from './DragDropGrid';
import SkeletonLoader from '../shared/SkeletonLoader';
export interface PageEditorProps { 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 // Optional callbacks to expose internal functions for PageEditorControls
onFunctionsReady?: (functions: { onFunctionsReady?: (functions: {
handleUndo: () => void; handleUndo: () => void;
@ -49,32 +46,42 @@ export interface PageEditorProps {
} }
const PageEditor = ({ const PageEditor = ({
activeFiles,
setActiveFiles,
onFunctionsReady, onFunctionsReady,
}: PageEditorProps) => { }: PageEditorProps) => {
const { t } = useTranslation(); 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 { const {
processedFiles: enhancedProcessedFiles, activeFiles,
processingStates, processedFiles,
selectedPageIds,
setSelectedPages,
isProcessing: globalProcessing, isProcessing: globalProcessing,
hasProcessingErrors,
processingProgress, processingProgress,
actions: processingActions clearAllFiles,
} = useEnhancedProcessedFiles(activeFiles, { getCurrentMergedDocument,
strategy: 'priority_pages', // Process first pages immediately setCurrentMergedDocument
thumbnailQuality: 'low', // Low quality for page editor navigation } = fileContext;
priorityPageCount: 10
// Use cached merged document from context instead of local state
const [filename, setFilename] = useState<string>("");
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 // Page editor state (use context for selectedPages)
const [mergedPdfDocument, setMergedPdfDocument] = useState<PDFDocument | null>(null);
const [filename, setFilename] = useState<string>("");
// Page editor state
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>(""); const [csvInput, setCsvInput] = useState<string>("");
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
@ -115,44 +122,85 @@ const PageEditor = ({
}; };
}, []); }, []);
// Merge multiple PDF documents into one // Merge multiple PDF documents into one (async to avoid blocking UI)
const mergeAllPDFs = useCallback(() => { const mergeAllPDFs = useCallback(async () => {
if (activeFiles.length === 0) { if (activeFiles.length === 0) {
setMergedPdfDocument(null); setIsMerging(false);
return; 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) { if (activeFiles.length === 1) {
// Single file - use enhanced processed file // Single file - use processed file from context
const enhancedFile = enhancedProcessedFiles.get(activeFiles[0]); const processedFile = processedFiles.get(activeFiles[0]);
if (enhancedFile) { if (processedFile) {
const pdfDoc = convertToPageEditorFormat(enhancedFile, activeFiles[0].name, activeFiles[0]); // Defer to next frame to avoid blocking
setMergedPdfDocument(pdfDoc); 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, '')); setFilename(activeFiles[0].name.replace(/\.pdf$/i, ''));
} }
} else { } else {
// Multiple files - merge them // Multiple files - merge them with chunked processing
const allPages: PDFPage[] = []; const allPages: PDFPage[] = [];
let totalPages = 0; let totalPages = 0;
const filenames: string[] = []; const filenames: string[] = [];
activeFiles.forEach((file, fileIndex) => { // Process files in chunks to avoid blocking UI
const enhancedFile = enhancedProcessedFiles.get(file); for (let i = 0; i < activeFiles.length; i++) {
if (enhancedFile) { const file = activeFiles[i];
const processedFile = processedFiles.get(file);
if (processedFile) {
filenames.push(file.name.replace(/\.pdf$/i, '')); filenames.push(file.name.replace(/\.pdf$/i, ''));
enhancedFile.pages.forEach((page, pageIndex) => {
// Create new page with updated IDs and page numbers for merged document // 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 = { const newPage: PDFPage = {
...page, ...page,
id: `${fileIndex}-${page.id}`, // Unique ID across all files id: `${i}-${page.id}`, // Unique ID across all files
pageNumber: totalPages + pageIndex + 1, pageNumber: totalPages + j + pageIndex + 1,
splitBefore: page.splitBefore || false splitBefore: page.splitBefore || false
}; };
allPages.push(newPage); allPages.push(newPage);
}); });
totalPages += enhancedFile.pages.length;
// 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) { if (allPages.length > 0) {
const mergedDocument: PDFDocument = { const mergedDocument: PDFDocument = {
@ -163,36 +211,57 @@ const PageEditor = ({
totalPages: totalPages totalPages: totalPages
}; };
setMergedPdfDocument(mergedDocument); // Cache the merged document
setCurrentMergedDocument(mergedDocument);
setFilename(filenames.join('_')); setFilename(filenames.join('_'));
} }
} }
}, [activeFiles, enhancedProcessedFiles, convertToPageEditorFormat]);
// Handle file upload from FileUploadSelector setIsMerging(false);
const handleMultipleFileUpload = useCallback((uploadedFiles: File[]) => { console.timeEnd('PageEditor: mergeAllPDFs');
}, [activeFiles, processedFiles]); // Removed function dependencies to prevent unnecessary re-runs
// Handle file upload from FileUploadSelector (now using context)
const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => {
if (!uploadedFiles || uploadedFiles.length === 0) { if (!uploadedFiles || uploadedFiles.length === 0) {
setStatus('No files provided'); setStatus('No files provided');
return; return;
} }
// Simply set the activeFiles to the selected files (same as existing approach) // Add files to context
setActiveFiles(uploadedFiles); await fileContext.addFiles(uploadedFiles);
setStatus(`Added ${uploadedFiles.length} file(s) for processing`); 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(() => { useEffect(() => {
const doMerge = async () => {
console.time('PageEditor: doMerge effect');
if (activeFiles.length > 0) { if (activeFiles.length > 0) {
const allProcessed = activeFiles.every(file => enhancedProcessedFiles.has(file)); const allProcessed = activeFiles.every(file => processedFiles.has(file));
if (allProcessed) { if (allProcessed) {
mergeAllPDFs(); console.log('PageEditor: All files processed, calling mergeAllPDFs');
await mergeAllPDFsRef.current();
} else {
console.log('PageEditor: Not all files processed yet');
} }
} else { } else {
setMergedPdfDocument(null); console.log('PageEditor: No active files');
} }
}, [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 // Shared PDF instance for thumbnail generation
const [sharedPdfInstance, setSharedPdfInstance] = useState<any>(null); const [sharedPdfInstance, setSharedPdfInstance] = useState<any>(null);
@ -209,7 +278,9 @@ const PageEditor = ({
// Start thumbnail generation process (separate from document loading) // Start thumbnail generation process (separate from document loading)
const startThumbnailGeneration = useCallback(() => { const startThumbnailGeneration = useCallback(() => {
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) return; if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) {
return;
}
const file = activeFiles[0]; const file = activeFiles[0];
const totalPages = mergedPdfDocument.totalPages; const totalPages = mergedPdfDocument.totalPages;
@ -225,12 +296,15 @@ const PageEditor = ({
// Generate all page numbers // Generate all page numbers
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); 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 // Start parallel thumbnail generation WITHOUT blocking the main thread
generateThumbnails( generateThumbnails(
arrayBuffer, arrayBuffer,
pageNumbers, pageNumbers,
{ {
scale: 0.2, // Low quality for page editor scale, // Dynamic quality based on file size
quality: 0.8, quality: 0.8,
batchSize: 15, // Smaller batches per worker for smoother UI batchSize: 15, // Smaller batches per worker for smoother UI
parallelBatches: 3 // Use 3 Web Workers in parallel parallelBatches: 3 // Use 3 Web Workers in parallel
@ -269,14 +343,20 @@ const PageEditor = ({
// Start thumbnail generation after document loads and UI settles // Start thumbnail generation after document loads and UI settles
useEffect(() => { 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 // 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); 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(() => { useEffect(() => {
return () => { return () => {
if (sharedPdfInstance) { if (sharedPdfInstance) {
@ -284,17 +364,17 @@ const PageEditor = ({
setSharedPdfInstance(null); setSharedPdfInstance(null);
} }
setThumbnailGenerationStarted(false); setThumbnailGenerationStarted(false);
// Stop generation but keep cache and workers alive for cross-tool persistence // DON'T stop generation on file changes - preserve cache for view switching
stopGeneration(); // stopGeneration();
}; };
}, [activeFiles, stopGeneration]); }, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles
// Clear selections when files change // Clear selections when files change
useEffect(() => { useEffect(() => {
setSelectedPages([]); setSelectedPages([]);
setCsvInput(""); setCsvInput("");
setSelectionMode(false); setSelectionMode(false);
}, [activeFiles]); }, [activeFiles, setSelectedPages]);
useEffect(() => { useEffect(() => {
const handleGlobalDragEnd = () => { const handleGlobalDragEnd = () => {
@ -325,9 +405,9 @@ const PageEditor = ({
if (mergedPdfDocument) { if (mergedPdfDocument) {
setSelectedPages(mergedPdfDocument.pages.map(p => p.id)); setSelectedPages(mergedPdfDocument.pages.map(p => p.id));
} }
}, [mergedPdfDocument]); }, [mergedPdfDocument, setSelectedPages]);
const deselectAll = useCallback(() => setSelectedPages([]), []); const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]);
const togglePage = useCallback((pageId: string) => { const togglePage = useCallback((pageId: string) => {
setSelectedPages(prev => setSelectedPages(prev =>
@ -335,7 +415,7 @@ const PageEditor = ({
? prev.filter(id => id !== pageId) ? prev.filter(id => id !== pageId)
: [...prev, pageId] : [...prev, pageId]
); );
}, []); }, [setSelectedPages]);
const toggleSelectionMode = useCallback(() => { const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => { setSelectionMode(prev => {
@ -385,15 +465,15 @@ const PageEditor = ({
setDraggedPage(pageId); setDraggedPage(pageId);
// Check if this is a multi-page drag in selection mode // 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({ setMultiPageDrag({
pageIds: selectedPages, pageIds: selectedPageIds,
count: selectedPages.length count: selectedPageIds.length
}); });
} else { } else {
setMultiPageDrag(null); setMultiPageDrag(null);
} }
}, [selectionMode, selectedPages]); }, [selectionMode, selectedPageIds]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
// Clean up drag state regardless of where the drop happened // Clean up drag state regardless of where the drop happened
@ -450,17 +530,18 @@ const PageEditor = ({
// Create setPdfDocument wrapper for merged document // Create setPdfDocument wrapper for merged document
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
setMergedPdfDocument(updatedDoc); // Update the cached merged document
setCurrentMergedDocument(updatedDoc);
// Return the updated document for immediate use in animations // Return the updated document for immediate use in animations
return updatedDoc; return updatedDoc;
}, []); }, [setCurrentMergedDocument]);
const animateReorder = useCallback((pageId: string, targetIndex: number) => { const animateReorder = useCallback((pageId: string, targetIndex: number) => {
if (!mergedPdfDocument || isAnimating) return; if (!mergedPdfDocument || isAnimating) return;
// In selection mode, if the dragged page is selected, move all selected pages // In selection mode, if the dragged page is selected, move all selected pages
const pagesToMove = selectionMode && selectedPages.includes(pageId) const pagesToMove = selectionMode && selectedPageIds.includes(pageId)
? selectedPages ? selectedPageIds
: [pageId]; : [pageId];
const originalIndex = mergedPdfDocument.pages.findIndex(p => p.id === pageId); const originalIndex = mergedPdfDocument.pages.findIndex(p => p.id === pageId);
@ -567,7 +648,7 @@ const PageEditor = ({
}); });
}); });
}, 10); // Small delay to allow state update }, 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') => { const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => {
e.preventDefault(); e.preventDefault();
@ -603,10 +684,10 @@ const PageEditor = ({
const rotation = direction === 'left' ? -90 : 90; const rotation = direction === 'left' ? -90 : 90;
const pagesToRotate = selectionMode const pagesToRotate = selectionMode
? selectedPages ? selectedPageIds
: mergedPdfDocument.pages.map(p => p.id); : mergedPdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return; if (selectionMode && selectedPageIds.length === 0) return;
const command = new RotatePagesCommand( const command = new RotatePagesCommand(
mergedPdfDocument, mergedPdfDocument,
@ -616,18 +697,18 @@ const PageEditor = ({
); );
executeCommand(command); executeCommand(command);
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length; const pageCount = selectionMode ? selectedPageIds.length : mergedPdfDocument.pages.length;
setStatus(`Rotated ${pageCount} pages ${direction}`); setStatus(`Rotated ${pageCount} pages ${direction}`);
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); }, [mergedPdfDocument, selectedPageIds, selectionMode, executeCommand, setPdfDocument]);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!mergedPdfDocument) return; if (!mergedPdfDocument) return;
const pagesToDelete = selectionMode const pagesToDelete = selectionMode
? selectedPages ? selectedPageIds
: mergedPdfDocument.pages.map(p => p.id); : mergedPdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return; if (selectionMode && selectedPageIds.length === 0) return;
const command = new DeletePagesCommand( const command = new DeletePagesCommand(
mergedPdfDocument, mergedPdfDocument,
@ -639,18 +720,18 @@ const PageEditor = ({
if (selectionMode) { if (selectionMode) {
setSelectedPages([]); setSelectedPages([]);
} }
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length; const pageCount = selectionMode ? selectedPageIds.length : mergedPdfDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`); setStatus(`Deleted ${pageCount} pages`);
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); }, [mergedPdfDocument, selectedPageIds, selectionMode, executeCommand, setPdfDocument, setSelectedPages]);
const handleSplit = useCallback(() => { const handleSplit = useCallback(() => {
if (!mergedPdfDocument) return; if (!mergedPdfDocument) return;
const pagesToSplit = selectionMode const pagesToSplit = selectionMode
? selectedPages ? selectedPageIds
: mergedPdfDocument.pages.map(p => p.id); : mergedPdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return; if (selectionMode && selectedPageIds.length === 0) return;
const command = new ToggleSplitCommand( const command = new ToggleSplitCommand(
mergedPdfDocument, mergedPdfDocument,
@ -659,25 +740,25 @@ const PageEditor = ({
); );
executeCommand(command); 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`); setStatus(`Split markers toggled for ${pageCount} pages`);
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); }, [mergedPdfDocument, selectedPageIds, selectionMode, executeCommand, setPdfDocument]);
const showExportPreview = useCallback((selectedOnly: boolean = false) => { const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return; if (!mergedPdfDocument) return;
const exportPageIds = selectedOnly ? selectedPages : []; const exportPageIds = selectedOnly ? selectedPageIds : [];
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
setExportPreview(preview); setExportPreview(preview);
setShowExportModal(true); setShowExportModal(true);
}, [mergedPdfDocument, selectedPages]); }, [mergedPdfDocument, selectedPageIds]);
const handleExport = useCallback(async (selectedOnly: boolean = false) => { const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return; if (!mergedPdfDocument) return;
setExportLoading(true); setExportLoading(true);
try { try {
const exportPageIds = selectedOnly ? selectedPages : []; const exportPageIds = selectedOnly ? selectedPageIds : [];
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
if (errors.length > 0) { if (errors.length > 0) {
setError(errors.join(', ')); setError(errors.join(', '));
@ -715,7 +796,7 @@ const PageEditor = ({
} finally { } finally {
setExportLoading(false); setExportLoading(false);
} }
}, [mergedPdfDocument, selectedPages, filename]); }, [mergedPdfDocument, selectedPageIds, filename]);
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
if (undo()) { if (undo()) {
@ -730,13 +811,9 @@ const PageEditor = ({
}, [redo]); }, [redo]);
const closePdf = useCallback(() => { const closePdf = useCallback(() => {
setActiveFiles([]); clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
setMergedPdfDocument(null);
setSelectedPages([]); setSelectedPages([]);
}, [clearAllFiles, setSelectedPages]);
// Only destroy thumbnails and workers on explicit PDF close
destroyThumbnails();
}, [setActiveFiles, destroyThumbnails]);
// PageEditorControls needs onExportSelected and onExportAll // PageEditorControls needs onExportSelected and onExportAll
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
@ -758,7 +835,7 @@ const PageEditor = ({
onExportAll, onExportAll,
exportLoading, exportLoading,
selectionMode, selectionMode,
selectedPages, selectedPages: selectedPageIds,
closePdf, closePdf,
}); });
} }
@ -776,67 +853,98 @@ const PageEditor = ({
onExportAll, onExportAll,
exportLoading, exportLoading,
selectionMode, selectionMode,
selectedPages, selectedPageIds,
closePdf closePdf
]); ]);
// Return early if no merged document - Homepage handles file selection // Show loading or empty state instead of blocking
if (!mergedPdfDocument) { const showLoading = !mergedPdfDocument && (globalProcessing || isMerging || activeFiles.length > 0);
return ( const showEmpty = !mergedPdfDocument && !globalProcessing && !isMerging && activeFiles.length === 0;
<Center h="100vh">
<LoadingOverlay visible={globalProcessing} /> // For large documents, implement pagination to avoid rendering too many components
{globalProcessing ? ( const isLargeDocument = mergedPdfDocument && mergedPdfDocument.pages.length > 200;
<Text c="dimmed">Processing PDF files...</Text> const [currentPageRange, setCurrentPageRange] = useState({ start: 0, end: 200 });
) : (
<Text c="dimmed">Waiting for PDF files...</Text> // Reset pagination when document changes
)} useEffect(() => {
</Center> setCurrentPageRange({ start: 0, end: 200 });
); }, [mergedPdfDocument]);
}
const displayedPages = isLargeDocument
? mergedPdfDocument.pages.slice(currentPageRange.start, currentPageRange.end)
: mergedPdfDocument?.pages || [];
return ( return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}> <Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} /> <LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
{showEmpty && (
<Center h="100vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📄</Text>
<Text c="dimmed">No PDF files loaded</Text>
<Text size="sm" c="dimmed">Add files to start editing pages</Text>
</Stack>
</Center>
)}
{showLoading && (
<Box p="md" pt="xl">
<SkeletonLoader type="controls" />
{/* Progress indicator */}
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>
{isMerging ? "Merging PDF documents..." : "Processing PDF files..."}
</Text>
<Text size="sm" c="dimmed">
{isMerging ? "" : `${Math.round(processingProgress || 0)}%`}
</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(processingProgress || 0)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-blue-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
<SkeletonLoader type="pageGrid" count={8} />
</Box>
)}
{mergedPdfDocument && (
<Box p="md" pt="xl"> <Box p="md" pt="xl">
{/* Enhanced Processing Status */} {/* Enhanced Processing Status */}
{(globalProcessing || hasProcessingErrors) && ( {globalProcessing && processingProgress < 100 && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}> <Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
{globalProcessing && (
<Group justify="space-between" mb="xs"> <Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Processing files...</Text> <Text size="sm" fw={500}>Processing thumbnails...</Text>
<Text size="sm" c="dimmed">{Math.round(processingProgress.overall)}%</Text> <Text size="sm" c="dimmed">{Math.round(processingProgress || 0)}%</Text>
</Group> </Group>
)} <div style={{
width: '100%',
{Array.from(processingStates.values()).map(state => ( height: '4px',
<Group key={state.fileKey} justify="space-between" mb={4}> backgroundColor: 'var(--mantine-color-gray-2)',
<Text size="xs">{state.fileName}</Text> borderRadius: '2px',
<Group gap="xs"> overflow: 'hidden'
<Text size="xs" c="dimmed">{state.progress}%</Text> }}>
{state.error && ( <div style={{
<Button width: `${Math.round(processingProgress || 0)}%`,
size="xs" height: '100%',
variant="light" backgroundColor: 'var(--mantine-color-blue-6)',
color="red" transition: 'width 0.3s ease'
onClick={() => { }} />
// Show error details or retry </div>
console.log('Processing error:', state.error);
}}
>
Error
</Button>
)}
</Group>
</Group>
))}
{hasProcessingErrors && (
<Text size="xs" c="red" mt="xs">
Some files failed to process. Check individual file status above.
</Text>
)}
</Box> </Box>
)} )}
@ -874,14 +982,49 @@ const PageEditor = ({
<BulkSelectionPanel <BulkSelectionPanel
csvInput={csvInput} csvInput={csvInput}
setCsvInput={setCsvInput} setCsvInput={setCsvInput}
selectedPages={selectedPages} selectedPages={selectedPageIds}
onUpdatePagesFromCSV={updatePagesFromCSV} onUpdatePagesFromCSV={updatePagesFromCSV}
/> />
)} )}
{isLargeDocument && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm" fw={500}>Large document detected ({mergedPdfDocument.pages.length} pages)</Text>
<Group gap="xs">
<Button
size="xs"
variant="light"
disabled={currentPageRange.start === 0}
onClick={() => setCurrentPageRange(prev => ({
start: Math.max(0, prev.start - 200),
end: Math.max(200, prev.end - 200)
}))}
>
Previous 200
</Button>
<Text size="xs" c="dimmed">
{currentPageRange.start + 1}-{Math.min(currentPageRange.end, mergedPdfDocument.pages.length)} of {mergedPdfDocument.pages.length}
</Text>
<Button
size="xs"
variant="light"
disabled={currentPageRange.end >= mergedPdfDocument.pages.length}
onClick={() => setCurrentPageRange(prev => ({
start: prev.start + 200,
end: Math.min(mergedPdfDocument.pages.length, prev.end + 200)
}))}
>
Next 200
</Button>
</Group>
</Group>
</Box>
)}
<DragDropGrid <DragDropGrid
items={mergedPdfDocument.pages} items={displayedPages}
selectedItems={selectedPages} selectedItems={selectedPageIds}
selectionMode={selectionMode} selectionMode={selectionMode}
isAnimating={isAnimating} isAnimating={isAnimating}
onDragStart={handleDragStart} onDragStart={handleDragStart}
@ -901,7 +1044,7 @@ const PageEditor = ({
index={index} index={index}
totalPages={mergedPdfDocument.pages.length} totalPages={mergedPdfDocument.pages.length}
originalFile={activeFiles.length === 1 ? activeFiles[0] : undefined} originalFile={activeFiles.length === 1 ? activeFiles[0] : undefined}
selectedPages={selectedPages} selectedPages={selectedPageIds}
selectionMode={selectionMode} selectionMode={selectionMode}
draggedPage={draggedPage} draggedPage={draggedPage}
dropTarget={dropTarget} dropTarget={dropTarget}
@ -941,9 +1084,10 @@ const PageEditor = ({
)} )}
/> />
</Box> </Box>
)}
{/* Modal should be outside the conditional but inside the main container */}
<Modal <Modal
opened={showExportModal} opened={showExportModal}
onClose={() => setShowExportModal(false)} onClose={() => setShowExportModal(false)}

View File

@ -80,11 +80,20 @@ const PageThumbnail = React.memo(({
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail); const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false); const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
// Listen for ready thumbnails from Web Workers (optimized) // Update thumbnail URL when page prop changes
useEffect(() => { 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 handleThumbnailReady = (event: CustomEvent) => {
const { pageNumber, thumbnail, pageId } = event.detail; const { pageNumber, thumbnail, pageId } = event.detail;
if (pageNumber === page.pageNumber && pageId === page.id && !thumbnailUrl) { if (pageNumber === page.pageNumber && pageId === page.id) {
setThumbnailUrl(thumbnail); setThumbnailUrl(thumbnail);
} }
}; };
@ -194,8 +203,8 @@ const PageThumbnail = React.memo(({
src={thumbnailUrl} src={thumbnailUrl}
alt={`Page ${page.pageNumber}`} alt={`Page ${page.pageNumber}`}
style={{ style={{
maxWidth: '100%', width: '100%',
maxHeight: '100%', height: '100%',
objectFit: 'contain', objectFit: 'contain',
borderRadius: 2, borderRadius: 2,
transform: `rotate(${page.rotation}deg)`, transform: `rotate(${page.rotation}deg)`,

View File

@ -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<SkeletonLoaderProps> = ({
type,
count = 8,
animated = true
}) => {
const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
const renderPageGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
w="100%"
h={240}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
}}
/>
))}
</div>
);
const renderFileGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
w="100%"
h={280}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
}}
/>
))}
</div>
);
const renderControlsSkeleton = () => (
<Group mb="md">
<Box w={150} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={120} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={100} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
</Group>
);
const renderViewerSkeleton = () => (
<Stack gap="md" h="100%">
{/* Toolbar skeleton */}
<Group>
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={80} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
</Group>
{/* Main content skeleton */}
<Box
flex={1}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle
}}
/>
</Stack>
);
switch (type) {
case 'pageGrid':
return renderPageGridSkeleton();
case 'fileGrid':
return renderFileGridSkeleton();
case 'controls':
return renderControlsSkeleton();
case 'viewer':
return renderViewerSkeleton();
default:
return null;
}
};
export default SkeletonLoader;

View File

@ -1,5 +1,5 @@
import React from "react"; import React, { useState, useCallback } from "react";
import { Button, SegmentedControl } from "@mantine/core"; import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider"; import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector"; import LanguageSelector from "./LanguageSelector";
import rainbowStyles from '../../styles/rainbow.module.css'; 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 FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
const VIEW_OPTIONS = [ // This will be created inside the component to access switchingTo
const createViewOptions = (switchingTo: string | null) => [
{ {
label: ( label: (
<Group gap={5}> <Group gap={5}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" /> <VisibilityIcon fontSize="small" />
)}
</Group> </Group>
), ),
value: "viewer", value: "viewer",
@ -23,7 +28,11 @@ const VIEW_OPTIONS = [
{ {
label: ( label: (
<Group gap={4}> <Group gap={4}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" /> <EditNoteIcon fontSize="small" />
)}
</Group> </Group>
), ),
value: "pageEditor", value: "pageEditor",
@ -31,7 +40,11 @@ const VIEW_OPTIONS = [
{ {
label: ( label: (
<Group gap={4}> <Group gap={4}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" /> <FolderIcon fontSize="small" />
)}
</Group> </Group>
), ),
value: "fileEditor", value: "fileEditor",
@ -48,6 +61,23 @@ const TopControls = ({
setCurrentView, setCurrentView,
}: TopControlsProps) => { }: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext(); const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<string | null>(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 = () => { const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />; if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
@ -80,14 +110,18 @@ const TopControls = ({
</div> </div>
<div className="flex justify-center items-center h-full pointer-events-auto"> <div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl <SegmentedControl
data={VIEW_OPTIONS} data={createViewOptions(switchingTo)}
value={currentView} value={currentView}
onChange={setCurrentView} onChange={handleViewChange}
color="blue" color="blue"
radius="xl" radius="xl"
size="md" size="md"
fullWidth fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''} className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
}}
/> />
</div> </div>
</div> </div>

View File

@ -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 DescriptionIcon from "@mui/icons-material/Description"; // for single page
import { useLocalStorage } from "@mantine/hooks"; import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage"; import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
GlobalWorkerOptions.workerSrc = "/pdf.worker.js"; GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
@ -414,9 +415,9 @@ const Viewer = ({
</Stack> </Stack>
</Center> </Center>
) : loading ? ( ) : loading ? (
<Center style={{ flex: 1 }}> <div style={{ flex: 1, padding: '1rem' }}>
<Loader size="lg" /> <SkeletonLoader type="viewer" />
</Center> </div>
) : ( ) : (
<ScrollArea <ScrollArea
style={{ flex: 1, height: "100vh", position: "relative"}} style={{ flex: 1, height: "100vh", position: "relative"}}

View File

@ -0,0 +1,766 @@
/**
* Global file context for managing files, edits, and navigation across all views and tools
*/
import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
FileContextValue,
FileContextState,
FileContextProviderProps,
ViewType,
ToolType,
FileOperation,
FileEditHistory,
ViewerConfig,
FileContextUrlParams
} from '../types/fileContext';
import { ProcessedFile } from '../types/processing';
import { PageOperation, PDFDocument } from '../types/pageEditor';
import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles';
import { fileStorage } from '../services/fileStorage';
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
// Initial state
const initialViewerConfig: ViewerConfig = {
zoom: 1.0,
currentPage: 1,
viewMode: 'single',
sidebarOpen: false
};
const initialState: FileContextState = {
activeFiles: [],
processedFiles: new Map(),
mergedDocuments: new Map(),
currentView: 'fileEditor',
currentTool: null,
fileEditHistory: new Map(),
globalFileOperations: [],
selectedFileIds: [],
selectedPageIds: [],
viewerConfig: initialViewerConfig,
isProcessing: false,
processingProgress: 0,
lastExportConfig: undefined
};
// Action types
type FileContextAction =
| { type: 'SET_ACTIVE_FILES'; payload: File[] }
| { type: 'ADD_FILES'; payload: File[] }
| { type: 'REMOVE_FILES'; payload: string[] }
| { type: 'SET_PROCESSED_FILES'; payload: Map<File, ProcessedFile> }
| { 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<ViewerConfig> }
| { 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<FileContextState> };
// 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<FileContextValue | undefined>(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<Map<string, NodeJS.Timeout>>(new Map());
const blobUrls = useRef<Set<string>>(new Set());
const pdfDocuments = useRef<Map<string, any>>(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<ViewerConfig>) => {
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 (
<FileContext.Provider value={contextValue}>
{children}
</FileContext.Provider>
);
}
// 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
};
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { ProcessedFile, ProcessingState, ProcessingConfig } from '../types/processing'; import { ProcessedFile, ProcessingState, ProcessingConfig } from '../types/processing';
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
import { FileHasher } from '../utils/fileHash'; import { FileHasher } from '../utils/fileHash';
@ -37,6 +37,7 @@ export function useEnhancedProcessedFiles(
config?: Partial<ProcessingConfig> config?: Partial<ProcessingConfig>
): UseEnhancedProcessedFilesResult { ): UseEnhancedProcessedFilesResult {
const [processedFiles, setProcessedFiles] = useState<Map<File, ProcessedFile>>(new Map()); const [processedFiles, setProcessedFiles] = useState<Map<File, ProcessedFile>>(new Map());
const fileHashMapRef = useRef<Map<File, string>>(new Map()); // Use ref to avoid state update loops
const [processingStates, setProcessingStates] = useState<Map<string, ProcessingState>>(new Map()); const [processingStates, setProcessingStates] = useState<Map<string, ProcessingState>>(new Map());
// Subscribe to processing state changes once // Subscribe to processing state changes once
@ -47,8 +48,13 @@ export function useEnhancedProcessedFiles(
// Process files when activeFiles changes // Process files when activeFiles changes
useEffect(() => { useEffect(() => {
console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files');
if (activeFiles.length === 0) { if (activeFiles.length === 0) {
console.log('useEnhancedProcessedFiles: No active files, clearing processed cache');
setProcessedFiles(new Map()); setProcessedFiles(new Map());
// Clear any ongoing processing when no files
enhancedPDFProcessingService.clearAllProcessing();
return; return;
} }
@ -56,38 +62,47 @@ export function useEnhancedProcessedFiles(
const newProcessedFiles = new Map<File, ProcessedFile>(); const newProcessedFiles = new Map<File, ProcessedFile>();
for (const file of activeFiles) { for (const file of activeFiles) {
// Check if we already have this file processed // Generate hash for this file
const existing = processedFiles.get(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) { if (existing) {
newProcessedFiles.set(file, existing); newProcessedFiles.set(file, existing);
continue; continue;
} }
try { 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); const processed = await enhancedPDFProcessingService.processFile(file, config);
if (processed) { if (processed) {
console.log('Got processed file for:', file.name);
newProcessedFiles.set(file, processed); newProcessedFiles.set(file, processed);
} else {
console.log('Processing started for:', file.name, '- waiting for completion');
} }
} catch (error) { } catch (error) {
console.error(`Failed to start processing for ${file.name}:`, error); console.error(`Failed to start processing for ${file.name}:`, error);
} }
} }
// Update processed files if we have any // Update processed files (hash mapping is updated via ref)
if (newProcessedFiles.size > 0) { if (newProcessedFiles.size > 0 || processedFiles.size > 0) {
setProcessedFiles(newProcessedFiles); setProcessedFiles(newProcessedFiles);
} }
}; };
processFiles(); processFiles();
}, [activeFiles]); }, [activeFiles]); // Only depend on activeFiles to avoid infinite loops
// Listen for processing completion // Listen for processing completion
useEffect(() => { useEffect(() => {
@ -114,7 +129,6 @@ export function useEnhancedProcessedFiles(
try { try {
const processed = await enhancedPDFProcessingService.processFile(file, config); const processed = await enhancedPDFProcessingService.processFile(file, config);
if (processed) { if (processed) {
console.log('Processing completed for:', file.name);
updatedFiles.set(file, processed); updatedFiles.set(file, processed);
hasNewFiles = true; hasNewFiles = true;
} }
@ -189,6 +203,13 @@ export function useEnhancedProcessedFiles(
} }
}; };
// Cleanup on unmount
useEffect(() => {
return () => {
enhancedPDFProcessingService.clearAllProcessing();
};
}, []);
return { return {
processedFiles, processedFiles,
processingStates, processingStates,

View File

@ -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
};
}

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useToolParams } from "../hooks/useToolParams"; import { useToolParams } from "../hooks/useToolParams";
import { useFileWithUrl } from "../hooks/useFileWithUrl"; import { useFileWithUrl } from "../hooks/useFileWithUrl";
import { useFileContext } from "../contexts/FileContext";
import { fileStorage } from "../services/fileStorage"; import { fileStorage } from "../services/fileStorage";
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import ContentCutIcon from "@mui/icons-material/ContentCut"; 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 ToolPicker from "../components/tools/ToolPicker";
import TopControls from "../components/shared/TopControls"; 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 PageEditor from "../components/pageEditor/PageEditor";
import PageEditorControls from "../components/pageEditor/PageEditorControls"; import PageEditorControls from "../components/pageEditor/PageEditorControls";
import Viewer from "../components/viewer/Viewer"; import Viewer from "../components/viewer/Viewer";
@ -48,13 +49,15 @@ export default function HomePage() {
const theme = useMantineTheme(); const theme = useMantineTheme();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
// Get file context
const fileContext = useFileContext();
const { activeFiles, currentView, setCurrentView, addFiles } = fileContext;
// Core app state // Core app state
const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("t") || "split"); const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("t") || "split");
const [currentView, setCurrentView] = useState<string>(searchParams.get("v") || "viewer");
// File state separation // File state separation
const [storedFiles, setStoredFiles] = useState<any[]>([]); // IndexedDB files (FileManager) const [storedFiles, setStoredFiles] = useState<any[]>([]); // IndexedDB files (FileManager)
const [activeFiles, setActiveFiles] = useState<File[]>([]); // Active working set (persisted)
const [preSelectedFiles, setPreSelectedFiles] = useState([]); const [preSelectedFiles, setPreSelectedFiles] = useState([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null); const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
@ -123,7 +126,7 @@ export default function HomePage() {
setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected
setReaderMode(false); // Exit reader mode when selecting a tool setReaderMode(false); // Exit reader mode when selecting a tool
}, },
[toolRegistry] [toolRegistry, setCurrentView]
); );
// Handle quick access actions // Handle quick access actions
@ -138,33 +141,31 @@ export default function HomePage() {
// Update URL when view changes // Update URL when view changes
const handleViewChange = useCallback((view: string) => { const handleViewChange = useCallback((view: string) => {
setCurrentView(view); setCurrentView(view as any);
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
params.set('view', view); params.set('view', view);
const newUrl = `${window.location.pathname}?${params.toString()}`; const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl); window.history.replaceState({}, '', newUrl);
}, []); }, [setCurrentView]);
// Active file management // Active file management using context
const addToActiveFiles = useCallback((file: File) => { const addToActiveFiles = useCallback(async (file: File) => {
setActiveFiles(prev => { // Check if file already exists
// Avoid duplicates based on name and size const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
const exists = prev.some(f => f.name === file.name && f.size === file.size); if (!exists) {
if (exists) return prev; await addFiles([file]);
return [file, ...prev]; }
}); }, [activeFiles, addFiles]);
}, []);
const removeFromActiveFiles = useCallback((file: File) => { 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) => { const setCurrentActiveFile = useCallback(async (file: File) => {
setActiveFiles(prev => { // Remove if exists, then add to front
const filtered = prev.filter(f => !(f.name === file.name && f.size === file.size)); const filtered = activeFiles.filter(f => !(f.name === file.name && f.size === file.size));
return [file, ...filtered]; await addFiles([file, ...filtered]);
}); }, [activeFiles, addFiles]);
}, []);
// Handle file selection from upload (adds to active files) // Handle file selection from upload (adds to active files)
const handleFileSelect = useCallback((file: File) => { const handleFileSelect = useCallback((file: File) => {
@ -213,13 +214,13 @@ export default function HomePage() {
// Filter out nulls and add to activeFiles // Filter out nulls and add to activeFiles
const validFiles = convertedFiles.filter((f): f is File => f !== null); const validFiles = convertedFiles.filter((f): f is File => f !== null);
setActiveFiles(validFiles); await addFiles(validFiles);
setPreSelectedFiles([]); // Clear preselected since we're using activeFiles now setPreSelectedFiles([]); // Clear preselected since we're using activeFiles now
handleViewChange("fileEditor"); handleViewChange("fileEditor");
} catch (error) { } catch (error) {
console.error('Error converting selected files:', error); console.error('Error converting selected files:', error);
} }
}, [handleViewChange, setActiveFiles]); }, [handleViewChange, addFiles]);
// Handle opening page editor with selected files // Handle opening page editor with selected files
const handleOpenPageEditor = useCallback(async (selectedFiles) => { const handleOpenPageEditor = useCallback(async (selectedFiles) => {
@ -262,12 +263,12 @@ export default function HomePage() {
// Filter out nulls and add to activeFiles // Filter out nulls and add to activeFiles
const validFiles = convertedFiles.filter((f): f is File => f !== null); const validFiles = convertedFiles.filter((f): f is File => f !== null);
setActiveFiles(validFiles); await addFiles(validFiles);
handleViewChange("pageEditor"); handleViewChange("pageEditor");
} catch (error) { } catch (error) {
console.error('Error converting selected files for page editor:', error); console.error('Error converting selected files for page editor:', error);
} }
}, [handleViewChange, setActiveFiles]); }, [handleViewChange, addFiles]);
const selectedTool = toolRegistry[selectedToolKey]; const selectedTool = toolRegistry[selectedToolKey];
@ -374,7 +375,12 @@ export default function HomePage() {
setCurrentView={handleViewChange} setCurrentView={handleViewChange}
/> />
{/* Main content area */} {/* Main content area */}
<Box className="flex-1 min-h-0 margin-top-200 relative z-10"> <Box
className="flex-1 min-h-0 margin-top-200 relative z-10"
style={{
transition: 'opacity 0.15s ease-in-out',
}}
>
{!activeFiles[0] ? ( {!activeFiles[0] ? (
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FileUploadSelector <FileUploadSelector
@ -398,10 +404,6 @@ export default function HomePage() {
</Container> </Container>
) : currentView === "fileEditor" ? ( ) : currentView === "fileEditor" ? (
<FileEditor <FileEditor
activeFiles={activeFiles}
setActiveFiles={setActiveFiles}
preSelectedFiles={preSelectedFiles}
onClearPreSelection={() => setPreSelectedFiles([])}
onOpenPageEditor={(file) => { onOpenPageEditor={(file) => {
setCurrentActiveFile(file); setCurrentActiveFile(file);
handleViewChange("pageEditor"); handleViewChange("pageEditor");
@ -419,7 +421,7 @@ export default function HomePage() {
if (fileObj) { if (fileObj) {
setCurrentActiveFile(fileObj.file); setCurrentActiveFile(fileObj.file);
} else { } else {
setActiveFiles([]); fileContext.clearAllFiles();
} }
}} }}
sidebarsVisible={sidebarsVisible} sidebarsVisible={sidebarsVisible}
@ -428,14 +430,9 @@ export default function HomePage() {
) : currentView === "pageEditor" ? ( ) : currentView === "pageEditor" ? (
<> <>
<PageEditor <PageEditor
activeFiles={activeFiles}
setActiveFiles={setActiveFiles}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
sharedFiles={storedFiles}
onFunctionsReady={setPageEditorFunctions} onFunctionsReady={setPageEditorFunctions}
/> />
{activeFiles[0] && pageEditorFunctions && ( {pageEditorFunctions && (
<PageEditorControls <PageEditorControls
onClosePdf={pageEditorFunctions.closePdf} onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo} onUndo={pageEditorFunctions.handleUndo}
@ -454,14 +451,23 @@ export default function HomePage() {
)} )}
</> </>
) : ( ) : (
<FileManager <Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
files={storedFiles} <FileUploadSelector
setFiles={setStoredFiles} title="File Management"
setCurrentView={handleViewChange} subtitle="Choose files from storage or upload new PDFs"
onOpenFileEditor={handleOpenFileEditor} sharedFiles={storedFiles}
onOpenPageEditor={handleOpenPageEditor} onFileSelect={(file) => {
onLoadFileToActive={addToActiveFiles} addToActiveFiles(file);
}}
onFilesSelect={(files) => {
files.forEach(addToActiveFiles);
}}
accept={["application/pdf"]}
loading={false}
showRecentFiles={true}
maxRecentFiles={8}
/> />
</Container>
)} )}
</Box> </Box>
</Box> </Box>

View File

@ -504,6 +504,27 @@ export class EnhancedPDFProcessingService {
this.notifyListeners(); 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 * Get cache statistics
*/ */

30
frontend/src/styles/skeleton.css vendored Normal file
View File

@ -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;
}

View File

@ -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<File, ProcessedFile>;
// Cached merged documents (for PageEditor performance)
mergedDocuments: Map<string, PDFDocument>;
// Current navigation state
currentView: ViewType;
currentTool: ToolType;
// Edit history and state
fileEditHistory: Map<string, FileEditHistory>;
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<void>;
removeFiles: (fileIds: string[]) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
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<ViewerConfig>) => 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<void>;
loadContext: () => Promise<void>;
resetContext: () => void;
// Memory management
trackBlobUrl: (url: string) => void;
trackPdfDocument: (fileId: string, pdfDoc: any) => void;
cleanupFile: (fileId: string) => Promise<void>;
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;
}

View File

@ -1,5 +1,19 @@
import { getDocument } from "pdfjs-dist"; 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 * Generate thumbnail for a PDF file during upload
* Returns base64 data URL or undefined if generation fails * Returns base64 data URL or undefined if generation fails
@ -14,6 +28,10 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
try { try {
console.log('Generating thumbnail for', file.name); console.log('Generating thumbnail for', file.name);
// Calculate quality scale based on file size
const scale = calculateScaleFromFileSize(file.size);
console.log(`Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
// Only read first 2MB for thumbnail generation to save memory // Only read first 2MB for thumbnail generation to save memory
const chunkSize = 2 * 1024 * 1024; // 2MB const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size)); const chunk = file.slice(0, Math.min(chunkSize, file.size));
@ -26,7 +44,7 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
}).promise; }).promise;
const page = await pdf.getPage(1); const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 0.2 }); // Smaller scale for memory efficiency const viewport = page.getViewport({ scale }); // Dynamic scale based on file size
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = viewport.width; canvas.width = viewport.width;
canvas.height = viewport.height; canvas.height = viewport.height;
@ -45,7 +63,45 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
return thumbnail; return thumbnail;
} catch (error) { } catch (error) {
if (error instanceof Error) {
if (error.name === 'InvalidPDFException') {
console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`);
// Return a placeholder or try with full file instead of chunk
try {
const fullArrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({
data: fullArrayBuffer,
disableAutoFetch: true,
disableStream: true,
verbosity: 0 // Reduce PDF.js warnings
}).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
pdf.destroy();
return thumbnail;
} catch (fallbackError) {
console.warn('Fallback thumbnail generation also failed for', file.name, fallbackError);
return undefined;
}
} else {
console.warn('Failed to generate thumbnail for', file.name, error); console.warn('Failed to generate thumbnail for', file.name, error);
return undefined; return undefined;
} }
} }
console.warn('Unknown error generating thumbnail for', file.name, error);
return undefined;
}
}