mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
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:
parent
759055a96d
commit
2f9c88b000
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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 */}
|
@ -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)}
|
||||||
|
@ -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)`,
|
||||||
|
104
frontend/src/components/shared/SkeletonLoader.tsx
Normal file
104
frontend/src/components/shared/SkeletonLoader.tsx
Normal 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;
|
@ -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>
|
||||||
|
@ -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"}}
|
||||||
|
766
frontend/src/contexts/FileContext.tsx
Normal file
766
frontend/src/contexts/FileContext.tsx
Normal 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
|
||||||
|
};
|
||||||
|
}
|
@ -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,
|
||||||
|
30
frontend/src/hooks/useMemoryManagement.ts
Normal file
30
frontend/src/hooks/useMemoryManagement.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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
30
frontend/src/styles/skeleton.css
vendored
Normal 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;
|
||||||
|
}
|
138
frontend/src/types/fileContext.ts
Normal file
138
frontend/src/types/fileContext.ts
Normal 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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user