From 9b14609236d7a30bcfc3832f3220ed2713f7a4fc Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Mon, 18 Aug 2025 21:00:19 +0100 Subject: [PATCH] feat: Introduce new file context management with hooks and lifecycle management - Added new contexts for file state and actions to improve performance. - Implemented unified file actions with a single `addFiles` helper for various file types. - Created performant hooks for accessing file state and actions, including selection and management. - Developed file selectors for efficient state access. - Introduced a lifecycle manager for resource cleanup and memory management. - Updated HomePage and tool components to utilize new navigation actions. - Refactored file context types to streamline state management and remove legacy compatibility. --- .claude/settings.local.json | 3 +- frontend/src/App.tsx | 9 +- frontend/src/components/layout/Workbench.tsx | 12 +- .../components/pageEditor/DragDropGrid.tsx | 2 +- .../src/components/pageEditor/PageEditor.tsx | 2 +- .../shared/NavigationWarningModal.tsx | 27 +- .../src/components/shared/TopControls.tsx | 2 +- frontend/src/contexts/FileContext.tsx | 1013 ++--------------- frontend/src/contexts/NavigationContext.tsx | 218 ++++ frontend/src/contexts/file/FileReducer.ts | 164 +++ frontend/src/contexts/file/contexts.ts | 13 + frontend/src/contexts/file/fileActions.ts | 225 ++++ frontend/src/contexts/file/fileHooks.ts | 228 ++++ frontend/src/contexts/file/fileSelectors.ts | 86 ++ frontend/src/contexts/file/lifecycle.ts | 257 +++++ frontend/src/pages/HomePage.tsx | 10 +- frontend/src/tools/AddPassword.tsx | 7 +- frontend/src/tools/ChangePermissions.tsx | 6 +- frontend/src/tools/Compress.tsx | 6 +- frontend/src/tools/Convert.tsx | 7 +- frontend/src/tools/OCR.tsx | 6 +- frontend/src/tools/Sanitize.tsx | 5 +- frontend/src/tools/Split.tsx | 6 +- frontend/src/types/fileContext.ts | 60 +- 24 files changed, 1367 insertions(+), 1007 deletions(-) create mode 100644 frontend/src/contexts/NavigationContext.tsx create mode 100644 frontend/src/contexts/file/FileReducer.ts create mode 100644 frontend/src/contexts/file/contexts.ts create mode 100644 frontend/src/contexts/file/fileActions.ts create mode 100644 frontend/src/contexts/file/fileHooks.ts create mode 100644 frontend/src/contexts/file/fileSelectors.ts create mode 100644 frontend/src/contexts/file/lifecycle.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dd65b777a..f18dd96c4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "Bash(npm test)", "Bash(npm test:*)", "Bash(ls:*)", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(node:*)" ], "deny": [], "defaultMode": "acceptEdits" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 852204b25..45b7f3045 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { FileContextProvider } from './contexts/FileContext'; +import { NavigationProvider } from './contexts/NavigationContext'; import { FilesModalProvider } from './contexts/FilesModalContext'; import HomePage from './pages/HomePage'; @@ -12,9 +13,11 @@ export default function App() { return ( - - - + + + + + ); diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index 8d6a2243d..657c807fd 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -5,6 +5,7 @@ import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext'; import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileState, useFileActions } from '../../contexts/FileContext'; +import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext'; import TopControls from '../shared/TopControls'; import FileEditor from '../fileEditor/FileEditor'; @@ -22,9 +23,10 @@ export default function Workbench() { // Use context-based hooks to eliminate all prop drilling const { state } = useFileState(); const { actions } = useFileActions(); + const { currentMode: currentView } = useNavigationState(); + const { actions: navActions } = useNavigationActions(); + const setCurrentView = navActions.setMode; const activeFiles = state.files.ids; - const currentView = state.ui.currentMode; - const setCurrentView = actions.setCurrentMode; const { previewFile, pageEditorFunctions, @@ -51,7 +53,7 @@ export default function Workbench() { handleToolSelect('convert'); sessionStorage.removeItem('previousMode'); } else { - actions.setMode('fileEditor'); + setCurrentView('fileEditor'); } }; @@ -73,11 +75,11 @@ export default function Workbench() { supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} {...(!selectedToolKey && { onOpenPageEditor: (file) => { - actions.setMode("pageEditor"); + setCurrentView("pageEditor"); }, onMergeFiles: (filesToMerge) => { filesToMerge.forEach(addToActiveFiles); - actions.setMode("viewer"); + setCurrentView("viewer"); } })} /> diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 3e9fd2206..ac2290dc1 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -65,7 +65,7 @@ const DragDropGrid = ({ }, [items.length, isLargeDocument, BUFFER_SIZE]); // Throttled scroll handler to prevent excessive re-renders - const throttleRef = useRef(); + const throttleRef = useRef(undefined); // Detect scroll position from parent container useEffect(() => { diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 99accb3f1..c4d0456b8 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -6,7 +6,7 @@ import { } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; -import { ModeType } from "../../types/fileContext"; +import { ModeType } from "../../contexts/NavigationContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { useUndoRedo } from "../../hooks/useUndoRedo"; diff --git a/frontend/src/components/shared/NavigationWarningModal.tsx b/frontend/src/components/shared/NavigationWarningModal.tsx index 3a7bf25b5..c7f591c71 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 { useFileState, useFileActions } from '../../contexts/FileContext'; +import { useNavigationGuard } from '../../contexts/NavigationContext'; interface NavigationWarningModalProps { onApplyAndContinue?: () => Promise; @@ -11,34 +11,37 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => { - const { state } = useFileState(); - const { actions } = useFileActions(); - const showNavigationWarning = state.ui.showNavigationWarning; - const hasUnsavedChanges = state.ui.hasUnsavedChanges; + const { + showNavigationWarning, + hasUnsavedChanges, + cancelNavigation, + confirmNavigation, + setHasUnsavedChanges + } = useNavigationGuard(); const handleKeepWorking = () => { - actions.cancelNavigation(); + cancelNavigation(); }; const handleDiscardChanges = () => { - actions.setHasUnsavedChanges(false); - actions.confirmNavigation(); + setHasUnsavedChanges(false); + confirmNavigation(); }; const handleApplyAndContinue = async () => { if (onApplyAndContinue) { await onApplyAndContinue(); } - actions.setHasUnsavedChanges(false); - actions.confirmNavigation(); + setHasUnsavedChanges(false); + confirmNavigation(); }; const handleExportAndContinue = async () => { if (onExportAndContinue) { await onExportAndContinue(); } - actions.setHasUnsavedChanges(false); - actions.confirmNavigation(); + setHasUnsavedChanges(false); + confirmNavigation(); }; if (!hasUnsavedChanges) { diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 0ea6dd10c..ee5591694 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -10,7 +10,7 @@ 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'; +import { ModeType } from '../../contexts/NavigationContext'; // Stable view option objects that don't recreate on every render const VIEW_OPTIONS_BASE = [ diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index edae69dbe..9c738d301 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -1,245 +1,36 @@ /** - * Refactored FileContext with reducer pattern and normalized state + * FileContext - Manages PDF files for Stirling PDF multi-tool workflow * - * 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 + * Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+). + * Users upload PDFs once and chain tools (split → merge → compress → view) without reloading. * - * 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 + * Key hooks: + * - useFileState() - access file state and UI state + * - useFileActions() - file operations (add/remove/update) + * - useToolFileSelection() - for tool components * - * 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 + * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation). */ -import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef, useMemo } from 'react'; +import React, { useReducer, useCallback, useEffect, useRef, useMemo } from 'react'; import { - FileContextState, FileContextProviderProps, FileContextSelectors, FileContextStateValue, FileContextActionsValue, FileContextActions, - FileContextAction, - ModeType, FileId, - FileRecord, - toFileRecord, - revokeFileResources, - createFileId, - createQuickKey + FileRecord } from '../types/fileContext'; -import { FileMetadata } from '../types/file'; -// Import real services -import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; -import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; -import { fileStorage } from '../services/fileStorage'; -import { fileProcessingService } from '../services/fileProcessingService'; -import { generateThumbnailWithMetadata } from '../utils/thumbnailUtils'; +// Import modular components +import { fileContextReducer, initialFileContextState } from './file/FileReducer'; +import { createFileSelectors } from './file/fileSelectors'; +import { addFiles, createFileActions } from './file/fileActions'; +import { FileLifecycleManager } from './file/lifecycle'; +import { FileStateContext, FileActionsContext } from './file/contexts'; -// Get service instances -const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance(); - -// Initial state -const initialFileContextState: FileContextState = { - files: { - ids: [], - byId: {} - }, - ui: { - currentMode: 'pageEditor' as ModeType, - selectedFileIds: [], - selectedPageNumbers: [], - isProcessing: false, - processingProgress: 0, - hasUnsavedChanges: false, - pendingNavigation: null, - showNavigationWarning: false - } -}; - -// Reducer -function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { - switch (action.type) { - case 'ADD_FILES': { - const { fileRecords } = action.payload; - const newIds: FileId[] = []; - const newById: Record = { ...state.files.byId }; - - fileRecords.forEach(record => { - // Only add if not already present (dedupe by stable ID) - if (!newById[record.id]) { - newIds.push(record.id); - newById[record.id] = record; - } - }); - - return { - ...state, - files: { - ids: [...state.files.ids, ...newIds], - byId: newById - } - }; - } - - 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, - 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; - - // Immutable merge supports all FileRecord fields - return { - ...state, - files: { - ...state.files, - byId: { - ...state.files.byId, - [id]: { ...existingRecord, ...updates } - } - } - }; - } - - case 'SET_CURRENT_MODE': { - return { - ...state, - ui: { - ...state.ui, - currentMode: action.payload - } - }; - } - - - 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 || 0 - } - }; - } - - 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 'RESET_CONTEXT': { - // Clean up all resources before reset - Object.values(state.files.byId).forEach(revokeFileResources); - return { ...initialFileContextState }; - } - - default: - return state; - } -} - -// Split contexts for performance -const FileStateContext = createContext(undefined); -const FileActionsContext = createContext(undefined); - -// Legacy context for backward compatibility -const FileContext = createContext(undefined); +const DEBUG = process.env.NODE_ENV === 'development'; // Provider component export function FileContextProvider({ @@ -252,748 +43,116 @@ export function FileContextProvider({ // File ref map - stores File objects outside React state const filesRef = useRef>(new Map()); - // Cleanup timers and refs - const cleanupTimers = useRef>(new Map()); - const blobUrls = useRef>(new Set()); - const pdfDocuments = useRef>(new Map()); - // Stable state reference for selectors const stateRef = useRef(state); stateRef.current = state; - // Stable selectors (memoized once to avoid re-renders) - const selectors = useMemo(() => ({ - getFile: (id: FileId) => filesRef.current.get(id), - - getFiles: (ids?: FileId[]) => { - const currentIds = ids || stateRef.current.files.ids; - return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; - }, - - getFileRecord: (id: FileId) => stateRef.current.files.byId[id], - - getFileRecords: (ids?: FileId[]) => { - const currentIds = ids || stateRef.current.files.ids; - return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); - }, - - getAllFileIds: () => stateRef.current.files.ids, - - getSelectedFiles: () => { - return stateRef.current.ui.selectedFileIds - .map(id => filesRef.current.get(id)) - .filter(Boolean) as File[]; - }, - - getSelectedFileRecords: () => { - return stateRef.current.ui.selectedFileIds - .map(id => stateRef.current.files.byId[id]) - .filter(Boolean); - }, - - // Stable signature for effects - prevents unnecessary re-renders - getFilesSignature: () => { - return stateRef.current.files.ids - .map(id => { - const record = stateRef.current.files.byId[id]; - return record ? `${id}:${record.size}:${record.lastModified}` : ''; - }) - .filter(Boolean) - .join('|'); - } - }), []); // Empty dependency array - selectors are now stable + // Create lifecycle manager + const lifecycleManagerRef = useRef(null); + if (!lifecycleManagerRef.current) { + lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch); + } + const lifecycleManager = lifecycleManagerRef.current; - // Centralized memory management - const trackBlobUrl = useCallback((url: string) => { - blobUrls.current.add(url); - }, []); + // Create stable selectors (memoized once to avoid re-renders) + const selectors = useMemo(() => + createFileSelectors(stateRef, filesRef), + [] // Empty deps - selectors are stable + ); - const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => { - // Clean up existing document for this file if any - const existing = pdfDocuments.current.get(fileId); - if (existing && existing.destroy) { - try { - existing.destroy(); - } catch (error) { - console.warn('Error destroying existing PDF document:', error); - } - } - pdfDocuments.current.set(fileId, pdfDoc); - }, []); + // Navigation management removed - moved to NavigationContext - 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); - } - - } catch (error) { - console.warn('Error during file cleanup:', error); - } - }, []); - - const cleanupAllFiles = useCallback(() => { - console.log('Cleaning up all files'); - - - try { - // Clear all timers - cleanupTimers.current.forEach(timer => clearTimeout(timer)); - cleanupTimers.current.clear(); - - // Destroy all PDF documents - pdfDocuments.current.forEach((pdfDoc, fileId) => { - if (pdfDoc && pdfDoc.destroy) { - try { - pdfDoc.destroy(); - } catch (error) { - console.warn(`Error destroying PDF document for ${fileId}:`, error); - } - } - }); - pdfDocuments.current.clear(); - - // Revoke all blob URLs (only blob: scheme) - blobUrls.current.forEach(url => { - if (url.startsWith('blob:')) { - try { - URL.revokeObjectURL(url); - } catch (error) { - console.warn('Error revoking blob URL:', error); - } - } - }); - blobUrls.current.clear(); - - // Clear all processing and cache - enhancedPDFProcessingService.clearAll(); - - // Cancel and clear centralized file processing - fileProcessingService.cancelAllProcessing(); - fileProcessingService.clearCache(); - - // Destroy thumbnails - thumbnailGenerationService.destroy(); - - // Force garbage collection hint - if (typeof window !== 'undefined' && (window as any).gc) { - let gc = (window as any).gc; - setTimeout(() => 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 = window.setTimeout(() => { - cleanupFile(fileId); - }, delay); - - - cleanupTimers.current.set(fileId, timer); - }, [cleanupFile]); - - // Action implementations - const addFiles = useCallback(async (files: File[]): Promise => { - console.log(`📄 addFiles: Adding ${files.length} files with immediate thumbnail generation`); - const fileRecords: FileRecord[] = []; - const addedFiles: File[] = []; - - // Build quickKey lookup from existing files for deduplication - const existingQuickKeys = new Set(); - Object.values(stateRef.current.files.byId).forEach(record => { - existingQuickKeys.add(record.quickKey); - }); - - for (const file of files) { - const quickKey = createQuickKey(file); - - // Soft deduplication: Check if file already exists by metadata - if (existingQuickKeys.has(quickKey)) { - console.log(`📄 Skipping duplicate file: ${file.name} (already exists)`); - continue; // Skip duplicate file - } - - const fileId = createFileId(); // UUID-based, zero collisions - - // Store File in ref map - filesRef.current.set(fileId, file); - - // Generate thumbnail and page count immediately - let thumbnail: string | undefined; - let pageCount: number = 1; - try { - console.log(`📄 Generating immediate thumbnail and metadata for ${file.name}`); - const result = await generateThumbnailWithMetadata(file); - thumbnail = result.thumbnail; - pageCount = result.pageCount; - console.log(`📄 Generated immediate metadata for ${file.name}: ${pageCount} pages, thumbnail: ${!!thumbnail}`); - } catch (error) { - console.warn(`📄 Failed to generate immediate metadata for ${file.name}:`, error); - // Continue with defaults - } - - // Create record with immediate thumbnail and page metadata - const record = toFileRecord(file, fileId); - if (thumbnail) { - record.thumbnailUrl = thumbnail; - } - - // Create initial processedFile metadata with page count - if (pageCount > 0) { - record.processedFile = { - totalPages: pageCount, - pages: Array.from({ length: pageCount }, (_, index) => ({ - pageNumber: index + 1, - thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially - rotation: 0, - splitBefore: false - })), - thumbnailUrl: thumbnail, - lastProcessed: Date.now() - }; - console.log(`📄 addFiles: Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); - } - - // Add to deduplication tracking - existingQuickKeys.add(quickKey); - - fileRecords.push(record); - addedFiles.push(file); - - // Start background processing for validation only (we already have thumbnail and page count) - fileProcessingService.processFile(file, fileId).then(result => { - // Only update if file still exists in context - if (filesRef.current.has(fileId)) { - if (result.success && result.metadata) { - // Only log if page count differs from our immediate calculation - const initialPageCount = pageCount; - if (result.metadata.totalPages !== initialPageCount) { - console.log(`📄 Page count validation: ${file.name} initial=${initialPageCount} → final=${result.metadata.totalPages} pages`); - // Update with the validated page count, but preserve existing thumbnail - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { - processedFile: { - ...result.metadata, - // Preserve our immediate thumbnail if we have one - thumbnailUrl: thumbnail || result.metadata.thumbnailUrl - }, - // Keep existing thumbnailUrl if we have one - thumbnailUrl: thumbnail || result.metadata.thumbnailUrl - } - } - }); - } else { - console.log(`✅ Page count validation passed for ${file.name}: ${result.metadata.totalPages} pages (immediate generation was correct)`); - } - - // Optional: Persist to IndexedDB if enabled - if (enablePersistence) { - try { - const finalThumbnail = thumbnail || result.metadata.thumbnailUrl; - fileStorage.storeFile(file, fileId, finalThumbnail).then(() => { - console.log('File persisted to IndexedDB:', fileId); - }).catch(error => { - console.warn('Failed to persist file to IndexedDB:', error); - }); - } catch (error) { - console.warn('Failed to initiate file persistence:', error); - } - } - } else { - console.warn(`❌ Background file processing failed for ${file.name}:`, result.error); - } - } - }).catch(error => { - console.error(`❌ Background file processing error for ${file.name}:`, error); - }); - - } - - // Only dispatch if we have new files - if (fileRecords.length > 0) { - dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); - } - - // Return only the newly added files - return addedFiles; - }, [enablePersistence]); // Remove updateFileRecord dependency - - // NEW: Add processed files with pre-existing thumbnails and metadata (for tool outputs) - const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => { - console.log(`📄 addProcessedFiles: Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`); - const fileRecords: FileRecord[] = []; - const addedFiles: File[] = []; - - // Build quickKey lookup from existing files for deduplication - const existingQuickKeys = new Set(); - Object.values(stateRef.current.files.byId).forEach(record => { - existingQuickKeys.add(record.quickKey); - }); - - for (const { file, thumbnail, pageCount } of filesWithThumbnails) { - const quickKey = createQuickKey(file); - - // Soft deduplication: Check if file already exists by metadata - if (existingQuickKeys.has(quickKey)) { - console.log(`📄 Skipping duplicate processed file: ${file.name} (already exists)`); - continue; // Skip duplicate file - } - - const fileId = createFileId(); // UUID-based, zero collisions - - // Store File in ref map - filesRef.current.set(fileId, file); - - // Create record with pre-existing thumbnail and page metadata - const record = toFileRecord(file, fileId); - if (thumbnail) { - record.thumbnailUrl = thumbnail; - } - - // If we have page count, create initial processedFile metadata - if (pageCount && pageCount > 0) { - record.processedFile = { - totalPages: pageCount, - pages: Array.from({ length: pageCount }, (_, index) => ({ - pageNumber: index + 1, - thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially - rotation: 0, - splitBefore: false - })), - thumbnailUrl: thumbnail, - lastProcessed: Date.now() - }; - console.log(`📄 addProcessedFiles: Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); - } - - // Add to deduplication tracking - existingQuickKeys.add(quickKey); - - fileRecords.push(record); - addedFiles.push(file); - - // Start background processing for page metadata only (thumbnail already provided) - fileProcessingService.processFile(file, fileId).then(result => { - // Only update if file still exists in context - if (filesRef.current.has(fileId)) { - if (result.success && result.metadata) { - // Update with processed metadata but preserve existing thumbnail - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { - processedFile: result.metadata, - // Keep existing thumbnail if we already have one, otherwise use processed one - thumbnailUrl: thumbnail || result.metadata.thumbnailUrl - } - } - }); - // Only log if page count changed (meaning our initial guess was wrong) - const initialPageCount = pageCount || 1; - if (result.metadata.totalPages !== initialPageCount) { - console.log(`📄 Page count updated for ${file.name}: ${initialPageCount} → ${result.metadata.totalPages} pages`); - } else { - console.log(`✅ Processed file metadata complete for ${file.name}: ${result.metadata.totalPages} pages (thumbnail: ${thumbnail ? 'PRE-EXISTING' : 'GENERATED'})`); - } - - // Optional: Persist to IndexedDB if enabled - if (enablePersistence) { - try { - const finalThumbnail = thumbnail || result.metadata.thumbnailUrl; - fileStorage.storeFile(file, fileId, finalThumbnail).then(() => { - console.log('Processed file persisted to IndexedDB:', fileId); - }).catch(error => { - console.warn('Failed to persist processed file to IndexedDB:', error); - }); - } catch (error) { - console.warn('Failed to initiate processed file persistence:', error); - } - } - } else { - console.warn(`❌ Processed file background processing failed for ${file.name}:`, result.error); - } - } - }).catch(error => { - console.error(`❌ Processed file background processing error for ${file.name}:`, error); - }); - } - - // Only dispatch if we have new files - if (fileRecords.length > 0) { - dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); - } - - console.log(`📄 Added ${fileRecords.length} processed files with pre-existing thumbnails`); - return addedFiles; - }, [enablePersistence]); - - // NEW: Add stored files with preserved IDs to prevent duplicates across sessions - // This is the CORRECT way to handle files from IndexedDB storage - no File object mutation - const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>): Promise => { - const fileRecords: FileRecord[] = []; - const addedFiles: File[] = []; - - for (const { file, originalId, metadata } of filesWithMetadata) { - // Skip if file already exists with same ID (exact match) - if (stateRef.current.files.byId[originalId]) { - console.log(`📄 Skipping stored file: ${file.name} (already loaded with same ID)`); - continue; - } - - // Store File in ref map with preserved ID - filesRef.current.set(originalId, file); - - // Create record with preserved ID and stored metadata - const record: FileRecord = { - id: originalId, // Preserve original UUID from storage - name: file.name, - size: file.size, - type: file.type, - lastModified: file.lastModified, - quickKey: createQuickKey(file), - thumbnailUrl: metadata.thumbnail, - createdAt: Date.now(), - // Skip processedFile for now - it will be populated by background processing if needed - }; - - fileRecords.push(record); - addedFiles.push(file); - - // Background processing with preserved ID (async, non-blocking) - fileProcessingService.processFile(file, originalId).then(result => { - // Only update if file still exists in context - if (filesRef.current.has(originalId)) { - if (result.success && result.metadata) { - // Update with processed metadata using dispatch directly - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: originalId, - updates: { - processedFile: result.metadata, - // Keep existing thumbnail if available, otherwise use processed one - thumbnailUrl: metadata.thumbnail || result.metadata.thumbnailUrl - } - } - }); - console.log(`✅ Stored file processing complete for ${file.name}: ${result.metadata.totalPages} pages`); - } else { - console.warn(`❌ Stored file processing failed for ${file.name}:`, result.error); - } - } - }).catch(error => { - console.error(`❌ Stored file processing error for ${file.name}:`, error); - }); - } - - // Only dispatch if we have new files - if (fileRecords.length > 0) { - dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); - } - - console.log(`📁 Added ${fileRecords.length} stored files with preserved IDs`); - return addedFiles; - }, []); - - const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => { - // Cancel any ongoing processing for removed files - fileIds.forEach(fileId => { - fileProcessingService.cancelProcessing(fileId); - }); - - // Clean up Files from ref map first - fileIds.forEach(fileId => { - filesRef.current.delete(fileId); - cleanupFile(fileId); - }); - - // Update state - 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 updateFileRecord = useCallback((id: FileId, updates: Partial) => { - // Ensure immutable merge by dispatching action - dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id, updates } }); - }, []); - - // Navigation guard system functions const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []); - const requestNavigation = useCallback((navigationFn: () => void): boolean => { - // Use stateRef to avoid stale closure issues with rapid state changes - if (stateRef.current.ui.hasUnsavedChanges) { - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); - return false; - } else { - navigationFn(); - return true; - } - }, []); // No dependencies - uses stateRef for current state - - const confirmNavigation = useCallback(() => { - // Use stateRef to get current navigation function - if (stateRef.current.ui.pendingNavigation) { - stateRef.current.ui.pendingNavigation(); - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); - } - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); - }, []); // No dependencies - uses stateRef - - const cancelNavigation = useCallback(() => { - dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); - dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); + // File operations using unified addFiles helper + const addRawFiles = useCallback(async (files: File[]): Promise => { + return addFiles('raw', { files }, stateRef, filesRef, dispatch); }, []); - // Memoized actions to prevent re-renders + const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => { + return addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch); + }, []); + + const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise => { + return addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch); + }, []); + + // Action creators + const baseActions = useMemo(() => createFileActions(dispatch), []); + + // Complete actions object const actions = useMemo(() => ({ - addFiles, + ...baseActions, + addFiles: addRawFiles, addProcessedFiles, - addStoredFiles, - removeFiles, - updateFileRecord, + addStoredFiles, + removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => + lifecycleManager.removeFiles(fileIds, stateRef), + updateFileRecord: (fileId: FileId, updates: Partial) => + lifecycleManager.updateFileRecord(fileId, updates, stateRef), clearAllFiles: () => { - cleanupAllFiles(); + lifecycleManager.cleanupAllFiles(); filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); }, - setCurrentMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), - setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }), - setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }), - clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }), - setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }), setHasUnsavedChanges, - resetContext: () => { - cleanupAllFiles(); - filesRef.current.clear(); - dispatch({ type: 'RESET_CONTEXT' }); - }, - // Legacy compatibility - setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), - confirmNavigation, - cancelNavigation - }), [addFiles, addProcessedFiles, addStoredFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]); + trackBlobUrl: lifecycleManager.trackBlobUrl, + trackPdfDocument: lifecycleManager.trackPdfDocument, + cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef), + scheduleCleanup: (fileId: string, delay?: number) => + lifecycleManager.scheduleCleanup(fileId, delay, stateRef) + }), [ + baseActions, + addRawFiles, + addProcessedFiles, + addStoredFiles, + lifecycleManager, + setHasUnsavedChanges + ]); // Split context values to minimize re-renders const stateValue = useMemo(() => ({ state, selectors - }), [state]); // selectors are now stable, no need to depend on them + }), [state, selectors]); const actionsValue = useMemo(() => ({ actions, dispatch }), [actions]); - // Legacy context value for backward compatibility - const legacyContextValue = useMemo(() => ({ - ...state, - ...state.ui, - // Action compatibility layer - addFiles, - addProcessedFiles, - addStoredFiles, - removeFiles, - updateFileRecord, - clearAllFiles: actions.clearAllFiles, - setCurrentMode: actions.setCurrentMode, - setSelectedFiles: actions.setSelectedFiles, - setSelectedPages: actions.setSelectedPages, - clearSelections: actions.clearSelections, - setHasUnsavedChanges, - requestNavigation, - confirmNavigation, - cancelNavigation, - trackBlobUrl, - trackPdfDocument, - cleanupFile, - scheduleCleanup, - // Missing operation functions (stubs) - recordOperation: () => { console.warn('recordOperation is deprecated'); }, - markOperationApplied: () => { console.warn('markOperationApplied is deprecated'); }, - markOperationFailed: () => { console.warn('markOperationFailed is deprecated'); }, - // Computed properties that components expect - get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render - // Selectors - ...selectors - }), [state, actions, addFiles, addProcessedFiles, addStoredFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency - // Cleanup on unmount useEffect(() => { return () => { - console.log('FileContext unmounting - cleaning up all resources'); - cleanupAllFiles(); + if (DEBUG) console.log('FileContext unmounting - cleaning up all resources'); + lifecycleManager.destroy(); }; - }, [cleanupAllFiles]); + }, [lifecycleManager]); return ( - - {children} - + {children} ); } -// New hooks for split contexts (prevent unnecessary re-renders) -export function useFileState() { - const context = useContext(FileStateContext); - if (!context) { - throw new Error('useFileState must be used within a FileContextProvider'); - } - return context; -} - -export function useFileActions() { - const context = useContext(FileActionsContext); - if (!context) { - throw new Error('useFileActions must be used within a FileContextProvider'); - } - return context; -} - -// Legacy hook for backward compatibility -export function useFileContext(): any { - const context = useContext(FileContext); - if (!context) { - throw new Error('useFileContext must be used within a FileContextProvider'); - } - return context; -} - -// Helper hooks for specific aspects -export function useCurrentFile() { - const { state, selectors } = useFileState(); - - const primaryFileId = state.files.ids[0]; - return useMemo(() => ({ - file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, - record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined - }), [primaryFileId]); // selectors are stable, don't depend on them -} - -export function useFileSelection() { - const { state } = useFileState(); - const { actions } = useFileActions(); - - return { - selectedFileIds: state.ui.selectedFileIds, - selectedPageNumbers: state.ui.selectedPageNumbers, - setSelectedFiles: actions.setSelectedFiles, - setSelectedPages: actions.setSelectedPages, - clearSelections: actions.clearSelections - }; -} - -// Legacy compatibility hooks - provide stubs for removed functionality -export function useToolFileSelection() { - const { state, selectors } = useFileState(); - const { actions } = useFileActions(); - - // Memoize selectedFiles to avoid recreating arrays - const selectedFiles = useMemo(() => { - return selectors.getSelectedFiles(); - }, [state.ui.selectedFileIds]); // selectors are stable, don't depend on them - - return useMemo(() => ({ - selectedFileIds: state.ui.selectedFileIds, - selectedPageNumbers: state.ui.selectedPageNumbers, - selectedFiles, // Now stable - only changes when selectedFileIds actually change - setSelectedFiles: actions.setSelectedFiles, - setSelectedPages: actions.setSelectedPages, - clearSelections: actions.clearSelections, - // Tool-specific properties that components expect - maxFiles: 10, // Default value - isToolMode: true, - setMaxFiles: (maxFiles: number) => { console.log('setMaxFiles called with:', maxFiles); }, // Stub with proper signature - setIsToolMode: (isToolMode: boolean) => { console.log('setIsToolMode called with:', isToolMode); } // Stub with proper signature - }), [selectedFiles, state.ui.selectedFileIds, state.ui.selectedPageNumbers, actions]); -} - -export function useProcessedFiles() { - const { state, selectors } = useFileState(); - - // Create a Map-like interface for backward compatibility - const compatibilityMap = { - size: state.files.ids.length, - get: (file: File) => { - console.warn('useProcessedFiles.get is deprecated - File objects no longer have stable IDs'); - return null; - }, - has: (file: File) => { - console.warn('useProcessedFiles.has is deprecated - File objects no longer have stable IDs'); - return false; - }, - set: () => { - console.warn('processedFiles.set is deprecated - use FileRecord updates instead'); - } - }; - - return { - processedFiles: compatibilityMap, // Map-like interface for backward compatibility - getProcessedFile: (file: File) => { - console.warn('getProcessedFile is deprecated - File objects no longer have stable IDs'); - return null; - }, - updateProcessedFile: () => { - console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord'); - } - }; -} - -export function useFileManagement() { - const { actions } = useFileActions(); - return { - addFiles: actions.addFiles, - removeFiles: actions.removeFiles, - clearAllFiles: actions.clearAllFiles - }; -} +// Export all hooks from the fileHooks module +export { + useFileState, + useFileActions, + useCurrentFile, + useFileSelection, + useFileManagement, + useFileUI, + useFileRecord, + useAllFiles, + useSelectedFiles, + // Primary API hooks for tools + useFileContext, + useToolFileSelection, + useProcessedFiles +} from './file/fileHooks'; \ No newline at end of file diff --git a/frontend/src/contexts/NavigationContext.tsx b/frontend/src/contexts/NavigationContext.tsx new file mode 100644 index 000000000..663ea9618 --- /dev/null +++ b/frontend/src/contexts/NavigationContext.tsx @@ -0,0 +1,218 @@ +import React, { createContext, useContext, useReducer, useCallback } from 'react'; + +/** + * NavigationContext - Complete navigation management system + * + * Handles navigation modes, navigation guards for unsaved changes, + * and breadcrumb/history navigation. Separated from FileContext to + * maintain clear separation of concerns. + */ + +// Navigation mode types +export type ModeType = + | 'viewer' + | 'pageEditor' + | 'fileEditor' + | 'merge' + | 'split' + | 'compress' + | 'ocr' + | 'convert' + | 'addPassword' + | 'changePermissions' + | 'sanitize'; + +// Navigation state +interface NavigationState { + currentMode: ModeType; + hasUnsavedChanges: boolean; + pendingNavigation: (() => void) | null; + showNavigationWarning: boolean; +} + +// Navigation actions +type NavigationAction = + | { type: 'SET_MODE'; payload: { mode: ModeType } } + | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } + | { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } } + | { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }; + +// Navigation reducer +const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => { + switch (action.type) { + case 'SET_MODE': + return { ...state, currentMode: action.payload.mode }; + + case 'SET_UNSAVED_CHANGES': + return { ...state, hasUnsavedChanges: action.payload.hasChanges }; + + case 'SET_PENDING_NAVIGATION': + return { ...state, pendingNavigation: action.payload.navigationFn }; + + case 'SHOW_NAVIGATION_WARNING': + return { ...state, showNavigationWarning: action.payload.show }; + + default: + return state; + } +}; + +// Initial state +const initialState: NavigationState = { + currentMode: 'pageEditor', + hasUnsavedChanges: false, + pendingNavigation: null, + showNavigationWarning: false +}; + +// Navigation context actions interface +export interface NavigationContextActions { + setMode: (mode: ModeType) => void; + setHasUnsavedChanges: (hasChanges: boolean) => void; + showNavigationWarning: (show: boolean) => void; + requestNavigation: (navigationFn: () => void) => void; + confirmNavigation: () => void; + cancelNavigation: () => void; +} + +// Split context values +export interface NavigationContextStateValue { + currentMode: ModeType; + hasUnsavedChanges: boolean; + pendingNavigation: (() => void) | null; + showNavigationWarning: boolean; +} + +export interface NavigationContextActionsValue { + actions: NavigationContextActions; +} + +// Create contexts +const NavigationStateContext = createContext(undefined); +const NavigationActionsContext = createContext(undefined); + +// Provider component +export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(navigationReducer, initialState); + + const actions: NavigationContextActions = { + setMode: useCallback((mode: ModeType) => { + dispatch({ type: 'SET_MODE', payload: { mode } }); + }, []), + + setHasUnsavedChanges: useCallback((hasChanges: boolean) => { + dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); + }, []), + + showNavigationWarning: useCallback((show: boolean) => { + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } }); + }, []), + + requestNavigation: useCallback((navigationFn: () => void) => { + // If no unsaved changes, navigate immediately + if (!state.hasUnsavedChanges) { + navigationFn(); + return; + } + + // Otherwise, store the navigation and show warning + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); + }, [state.hasUnsavedChanges]), + + confirmNavigation: useCallback(() => { + // Execute pending navigation + if (state.pendingNavigation) { + state.pendingNavigation(); + } + + // Clear navigation state + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); + }, [state.pendingNavigation]), + + cancelNavigation: useCallback(() => { + // Clear navigation without executing + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); + }, []) + }; + + const stateValue: NavigationContextStateValue = { + currentMode: state.currentMode, + hasUnsavedChanges: state.hasUnsavedChanges, + pendingNavigation: state.pendingNavigation, + showNavigationWarning: state.showNavigationWarning + }; + + const actionsValue: NavigationContextActionsValue = { + actions + }; + + return ( + + + {children} + + + ); +}; + +// Navigation hooks +export const useNavigationState = () => { + const context = useContext(NavigationStateContext); + if (context === undefined) { + throw new Error('useNavigationState must be used within NavigationProvider'); + } + return context; +}; + +export const useNavigationActions = () => { + const context = useContext(NavigationActionsContext); + if (context === undefined) { + throw new Error('useNavigationActions must be used within NavigationProvider'); + } + return context; +}; + +// Combined hook for convenience +export const useNavigation = () => { + const state = useNavigationState(); + const { actions } = useNavigationActions(); + return { ...state, ...actions }; +}; + +// Navigation guard hook (equivalent to old useFileNavigation) +export const useNavigationGuard = () => { + const state = useNavigationState(); + const { actions } = useNavigationActions(); + + return { + pendingNavigation: state.pendingNavigation, + showNavigationWarning: state.showNavigationWarning, + hasUnsavedChanges: state.hasUnsavedChanges, + requestNavigation: actions.requestNavigation, + confirmNavigation: actions.confirmNavigation, + cancelNavigation: actions.cancelNavigation, + setHasUnsavedChanges: actions.setHasUnsavedChanges, + setShowNavigationWarning: actions.showNavigationWarning + }; +}; + +// Utility functions for mode handling +export const isValidMode = (mode: string): mode is ModeType => { + const validModes: ModeType[] = [ + 'viewer', 'pageEditor', 'fileEditor', 'merge', 'split', + 'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize' + ]; + return validModes.includes(mode as ModeType); +}; + +export const getDefaultMode = (): ModeType => 'pageEditor'; + +// TODO: This will be expanded for URL-based routing system +// - URL parsing utilities +// - Route definitions +// - Navigation hooks with URL sync +// - History management +// - Breadcrumb restoration from URL params \ No newline at end of file diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts new file mode 100644 index 000000000..5151d5aca --- /dev/null +++ b/frontend/src/contexts/file/FileReducer.ts @@ -0,0 +1,164 @@ +/** + * FileContext reducer - Pure state management for file operations + */ + +import { + FileContextState, + FileContextAction, + FileId, + FileRecord +} from '../../types/fileContext'; + +// Initial state +export const initialFileContextState: FileContextState = { + files: { + ids: [], + byId: {} + }, + ui: { + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false + } +}; + +// Pure reducer function +export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { + switch (action.type) { + case 'ADD_FILES': { + const { fileRecords } = action.payload; + const newIds: FileId[] = []; + const newById: Record = { ...state.files.byId }; + + fileRecords.forEach(record => { + // Only add if not already present (dedupe by stable ID) + if (!newById[record.id]) { + newIds.push(record.id); + newById[record.id] = record; + } + }); + + return { + ...state, + files: { + ids: [...state.files.ids, ...newIds], + byId: newById + } + }; + } + + case 'REMOVE_FILES': { + const { fileIds } = action.payload; + const remainingIds = state.files.ids.filter(id => !fileIds.includes(id)); + const newById = { ...state.files.byId }; + + // Remove files from state (resource cleanup handled by lifecycle manager) + fileIds.forEach(id => { + delete newById[id]; + }); + + // Clear selections that reference removed files + const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id)); + + return { + ...state, + files: { + ids: remainingIds, + byId: newById + }, + ui: { + ...state.ui, + selectedFileIds: validSelectedFileIds + } + }; + } + + case 'UPDATE_FILE_RECORD': { + const { id, updates } = action.payload; + const existingRecord = state.files.byId[id]; + + if (!existingRecord) { + return state; // File doesn't exist, no-op + } + + return { + ...state, + files: { + ...state.files, + byId: { + ...state.files.byId, + [id]: { + ...existingRecord, + ...updates + } + } + } + }; + } + + case 'SET_SELECTED_FILES': { + const { fileIds } = action.payload; + return { + ...state, + ui: { + ...state.ui, + selectedFileIds: fileIds + } + }; + } + + case 'SET_SELECTED_PAGES': { + const { pageNumbers } = action.payload; + return { + ...state, + ui: { + ...state.ui, + selectedPageNumbers: pageNumbers + } + }; + } + + case 'CLEAR_SELECTIONS': { + return { + ...state, + ui: { + ...state.ui, + selectedFileIds: [], + selectedPageNumbers: [] + } + }; + } + + case 'SET_PROCESSING': { + const { isProcessing, progress } = action.payload; + return { + ...state, + ui: { + ...state.ui, + isProcessing, + processingProgress: progress + } + }; + } + + case 'SET_UNSAVED_CHANGES': { + return { + ...state, + ui: { + ...state.ui, + hasUnsavedChanges: action.payload.hasChanges + } + }; + } + + case 'RESET_CONTEXT': { + // Reset UI state to clean slate (resource cleanup handled by lifecycle manager) + return { ...initialFileContextState }; + } + + default: + return state; + } +} \ No newline at end of file diff --git a/frontend/src/contexts/file/contexts.ts b/frontend/src/contexts/file/contexts.ts new file mode 100644 index 000000000..2474f1094 --- /dev/null +++ b/frontend/src/contexts/file/contexts.ts @@ -0,0 +1,13 @@ +/** + * React contexts for file state and actions + */ + +import { createContext } from 'react'; +import { FileContextStateValue, FileContextActionsValue } from '../../types/fileContext'; + +// Split contexts for performance +export const FileStateContext = createContext(undefined); +export const FileActionsContext = createContext(undefined); + +// Export types for use in hooks +export type { FileContextStateValue, FileContextActionsValue }; \ No newline at end of file diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts new file mode 100644 index 000000000..4953cdd9a --- /dev/null +++ b/frontend/src/contexts/file/fileActions.ts @@ -0,0 +1,225 @@ +/** + * File actions - Unified file operations with single addFiles helper + */ + +import { + FileId, + FileRecord, + FileContextAction, + FileContextState, + toFileRecord, + createFileId, + createQuickKey +} from '../../types/fileContext'; +import { FileMetadata } from '../../types/file'; +import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; +import { fileProcessingService } from '../../services/fileProcessingService'; +import { buildQuickKeySet } from './fileSelectors'; + +const DEBUG = process.env.NODE_ENV === 'development'; + +/** + * Helper to create ProcessedFile metadata structure + */ +export function createProcessedFile(pageCount: number, thumbnail?: string) { + return { + totalPages: pageCount, + pages: Array.from({ length: pageCount }, (_, index) => ({ + pageNumber: index + 1, + thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially + rotation: 0, + splitBefore: false + })), + thumbnailUrl: thumbnail, + lastProcessed: Date.now() + }; +} + +/** + * File addition types + */ +type AddFileKind = 'raw' | 'processed' | 'stored'; + +interface AddFileOptions { + // For 'raw' files + files?: File[]; + + // For 'processed' files + filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>; + + // For 'stored' files + filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>; +} + +/** + * Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles + */ +export async function addFiles( + kind: AddFileKind, + options: AddFileOptions, + stateRef: React.MutableRefObject, + filesRef: React.MutableRefObject>, + dispatch: React.Dispatch +): Promise { + const fileRecords: FileRecord[] = []; + const addedFiles: File[] = []; + + // Build quickKey lookup from existing files for deduplication + const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); + + switch (kind) { + case 'raw': { + const { files = [] } = options; + if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`); + + for (const file of files) { + const quickKey = createQuickKey(file); + + // Soft deduplication: Check if file already exists by metadata + if (existingQuickKeys.has(quickKey)) { + if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (already exists)`); + continue; + } + + const fileId = createFileId(); + filesRef.current.set(fileId, file); + + // Generate thumbnail and page count immediately + let thumbnail: string | undefined; + let pageCount: number = 1; + + try { + if (DEBUG) console.log(`📄 Generating immediate thumbnail and metadata for ${file.name}`); + const result = await generateThumbnailWithMetadata(file); + thumbnail = result.thumbnail; + pageCount = result.pageCount; + if (DEBUG) console.log(`📄 Generated immediate metadata for ${file.name}: ${pageCount} pages, thumbnail: ${!!thumbnail}`); + } catch (error) { + if (DEBUG) console.warn(`📄 Failed to generate immediate metadata for ${file.name}:`, error); + } + + // Create record with immediate thumbnail and page metadata + const record = toFileRecord(file, fileId); + if (thumbnail) { + record.thumbnailUrl = thumbnail; + } + + // Create initial processedFile metadata with page count + if (pageCount > 0) { + record.processedFile = createProcessedFile(pageCount, thumbnail); + if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); + } + + existingQuickKeys.add(quickKey); + fileRecords.push(record); + addedFiles.push(file); + + // Start background processing for validation only (we already have thumbnail and page count) + fileProcessingService.processFile(file, fileId).then(result => { + // Only update if file still exists in context + if (filesRef.current.has(fileId)) { + if (result.success && result.metadata) { + // Only log if page count differs from our immediate calculation + const initialPageCount = pageCount; + if (result.metadata.totalPages !== initialPageCount) { + if (DEBUG) console.log(`📄 Background processing found different page count for ${file.name}: ${result.metadata.totalPages} vs immediate ${initialPageCount}`); + } + } + } + }); + } + break; + } + + case 'processed': { + const { filesWithThumbnails = [] } = options; + if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`); + + for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) { + const quickKey = createQuickKey(file); + + if (existingQuickKeys.has(quickKey)) { + if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`); + continue; + } + + const fileId = createFileId(); + filesRef.current.set(fileId, file); + + const record = toFileRecord(file, fileId); + if (thumbnail) { + record.thumbnailUrl = thumbnail; + } + + // Create processedFile with provided metadata + if (pageCount > 0) { + record.processedFile = createProcessedFile(pageCount, thumbnail); + if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); + } + + existingQuickKeys.add(quickKey); + fileRecords.push(record); + addedFiles.push(file); + } + break; + } + + case 'stored': { + const { filesWithMetadata = [] } = options; + if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`); + + for (const { file, originalId, metadata } of filesWithMetadata) { + const quickKey = createQuickKey(file); + + if (existingQuickKeys.has(quickKey)) { + if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name}`); + continue; + } + + // Try to preserve original ID, but generate new if it conflicts + let fileId = originalId; + if (filesRef.current.has(originalId)) { + fileId = createFileId(); + if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`); + } + + filesRef.current.set(fileId, file); + + const record = toFileRecord(file, fileId); + + // Restore metadata from storage + if (metadata.thumbnail) { + record.thumbnailUrl = metadata.thumbnail; + } + + // Note: For stored files, processedFile will be restored from FileRecord if it exists + // The metadata here is just basic file info, not processed file data + + existingQuickKeys.add(quickKey); + fileRecords.push(record); + addedFiles.push(file); + } + break; + } + } + + // Dispatch ADD_FILES action if we have new files + if (fileRecords.length > 0) { + dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); + if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`); + } + + return addedFiles; +} + +/** + * Action factory functions + */ +export const createFileActions = (dispatch: React.Dispatch) => ({ + setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }), + setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }), + clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }), + setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }), + setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }), + resetContext: () => dispatch({ type: 'RESET_CONTEXT' }) +}); \ No newline at end of file diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts new file mode 100644 index 000000000..617babfe2 --- /dev/null +++ b/frontend/src/contexts/file/fileHooks.ts @@ -0,0 +1,228 @@ +/** + * New performant file hooks - Clean API without legacy compatibility + */ + +import { useContext, useMemo } from 'react'; +import { + FileStateContext, + FileActionsContext, + FileContextStateValue, + FileContextActionsValue +} from './contexts'; +import { FileId, FileRecord } from '../../types/fileContext'; + +/** + * Hook for accessing file state (will re-render on any state change) + * Use individual selector hooks below for better performance + */ +export function useFileState(): FileContextStateValue { + const context = useContext(FileStateContext); + if (!context) { + throw new Error('useFileState must be used within a FileContextProvider'); + } + return context; +} + +/** + * Hook for accessing file actions (stable - won't cause re-renders) + */ +export function useFileActions(): FileContextActionsValue { + const context = useContext(FileActionsContext); + if (!context) { + throw new Error('useFileActions must be used within a FileContextProvider'); + } + return context; +} + +/** + * Hook for current/primary file (first in list) + */ +export function useCurrentFile(): { file?: File; record?: FileRecord } { + const { state, selectors } = useFileState(); + + const primaryFileId = state.files.ids[0]; + return useMemo(() => ({ + file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, + record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined + }), [primaryFileId, selectors]); +} + +/** + * Hook for file selection state and actions + */ +export function useFileSelection() { + const { state } = useFileState(); + const { actions } = useFileActions(); + + return useMemo(() => ({ + selectedFileIds: state.ui.selectedFileIds, + selectedPageNumbers: state.ui.selectedPageNumbers, + setSelectedFiles: actions.setSelectedFiles, + setSelectedPages: actions.setSelectedPages, + clearSelections: actions.clearSelections + }), [ + state.ui.selectedFileIds, + state.ui.selectedPageNumbers, + actions.setSelectedFiles, + actions.setSelectedPages, + actions.clearSelections + ]); +} + +/** + * Hook for file management operations + */ +export function useFileManagement() { + const { actions } = useFileActions(); + + return useMemo(() => ({ + addFiles: actions.addFiles, + removeFiles: actions.removeFiles, + clearAllFiles: actions.clearAllFiles, + updateFileRecord: actions.updateFileRecord + }), [actions]); +} + +/** + * Hook for UI state + */ +export function useFileUI() { + const { state } = useFileState(); + const { actions } = useFileActions(); + + return useMemo(() => ({ + isProcessing: state.ui.isProcessing, + processingProgress: state.ui.processingProgress, + hasUnsavedChanges: state.ui.hasUnsavedChanges, + setProcessing: actions.setProcessing, + setUnsavedChanges: actions.setHasUnsavedChanges + }), [state.ui, actions]); +} + +/** + * Hook for specific file by ID (optimized for individual file access) + */ +export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } { + const { selectors } = useFileState(); + + return useMemo(() => ({ + file: selectors.getFile(fileId), + record: selectors.getFileRecord(fileId) + }), [fileId, selectors]); +} + +/** + * Hook for all files (use sparingly - causes re-renders on file list changes) + */ +export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { + const { state, selectors } = useFileState(); + + return useMemo(() => ({ + files: selectors.getFiles(), + records: selectors.getFileRecords(), + fileIds: state.files.ids + }), [state.files.ids, selectors]); +} + +/** + * Hook for selected files (optimized for selection-based UI) + */ +export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { + const { state, selectors } = useFileState(); + + return useMemo(() => ({ + files: selectors.getSelectedFiles(), + records: selectors.getSelectedFileRecords(), + fileIds: state.ui.selectedFileIds + }), [state.ui.selectedFileIds, selectors]); +} + +// Navigation management removed - moved to NavigationContext + +/** + * Primary API hook for file context operations + * Used by tools for core file context functionality + */ +export function useFileContext() { + const { actions } = useFileActions(); + + return useMemo(() => ({ + trackBlobUrl: actions.trackBlobUrl, + trackPdfDocument: actions.trackPdfDocument, + scheduleCleanup: actions.scheduleCleanup, + setUnsavedChanges: actions.setHasUnsavedChanges + }), [actions]); +} + +/** + * Primary API hook for tool file selection workflow + * Used by tools for managing file selection and tool-specific operations + */ +export function useToolFileSelection() { + const { state, selectors } = useFileState(); + const { actions } = useFileActions(); + + // Memoize selected files to avoid recreating arrays + const selectedFiles = useMemo(() => { + return selectors.getSelectedFiles(); + }, [state.ui.selectedFileIds, selectors]); + + return useMemo(() => ({ + selectedFiles, + selectedFileIds: state.ui.selectedFileIds, + selectedPageNumbers: state.ui.selectedPageNumbers, + setSelectedFiles: actions.setSelectedFiles, + setSelectedPages: actions.setSelectedPages, + clearSelections: actions.clearSelections, + // Tool workflow properties + maxFiles: 10, // Default value for tools + isToolMode: true, + setMaxFiles: (maxFiles: number) => { /* Tool-specific - can be implemented if needed */ }, + setIsToolMode: (isToolMode: boolean) => { /* Tool-specific - can be implemented if needed */ } + }), [ + selectedFiles, + state.ui.selectedFileIds, + state.ui.selectedPageNumbers, + actions.setSelectedFiles, + actions.setSelectedPages, + actions.clearSelections + ]); +} + +/** + * Hook for processed files (compatibility with old FileContext) + * Provides access to files with their processed metadata + */ +export function useProcessedFiles() { + const { state, selectors } = useFileState(); + + // Create a Map-like interface for backward compatibility + const compatibilityMap = useMemo(() => ({ + size: state.files.ids.length, + get: (file: File) => { + // Find file record by matching File object properties + const record = Object.values(state.files.byId).find(r => + r.name === file.name && r.size === file.size && r.lastModified === file.lastModified + ); + return record?.processedFile || null; + }, + has: (file: File) => { + // Find file record by matching File object properties + const record = Object.values(state.files.byId).find(r => + r.name === file.name && r.size === file.size && r.lastModified === file.lastModified + ); + return !!record?.processedFile; + }, + set: () => { + console.warn('processedFiles.set is deprecated - use FileRecord updates instead'); + } + }), [state.files.byId, state.files.ids.length]); + + return useMemo(() => ({ + processedFiles: compatibilityMap, + getProcessedFile: (file: File) => compatibilityMap.get(file), + updateProcessedFile: () => { + console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord'); + } + }), [compatibilityMap]); +} \ No newline at end of file diff --git a/frontend/src/contexts/file/fileSelectors.ts b/frontend/src/contexts/file/fileSelectors.ts new file mode 100644 index 000000000..268f55b1d --- /dev/null +++ b/frontend/src/contexts/file/fileSelectors.ts @@ -0,0 +1,86 @@ +/** + * File selectors - Pure functions for accessing file state + */ + +import { + FileId, + FileRecord, + FileContextState, + FileContextSelectors +} from '../../types/fileContext'; + +/** + * Create stable selectors using stateRef and filesRef + */ +export function createFileSelectors( + stateRef: React.MutableRefObject, + filesRef: React.MutableRefObject> +): FileContextSelectors { + return { + getFile: (id: FileId) => filesRef.current.get(id), + + getFiles: (ids?: FileId[]) => { + const currentIds = ids || stateRef.current.files.ids; + return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; + }, + + getFileRecord: (id: FileId) => stateRef.current.files.byId[id], + + getFileRecords: (ids?: FileId[]) => { + const currentIds = ids || stateRef.current.files.ids; + return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); + }, + + getAllFileIds: () => stateRef.current.files.ids, + + getSelectedFiles: () => { + return stateRef.current.ui.selectedFileIds + .map(id => filesRef.current.get(id)) + .filter(Boolean) as File[]; + }, + + getSelectedFileRecords: () => { + return stateRef.current.ui.selectedFileIds + .map(id => stateRef.current.files.byId[id]) + .filter(Boolean); + }, + + // Stable signature for effects - prevents unnecessary re-renders + getFilesSignature: () => { + return stateRef.current.files.ids + .map(id => { + const record = stateRef.current.files.byId[id]; + return record ? `${id}:${record.size}:${record.lastModified}` : ''; + }) + .join('|'); + }, + + }; +} + +/** + * Helper for building quickKey sets for deduplication + */ +export function buildQuickKeySet(fileRecords: Record): Set { + const quickKeys = new Set(); + Object.values(fileRecords).forEach(record => { + quickKeys.add(record.quickKey); + }); + return quickKeys; +} + +/** + * Get primary file (first in list) - commonly used pattern + */ +export function getPrimaryFile( + stateRef: React.MutableRefObject, + filesRef: React.MutableRefObject> +): { file?: File; record?: FileRecord } { + const primaryFileId = stateRef.current.files.ids[0]; + if (!primaryFileId) return {}; + + return { + file: filesRef.current.get(primaryFileId), + record: stateRef.current.files.byId[primaryFileId] + }; +} \ No newline at end of file diff --git a/frontend/src/contexts/file/lifecycle.ts b/frontend/src/contexts/file/lifecycle.ts new file mode 100644 index 000000000..703984c78 --- /dev/null +++ b/frontend/src/contexts/file/lifecycle.ts @@ -0,0 +1,257 @@ +/** + * File lifecycle management - Resource cleanup and memory management + */ + +import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext'; + +const DEBUG = process.env.NODE_ENV === 'development'; + +/** + * Resource tracking and cleanup utilities + */ +export class FileLifecycleManager { + private cleanupTimers = new Map(); + private blobUrls = new Set(); + private pdfDocuments = new Map(); + private fileGenerations = new Map(); // Generation tokens to prevent stale cleanup + + constructor( + private filesRef: React.MutableRefObject>, + private dispatch: React.Dispatch + ) {} + + /** + * Track blob URLs for cleanup + */ + trackBlobUrl = (url: string): void => { + // Only track actual blob URLs to avoid trying to revoke other schemes + if (url.startsWith('blob:')) { + this.blobUrls.add(url); + if (DEBUG) console.log(`🗂️ Tracking blob URL: ${url.substring(0, 50)}...`); + } else { + if (DEBUG) console.warn(`🗂️ Attempted to track non-blob URL: ${url.substring(0, 50)}...`); + } + }; + + /** + * Track PDF documents for cleanup + */ + trackPdfDocument = (key: string, pdfDoc: any): void => { + // Clean up existing PDF document if present + const existing = this.pdfDocuments.get(key); + if (existing && typeof existing.destroy === 'function') { + try { + existing.destroy(); + if (DEBUG) console.log(`🗂️ Destroyed existing PDF document for key: ${key}`); + } catch (error) { + if (DEBUG) console.warn('Error destroying existing PDF document:', error); + } + } + + this.pdfDocuments.set(key, pdfDoc); + if (DEBUG) console.log(`🗂️ Tracking PDF document for key: ${key}`); + }; + + /** + * Clean up resources for a specific file (with stateRef access for complete cleanup) + */ + cleanupFile = (fileId: string, stateRef?: React.MutableRefObject): void => { + if (DEBUG) console.log(`🗂️ Cleaning up resources for file: ${fileId}`); + + // Use comprehensive cleanup (same as removeFiles) + this.cleanupAllResourcesForFile(fileId, stateRef); + + // Remove file from state + this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } }); + }; + + /** + * Clean up all files and resources + */ + cleanupAllFiles = (): void => { + if (DEBUG) console.log('🗂️ Cleaning up all files and resources'); + + // Clean up all PDF documents + this.pdfDocuments.forEach((pdfDoc, key) => { + if (pdfDoc && typeof pdfDoc.destroy === 'function') { + try { + pdfDoc.destroy(); + if (DEBUG) console.log(`🗂️ Destroyed PDF document for key: ${key}`); + } catch (error) { + if (DEBUG) console.warn(`Error destroying PDF document for key ${key}:`, error); + } + } + }); + this.pdfDocuments.clear(); + + // Revoke all blob URLs + this.blobUrls.forEach(url => { + try { + URL.revokeObjectURL(url); + if (DEBUG) console.log(`🗂️ Revoked blob URL: ${url.substring(0, 50)}...`); + } catch (error) { + if (DEBUG) console.warn('Error revoking blob URL:', error); + } + }); + this.blobUrls.clear(); + + // Clear all cleanup timers and generations + this.cleanupTimers.forEach(timer => clearTimeout(timer)); + this.cleanupTimers.clear(); + this.fileGenerations.clear(); + + // Clear files ref + this.filesRef.current.clear(); + + if (DEBUG) console.log('🗂️ All resources cleaned up'); + }; + + /** + * Schedule delayed cleanup for a file with generation token to prevent stale cleanup + */ + scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject): void => { + // Cancel existing timer + const existingTimer = this.cleanupTimers.get(fileId); + if (existingTimer) { + clearTimeout(existingTimer); + this.cleanupTimers.delete(fileId); + } + + // If delay is negative, just cancel (don't reschedule) + if (delay < 0) { + return; + } + + // Increment generation for this file to invalidate any pending cleanup + const currentGen = (this.fileGenerations.get(fileId) || 0) + 1; + this.fileGenerations.set(fileId, currentGen); + + // Schedule new cleanup with generation token + const timer = window.setTimeout(() => { + // Check if this cleanup is still valid (file hasn't been re-added) + if (this.fileGenerations.get(fileId) === currentGen) { + this.cleanupFile(fileId, stateRef); + } else { + if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`); + } + }, delay); + + this.cleanupTimers.set(fileId, timer); + if (DEBUG) console.log(`🗂️ Scheduled cleanup for file ${fileId} in ${delay}ms (gen ${currentGen})`); + }; + + /** + * Remove a file immediately with complete resource cleanup + */ + removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject): void => { + if (DEBUG) console.log(`🗂️ Removing ${fileIds.length} files immediately`); + + fileIds.forEach(fileId => { + // Clean up all resources for this file + this.cleanupAllResourcesForFile(fileId, stateRef); + }); + + // Dispatch removal action once for all files (reducer only updates state) + this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } }); + }; + + /** + * Complete resource cleanup for a single file + */ + private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject): void => { + // Remove from files ref + this.filesRef.current.delete(fileId); + + // Clean up PDF documents (scan all keys that start with fileId) + const keysToDelete: string[] = []; + this.pdfDocuments.forEach((pdfDoc, key) => { + if (key === fileId || key.startsWith(`${fileId}:`)) { + if (pdfDoc && typeof pdfDoc.destroy === 'function') { + try { + pdfDoc.destroy(); + keysToDelete.push(key); + if (DEBUG) console.log(`🗂️ Destroyed PDF document for key: ${key}`); + } catch (error) { + if (DEBUG) console.warn(`Error destroying PDF document for key ${key}:`, error); + } + } + } + }); + keysToDelete.forEach(key => this.pdfDocuments.delete(key)); + + // Cancel cleanup timer and generation + const timer = this.cleanupTimers.get(fileId); + if (timer) { + clearTimeout(timer); + this.cleanupTimers.delete(fileId); + if (DEBUG) console.log(`🗂️ Cancelled cleanup timer for file: ${fileId}`); + } + this.fileGenerations.delete(fileId); + + // Clean up blob URLs from file record if we have access to state + if (stateRef) { + const record = stateRef.current.files.byId[fileId]; + if (record) { + // Revoke blob URLs from file record + if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(record.thumbnailUrl); + if (DEBUG) console.log(`🗂️ Revoked thumbnail blob URL for file: ${fileId}`); + } catch (error) { + if (DEBUG) console.warn('Error revoking thumbnail URL:', error); + } + } + + if (record.blobUrl && record.blobUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(record.blobUrl); + if (DEBUG) console.log(`🗂️ Revoked file blob URL for file: ${fileId}`); + } catch (error) { + if (DEBUG) console.warn('Error revoking file URL:', error); + } + } + + // Clean up processed file thumbnails + if (record.processedFile?.pages) { + record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => { + if (page.thumbnail && page.thumbnail.startsWith('blob:')) { + try { + URL.revokeObjectURL(page.thumbnail); + if (DEBUG) console.log(`🗂️ Revoked page ${index} thumbnail for file: ${fileId}`); + } catch (error) { + if (DEBUG) console.warn('Error revoking page thumbnail URL:', error); + } + } + }); + } + } + } + }; + + /** + * Update file record with race condition guards + */ + updateFileRecord = (fileId: FileId, updates: Partial, stateRef?: React.MutableRefObject): void => { + // Guard against updating removed files (race condition protection) + if (!this.filesRef.current.has(fileId)) { + if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`); + return; + } + + // Additional state guard for rare race conditions + if (stateRef && !stateRef.current.files.byId[fileId]) { + if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`); + return; + } + + this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } }); + }; + + /** + * Cleanup on unmount + */ + destroy = (): void => { + if (DEBUG) console.log('🗂️ FileLifecycleManager destroying - cleaning up all resources'); + this.cleanupAllFiles(); + }; +} \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 525a6f59b..d676fcf10 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useFileActions, useToolFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; @@ -65,10 +66,15 @@ function HomePageContent() { } function HomePageWithProviders() { - const { actions } = useFileActions(); + const { actions } = useNavigationActions(); + + // Wrapper to convert string to ModeType + const handleViewChange = (view: string) => { + actions.setMode(view as any); // ToolWorkflowContext should validate this + }; return ( - + diff --git a/frontend/src/tools/AddPassword.tsx b/frontend/src/tools/AddPassword.tsx index 2d265b84f..876f746cc 100644 --- a/frontend/src/tools/AddPassword.tsx +++ b/frontend/src/tools/AddPassword.tsx @@ -3,7 +3,7 @@ import { Box, Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext, useToolFileSelection } from "../contexts/FileContext"; +import { useToolFileSelection } from "../contexts/FileContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -22,8 +22,8 @@ import { BaseToolProps } from "../types/tool"; const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); const { selectedFiles } = useToolFileSelection(); + // const setCurrentMode = (mode) => console.log('Navigate to:', mode); // TODO: Hook up to URL routing const [collapsedPermissions, setCollapsedPermissions] = useState(true); @@ -58,14 +58,11 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); - sessionStorage.setItem('previousMode', 'addPassword'); - setCurrentMode('viewer'); }; const handleSettingsReset = () => { addPasswordOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode('addPassword'); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/ChangePermissions.tsx b/frontend/src/tools/ChangePermissions.tsx index 3e42e1727..a2ca0f175 100644 --- a/frontend/src/tools/ChangePermissions.tsx +++ b/frontend/src/tools/ChangePermissions.tsx @@ -3,7 +3,8 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext, useToolFileSelection } from "../contexts/FileContext"; +import { useToolFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -20,7 +21,8 @@ import { BaseToolProps } from "../types/tool"; const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); + const { actions } = useNavigationActions(); + const setCurrentMode = actions.setMode; const { selectedFiles } = useToolFileSelection(); const changePermissionsParams = useChangePermissionsParameters(); diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index 8f364d06e..16d121654 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -3,8 +3,8 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileActions } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -21,8 +21,8 @@ import { useCompressTips } from "../components/tooltips/useCompressTips"; const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useFileActions(); - const setCurrentMode = actions.setCurrentMode; + const { actions } = useNavigationActions(); + const setCurrentMode = actions.setMode; const { selectedFiles } = useToolFileSelection(); const compressParams = useCompressParameters(); diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 83cff87a7..4ea14243f 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -3,8 +3,9 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileActions, useFileState } from "../contexts/FileContext"; +import { useFileState } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -20,9 +21,9 @@ import { BaseToolProps } from "../types/tool"; const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useFileActions(); const { selectors } = useFileState(); - const setCurrentMode = actions.setCurrentMode; + const { actions } = useNavigationActions(); + const setCurrentMode = actions.setMode; const activeFiles = selectors.getFiles(); const { selectedFiles } = useToolFileSelection(); const scrollContainerRef = useRef(null); diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index f43fcc43e..cbc17763d 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -3,8 +3,8 @@ import { Button, Stack, Text, Box } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileActions } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -22,8 +22,8 @@ import { useOCRTips } from "../components/tooltips/useOCRTips"; const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useFileActions(); - const setCurrentMode = actions.setCurrentMode; + const { actions } = useNavigationActions(); + const setCurrentMode = actions.setMode; const { selectedFiles } = useToolFileSelection(); const ocrParams = useOCRParameters(); diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 384565fdd..68f09f9bd 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useToolFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -15,13 +16,13 @@ import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings"; import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters"; import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation"; import { BaseToolProps } from "../types/tool"; -import { useFileContext } from "../contexts/FileContext"; const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectedFiles } = useToolFileSelection(); - const { setCurrentMode } = useFileContext(); + const { actions } = useNavigationActions(); + const setCurrentMode = actions.setMode; const sanitizeParams = useSanitizeParameters(); const sanitizeOperation = useSanitizeOperation(); diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index b7de5ba3f..6cc5c9157 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -3,8 +3,8 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileActions } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -19,8 +19,8 @@ import { BaseToolProps } from "../types/tool"; const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useFileActions(); - const setCurrentMode = actions.setCurrentMode; + const { actions } = useNavigationActions(); + const setCurrentMode = actions.setMode; const { selectedFiles } = useToolFileSelection(); const splitParams = useSplitParameters(); diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 1a96db40c..c0110944f 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -6,11 +6,26 @@ import { ProcessedFile } from './processing'; import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; import { FileMetadata } from './file'; -export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'convert'; // Normalized state types export type FileId = string; +export interface ProcessedFilePage { + thumbnail?: string; + pageNumber?: number; + rotation?: number; + splitBefore?: boolean; + [key: string]: any; +} + +export interface ProcessedFileMetadata { + pages: ProcessedFilePage[]; + totalPages?: number; + thumbnailUrl?: string; + lastProcessed?: number; + [key: string]: any; +} + export interface FileRecord { id: FileId; name: string; @@ -21,13 +36,7 @@ export interface FileRecord { thumbnailUrl?: string; blobUrl?: string; createdAt: number; - processedFile?: { - pages: Array<{ - thumbnail?: string; - [key: string]: any; - }>; - [key: string]: any; - }; + processedFile?: ProcessedFileMetadata; // Note: File object stored in provider ref, not in state } @@ -153,16 +162,13 @@ export interface FileContextState { byId: Record; }; - // UI state - flat structure for performance + // UI state - file-related UI state only ui: { - currentMode: ModeType; selectedFileIds: FileId[]; selectedPageNumbers: number[]; isProcessing: boolean; processingProgress: number; hasUnsavedChanges: boolean; - pendingNavigation: (() => void) | null; - showNavigationWarning: boolean; }; } @@ -173,17 +179,14 @@ export type FileContextAction = | { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } } | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } - // UI actions - | { type: 'SET_CURRENT_MODE'; payload: ModeType } + // UI actions | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } | { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } } | { type: 'CLEAR_SELECTIONS' } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } - // Navigation guard actions + // Navigation guard actions (minimal for file-related unsaved changes only) | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } - | { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } } - | { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } } // Context management | { type: 'RESET_CONTEXT' }; @@ -198,9 +201,6 @@ export interface FileContextActions { clearAllFiles: () => void; - // Navigation - setCurrentMode: (mode: ModeType) => void; - // Selection management setSelectedFiles: (fileIds: FileId[]) => void; setSelectedPages: (pageNumbers: number[]) => void; @@ -209,16 +209,17 @@ export interface FileContextActions { // Processing state - simple flags only setProcessing: (isProcessing: boolean, progress?: number) => void; - // Navigation guard system + // File-related unsaved changes (minimal navigation guard support) setHasUnsavedChanges: (hasChanges: boolean) => void; // Context management resetContext: () => void; - // Legacy compatibility - setMode: (mode: ModeType) => void; - confirmNavigation: () => void; - cancelNavigation: () => void; + // Resource management + trackBlobUrl: (url: string) => void; + trackPdfDocument: (key: string, pdfDoc: any) => void; + scheduleCleanup: (fileId: string, delay?: number) => void; + cleanupFile: (fileId: string) => void; } // File selectors (separate from actions to avoid re-renders) @@ -258,12 +259,5 @@ export interface FileContextActionsValue { dispatch: (action: FileContextAction) => void; } -// URL parameter types for deep linking -export interface FileContextUrlParams { - mode?: ModeType; - fileIds?: string[]; - pageIds?: string[]; - zoom?: number; - page?: number; -} +// TODO: URL parameter types will be redesigned for new routing system