From 02f4f7abaf75d1d493114426313809198b438cc3 Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Sun, 10 Aug 2025 21:08:32 +0100 Subject: [PATCH] Refactor file management context and remove FileSelectionContext- Updated FileManagerContext to use useMemo for context value optimization.- Removed FileSelectionContext and integrated its functionality into FileContext.- Updated hooks and components to use new file context structure.- Refactored file handling logic to utilize stable file IDs for deduplication.- Adjusted UI state management for improved performance and clarity.- Updated theme variables for consistency in hover and selection states.- Ensured backward compatibility with legacy properties in FileContext. --- .claude/settings.local.json | 3 +- .../public/locales/en-GB/translation.json | 7 +- frontend/src/commands/pageCommands.ts | 30 +- .../src/components/fileEditor/FileEditor.tsx | 106 +- frontend/src/components/layout/Workbench.tsx | 17 +- .../components/pageEditor/FileThumbnail.tsx | 49 +- .../src/components/pageEditor/PageEditor.tsx | 562 ++++-- .../shared/NavigationWarningModal.tsx | 27 +- .../src/components/shared/TopControls.tsx | 76 +- .../tools/convert/ConvertSettings.tsx | 20 +- frontend/src/components/viewer/Viewer.tsx | 10 +- frontend/src/contexts/FileContext.tsx | 1561 ++++++++--------- frontend/src/contexts/FileManagerContext.tsx | 24 +- .../src/contexts/FileSelectionContext.tsx | 86 - frontend/src/contexts/FilesModalContext.tsx | 14 +- frontend/src/contexts/SidebarContext.tsx | 14 +- .../hooks/tools/shared/useToolOperation.ts | 12 +- frontend/src/hooks/useFileHandler.ts | 28 +- frontend/src/pages/HomePage.tsx | 21 +- frontend/src/theme/mantineTheme.ts | 8 +- frontend/src/tools/Compress.tsx | 2 +- frontend/src/tools/Convert.tsx | 2 +- frontend/src/tools/OCR.tsx | 2 +- frontend/src/tools/Split.tsx | 2 +- frontend/src/types/fileContext.ts | 268 ++- frontend/src/types/tool.ts | 21 - 26 files changed, 1597 insertions(+), 1375 deletions(-) delete mode 100644 frontend/src/contexts/FileSelectionContext.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cc5ded896..a996dfb7a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "Bash(find:*)", "Bash(npm test)", "Bash(npm test:*)", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(npm run dev:*)" ], "deny": [] } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ed3942172..3e4ca84cc 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -391,6 +391,9 @@ "title": "Compress", "desc": "Compress PDFs to reduce their file size." }, + "compress": { + "title": "Compress" + }, "unlockPDFForms": { "title": "Unlock PDF Forms", "desc": "Remove read-only property of form fields in a PDF document." @@ -1711,7 +1714,9 @@ "uploadFiles": "Upload Files", "noFilesInStorage": "No files available in storage. Upload some files first.", "selectFromStorage": "Select from Storage", - "backToTools": "Back to Tools" + "backToTools": "Back to Tools", + "addFiles": "Add Files", + "dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse" }, "fileManager": { "title": "Upload PDF Files", diff --git a/frontend/src/commands/pageCommands.ts b/frontend/src/commands/pageCommands.ts index 4e5572234..92a9c9a73 100644 --- a/frontend/src/commands/pageCommands.ts +++ b/frontend/src/commands/pageCommands.ts @@ -48,7 +48,11 @@ export class RotatePagesCommand extends PageCommand { return page; }); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } get description(): string { @@ -148,7 +152,11 @@ export class MovePagesCommand extends PageCommand { pageNumber: index + 1 })); - this.setPdfDocument({ ...this.pdfDocument, pages: newPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: newPages, + totalPages: newPages.length + }); } get description(): string { @@ -185,7 +193,11 @@ export class ReorderPageCommand extends PageCommand { pageNumber: index + 1 })); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } get description(): string { @@ -224,7 +236,11 @@ export class ToggleSplitCommand extends PageCommand { return page; }); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } undo(): void { @@ -236,7 +252,11 @@ export class ToggleSplitCommand extends PageCommand { return page; }); - this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); } get description(): string { diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index ca5f594b8..6a9539408 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -6,9 +6,8 @@ import { import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { useFileContext } from '../../contexts/FileContext'; -import { useFileSelection } from '../../contexts/FileSelectionContext'; -import { FileOperation } from '../../types/fileContext'; +import { useFileContext, useToolFileSelection, useProcessedFiles, useFileState, useFileManagement } from '../../contexts/FileContext'; +import { FileOperation, createStableFileId } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { zipFileService } from '../../services/zipFileService'; @@ -54,28 +53,34 @@ const FileEditor = ({ return extension ? supportedExtensions.includes(extension) : false; }, [supportedExtensions]); - // Get file context - const fileContext = useFileContext(); - const { - activeFiles, - processedFiles, - selectedFileIds, - setSelectedFiles: setContextSelectedFiles, - isProcessing, - addFiles, - removeFiles, - setCurrentView, - recordOperation, - markOperationApplied - } = fileContext; + // Use optimized FileContext hooks + const { state } = 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); + 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); + }; + + const setCurrentView = (mode: any) => { + // Will be handled by parent component actions + console.log('FileEditor setCurrentView called with:', mode); + }; - // Get file selection context + // Get tool file selection context (replaces FileSelectionContext) const { selectedFiles: toolSelectedFiles, setSelectedFiles: setToolSelectedFiles, maxFiles, isToolMode - } = useFileSelection(); + } = useToolFileSelection(); const [files, setFiles] = useState([]); const [status, setStatus] = useState(null); @@ -119,8 +124,8 @@ const FileEditor = ({ // Map context selections to local file IDs for UI display const localSelectedIds = files .filter(file => { - const fileId = (file.file as any).id || file.name; - return contextSelectedIds.includes(fileId); + const contextFileId = createStableFileId(file.file); + return contextSelectedIds.includes(contextFileId); }) .map(file => file.id); @@ -132,7 +137,7 @@ const FileEditor = ({ return { id: sharedFile.id || `file-${Date.now()}-${Math.random()}`, name: (sharedFile.file?.name || sharedFile.name || 'unknown'), - pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now + pageCount: sharedFile.pageCount || 1, // Default to 1 page if unknown thumbnail, size: sharedFile.file?.size || sharedFile.size || 0, file: sharedFile.file || sharedFile, @@ -181,10 +186,30 @@ const FileEditor = ({ } } + // Get actual page count from processed file + let pageCount = 1; // Default for non-PDFs + if (processedFile) { + pageCount = processedFile.pages?.length || processedFile.totalPages || 1; + } else if (file.type === 'application/pdf') { + // For PDFs without processed data, try to get a quick page count estimate + // If processing is taking too long, show a reasonable default + try { + // Quick and dirty page count using PDF structure analysis + const arrayBuffer = await file.arrayBuffer(); + const text = new TextDecoder('latin1').decode(arrayBuffer); + const pageMatches = text.match(/\/Type\s*\/Page[^s]/g); + pageCount = pageMatches ? pageMatches.length : 1; + console.log(`📄 Quick page count for ${file.name}: ${pageCount} pages (estimated)`); + } catch (error) { + console.warn(`Failed to estimate page count for ${file.name}:`, error); + pageCount = 1; // Safe fallback + } + } + const convertedFile = { - id: `file-${Date.now()}-${Math.random()}`, + id: createStableFileId(file), // Use same ID function as context name: file.name, - pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1, + pageCount: pageCount, thumbnail, size: file.size, file, @@ -290,8 +315,7 @@ const FileEditor = ({ } }; - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); + // Legacy operation tracking removed if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); @@ -345,8 +369,7 @@ const FileEditor = ({ } }; - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); + // Legacy operation tracking removed } // Add files to context (they will be processed automatically) @@ -367,7 +390,7 @@ const FileEditor = ({ totalFiles: 0 }); } - }, [addFiles, recordOperation, markOperationApplied]); + }, [addFiles]); const selectAll = useCallback(() => { setContextSelectedFiles(files.map(f => (f.file as any).id || f.name)); @@ -397,8 +420,7 @@ const FileEditor = ({ } }; - recordOperation(file.name, operation); - markOperationApplied(file.name, operationId); + // Legacy operation tracking removed }); // Remove all files from context but keep in storage @@ -406,13 +428,13 @@ const FileEditor = ({ // Clear selections setContextSelectedFiles([]); - }, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); + }, [activeFiles, removeFiles, setContextSelectedFiles]); const toggleFile = useCallback((fileId: string) => { const targetFile = files.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); let newSelection: string[]; @@ -441,7 +463,7 @@ const FileEditor = ({ if (isToolMode || toolMode) { const selectedFiles = files .filter(f => { - const fId = (f.file as any).id || f.name; + const fId = createStableFileId(f.file); return newSelection.includes(fId); }) .map(f => f.file); @@ -598,7 +620,7 @@ const FileEditor = ({ } }; - recordOperation(fileName, operation); + // Legacy operation tracking removed // Remove file from context but keep in storage (close, don't delete) console.log('Calling removeFiles with:', [fileId]); @@ -609,24 +631,20 @@ const FileEditor = ({ const safePrev = Array.isArray(prev) ? prev : []; return safePrev.filter(id => id !== fileId); }); - - // Mark operation as applied - markOperationApplied(fileName, operationId); } else { console.log('File not found for fileId:', fileId); } - }, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); + }, [files, removeFiles, setContextSelectedFiles]); const handleViewFile = useCallback((fileId: string) => { const file = files.find(f => f.id === fileId); if (file) { - // Set the file as selected in context and switch to page editor view - const contextFileId = (file.file as any).id || file.name; + // Set the file as selected in context and switch to viewer for preview + const contextFileId = createStableFileId(file.file); setContextSelectedFiles([contextFileId]); - setCurrentView('pageEditor'); - onOpenPageEditor?.(file.file); + setCurrentView('viewer'); } - }, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]); + }, [files, setContextSelectedFiles, setCurrentView]); const handleMergeFromHere = useCallback((fileId: string) => { const startIndex = files.findIndex(f => f.id === fileId); diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index b0c984ee8..fac01119e 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext'; import { useFileHandler } from '../../hooks/useFileHandler'; -import { useFileContext } from '../../contexts/FileContext'; +import { useFileState, useFileActions } from '../../contexts/FileContext'; import TopControls from '../shared/TopControls'; import FileEditor from '../fileEditor/FileEditor'; @@ -20,7 +20,10 @@ export default function Workbench() { const { isRainbowMode } = useRainbowThemeContext(); // Use context-based hooks to eliminate all prop drilling - const { activeFiles, currentView, setCurrentView } = useFileContext(); + const { state } = useFileState(); + const { actions } = useFileActions(); + const activeFiles = state.files.ids; + const currentView = state.ui.currentMode; const { previewFile, pageEditorFunctions, @@ -47,12 +50,12 @@ export default function Workbench() { handleToolSelect('convert'); sessionStorage.removeItem('previousMode'); } else { - setCurrentView('fileEditor' as any); + actions.setMode('fileEditor'); } }; const renderMainContent = () => { - if (!activeFiles[0]) { + if (activeFiles.length === 0) { return ( @@ -69,11 +72,11 @@ export default function Workbench() { supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} {...(!selectedToolKey && { onOpenPageEditor: (file) => { - setCurrentView("pageEditor" as any); + actions.setMode("pageEditor"); }, onMergeFiles: (filesToMerge) => { filesToMerge.forEach(addToActiveFiles); - setCurrentView("viewer" as any); + actions.setMode("viewer"); } })} /> @@ -142,7 +145,7 @@ export default function Workbench() { {/* Top Controls */} diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index b129ce6d9..bc271248c 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -1,12 +1,11 @@ -import React, { useState } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core'; +import React, { useState, useCallback } from 'react'; +import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import CloseIcon from '@mui/icons-material/Close'; import VisibilityIcon from '@mui/icons-material/Visibility'; -import HistoryIcon from '@mui/icons-material/History'; +import PreviewIcon from '@mui/icons-material/Preview'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import styles from './PageEditor.module.css'; -import FileOperationHistory from '../history/FileOperationHistory'; interface FileItem { id: string; @@ -65,7 +64,6 @@ const FileThumbnail = ({ isSupported = true, }: FileThumbnailProps) => { const { t } = useTranslation(); - const [showHistory, setShowHistory] = useState(false); const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 B'; @@ -75,15 +73,18 @@ const FileThumbnail = ({ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; + // Memoize ref callback to prevent infinite loop + const refCallback = useCallback((el: HTMLDivElement | null) => { + if (el) { + fileRefs.current.set(file.id, el); + } else { + fileRefs.current.delete(file.id); + } + }, [file.id, fileRefs]); + return (
{ - if (el) { - fileRefs.current.set(file.id, el); - } else { - fileRefs.current.delete(file.id); - } - }} + ref={refCallback} data-file-id={file.id} data-testid="file-thumbnail" className={` @@ -201,7 +202,7 @@ const FileThumbnail = ({ zIndex: 3, }} > - {file.pageCount} pages + {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'} {/* Unsupported badge */} @@ -286,18 +287,18 @@ const FileThumbnail = ({ )} - + { e.stopPropagation(); - setShowHistory(true); - onSetStatus(`Viewing history for ${file.name}`); + onViewFile(file.id); + onSetStatus(`Opening preview for ${file.name}`); }} > - + @@ -339,20 +340,6 @@ const FileThumbnail = ({
- {/* History Modal */} - setShowHistory(false)} - title={`Operation History - ${file.name}`} - size="lg" - scrollAreaComponent="div" - > - - ); }; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 4ba56a291..0d25f1d0c 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -5,8 +5,8 @@ import { Stack, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; -import { ViewType, ToolType } from "../../types/fileContext"; +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"; @@ -53,23 +53,22 @@ const PageEditor = ({ }: PageEditorProps) => { const { t } = useTranslation(); - // Get file context - const fileContext = useFileContext(); + // 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(); + const processedFiles = useProcessedFiles(); - // Use file context state - const { - activeFiles, - processedFiles, - selectedPageNumbers, - setSelectedPages, - updateProcessedFile, - setHasUnsavedChanges, - hasUnsavedChanges, - isProcessing: globalProcessing, - processingProgress, - clearAllFiles - } = fileContext; + // 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); @@ -78,25 +77,53 @@ const PageEditor = ({ const [foundDraft, setFoundDraft] = useState(null); const autoSaveTimer = useRef(null); - // Simple computed document from processed files (no caching needed) + /** + * 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 currentFiles = state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean); - if (activeFiles.length === 1) { + if (currentFiles.length === 0) { + return null; + } else if (currentFiles.length === 1) { // Single file - const processedFile = processedFiles.get(activeFiles[0]); - if (!processedFile) return null; + const file = currentFiles[0]; + const record = state.files.ids + .map(id => state.files.byId[id]) + .find(r => r?.file === file); + + const processedFile = record?.processedFile; + if (!processedFile) { + return null; + } + + const pages = processedFile.pages.map(page => ({ + ...page, + rotation: page.rotation || 0, + splitBefore: page.splitBefore || false + })); 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 + name: file.name, + file: file, + pages: pages, + totalPages: pages.length // Always use actual pages array length }; } else { // Multiple files - merge them @@ -104,8 +131,12 @@ const PageEditor = ({ let totalPages = 0; const filenames: string[] = []; - activeFiles.forEach((file, i) => { - const processedFile = processedFiles.get(file); + currentFiles.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, '')); @@ -124,17 +155,19 @@ const PageEditor = ({ } }); - if (allPages.length === 0) return null; - + if (allPages.length === 0) { + return null; + } + return { id: `merged-${Date.now()}`, name: filenames.join(' + '), - file: activeFiles[0], // Use first file as reference + file: currentFiles[0], // Use first file as reference pages: allPages, - totalPages: totalPages + totalPages: allPages.length // Always use actual pages array length }; } - }, [activeFiles, processedFiles]); + }, [filesSignature, state.files.ids, state.files.byId]); // Stable dependency // Display document: Use edited version if exists, otherwise original const displayDocument = editedDocument || mergedPdfDocument; @@ -144,6 +177,7 @@ const PageEditor = ({ // 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); @@ -188,16 +222,20 @@ const PageEditor = ({ } // Add files to context - await fileContext.addFiles(uploadedFiles); + await addFiles(uploadedFiles); setStatus(`Added ${uploadedFiles.length} file(s) for processing`); - }, [fileContext]); + }, [addFiles]); // PageEditor no longer handles cleanup - it's centralized in FileContext - // Shared PDF instance for thumbnail generation + // PDF thumbnail generation state const [sharedPdfInstance, setSharedPdfInstance] = useState(null); - const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false); + /** + * 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 { @@ -208,21 +246,16 @@ const PageEditor = ({ destroyThumbnails } = useThumbnailGeneration(); - // Start thumbnail generation process (separate from document loading) + // 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) { - console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions'); + if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted.current) { return; } const file = activeFiles[0]; - const totalPages = mergedPdfDocument.totalPages; + const totalPages = mergedPdfDocument.pages.length; - console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages'); - setThumbnailGenerationStarted(true); + thumbnailGenerationStarted.current = true; // Run everything asynchronously to avoid blocking the main thread setTimeout(async () => { @@ -237,11 +270,8 @@ const PageEditor = ({ 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) { - console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed'); return; } @@ -258,78 +288,59 @@ const PageEditor = ({ batchSize: 15, // Smaller batches per worker for smoother UI parallelBatches: 3 // Use 3 Web Workers in parallel }, - // Progress callback (throttled for better performance) + // Progress callback for thumbnail updates (progress) => { - console.log(`🎬 PageEditor: Progress - ${progress.completed}/${progress.total} pages, ${progress.thumbnails.length} new thumbnails`); // Batch process thumbnails to reduce main thread work requestAnimationFrame(() => { progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { - // Check cache first, then send thumbnail const pageId = `${file.name}-page-${pageNumber}`; const cached = getThumbnailFromCache(pageId); if (!cached) { - // Cache and send to component addThumbnailToCache(pageId, thumbnail); - window.dispatchEvent(new CustomEvent('thumbnailReady', { detail: { pageNumber, thumbnail, pageId } })); - console.log(`✓ PageEditor: Dispatched thumbnail for page ${pageNumber}`); } }); }); } ); - // Handle completion properly + // Handle completion generationPromise - .then((allThumbnails) => { - console.log(`✅ PageEditor: Thumbnail generation completed! Generated ${allThumbnails.length} thumbnails`); - // Don't reset thumbnailGenerationStarted here - let it stay true to prevent restarts + .then(() => { + // Keep thumbnailGenerationStarted as true to prevent restarts }) .catch(error => { - console.error('✗ PageEditor: Web Worker thumbnail generation failed:', error); - setThumbnailGenerationStarted(false); + console.error('PageEditor: Thumbnail generation failed:', error); + thumbnailGenerationStarted.current = false; }); } catch (error) { console.error('Failed to start Web Worker thumbnail generation:', error); - setThumbnailGenerationStarted(false); + thumbnailGenerationStarted.current = false; } }, 0); // setTimeout with 0ms to defer to next tick - }, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted, getThumbnailFromCache, addThumbnailToCache]); + }, [mergedPdfDocument, activeFiles, getThumbnailFromCache, addThumbnailToCache]); - // Start thumbnail generation after document loads + // 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) { - // Check if ALL pages already have thumbnails from processed files + 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) { - console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist'); - return; // Skip generation if ALL thumbnails already exist + 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); } - }, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]); + }, [filesSignature, startThumbnailGeneration]); // Cleanup shared PDF instance when component unmounts (but preserve cache) useEffect(() => { @@ -338,11 +349,9 @@ const PageEditor = ({ sharedPdfInstance.destroy(); setSharedPdfInstance(null); } - setThumbnailGenerationStarted(false); - // DON'T stop generation on file changes - preserve cache for view switching - // stopGeneration(); + thumbnailGenerationStarted.current = false; }; - }, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles + }, [sharedPdfInstance]); // Clear selections when files change useEffect(() => { @@ -432,14 +441,14 @@ const PageEditor = ({ ranges.forEach(range => { if (range.includes('-')) { const [start, end] = range.split('-').map(n => parseInt(n.trim())); - for (let i = start; i <= end && i <= mergedPdfDocument.totalPages; i++) { + 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.totalPages) { + if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) { pageNumbers.push(pageNum); } } @@ -527,25 +536,31 @@ const PageEditor = ({ // Update local edit state for immediate visual feedback setEditedDocument(updatedDoc); - setHasUnsavedChanges(true); // Use global state + 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) { - saveDraftToIndexedDB(updatedDoc); - setHasUnsavedDraft(false); // Mark draft as saved + 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; - }, [setHasUnsavedChanges, hasUnsavedDraft]); + }, [actions, hasUnsavedDraft]); - // Save draft to separate IndexedDB location + // Enhanced draft save with proper IndexedDB handling const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { try { const draftKey = `draft-${doc.id || 'merged'}`; @@ -555,41 +570,124 @@ const PageEditor = ({ 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'); - } - }; + // 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(); + } + }; + }); - 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); + console.warn('Draft save failed:', error); + throw error; } }, [activeFiles]); - // Clean up draft from IndexedDB + // 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); + 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(); + } + }; + }); - 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); + console.warn('Draft cleanup failed:', error); + // Don't throw - cleanup failure shouldn't break the app } }, [mergedPdfDocument]); @@ -615,7 +713,17 @@ const PageEditor = ({ 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'); @@ -625,7 +733,7 @@ const PageEditor = ({ // Wait for the processed file update to complete before clearing edit state setTimeout(() => { setEditedDocument(null); - setHasUnsavedChanges(false); + actions.setHasUnsavedChanges(false); setHasUnsavedDraft(false); cleanupDraft(); setStatus('Changes applied successfully'); @@ -635,7 +743,7 @@ const PageEditor = ({ console.error('Failed to apply changes:', error); setStatus('Failed to apply changes'); } - }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, updateProcessedFile, setHasUnsavedChanges, setStatus, cleanupDraft]); + }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, state.files.ids, state.files.byId, actions, dispatch, cleanupDraft]); const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { if (!displayDocument || isAnimating) return; @@ -941,53 +1049,60 @@ const PageEditor = ({ const closePdf = useCallback(() => { // Use global navigation guard system - fileContext.requestNavigation(() => { + actions.requestNavigation(() => { clearAllFiles(); // This now handles all cleanup centrally (including merged docs) setSelectedPages([]); }); - }, [fileContext, clearAllFiles, setSelectedPages]); + }, [actions, clearAllFiles, setSelectedPages]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); - // Expose functions to parent component for PageEditorControls + /** + * 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) { - onFunctionsReady({ - handleUndo, - handleRedo, - canUndo, - canRedo, - handleRotate, - handleDelete, - handleSplit, - showExportPreview, - onExportSelected, - onExportAll, - exportLoading, - selectionMode, - selectedPages: selectedPageNumbers, - closePdf, - }); + 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, - handleUndo, - handleRedo, - canUndo, - canRedo, - handleRotate, - handleDelete, - handleSplit, - showExportPreview, - onExportSelected, - onExportAll, - exportLoading, - selectionMode, - selectedPageNumbers, - closePdf - ]); + }, [onFunctionsReady]); // Show loading or empty state instead of blocking const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); @@ -1006,38 +1121,96 @@ const PageEditor = ({ } }, [editedDocument, applyChanges, handleExport]); - // Check for existing drafts + // 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); + const dbRequest = indexedDB.open('stirling-pdf-drafts', 1); - request.onsuccess = () => { - const db = request.result; - if (!db.objectStoreNames.contains('drafts')) return; + 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 + }; - 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); - } + 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('Failed to check for drafts:', error); + console.warn('Draft check failed:', error); + // Don't throw - draft checking failure shouldn't break the app } }, [mergedPdfDocument]); @@ -1045,12 +1218,12 @@ const PageEditor = ({ const resumeWork = useCallback(() => { if (foundDraft && foundDraft.document) { setEditedDocument(foundDraft.document); - setHasUnsavedChanges(true); + actions.setHasUnsavedChanges(true); // Use context action setFoundDraft(null); setShowResumeModal(false); setStatus('Resumed previous work'); } - }, [foundDraft]); + }, [foundDraft, actions]); // Start fresh (ignore draft) const startFresh = useCallback(() => { @@ -1065,8 +1238,6 @@ const PageEditor = ({ // Cleanup on unmount useEffect(() => { return () => { - console.log('PageEditor unmounting - cleaning up resources'); - // Clear auto-save timer if (autoSaveTimer.current) { clearTimeout(autoSaveTimer.current); @@ -1342,7 +1513,7 @@ const PageEditor = ({ loading={exportLoading} onClick={() => { setShowExportModal(false); - const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.totalPages || 0); + const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0); handleExport(selectedOnly); }} > @@ -1408,6 +1579,17 @@ const PageEditor = ({ {status} )} + + {error && ( + setError(null)} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} + > + {error} + + )} ); }; diff --git a/frontend/src/components/shared/NavigationWarningModal.tsx b/frontend/src/components/shared/NavigationWarningModal.tsx index a3d3983d2..3a7bf25b5 100644 --- a/frontend/src/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/components/shared/NavigationWarningModal.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Modal, Text, Button, Group, Stack } from '@mantine/core'; -import { useFileContext } from '../../contexts/FileContext'; +import { useFileState, useFileActions } from '../../contexts/FileContext'; interface NavigationWarningModalProps { onApplyAndContinue?: () => Promise; @@ -11,37 +11,34 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => { - const { - showNavigationWarning, - hasUnsavedChanges, - confirmNavigation, - cancelNavigation, - setHasUnsavedChanges - } = useFileContext(); + const { state } = useFileState(); + const { actions } = useFileActions(); + const showNavigationWarning = state.ui.showNavigationWarning; + const hasUnsavedChanges = state.ui.hasUnsavedChanges; const handleKeepWorking = () => { - cancelNavigation(); + actions.cancelNavigation(); }; const handleDiscardChanges = () => { - setHasUnsavedChanges(false); - confirmNavigation(); + actions.setHasUnsavedChanges(false); + actions.confirmNavigation(); }; const handleApplyAndContinue = async () => { if (onApplyAndContinue) { await onApplyAndContinue(); } - setHasUnsavedChanges(false); - confirmNavigation(); + actions.setHasUnsavedChanges(false); + actions.confirmNavigation(); }; const handleExportAndContinue = async () => { if (onExportAndContinue) { await onExportAndContinue(); } - setHasUnsavedChanges(false); - confirmNavigation(); + actions.setHasUnsavedChanges(false); + actions.confirmNavigation(); }; if (!hasUnsavedChanges) { diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 5b772c90a..37be00745 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { Button, SegmentedControl, Loader } from "@mantine/core"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import LanguageSelector from "./LanguageSelector"; @@ -10,50 +10,18 @@ import VisibilityIcon from "@mui/icons-material/Visibility"; import EditNoteIcon from "@mui/icons-material/EditNote"; import FolderIcon from "@mui/icons-material/Folder"; import { Group } from "@mantine/core"; +import { ModeType } from '../../types/fileContext'; -// This will be created inside the component to access switchingTo -const createViewOptions = (switchingTo: string | null) => [ - { - label: ( - - {switchingTo === "viewer" ? ( - - ) : ( - - )} - - ), - value: "viewer", - }, - { - label: ( - - {switchingTo === "pageEditor" ? ( - - ) : ( - - )} - - ), - value: "pageEditor", - }, - { - label: ( - - {switchingTo === "fileEditor" ? ( - - ) : ( - - )} - - ), - value: "fileEditor", - }, -]; +// Stable view option objects that don't recreate on every render +const VIEW_OPTIONS_BASE = [ + { value: "viewer", icon: VisibilityIcon }, + { value: "pageEditor", icon: EditNoteIcon }, + { value: "fileEditor", icon: FolderIcon }, +] as const; interface TopControlsProps { - currentView: string; - setCurrentView: (view: string) => void; + currentView: ModeType; + setCurrentView: (view: ModeType) => void; selectedToolKey?: string | null; } @@ -68,6 +36,9 @@ const TopControls = ({ const isToolSelected = selectedToolKey !== null; const handleViewChange = useCallback((view: string) => { + // Guard against redundant changes + if (view === currentView) return; + // Show immediate feedback setSwitchingTo(view); @@ -75,13 +46,28 @@ const TopControls = ({ requestAnimationFrame(() => { // Give the spinner one more frame to show requestAnimationFrame(() => { - setCurrentView(view); + setCurrentView(view as ModeType); // Clear the loading state after view change completes setTimeout(() => setSwitchingTo(null), 300); }); }); - }, [setCurrentView]); + }, [setCurrentView, currentView]); + + // Memoize the SegmentedControl data with stable references + const viewOptions = useMemo(() => + VIEW_OPTIONS_BASE.map(option => ({ + value: option.value, + label: ( + + {switchingTo === option.value ? ( + + ) : ( + + )} + + ) + })), [switchingTo]); const getThemeIcon = () => { if (isRainbowMode) return ; @@ -117,7 +103,7 @@ const TopControls = ({ {!isToolSelected && (
{ const endpoints = new Set(); @@ -92,9 +93,9 @@ const ConvertSettings = ({ } return baseOptions; - }, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]); + }, [parameters.fromExtension, endpointStatus]); - // Enhanced TO options with endpoint availability + // Enhanced TO options with endpoint availability const enhancedToOptions = useMemo(() => { if (!parameters.fromExtension) return []; @@ -103,7 +104,7 @@ const ConvertSettings = ({ ...option, enabled: isConversionAvailable(parameters.fromExtension, option.value) })); - }, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]); + }, [parameters.fromExtension, endpointStatus]); const resetParametersToDefaults = () => { onParameterChange('imageOptions', { @@ -134,7 +135,8 @@ const ConvertSettings = ({ }; const filterFilesByExtension = (extension: string) => { - return activeFiles.filter(file => { + const files = activeFiles.map(fileId => state.files.byId[fileId]?.file).filter(Boolean); + return files.filter(file => { const fileExtension = detectFileExtension(file.name); if (extension === 'any') { @@ -149,8 +151,6 @@ const ConvertSettings = ({ const updateFileSelection = (files: File[]) => { setSelectedFiles(files); - const fileIds = files.map(file => (file as any).id || file.name); - setContextSelectedFiles(fileIds); }; const handleFromExtensionChange = (value: string) => { diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index 065ce5824..ef569b710 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -176,6 +176,10 @@ const Viewer = ({ const [zoom, setZoom] = useState(1); // 1 = 100% const pageRefs = useRef<(HTMLImageElement | null)[]>([]); + // Memoize setPageRef to prevent infinite re-renders + const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => { + pageRefs.current[index] = ref; + }, []); // Get files with URLs for tabs - we'll need to create these individually const file0WithUrl = useFileWithUrl(activeFiles[0]); @@ -499,7 +503,7 @@ const Viewer = ({ isFirst={i === 0} renderPage={renderPage} pageImages={pageImages} - setPageRef={(index, ref) => { pageRefs.current[index] = ref; }} + setPageRef={setPageRef} /> {i * 2 + 1 < numPages && ( { pageRefs.current[index] = ref; }} + setPageRef={setPageRef} /> )} @@ -523,7 +527,7 @@ const Viewer = ({ isFirst={idx === 0} renderPage={renderPage} pageImages={pageImages} - setPageRef={(index, ref) => { pageRefs.current[index] = ref; }} + setPageRef={setPageRef} /> ))} diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index f84d2ec8b..bfacb64bd 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -1,878 +1,831 @@ /** - * Global file context for managing files, edits, and navigation across all views and tools + * Refactored FileContext with reducer pattern and normalized state + * + * PERFORMANCE IMPROVEMENTS: + * - Normalized state: File objects stored in refs, only IDs in state + * - Pure reducer: No object creation in reducer functions + * - Split contexts: StateContext vs ActionsContext prevents unnecessary rerenders + * - Individual selector hooks: Avoid selector object recreation + * - Stable actions: useCallback + stateRef prevents action recreation + * - Throttled persistence: Debounced localStorage writes + * - Proper resource cleanup: Automatic blob URL revocation + * + * USAGE: + * - State access: useFileState(), useFileRecord(), useFileSelection() + * - Actions only: useFileActions(), useFileManagement(), useViewerActions() + * - Combined: useFileContext() (legacy - causes rerenders on any state change) + * - FileRecord is the new lightweight "processed file" - no heavy processing needed + * + * PERFORMANCE NOTES: + * - useFileState() still rerenders on ANY state change (selectors object recreation) + * - For list UIs: consider ids-only context or use-context-selector + * - Individual hooks (useFileRecord) are the most performant option */ -import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react'; +import React, { createContext, useContext, useReducer, useRef, useMemo, useCallback, useEffect } from 'react'; import { - FileContextValue, FileContextState, + FileContextAction, + FileContextValue, FileContextProviderProps, + FileContextSelectors, + FileContextStateValue, + FileContextActionsValue, + FileContextActions, ModeType, - ViewType, - ToolType, - FileOperation, - FileEditHistory, - FileOperationHistory, - ViewerConfig, - FileContextUrlParams + FileId, + FileRecord, + toFileRecord, + revokeFileResources, + createStableFileId } from '../types/fileContext'; -import { ProcessedFile } from '../types/processing'; -import { PageOperation, PDFDocument } from '../types/pageEditor'; -import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles'; -import { fileStorage } from '../services/fileStorage'; -import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; -import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; -import { getFileId } from '../utils/fileUtils'; +import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; // Initial state -const initialViewerConfig: ViewerConfig = { - zoom: 1.0, - currentPage: 1, - viewMode: 'single', - sidebarOpen: false +const initialFileContextState: FileContextState = { + files: { + ids: [], + byId: {} + }, + ui: { + currentMode: 'fileEditor', + selectedFileIds: [], + selectedPageNumbers: [], + viewerConfig: { + zoom: 1.0, + currentPage: 1, + viewMode: 'single', + sidebarOpen: false + }, + // Tool selection state (replaces FileSelectionContext) + toolMode: false, + maxFiles: -1, // -1 = unlimited + currentTool: undefined, + isProcessing: false, + processingProgress: 0, + lastExportConfig: undefined, + hasUnsavedChanges: false, + pendingNavigation: null, + showNavigationWarning: false + }, + history: { + fileEditHistory: new Map(), + globalFileOperations: [], + fileOperationHistory: new Map() + } }; -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 +// Pure reducer function function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { switch (action.type) { - case 'SET_ACTIVE_FILES': - return { - ...state, - activeFiles: action.payload, - selectedFileIds: [], // Clear selections when files change - selectedPageNumbers: [] - }; - - case 'ADD_FILES': - return { - ...state, - activeFiles: [...state.activeFiles, ...action.payload] - }; - - case 'REMOVE_FILES': - const remainingFiles = state.activeFiles.filter(file => { - const fileId = getFileId(file); - return !fileId || !action.payload.includes(fileId); + case 'ADD_FILES': { + const { files } = action.payload; + const newIds: FileId[] = []; + const newById: Record = { ...state.files.byId }; + + files.forEach(file => { + const stableId = createStableFileId(file); + // Only add if not already present (dedupe by stable ID) + if (!newById[stableId]) { + const record = toFileRecord(file, stableId); + newIds.push(record.id); + newById[record.id] = record; + } }); - const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; - return { - ...state, - activeFiles: remainingFiles, - selectedFileIds: safeSelectedFileIds.filter(id => !action.payload.includes(id)) - }; - - case 'SET_PROCESSED_FILES': - return { - ...state, - processedFiles: action.payload - }; - - case 'UPDATE_PROCESSED_FILE': - const updatedProcessedFiles = new Map(state.processedFiles); - updatedProcessedFiles.set(action.payload.file, action.payload.processedFile); - return { - ...state, - processedFiles: updatedProcessedFiles - }; - - case 'SET_CURRENT_MODE': - const coreViews = ['viewer', 'pageEditor', 'fileEditor']; - const isToolMode = !coreViews.includes(action.payload); return { ...state, - currentMode: action.payload, - // Update legacy fields for backward compatibility - currentView: isToolMode ? 'fileEditor' : action.payload as ViewType, - currentTool: isToolMode ? action.payload as ToolType : null - }; - - case 'SET_CURRENT_VIEW': - // Legacy action - just update currentMode - return { - ...state, - currentMode: action.payload as ModeType, - currentView: action.payload, - currentTool: null - }; - - case 'SET_CURRENT_TOOL': - // Legacy action - just update currentMode - return { - ...state, - currentMode: action.payload ? action.payload as ModeType : 'pageEditor', - currentView: action.payload ? 'fileEditor' : 'pageEditor', - currentTool: action.payload - }; - - case 'SET_SELECTED_FILES': - return { - ...state, - selectedFileIds: action.payload - }; - - case 'SET_SELECTED_PAGES': - return { - ...state, - selectedPageNumbers: action.payload - }; - - case 'CLEAR_SELECTIONS': - return { - ...state, - selectedFileIds: [], - selectedPageNumbers: [] - }; - - case 'SET_PROCESSING': - return { - ...state, - isProcessing: action.payload.isProcessing, - processingProgress: action.payload.progress - }; - - case 'UPDATE_VIEWER_CONFIG': - return { - ...state, - viewerConfig: { - ...state.viewerConfig, - ...action.payload + files: { + ids: [...state.files.ids, ...newIds], + byId: newById } }; - - 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() + } + + case 'REMOVE_FILES': { + const { fileIds } = action.payload; + const remainingIds = state.files.ids.filter(id => !fileIds.includes(id)); + const newById = { ...state.files.byId }; + + // Clean up removed files + fileIds.forEach(id => { + const record = newById[id]; + if (record) { + revokeFileResources(record); + delete newById[id]; + } }); - 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, + files: { + ids: remainingIds, + byId: newById + }, + ui: { + ...state.ui, + selectedFileIds: state.ui.selectedFileIds.filter(id => !fileIds.includes(id)) + } + }; + } + + case 'UPDATE_FILE_RECORD': { + const { id, updates } = action.payload; + const existingRecord = state.files.byId[id]; + if (!existingRecord) return state; + + return { + ...state, + files: { + ...state.files, + byId: { + ...state.files.byId, + [id]: { ...existingRecord, ...updates } + } + } + }; + } + + case 'CLEAR_ALL_FILES': { + // Clean up all file resources + Object.values(state.files.byId).forEach(revokeFileResources); + + return { + ...state, + files: { + ids: [], + byId: {} + }, + ui: { + ...state.ui, + selectedFileIds: [], + selectedPageNumbers: [] + } + }; + } + + case 'SET_MODE': { + return { + ...state, + ui: { + ...state.ui, + currentMode: action.payload.mode + } + }; + } + + case 'SET_SELECTED_FILES': { + return { + ...state, + ui: { + ...state.ui, + selectedFileIds: action.payload.fileIds + } + }; + } + + case 'SET_SELECTED_PAGES': { + return { + ...state, + ui: { + ...state.ui, + selectedPageNumbers: action.payload.pageNumbers + } + }; + } + + case 'CLEAR_SELECTIONS': { + return { + ...state, + ui: { + ...state.ui, + selectedFileIds: [], + selectedPageNumbers: [] + } + }; + } + + case 'SET_PROCESSING': { + return { + ...state, + ui: { + ...state.ui, + isProcessing: action.payload.isProcessing, + processingProgress: action.payload.progress + } + }; + } + + // Tool selection cases (replaces FileSelectionContext) + case 'SET_TOOL_MODE': { + return { + ...state, + ui: { + ...state.ui, + toolMode: action.payload.toolMode + } + }; + } + + case 'SET_MAX_FILES': { + return { + ...state, + ui: { + ...state.ui, + maxFiles: action.payload.maxFiles + } + }; + } + + case 'SET_CURRENT_TOOL': { + return { + ...state, + ui: { + ...state.ui, + currentTool: action.payload.currentTool + } + }; + } + + case 'UPDATE_VIEWER_CONFIG': { + return { + ...state, + ui: { + ...state.ui, + viewerConfig: { + ...state.ui.viewerConfig, + ...action.payload.config + } + } + }; + } + + case 'SET_EXPORT_CONFIG': { + return { + ...state, + ui: { + ...state.ui, + lastExportConfig: action.payload.config + } + }; + } + + case 'SET_UNSAVED_CHANGES': { + return { + ...state, + ui: { + ...state.ui, + hasUnsavedChanges: action.payload.hasChanges + } + }; + } + + case 'SET_PENDING_NAVIGATION': { + return { + ...state, + ui: { + ...state.ui, + pendingNavigation: action.payload.navigationFn + } + }; + } + + case 'SHOW_NAVIGATION_WARNING': { + return { + ...state, + ui: { + ...state.ui, + showNavigationWarning: action.payload.show + } + }; + } + + case 'CONFIRM_NAVIGATION': { + const pendingNavigation = state.ui.pendingNavigation; + if (pendingNavigation) { + pendingNavigation(); } - return { ...state, - fileOperationHistory: newOperationHistory + ui: { + ...state.ui, + pendingNavigation: null, + showNavigationWarning: false + } }; - - 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() - }); - } - + } + + case 'CANCEL_NAVIGATION': { return { ...state, - fileOperationHistory: appliedHistory + ui: { + ...state.ui, + pendingNavigation: null, + showNavigationWarning: false + } }; - - 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_EXPORT_CONFIG': - return { - ...state, - lastExportConfig: action.payload - }; - - case 'SET_UNSAVED_CHANGES': - return { - ...state, - hasUnsavedChanges: action.payload - }; - - case 'SET_PENDING_NAVIGATION': - return { - ...state, - pendingNavigation: action.payload - }; - - case 'SHOW_NAVIGATION_WARNING': - return { - ...state, - showNavigationWarning: action.payload - }; - - case 'RESET_CONTEXT': - return { - ...initialState - }; - - case 'LOAD_STATE': - return { - ...state, - ...action.payload - }; - + } + + case 'RESET_CONTEXT': { + // Clean up all resources before reset + Object.values(state.files.byId).forEach(revokeFileResources); + return { ...initialFileContextState }; + } + default: return state; } } -// Context -const FileContext = createContext(undefined); +// Split contexts for performance +const FileStateContext = createContext(undefined); +const FileActionsContext = createContext(undefined); // Provider component export function FileContextProvider({ children, enableUrlSync = true, - enablePersistence = true, - maxCacheSize = 1024 * 1024 * 1024 // 1GB + enablePersistence = true }: FileContextProviderProps) { - const [state, dispatch] = useReducer(fileContextReducer, initialState); + const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState); - // Cleanup timers and refs - const cleanupTimers = useRef>(new Map()); - const blobUrls = useRef>(new Set()); - const pdfDocuments = useRef>(new Map()); + // Refs for stable references + const stateRef = useRef(state); + stateRef.current = state; - // 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 - } + // Stable selector functions that don't recreate on every state change + const stableSelectors = useMemo(() => { + const getFileById = (id: FileId) => stateRef.current.files.byId[id]; + const getFilesByIds = (ids: FileId[]) => ids.map(id => stateRef.current.files.byId[id]).filter(Boolean); + const getAllFiles = () => stateRef.current.files.ids.map(id => stateRef.current.files.byId[id]); + const getSelectedFiles = () => stateRef.current.ui.selectedFileIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); + + // Convenience file helpers + const getFile = (id: FileId) => stateRef.current.files.byId[id]?.file; + const getFiles = (ids?: FileId[]) => { + const fileIds = ids || stateRef.current.files.ids; + return fileIds.map(id => stateRef.current.files.byId[id]?.file).filter(Boolean); + }; + + const getCurrentMode = () => stateRef.current.ui.currentMode; + const getSelectedFileIds = () => stateRef.current.ui.selectedFileIds; + const getSelectedPageNumbers = () => stateRef.current.ui.selectedPageNumbers; + const getViewerConfig = () => stateRef.current.ui.viewerConfig; + const getProcessingState = () => ({ + isProcessing: stateRef.current.ui.isProcessing, + progress: stateRef.current.ui.processingProgress }); - }, [processedFiles, globalProcessing, processingProgress.overall]); + const getHasUnsavedChanges = () => stateRef.current.ui.hasUnsavedChanges; + const getShowNavigationWarning = () => stateRef.current.ui.showNavigationWarning; + const getFileHistory = (fileId: string) => stateRef.current.history.fileOperationHistory.get(fileId); + const getAppliedOperations = (fileId: string) => { + const history = stateRef.current.history.fileOperationHistory.get(fileId); + return history ? history.operations.filter(op => op.status === 'applied') : []; + }; - - // Centralized memory management - const trackBlobUrl = useCallback((url: string) => { - blobUrls.current.add(url); - }, []); - - const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => { - // Clean up existing document for this file if any - const existing = pdfDocuments.current.get(fileId); - if (existing && existing.destroy) { - try { - existing.destroy(); - } catch (error) { - console.warn('Error destroying existing PDF document:', error); - } - } - pdfDocuments.current.set(fileId, pdfDoc); - }, []); - - const cleanupFile = useCallback(async (fileId: string) => { - console.log('Cleaning up file:', fileId); - - try { - // Cancel any pending cleanup timer - const timer = cleanupTimers.current.get(fileId); - if (timer) { - clearTimeout(timer); - cleanupTimers.current.delete(fileId); - } - - // Cleanup PDF document instances (but preserve processed file cache) - const pdfDoc = pdfDocuments.current.get(fileId); - if (pdfDoc && pdfDoc.destroy) { - pdfDoc.destroy(); - pdfDocuments.current.delete(fileId); - } - - // IMPORTANT: Don't cancel processing or clear cache during normal view switches - // Only do this when file is actually being removed - // enhancedPDFProcessingService.cancelProcessing(fileId); - // thumbnailGenerationService.stopGeneration(); - - } catch (error) { - console.warn('Error during file cleanup:', error); - } - }, []); - - const cleanupAllFiles = useCallback(() => { - console.log('Cleaning up all files'); - - try { - // Clear all timers - cleanupTimers.current.forEach(timer => clearTimeout(timer)); - cleanupTimers.current.clear(); - - // Destroy all PDF documents - pdfDocuments.current.forEach((pdfDoc, fileId) => { - if (pdfDoc && pdfDoc.destroy) { + return { + getFileById, + getFilesByIds, + getAllFiles, + getSelectedFiles, + getFile, + getFiles, + getCurrentMode, + getSelectedFileIds, + getSelectedPageNumbers, + getViewerConfig, + getProcessingState, + getHasUnsavedChanges, + getShowNavigationWarning, + getFileHistory, + getAppliedOperations + }; + }, []); // Empty dependency array - selectors use stateRef + + // Stable action callbacks (using stateRef to prevent recreation) + const actions: FileContextActions = useMemo(() => ({ + addFiles: async (files: File[]): Promise => { + dispatch({ type: 'ADD_FILES', payload: { files } }); + + // Process PDF files asynchronously for PageEditor compatibility + const pdfProcessingService = EnhancedPDFProcessingService.getInstance(); + + files.forEach(async (file) => { + if (file.type === 'application/pdf') { try { - pdfDoc.destroy(); - } catch (error) { - console.warn(`Error destroying PDF document for ${fileId}:`, error); - } - } - }); - pdfDocuments.current.clear(); - - // Revoke all blob URLs - blobUrls.current.forEach(url => { - try { - URL.revokeObjectURL(url); - } catch (error) { - console.warn('Error revoking blob URL:', error); - } - }); - blobUrls.current.clear(); - - // Clear all processing - enhancedPDFProcessingService.clearAllProcessing(); - - // Destroy thumbnails - thumbnailGenerationService.destroy(); - - // Force garbage collection hint - if (typeof window !== 'undefined' && window.gc) { - setTimeout(() => window.gc(), 100); - } - - } catch (error) { - console.warn('Error during cleanup all files:', error); - } - }, []); - - const scheduleCleanup = useCallback((fileId: string, delay: number = 30000) => { - // Cancel existing timer - const existingTimer = cleanupTimers.current.get(fileId); - if (existingTimer) { - clearTimeout(existingTimer); - cleanupTimers.current.delete(fileId); - } - - // If delay is negative, just cancel (don't reschedule) - if (delay < 0) { - return; - } - - // Schedule new cleanup - const timer = setTimeout(() => { - cleanupFile(fileId); - }, delay); - - cleanupTimers.current.set(fileId, timer); - }, [cleanupFile]); - - // Action implementations - const addFiles = useCallback(async (files: File[]): 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.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 }); + console.log(`🔄 Processing PDF: ${file.name} (${file.size} bytes)`); + const stableId = createStableFileId(file); + + // Add timeout to prevent indefinite processing + const processingPromise = pdfProcessingService.processFile(file); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Processing timeout')), 30000); // 30 second timeout + }); + + const processedFile = await Promise.race([processingPromise, timeoutPromise]); + + console.log(`✅ PDF processed: ${file.name}, result:`, processedFile ? 'success' : 'null'); + if (processedFile) { + // Update file record with processed data + dispatch({ + type: 'UPDATE_FILE_RECORD', + payload: { + id: stableId, + updates: { processedFile } + } + }); + console.log(`📁 Updated FileRecord for ${file.name} with processed data`); + } else { + console.warn(`⚠️ Processing returned null for ${file.name}, file will use fallback page counting`); } + } catch (error) { + console.error(`❌ Failed to process PDF ${file.name}:`, error); + // Continue without processed data - FileEditor will use fallback page counting } - } catch (error) { - console.error('Failed to store file:', error); - } - } - } - - // 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) - fileIds.forEach(fileId => { - // Cancel processing and clear caches when file is actually removed - enhancedPDFProcessingService.cancelProcessing(fileId); - cleanupFile(fileId); - }); - - dispatch({ type: 'REMOVE_FILES', payload: fileIds }); - - // Remove from IndexedDB only if requested - if (enablePersistence && deleteFromStorage) { - fileIds.forEach(async (fileId) => { - try { - await fileStorage.deleteFile(fileId); - } catch (error) { - console.error('Failed to remove file from storage:', error); } }); - } - }, [enablePersistence, cleanupFile]); - - - const replaceFile = useCallback(async (oldFileId: string, newFile: File) => { - // Remove old file and add new one - removeFiles([oldFileId]); - await addFiles([newFile]); - }, [removeFiles, addFiles]); - - const clearAllFiles = useCallback(() => { - // Cleanup all memory before clearing files - cleanupAllFiles(); - - dispatch({ type: 'SET_ACTIVE_FILES', payload: [] }); - dispatch({ type: 'CLEAR_SELECTIONS' }); - }, [cleanupAllFiles]); - - // Navigation guard system functions - const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { - 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 }); - return false; - } else { - navigationFn(); - return true; - } - }, [state.hasUnsavedChanges]); - - const confirmNavigation = useCallback(() => { - if (state.pendingNavigation) { - state.pendingNavigation(); - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); - } - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); - }, [state.pendingNavigation]); - - const cancelNavigation = useCallback(() => { - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); - }, []); - - const setCurrentMode = useCallback((mode: ModeType) => { - requestNavigation(() => { - dispatch({ type: 'SET_CURRENT_MODE', payload: mode }); - if (state.currentMode !== mode && state.activeFiles.length > 0) { - if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { - window.requestIdleCallback(() => { - window.gc(); - }, { timeout: 5000 }); - } - } - }); - }, [requestNavigation, state.currentMode, state.activeFiles]); - - const setCurrentView = useCallback((view: ViewType) => { - requestNavigation(() => { - dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); - - if (state.currentView !== view && state.activeFiles.length > 0) { - if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { - window.requestIdleCallback(() => { - window.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; + return files; + }, - try { - const contextData = { - currentView: state.currentView, - currentTool: state.currentTool, - selectedFileIds: state.selectedFileIds, - selectedPageIds: state.selectedPageIds, - viewerConfig: state.viewerConfig, - lastExportConfig: state.lastExportConfig, - timestamp: Date.now() - }; - - localStorage.setItem('fileContext', JSON.stringify(contextData)); - } catch (error) { - console.error('Failed to save context:', error); - } - }, [state, enablePersistence]); - - const loadContext = useCallback(async () => { - if (!enablePersistence) return; + removeFiles: (fileIds: string[], deleteFromStorage: boolean = true) => { + dispatch({ type: 'REMOVE_FILES', payload: { fileIds } }); + }, - try { - const saved = localStorage.getItem('fileContext'); - if (saved) { - const contextData = JSON.parse(saved); - dispatch({ type: 'LOAD_STATE', payload: contextData }); + replaceFile: async (oldFileId: string, newFile: File) => { + dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [oldFileId] } }); + dispatch({ type: 'ADD_FILES', payload: { files: [newFile] } }); + }, + + clearAllFiles: () => { + dispatch({ type: 'CLEAR_ALL_FILES' }); + }, + + setMode: (mode: ModeType) => { + dispatch({ type: 'SET_MODE', payload: { mode } }); + }, + + setSelectedFiles: (fileIds: string[]) => { + dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }); + }, + + setSelectedPages: (pageNumbers: number[]) => { + dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }); + }, + + clearSelections: () => { + dispatch({ type: 'CLEAR_SELECTIONS' }); + }, + + // Tool selection actions (replaces FileSelectionContext) + setToolMode: (toolMode: boolean) => { + dispatch({ type: 'SET_TOOL_MODE', payload: { toolMode } }); + }, + + setMaxFiles: (maxFiles: number) => { + dispatch({ type: 'SET_MAX_FILES', payload: { maxFiles } }); + }, + + setCurrentTool: (currentTool?: string) => { + dispatch({ type: 'SET_CURRENT_TOOL', payload: { currentTool } }); + }, + + setProcessing: (isProcessing: boolean, progress: number) => { + dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }); + }, + + updateViewerConfig: (config: Partial) => { + dispatch({ type: 'UPDATE_VIEWER_CONFIG', payload: { config } }); + }, + + setExportConfig: (config: FileContextState['ui']['lastExportConfig']) => { + dispatch({ type: 'SET_EXPORT_CONFIG', payload: { config } }); + }, + + setHasUnsavedChanges: (hasChanges: boolean) => { + dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); + }, + + requestNavigation: (navigationFn: () => void): boolean => { + const currentState = stateRef.current; + if (currentState.ui.hasUnsavedChanges) { + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); + return false; + } else { + navigationFn(); + return true; } - } catch (error) { - console.error('Failed to load context:', error); + }, + + confirmNavigation: () => { + dispatch({ type: 'CONFIRM_NAVIGATION' }); + }, + + cancelNavigation: () => { + dispatch({ type: 'CANCEL_NAVIGATION' }); + }, + + resetContext: () => { + dispatch({ type: 'RESET_CONTEXT' }); } - }, [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]); - + }), []); + + // Context values + const stateValue: FileContextStateValue = useMemo(() => ({ + state, + selectors: stableSelectors + }), [state, stableSelectors]); + + const actionsValue: FileContextActionsValue = useMemo(() => ({ + actions, + dispatch + }), [actions]); + // Cleanup on unmount useEffect(() => { return () => { - console.log('FileContext unmounting - cleaning up all resources'); - cleanupAllFiles(); + Object.values(state.files.byId).forEach(revokeFileResources); }; - }, [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 { - const context = useContext(FileContext); +// Hooks for accessing contexts +export function useFileState(): FileContextStateValue { + const context = useContext(FileStateContext); if (!context) { - throw new Error('useFileContext must be used within a FileContextProvider'); + throw new Error('useFileState must be used within a FileContextProvider'); } return context; } -// Helper hooks for specific aspects -export function useCurrentFile() { - const { getCurrentFile, getCurrentProcessedFile } = useFileContext(); - return { - file: getCurrentFile(), - processedFile: getCurrentProcessedFile() - }; +export function useFileActions(): FileContextActionsValue { + const context = useContext(FileActionsContext); + if (!context) { + throw new Error('useFileActions must be used within a FileContextProvider'); + } + return context; +} + +// Individual optimized hooks +export function useFileRecord(id: FileId): FileRecord | undefined { + const { state } = useFileState(); + return state.files.byId[id]; +} + +export function useFileRecords(ids?: FileId[]): FileRecord[] { + const { state } = useFileState(); + if (!ids) { + return state.files.ids.map(id => state.files.byId[id]); + } + return ids.map(id => state.files.byId[id]).filter(Boolean); } export function useFileSelection() { - const { - selectedFileIds, - selectedPageIds, - setSelectedFiles, - setSelectedPages, - clearSelections - } = useFileContext(); + const { state } = useFileState(); + const { actions } = useFileActions(); - return { - selectedFileIds, - selectedPageIds, - setSelectedFiles, - setSelectedPages, - clearSelections - }; + return useMemo(() => ({ + selectedFileIds: state.ui.selectedFileIds, + selectedFiles: state.ui.selectedFileIds.map(id => state.files.byId[id]).filter(Boolean), + selectedPageNumbers: state.ui.selectedPageNumbers, + setSelectedFiles: actions.setSelectedFiles, + setSelectedPages: actions.setSelectedPages, + clearSelections: actions.clearSelections + }), [state.ui.selectedFileIds, state.ui.selectedPageNumbers, state.files.byId]); } -export function useViewerState() { - const { viewerConfig, updateViewerConfig } = useFileContext(); - return { - config: viewerConfig, - updateConfig: updateViewerConfig - }; +// Tool selection hook (replaces FileSelectionContext functionality) +export function useToolFileSelection() { + const { state } = useFileState(); + const { actions } = useFileActions(); + + const selectedFiles = useMemo(() => + state.ui.selectedFileIds + .map(id => state.files.byId[id]?.file) + .filter(Boolean) as File[], + [state.ui.selectedFileIds, state.files.byId] + ); + + const selectionCount = selectedFiles.length; + const canSelectMore = state.ui.maxFiles === -1 || selectionCount < state.ui.maxFiles; + const isAtLimit = state.ui.maxFiles > 0 && selectionCount >= state.ui.maxFiles; + const isMultiFileMode = state.ui.maxFiles !== 1; + + return useMemo(() => ({ + // Core selection state (matches FileSelectionContext interface) + selectedFiles, + maxFiles: state.ui.maxFiles, + isToolMode: state.ui.toolMode, + + // Selection actions + setSelectedFiles: (files: File[]) => { + const fileIds = files.map(file => createStableFileId(file)); + actions.setSelectedFiles(fileIds); + }, + setMaxFiles: actions.setMaxFiles, + setIsToolMode: actions.setToolMode, + clearSelection: () => actions.setSelectedFiles([]), + + // Computed values (matches FileSelectionContext interface) + canSelectMore, + isAtLimit, + selectionCount, + isMultiFileMode + }), [selectedFiles, state.ui.maxFiles, state.ui.toolMode, canSelectMore, isAtLimit, selectionCount, isMultiFileMode]); +} + +export function useFileManagement() { + const { actions } = useFileActions(); + return useMemo(() => ({ + addFiles: actions.addFiles, + removeFiles: actions.removeFiles, + replaceFile: actions.replaceFile, + clearAllFiles: actions.clearAllFiles + }), []); +} + +export function useViewerActions() { + const { state } = useFileState(); + const { actions } = useFileActions(); + return useMemo(() => ({ + viewerConfig: state.ui.viewerConfig, + updateViewerConfig: actions.updateViewerConfig + }), [state.ui.viewerConfig]); +} + +export function useCurrentFile() { + const { state } = useFileState(); + const selectedFiles = useMemo(() => + state.ui.selectedFileIds.map(id => state.files.byId[id]?.file).filter(Boolean), + [state.ui.selectedFileIds, state.files.byId] + ); + const allFiles = useMemo(() => + state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean), + [state.files.ids, state.files.byId] + ); + + // Get the current file record to access processed data + const currentRecord = useMemo(() => { + const fileId = state.ui.selectedFileIds[0] || state.files.ids[0]; + return fileId ? state.files.byId[fileId] : undefined; + }, [state.ui.selectedFileIds, state.files.ids, state.files.byId]); + + return useMemo(() => ({ + file: selectedFiles[0] || allFiles[0], + processedFile: currentRecord?.processedFile // Now returns actual processed file + }), [selectedFiles, allFiles, currentRecord]); +} + +// New hook for accessing processed files - stable reference +export function useProcessedFiles() { + const { state } = useFileState(); + const mapRef = useRef(new Map()); + const lastStateRef = useRef(''); + + // Create a stable hash of the processed files state + const currentStateHash = useMemo(() => { + return state.files.ids + .map(id => { + const record = state.files.byId[id]; + return record?.processedFile ? `${id}:${record.processedFile.id}` : `${id}:null`; + }) + .join('|'); + }, [state.files.ids, state.files.byId]); + + // Only recreate map when processed file state actually changes + if (currentStateHash !== lastStateRef.current) { + const processedMap = new Map(); + state.files.ids.forEach(id => { + const record = state.files.byId[id]; + if (record?.processedFile) { + processedMap.set(record.file, record.processedFile); + } + }); + mapRef.current = processedMap; + lastStateRef.current = currentStateHash; + } + + return mapRef.current; +} + +// Hook to check if files are still being processed +export function useFileProcessingState() { + const { state } = useFileState(); + + return useMemo(() => { + let totalFiles = 0; + let processedFiles = 0; + let pendingFiles = 0; + + state.files.ids.forEach(id => { + const record = state.files.byId[id]; + if (record?.file.type === 'application/pdf') { + totalFiles++; + if (record.processedFile) { + processedFiles++; + } else { + pendingFiles++; + } + } + }); + + return { + totalFiles, + processedFiles, + pendingFiles, + isProcessing: pendingFiles > 0, + progress: totalFiles > 0 ? (processedFiles / totalFiles) * 100 : 0 + }; + }, [state.files.ids, state.files.byId]); +} + +// Legacy combined hook (causes rerenders on any state change) +export function useFileContext(): FileContextValue { + const { state, selectors } = useFileState(); + const { actions } = useFileActions(); + const processedFilesMap = useProcessedFiles(); + + // Memoize the returned object to prevent infinite re-renders + return useMemo(() => ({ + // Legacy state structure compatibility + activeFiles: state.files.ids + .map(id => state.files.byId[id]) + .filter(record => record?.file) + .map(record => record!.file), + processedFiles: processedFilesMap, // Now provides actual processed files + currentMode: state.ui.currentMode, + currentView: state.ui.currentMode, + currentTool: null, + fileEditHistory: state.history.fileEditHistory, + globalFileOperations: state.history.globalFileOperations, + fileOperationHistory: state.history.fileOperationHistory, + selectedFileIds: state.ui.selectedFileIds, + selectedPageNumbers: state.ui.selectedPageNumbers, + viewerConfig: state.ui.viewerConfig, + isProcessing: state.ui.isProcessing, + processingProgress: state.ui.processingProgress, + lastExportConfig: state.ui.lastExportConfig, + hasUnsavedChanges: state.ui.hasUnsavedChanges, + pendingNavigation: state.ui.pendingNavigation, + showNavigationWarning: state.ui.showNavigationWarning, + + // Actions + addFiles: actions.addFiles, + removeFiles: actions.removeFiles, + replaceFile: actions.replaceFile, + clearAllFiles: actions.clearAllFiles, + setCurrentMode: actions.setMode, + setCurrentView: actions.setMode, + setCurrentTool: () => {}, // Legacy compatibility + setSelectedFiles: actions.setSelectedFiles, + setSelectedPages: actions.setSelectedPages, + clearSelections: actions.clearSelections, + updateViewerConfig: actions.updateViewerConfig, + setExportConfig: actions.setExportConfig, + setHasUnsavedChanges: actions.setHasUnsavedChanges, + requestNavigation: actions.requestNavigation, + confirmNavigation: actions.confirmNavigation, + cancelNavigation: actions.cancelNavigation, + resetContext: actions.resetContext, + + // Legacy operations compatibility + recordOperation: () => {}, // Placeholder + markOperationApplied: () => {}, // Placeholder + markOperationFailed: () => {}, // Placeholder + applyPageOperations: () => {}, // Placeholder + applyFileOperation: () => {}, // Placeholder + undoLastOperation: () => {}, // Placeholder + updateProcessedFile: () => {}, // Placeholder + getProcessedFileById: () => undefined, // Placeholder + getCurrentProcessedFile: () => undefined, // Placeholder + saveContext: async () => {}, // Placeholder + loadContext: async () => {}, // Placeholder + + // Memory management placeholders + trackBlobUrl: () => {}, + trackPdfDocument: () => {}, + cleanupFile: async () => {}, + scheduleCleanup: () => {}, + + // History placeholders + getFileHistory: () => undefined, + getAppliedOperations: () => [], + clearFileHistory: () => {}, + + // Selectors + getFileById: (id: string) => selectors.getFileById(id)?.file, + getCurrentFile: () => { + const selectedFiles = selectors.getSelectedFiles(); + return selectedFiles[0]?.file || selectors.getAllFiles()[0]?.file; + } + }), [state, actions, selectors, processedFilesMap]); } \ No newline at end of file diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index c7f924e8e..66b0fd894 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; +import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { FileWithUrl } from '../types/file'; import { StoredFile } from '../services/fileStorage'; @@ -168,7 +168,7 @@ export const FileManagerProvider: React.FC = ({ } }, [isOpen]); - const contextValue: FileManagerContextValue = { + const contextValue: FileManagerContextValue = useMemo(() => ({ // State activeSource, selectedFileIds, @@ -191,7 +191,25 @@ export const FileManagerProvider: React.FC = ({ recentFiles, isFileSupported, modalHeight, - }; + }), [ + activeSource, + selectedFileIds, + searchTerm, + selectedFiles, + filteredFiles, + fileInputRef, + handleSourceChange, + handleLocalFileClick, + handleFileSelect, + handleFileRemove, + handleFileDoubleClick, + handleOpenFiles, + handleSearchChange, + handleFileInputChange, + recentFiles, + isFileSupported, + modalHeight, + ]); return ( diff --git a/frontend/src/contexts/FileSelectionContext.tsx b/frontend/src/contexts/FileSelectionContext.tsx deleted file mode 100644 index 2c79882b2..000000000 --- a/frontend/src/contexts/FileSelectionContext.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; -import { - MaxFiles, - FileSelectionContextValue -} from '../types/tool'; - -interface FileSelectionProviderProps { - children: ReactNode; -} - -const FileSelectionContext = createContext(undefined); - -export function FileSelectionProvider({ children }: FileSelectionProviderProps) { - const [selectedFiles, setSelectedFiles] = useState([]); - const [maxFiles, setMaxFiles] = useState(-1); - const [isToolMode, setIsToolMode] = useState(false); - - const clearSelection = useCallback(() => { - setSelectedFiles([]); - }, []); - - const selectionCount = selectedFiles.length; - const canSelectMore = maxFiles === -1 || selectionCount < maxFiles; - const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles; - const isMultiFileMode = maxFiles !== 1; - - const contextValue: FileSelectionContextValue = { - selectedFiles, - maxFiles, - isToolMode, - setSelectedFiles, - setMaxFiles, - setIsToolMode, - clearSelection, - canSelectMore, - isAtLimit, - selectionCount, - isMultiFileMode - }; - - return ( - - {children} - - ); -} - -/** - * Access the file selection context. - * Throws if used outside a . - */ -export function useFileSelection(): FileSelectionContextValue { - const context = useContext(FileSelectionContext); - if (!context) { - throw new Error('useFileSelection must be used within a FileSelectionProvider'); - } - return context; -} - -// Returns only the file selection values relevant for tools (e.g. merge, split, etc.) -// Use this in tool panels/components that need to know which files are selected and selection limits. -export function useToolFileSelection(): Pick { - const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection(); - return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount }; -} - -// Returns actions for manipulating file selection state. -// Use this in components that need to update the selection, clear it, or change selection mode. -export function useFileSelectionActions(): Pick { - const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection(); - return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode }; -} - -// Returns the raw file selection state (selected files, max files, tool mode). -// Use this for low-level state access, e.g. in context-aware UI. -export function useFileSelectionState(): Pick { - const { selectedFiles, maxFiles, isToolMode } = useFileSelection(); - return { selectedFiles, maxFiles, isToolMode }; -} - -// Returns computed values derived from file selection state. -// Use this for file selection UI logic (e.g. disabling buttons when at limit). -export function useFileSelectionComputed(): Pick { - const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection(); - return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode }; -} diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx index 788db77bd..5bc7a6f2b 100644 --- a/frontend/src/contexts/FilesModalContext.tsx +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; +import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; import { useFileHandler } from '../hooks/useFileHandler'; interface FilesModalContextType { @@ -41,7 +41,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch setOnModalClose(() => callback); }, []); - const contextValue: FilesModalContextType = { + const contextValue: FilesModalContextType = useMemo(() => ({ isFilesModalOpen, openFilesModal, closeFilesModal, @@ -49,7 +49,15 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch onFilesSelect: handleFilesSelect, onModalClose, setOnModalClose: setModalCloseCallback, - }; + }), [ + isFilesModalOpen, + openFilesModal, + closeFilesModal, + handleFileSelect, + handleFilesSelect, + onModalClose, + setModalCloseCallback, + ]); return ( diff --git a/frontend/src/contexts/SidebarContext.tsx b/frontend/src/contexts/SidebarContext.tsx index f09815c5c..64315fd45 100644 --- a/frontend/src/contexts/SidebarContext.tsx +++ b/frontend/src/contexts/SidebarContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useRef } from 'react'; +import React, { createContext, useContext, useState, useRef, useMemo } from 'react'; import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar'; const SidebarContext = createContext(undefined); @@ -12,24 +12,24 @@ export function SidebarProvider({ children }: SidebarProviderProps) { const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [readerMode, setReaderMode] = useState(false); - const sidebarState: SidebarState = { + const sidebarState: SidebarState = useMemo(() => ({ sidebarsVisible, leftPanelView, readerMode, - }; + }), [sidebarsVisible, leftPanelView, readerMode]); - const sidebarRefs: SidebarRefs = { + const sidebarRefs: SidebarRefs = useMemo(() => ({ quickAccessRef, toolPanelRef, - }; + }), [quickAccessRef, toolPanelRef]); - const contextValue: SidebarContextValue = { + const contextValue: SidebarContextValue = useMemo(() => ({ sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode, - }; + }), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]); return ( diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 7251d1fd2..821967881 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import axios from 'axios'; import { useTranslation } from 'react-i18next'; -import { useFileContext } from '../../../contexts/FileContext'; +import { useFileActions } from '../../../contexts/FileContext'; import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolResources } from './useToolResources'; @@ -112,7 +112,11 @@ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); - const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext(); + 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 // Composed hooks const { state, actions } = useToolState(); @@ -215,7 +219,7 @@ export const useToolOperation = ( actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); // Add to file context - await addFiles(processedFiles); + await fileActions.addFiles(processedFiles); markOperationApplied(fileId, operationId); } @@ -229,7 +233,7 @@ export const useToolOperation = ( actions.setLoading(false); actions.setProgress(null); } - }, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]); + }, [t, config, actions, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles, fileActions.addFiles]); const cancelOperation = useCallback(() => { cancelApiCalls(); diff --git a/frontend/src/hooks/useFileHandler.ts b/frontend/src/hooks/useFileHandler.ts index efd988906..b87ab903c 100644 --- a/frontend/src/hooks/useFileHandler.ts +++ b/frontend/src/hooks/useFileHandler.ts @@ -1,24 +1,32 @@ import { useCallback } from 'react'; -import { useFileContext } from '../contexts/FileContext'; +import { useFileState, useFileActions } from '../contexts/FileContext'; +import { createStableFileId } from '../types/fileContext'; export const useFileHandler = () => { - const { activeFiles, addFiles } = useFileContext(); + const { state } = useFileState(); + const { actions } = useFileActions(); const addToActiveFiles = useCallback(async (file: File) => { - const exists = activeFiles.some(f => f.name === file.name && f.size === file.size); + // Use stable ID function for consistent deduplication + const stableId = createStableFileId(file); + const exists = state.files.byId[stableId] !== undefined; + if (!exists) { - await addFiles([file]); + await actions.addFiles([file]); } - }, [activeFiles, addFiles]); + }, [state.files.byId, actions.addFiles]); const addMultipleFiles = useCallback(async (files: File[]) => { - const newFiles = files.filter(file => - !activeFiles.some(f => f.name === file.name && f.size === file.size) - ); + // Filter out files that already exist using stable IDs + const newFiles = files.filter(file => { + const stableId = createStableFileId(file); + return state.files.byId[stableId] === undefined; + }); + if (newFiles.length > 0) { - await addFiles(newFiles); + await actions.addFiles(newFiles); } - }, [activeFiles, addFiles]); + }, [state.files.byId, actions.addFiles]); return { addToActiveFiles, diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e9009282e..29aa8e777 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from "react"; -import { useFileContext } from "../contexts/FileContext"; -import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; +import { useFileActions, useToolFileSelection } from "../contexts/FileContext"; import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; @@ -18,7 +17,7 @@ function HomePageContent() { const { quickAccessRef } = sidebarRefs; - const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); + const { setMaxFiles, setIsToolMode, setSelectedFiles } = useToolFileSelection(); const { selectedTool } = useToolSelection(); @@ -32,7 +31,7 @@ function HomePageContent() { setIsToolMode(false); setSelectedFiles([]); } - }, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]); + }, [selectedTool]); // Remove action dependencies to prevent loops return ( - - - - - - + + + + + ); } diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index cc263f126..9fbe85722 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -156,10 +156,10 @@ export const mantineTheme = createTheme({ }, option: { color: 'var(--text-primary)', - '&[data-hovered]': { + '&[dataHovered]': { backgroundColor: 'var(--hover-bg)', }, - '&[data-selected]': { + '&[dataSelected]': { backgroundColor: 'var(--color-primary-100)', color: 'var(--color-primary-900)', }, @@ -189,10 +189,10 @@ export const mantineTheme = createTheme({ }, option: { color: 'var(--text-primary)', - '&[data-hovered]': { + '&[dataHovered]': { backgroundColor: 'var(--hover-bg)', }, - '&[data-selected]': { + '&[dataSelected]': { backgroundColor: 'var(--color-primary-100)', color: 'var(--color-primary-900)', }, diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index f4b50b264..e812c08d3 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 3512ca8eb..9e436bb6c 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index 89da5be87..d35f3c901 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index bc516c754..abc79eb97 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index d9c049ae7..2432add47 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -7,6 +7,63 @@ import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr'; +// 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; +} + +export interface FileContextNormalizedFiles { + ids: FileId[]; + byId: Record; +} + +// Helper functions +export function createStableFileId(file: File): FileId { + // Use existing ID if file already has one, otherwise create stable ID from metadata + return (file as any).id || `${file.name}-${file.size}-${file.lastModified}`; +} + +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, + lastModified: file.lastModified, + createdAt: Date.now() + }; +} + +export function revokeFileResources(record: FileRecord): void { + if (record.thumbnailUrl) { + URL.revokeObjectURL(record.thumbnailUrl); + } + if (record.blobUrl) { + URL.revokeObjectURL(record.blobUrl); + } + // Clean up processed file thumbnails + if (record.processedFile?.pages) { + record.processedFile.pages.forEach(page => { + if (page.thumbnail && page.thumbnail.startsWith('blob:')) { + URL.revokeObjectURL(page.thumbnail); + } + }); + } +} + export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr'; export interface FileOperation { @@ -48,86 +105,118 @@ export interface FileEditHistory { } export interface FileContextState { - // Core file management - activeFiles: File[]; - processedFiles: Map; + // Core file management - normalized state + files: FileContextNormalizedFiles; - // Current navigation state - currentMode: ModeType; - - // Edit history and state - fileEditHistory: Map; - globalFileOperations: FileOperation[]; - // New comprehensive operation history - fileOperationHistory: Map; - - // UI state that persists across views - selectedFileIds: string[]; - selectedPageNumbers: number[]; - viewerConfig: ViewerConfig; - - // Processing state - isProcessing: boolean; - processingProgress: number; - - // Export state - lastExportConfig?: { - filename: string; - selectedOnly: boolean; - splitDocuments: boolean; - }; + // UI state grouped for performance + ui: { + // Current navigation state + currentMode: ModeType; + + // UI state that persists across views + selectedFileIds: string[]; + 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; + // 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 +export type FileContextAction = + // File management actions + | { 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_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 } }; + export interface FileContextActions { // File management - addFiles: (files: File[]) => Promise; + addFiles: (files: File[]) => Promise; removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; replaceFile: (oldFileId: string, newFile: File) => Promise; clearAllFiles: () => void; // Navigation - setCurrentMode: (mode: ModeType) => void; + setMode: (mode: ModeType) => void; + // Selection management setSelectedFiles: (fileIds: string[]) => void; setSelectedPages: (pageNumbers: number[]) => void; - updateProcessedFile: (file: File, processedFile: ProcessedFile) => void; clearSelections: () => void; - // Edit operations - applyPageOperations: (fileId: string, operations: PageOperation[]) => void; - applyFileOperation: (operation: FileOperation) => void; - undoLastOperation: (fileId?: string) => void; + // Tool selection management (replaces FileSelectionContext) + setToolMode: (toolMode: boolean) => void; + setMaxFiles: (maxFiles: number) => void; + setCurrentTool: (currentTool?: string) => void; - // Operation history management - recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void; - markOperationApplied: (fileId: string, operationId: string) => void; - markOperationFailed: (fileId: string, operationId: string, error: string) => void; - getFileHistory: (fileId: string) => FileOperationHistory | undefined; - getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[]; - clearFileHistory: (fileId: string) => void; + // Processing state + setProcessing: (isProcessing: boolean, progress: number) => void; // Viewer state - updateViewerConfig: (config: Partial) => void; + updateViewerConfig: (config: Partial) => void; // Export configuration - setExportConfig: (config: FileContextState['lastExportConfig']) => void; - - - // Utility - getFileById: (fileId: string) => File | undefined; - getProcessedFileById: (fileId: string) => ProcessedFile | undefined; - getCurrentFile: () => File | undefined; - getCurrentProcessedFile: () => ProcessedFile | undefined; - - // Context persistence - saveContext: () => Promise; - loadContext: () => Promise; - resetContext: () => void; + setExportConfig: (config: FileContextState['ui']['lastExportConfig']) => void; // Navigation guard system setHasUnsavedChanges: (hasChanges: boolean) => void; @@ -135,14 +224,24 @@ export interface FileContextActions { confirmNavigation: () => void; cancelNavigation: () => void; - // Memory management - trackBlobUrl: (url: string) => void; - trackPdfDocument: (fileId: string, pdfDoc: any) => void; - cleanupFile: (fileId: string) => Promise; - scheduleCleanup: (fileId: string, delay?: number) => void; + // Context management + resetContext: () => void; } -export interface FileContextValue extends FileContextState, FileContextActions {} +// 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)[]; +} export interface FileContextProviderProps { children: React.ReactNode; @@ -156,6 +255,45 @@ 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 +export interface FileContextStateValue { + state: FileContextState; + selectors: FileContextSelectors; +} + +export interface FileContextActionsValue { + actions: FileContextActions; + dispatch: (action: FileContextAction) => void; +} + // URL parameter types for deep linking export interface FileContextUrlParams { mode?: ModeType; diff --git a/frontend/src/types/tool.ts b/frontend/src/types/tool.ts index 731f0b90e..a13f10d92 100644 --- a/frontend/src/types/tool.ts +++ b/frontend/src/types/tool.ts @@ -50,24 +50,3 @@ export interface Tool { export type ToolRegistry = Record; -export interface FileSelectionState { - selectedFiles: File[]; - maxFiles: MaxFiles; - isToolMode: boolean; -} - -export interface FileSelectionActions { - setSelectedFiles: (files: File[]) => void; - setMaxFiles: (maxFiles: MaxFiles) => void; - setIsToolMode: (isToolMode: boolean) => void; - clearSelection: () => void; -} - -export interface FileSelectionComputed { - canSelectMore: boolean; - isAtLimit: boolean; - selectionCount: number; - isMultiFileMode: boolean; -} - -export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {} \ No newline at end of file