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

View File

@ -6,13 +6,15 @@ import {
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import styles from './PageEditor.module.css';
import FileThumbnail from './FileThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel';
import DragDropGrid from './DragDropGrid';
import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import DragDropGrid from '../pageEditor/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
interface FileItem {
id: string;
@ -27,27 +29,31 @@ interface FileItem {
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void;
activeFiles?: File[];
setActiveFiles?: (files: File[]) => void;
preSelectedFiles?: { file: File; url: string }[];
onClearPreSelection?: () => void;
}
const FileEditor = ({
onOpenPageEditor,
onMergeFiles,
activeFiles = [],
setActiveFiles,
preSelectedFiles = [],
onClearPreSelection
onMergeFiles
}: FileEditorProps) => {
const { t } = useTranslation();
// Get file context
const fileContext = useFileContext();
const {
activeFiles,
processedFiles,
selectedFileIds,
setSelectedFiles: setContextSelectedFiles,
isProcessing,
addFiles,
removeFiles,
setCurrentView
} = fileContext;
const [files, setFiles] = useState<FileItem[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [localLoading, setLocalLoading] = useState(false);
const [csvInput, setCsvInput] = useState<string>('');
const [selectionMode, setSelectionMode] = useState(false);
const [draggedFile, setDraggedFile] = useState<string | null>(null);
@ -56,8 +62,14 @@ const FileEditor = ({
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [conversionProgress, setConversionProgress] = useState(0);
const fileRefs = useRef<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
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Generate thumbnail if not already available
@ -73,146 +85,124 @@ const FileEditor = ({
};
}, []);
// Convert activeFiles to FileItem format
// Convert activeFiles to FileItem format using context (async to avoid blocking)
useEffect(() => {
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
setLoading(true);
setLocalLoading(true);
try {
const convertedFiles = await Promise.all(
activeFiles.map(async (file) => {
const thumbnail = await generateThumbnailForFile(file);
return {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''),
pageCount: Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: file.size,
file,
};
})
);
// Process files in chunks to avoid blocking UI
const convertedFiles: FileItem[] = [];
for (let i = 0; i < activeFiles.length; i++) {
const file = activeFiles[i];
// Try to get thumbnail from processed file first
const processedFile = processedFiles.get(file);
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
// If no thumbnail from processed file, try to generate one
if (!thumbnail) {
try {
thumbnail = await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
thumbnail = undefined; // Use placeholder
}
}
const convertedFile = {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''),
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
thumbnail,
size: file.size,
file,
};
convertedFiles.push(convertedFile);
// Update progress
setConversionProgress(((i + 1) / activeFiles.length) * 100);
// Yield to main thread between files
if (i < activeFiles.length - 1) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting active files:', err);
} finally {
setLoading(false);
setLocalLoading(false);
setConversionProgress(0);
}
} else {
setFiles([]);
setLocalLoading(false);
setConversionProgress(0);
}
};
convertActiveFiles();
}, [activeFiles]);
}, [activeFiles, processedFiles]);
// Only load shared files when explicitly passed (not on mount)
useEffect(() => {
const loadSharedFiles = async () => {
// Only load if we have pre-selected files (coming from FileManager)
if (preSelectedFiles.length > 0) {
setLoading(true);
try {
const convertedFiles = await Promise.all(
preSelectedFiles.map(convertToFileItem)
);
if (setActiveFiles) {
const updatedActiveFiles = convertedFiles.map(fileItem => fileItem.file);
setActiveFiles(updatedActiveFiles);
}
} catch (err) {
console.error('Error converting pre-selected files:', err);
} finally {
setLoading(false);
}
}
};
loadSharedFiles();
}, [preSelectedFiles, convertToFileItem]);
// Handle pre-selected files
useEffect(() => {
if (preSelectedFiles.length > 0) {
const preSelectedIds = preSelectedFiles.map(f => f.id || f.name);
setSelectedFiles(preSelectedIds);
onClearPreSelection?.();
}
}, [preSelectedFiles, onClearPreSelection]);
// Process uploaded files
// Process uploaded files using context
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setLoading(true);
setError(null);
try {
const newFiles: FileItem[] = [];
for (const file of uploadedFiles) {
const validFiles = uploadedFiles.filter(file => {
if (file.type !== 'application/pdf') {
setError('Please upload only PDF files');
continue;
return false;
}
return true;
});
// Generate thumbnail and get page count
const thumbnail = await generateThumbnailForFile(file);
const fileItem: FileItem = {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''),
pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count
thumbnail,
size: file.size,
file,
};
newFiles.push(fileItem);
// Store in IndexedDB
await fileStorage.storeFile(file, thumbnail);
if (validFiles.length > 0) {
// Add files to context (they will be processed automatically)
await addFiles(validFiles);
setStatus(`Added ${validFiles.length} files`);
}
if (setActiveFiles) {
setActiveFiles(prev => [...prev, ...newFiles.map(f => f.file)]);
}
setStatus(`Added ${newFiles.length} files`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
console.error('File processing error:', err);
} finally {
setLoading(false);
}
}, [setActiveFiles]);
}, [addFiles]);
const selectAll = useCallback(() => {
setSelectedFiles(files.map(f => f.id));
}, [files]);
setContextSelectedFiles(files.map(f => f.name)); // Use file name as ID for context
}, [files, setContextSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), []);
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
const toggleFile = useCallback((fileId: string) => {
setSelectedFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
const fileName = files.find(f => f.id === fileId)?.name || fileId;
setContextSelectedFiles(prev =>
prev.includes(fileName)
? prev.filter(id => id !== fileName)
: [...prev, fileName]
);
}, []);
}, [files, setContextSelectedFiles]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setSelectedFiles([]);
setContextSelectedFiles([]);
setCsvInput('');
}
return newMode;
});
}, []);
}, [setContextSelectedFiles]);
const parseCSVInput = useCallback((csv: string) => {
const fileIds: string[] = [];
const fileNames: string[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
@ -221,39 +211,39 @@ const FileEditor = ({
for (let i = start; i <= end && i <= files.length; i++) {
if (i > 0) {
const file = files[i - 1];
if (file) fileIds.push(file.id);
if (file) fileNames.push(file.name);
}
}
} else {
const fileIndex = parseInt(range);
if (fileIndex > 0 && fileIndex <= files.length) {
const file = files[fileIndex - 1];
if (file) fileIds.push(file.id);
if (file) fileNames.push(file.name);
}
}
});
return fileIds;
return fileNames;
}, [files]);
const updateFilesFromCSV = useCallback(() => {
const fileIds = parseCSVInput(csvInput);
setSelectedFiles(fileIds);
}, [csvInput, parseCSVInput]);
const fileNames = parseCSVInput(csvInput);
setContextSelectedFiles(fileNames);
}, [csvInput, parseCSVInput, setContextSelectedFiles]);
// Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId);
if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) {
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) {
setMultiFileDrag({
fileIds: selectedFiles,
count: selectedFiles.length
fileIds: localSelectedFiles,
count: localSelectedFiles.length
});
} else {
setMultiFileDrag(null);
}
}, [selectionMode, selectedFiles]);
}, [selectionMode, localSelectedFiles]);
const handleDragEnd = useCallback(() => {
setDraggedFile(null);
@ -314,37 +304,33 @@ const FileEditor = ({
if (targetIndex === -1) return;
}
const filesToMove = selectionMode && selectedFiles.includes(draggedFile)
? selectedFiles
const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile)
? localSelectedFiles
: [draggedFile];
if (setActiveFiles) {
// Update the local files state and sync with activeFiles
setFiles(prev => {
const newFiles = [...prev];
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
// Update the local files state and sync with activeFiles
setFiles(prev => {
const newFiles = [...prev];
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
// Remove moved files
filesToMove.forEach(id => {
const index = newFiles.findIndex(f => f.id === id);
if (index !== -1) newFiles.splice(index, 1);
});
// Insert at target position
newFiles.splice(targetIndex, 0, ...movedFiles);
// Update activeFiles with the reordered File objects
setActiveFiles(newFiles.map(f => f.file));
return newFiles;
// Remove moved files
filesToMove.forEach(id => {
const index = newFiles.findIndex(f => f.id === id);
if (index !== -1) newFiles.splice(index, 1);
});
}
// Insert at target position
newFiles.splice(targetIndex, 0, ...movedFiles);
// TODO: Update context with reordered files (need to implement file reordering in context)
// For now, just return the reordered local state
return newFiles;
});
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
handleDragEnd();
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setActiveFiles]);
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
@ -352,25 +338,26 @@ const FileEditor = ({
}
}, [draggedFile]);
// File operations
// File operations using context
const handleDeleteFile = useCallback((fileId: string) => {
if (setActiveFiles) {
// Remove from local files and sync with activeFiles
setFiles(prev => {
const newFiles = prev.filter(f => f.id !== fileId);
setActiveFiles(newFiles.map(f => f.file));
return newFiles;
});
const file = files.find(f => f.id === fileId);
if (file) {
// Remove from context
removeFiles([file.name]);
// Remove from context selections
setContextSelectedFiles(prev => prev.filter(id => id !== file.name));
}
setSelectedFiles(prev => prev.filter(id => id !== fileId));
}, [setActiveFiles]);
}, [files, removeFiles, setContextSelectedFiles]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
if (file) {
// Set the file as selected in context and switch to page editor view
setContextSelectedFiles([file.name]);
setCurrentView('pageEditor');
onOpenPageEditor?.(file.file);
}
}, [files, onOpenPageEditor]);
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
@ -392,7 +379,7 @@ const FileEditor = ({
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return;
setLoading(true);
setLocalLoading(true);
try {
const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem)
@ -403,14 +390,14 @@ const FileEditor = ({
console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage');
} finally {
setLoading(false);
setLocalLoading(false);
}
}, [convertToFileItem]);
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading} />
<LoadingOverlay visible={false} />
<Box p="md" pt="xl">
<Group mb="md">
@ -462,16 +449,53 @@ const FileEditor = ({
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedFiles}
selectedPages={localSelectedFiles}
onUpdatePagesFromCSV={updateFilesFromCSV}
/>
)}
<DragDropGrid
items={files}
selectedItems={selectedFiles}
selectionMode={selectionMode}
isAnimating={isAnimating}
{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
items={files}
selectedItems={localSelectedFiles}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
@ -488,7 +512,7 @@ const FileEditor = ({
file={file}
index={index}
totalFiles={files.length}
selectedFiles={selectedFiles}
selectedFiles={localSelectedFiles}
selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
@ -522,6 +546,7 @@ const FileEditor = ({
/>
)}
/>
)}
</Box>
{/* File Picker Modal */}

View File

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

View File

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

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 { Button, SegmentedControl } from "@mantine/core";
import React, { useState, useCallback } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
import rainbowStyles from '../../styles/rainbow.module.css';
@ -11,11 +11,16 @@ import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
const VIEW_OPTIONS = [
// This will be created inside the component to access switchingTo
const createViewOptions = (switchingTo: string | null) => [
{
label: (
<Group gap={5}>
<VisibilityIcon fontSize="small" />
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
</Group>
),
value: "viewer",
@ -23,7 +28,11 @@ const VIEW_OPTIONS = [
{
label: (
<Group gap={4}>
<EditNoteIcon fontSize="small" />
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
</Group>
),
value: "pageEditor",
@ -31,7 +40,11 @@ const VIEW_OPTIONS = [
{
label: (
<Group gap={4}>
<FolderIcon fontSize="small" />
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
</Group>
),
value: "fileEditor",
@ -48,6 +61,23 @@ const TopControls = ({
setCurrentView,
}: TopControlsProps) => {
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 = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
@ -80,14 +110,18 @@ const TopControls = ({
</div>
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={VIEW_OPTIONS}
data={createViewOptions(switchingTo)}
value={currentView}
onChange={setCurrentView}
onChange={handleViewChange}
color="blue"
radius="xl"
size="md"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
}}
/>
</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 { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
@ -414,9 +415,9 @@ const Viewer = ({
</Stack>
</Center>
) : loading ? (
<Center style={{ flex: 1 }}>
<Loader size="lg" />
</Center>
<div style={{ flex: 1, padding: '1rem' }}>
<SkeletonLoader type="viewer" />
</div>
) : (
<ScrollArea
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 { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
import { FileHasher } from '../utils/fileHash';
@ -37,6 +37,7 @@ export function useEnhancedProcessedFiles(
config?: Partial<ProcessingConfig>
): UseEnhancedProcessedFilesResult {
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());
// Subscribe to processing state changes once
@ -47,8 +48,13 @@ export function useEnhancedProcessedFiles(
// Process files when activeFiles changes
useEffect(() => {
console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files');
if (activeFiles.length === 0) {
console.log('useEnhancedProcessedFiles: No active files, clearing processed cache');
setProcessedFiles(new Map());
// Clear any ongoing processing when no files
enhancedPDFProcessingService.clearAllProcessing();
return;
}
@ -56,38 +62,47 @@ export function useEnhancedProcessedFiles(
const newProcessedFiles = new Map<File, ProcessedFile>();
for (const file of activeFiles) {
// Check if we already have this file processed
const existing = processedFiles.get(file);
// Generate hash for this file
const fileHash = await FileHasher.generateHybridHash(file);
fileHashMapRef.current.set(file, fileHash);
// First, check if we have this exact File object cached
let existing = processedFiles.get(file);
// If not found by File object, try to find by hash in case File was recreated
if (!existing) {
for (const [cachedFile, processed] of processedFiles.entries()) {
const cachedHash = fileHashMapRef.current.get(cachedFile);
if (cachedHash === fileHash) {
existing = processed;
break;
}
}
}
if (existing) {
newProcessedFiles.set(file, existing);
continue;
}
try {
// Generate proper file key matching the service
const fileKey = await FileHasher.generateHybridHash(file);
console.log('Processing file:', file.name);
const processed = await enhancedPDFProcessingService.processFile(file, config);
if (processed) {
console.log('Got processed file for:', file.name);
newProcessedFiles.set(file, processed);
} else {
console.log('Processing started for:', file.name, '- waiting for completion');
}
} catch (error) {
console.error(`Failed to start processing for ${file.name}:`, error);
}
}
// Update processed files if we have any
if (newProcessedFiles.size > 0) {
// Update processed files (hash mapping is updated via ref)
if (newProcessedFiles.size > 0 || processedFiles.size > 0) {
setProcessedFiles(newProcessedFiles);
}
};
processFiles();
}, [activeFiles]);
}, [activeFiles]); // Only depend on activeFiles to avoid infinite loops
// Listen for processing completion
useEffect(() => {
@ -114,7 +129,6 @@ export function useEnhancedProcessedFiles(
try {
const processed = await enhancedPDFProcessingService.processFile(file, config);
if (processed) {
console.log('Processing completed for:', file.name);
updatedFiles.set(file, processed);
hasNewFiles = true;
}
@ -189,6 +203,13 @@ export function useEnhancedProcessedFiles(
}
};
// Cleanup on unmount
useEffect(() => {
return () => {
enhancedPDFProcessingService.clearAllProcessing();
};
}, []);
return {
processedFiles,
processingStates,

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

View File

@ -504,6 +504,27 @@ export class EnhancedPDFProcessingService {
this.notifyListeners();
}
/**
* Clear all processing for view switches
*/
clearAllProcessing(): void {
// Cancel all ongoing processing
this.processing.forEach((state, key) => {
if (state.cancellationToken) {
state.cancellationToken.abort();
}
});
// Clear processing states
this.processing.clear();
this.notifyListeners();
// Force memory cleanup hint
if (typeof window !== 'undefined' && window.gc) {
setTimeout(() => window.gc(), 100);
}
}
/**
* Get cache statistics
*/

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";
/**
* Calculate thumbnail scale based on file size
* Smaller files get higher quality, larger files get lower quality
*/
export function calculateScaleFromFileSize(fileSize: number): number {
const MB = 1024 * 1024;
if (fileSize < 1 * MB) return 0.6; // < 1MB: High quality
if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality
if (fileSize < 15 * MB) return 0.3; // 5-15MB: Medium quality
if (fileSize < 30 * MB) return 0.2; // 15-30MB: Low-medium quality
return 0.15; // 30MB+: Low quality
}
/**
* Generate thumbnail for a PDF file during upload
* Returns base64 data URL or undefined if generation fails
@ -14,6 +28,10 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
try {
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
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size));
@ -26,7 +44,7 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
}).promise;
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");
canvas.width = viewport.width;
canvas.height = viewport.height;
@ -45,7 +63,45 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
return thumbnail;
} catch (error) {
console.warn('Failed to generate thumbnail for', file.name, 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);
return undefined;
}
}
console.warn('Unknown error generating thumbnail for', file.name, error);
return undefined;
}
}