Stirling-PDF/frontend/src/contexts/FileContext.tsx
Reece Browne e4d480c7b3
Feature/v2/multiselect (#4024)
# Description of Changes
This pull request introduces significant updates to the file selection
logic, tool rendering, and file context management in the frontend
codebase. The changes aim to improve modularity, enhance
maintainability, and streamline the handling of file-related operations.
Key updates include the introduction of a new `FileSelectionContext`,
refactoring of file selection logic, and updates to tool management and
rendering.

### File Selection Context and Logic Refactor:
* Added a new `FileSelectionContext` to centralize file selection state
and provide utility hooks for managing selected files, selection limits,
and tool mode. (`frontend/src/contexts/FileSelectionContext.tsx`,
[frontend/src/contexts/FileSelectionContext.tsxR1-R77](diffhunk://#diff-bda35f1aaa5eafa0a0dc48e0b1270d862f6da360ba1241234e891f0ca8907327R1-R77))
* Replaced local file selection logic in `FileEditor` with context-based
logic, improving consistency and reducing duplication.
(`frontend/src/components/fileEditor/FileEditor.tsx`,
[[1]](diffhunk://#diff-481d0a2d8a1714d34d21181db63a020b08dfccfbfa80bf47ac9af382dff25310R63-R70)
[[2]](diffhunk://#diff-481d0a2d8a1714d34d21181db63a020b08dfccfbfa80bf47ac9af382dff25310R404-R438)

### Tool Management and Rendering:
* Refactored `ToolRenderer` to use a `Suspense` fallback for lazy-loaded
tools, improving user experience during tool loading.
(`frontend/src/components/tools/ToolRenderer.tsx`,
[frontend/src/components/tools/ToolRenderer.tsxL32-L64](diffhunk://#diff-2083701113aa92cd1f5ce1b4b52cc233858e31ed7bcf39c5bfb1bcc34e99b6a9L32-L64))
* Simplified `ToolPicker` by reusing the `ToolRegistry` type, reducing
redundancy. (`frontend/src/components/tools/ToolPicker.tsx`,
[frontend/src/components/tools/ToolPicker.tsxL4-R4](diffhunk://#diff-e47deca9132018344c159925f1264794acdd57f4b65e582eb9b2a4ea69ec126dL4-R4))

### File Context Enhancements:
* Introduced a utility function `getFileId` for consistent file ID
extraction, replacing repetitive inline logic.
(`frontend/src/contexts/FileContext.tsx`,
[[1]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcR25)
[[2]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL101-R102)
* Updated `FileContextProvider` to use more specific types for PDF
documents, enhancing type safety.
(`frontend/src/contexts/FileContext.tsx`,
[[1]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL350-R351)
[[2]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL384-R385)

### Compression Tool Enhancements:
* Added blob URL cleanup logic to the compression hook to prevent memory
leaks. (`frontend/src/hooks/tools/compress/useCompressOperation.ts`,
[frontend/src/hooks/tools/compress/useCompressOperation.tsR58-L66](diffhunk://#diff-d7815fea0e89989511ae1786f7031cba492b9f2db39b7ade92d9736d1bd4b673R58-L66))
* Adjusted file ID generation in the compression operation to handle
multiple files more effectively.
(`frontend/src/hooks/tools/compress/useCompressOperation.ts`,
[frontend/src/hooks/tools/compress/useCompressOperation.tsL90-R102](diffhunk://#diff-d7815fea0e89989511ae1786f7031cba492b9f2db39b7ade92d9736d1bd4b673L90-R102))

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-07-25 09:37:52 +01:00

866 lines
26 KiB
TypeScript

/**
* Global file context for managing files, edits, and navigation across all views and tools
*/
import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react';
import {
FileContextValue,
FileContextState,
FileContextProviderProps,
ModeType,
ViewType,
ToolType,
FileOperation,
FileEditHistory,
FileOperationHistory,
ViewerConfig,
FileContextUrlParams
} from '../types/fileContext';
import { ProcessedFile } from '../types/processing';
import { PageOperation, PDFDocument } from '../types/pageEditor';
import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles';
import { fileStorage } from '../services/fileStorage';
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { getFileId } from '../utils/fileUtils';
// Initial state
const initialViewerConfig: ViewerConfig = {
zoom: 1.0,
currentPage: 1,
viewMode: 'single',
sidebarOpen: false
};
const initialState: FileContextState = {
activeFiles: [],
processedFiles: new Map(),
currentMode: 'pageEditor',
currentView: 'fileEditor', // Legacy field
currentTool: null, // Legacy field
fileEditHistory: new Map(),
globalFileOperations: [],
fileOperationHistory: new Map(),
selectedFileIds: [],
selectedPageNumbers: [],
viewerConfig: initialViewerConfig,
isProcessing: false,
processingProgress: 0,
lastExportConfig: undefined,
hasUnsavedChanges: false,
pendingNavigation: null,
showNavigationWarning: false
};
// Action types
type FileContextAction =
| { type: 'SET_ACTIVE_FILES'; payload: File[] }
| { type: 'ADD_FILES'; payload: File[] }
| { type: 'REMOVE_FILES'; payload: string[] }
| { type: 'SET_PROCESSED_FILES'; payload: Map<File, ProcessedFile> }
| { type: 'UPDATE_PROCESSED_FILE'; payload: { file: File; processedFile: ProcessedFile } }
| { type: 'SET_CURRENT_MODE'; payload: ModeType }
| { type: 'SET_CURRENT_VIEW'; payload: ViewType }
| { type: 'SET_CURRENT_TOOL'; payload: ToolType }
| { type: 'SET_SELECTED_FILES'; payload: string[] }
| { type: 'SET_SELECTED_PAGES'; payload: number[] }
| { type: 'CLEAR_SELECTIONS' }
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
| { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial<ViewerConfig> }
| { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
| { type: 'ADD_FILE_OPERATION'; payload: FileOperation }
| { type: 'RECORD_OPERATION'; payload: { fileId: string; operation: FileOperation | PageOperation } }
| { type: 'MARK_OPERATION_APPLIED'; payload: { fileId: string; operationId: string } }
| { type: 'MARK_OPERATION_FAILED'; payload: { fileId: string; operationId: string; error: string } }
| { type: 'CLEAR_FILE_HISTORY'; payload: string }
| { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] }
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean }
| { type: 'RESET_CONTEXT' }
| { type: 'LOAD_STATE'; payload: Partial<FileContextState> };
// Reducer
function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) {
case 'SET_ACTIVE_FILES':
return {
...state,
activeFiles: action.payload,
selectedFileIds: [], // Clear selections when files change
selectedPageNumbers: []
};
case 'ADD_FILES':
return {
...state,
activeFiles: [...state.activeFiles, ...action.payload]
};
case 'REMOVE_FILES':
const remainingFiles = state.activeFiles.filter(file => {
const fileId = getFileId(file);
return !action.payload.includes(fileId);
});
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
return {
...state,
activeFiles: remainingFiles,
selectedFileIds: safeSelectedFileIds.filter(id => !action.payload.includes(id))
};
case 'SET_PROCESSED_FILES':
return {
...state,
processedFiles: action.payload
};
case 'UPDATE_PROCESSED_FILE':
const updatedProcessedFiles = new Map(state.processedFiles);
updatedProcessedFiles.set(action.payload.file, action.payload.processedFile);
return {
...state,
processedFiles: updatedProcessedFiles
};
case 'SET_CURRENT_MODE':
const coreViews = ['viewer', 'pageEditor', 'fileEditor'];
const isToolMode = !coreViews.includes(action.payload);
return {
...state,
currentMode: action.payload,
// Update legacy fields for backward compatibility
currentView: isToolMode ? 'fileEditor' : action.payload as ViewType,
currentTool: isToolMode ? action.payload as ToolType : null
};
case 'SET_CURRENT_VIEW':
// Legacy action - just update currentMode
return {
...state,
currentMode: action.payload as ModeType,
currentView: action.payload,
currentTool: null
};
case 'SET_CURRENT_TOOL':
// Legacy action - just update currentMode
return {
...state,
currentMode: action.payload ? action.payload as ModeType : 'pageEditor',
currentView: action.payload ? 'fileEditor' : 'pageEditor',
currentTool: action.payload
};
case 'SET_SELECTED_FILES':
return {
...state,
selectedFileIds: action.payload
};
case 'SET_SELECTED_PAGES':
return {
...state,
selectedPageNumbers: action.payload
};
case 'CLEAR_SELECTIONS':
return {
...state,
selectedFileIds: [],
selectedPageNumbers: []
};
case 'SET_PROCESSING':
return {
...state,
isProcessing: action.payload.isProcessing,
processingProgress: action.payload.progress
};
case 'UPDATE_VIEWER_CONFIG':
return {
...state,
viewerConfig: {
...state.viewerConfig,
...action.payload
}
};
case 'ADD_PAGE_OPERATIONS':
const newHistory = new Map(state.fileEditHistory);
const existing = newHistory.get(action.payload.fileId);
newHistory.set(action.payload.fileId, {
fileId: action.payload.fileId,
pageOperations: existing ?
[...existing.pageOperations, ...action.payload.operations] :
action.payload.operations,
lastModified: Date.now()
});
return {
...state,
fileEditHistory: newHistory
};
case 'ADD_FILE_OPERATION':
return {
...state,
globalFileOperations: [...state.globalFileOperations, action.payload]
};
case 'RECORD_OPERATION':
const { fileId, operation } = action.payload;
const newOperationHistory = new Map(state.fileOperationHistory);
const existingHistory = newOperationHistory.get(fileId);
if (existingHistory) {
// Add operation to existing history
newOperationHistory.set(fileId, {
...existingHistory,
operations: [...existingHistory.operations, operation],
lastModified: Date.now()
});
} else {
// Create new history for this file
newOperationHistory.set(fileId, {
fileId,
fileName: fileId, // Will be updated with actual filename when available
operations: [operation],
createdAt: Date.now(),
lastModified: Date.now()
});
}
return {
...state,
fileOperationHistory: newOperationHistory
};
case 'MARK_OPERATION_APPLIED':
const appliedHistory = new Map(state.fileOperationHistory);
const appliedFileHistory = appliedHistory.get(action.payload.fileId);
if (appliedFileHistory) {
const updatedOperations = appliedFileHistory.operations.map(op =>
op.id === action.payload.operationId
? { ...op, status: 'applied' as const }
: op
);
appliedHistory.set(action.payload.fileId, {
...appliedFileHistory,
operations: updatedOperations,
lastModified: Date.now()
});
}
return {
...state,
fileOperationHistory: appliedHistory
};
case 'MARK_OPERATION_FAILED':
const failedHistory = new Map(state.fileOperationHistory);
const failedFileHistory = failedHistory.get(action.payload.fileId);
if (failedFileHistory) {
const updatedOperations = failedFileHistory.operations.map(op =>
op.id === action.payload.operationId
? {
...op,
status: 'failed' as const,
metadata: { ...op.metadata, error: action.payload.error }
}
: op
);
failedHistory.set(action.payload.fileId, {
...failedFileHistory,
operations: updatedOperations,
lastModified: Date.now()
});
}
return {
...state,
fileOperationHistory: failedHistory
};
case 'CLEAR_FILE_HISTORY':
const clearedHistory = new Map(state.fileOperationHistory);
clearedHistory.delete(action.payload);
return {
...state,
fileOperationHistory: clearedHistory
};
case 'SET_EXPORT_CONFIG':
return {
...state,
lastExportConfig: action.payload
};
case 'SET_UNSAVED_CHANGES':
return {
...state,
hasUnsavedChanges: action.payload
};
case 'SET_PENDING_NAVIGATION':
return {
...state,
pendingNavigation: action.payload
};
case 'SHOW_NAVIGATION_WARNING':
return {
...state,
showNavigationWarning: action.payload
};
case 'RESET_CONTEXT':
return {
...initialState
};
case 'LOAD_STATE':
return {
...state,
...action.payload
};
default:
return state;
}
}
// Context
const FileContext = createContext<FileContextValue | undefined>(undefined);
// Provider component
export function FileContextProvider({
children,
enableUrlSync = true,
enablePersistence = true,
maxCacheSize = 1024 * 1024 * 1024 // 1GB
}: FileContextProviderProps) {
const [state, dispatch] = useReducer(fileContextReducer, initialState);
// Cleanup timers and refs
const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
const blobUrls = useRef<Set<string>>(new Set());
const pdfDocuments = useRef<Map<string, PDFDocument>>(new Map());
// Enhanced file processing hook
const {
processedFiles,
processingStates,
isProcessing: globalProcessing,
processingProgress,
actions: processingActions
} = useEnhancedProcessedFiles(state.activeFiles, {
strategy: 'progressive_chunked',
thumbnailQuality: 'medium',
chunkSize: 5, // Process 5 pages at a time for smooth progress
priorityPageCount: 0 // No special priority pages
});
// Update processed files when they change
useEffect(() => {
dispatch({ type: 'SET_PROCESSED_FILES', payload: processedFiles });
dispatch({
type: 'SET_PROCESSING',
payload: {
isProcessing: globalProcessing,
progress: processingProgress.overall
}
});
}, [processedFiles, globalProcessing, processingProgress.overall]);
// Centralized memory management
const trackBlobUrl = useCallback((url: string) => {
blobUrls.current.add(url);
}, []);
const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => {
// Clean up existing document for this file if any
const existing = pdfDocuments.current.get(fileId);
if (existing && existing.destroy) {
try {
existing.destroy();
} catch (error) {
console.warn('Error destroying existing PDF document:', error);
}
}
pdfDocuments.current.set(fileId, pdfDoc);
}, []);
const cleanupFile = useCallback(async (fileId: string) => {
console.log('Cleaning up file:', fileId);
try {
// Cancel any pending cleanup timer
const timer = cleanupTimers.current.get(fileId);
if (timer) {
clearTimeout(timer);
cleanupTimers.current.delete(fileId);
}
// Cleanup PDF document instances (but preserve processed file cache)
const pdfDoc = pdfDocuments.current.get(fileId);
if (pdfDoc && pdfDoc.destroy) {
pdfDoc.destroy();
pdfDocuments.current.delete(fileId);
}
// IMPORTANT: Don't cancel processing or clear cache during normal view switches
// Only do this when file is actually being removed
// enhancedPDFProcessingService.cancelProcessing(fileId);
// thumbnailGenerationService.stopGeneration();
} catch (error) {
console.warn('Error during file cleanup:', error);
}
}, []);
const cleanupAllFiles = useCallback(() => {
console.log('Cleaning up all files');
try {
// Clear all timers
cleanupTimers.current.forEach(timer => clearTimeout(timer));
cleanupTimers.current.clear();
// Destroy all PDF documents
pdfDocuments.current.forEach((pdfDoc, fileId) => {
if (pdfDoc && pdfDoc.destroy) {
try {
pdfDoc.destroy();
} catch (error) {
console.warn(`Error destroying PDF document for ${fileId}:`, error);
}
}
});
pdfDocuments.current.clear();
// Revoke all blob URLs
blobUrls.current.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Error revoking blob URL:', error);
}
});
blobUrls.current.clear();
// Clear all processing
enhancedPDFProcessingService.clearAllProcessing();
// Destroy thumbnails
thumbnailGenerationService.destroy();
// Force garbage collection hint
if (typeof window !== 'undefined' && window.gc) {
setTimeout(() => window.gc(), 100);
}
} catch (error) {
console.warn('Error during cleanup all files:', error);
}
}, []);
const scheduleCleanup = useCallback((fileId: string, delay: number = 30000) => {
// Cancel existing timer
const existingTimer = cleanupTimers.current.get(fileId);
if (existingTimer) {
clearTimeout(existingTimer);
cleanupTimers.current.delete(fileId);
}
// If delay is negative, just cancel (don't reschedule)
if (delay < 0) {
return;
}
// Schedule new cleanup
const timer = setTimeout(() => {
cleanupFile(fileId);
}, delay);
cleanupTimers.current.set(fileId, timer);
}, [cleanupFile]);
// Action implementations
const addFiles = useCallback(async (files: File[]) => {
dispatch({ type: 'ADD_FILES', payload: files });
// Auto-save to IndexedDB if persistence enabled
if (enablePersistence) {
for (const file of files) {
try {
// Check if file already has an ID (already in IndexedDB)
const fileId = getFileId(file);
if (!fileId) {
// File doesn't have ID, store it and get the ID
const storedFile = await fileStorage.storeFile(file);
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
}
} catch (error) {
console.error('Failed to store file:', error);
}
}
}
}, [enablePersistence]);
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
// FULL cleanup for actually removed files (including cache)
fileIds.forEach(fileId => {
// Cancel processing and clear caches when file is actually removed
enhancedPDFProcessingService.cancelProcessing(fileId);
cleanupFile(fileId);
});
dispatch({ type: 'REMOVE_FILES', payload: fileIds });
// Remove from IndexedDB only if requested
if (enablePersistence && deleteFromStorage) {
fileIds.forEach(async (fileId) => {
try {
await fileStorage.deleteFile(fileId);
} catch (error) {
console.error('Failed to remove file from storage:', error);
}
});
}
}, [enablePersistence, cleanupFile]);
const replaceFile = useCallback(async (oldFileId: string, newFile: File) => {
// Remove old file and add new one
removeFiles([oldFileId]);
await addFiles([newFile]);
}, [removeFiles, addFiles]);
const clearAllFiles = useCallback(() => {
// Cleanup all memory before clearing files
cleanupAllFiles();
dispatch({ type: 'SET_ACTIVE_FILES', payload: [] });
dispatch({ type: 'CLEAR_SELECTIONS' });
}, [cleanupAllFiles]);
// Navigation guard system functions
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
}, []);
const requestNavigation = useCallback((navigationFn: () => void): boolean => {
if (state.hasUnsavedChanges) {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: navigationFn });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: true });
return false;
} else {
navigationFn();
return true;
}
}, [state.hasUnsavedChanges]);
const confirmNavigation = useCallback(() => {
if (state.pendingNavigation) {
state.pendingNavigation();
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null });
}
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false });
}, [state.pendingNavigation]);
const cancelNavigation = useCallback(() => {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false });
}, []);
const setCurrentMode = useCallback((mode: ModeType) => {
requestNavigation(() => {
dispatch({ type: 'SET_CURRENT_MODE', payload: mode });
if (state.currentMode !== mode && state.activeFiles.length > 0) {
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) {
window.requestIdleCallback(() => {
window.gc();
}, { timeout: 5000 });
}
}
});
}, [requestNavigation, state.currentMode, state.activeFiles]);
const setCurrentView = useCallback((view: ViewType) => {
requestNavigation(() => {
dispatch({ type: 'SET_CURRENT_VIEW', payload: view });
if (state.currentView !== view && state.activeFiles.length > 0) {
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) {
window.requestIdleCallback(() => {
window.gc();
}, { timeout: 5000 });
}
}
});
}, [requestNavigation, state.currentView, state.activeFiles]);
const setCurrentTool = useCallback((tool: ToolType) => {
requestNavigation(() => {
dispatch({ type: 'SET_CURRENT_TOOL', payload: tool });
});
}, [requestNavigation]);
const setSelectedFiles = useCallback((fileIds: string[]) => {
dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds });
}, []);
const setSelectedPages = useCallback((pageNumbers: number[]) => {
dispatch({ type: 'SET_SELECTED_PAGES', payload: pageNumbers });
}, []);
const updateProcessedFile = useCallback((file: File, processedFile: ProcessedFile) => {
dispatch({ type: 'UPDATE_PROCESSED_FILE', payload: { file, processedFile } });
}, []);
const clearSelections = useCallback(() => {
dispatch({ type: 'CLEAR_SELECTIONS' });
}, []);
const applyPageOperations = useCallback((fileId: string, operations: PageOperation[]) => {
dispatch({
type: 'ADD_PAGE_OPERATIONS',
payload: { fileId, operations }
});
}, []);
const applyFileOperation = useCallback((operation: FileOperation) => {
dispatch({ type: 'ADD_FILE_OPERATION', payload: operation });
}, []);
const undoLastOperation = useCallback((fileId?: string) => {
console.warn('Undo not yet implemented');
}, []);
const updateViewerConfig = useCallback((config: Partial<ViewerConfig>) => {
dispatch({ type: 'UPDATE_VIEWER_CONFIG', payload: config });
}, []);
const setExportConfig = useCallback((config: FileContextState['lastExportConfig']) => {
dispatch({ type: 'SET_EXPORT_CONFIG', payload: config });
}, []);
// Operation history management functions
const recordOperation = useCallback((fileId: string, operation: FileOperation | PageOperation) => {
dispatch({ type: 'RECORD_OPERATION', payload: { fileId, operation } });
}, []);
const markOperationApplied = useCallback((fileId: string, operationId: string) => {
dispatch({ type: 'MARK_OPERATION_APPLIED', payload: { fileId, operationId } });
}, []);
const markOperationFailed = useCallback((fileId: string, operationId: string, error: string) => {
dispatch({ type: 'MARK_OPERATION_FAILED', payload: { fileId, operationId, error } });
}, []);
const getFileHistory = useCallback((fileId: string): FileOperationHistory | undefined => {
return state.fileOperationHistory.get(fileId);
}, [state.fileOperationHistory]);
const getAppliedOperations = useCallback((fileId: string): (FileOperation | PageOperation)[] => {
const history = state.fileOperationHistory.get(fileId);
return history ? history.operations.filter(op => op.status === 'applied') : [];
}, [state.fileOperationHistory]);
const clearFileHistory = useCallback((fileId: string) => {
dispatch({ type: 'CLEAR_FILE_HISTORY', payload: fileId });
}, []);
// Utility functions
const getFileById = useCallback((fileId: string): File | undefined => {
return state.activeFiles.find(file => {
const actualFileId = getFileId(file);
return actualFileId === fileId;
});
}, [state.activeFiles]);
const getProcessedFileById = useCallback((fileId: string): ProcessedFile | undefined => {
const file = getFileById(fileId);
return file ? state.processedFiles.get(file) : undefined;
}, [getFileById, state.processedFiles]);
const getCurrentFile = useCallback((): File | undefined => {
if (state.selectedFileIds.length > 0) {
return getFileById(state.selectedFileIds[0]);
}
return state.activeFiles[0]; // Default to first file
}, [state.selectedFileIds, state.activeFiles, getFileById]);
const getCurrentProcessedFile = useCallback((): ProcessedFile | undefined => {
const file = getCurrentFile();
return file ? state.processedFiles.get(file) : undefined;
}, [getCurrentFile, state.processedFiles]);
// Context persistence
const saveContext = useCallback(async () => {
if (!enablePersistence) return;
try {
const contextData = {
currentView: state.currentView,
currentTool: state.currentTool,
selectedFileIds: state.selectedFileIds,
selectedPageIds: state.selectedPageIds,
viewerConfig: state.viewerConfig,
lastExportConfig: state.lastExportConfig,
timestamp: Date.now()
};
localStorage.setItem('fileContext', JSON.stringify(contextData));
} catch (error) {
console.error('Failed to save context:', error);
}
}, [state, enablePersistence]);
const loadContext = useCallback(async () => {
if (!enablePersistence) return;
try {
const saved = localStorage.getItem('fileContext');
if (saved) {
const contextData = JSON.parse(saved);
dispatch({ type: 'LOAD_STATE', payload: contextData });
}
} catch (error) {
console.error('Failed to load context:', error);
}
}, [enablePersistence]);
const resetContext = useCallback(() => {
dispatch({ type: 'RESET_CONTEXT' });
if (enablePersistence) {
localStorage.removeItem('fileContext');
}
}, [enablePersistence]);
// Auto-save context when it changes
useEffect(() => {
saveContext();
}, [saveContext]);
// Load context on mount
useEffect(() => {
loadContext();
}, [loadContext]);
// Cleanup on unmount
useEffect(() => {
return () => {
console.log('FileContext unmounting - cleaning up all resources');
cleanupAllFiles();
};
}, [cleanupAllFiles]);
const contextValue: FileContextValue = {
// State
...state,
// Actions
addFiles,
removeFiles,
replaceFile,
clearAllFiles,
setCurrentMode,
setCurrentView,
setCurrentTool,
setSelectedFiles,
setSelectedPages,
updateProcessedFile,
clearSelections,
applyPageOperations,
applyFileOperation,
undoLastOperation,
updateViewerConfig,
setExportConfig,
getFileById,
getProcessedFileById,
getCurrentFile,
getCurrentProcessedFile,
saveContext,
loadContext,
resetContext,
// Operation history management
recordOperation,
markOperationApplied,
markOperationFailed,
getFileHistory,
getAppliedOperations,
clearFileHistory,
// Navigation guard system
setHasUnsavedChanges,
requestNavigation,
confirmNavigation,
cancelNavigation,
// Memory management
trackBlobUrl,
trackPdfDocument,
cleanupFile,
scheduleCleanup
};
return (
<FileContext.Provider value={contextValue}>
{children}
</FileContext.Provider>
);
}
// Custom hook to use the context
export function useFileContext(): FileContextValue {
const context = useContext(FileContext);
if (!context) {
throw new Error('useFileContext must be used within a FileContextProvider');
}
return context;
}
// Helper hooks for specific aspects
export function useCurrentFile() {
const { getCurrentFile, getCurrentProcessedFile } = useFileContext();
return {
file: getCurrentFile(),
processedFile: getCurrentProcessedFile()
};
}
export function useFileSelection() {
const {
selectedFileIds,
selectedPageIds,
setSelectedFiles,
setSelectedPages,
clearSelections
} = useFileContext();
return {
selectedFileIds,
selectedPageIds,
setSelectedFiles,
setSelectedPages,
clearSelections
};
}
export function useViewerState() {
const { viewerConfig, updateViewerConfig } = useFileContext();
return {
config: viewerConfig,
updateConfig: updateViewerConfig
};
}