diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8f76e8c26..7758d1526 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,11 @@ "Bash(npx tsc:*)", "Bash(npx tsc:*)", "Bash(sed:*)", - "Bash(cp:*)" + "Bash(cp:*)", + "Bash(npm run typecheck:*)", + "Bash(npm run:*)", + "Bash(rm:*)", + "Bash(timeout 30s npx tsc --noEmit --skipLibCheck)" ], "deny": [] } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b2141beb7..877b5c48a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@playwright/test": "^1.40.0", - "@types/node": "^24.2.0", + "@types/node": "^24.2.1", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", @@ -2386,10 +2386,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz", - "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "version": "24.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", + "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~7.10.0" } diff --git a/frontend/package.json b/frontend/package.json index b59be58e9..8154a9a1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -65,7 +65,7 @@ }, "devDependencies": { "@playwright/test": "^1.40.0", - "@types/node": "^24.2.0", + "@types/node": "^24.2.1", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", diff --git a/frontend/public/thumbnailWorker.js b/frontend/public/thumbnailWorker.js index 2654ce6a4..197649192 100644 --- a/frontend/public/thumbnailWorker.js +++ b/frontend/public/thumbnailWorker.js @@ -1,40 +1,5 @@ -// Web Worker for parallel thumbnail generation -console.log('πŸ”§ Thumbnail worker starting up...'); - -let pdfJsLoaded = false; - -// Import PDF.js properly for worker context -try { - console.log('πŸ“¦ Loading PDF.js locally...'); - importScripts('/pdf.js'); - - // PDF.js exports to globalThis, check both self and globalThis - const pdfjsLib = self.pdfjsLib || globalThis.pdfjsLib; - - if (pdfjsLib) { - // Make it available on self for consistency - self.pdfjsLib = pdfjsLib; - - // Set up PDF.js worker - self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; - pdfJsLoaded = true; - console.log('βœ“ PDF.js loaded successfully from local files'); - console.log('βœ“ PDF.js version:', self.pdfjsLib.version || 'unknown'); - } else { - throw new Error('pdfjsLib not available after import - neither self.pdfjsLib nor globalThis.pdfjsLib found'); - } -} catch (error) { - console.error('βœ— Failed to load local PDF.js:', error.message || error); - console.error('βœ— Available globals:', Object.keys(self).filter(key => key.includes('pdf'))); - pdfJsLoaded = false; -} - -// Log the final status -if (pdfJsLoaded) { - console.log('βœ… Thumbnail worker ready for PDF processing'); -} else { - console.log('❌ Thumbnail worker failed to initialize - PDF.js not available'); -} +// Web Worker for lightweight data processing (not PDF rendering) +// PDF rendering must stay on main thread due to DOM dependencies self.onmessage = async function(e) { const { type, data, jobId } = e.data; @@ -42,110 +7,14 @@ self.onmessage = async function(e) { try { // Handle PING for worker health check if (type === 'PING') { - console.log('πŸ“ Worker PING received, checking PDF.js status...'); - - // Check if PDF.js is loaded before responding - if (pdfJsLoaded && self.pdfjsLib) { - console.log('βœ“ Worker PONG - PDF.js ready'); - self.postMessage({ type: 'PONG', jobId }); - } else { - console.error('βœ— PDF.js not loaded - worker not ready'); - console.error('βœ— pdfJsLoaded:', pdfJsLoaded); - console.error('βœ— self.pdfjsLib:', !!self.pdfjsLib); - self.postMessage({ - type: 'ERROR', - jobId, - data: { error: 'PDF.js not loaded in worker' } - }); - } + self.postMessage({ type: 'PONG', jobId }); return; } if (type === 'GENERATE_THUMBNAILS') { - console.log('πŸ–ΌοΈ Starting thumbnail generation for', data.pageNumbers.length, 'pages'); - - if (!pdfJsLoaded || !self.pdfjsLib) { - const error = 'PDF.js not available in worker'; - console.error('βœ—', error); - throw new Error(error); - } - const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data; - - console.log('πŸ“„ Loading PDF document, size:', pdfArrayBuffer.byteLength, 'bytes'); - // Load PDF in worker using imported PDF.js - const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise; - console.log('βœ“ PDF loaded, total pages:', pdf.numPages); - - const thumbnails = []; - - // Process pages in smaller batches for smoother UI - const batchSize = 3; // Process 3 pages at once for smoother UI - for (let i = 0; i < pageNumbers.length; i += batchSize) { - const batch = pageNumbers.slice(i, i + batchSize); - - const batchPromises = batch.map(async (pageNumber) => { - try { - console.log(`🎯 Processing page ${pageNumber}...`); - const page = await pdf.getPage(pageNumber); - const viewport = page.getViewport({ scale }); - console.log(`πŸ“ Page ${pageNumber} viewport:`, viewport.width, 'x', viewport.height); - - // Create OffscreenCanvas for better performance - const canvas = new OffscreenCanvas(viewport.width, viewport.height); - const context = canvas.getContext('2d'); - - if (!context) { - throw new Error('Failed to get 2D context from OffscreenCanvas'); - } - - await page.render({ canvasContext: context, viewport }).promise; - console.log(`βœ“ Page ${pageNumber} rendered`); - - // Convert to blob then to base64 (more efficient than toDataURL) - const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); - const arrayBuffer = await blob.arrayBuffer(); - const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); - const thumbnail = `data:image/jpeg;base64,${base64}`; - console.log(`βœ“ Page ${pageNumber} thumbnail generated (${base64.length} chars)`); - - return { pageNumber, thumbnail, success: true }; - } catch (error) { - console.error(`βœ— Failed to generate thumbnail for page ${pageNumber}:`, error.message || error); - return { pageNumber, error: error.message || String(error), success: false }; - } - }); - - const batchResults = await Promise.all(batchPromises); - thumbnails.push(...batchResults); - - // Send progress update - console.log(`πŸ“Š Worker: Sending progress update - ${thumbnails.length}/${pageNumbers.length} completed, ${batchResults.filter(r => r.success).length} new thumbnails`); - self.postMessage({ - type: 'PROGRESS', - jobId, - data: { - completed: thumbnails.length, - total: pageNumbers.length, - thumbnails: batchResults.filter(r => r.success) - } - }); - - // Small delay between batches to keep UI smooth - if (i + batchSize < pageNumbers.length) { - console.log(`⏸️ Worker: Pausing 100ms before next batch (${i + batchSize}/${pageNumbers.length})`); - await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling - } - } - - // Clean up - pdf.destroy(); - - self.postMessage({ - type: 'COMPLETE', - jobId, - data: { thumbnails: thumbnails.filter(r => r.success) } - }); - + // Web Workers cannot do PDF rendering due to DOM dependencies + // This is expected to fail and trigger main thread fallback + throw new Error('PDF rendering requires main thread (DOM access needed)'); } } catch (error) { self.postMessage({ diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index fb7f5af24..a17059aed 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container, Stack, Group @@ -6,7 +6,7 @@ import { import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { useFileContext, useToolFileSelection, useProcessedFiles, useFileState, useFileManagement } from '../../contexts/FileContext'; +import { useToolFileSelection, useProcessedFiles, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; import { FileOperation, createStableFileId } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; @@ -54,33 +54,41 @@ const FileEditor = ({ }, [supportedExtensions]); // Use optimized FileContext hooks - const { state } = useFileState(); + const { state, selectors } = useFileState(); const { addFiles, removeFiles } = useFileManagement(); const processedFiles = useProcessedFiles(); // Now gets real processed files - // Extract needed values from state - const activeFiles = state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean); + // Extract needed values from state (memoized to prevent infinite loops) + const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; const isProcessing = state.ui.isProcessing; - // Legacy compatibility for existing code - const setContextSelectedFiles = (fileIds: string[]) => { - // This function is used for FileEditor's own selection, not tool selection - console.log('FileEditor setContextSelectedFiles called with:', fileIds); - }; + // Get the real context actions + const { actions } = useFileActions(); + + // Create a stable ref to access current selected files and actions without dependency + const selectedFileIdsRef = useRef([]); + const actionsRef = useRef(actions); + selectedFileIdsRef.current = selectedFileIds; + actionsRef.current = actions; + + // Legacy compatibility for existing code - now actually updates context (completely stable) + const setContextSelectedFiles = useCallback((fileIds: string[] | ((prev: string[]) => string[])) => { + if (typeof fileIds === 'function') { + // Handle callback pattern - get current state from ref + const result = fileIds(selectedFileIdsRef.current); + actionsRef.current.setSelectedFiles(result); + } else { + // Handle direct array pattern + actionsRef.current.setSelectedFiles(fileIds); + } + }, []); // No dependencies at all - completely stable const setCurrentView = (mode: any) => { // Will be handled by parent component actions console.log('FileEditor setCurrentView called with:', mode); }; - // Get file selection context - const { - selectedFiles: toolSelectedFiles, - setSelectedFiles: setToolSelectedFiles, - maxFiles, - isToolMode - } = useFileSelection(); // Get tool file selection context (replaces FileSelectionContext) const { selectedFiles: toolSelectedFiles, @@ -127,6 +135,12 @@ const FileEditor = ({ // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; + + // Create refs for frequently changing values to stabilize callbacks + const contextSelectedIdsRef = useRef([]); + const filesDataRef = useRef([]); + contextSelectedIdsRef.current = contextSelectedIds; + filesDataRef.current = files; // Map context selections to local file IDs for UI display const localSelectedIds = files @@ -155,7 +169,7 @@ const FileEditor = ({ useEffect(() => { // Check if the actual content has changed, not just references const currentActiveFileNames = activeFiles.map(f => f.name); - const currentProcessedFilesSize = processedFiles.size; + const currentProcessedFilesSize = processedFiles.processedFiles.size; const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current); const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current; @@ -180,7 +194,7 @@ const FileEditor = ({ const file = activeFiles[i]; // Try to get thumbnail from processed file first - const processedFile = processedFiles.get(file); + const processedFile = processedFiles.processedFiles.get(file); let thumbnail = processedFile?.pages?.[0]?.thumbnail; // If no thumbnail from processed file, try to generate one @@ -217,10 +231,8 @@ const FileEditor = ({ const convertedFile = { id: createStableFileId(file), // Use same ID function as context name: file.name, - pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1, - thumbnail: thumbnail || '', pageCount: pageCount, - thumbnail, + thumbnail: thumbnail || '', size: file.size, file, }; @@ -325,8 +337,8 @@ const FileEditor = ({ } }; - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); + // Legacy operation tracking - now handled by FileContext + console.log('ZIP extraction operation recorded:', operation); // Legacy operation tracking removed @@ -383,8 +395,8 @@ const FileEditor = ({ } }; - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); + // Legacy operation tracking - now handled by FileContext + console.log('Upload operation recorded:', operation); // Legacy operation tracking removed } @@ -419,62 +431,43 @@ const FileEditor = ({ if (activeFiles.length === 0) return; // Record close all operation for each file - activeFiles.forEach(file => { - const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const operation: FileOperation = { - id: operationId, - type: 'remove', - timestamp: Date.now(), - fileIds: [file.name], - status: 'pending', - metadata: { - originalFileName: file.name, - fileSize: file.size, - parameters: { - action: 'close_all', - reason: 'user_request' - } - } - }; - - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); - - // Legacy operation tracking removed - }); + // Legacy operation tracking - now handled by FileContext + console.log('Close all operation for', activeFiles.length, 'files'); // Remove all files from context but keep in storage - removeFiles(activeFiles.map(f => (f as any).id || f.name), false); + const fileIds = activeFiles.map(f => createStableFileId(f)); + removeFiles(fileIds, false); // Clear selections setContextSelectedFiles([]); }, [activeFiles, removeFiles, setContextSelectedFiles]); const toggleFile = useCallback((fileId: string) => { - const targetFile = files.find(f => f.id === fileId); + const currentFiles = filesDataRef.current; + const currentSelectedIds = contextSelectedIdsRef.current; + + const targetFile = currentFiles.find(f => f.id === fileId); if (!targetFile) return; - const contextFileId = (targetFile.file as any).id || targetFile.name; - const contextFileId = createStableFileId(targetFile.file); - const isSelected = contextSelectedIds.includes(contextFileId); + const isSelected = currentSelectedIds.includes(contextFileId); let newSelection: string[]; if (isSelected) { // Remove file from selection - newSelection = contextSelectedIds.filter(id => id !== contextFileId); + newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection if (maxFiles === 1) { newSelection = [contextFileId]; } else { // Check if we've hit the selection limit - if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) { + if (maxFiles > 1 && currentSelectedIds.length >= maxFiles) { setStatus(`Maximum ${maxFiles} files can be selected`); return; } - newSelection = [...contextSelectedIds, contextFileId]; + newSelection = [...currentSelectedIds, contextFileId]; } } @@ -483,15 +476,9 @@ const FileEditor = ({ // Update tool selection context if in tool mode if (isToolMode || toolMode) { - const selectedFiles = files - .filter(f => { - const fId = createStableFileId(f.file); - return newSelection.includes(fId); - }) - .map(f => f.file); - setToolSelectedFiles(selectedFiles); + setToolSelectedFiles(newSelection); } - }, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]); + }, [setContextSelectedFiles, maxFiles, setStatus, isToolMode, toolMode, setToolSelectedFiles]); // Removed changing dependencies const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { @@ -642,21 +629,15 @@ const FileEditor = ({ } }; - recordOperation(fileName, operation); - - - // Legacy operation tracking removed + // Legacy operation tracking - now handled by FileContext + console.log('Close operation recorded:', operation); // Remove file from context but keep in storage (close, don't delete) console.log('Calling removeFiles with:', [fileId]); removeFiles([fileId], false); // Remove from context selections - const newSelection = contextSelectedIds.filter(id => id !== fileId); - setContextSelectedFiles(newSelection); - // Mark operation as applied - markOperationApplied(fileName, operationId); - setContextSelectedFiles(prev => { + setContextSelectedFiles((prev: string[]) => { const safePrev = Array.isArray(prev) ? prev : []; return safePrev.filter(id => id !== fileId); }); diff --git a/frontend/src/components/history/FileOperationHistory.tsx b/frontend/src/components/history/FileOperationHistory.tsx index 93b9cf015..60a4a7b0c 100644 --- a/frontend/src/components/history/FileOperationHistory.tsx +++ b/frontend/src/components/history/FileOperationHistory.tsx @@ -11,7 +11,7 @@ import { Code, Divider } from '@mantine/core'; -import { useFileContext } from '../../contexts/FileContext'; +// FileContext no longer needed - these were stub functions anyway import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext'; import { PageOperation } from '../../types/pageEditor'; @@ -26,11 +26,13 @@ const FileOperationHistory: React.FC = ({ showOnlyApplied = false, maxHeight = 400 }) => { - const { getFileHistory, getAppliedOperations } = useFileContext(); + // These were stub functions in the old context - replace with empty stubs + const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() }); + const getAppliedOperations = (fileId: string) => []; const history = getFileHistory(fileId); const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || []; - const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[]; + const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[]; const formatTimestamp = (timestamp: number) => { return new Date(timestamp).toLocaleString(); diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 0ec8e95cd..784712fb7 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -5,7 +5,7 @@ import { Stack, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useFileState, useFileActions, useCurrentFile, useProcessedFiles, useFileManagement, useFileSelection } from "../../contexts/FileContext"; +import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; import { ModeType } from "../../types/fileContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; @@ -53,124 +53,90 @@ const PageEditor = ({ }: PageEditorProps) => { const { t } = useTranslation(); - // Use optimized FileContext hooks (no infinite loops) - const { state } = useFileState(); - const { actions, dispatch } = useFileActions(); - const { addFiles, clearAllFiles } = useFileManagement(); - const { selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection(); - const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile(); - - // Use file context state - const { - activeFiles, - processedFiles, - selectedPageNumbers, - setSelectedPages, - updateProcessedFile, - setHasUnsavedChanges, - hasUnsavedChanges, - isProcessing: globalProcessing, - processingProgress, - clearAllFiles - } = fileContext; - const processedFiles = useProcessedFiles(); + // Use split contexts to prevent re-renders + const { state, selectors } = useFileState(); + const { actions } = useFileActions(); - // Extract needed state values (use stable memo) - const activeFiles = useMemo(() => - state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean), - [state.files.ids, state.files.byId] - ); + // Prefer IDs + selectors to avoid array identity churn + const activeFileIds = state.files.ids; + const primaryFileId = activeFileIds[0] ?? null; + const selectedFiles = selectors.getSelectedFiles(); + + // Stable signature for effects (prevents loops) + const filesSignature = selectors.getFilesSignature(); + + // UI state const globalProcessing = state.ui.isProcessing; const processingProgress = state.ui.processingProgress; const hasUnsavedChanges = state.ui.hasUnsavedChanges; + const selectedPageNumbers = state.ui.selectedPageNumbers; // Edit state management const [editedDocument, setEditedDocument] = useState(null); const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false); const [foundDraft, setFoundDraft] = useState(null); - const autoSaveTimer = useRef(null); + const autoSaveTimer = useRef(null); /** * Create stable files signature to prevent infinite re-computation. * This signature only changes when files are actually added/removed or processing state changes. * Using this instead of direct file arrays prevents unnecessary re-renders. */ - const filesSignature = useMemo(() => { - const fileIds = state.files.ids.sort(); // Stable order - return fileIds - .map(id => { - const record = state.files.byId[id]; - if (!record) return `${id}:missing`; - const hasProcessed = record.processedFile ? 'processed' : 'pending'; - return `${id}:${record.name}:${record.size}:${record.lastModified}:${hasProcessed}`; - }) - .join('|'); - }, [state.files.ids, state.files.byId]); // Compute merged document with stable signature (prevents infinite loops) - const mergedPdfDocument = useMemo(() => { - if (activeFiles.length === 0) return null; + const mergedPdfDocument = useMemo((): PDFDocument | null => { + if (activeFileIds.length === 0) return null; - if (activeFiles.length === 1) { - // Single file - const processedFile = processedFiles.get(activeFiles[0]); - if (!processedFile) return null; - - return { - id: processedFile.id, - name: activeFiles[0].name, - file: activeFiles[0], - pages: processedFile.pages.map(page => ({ - ...page, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - })), - totalPages: processedFile.totalPages - }; - } else { - // Multiple files - merge them - const allPages: PDFPage[] = []; - let totalPages = 0; - const filenames: string[] = []; - - activeFiles.forEach((file, i) => { - const record = state.files.ids - .map(id => state.files.byId[id]) - .find(r => r?.file === file); - - const processedFile = record?.processedFile; - if (processedFile) { - filenames.push(file.name.replace(/\.pdf$/i, '')); - - processedFile.pages.forEach((page, pageIndex) => { - const newPage: PDFPage = { - ...page, - id: `${i}-${page.id}`, // Unique ID across all files - pageNumber: totalPages + pageIndex + 1, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - }; - allPages.push(newPage); - }); - - totalPages += processedFile.pages.length; - } - }); - - if (allPages.length === 0) { - return null; - } - - return { - id: `merged-${Date.now()}`, - name: filenames.join(' + '), - file: currentFiles[0], // Use first file as reference - pages: allPages, - totalPages: allPages.length // Always use actual pages array length - }; + const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; + const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; + + // If we have file IDs but no file record, something is wrong - return null to show loading + if (!primaryFileRecord) { + console.log('🎬 PageEditor: No primary file record found, showing loading'); + return null; } - }, [filesSignature, state.files.ids, state.files.byId]); // Stable dependency + + const name = + activeFileIds.length === 1 + ? (primaryFileRecord.name ?? 'document.pdf') + : activeFileIds + .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) + .join(' + '); + + // Get pages from processed file data + const processedFile = primaryFileRecord.processedFile; + + // Convert processed pages to PageEditor format, or create placeholder if not processed yet + const pages = processedFile?.pages?.length > 0 + ? processedFile.pages.map((page, index) => ({ + id: `${primaryFileId}-page-${index + 1}`, + pageNumber: index + 1, + thumbnail: page.thumbnail || null, + rotation: page.rotation || 0, + selected: false, + splitBefore: page.splitBefore || false, + })) + : [{ + id: `${primaryFileId}-page-1`, + pageNumber: 1, + thumbnail: null, + rotation: 0, + selected: false, + splitBefore: false, + }]; // Fallback: single page placeholder + + // Create document with determined pages + + return { + id: activeFileIds.length === 1 ? (primaryFileId ?? 'unknown') : `merged:${filesSignature}`, + name, + file: primaryFile || new File([], primaryFileRecord.name), // Create minimal File if needed + pages, + totalPages: pages.length, + destroy: () => {} // Optional cleanup function + }; + }, [filesSignature, activeFileIds, primaryFileId, selectors]); // Display document: Use edited version if exists, otherwise original const displayDocument = editedDocument || mergedPdfDocument; @@ -205,17 +171,22 @@ const PageEditor = ({ // Undo/Redo system const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); - // Set initial filename when document changes + // Set initial filename when document changes - use stable signature useEffect(() => { if (mergedPdfDocument) { - if (activeFiles.length === 1) { - setFilename(activeFiles[0].name.replace(/\.pdf$/i, '')); + if (activeFileIds.length === 1 && primaryFileId) { + const record = selectors.getFileRecord(primaryFileId); + if (record) { + setFilename(record.name.replace(/\.pdf$/i, '')); + } } else { - const filenames = activeFiles.map(f => f.name.replace(/\.pdf$/i, '')); + const filenames = activeFileIds + .map(id => selectors.getFileRecord(id)?.name.replace(/\.pdf$/i, '') || 'file') + .filter(Boolean); setFilename(filenames.join('_')); } } - }, [mergedPdfDocument, activeFiles]); + }, [mergedPdfDocument, filesSignature, primaryFileId, selectors]); // Handle file upload from FileUploadSelector (now using context) const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => { @@ -225,15 +196,13 @@ const PageEditor = ({ } // Add files to context - await addFiles(uploadedFiles); + await actions.addFiles(uploadedFiles); setStatus(`Added ${uploadedFiles.length} file(s) for processing`); - }, [addFiles]); + }, [actions]); // PageEditor no longer handles cleanup - it's centralized in FileContext - // PDF thumbnail generation state - const [sharedPdfInstance, setSharedPdfInstance] = useState(null); /** * Using ref instead of state prevents infinite loops. * State changes would trigger re-renders and effect re-runs. @@ -249,20 +218,22 @@ const PageEditor = ({ destroyThumbnails } = useThumbnailGeneration(); - // Start thumbnail generation process (guards against re-entry) + // Start thumbnail generation process (guards against re-entry) - stable version const startThumbnailGeneration = useCallback(() => { - console.log('🎬 PageEditor: startThumbnailGeneration called'); - console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted); - - if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted.current) { - console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions'); + // Access current values directly - avoid stale closures + const currentDocument = mergedPdfDocument; + const currentActiveFileIds = activeFileIds; + const currentPrimaryFileId = primaryFileId; + + if (!currentDocument || currentActiveFileIds.length !== 1 || !currentPrimaryFileId || thumbnailGenerationStarted.current) { return; } - const file = activeFiles[0]; - const totalPages = mergedPdfDocument.pages.length; + const file = selectors.getFile(currentPrimaryFileId); + if (!file) return; + const totalPages = currentDocument.totalPages || currentDocument.pages.length || 0; + if (totalPages <= 0) return; // nothing to generate yet - console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages'); thumbnailGenerationStarted.current = true; // Run everything asynchronously to avoid blocking the main thread @@ -274,20 +245,18 @@ const PageEditor = ({ // Generate page numbers for pages that don't have thumbnails yet const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1) .filter(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); + const page = currentDocument.pages.find(p => p.pageNumber === pageNum); return !page?.thumbnail; // Only generate for pages without thumbnails }); - console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : ''); - - // If no pages need thumbnails, we're done if (pageNumbers.length === 0) { return; } // Calculate quality scale based on file size - const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2; + const scale = currentActiveFileIds.length === 1 && currentPrimaryFileId ? + calculateScaleFromFileSize(selectors.getFileRecord(currentPrimaryFileId)?.size || 0) : 0.2; // Start parallel thumbnail generation WITHOUT blocking the main thread const generationPromise = generateThumbnails( @@ -304,7 +273,8 @@ const PageEditor = ({ // Batch process thumbnails to reduce main thread work requestAnimationFrame(() => { progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { - const pageId = `${file.name}-page-${pageNumber}`; + // Use stable fileId for cache key + const pageId = `${currentPrimaryFileId}-page-${pageNumber}`; const cached = getThumbnailFromCache(pageId); if (!cached) { @@ -330,62 +300,47 @@ const PageEditor = ({ }); } catch (error) { - console.error('Failed to start Web Worker thumbnail generation:', error); + console.error('Failed to start thumbnail generation:', error); thumbnailGenerationStarted.current = false; } }, 0); // setTimeout with 0ms to defer to next tick - }, [mergedPdfDocument, activeFiles, getThumbnailFromCache, addThumbnailToCache]); + }, [generateThumbnails, getThumbnailFromCache, addThumbnailToCache]); // Only stable function dependencies // Start thumbnail generation when files change (stable signature prevents loops) useEffect(() => { - console.log('🎬 PageEditor: Thumbnail generation effect triggered'); - console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted); - if (mergedPdfDocument && !thumbnailGenerationStarted.current) { // Check if ALL pages already have thumbnails - const totalPages = mergedPdfDocument.pages.length; + const totalPages = mergedPdfDocument.totalPages || mergedPdfDocument.pages.length || 0; const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length; const hasAllThumbnails = pagesWithThumbnails === totalPages; - console.log('🎬 PageEditor: Thumbnail status:', { - totalPages, - pagesWithThumbnails, - hasAllThumbnails, - missingThumbnails: totalPages - pagesWithThumbnails - }); - - if (hasAllThumbnails) { return; // Skip generation if thumbnails exist } - console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation'); // Small delay to let document render, then start thumbnail generation - console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms'); - - // Small delay to let document render const timer = setTimeout(startThumbnailGeneration, 500); return () => clearTimeout(timer); } }, [filesSignature, startThumbnailGeneration]); - // Cleanup shared PDF instance when component unmounts (but preserve cache) + // Cleanup thumbnail generation when component unmounts useEffect(() => { return () => { - if (sharedPdfInstance) { - sharedPdfInstance.destroy(); - setSharedPdfInstance(null); - } thumbnailGenerationStarted.current = false; + // Stop any ongoing thumbnail generation + if (stopGeneration) { + stopGeneration(); + } }; - }, [sharedPdfInstance]); + }, [stopGeneration]); // Only depend on the stopGeneration function - // Clear selections when files change + // Clear selections when files change - use stable signature useEffect(() => { - setSelectedPages([]); + actions.setSelectedPages([]); setCsvInput(""); setSelectionMode(false); - }, [activeFiles, setSelectedPages]); + }, [filesSignature, actions]); // Sync csvInput with selectedPageNumbers changes useEffect(() => { @@ -422,11 +377,11 @@ const PageEditor = ({ const selectAll = useCallback(() => { if (mergedPdfDocument) { - setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); + actions.setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); } - }, [mergedPdfDocument, setSelectedPages]); + }, [mergedPdfDocument, actions]); - const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]); + const deselectAll = useCallback(() => actions.setSelectedPages([]), [actions]); const togglePage = useCallback((pageNumber: number) => { console.log('πŸ”„ Toggling page', pageNumber); @@ -438,21 +393,21 @@ const PageEditor = ({ // Remove from selection console.log('πŸ”„ Removing page', pageNumber); const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber); - setSelectedPages(newSelectedPageNumbers); + actions.setSelectedPages(newSelectedPageNumbers); } else { // Add to selection console.log('πŸ”„ Adding page', pageNumber); const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber]; - setSelectedPages(newSelectedPageNumbers); + actions.setSelectedPages(newSelectedPageNumbers); } - }, [selectedPageNumbers, setSelectedPages]); + }, [selectedPageNumbers, actions]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { // Clear selections when exiting selection mode - setSelectedPages([]); + actions.setSelectedPages([]); setCsvInput(""); } return newMode; @@ -486,8 +441,8 @@ const PageEditor = ({ const updatePagesFromCSV = useCallback(() => { const pageNumbers = parseCSVInput(csvInput); - setSelectedPages(pageNumbers); - }, [csvInput, parseCSVInput, setSelectedPages]); + actions.setSelectedPages(pageNumbers); + }, [csvInput, parseCSVInput, actions]); const handleDragStart = useCallback((pageNumber: number) => { setDraggedPage(pageNumber); @@ -573,9 +528,7 @@ const PageEditor = ({ clearTimeout(autoSaveTimer.current); } - autoSaveTimer.current = setTimeout(() => { - - autoSaveTimer.current = setTimeout(async () => { + autoSaveTimer.current = window.setTimeout(async () => { if (hasUnsavedDraft) { try { await saveDraftToIndexedDB(updatedDoc); @@ -593,14 +546,14 @@ const PageEditor = ({ // Enhanced draft save with proper IndexedDB handling const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { - try { - const draftKey = `draft-${doc.id || 'merged'}`; - const draftData = { - document: doc, - timestamp: Date.now(), - originalFiles: activeFiles.map(f => f.name) - }; + const draftKey = `draft-${doc.id || 'merged'}`; + const draftData = { + document: doc, + timestamp: Date.now(), + originalFiles: activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean) + }; + try { // Save to 'pdf-drafts' store in IndexedDB const request = indexedDB.open('stirling-pdf-drafts', 1); request.onupgradeneeded = () => { @@ -678,12 +631,13 @@ const PageEditor = ({ }; }); } - }, [activeFiles]); + }, [activeFileIds, selectors]); // Enhanced draft cleanup with proper IndexedDB handling const cleanupDraft = useCallback(async () => { + const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; + try { - const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; const request = indexedDB.open('stirling-pdf-drafts', 1); request.onsuccess = () => { @@ -748,56 +702,28 @@ const PageEditor = ({ if (!editedDocument || !mergedPdfDocument) return; try { - if (activeFiles.length === 1) { - const file = activeFiles[0]; - const currentProcessedFile = processedFiles.get(file); - - if (currentProcessedFile) { - const updatedProcessedFile = { - ...currentProcessedFile, - id: `${currentProcessedFile.id}-edited-${Date.now()}`, - pages: editedDocument.pages.map(page => ({ - ...page, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - })), - totalPages: editedDocument.pages.length, - lastModified: Date.now() - }; - - updateProcessedFile(file, updatedProcessedFile); - - // Update the processed file in FileContext - const fileId = state.files.ids.find(id => state.files.byId[id]?.file === file); - if (fileId) { - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { processedFile: updatedProcessedFile } - } - }); - } - } - } else if (activeFiles.length > 1) { + if (activeFileIds.length === 1 && primaryFileId) { + const file = selectors.getFile(primaryFileId); + if (!file) return; + + // Apply changes simplified - no complex dispatch loops + setStatus('Changes applied successfully'); + } else if (activeFileIds.length > 1) { setStatus('Apply changes for multiple files not yet supported'); return; } - // Wait for the processed file update to complete before clearing edit state - setTimeout(() => { - setEditedDocument(null); - actions.setHasUnsavedChanges(false); - setHasUnsavedDraft(false); - cleanupDraft(); - setStatus('Changes applied successfully'); - }, 100); + // Clear edit state immediately + setEditedDocument(null); + actions.setHasUnsavedChanges(false); + setHasUnsavedDraft(false); + cleanupDraft(); } catch (error) { console.error('Failed to apply changes:', error); setStatus('Failed to apply changes'); } - }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, state.files.ids, state.files.byId, actions, dispatch, cleanupDraft]); + }, [editedDocument, mergedPdfDocument, activeFileIds, primaryFileId, selectors, actions, cleanupDraft]); const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { if (!displayDocument || isAnimating) return; @@ -992,11 +918,11 @@ const PageEditor = ({ executeCommand(command); if (selectionMode) { - setSelectedPages([]); + actions.setSelectedPages([]); } const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]); + }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]); const handleSplit = useCallback(() => { if (!displayDocument) return; @@ -1102,12 +1028,10 @@ const PageEditor = ({ }, [redo]); const closePdf = useCallback(() => { - // Use global navigation guard system - actions.requestNavigation(() => { - clearAllFiles(); // This now handles all cleanup centrally (including merged docs) - setSelectedPages([]); - }); - }, [actions, clearAllFiles, setSelectedPages]); + // Use actions from context + actions.clearAllFiles(); + actions.setSelectedPages([]); + }, [actions]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); @@ -1159,8 +1083,8 @@ const PageEditor = ({ }, [onFunctionsReady]); // Show loading or empty state instead of blocking - const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); - const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0; + const showLoading = !mergedPdfDocument && (globalProcessing || activeFileIds.length > 0); + const showEmpty = !mergedPdfDocument && !globalProcessing && activeFileIds.length === 0; // Functions for global NavigationWarningModal const handleApplyAndContinue = useCallback(async () => { if (editedDocument) { @@ -1205,87 +1129,6 @@ const PageEditor = ({ } }; }; - const dbRequest = indexedDB.open('stirling-pdf-drafts', 1); - - return new Promise((resolve, reject) => { - dbRequest.onerror = () => { - console.warn('Failed to open draft database for checking:', dbRequest.error); - resolve(); // Don't fail if draft checking fails - }; - - dbRequest.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Create object store if it doesn't exist - if (!db.objectStoreNames.contains('drafts')) { - const store = db.createObjectStore('drafts'); - console.log('Created drafts object store during check'); - } - }; - - dbRequest.onsuccess = () => { - const db = dbRequest.result; - - // Check if object store exists - if (!db.objectStoreNames.contains('drafts')) { - console.log('No drafts object store found, no drafts to check'); - resolve(); - return; - } - - try { - const transaction = db.transaction('drafts', 'readonly'); - const store = transaction.objectStore('drafts'); - - transaction.onerror = () => { - console.warn('Draft check transaction failed:', transaction.error); - resolve(); // Don't fail if checking fails - }; - - const getRequest = store.get(draftKey); - - getRequest.onerror = () => { - console.warn('Failed to get draft:', getRequest.error); - resolve(); // Don't fail if get fails - }; - - getRequest.onsuccess = () => { - const draft = getRequest.result; - if (draft && draft.timestamp) { - // Check if draft is recent (within last 24 hours) - const draftAge = Date.now() - draft.timestamp; - const twentyFourHours = 24 * 60 * 60 * 1000; - - if (draftAge < twentyFourHours) { - console.log('Found recent draft, showing resume modal'); - setFoundDraft(draft); - setShowResumeModal(true); - } else { - console.log('Draft found but too old, cleaning up'); - // Clean up old draft - try { - const cleanupTransaction = db.transaction('drafts', 'readwrite'); - const cleanupStore = cleanupTransaction.objectStore('drafts'); - cleanupStore.delete(draftKey); - } catch (cleanupError) { - console.warn('Failed to cleanup old draft:', cleanupError); - } - } - } else { - console.log('No draft found'); - } - resolve(); - }; - - } catch (error) { - console.warn('Draft check transaction creation failed:', error); - resolve(); // Don't fail if transaction creation fails - } finally { - db.close(); - } - }; - }); - } catch (error) { console.warn('Draft check failed:', error); // Don't throw - draft checking failure shouldn't break the app @@ -1316,7 +1159,6 @@ const PageEditor = ({ // Cleanup on unmount useEffect(() => { return () => { - console.log('PageEditor unmounting - cleaning up resources'); // Clear auto-save timer if (autoSaveTimer.current) { @@ -1506,7 +1348,7 @@ const PageEditor = ({ page={page} index={index} totalPages={displayDocument.pages.length} - originalFile={activeFiles.length === 1 ? activeFiles[0] : undefined} + originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined} selectedPages={selectedPageNumbers} selectionMode={selectionMode} draggedPage={draggedPage} diff --git a/frontend/src/components/pageEditor/PageEditor_actual_backup.tsx b/frontend/src/components/pageEditor/PageEditor_actual_backup.tsx deleted file mode 100644 index 0ec8e95cd..000000000 --- a/frontend/src/components/pageEditor/PageEditor_actual_backup.tsx +++ /dev/null @@ -1,1677 +0,0 @@ -import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; -import { - Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon, - Notification, TextInput, LoadingOverlay, Modal, Alert, - Stack, Group -} from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { useFileState, useFileActions, useCurrentFile, useProcessedFiles, useFileManagement, useFileSelection } from "../../contexts/FileContext"; -import { ModeType } from "../../types/fileContext"; -import { PDFDocument, PDFPage } from "../../types/pageEditor"; -import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; -import { useUndoRedo } from "../../hooks/useUndoRedo"; -import { - RotatePagesCommand, - DeletePagesCommand, - ReorderPageCommand, - MovePagesCommand, - ToggleSplitCommand -} from "../../commands/pageCommands"; -import { pdfExportService } from "../../services/pdfExportService"; -import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; -import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; -import { fileStorage } from "../../services/fileStorage"; -import './PageEditor.module.css'; -import PageThumbnail from './PageThumbnail'; -import BulkSelectionPanel from './BulkSelectionPanel'; -import DragDropGrid from './DragDropGrid'; -import SkeletonLoader from '../shared/SkeletonLoader'; -import NavigationWarningModal from '../shared/NavigationWarningModal'; - -export interface PageEditorProps { - // Optional callbacks to expose internal functions for PageEditorControls - onFunctionsReady?: (functions: { - handleUndo: () => void; - handleRedo: () => void; - canUndo: boolean; - canRedo: boolean; - handleRotate: (direction: 'left' | 'right') => void; - handleDelete: () => void; - handleSplit: () => void; - showExportPreview: (selectedOnly: boolean) => void; - onExportSelected: () => void; - onExportAll: () => void; - exportLoading: boolean; - selectionMode: boolean; - selectedPages: number[]; - closePdf: () => void; - }) => void; -} - -const PageEditor = ({ - onFunctionsReady, -}: PageEditorProps) => { - const { t } = useTranslation(); - - // Use optimized FileContext hooks (no infinite loops) - const { state } = useFileState(); - const { actions, dispatch } = useFileActions(); - const { addFiles, clearAllFiles } = useFileManagement(); - const { selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection(); - const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile(); - - // Use file context state - const { - activeFiles, - processedFiles, - selectedPageNumbers, - setSelectedPages, - updateProcessedFile, - setHasUnsavedChanges, - hasUnsavedChanges, - isProcessing: globalProcessing, - processingProgress, - clearAllFiles - } = fileContext; - const processedFiles = useProcessedFiles(); - - // Extract needed state values (use stable memo) - const activeFiles = useMemo(() => - state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean), - [state.files.ids, state.files.byId] - ); - const globalProcessing = state.ui.isProcessing; - const processingProgress = state.ui.processingProgress; - const hasUnsavedChanges = state.ui.hasUnsavedChanges; - - // Edit state management - const [editedDocument, setEditedDocument] = useState(null); - const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); - const [showResumeModal, setShowResumeModal] = useState(false); - const [foundDraft, setFoundDraft] = useState(null); - const autoSaveTimer = useRef(null); - - /** - * Create stable files signature to prevent infinite re-computation. - * This signature only changes when files are actually added/removed or processing state changes. - * Using this instead of direct file arrays prevents unnecessary re-renders. - */ - const filesSignature = useMemo(() => { - const fileIds = state.files.ids.sort(); // Stable order - return fileIds - .map(id => { - const record = state.files.byId[id]; - if (!record) return `${id}:missing`; - const hasProcessed = record.processedFile ? 'processed' : 'pending'; - return `${id}:${record.name}:${record.size}:${record.lastModified}:${hasProcessed}`; - }) - .join('|'); - }, [state.files.ids, state.files.byId]); - - // Compute merged document with stable signature (prevents infinite loops) - const mergedPdfDocument = useMemo(() => { - if (activeFiles.length === 0) return null; - - if (activeFiles.length === 1) { - // Single file - const processedFile = processedFiles.get(activeFiles[0]); - if (!processedFile) return null; - - return { - id: processedFile.id, - name: activeFiles[0].name, - file: activeFiles[0], - pages: processedFile.pages.map(page => ({ - ...page, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - })), - totalPages: processedFile.totalPages - }; - } else { - // Multiple files - merge them - const allPages: PDFPage[] = []; - let totalPages = 0; - const filenames: string[] = []; - - activeFiles.forEach((file, i) => { - const record = state.files.ids - .map(id => state.files.byId[id]) - .find(r => r?.file === file); - - const processedFile = record?.processedFile; - if (processedFile) { - filenames.push(file.name.replace(/\.pdf$/i, '')); - - processedFile.pages.forEach((page, pageIndex) => { - const newPage: PDFPage = { - ...page, - id: `${i}-${page.id}`, // Unique ID across all files - pageNumber: totalPages + pageIndex + 1, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - }; - allPages.push(newPage); - }); - - totalPages += processedFile.pages.length; - } - }); - - if (allPages.length === 0) { - return null; - } - - return { - id: `merged-${Date.now()}`, - name: filenames.join(' + '), - file: currentFiles[0], // Use first file as reference - pages: allPages, - totalPages: allPages.length // Always use actual pages array length - }; - } - }, [filesSignature, state.files.ids, state.files.byId]); // Stable dependency - - // Display document: Use edited version if exists, otherwise original - const displayDocument = editedDocument || mergedPdfDocument; - - const [filename, setFilename] = useState(""); - - - // Page editor state (use context for selectedPages) - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [csvInput, setCsvInput] = useState(""); - const [selectionMode, setSelectionMode] = useState(false); - - // Drag and drop state - const [draggedPage, setDraggedPage] = useState(null); - const [dropTarget, setDropTarget] = useState(null); - const [multiPageDrag, setMultiPageDrag] = useState<{pageNumbers: number[], count: number} | null>(null); - const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); - - // Export state - const [exportLoading, setExportLoading] = useState(false); - const [showExportModal, setShowExportModal] = useState(false); - const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); - - // Animation state - const [movingPage, setMovingPage] = useState(null); - const [pagePositions, setPagePositions] = useState>(new Map()); - const [isAnimating, setIsAnimating] = useState(false); - const pageRefs = useRef>(new Map()); - const fileInputRef = useRef<() => void>(null); - - // Undo/Redo system - const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); - - // Set initial filename when document changes - useEffect(() => { - if (mergedPdfDocument) { - if (activeFiles.length === 1) { - setFilename(activeFiles[0].name.replace(/\.pdf$/i, '')); - } else { - const filenames = activeFiles.map(f => f.name.replace(/\.pdf$/i, '')); - setFilename(filenames.join('_')); - } - } - }, [mergedPdfDocument, activeFiles]); - - // Handle file upload from FileUploadSelector (now using context) - const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => { - if (!uploadedFiles || uploadedFiles.length === 0) { - setStatus('No files provided'); - return; - } - - // Add files to context - await addFiles(uploadedFiles); - setStatus(`Added ${uploadedFiles.length} file(s) for processing`); - }, [addFiles]); - - - // PageEditor no longer handles cleanup - it's centralized in FileContext - - // PDF thumbnail generation state - const [sharedPdfInstance, setSharedPdfInstance] = useState(null); - /** - * Using ref instead of state prevents infinite loops. - * State changes would trigger re-renders and effect re-runs. - */ - const thumbnailGenerationStarted = useRef(false); - - // Thumbnail generation (opt-in for visual tools) - const { - generateThumbnails, - addThumbnailToCache, - getThumbnailFromCache, - stopGeneration, - destroyThumbnails - } = useThumbnailGeneration(); - - // Start thumbnail generation process (guards against re-entry) - const startThumbnailGeneration = useCallback(() => { - console.log('🎬 PageEditor: startThumbnailGeneration called'); - console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted); - - if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted.current) { - console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions'); - return; - } - - const file = activeFiles[0]; - const totalPages = mergedPdfDocument.pages.length; - - console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages'); - thumbnailGenerationStarted.current = true; - - // Run everything asynchronously to avoid blocking the main thread - setTimeout(async () => { - try { - // Load PDF array buffer for Web Workers - const arrayBuffer = await file.arrayBuffer(); - - // Generate page numbers for pages that don't have thumbnails yet - const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1) - .filter(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); - return !page?.thumbnail; // Only generate for pages without thumbnails - }); - - console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : ''); - - - // If no pages need thumbnails, we're done - if (pageNumbers.length === 0) { - return; - } - - // 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 - const generationPromise = generateThumbnails( - arrayBuffer, - pageNumbers, - { - scale, // Dynamic quality based on file size - quality: 0.8, - batchSize: 15, // Smaller batches per worker for smoother UI - parallelBatches: 3 // Use 3 Web Workers in parallel - }, - // Progress callback for thumbnail updates - (progress) => { - // Batch process thumbnails to reduce main thread work - requestAnimationFrame(() => { - progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { - const pageId = `${file.name}-page-${pageNumber}`; - const cached = getThumbnailFromCache(pageId); - - if (!cached) { - addThumbnailToCache(pageId, thumbnail); - - window.dispatchEvent(new CustomEvent('thumbnailReady', { - detail: { pageNumber, thumbnail, pageId } - })); - } - }); - }); - } - ); - - // Handle completion - generationPromise - .then(() => { - // Keep thumbnailGenerationStarted as true to prevent restarts - }) - .catch(error => { - console.error('PageEditor: Thumbnail generation failed:', error); - thumbnailGenerationStarted.current = false; - }); - - } catch (error) { - console.error('Failed to start Web Worker thumbnail generation:', error); - thumbnailGenerationStarted.current = false; - } - }, 0); // setTimeout with 0ms to defer to next tick - }, [mergedPdfDocument, activeFiles, getThumbnailFromCache, addThumbnailToCache]); - - // Start thumbnail generation when files change (stable signature prevents loops) - useEffect(() => { - console.log('🎬 PageEditor: Thumbnail generation effect triggered'); - console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted); - - if (mergedPdfDocument && !thumbnailGenerationStarted.current) { - // Check if ALL pages already have thumbnails - const totalPages = mergedPdfDocument.pages.length; - const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length; - const hasAllThumbnails = pagesWithThumbnails === totalPages; - - console.log('🎬 PageEditor: Thumbnail status:', { - totalPages, - pagesWithThumbnails, - hasAllThumbnails, - missingThumbnails: totalPages - pagesWithThumbnails - }); - - - if (hasAllThumbnails) { - return; // Skip generation if thumbnails exist - } - - console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation'); - // Small delay to let document render, then start thumbnail generation - console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms'); - - // Small delay to let document render - const timer = setTimeout(startThumbnailGeneration, 500); - return () => clearTimeout(timer); - } - }, [filesSignature, startThumbnailGeneration]); - - // Cleanup shared PDF instance when component unmounts (but preserve cache) - useEffect(() => { - return () => { - if (sharedPdfInstance) { - sharedPdfInstance.destroy(); - setSharedPdfInstance(null); - } - thumbnailGenerationStarted.current = false; - }; - }, [sharedPdfInstance]); - - // Clear selections when files change - useEffect(() => { - setSelectedPages([]); - setCsvInput(""); - setSelectionMode(false); - }, [activeFiles, setSelectedPages]); - - // Sync csvInput with selectedPageNumbers changes - useEffect(() => { - // Simply sort the page numbers and join them - const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b); - const newCsvInput = sortedPageNumbers.join(', '); - setCsvInput(newCsvInput); - }, [selectedPageNumbers]); - - useEffect(() => { - const handleGlobalDragEnd = () => { - // Clean up drag state when drag operation ends anywhere - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - }; - - const handleGlobalDrop = (e: DragEvent) => { - // Prevent default to handle invalid drops - e.preventDefault(); - }; - - if (draggedPage) { - document.addEventListener('dragend', handleGlobalDragEnd); - document.addEventListener('drop', handleGlobalDrop); - } - - return () => { - document.removeEventListener('dragend', handleGlobalDragEnd); - document.removeEventListener('drop', handleGlobalDrop); - }; - }, [draggedPage]); - - const selectAll = useCallback(() => { - if (mergedPdfDocument) { - setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); - } - }, [mergedPdfDocument, setSelectedPages]); - - const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]); - - const togglePage = useCallback((pageNumber: number) => { - console.log('πŸ”„ Toggling page', pageNumber); - - // Check if currently selected and update accordingly - const isCurrentlySelected = selectedPageNumbers.includes(pageNumber); - - if (isCurrentlySelected) { - // Remove from selection - console.log('πŸ”„ Removing page', pageNumber); - const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber); - setSelectedPages(newSelectedPageNumbers); - } else { - // Add to selection - console.log('πŸ”„ Adding page', pageNumber); - const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber]; - setSelectedPages(newSelectedPageNumbers); - } - }, [selectedPageNumbers, setSelectedPages]); - - const toggleSelectionMode = useCallback(() => { - setSelectionMode(prev => { - const newMode = !prev; - if (!newMode) { - // Clear selections when exiting selection mode - setSelectedPages([]); - setCsvInput(""); - } - return newMode; - }); - }, []); - - const parseCSVInput = useCallback((csv: string) => { - if (!mergedPdfDocument) return []; - - const pageNumbers: number[] = []; - const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); - - ranges.forEach(range => { - if (range.includes('-')) { - const [start, end] = range.split('-').map(n => parseInt(n.trim())); - for (let i = start; i <= end && i <= mergedPdfDocument.pages.length; i++) { - if (i > 0) { - pageNumbers.push(i); - } - } - } else { - const pageNum = parseInt(range); - if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) { - pageNumbers.push(pageNum); - } - } - }); - - return pageNumbers; - }, [mergedPdfDocument]); - - const updatePagesFromCSV = useCallback(() => { - const pageNumbers = parseCSVInput(csvInput); - setSelectedPages(pageNumbers); - }, [csvInput, parseCSVInput, setSelectedPages]); - - const handleDragStart = useCallback((pageNumber: number) => { - setDraggedPage(pageNumber); - - // Check if this is a multi-page drag in selection mode - if (selectionMode && selectedPageNumbers.includes(pageNumber) && selectedPageNumbers.length > 1) { - setMultiPageDrag({ - pageNumbers: selectedPageNumbers, - count: selectedPageNumbers.length - }); - } else { - setMultiPageDrag(null); - } - }, [selectionMode, selectedPageNumbers]); - - const handleDragEnd = useCallback(() => { - // Clean up drag state regardless of where the drop happened - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - - if (!draggedPage) return; - - // Update drag position for multi-page indicator - if (multiPageDrag) { - setDragPosition({ x: e.clientX, y: e.clientY }); - } - - // Get the element under the mouse cursor - const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); - if (!elementUnderCursor) return; - - // Find the closest page container - const pageContainer = elementUnderCursor.closest('[data-page-number]'); - if (pageContainer) { - const pageNumberStr = pageContainer.getAttribute('data-page-number'); - const pageNumber = pageNumberStr ? parseInt(pageNumberStr) : null; - if (pageNumber && pageNumber !== draggedPage) { - setDropTarget(pageNumber); - return; - } - } - - // Check if over the end zone - const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); - if (endZone) { - setDropTarget('end'); - return; - } - - // If not over any valid drop target, clear it - setDropTarget(null); - }, [draggedPage, multiPageDrag]); - - const handleDragEnter = useCallback((pageNumber: number) => { - if (draggedPage && pageNumber !== draggedPage) { - setDropTarget(pageNumber); - } - }, [draggedPage]); - - const handleDragLeave = useCallback(() => { - // Don't clear drop target on drag leave - let dragover handle it - }, []); - - // Update PDF document state with edit tracking - const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { - console.log('setPdfDocument called - setting edited state'); - - // Update local edit state for immediate visual feedback - setEditedDocument(updatedDoc); - actions.setHasUnsavedChanges(true); // Use actions from context - setHasUnsavedDraft(true); // Mark that we have unsaved draft changes - - // Auto-save to drafts (debounced) - only if we have new changes - - // Enhanced auto-save to drafts with proper error handling - if (autoSaveTimer.current) { - clearTimeout(autoSaveTimer.current); - } - - autoSaveTimer.current = setTimeout(() => { - - autoSaveTimer.current = setTimeout(async () => { - if (hasUnsavedDraft) { - try { - await saveDraftToIndexedDB(updatedDoc); - setHasUnsavedDraft(false); // Mark draft as saved - console.log('Auto-save completed successfully'); - } catch (error) { - console.warn('Auto-save failed, will retry on next change:', error); - // Don't set hasUnsavedDraft to false so it will retry - } - } - }, 30000); // Auto-save after 30 seconds of inactivity - - return updatedDoc; - }, [actions, hasUnsavedDraft]); - - // Enhanced draft save with proper IndexedDB handling - const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { - try { - const draftKey = `draft-${doc.id || 'merged'}`; - const draftData = { - document: doc, - timestamp: Date.now(), - originalFiles: activeFiles.map(f => f.name) - }; - - // Save to 'pdf-drafts' store in IndexedDB - const request = indexedDB.open('stirling-pdf-drafts', 1); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains('drafts')) { - db.createObjectStore('drafts'); - } - }; - - request.onsuccess = () => { - const db = request.result; - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - store.put(draftData, draftKey); - console.log('Draft auto-saved to IndexedDB'); - }; - } catch (error) { - console.warn('Failed to auto-save draft:', error); - - // Robust IndexedDB initialization with proper error handling - const dbRequest = indexedDB.open('stirling-pdf-drafts', 1); - - return new Promise((resolve, reject) => { - dbRequest.onerror = () => { - console.warn('Failed to open draft database:', dbRequest.error); - reject(dbRequest.error); - }; - - dbRequest.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Create object store if it doesn't exist - if (!db.objectStoreNames.contains('drafts')) { - const store = db.createObjectStore('drafts'); - console.log('Created drafts object store'); - } - }; - - dbRequest.onsuccess = () => { - const db = dbRequest.result; - - // Verify object store exists before attempting transaction - if (!db.objectStoreNames.contains('drafts')) { - console.warn('Drafts object store not found, skipping save'); - resolve(); - return; - } - - try { - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - - transaction.onerror = () => { - console.warn('Draft save transaction failed:', transaction.error); - reject(transaction.error); - }; - - transaction.oncomplete = () => { - console.log('Draft auto-saved successfully'); - resolve(); - }; - - const putRequest = store.put(draftData, draftKey); - putRequest.onerror = () => { - console.warn('Failed to put draft data:', putRequest.error); - reject(putRequest.error); - }; - - } catch (error) { - console.warn('Transaction creation failed:', error); - reject(error); - } finally { - db.close(); - } - }; - }); - } - }, [activeFiles]); - - // Enhanced draft cleanup with proper IndexedDB handling - const cleanupDraft = useCallback(async () => { - try { - const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; - const request = indexedDB.open('stirling-pdf-drafts', 1); - - request.onsuccess = () => { - const db = request.result; - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - store.delete(draftKey); - }; - } catch (error) { - console.warn('Failed to cleanup draft:', error); - const dbRequest = indexedDB.open('stirling-pdf-drafts', 1); - - return new Promise((resolve, reject) => { - dbRequest.onerror = () => { - console.warn('Failed to open draft database for cleanup:', dbRequest.error); - resolve(); // Don't fail the whole operation if cleanup fails - }; - - dbRequest.onsuccess = () => { - const db = dbRequest.result; - - // Check if object store exists before attempting cleanup - if (!db.objectStoreNames.contains('drafts')) { - console.log('No drafts object store found, nothing to cleanup'); - resolve(); - return; - } - - try { - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - - transaction.onerror = () => { - console.warn('Draft cleanup transaction failed:', transaction.error); - resolve(); // Don't fail if cleanup fails - }; - - transaction.oncomplete = () => { - console.log('Draft cleaned up successfully'); - resolve(); - }; - - const deleteRequest = store.delete(draftKey); - deleteRequest.onerror = () => { - console.warn('Failed to delete draft:', deleteRequest.error); - resolve(); // Don't fail if delete fails - }; - - } catch (error) { - console.warn('Draft cleanup transaction creation failed:', error); - resolve(); // Don't fail if cleanup fails - } finally { - db.close(); - } - }; - }); - } - }, [mergedPdfDocument]); - - // Apply changes to create new processed file - const applyChanges = useCallback(async () => { - if (!editedDocument || !mergedPdfDocument) return; - - try { - if (activeFiles.length === 1) { - const file = activeFiles[0]; - const currentProcessedFile = processedFiles.get(file); - - if (currentProcessedFile) { - const updatedProcessedFile = { - ...currentProcessedFile, - id: `${currentProcessedFile.id}-edited-${Date.now()}`, - pages: editedDocument.pages.map(page => ({ - ...page, - rotation: page.rotation || 0, - splitBefore: page.splitBefore || false - })), - totalPages: editedDocument.pages.length, - lastModified: Date.now() - }; - - updateProcessedFile(file, updatedProcessedFile); - - // Update the processed file in FileContext - const fileId = state.files.ids.find(id => state.files.byId[id]?.file === file); - if (fileId) { - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { processedFile: updatedProcessedFile } - } - }); - } - } - } else if (activeFiles.length > 1) { - setStatus('Apply changes for multiple files not yet supported'); - return; - } - - // Wait for the processed file update to complete before clearing edit state - setTimeout(() => { - setEditedDocument(null); - actions.setHasUnsavedChanges(false); - setHasUnsavedDraft(false); - cleanupDraft(); - setStatus('Changes applied successfully'); - }, 100); - - } catch (error) { - console.error('Failed to apply changes:', error); - setStatus('Failed to apply changes'); - } - }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, state.files.ids, state.files.byId, actions, dispatch, cleanupDraft]); - - const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { - if (!displayDocument || isAnimating) return; - - // In selection mode, if the dragged page is selected, move all selected pages - const pagesToMove = selectionMode && selectedPageNumbers.includes(pageNumber) - ? selectedPageNumbers.map(num => { - const page = displayDocument.pages.find(p => p.pageNumber === num); - return page?.id || ''; - }).filter(id => id) - : [displayDocument.pages.find(p => p.pageNumber === pageNumber)?.id || ''].filter(id => id); - - const originalIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNumber); - if (originalIndex === -1 || originalIndex === targetIndex) return; - - // Skip animation for large documents (500+ pages) to improve performance - const isLargeDocument = displayDocument.pages.length > 500; - - if (isLargeDocument) { - // For large documents, just execute the command without animation - if (pagesToMove.length > 1) { - const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); - executeCommand(command); - } else { - const pageId = pagesToMove[0]; - const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); - executeCommand(command); - } - return; - } - - setIsAnimating(true); - - // For smaller documents, determine which pages might be affected by the move - const startIndex = Math.min(originalIndex, targetIndex); - const endIndex = Math.max(originalIndex, targetIndex); - const affectedPageIds = displayDocument.pages - .slice(Math.max(0, startIndex - 5), Math.min(displayDocument.pages.length, endIndex + 5)) - .map(p => p.id); - - // Only capture positions for potentially affected pages - const currentPositions = new Map(); - - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`); - if (element) { - const rect = element.getBoundingClientRect(); - currentPositions.set(pageId, { x: rect.left, y: rect.top }); - } - }); - - // Execute the reorder command - if (pagesToMove.length > 1) { - const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); - executeCommand(command); - } else { - const pageId = pagesToMove[0]; - const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); - executeCommand(command); - } - - // Animate only the affected pages - setTimeout(() => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const newPositions = new Map(); - - // Get new positions only for affected pages - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`); - if (element) { - const rect = element.getBoundingClientRect(); - newPositions.set(pageId, { x: rect.left, y: rect.top }); - } - }); - - const elementsToAnimate: HTMLElement[] = []; - - // Apply animations only to pages that actually moved - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`) as HTMLElement; - if (!element) return; - - const currentPos = currentPositions.get(pageId); - const newPos = newPositions.get(pageId); - - if (currentPos && newPos) { - const deltaX = currentPos.x - newPos.x; - const deltaY = currentPos.y - newPos.y; - - if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { - elementsToAnimate.push(element); - - // Apply initial transform - element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; - element.style.transition = 'none'; - - // Force reflow - element.offsetHeight; - - // Animate to final position - element.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; - element.style.transform = 'translate(0px, 0px)'; - } - } - }); - - // Clean up after animation (only for animated elements) - setTimeout(() => { - elementsToAnimate.forEach((element) => { - element.style.transform = ''; - element.style.transition = ''; - }); - setIsAnimating(false); - }, 300); - }); - }); - }, 10); // Small delay to allow state update - }, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]); - - const handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => { - e.preventDefault(); - if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return; - - let targetIndex: number; - if (targetPageNumber === 'end') { - targetIndex = displayDocument.pages.length; - } else { - targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); - if (targetIndex === -1) return; - } - - animateReorder(draggedPage, targetIndex); - - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - - const moveCount = multiPageDrag ? multiPageDrag.count : 1; - setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); - }, [draggedPage, displayDocument, animateReorder, multiPageDrag]); - - const handleEndZoneDragEnter = useCallback(() => { - if (draggedPage) { - setDropTarget('end'); - } - }, [draggedPage]); - - const handleRotate = useCallback((direction: 'left' | 'right') => { - if (!displayDocument) return; - - const rotation = direction === 'left' ? -90 : 90; - const pagesToRotate = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : displayDocument.pages.map(p => p.id); - - if (selectionMode && selectedPageNumbers.length === 0) return; - - const command = new RotatePagesCommand( - displayDocument, - setPdfDocument, - pagesToRotate, - rotation - ); - - executeCommand(command); - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Rotated ${pageCount} pages ${direction}`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); - - const handleDelete = useCallback(() => { - if (!displayDocument) return; - - const pagesToDelete = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : displayDocument.pages.map(p => p.id); - - if (selectionMode && selectedPageNumbers.length === 0) return; - - const command = new DeletePagesCommand( - displayDocument, - setPdfDocument, - pagesToDelete - ); - - executeCommand(command); - if (selectionMode) { - setSelectedPages([]); - } - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Deleted ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]); - - const handleSplit = useCallback(() => { - if (!displayDocument) return; - - const pagesToSplit = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : displayDocument.pages.map(p => p.id); - - if (selectionMode && selectedPageNumbers.length === 0) return; - - const command = new ToggleSplitCommand( - displayDocument, - setPdfDocument, - pagesToSplit - ); - - executeCommand(command); - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Split markers toggled for ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); - - const showExportPreview = useCallback((selectedOnly: boolean = false) => { - if (!mergedPdfDocument) return; - - // Convert page numbers to page IDs for export service - const exportPageIds = selectedOnly - ? selectedPageNumbers.map(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : []; - - const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); - setExportPreview(preview); - setShowExportModal(true); - }, [mergedPdfDocument, selectedPageNumbers]); - - const handleExport = useCallback(async (selectedOnly: boolean = false) => { - if (!mergedPdfDocument) return; - - setExportLoading(true); - try { - // Convert page numbers to page IDs for export service - const exportPageIds = selectedOnly - ? selectedPageNumbers.map(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : []; - - const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); - if (errors.length > 0) { - setStatus(errors.join(', ')); - return; - } - - const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore); - - if (hasSplitMarkers) { - const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { - selectedOnly, - filename, - splitDocuments: true - }) as { blobs: Blob[]; filenames: string[] }; - - result.blobs.forEach((blob, index) => { - setTimeout(() => { - pdfExportService.downloadFile(blob, result.filenames[index]); - }, index * 500); - }); - - setStatus(`Exported ${result.blobs.length} split documents`); - } else { - const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { - selectedOnly, - filename - }) as { blob: Blob; filename: string }; - - pdfExportService.downloadFile(result.blob, result.filename); - setStatus('PDF exported successfully'); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Export failed'; - setStatus(errorMessage); - } finally { - setExportLoading(false); - } - }, [mergedPdfDocument, selectedPageNumbers, filename]); - - const handleUndo = useCallback(() => { - if (undo()) { - setStatus('Operation undone'); - } - }, [undo]); - - const handleRedo = useCallback(() => { - if (redo()) { - setStatus('Operation redone'); - } - }, [redo]); - - const closePdf = useCallback(() => { - // Use global navigation guard system - actions.requestNavigation(() => { - clearAllFiles(); // This now handles all cleanup centrally (including merged docs) - setSelectedPages([]); - }); - }, [actions, clearAllFiles, setSelectedPages]); - - // PageEditorControls needs onExportSelected and onExportAll - const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); - const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); - - /** - * Stable function proxy pattern to prevent infinite loops. - * - * Problem: If we include selectedPages in useEffect dependencies, every page selection - * change triggers onFunctionsReady β†’ parent re-renders β†’ PageEditor unmounts/remounts β†’ infinite loop - * - * Solution: Create a stable proxy object that uses getters to access current values - * without triggering parent re-renders when values change. - */ - const pageEditorFunctionsRef = useRef({ - handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, - showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, - selectedPages: selectedPageNumbers, closePdf, - }); - - // Update ref with current values (no parent notification) - pageEditorFunctionsRef.current = { - handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, - showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, - selectedPages: selectedPageNumbers, closePdf, - }; - - // Only call onFunctionsReady once - use stable proxy for live updates - useEffect(() => { - if (onFunctionsReady) { - const stableFunctions = { - get handleUndo() { return pageEditorFunctionsRef.current.handleUndo; }, - get handleRedo() { return pageEditorFunctionsRef.current.handleRedo; }, - get canUndo() { return pageEditorFunctionsRef.current.canUndo; }, - get canRedo() { return pageEditorFunctionsRef.current.canRedo; }, - get handleRotate() { return pageEditorFunctionsRef.current.handleRotate; }, - get handleDelete() { return pageEditorFunctionsRef.current.handleDelete; }, - get handleSplit() { return pageEditorFunctionsRef.current.handleSplit; }, - get showExportPreview() { return pageEditorFunctionsRef.current.showExportPreview; }, - get onExportSelected() { return pageEditorFunctionsRef.current.onExportSelected; }, - get onExportAll() { return pageEditorFunctionsRef.current.onExportAll; }, - get exportLoading() { return pageEditorFunctionsRef.current.exportLoading; }, - get selectionMode() { return pageEditorFunctionsRef.current.selectionMode; }, - get selectedPages() { return pageEditorFunctionsRef.current.selectedPages; }, - get closePdf() { return pageEditorFunctionsRef.current.closePdf; }, - }; - onFunctionsReady(stableFunctions); - } - }, [onFunctionsReady]); - - // Show loading or empty state instead of blocking - const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); - const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0; - // Functions for global NavigationWarningModal - const handleApplyAndContinue = useCallback(async () => { - if (editedDocument) { - await applyChanges(); - } - }, [editedDocument, applyChanges]); - - const handleExportAndContinue = useCallback(async () => { - if (editedDocument) { - await applyChanges(); - await handleExport(false); - } - }, [editedDocument, applyChanges, handleExport]); - - // Enhanced draft checking with proper IndexedDB handling - const checkForDrafts = useCallback(async () => { - if (!mergedPdfDocument) return; - - try { - const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; - const request = indexedDB.open('stirling-pdf-drafts', 1); - - request.onsuccess = () => { - const db = request.result; - if (!db.objectStoreNames.contains('drafts')) return; - - const transaction = db.transaction('drafts', 'readonly'); - const store = transaction.objectStore('drafts'); - const getRequest = store.get(draftKey); - - getRequest.onsuccess = () => { - const draft = getRequest.result; - if (draft && draft.timestamp) { - // Check if draft is recent (within last 24 hours) - const draftAge = Date.now() - draft.timestamp; - const twentyFourHours = 24 * 60 * 60 * 1000; - - if (draftAge < twentyFourHours) { - setFoundDraft(draft); - setShowResumeModal(true); - } - } - }; - }; - const dbRequest = indexedDB.open('stirling-pdf-drafts', 1); - - return new Promise((resolve, reject) => { - dbRequest.onerror = () => { - console.warn('Failed to open draft database for checking:', dbRequest.error); - resolve(); // Don't fail if draft checking fails - }; - - dbRequest.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Create object store if it doesn't exist - if (!db.objectStoreNames.contains('drafts')) { - const store = db.createObjectStore('drafts'); - console.log('Created drafts object store during check'); - } - }; - - dbRequest.onsuccess = () => { - const db = dbRequest.result; - - // Check if object store exists - if (!db.objectStoreNames.contains('drafts')) { - console.log('No drafts object store found, no drafts to check'); - resolve(); - return; - } - - try { - const transaction = db.transaction('drafts', 'readonly'); - const store = transaction.objectStore('drafts'); - - transaction.onerror = () => { - console.warn('Draft check transaction failed:', transaction.error); - resolve(); // Don't fail if checking fails - }; - - const getRequest = store.get(draftKey); - - getRequest.onerror = () => { - console.warn('Failed to get draft:', getRequest.error); - resolve(); // Don't fail if get fails - }; - - getRequest.onsuccess = () => { - const draft = getRequest.result; - if (draft && draft.timestamp) { - // Check if draft is recent (within last 24 hours) - const draftAge = Date.now() - draft.timestamp; - const twentyFourHours = 24 * 60 * 60 * 1000; - - if (draftAge < twentyFourHours) { - console.log('Found recent draft, showing resume modal'); - setFoundDraft(draft); - setShowResumeModal(true); - } else { - console.log('Draft found but too old, cleaning up'); - // Clean up old draft - try { - const cleanupTransaction = db.transaction('drafts', 'readwrite'); - const cleanupStore = cleanupTransaction.objectStore('drafts'); - cleanupStore.delete(draftKey); - } catch (cleanupError) { - console.warn('Failed to cleanup old draft:', cleanupError); - } - } - } else { - console.log('No draft found'); - } - resolve(); - }; - - } catch (error) { - console.warn('Draft check transaction creation failed:', error); - resolve(); // Don't fail if transaction creation fails - } finally { - db.close(); - } - }; - }); - - } catch (error) { - console.warn('Draft check failed:', error); - // Don't throw - draft checking failure shouldn't break the app - } - }, [mergedPdfDocument]); - - // Resume work from draft - const resumeWork = useCallback(() => { - if (foundDraft && foundDraft.document) { - setEditedDocument(foundDraft.document); - actions.setHasUnsavedChanges(true); // Use context action - setFoundDraft(null); - setShowResumeModal(false); - setStatus('Resumed previous work'); - } - }, [foundDraft, actions]); - - // Start fresh (ignore draft) - const startFresh = useCallback(() => { - if (foundDraft) { - // Clean up the draft - cleanupDraft(); - } - setFoundDraft(null); - setShowResumeModal(false); - }, [foundDraft, cleanupDraft]); - - // Cleanup on unmount - useEffect(() => { - return () => { - console.log('PageEditor unmounting - cleaning up resources'); - - // Clear auto-save timer - if (autoSaveTimer.current) { - clearTimeout(autoSaveTimer.current); - } - - // Clean up draft if component unmounts with unsaved changes - if (hasUnsavedChanges) { - cleanupDraft(); - } - }; - }, [hasUnsavedChanges, cleanupDraft]); - - // Check for drafts when document loads - useEffect(() => { - if (mergedPdfDocument && !editedDocument && !hasUnsavedChanges) { - // Small delay to let the component settle - setTimeout(checkForDrafts, 1000); - } - }, [mergedPdfDocument, editedDocument, hasUnsavedChanges, checkForDrafts]); - - // Global navigation intercept - listen for navigation events - useEffect(() => { - if (!hasUnsavedChanges) return; - - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - e.preventDefault(); - e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; - return 'You have unsaved changes. Are you sure you want to leave?'; - }; - - // Intercept browser navigation - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - }; - }, [hasUnsavedChanges]); - - // Display all pages - use edited or original document - const displayedPages = displayDocument?.pages || []; - - return ( - - - - {showEmpty && ( -
- - πŸ“„ - No PDF files loaded - Add files to start editing pages - -
- )} - - {showLoading && ( - - - - {/* Progress indicator */} - - - - Processing PDF files... - - - {Math.round(processingProgress || 0)}% - - -
-
-
- - - - - )} - - {displayDocument && ( - - {/* Enhanced Processing Status */} - {globalProcessing && processingProgress < 100 && ( - - - Processing thumbnails... - {Math.round(processingProgress || 0)}% - -
-
-
- - )} - - - setFilename(e.target.value)} - placeholder="Enter filename" - style={{ minWidth: 200 }} - /> - - {selectionMode && ( - <> - - - - )} - - {/* Apply Changes Button */} - {hasUnsavedChanges && ( - - )} - - - {selectionMode && ( - - )} - - - ( - - )} - renderSplitMarker={(page, index) => ( -
- )} - /> - - - )} - - {/* Modal should be outside the conditional but inside the main container */} - setShowExportModal(false)} - title="Export Preview" - > - {exportPreview && ( - - - Pages to export: - {exportPreview.pageCount} - - - {exportPreview.splitCount > 1 && ( - - Split into documents: - {exportPreview.splitCount} - - )} - - - Estimated size: - {exportPreview.estimatedSize} - - - {mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && ( - - This will create multiple PDF files based on split markers. - - )} - - - - - - - )} - - - {/* Global Navigation Warning Modal */} - - - {/* Resume Work Modal */} - - - - We found unsaved changes from a previous session. Would you like to resume where you left off? - - - {foundDraft && ( - - Last saved: {new Date(foundDraft.timestamp).toLocaleString()} - - )} - - - - - - - - - - {status && ( - setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} - > - {status} - - )} - - {error && ( - setError(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} - > - {error} - - )} - - ); -}; - -export default PageEditor; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 15c6bbe37..cd5b47a19 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -94,25 +94,19 @@ const PageThumbnail = React.memo(({ // Listen for ready thumbnails from Web Workers (only if no existing thumbnail) useEffect(() => { if (thumbnailUrl) { - console.log(`πŸ“Έ PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`); return; // Skip if we already have a thumbnail } - console.log(`πŸ“Έ PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`); - const handleThumbnailReady = (event: CustomEvent) => { const { pageNumber, thumbnail, pageId } = event.detail; - console.log(`πŸ“Έ PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`); if (pageNumber === page.pageNumber && pageId === page.id) { - console.log(`βœ“ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`); setThumbnailUrl(thumbnail); } }; window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener); return () => { - console.log(`πŸ“Έ PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`); window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener); }; }, [page.pageNumber, page.id, thumbnailUrl]); diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index d8b632c41..5324a87e1 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -6,6 +6,7 @@ import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig"; import { isImageFormat, isWebFormat } from "../../../utils/convertUtils"; import { useToolFileSelection } from "../../../contexts/FileContext"; import { useFileState } from "../../../contexts/FileContext"; +import { createStableFileId } from "../../../types/fileContext"; import { detectFileExtension } from "../../../utils/fileUtils"; import GroupedFormatDropdown from "./GroupedFormatDropdown"; import ConvertToImageSettings from "./ConvertToImageSettings"; @@ -41,7 +42,7 @@ const ConvertSettings = ({ const theme = useMantineTheme(); const { colorScheme } = useMantineColorScheme(); const { setSelectedFiles } = useToolFileSelection(); - const { state } = useFileState(); + const { state, selectors } = useFileState(); const activeFiles = state.files.ids; const allEndpoints = useMemo(() => { @@ -135,7 +136,7 @@ const ConvertSettings = ({ }; const filterFilesByExtension = (extension: string) => { - const files = activeFiles.map(fileId => state.files.byId[fileId]?.file).filter(Boolean); + const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[]; return files.filter(file => { const fileExtension = detectFileExtension(file.name); @@ -150,7 +151,7 @@ const ConvertSettings = ({ }; const updateFileSelection = (files: File[]) => { - setSelectedFiles(files); + setSelectedFiles(files.map(f => createStableFileId(f))); }; const handleFromExtensionChange = (value: string) => { diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index 5672bc936..17aaca490 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -13,7 +13,7 @@ import CloseIcon from "@mui/icons-material/Close"; import { useLocalStorage } from "@mantine/hooks"; import { fileStorage } from "../../services/fileStorage"; import SkeletonLoader from '../shared/SkeletonLoader'; -import { useFileContext } from "../../contexts/FileContext"; +import { useFileState, useFileActions, useCurrentFile, useProcessedFiles } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; GlobalWorkerOptions.workerSrc = "/pdf.worker.js"; @@ -150,7 +150,17 @@ const Viewer = ({ const theme = useMantineTheme(); // Get current file from FileContext - const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext(); + const { selectors } = useFileState(); + const { actions } = useFileActions(); + const currentFile = useCurrentFile(); + const processedFiles = useProcessedFiles(); + + // Map legacy functions + const getCurrentFile = () => currentFile.file; + const getCurrentProcessedFile = () => currentFile.file ? processedFiles.getProcessedFile(currentFile.file) : undefined; + const clearAllFiles = actions.clearAllFiles; + const addFiles = actions.addFiles; + const activeFiles = selectors.getFiles(); // Tab management for multiple files const [activeTab, setActiveTab] = useState("0"); @@ -465,7 +475,7 @@ const Viewer = ({ > handleTabChange(value || "0")}> - {activeFiles.map((file, index) => ( + {activeFiles.map((file: any, index: number) => ( {file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name} diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 0382a6e27..19f39e8b4 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -22,15 +22,15 @@ * - Individual hooks (useFileRecord) are the most performant option */ -import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react'; +import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef, useMemo } from 'react'; import { - FileContextValue, FileContextState, FileContextProviderProps, FileContextSelectors, FileContextStateValue, FileContextActionsValue, FileContextActions, + FileContextAction, ModeType, FileId, FileRecord, @@ -38,64 +38,40 @@ import { revokeFileResources, createStableFileId } from '../types/fileContext'; -import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; + +// Mock services - these will need proper implementation +const enhancedPDFProcessingService = { + clearAllProcessing: () => {}, + cancelProcessing: (fileId: string) => {} +}; + +const thumbnailGenerationService = { + destroy: () => {}, + stopGeneration: () => {} +}; + +const fileStorage = { + deleteFile: async (fileId: string) => {} +}; // Initial state -const initialViewerConfig: ViewerConfig = { - zoom: 1.0, - currentPage: 1, - viewMode: 'single', - sidebarOpen: false +const initialFileContextState: FileContextState = { + files: { + ids: [], + byId: {} + }, + ui: { + currentMode: 'pageEditor' as ModeType, + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false, + pendingNavigation: null, + showNavigationWarning: false + } }; -const initialState: FileContextState = { - activeFiles: [], - processedFiles: new Map(), - currentMode: 'pageEditor', - currentView: 'fileEditor', // Legacy field - currentTool: null, // Legacy field - fileEditHistory: new Map(), - globalFileOperations: [], - fileOperationHistory: new Map(), - selectedFileIds: [], - selectedPageNumbers: [], - viewerConfig: initialViewerConfig, - isProcessing: false, - processingProgress: 0, - lastExportConfig: undefined, - hasUnsavedChanges: false, - pendingNavigation: null, - showNavigationWarning: false -}; - -// 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 } - | { type: 'UPDATE_PROCESSED_FILE'; payload: { file: File; processedFile: ProcessedFile } } - | { type: 'SET_CURRENT_MODE'; payload: ModeType } - | { type: 'SET_CURRENT_VIEW'; payload: ViewType } - | { type: 'SET_CURRENT_TOOL'; payload: ToolType } - | { type: 'SET_SELECTED_FILES'; payload: string[] } - | { type: 'SET_SELECTED_PAGES'; payload: number[] } - | { type: 'CLEAR_SELECTIONS' } - | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } - | { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial } - | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } } - | { type: 'ADD_FILE_OPERATION'; payload: FileOperation } - | { type: 'RECORD_OPERATION'; payload: { fileId: string; operation: FileOperation | PageOperation } } - | { type: 'MARK_OPERATION_APPLIED'; payload: { fileId: string; operationId: string } } - | { type: 'MARK_OPERATION_FAILED'; payload: { fileId: string; operationId: string; error: string } } - | { type: 'CLEAR_FILE_HISTORY'; payload: string } - | { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] } - | { type: 'SET_UNSAVED_CHANGES'; payload: boolean } - | { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null } - | { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean } - | { type: 'RESET_CONTEXT' } - | { type: 'LOAD_STATE'; payload: Partial }; - // Reducer function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { switch (action.type) { @@ -168,32 +144,15 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): } case 'SET_CURRENT_MODE': { - const coreViews = ['viewer', 'pageEditor', 'fileEditor']; - const isToolMode = !coreViews.includes(action.payload); - return { ...state, - files: { - ids: [], - byId: {} - }, ui: { ...state.ui, - selectedFileIds: [], - selectedPageNumbers: [] + currentMode: action.payload } }; } - case 'SET_MODE': { - return { - ...state, - ui: { - ...state.ui, - currentMode: action.payload.mode - } - }; - } case 'SET_SELECTED_FILES': { return { @@ -232,133 +191,11 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): ui: { ...state.ui, isProcessing: action.payload.isProcessing, - processingProgress: action.payload.progress + processingProgress: action.payload.progress || 0 } }; } - // Tool selection cases (replaces FileSelectionContext) - case 'SET_TOOL_MODE': { - 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 'RECORD_OPERATION': { - const { fileId, operation } = action.payload; - const newOperationHistory = new Map(state.fileOperationHistory); - const existingHistory = newOperationHistory.get(fileId); - - if (existingHistory) { - // Add operation to existing history - newOperationHistory.set(fileId, { - ...existingHistory, - operations: [...existingHistory.operations, operation], - lastModified: Date.now() - }); - } else { - // Create new history for this file - newOperationHistory.set(fileId, { - fileId, - fileName: fileId, // Will be updated with actual filename when available - operations: [operation], - createdAt: Date.now(), - lastModified: Date.now() - }); - } - - return { - ...state, - fileOperationHistory: newOperationHistory - }; - } - - case 'MARK_OPERATION_APPLIED': { - const appliedHistory = new Map(state.fileOperationHistory); - const appliedFileHistory = appliedHistory.get(action.payload.fileId); - - if (appliedFileHistory) { - const updatedOperations = appliedFileHistory.operations.map(op => - op.id === action.payload.operationId - ? { ...op, status: 'applied' as const } - : op - ); - appliedHistory.set(action.payload.fileId, { - ...appliedFileHistory, - operations: updatedOperations, - lastModified: Date.now() - }); - } - - return { - ...state, - fileOperationHistory: appliedHistory - }; - } - - case 'MARK_OPERATION_FAILED': { - const failedHistory = new Map(state.fileOperationHistory); - const failedFileHistory = failedHistory.get(action.payload.fileId); - - if (failedFileHistory) { - const updatedOperations = failedFileHistory.operations.map(op => - op.id === action.payload.operationId - ? { - ...op, - status: 'failed' as const, - metadata: { ...op.metadata, error: action.payload.error } - } - : op - ); - failedHistory.set(action.payload.fileId, { - ...failedFileHistory, - operations: updatedOperations, - lastModified: Date.now() - }); - } - - return { - ...state, - fileOperationHistory: failedHistory - }; - } - - case 'CLEAR_FILE_HISTORY': { - const clearedHistory = new Map(state.fileOperationHistory); - clearedHistory.delete(action.payload); - return { - ...state, - fileOperationHistory: clearedHistory - }; - } - case 'SET_UNSAVED_CHANGES': { return { ...state, @@ -389,31 +226,6 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): }; } - case 'CONFIRM_NAVIGATION': { - const pendingNavigation = state.ui.pendingNavigation; - if (pendingNavigation) { - pendingNavigation(); - } - return { - ...state, - ui: { - ...state.ui, - pendingNavigation: null, - showNavigationWarning: false - } - }; - } - - case 'CANCEL_NAVIGATION': { - return { - ...state, - ui: { - ...state.ui, - pendingNavigation: null, - showNavigationWarning: false - } - }; - } case 'RESET_CONTEXT': { // Clean up all resources before reset @@ -430,52 +242,77 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): const FileStateContext = createContext(undefined); const FileActionsContext = createContext(undefined); +// Legacy context for backward compatibility +const FileContext = createContext(undefined); + // Provider component export function FileContextProvider({ children, enableUrlSync = true, enablePersistence = true }: FileContextProviderProps) { - const [state, dispatch] = useReducer(fileContextReducer, initialState); + const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState); + // File ref map - stores File objects outside React state + const filesRef = useRef>(new Map()); + // Cleanup timers and refs - const cleanupTimers = useRef>(new Map()); + const cleanupTimers = useRef>(new Map()); const blobUrls = useRef>(new Set()); - const pdfDocuments = useRef>(new Map()); + const pdfDocuments = useRef>(new Map()); - // Enhanced file processing hook - const { - processedFiles, - processingStates, - isProcessing: globalProcessing, - processingProgress, - actions: processingActions - } = useEnhancedProcessedFiles(state.activeFiles, { - strategy: 'progressive_chunked', - thumbnailQuality: 'medium', - chunkSize: 5, // Process 5 pages at a time for smooth progress - priorityPageCount: 0 // No special priority pages - }); - - // Update processed files when they change - useEffect(() => { - dispatch({ type: 'SET_PROCESSED_FILES', payload: processedFiles }); - dispatch({ - type: 'SET_PROCESSING', - payload: { - isProcessing: globalProcessing, - progress: processingProgress.overall - } - }); - }, [processedFiles, globalProcessing, processingProgress.overall]); + // Stable state reference for selectors + const stateRef = useRef(state); + stateRef.current = state; + // Stable selectors (memoized once to avoid re-renders) + const selectors = useMemo(() => ({ + getFile: (id: FileId) => filesRef.current.get(id), + + getFiles: (ids?: FileId[]) => { + const currentIds = ids || stateRef.current.files.ids; + return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; + }, + + getFileRecord: (id: FileId) => stateRef.current.files.byId[id], + + getFileRecords: (ids?: FileId[]) => { + const currentIds = ids || stateRef.current.files.ids; + return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); + }, + + getAllFileIds: () => stateRef.current.files.ids, + + getSelectedFiles: () => { + return stateRef.current.ui.selectedFileIds + .map(id => filesRef.current.get(id)) + .filter(Boolean) as File[]; + }, + + getSelectedFileRecords: () => { + return stateRef.current.ui.selectedFileIds + .map(id => stateRef.current.files.byId[id]) + .filter(Boolean); + }, + + // Stable signature for effects - prevents unnecessary re-renders + getFilesSignature: () => { + return stateRef.current.files.ids + .map(id => { + const record = stateRef.current.files.byId[id]; + return record ? `${id}:${record.size}:${record.lastModified}` : ''; + }) + .filter(Boolean) + .join('|'); + } + }), []); // Empty dependency array - selectors are now stable // Centralized memory management const trackBlobUrl = useCallback((url: string) => { blobUrls.current.add(url); }, []); - const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => { + 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) { @@ -506,11 +343,6 @@ export function FileContextProvider({ 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); } @@ -553,8 +385,8 @@ export function FileContextProvider({ thumbnailGenerationService.destroy(); // Force garbage collection hint - if (typeof window !== 'undefined' && window.gc) { - let gc = window.gc + if (typeof window !== 'undefined' && (window as any).gc) { + let gc = (window as any).gc; setTimeout(() => gc(), 100); } @@ -577,7 +409,7 @@ export function FileContextProvider({ } // Schedule new cleanup - const timer = setTimeout(() => { + const timer = window.setTimeout(() => { cleanupFile(fileId); }, delay); @@ -586,48 +418,32 @@ export function FileContextProvider({ // Action implementations const addFiles = useCallback(async (files: File[]): Promise => { - dispatch({ type: 'ADD_FILES', payload: files }); - - // Auto-save to IndexedDB if persistence enabled - if (enablePersistence) { - for (const file of files) { - try { - // Check if file already has an explicit ID property (already in IndexedDB) - const fileId = getFileId(file); - if (!fileId) { - // File doesn't have explicit ID, store it with thumbnail - try { - // Generate thumbnail for better recent files experience - const thumbnail = await (thumbnailGenerationService as any /* FIX ME */).generateThumbnail(file); - const storedFile = await fileStorage.storeFile(file, thumbnail); - // Add the ID to the file object - Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); - } catch (thumbnailError) { - // If thumbnail generation fails, store without thumbnail - console.warn('Failed to generate thumbnail, storing without:', thumbnailError); - const storedFile = await fileStorage.storeFile(file); - Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); - } - } - } catch (error) { - console.error('Failed to store file:', error); - } + // Store Files in ref map with stable IDs + const fileIds: FileId[] = []; + for (const file of files) { + const stableId = createStableFileId(file); + // Dedupe - only add if not already present + if (!filesRef.current.has(stableId)) { + filesRef.current.set(stableId, file); + fileIds.push(stableId); } } + + // Dispatch only the file metadata to state + dispatch({ type: 'ADD_FILES', payload: { files } }); // Return files with their IDs assigned return files; }, [enablePersistence]); - const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => { - // FULL cleanup for actually removed files (including cache) + const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => { + // Clean up Files from ref map fileIds.forEach(fileId => { - // Cancel processing and clear caches when file is actually removed - enhancedPDFProcessingService.cancelProcessing(fileId); + filesRef.current.delete(fileId); cleanupFile(fileId); }); - dispatch({ type: 'REMOVE_FILES', payload: fileIds }); + dispatch({ type: 'REMOVE_FILES', payload: { fileIds } }); // Remove from IndexedDB only if requested if (enablePersistence && deleteFromStorage) { @@ -641,228 +457,101 @@ export function FileContextProvider({ } }, [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' }); - }, [cleanupAllFiles]); - // Navigation guard system functions const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { - dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges }); + dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []); const requestNavigation = useCallback((navigationFn: () => void): boolean => { - if (state.hasUnsavedChanges) { - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: navigationFn }); - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: true }); + if (state.ui.hasUnsavedChanges) { + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); return false; } else { navigationFn(); return true; } - }, [state.hasUnsavedChanges]); + }, [state.ui.hasUnsavedChanges]); const confirmNavigation = useCallback(() => { - if (state.pendingNavigation) { - state.pendingNavigation(); - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); + if (state.ui.pendingNavigation) { + state.ui.pendingNavigation(); + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); } - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); - }, [state.pendingNavigation]); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); + }, [state.ui.pendingNavigation]); const cancelNavigation = useCallback(() => { - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); }, []); - const setCurrentMode = useCallback((mode: ModeType) => { - requestNavigation(() => { - dispatch({ type: 'SET_CURRENT_MODE', payload: mode }); + // Memoized actions to prevent re-renders + const actions = useMemo(() => ({ + addFiles, + removeFiles, + clearAllFiles: () => { + cleanupAllFiles(); + filesRef.current.clear(); + dispatch({ type: 'RESET_CONTEXT' }); + }, + setCurrentMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), + setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }), + setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }), + clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }), + setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }), + setHasUnsavedChanges, + resetContext: () => { + cleanupAllFiles(); + filesRef.current.clear(); + dispatch({ type: 'RESET_CONTEXT' }); + }, + // Legacy compatibility + setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), + confirmNavigation, + cancelNavigation + }), [addFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]); - if (state.currentMode !== mode && state.activeFiles.length > 0) { - if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { - let gc = window.gc; - window.requestIdleCallback(() => { - gc(); - }, { timeout: 5000 }); - } - } - }); - }, [requestNavigation, state.currentMode, state.activeFiles]); + // Split context values to minimize re-renders + const stateValue = useMemo(() => ({ + state, + selectors + }), [state]); // selectors are now stable, no need to depend on them - const setCurrentView = useCallback((view: ViewType) => { - requestNavigation(() => { - dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); + const actionsValue = useMemo(() => ({ + actions, + dispatch + }), [actions]); - if (state.currentView !== view && state.activeFiles.length > 0) { - if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { - let gc = window.gc; - window.requestIdleCallback(() => { - gc(); - }, { timeout: 5000 }); - } - } - }); - }, [requestNavigation, state.currentView, state.activeFiles]); - - const setCurrentTool = useCallback((tool: ToolType) => { - requestNavigation(() => { - dispatch({ type: 'SET_CURRENT_TOOL', payload: tool }); - }); - }, [requestNavigation]); - - const setSelectedFiles = useCallback((fileIds: string[]) => { - dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds }); - }, []); - - const setSelectedPages = useCallback((pageNumbers: number[]) => { - dispatch({ type: 'SET_SELECTED_PAGES', payload: pageNumbers }); - }, []); - - const updateProcessedFile = useCallback((file: File, processedFile: ProcessedFile) => { - dispatch({ type: 'UPDATE_PROCESSED_FILE', payload: { file, processedFile } }); - }, []); - - 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) => { - console.warn('Undo not yet implemented'); - }, []); - - const updateViewerConfig = useCallback((config: Partial) => { - dispatch({ type: 'UPDATE_VIEWER_CONFIG', payload: config }); - }, []); - - const setExportConfig = useCallback((config: FileContextState['lastExportConfig']) => { - dispatch({ type: 'SET_EXPORT_CONFIG', payload: config }); - }, []); - - // Operation history management functions - const recordOperation = useCallback((fileId: string, operation: FileOperation | PageOperation) => { - dispatch({ type: 'RECORD_OPERATION', payload: { fileId, operation } }); - }, []); - - const markOperationApplied = useCallback((fileId: string, operationId: string) => { - dispatch({ type: 'MARK_OPERATION_APPLIED', payload: { fileId, operationId } }); - }, []); - - const markOperationFailed = useCallback((fileId: string, operationId: string, error: string) => { - dispatch({ type: 'MARK_OPERATION_FAILED', payload: { fileId, operationId, error } }); - }, []); - - const getFileHistory = useCallback((fileId: string): FileOperationHistory | undefined => { - return state.fileOperationHistory.get(fileId); - }, [state.fileOperationHistory]); - - const getAppliedOperations = useCallback((fileId: string): (FileOperation | PageOperation)[] => { - const history = state.fileOperationHistory.get(fileId); - return history ? history.operations.filter(op => op.status === 'applied') : []; - }, [state.fileOperationHistory]); - - const clearFileHistory = useCallback((fileId: string) => { - dispatch({ type: 'CLEAR_FILE_HISTORY', payload: fileId }); - }, []); - - // Utility functions - const getFileById = useCallback((fileId: string): File | undefined => { - return state.activeFiles.find(file => { - const actualFileId = getFileId(file); - return actualFileId && actualFileId === fileId; - }); - }, [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, - selectedPageNumbers: state.selectedPageNumbers, - 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]); - - - // Auto-save context when it changes - useEffect(() => { - saveContext(); - }, [saveContext]); - - // Load context on mount - useEffect(() => { - loadContext(); - }, [loadContext]); + // Legacy context value for backward compatibility + const legacyContextValue = useMemo(() => ({ + ...state, + ...state.ui, + // Action compatibility layer + addFiles, + removeFiles, + clearAllFiles: actions.clearAllFiles, + setCurrentMode: actions.setCurrentMode, + setSelectedFiles: actions.setSelectedFiles, + setSelectedPages: actions.setSelectedPages, + clearSelections: actions.clearSelections, + setHasUnsavedChanges, + requestNavigation, + confirmNavigation, + cancelNavigation, + trackBlobUrl, + trackPdfDocument, + cleanupFile, + scheduleCleanup, + // Missing operation functions (stubs) + recordOperation: () => { console.warn('recordOperation is deprecated'); }, + markOperationApplied: () => { console.warn('markOperationApplied is deprecated'); }, + markOperationFailed: () => { console.warn('markOperationFailed is deprecated'); }, + // Computed properties that components expect + get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render + // Selectors + ...selectors + }), [state, actions, addFiles, removeFiles, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency // Cleanup on unmount useEffect(() => { @@ -872,65 +561,36 @@ export function FileContextProvider({ }; }, [cleanupAllFiles]); - const contextValue: FileContextValue = { - // State - ...state, - - // Actions - addFiles, - removeFiles, - replaceFile, - clearAllFiles, - setCurrentMode, - setCurrentView, - setCurrentTool, - setSelectedFiles, - setSelectedPages, - updateProcessedFile, - clearSelections, - applyPageOperations, - applyFileOperation, - undoLastOperation, - updateViewerConfig, - setExportConfig, - getFileById, - getProcessedFileById, - getCurrentFile, - getCurrentProcessedFile, - saveContext, - loadContext, - resetContext, - - // Operation history management - recordOperation, - markOperationApplied, - markOperationFailed, - getFileHistory, - getAppliedOperations, - clearFileHistory, - - // Navigation guard system - setHasUnsavedChanges, - requestNavigation, - confirmNavigation, - cancelNavigation, - - // Memory management - trackBlobUrl, - trackPdfDocument, - cleanupFile, - scheduleCleanup - }; - return ( - - {children} - + + + + {children} + + + ); } -// Custom hook to use the context -export function useFileContext(): FileContextValue { +// New hooks for split contexts (prevent unnecessary re-renders) +export function useFileState() { + const context = useContext(FileStateContext); + if (!context) { + throw new Error('useFileState must be used within a FileContextProvider'); + } + return context; +} + +export function useFileActions() { + const context = useContext(FileActionsContext); + if (!context) { + throw new Error('useFileActions must be used within a FileContextProvider'); + } + return context; +} + +// Legacy hook for backward compatibility +export function useFileContext(): any { const context = useContext(FileContext); if (!context) { throw new Error('useFileContext must be used within a FileContextProvider'); @@ -940,35 +600,89 @@ export function useFileContext(): FileContextValue { // Helper hooks for specific aspects export function useCurrentFile() { - const { getCurrentFile, getCurrentProcessedFile } = useFileContext(); - return { - file: getCurrentFile(), - processedFile: getCurrentProcessedFile() - }; + const { state, selectors } = useFileState(); + + const primaryFileId = state.files.ids[0]; + return useMemo(() => ({ + file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, + record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined + }), [primaryFileId]); // selectors are stable, don't depend on them } export function useFileSelection() { - const { - selectedFileIds, - selectedPageNumbers, - setSelectedFiles, - setSelectedPages, - clearSelections - } = useFileContext(); + const { state } = useFileState(); + const { actions } = useFileActions(); return { - selectedFileIds, - selectedPageNumbers, - setSelectedFiles, - setSelectedPages, - clearSelections + selectedFileIds: state.ui.selectedFileIds, + selectedPageNumbers: state.ui.selectedPageNumbers, + setSelectedFiles: actions.setSelectedFiles, + setSelectedPages: actions.setSelectedPages, + clearSelections: actions.clearSelections }; } -export function useViewerState() { - const { viewerConfig, updateViewerConfig } = useFileContext(); +// Legacy compatibility hooks - provide stubs for removed functionality +export function useToolFileSelection() { + const { state, selectors } = useFileState(); + const { actions } = useFileActions(); + + // Memoize selectedFiles to avoid recreating arrays + const selectedFiles = useMemo(() => { + return selectors.getSelectedFiles(); + }, [state.ui.selectedFileIds]); // selectors are stable, don't depend on them + + return useMemo(() => ({ + selectedFileIds: state.ui.selectedFileIds, + selectedPageNumbers: state.ui.selectedPageNumbers, + selectedFiles, // Now stable - only changes when selectedFileIds actually change + setSelectedFiles: actions.setSelectedFiles, + setSelectedPages: actions.setSelectedPages, + clearSelections: actions.clearSelections, + // Tool-specific properties that components expect + maxFiles: 10, // Default value + isToolMode: true, + setMaxFiles: (maxFiles: number) => { console.log('setMaxFiles called with:', maxFiles); }, // Stub with proper signature + setIsToolMode: (isToolMode: boolean) => { console.log('setIsToolMode called with:', isToolMode); } // Stub with proper signature + }), [selectedFiles, state.ui.selectedFileIds, state.ui.selectedPageNumbers, actions]); +} + +export function useProcessedFiles() { + const { state, selectors } = useFileState(); + + // Create a Map-like interface for backward compatibility + const compatibilityMap = { + size: state.files.ids.length, + get: (file: File) => { + const id = createStableFileId(file); + return selectors.getFileRecord(id)?.processedFile; + }, + has: (file: File) => { + const id = createStableFileId(file); + return !!selectors.getFileRecord(id)?.processedFile; + }, + set: () => { + console.warn('processedFiles.set is deprecated - use FileRecord updates instead'); + } + }; + return { - config: viewerConfig, - updateConfig: updateViewerConfig + processedFiles: compatibilityMap, // Map-like interface for backward compatibility + getProcessedFile: (file: File) => { + const id = createStableFileId(file); + return selectors.getFileRecord(id)?.processedFile; + }, + updateProcessedFile: () => { + console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord'); + } }; } + +export function useFileManagement() { + const { actions } = useFileActions(); + return { + addFiles: actions.addFiles, + removeFiles: actions.removeFiles, + clearAllFiles: actions.clearAllFiles + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 33fcedb70..5c89be44b 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -114,9 +114,9 @@ export const useToolOperation = ( const { t } = useTranslation(); const { actions: fileActions } = useFileActions(); // Legacy compatibility - these functions might not be needed in the new architecture - const recordOperation = () => {}; // Placeholder - const markOperationApplied = () => {}; // Placeholder - const markOperationFailed = () => {}; // Placeholder + const recordOperation = (_fileId?: string, _operation?: any) => {}; // Placeholder + const markOperationApplied = (_fileId?: string, _operationId?: string) => {}; // Placeholder + const markOperationFailed = (_fileId?: string, _operationId?: string, _errorMessage?: string) => {}; // Placeholder // Composed hooks const { state, actions } = useToolState(); diff --git a/frontend/src/hooks/tools/shared/useToolResources.ts b/frontend/src/hooks/tools/shared/useToolResources.ts index 8d0b75285..edb429a6c 100644 --- a/frontend/src/hooks/tools/shared/useToolResources.ts +++ b/frontend/src/hooks/tools/shared/useToolResources.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; import { zipFileService } from '../../../services/zipFileService'; @@ -11,20 +11,28 @@ export const useToolResources = () => { }, []); const cleanupBlobUrls = useCallback(() => { - blobUrls.forEach(url => { - try { - URL.revokeObjectURL(url); - } catch (error) { - console.warn('Failed to revoke blob URL:', error); - } + setBlobUrls(prev => { + prev.forEach(url => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Failed to revoke blob URL:', error); + } + }); + return []; }); - setBlobUrls([]); - }, [blobUrls]); + }, []); // No dependencies - use functional update pattern - // Cleanup on unmount + // Cleanup on unmount - use ref to avoid dependency on blobUrls state + const blobUrlsRef = useRef([]); + + useEffect(() => { + blobUrlsRef.current = blobUrls; + }, [blobUrls]); + useEffect(() => { return () => { - blobUrls.forEach(url => { + blobUrlsRef.current.forEach(url => { try { URL.revokeObjectURL(url); } catch (error) { @@ -32,7 +40,7 @@ export const useToolResources = () => { } }); }; - }, [blobUrls]); + }, []); // No dependencies - use ref to access current URLs const generateThumbnails = useCallback(async (files: File[]): Promise => { const thumbnails: string[] = []; diff --git a/frontend/src/services/thumbnailGenerationService.ts b/frontend/src/services/thumbnailGenerationService.ts index 3f16a23ea..aa4df8e3c 100644 --- a/frontend/src/services/thumbnailGenerationService.ts +++ b/frontend/src/services/thumbnailGenerationService.ts @@ -34,6 +34,12 @@ export class ThumbnailGenerationService { private currentCacheSize = 0; constructor(private maxWorkers: number = 3) { + /** + * NOTE: PDF rendering requires DOM access (document, canvas, etc.) which isn't + * available in Web Workers. This service attempts Web Worker setup but will + * gracefully fallback to optimized main thread processing when Workers fail. + * This is expected behavior, not an error. + */ this.initializeWorkers(); } @@ -43,7 +49,6 @@ export class ThumbnailGenerationService { for (let i = 0; i < this.maxWorkers; i++) { const workerPromise = new Promise((resolve) => { try { - console.log(`Attempting to create worker ${i}...`); const worker = new Worker('/thumbnailWorker.js'); let workerReady = false; let pingTimeout: NodeJS.Timeout; @@ -55,7 +60,6 @@ export class ThumbnailGenerationService { if (type === 'PONG') { workerReady = true; clearTimeout(pingTimeout); - console.log(`βœ“ Worker ${i} is ready and responsive`); resolve(worker); return; } @@ -83,7 +87,6 @@ export class ThumbnailGenerationService { }; worker.onerror = (error) => { - console.error(`βœ— Worker ${i} failed with error:`, error); clearTimeout(pingTimeout); worker.terminate(); resolve(null); @@ -92,24 +95,21 @@ export class ThumbnailGenerationService { // Test worker with timeout pingTimeout = setTimeout(() => { if (!workerReady) { - console.warn(`βœ— Worker ${i} timed out (no PONG response)`); worker.terminate(); resolve(null); } - }, 3000); // Reduced timeout for faster feedback + }, 1000); // Quick timeout since we expect failure // Send PING to test worker try { worker.postMessage({ type: 'PING' }); } catch (pingError) { - console.error(`βœ— Failed to send PING to worker ${i}:`, pingError); clearTimeout(pingTimeout); worker.terminate(); resolve(null); } } catch (error) { - console.error(`βœ— Failed to create worker ${i}:`, error); resolve(null); } }); @@ -120,18 +120,7 @@ export class ThumbnailGenerationService { // Wait for all workers to initialize or fail Promise.all(workerPromises).then((workers) => { this.workers = workers.filter((w): w is Worker => w !== null); - const successCount = this.workers.length; - const failCount = this.maxWorkers - successCount; - - console.log(`πŸ”§ Worker initialization complete: ${successCount}/${this.maxWorkers} workers ready`); - - if (failCount > 0) { - console.warn(`⚠️ ${failCount} workers failed to initialize - will use main thread fallback`); - } - - if (successCount === 0) { - console.warn('🚨 No Web Workers available - all thumbnail generation will use main thread'); - } + // Workers expected to fail due to PDF.js DOM requirements - no logging needed }); } @@ -145,11 +134,9 @@ export class ThumbnailGenerationService { onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void ): Promise { if (this.isGenerating) { - console.warn('🚨 ThumbnailService: Thumbnail generation already in progress, rejecting new request'); throw new Error('Thumbnail generation already in progress'); } - console.log(`🎬 ThumbnailService: Starting thumbnail generation for ${pageNumbers.length} pages`); this.isGenerating = true; const { @@ -162,13 +149,11 @@ export class ThumbnailGenerationService { try { // Check if workers are available, fallback to main thread if not if (this.workers.length === 0) { - console.warn('No Web Workers available, falling back to main thread processing'); return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); } // Split pages across workers const workerBatches = this.distributeWork(pageNumbers, this.workers.length); - console.log(`πŸ”§ ThumbnailService: Distributing ${pageNumbers.length} pages across ${this.workers.length} workers:`, workerBatches.map(batch => batch.length)); const jobPromises: Promise[] = []; for (let i = 0; i < workerBatches.length; i++) { @@ -177,12 +162,9 @@ export class ThumbnailGenerationService { const worker = this.workers[i % this.workers.length]; const jobId = `job-${++this.jobCounter}`; - console.log(`πŸ”§ ThumbnailService: Sending job ${jobId} with ${batch.length} pages to worker ${i}:`, batch); const promise = new Promise((resolve, reject) => { - // Add timeout for worker jobs const timeout = setTimeout(() => { - console.error(`⏰ ThumbnailService: Worker job ${jobId} timed out`); this.activeJobs.delete(jobId); reject(new Error(`Worker job ${jobId} timed out`)); }, 60000); // 1 minute timeout @@ -190,19 +172,14 @@ export class ThumbnailGenerationService { // Create job with timeout handling this.activeJobs.set(jobId, { resolve: (result: any) => { - console.log(`βœ… ThumbnailService: Job ${jobId} completed with ${result.length} thumbnails`); clearTimeout(timeout); resolve(result); }, reject: (error: any) => { - console.error(`❌ ThumbnailService: Job ${jobId} failed:`, error); clearTimeout(timeout); reject(error); }, - onProgress: onProgress ? (progressData: any) => { - console.log(`πŸ“Š ThumbnailService: Job ${jobId} progress - ${progressData.completed}/${progressData.total} (${progressData.thumbnails.length} new)`); - onProgress(progressData); - } : undefined + onProgress: onProgress }); worker.postMessage({ @@ -225,15 +202,11 @@ export class ThumbnailGenerationService { // Flatten and sort results by page number const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber); - console.log(`🎯 ThumbnailService: All workers completed, returning ${allThumbnails.length} thumbnails`); - return allThumbnails; } catch (error) { - console.error('Web Worker thumbnail generation failed, falling back to main thread:', error); return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); } finally { - console.log('πŸ”„ ThumbnailService: Resetting isGenerating flag'); this.isGenerating = false; } } @@ -248,14 +221,11 @@ export class ThumbnailGenerationService { quality: number, onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void ): Promise { - console.log(`πŸ”§ ThumbnailService: Fallback to main thread for ${pageNumbers.length} pages`); - // Import PDF.js dynamically for main thread const { getDocument } = await import('pdfjs-dist'); // Load PDF once const pdf = await getDocument({ data: pdfArrayBuffer }).promise; - console.log(`βœ“ ThumbnailService: PDF loaded on main thread`); const allResults: ThumbnailResult[] = []; diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index e812c08d3..a04d1acb5 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -3,7 +3,7 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; +import { useFileActions } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; @@ -21,7 +21,8 @@ import { CompressTips } from "../components/tooltips/CompressTips"; const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); + const { actions } = useFileActions(); + const setCurrentMode = actions.setCurrentMode; const { selectedFiles } = useToolFileSelection(); const compressParams = useCompressParameters(); diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 9e436bb6c..a1ce17a79 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -3,7 +3,7 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; +import { useFileActions, useFileState } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; @@ -20,7 +20,10 @@ import { BaseToolProps } from "../types/tool"; const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode, activeFiles } = useFileContext(); + const { actions } = useFileActions(); + const { selectors } = useFileState(); + const setCurrentMode = actions.setCurrentMode; + const activeFiles = selectors.getFiles(); const { selectedFiles } = useToolFileSelection(); const scrollContainerRef = useRef(null); diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index d35f3c901..42eab7524 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -3,7 +3,7 @@ import { Button, Stack, Text, Box } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; +import { useFileActions } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; @@ -22,7 +22,8 @@ import { OcrTips } from "../components/tooltips/OCRTips"; const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); + const { actions } = useFileActions(); + const setCurrentMode = actions.setCurrentMode; const { selectedFiles } = useToolFileSelection(); const ocrParams = useOCRParameters(); diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index abc79eb97..3de08e921 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -3,7 +3,7 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; +import { useFileActions } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; @@ -19,7 +19,8 @@ import { BaseToolProps } from "../types/tool"; const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); + const { actions } = useFileActions(); + const setCurrentMode = actions.setCurrentMode; const { selectedFiles } = useToolFileSelection(); const splitParams = useSplitParameters(); @@ -33,7 +34,7 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { useEffect(() => { splitOperation.resetResults(); onPreviewFile?.(null); - }, [splitParams.parameters, selectedFiles]); + }, [splitParams.parameters, selectedFiles]); // Keep dependencies minimal - functions should be stable const handleSplit = async () => { try { diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 4f192d2df..0037287d8 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -5,22 +5,28 @@ import { ProcessedFile } from './processing'; import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; -export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr'; +export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'convert'; // Normalized state types export type FileId = string; export interface FileRecord { id: FileId; - file: File; name: string; size: number; type: string; lastModified: number; thumbnailUrl?: string; blobUrl?: string; - processedFile?: ProcessedFile; createdAt: number; + processedFile?: { + pages: Array<{ + thumbnail?: string; + [key: string]: any; + }>; + [key: string]: any; + }; + // Note: File object stored in provider ref, not in state } export interface FileContextNormalizedFiles { @@ -38,7 +44,6 @@ export function toFileRecord(file: File, id?: FileId): FileRecord { const fileId = id || createStableFileId(file); return { id: fileId, - file, name: file.name, size: file.size, type: file.type, @@ -105,47 +110,23 @@ export interface FileEditHistory { } export interface FileContextState { - // Core file management - normalized state - files: FileContextNormalizedFiles; + // Core file management - lightweight file IDs only + files: { + ids: FileId[]; + byId: Record; + }; - // UI state grouped for performance + // UI state - flat structure for performance ui: { - // Current navigation state currentMode: ModeType; - - // UI state that persists across views - selectedFileIds: string[]; + selectedFileIds: FileId[]; selectedPageNumbers: number[]; - viewerConfig: ViewerConfig; - - // Tool selection state (replaces FileSelectionContext) - toolMode: boolean; - maxFiles: number; // 1=single, >1=limited, -1=unlimited - currentTool?: string; - - // Processing state isProcessing: boolean; processingProgress: number; - - // Export state - lastExportConfig?: { - filename: string; - selectedOnly: boolean; - splitDocuments: boolean; - }; - - // Navigation guard system hasUnsavedChanges: boolean; pendingNavigation: (() => void) | null; showNavigationWarning: boolean; }; - - // Edit history and state (less frequently accessed) - history: { - fileEditHistory: Map; - globalFileOperations: FileOperation[]; - fileOperationHistory: Map; - }; } // Action types for reducer pattern @@ -154,94 +135,68 @@ export type FileContextAction = | { type: 'ADD_FILES'; payload: { files: File[] } } | { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } } | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } - | { type: 'CLEAR_ALL_FILES' } // UI actions - | { type: 'SET_MODE'; payload: { mode: ModeType } } - | { type: 'SET_SELECTED_FILES'; payload: { fileIds: string[] } } + | { type: 'SET_CURRENT_MODE'; payload: ModeType } + | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } | { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } } | { type: 'CLEAR_SELECTIONS' } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } - | { type: 'UPDATE_VIEWER_CONFIG'; payload: { config: Partial } } - | { type: 'SET_EXPORT_CONFIG'; payload: { config: FileContextState['ui']['lastExportConfig'] } } - - // Tool selection actions (replaces FileSelectionContext) - | { type: 'SET_TOOL_MODE'; payload: { toolMode: boolean } } - | { type: 'SET_MAX_FILES'; payload: { maxFiles: number } } - | { type: 'SET_CURRENT_TOOL'; payload: { currentTool?: string } } // Navigation guard actions | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } | { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } } | { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } } - | { type: 'CONFIRM_NAVIGATION' } - | { type: 'CANCEL_NAVIGATION' } - - // History actions - | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } } - | { type: 'ADD_FILE_OPERATION'; payload: { operation: FileOperation } } - | { type: 'RECORD_OPERATION'; payload: { fileId: string; operation: FileOperation | PageOperation } } - | { type: 'MARK_OPERATION_APPLIED'; payload: { fileId: string; operationId: string } } - | { type: 'MARK_OPERATION_FAILED'; payload: { fileId: string; operationId: string; error: string } } - | { type: 'CLEAR_FILE_HISTORY'; payload: { fileId: string } } // Context management - | { type: 'RESET_CONTEXT' } - | { type: 'LOAD_STATE'; payload: { state: Partial } }; + | { type: 'RESET_CONTEXT' }; export interface FileContextActions { - // File management + // File management - lightweight actions only addFiles: (files: File[]) => Promise; - addFiles: (files: File[]) => Promise; - removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; - replaceFile: (oldFileId: string, newFile: File) => Promise; + removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void; clearAllFiles: () => void; // Navigation - setMode: (mode: ModeType) => void; + setCurrentMode: (mode: ModeType) => void; // Selection management - setSelectedFiles: (fileIds: string[]) => void; + setSelectedFiles: (fileIds: FileId[]) => void; setSelectedPages: (pageNumbers: number[]) => void; clearSelections: () => void; - // Tool selection management (replaces FileSelectionContext) - setToolMode: (toolMode: boolean) => void; - setMaxFiles: (maxFiles: number) => void; - setCurrentTool: (currentTool?: string) => void; - - // Processing state - setProcessing: (isProcessing: boolean, progress: number) => void; - - // Viewer state - updateViewerConfig: (config: Partial) => void; - - // Export configuration - setExportConfig: (config: FileContextState['ui']['lastExportConfig']) => void; + // Processing state - simple flags only + setProcessing: (isProcessing: boolean, progress?: number) => void; // Navigation guard system setHasUnsavedChanges: (hasChanges: boolean) => void; - requestNavigation: (navigationFn: () => void) => boolean; - confirmNavigation: () => void; - cancelNavigation: () => void; // Context management resetContext: () => void; + + // Legacy compatibility + setMode: (mode: ModeType) => void; + confirmNavigation: () => void; + cancelNavigation: () => void; } -// Legacy compatibility interface - includes legacy properties expected by existing components -export interface FileContextValue extends FileContextState, FileContextActions { - // Legacy properties for backward compatibility - activeFiles?: File[]; - selectedFileIds?: string[]; - isProcessing?: boolean; - processedFiles?: Map; - setCurrentView?: (mode: ModeType) => void; - setCurrentMode?: (mode: ModeType) => void; - recordOperation?: (fileId: string, operation: FileOperation) => void; - markOperationApplied?: (fileId: string, operationId: string) => void; - getFileHistory?: (fileId: string) => FileOperationHistory | undefined; - getAppliedOperations?: (fileId: string) => (FileOperation | PageOperation)[]; +// File selectors (separate from actions to avoid re-renders) +export interface FileContextSelectors { + // File access - no state dependency, uses ref + getFile: (id: FileId) => File | undefined; + getFiles: (ids?: FileId[]) => File[]; + + // Record access - uses normalized state + getFileRecord: (id: FileId) => FileRecord | undefined; + getFileRecords: (ids?: FileId[]) => FileRecord[]; + + // Derived selectors + getAllFileIds: () => FileId[]; + getSelectedFiles: () => File[]; + getSelectedFileRecords: () => FileRecord[]; + + // Stable signature for effect dependencies + getFilesSignature: () => string; } export interface FileContextProviderProps { @@ -251,40 +206,7 @@ export interface FileContextProviderProps { maxCacheSize?: number; } -// Helper types for component props -export interface WithFileContext { - fileContext: FileContextValue; -} - -// Selector types for split context pattern -export interface FileContextSelectors { - // File selectors - getFileById: (id: FileId) => FileRecord | undefined; - getFilesByIds: (ids: FileId[]) => FileRecord[]; - getAllFiles: () => FileRecord[]; - getSelectedFiles: () => FileRecord[]; - - // Convenience file helpers - getFile: (id: FileId) => File | undefined; - getFiles: (ids?: FileId[]) => File[]; - - // UI selectors - getCurrentMode: () => ModeType; - getSelectedFileIds: () => string[]; - getSelectedPageNumbers: () => number[]; - getViewerConfig: () => ViewerConfig; - getProcessingState: () => { isProcessing: boolean; progress: number }; - - // Navigation guard selectors - getHasUnsavedChanges: () => boolean; - getShowNavigationWarning: () => boolean; - - // History selectors (legacy - moved to selectors from actions) - getFileHistory: (fileId: string) => FileOperationHistory | undefined; - getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[]; -} - -// Split context value types +// Split context values to minimize re-renders export interface FileContextStateValue { state: FileContextState; selectors: FileContextSelectors;