2025-07-16 17:53:50 +01:00
|
|
|
/**
|
2025-08-10 21:08:32 +01:00
|
|
|
* Refactored FileContext with reducer pattern and normalized state
|
|
|
|
*
|
|
|
|
* PERFORMANCE IMPROVEMENTS:
|
|
|
|
* - Normalized state: File objects stored in refs, only IDs in state
|
|
|
|
* - Pure reducer: No object creation in reducer functions
|
|
|
|
* - Split contexts: StateContext vs ActionsContext prevents unnecessary rerenders
|
|
|
|
* - Individual selector hooks: Avoid selector object recreation
|
|
|
|
* - Stable actions: useCallback + stateRef prevents action recreation
|
|
|
|
* - Throttled persistence: Debounced localStorage writes
|
|
|
|
* - Proper resource cleanup: Automatic blob URL revocation
|
|
|
|
*
|
|
|
|
* USAGE:
|
|
|
|
* - State access: useFileState(), useFileRecord(), useFileSelection()
|
|
|
|
* - Actions only: useFileActions(), useFileManagement(), useViewerActions()
|
|
|
|
* - Combined: useFileContext() (legacy - causes rerenders on any state change)
|
|
|
|
* - FileRecord is the new lightweight "processed file" - no heavy processing needed
|
|
|
|
*
|
|
|
|
* PERFORMANCE NOTES:
|
|
|
|
* - useFileState() still rerenders on ANY state change (selectors object recreation)
|
|
|
|
* - For list UIs: consider ids-only context or use-context-selector
|
|
|
|
* - Individual hooks (useFileRecord) are the most performant option
|
2025-07-16 17:53:50 +01:00
|
|
|
*/
|
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
|
2025-08-11 16:40:38 +01:00
|
|
|
import {
|
|
|
|
FileContextState,
|
2025-07-16 17:53:50 +01:00
|
|
|
FileContextProviderProps,
|
2025-08-10 21:08:32 +01:00
|
|
|
FileContextSelectors,
|
|
|
|
FileContextStateValue,
|
|
|
|
FileContextActionsValue,
|
|
|
|
FileContextActions,
|
2025-08-12 14:37:45 +01:00
|
|
|
FileContextAction,
|
2025-07-16 17:53:50 +01:00
|
|
|
ModeType,
|
2025-08-10 21:08:32 +01:00
|
|
|
FileId,
|
|
|
|
FileRecord,
|
|
|
|
toFileRecord,
|
|
|
|
revokeFileResources,
|
2025-08-14 18:07:18 +01:00
|
|
|
createFileId,
|
|
|
|
computeContentHash
|
2025-07-16 17:53:50 +01:00
|
|
|
} from '../types/fileContext';
|
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Import real services
|
|
|
|
import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
|
|
|
|
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
|
|
|
import { fileStorage } from '../services/fileStorage';
|
2025-08-14 19:46:02 +01:00
|
|
|
import { fileProcessingService } from '../services/fileProcessingService';
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Get service instances
|
|
|
|
const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
|
2025-08-12 14:37:45 +01:00
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
};
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
// Reducer
|
2025-07-16 17:53:50 +01:00
|
|
|
function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
|
|
|
switch (action.type) {
|
2025-08-10 21:08:32 +01:00
|
|
|
case 'ADD_FILES': {
|
2025-08-14 18:07:18 +01:00
|
|
|
const { fileRecords } = action.payload;
|
2025-08-10 21:08:32 +01:00
|
|
|
const newIds: FileId[] = [];
|
|
|
|
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
fileRecords.forEach(record => {
|
2025-08-10 21:08:32 +01:00
|
|
|
// Only add if not already present (dedupe by stable ID)
|
2025-08-14 18:07:18 +01:00
|
|
|
if (!newById[record.id]) {
|
2025-08-10 21:08:32 +01:00
|
|
|
newIds.push(record.id);
|
|
|
|
newById[record.id] = record;
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
files: {
|
|
|
|
ids: [...state.files.ids, ...newIds],
|
|
|
|
byId: newById
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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];
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
files: {
|
|
|
|
ids: remainingIds,
|
|
|
|
byId: newById
|
|
|
|
},
|
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
selectedFileIds: state.ui.selectedFileIds.filter(id => !fileIds.includes(id))
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'UPDATE_FILE_RECORD': {
|
|
|
|
const { id, updates } = action.payload;
|
|
|
|
const existingRecord = state.files.byId[id];
|
|
|
|
if (!existingRecord) return state;
|
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Immutable merge supports all FileRecord fields including contentHash, hashStatus
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
files: {
|
|
|
|
...state.files,
|
|
|
|
byId: {
|
|
|
|
...state.files.byId,
|
|
|
|
[id]: { ...existingRecord, ...updates }
|
|
|
|
}
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
case 'SET_CURRENT_MODE': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
2025-08-12 14:37:45 +01:00
|
|
|
currentMode: action.payload
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'SET_SELECTED_FILES': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
selectedFileIds: action.payload.fileIds
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_SELECTED_PAGES': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
selectedPageNumbers: action.payload.pageNumbers
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'CLEAR_SELECTIONS': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
selectedFileIds: [],
|
|
|
|
selectedPageNumbers: []
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_PROCESSING': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
isProcessing: action.payload.isProcessing,
|
2025-08-12 14:37:45 +01:00
|
|
|
processingProgress: action.payload.progress || 0
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_UNSAVED_CHANGES': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
hasUnsavedChanges: action.payload.hasChanges
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_PENDING_NAVIGATION': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
pendingNavigation: action.payload.navigationFn
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SHOW_NAVIGATION_WARNING': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
showNavigationWarning: action.payload.show
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'RESET_CONTEXT': {
|
|
|
|
// Clean up all resources before reset
|
|
|
|
Object.values(state.files.byId).forEach(revokeFileResources);
|
|
|
|
return { ...initialFileContextState };
|
|
|
|
}
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
default:
|
|
|
|
return state;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
// Split contexts for performance
|
|
|
|
const FileStateContext = createContext<FileContextStateValue | undefined>(undefined);
|
|
|
|
const FileActionsContext = createContext<FileContextActionsValue | undefined>(undefined);
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
// Legacy context for backward compatibility
|
|
|
|
const FileContext = createContext<any | undefined>(undefined);
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Provider component
|
2025-08-11 16:40:38 +01:00
|
|
|
export function FileContextProvider({
|
|
|
|
children,
|
2025-07-16 17:53:50 +01:00
|
|
|
enableUrlSync = true,
|
2025-08-10 21:08:32 +01:00
|
|
|
enablePersistence = true
|
2025-07-16 17:53:50 +01:00
|
|
|
}: FileContextProviderProps) {
|
2025-08-12 14:37:45 +01:00
|
|
|
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
2025-08-11 16:40:38 +01:00
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
// File ref map - stores File objects outside React state
|
|
|
|
const filesRef = useRef<Map<FileId, File>>(new Map());
|
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
// Cleanup timers and refs
|
2025-08-12 14:37:45 +01:00
|
|
|
const cleanupTimers = useRef<Map<string, number>>(new Map());
|
2025-08-11 16:40:38 +01:00
|
|
|
const blobUrls = useRef<Set<string>>(new Set());
|
2025-08-12 14:37:45 +01:00
|
|
|
const pdfDocuments = useRef<Map<string, any>>(new Map());
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
// Stable state reference for selectors
|
|
|
|
const stateRef = useRef(state);
|
|
|
|
stateRef.current = state;
|
|
|
|
|
|
|
|
// Stable selectors (memoized once to avoid re-renders)
|
|
|
|
const selectors = useMemo<FileContextSelectors>(() => ({
|
|
|
|
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
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
// Centralized memory management
|
|
|
|
const trackBlobUrl = useCallback((url: string) => {
|
|
|
|
blobUrls.current.add(url);
|
|
|
|
}, []);
|
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => {
|
2025-08-11 16:40:38 +01:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
|
|
|
|
} 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) {
|
2025-07-16 17:53:50 +01:00
|
|
|
try {
|
2025-08-11 16:40:38 +01:00
|
|
|
pdfDoc.destroy();
|
2025-07-16 17:53:50 +01:00
|
|
|
} catch (error) {
|
2025-08-11 16:40:38 +01:00
|
|
|
console.warn(`Error destroying PDF document for ${fileId}:`, error);
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
2025-08-11 16:40:38 +01:00
|
|
|
pdfDocuments.current.clear();
|
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Revoke all blob URLs (only blob: scheme)
|
2025-08-11 16:40:38 +01:00
|
|
|
blobUrls.current.forEach(url => {
|
2025-08-14 18:07:18 +01:00
|
|
|
if (url.startsWith('blob:')) {
|
|
|
|
try {
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
} catch (error) {
|
|
|
|
console.warn('Error revoking blob URL:', error);
|
|
|
|
}
|
2025-08-11 16:40:38 +01:00
|
|
|
}
|
|
|
|
});
|
|
|
|
blobUrls.current.clear();
|
|
|
|
|
|
|
|
// Clear all processing
|
|
|
|
enhancedPDFProcessingService.clearAllProcessing();
|
|
|
|
|
|
|
|
// Destroy thumbnails
|
|
|
|
thumbnailGenerationService.destroy();
|
|
|
|
|
|
|
|
// Force garbage collection hint
|
2025-08-12 14:37:45 +01:00
|
|
|
if (typeof window !== 'undefined' && (window as any).gc) {
|
|
|
|
let gc = (window as any).gc;
|
2025-08-11 16:40:38 +01:00
|
|
|
setTimeout(() => gc(), 100);
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
console.warn('Error during cleanup all files:', error);
|
|
|
|
}
|
2025-08-10 21:08:32 +01:00
|
|
|
}, []);
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
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);
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
// If delay is negative, just cancel (don't reschedule)
|
|
|
|
if (delay < 0) {
|
|
|
|
return;
|
|
|
|
}
|
2025-08-10 21:08:32 +01:00
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
// Schedule new cleanup
|
2025-08-12 14:37:45 +01:00
|
|
|
const timer = window.setTimeout(() => {
|
2025-08-11 16:40:38 +01:00
|
|
|
cleanupFile(fileId);
|
|
|
|
}, delay);
|
2025-08-10 21:08:32 +01:00
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
cleanupTimers.current.set(fileId, timer);
|
|
|
|
}, [cleanupFile]);
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
// Action implementations
|
|
|
|
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
2025-08-14 18:07:18 +01:00
|
|
|
// Generate UUID-based IDs and create records
|
|
|
|
const fileRecords: FileRecord[] = [];
|
|
|
|
const addedFiles: File[] = [];
|
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
for (const file of files) {
|
2025-08-14 18:07:18 +01:00
|
|
|
const fileId = createFileId(); // UUID-based, zero collisions
|
|
|
|
|
|
|
|
// Store File in ref map
|
|
|
|
filesRef.current.set(fileId, file);
|
|
|
|
|
|
|
|
// Create record with pending hash status
|
|
|
|
const record = toFileRecord(file, fileId);
|
|
|
|
record.hashStatus = 'pending';
|
|
|
|
|
|
|
|
fileRecords.push(record);
|
|
|
|
addedFiles.push(file);
|
|
|
|
|
2025-08-14 19:46:02 +01:00
|
|
|
// Start centralized file processing (async, non-blocking)
|
|
|
|
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 using dispatch directly
|
|
|
|
dispatch({
|
|
|
|
type: 'UPDATE_FILE_RECORD',
|
|
|
|
payload: {
|
|
|
|
id: fileId,
|
|
|
|
updates: {
|
|
|
|
processedFile: result.metadata,
|
|
|
|
thumbnailUrl: result.metadata.thumbnailUrl
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
console.log(`✅ File processing complete for ${file.name}: ${result.metadata.totalPages} pages`);
|
|
|
|
} else {
|
|
|
|
console.warn(`❌ File processing failed for ${file.name}:`, result.error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}).catch(error => {
|
|
|
|
console.error(`❌ File processing error for ${file.name}:`, error);
|
|
|
|
});
|
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Optional: Persist to IndexedDB if enabled
|
|
|
|
if (enablePersistence) {
|
|
|
|
try {
|
2025-08-14 19:46:02 +01:00
|
|
|
// Use the thumbnail from processing service if available
|
|
|
|
fileProcessingService.processFile(file, fileId).then(result => {
|
|
|
|
const thumbnail = result.metadata?.thumbnailUrl;
|
2025-08-14 18:07:18 +01:00
|
|
|
return fileStorage.storeFile(file, fileId, thumbnail);
|
|
|
|
}).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);
|
|
|
|
}
|
2025-08-11 16:40:38 +01:00
|
|
|
}
|
2025-08-14 18:07:18 +01:00
|
|
|
|
|
|
|
// Start async content hashing (don't block add operation)
|
|
|
|
computeContentHash(file).then(contentHash => {
|
|
|
|
// Only update if file still exists in context
|
|
|
|
if (filesRef.current.has(fileId)) {
|
|
|
|
updateFileRecord(fileId, {
|
|
|
|
contentHash: contentHash || undefined, // Convert null to undefined
|
|
|
|
hashStatus: contentHash ? 'completed' : 'failed'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}).catch(() => {
|
|
|
|
// Hash failed, update status if file still exists
|
|
|
|
if (filesRef.current.has(fileId)) {
|
|
|
|
updateFileRecord(fileId, { hashStatus: 'failed' });
|
|
|
|
}
|
|
|
|
});
|
2025-08-11 16:40:38 +01:00
|
|
|
}
|
2025-08-12 14:37:45 +01:00
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Only dispatch if we have new files
|
|
|
|
if (fileRecords.length > 0) {
|
|
|
|
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
|
|
|
}
|
2025-08-10 21:08:32 +01:00
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Return only the newly added files
|
|
|
|
return addedFiles;
|
2025-08-14 19:46:02 +01:00
|
|
|
}, [enablePersistence]); // Remove updateFileRecord dependency
|
2025-08-10 21:08:32 +01:00
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => {
|
2025-08-14 18:07:18 +01:00
|
|
|
// Clean up Files from ref map first
|
2025-08-11 16:40:38 +01:00
|
|
|
fileIds.forEach(fileId => {
|
2025-08-12 14:37:45 +01:00
|
|
|
filesRef.current.delete(fileId);
|
2025-08-11 16:40:38 +01:00
|
|
|
cleanupFile(fileId);
|
|
|
|
});
|
2025-08-10 21:08:32 +01:00
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
// Update state
|
2025-08-12 14:37:45 +01:00
|
|
|
dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
2025-08-14 18:07:18 +01:00
|
|
|
const updateFileRecord = useCallback((id: FileId, updates: Partial<FileRecord>) => {
|
|
|
|
// Ensure immutable merge by dispatching action
|
|
|
|
dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id, updates } });
|
|
|
|
}, []);
|
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
// Navigation guard system functions
|
|
|
|
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
2025-08-12 14:37:45 +01:00
|
|
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
2025-08-11 16:40:38 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
const requestNavigation = useCallback((navigationFn: () => void): boolean => {
|
2025-08-14 18:07:18 +01:00
|
|
|
// Use stateRef to avoid stale closure issues with rapid state changes
|
|
|
|
if (stateRef.current.ui.hasUnsavedChanges) {
|
2025-08-12 14:37:45 +01:00
|
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
|
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
2025-08-11 16:40:38 +01:00
|
|
|
return false;
|
|
|
|
} else {
|
|
|
|
navigationFn();
|
|
|
|
return true;
|
|
|
|
}
|
2025-08-14 18:07:18 +01:00
|
|
|
}, []); // No dependencies - uses stateRef for current state
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
const confirmNavigation = useCallback(() => {
|
2025-08-14 18:07:18 +01:00
|
|
|
// Use stateRef to get current navigation function
|
|
|
|
if (stateRef.current.ui.pendingNavigation) {
|
|
|
|
stateRef.current.ui.pendingNavigation();
|
2025-08-12 14:37:45 +01:00
|
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
2025-08-11 16:40:38 +01:00
|
|
|
}
|
2025-08-12 14:37:45 +01:00
|
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
2025-08-14 18:07:18 +01:00
|
|
|
}, []); // No dependencies - uses stateRef
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
const cancelNavigation = useCallback(() => {
|
2025-08-12 14:37:45 +01:00
|
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
|
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
2025-08-11 16:40:38 +01:00
|
|
|
}, []);
|
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
// Memoized actions to prevent re-renders
|
|
|
|
const actions = useMemo<FileContextActions>(() => ({
|
|
|
|
addFiles,
|
|
|
|
removeFiles,
|
2025-08-14 18:07:18 +01:00
|
|
|
updateFileRecord,
|
2025-08-12 14:37:45 +01:00
|
|
|
clearAllFiles: () => {
|
2025-08-11 16:40:38 +01:00
|
|
|
cleanupAllFiles();
|
2025-08-12 14:37:45 +01:00
|
|
|
filesRef.current.clear();
|
|
|
|
dispatch({ type: 'RESET_CONTEXT' });
|
|
|
|
},
|
|
|
|
setCurrentMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }),
|
|
|
|
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
|
|
|
|
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
|
|
|
|
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
|
|
|
|
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
|
|
|
|
setHasUnsavedChanges,
|
|
|
|
resetContext: () => {
|
|
|
|
cleanupAllFiles();
|
|
|
|
filesRef.current.clear();
|
|
|
|
dispatch({ type: 'RESET_CONTEXT' });
|
|
|
|
},
|
|
|
|
// Legacy compatibility
|
|
|
|
setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }),
|
|
|
|
confirmNavigation,
|
|
|
|
cancelNavigation
|
|
|
|
}), [addFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]);
|
|
|
|
|
|
|
|
// Split context values to minimize re-renders
|
|
|
|
const stateValue = useMemo<FileContextStateValue>(() => ({
|
|
|
|
state,
|
|
|
|
selectors
|
|
|
|
}), [state]); // selectors are now stable, no need to depend on them
|
|
|
|
|
|
|
|
const actionsValue = useMemo<FileContextActionsValue>(() => ({
|
|
|
|
actions,
|
|
|
|
dispatch
|
|
|
|
}), [actions]);
|
|
|
|
|
|
|
|
// Legacy context value for backward compatibility
|
|
|
|
const legacyContextValue = useMemo(() => ({
|
2025-08-11 16:40:38 +01:00
|
|
|
...state,
|
2025-08-12 14:37:45 +01:00
|
|
|
...state.ui,
|
|
|
|
// Action compatibility layer
|
2025-08-11 16:40:38 +01:00
|
|
|
addFiles,
|
|
|
|
removeFiles,
|
2025-08-14 18:07:18 +01:00
|
|
|
updateFileRecord,
|
2025-08-12 14:37:45 +01:00
|
|
|
clearAllFiles: actions.clearAllFiles,
|
|
|
|
setCurrentMode: actions.setCurrentMode,
|
|
|
|
setSelectedFiles: actions.setSelectedFiles,
|
|
|
|
setSelectedPages: actions.setSelectedPages,
|
|
|
|
clearSelections: actions.clearSelections,
|
2025-08-11 16:40:38 +01:00
|
|
|
setHasUnsavedChanges,
|
|
|
|
requestNavigation,
|
|
|
|
confirmNavigation,
|
|
|
|
cancelNavigation,
|
|
|
|
trackBlobUrl,
|
|
|
|
trackPdfDocument,
|
|
|
|
cleanupFile,
|
2025-08-12 14:37:45 +01:00
|
|
|
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
|
2025-08-14 18:07:18 +01:00
|
|
|
}), [state, actions, addFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency
|
2025-08-12 14:37:45 +01:00
|
|
|
|
|
|
|
// Cleanup on unmount
|
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
|
|
|
console.log('FileContext unmounting - cleaning up all resources');
|
|
|
|
cleanupAllFiles();
|
|
|
|
};
|
|
|
|
}, [cleanupAllFiles]);
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
return (
|
2025-08-12 14:37:45 +01:00
|
|
|
<FileStateContext.Provider value={stateValue}>
|
|
|
|
<FileActionsContext.Provider value={actionsValue}>
|
|
|
|
<FileContext.Provider value={legacyContextValue}>
|
|
|
|
{children}
|
|
|
|
</FileContext.Provider>
|
|
|
|
</FileActionsContext.Provider>
|
|
|
|
</FileStateContext.Provider>
|
2025-08-11 16:40:38 +01:00
|
|
|
);
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
// 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 {
|
2025-08-11 16:40:38 +01:00
|
|
|
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() {
|
2025-08-12 14:37:45 +01:00
|
|
|
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
|
2025-08-11 16:40:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function useFileSelection() {
|
2025-08-12 14:37:45 +01:00
|
|
|
const { state } = useFileState();
|
|
|
|
const { actions } = useFileActions();
|
2025-08-11 16:40:38 +01:00
|
|
|
|
|
|
|
return {
|
2025-08-12 14:37:45 +01:00
|
|
|
selectedFileIds: state.ui.selectedFileIds,
|
|
|
|
selectedPageNumbers: state.ui.selectedPageNumbers,
|
|
|
|
setSelectedFiles: actions.setSelectedFiles,
|
|
|
|
setSelectedPages: actions.setSelectedPages,
|
|
|
|
clearSelections: actions.clearSelections
|
2025-08-11 16:40:38 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2025-08-12 14:37:45 +01:00
|
|
|
// 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) => {
|
2025-08-14 18:07:18 +01:00
|
|
|
console.warn('useProcessedFiles.get is deprecated - File objects no longer have stable IDs');
|
|
|
|
return null;
|
2025-08-12 14:37:45 +01:00
|
|
|
},
|
|
|
|
has: (file: File) => {
|
2025-08-14 18:07:18 +01:00
|
|
|
console.warn('useProcessedFiles.has is deprecated - File objects no longer have stable IDs');
|
|
|
|
return false;
|
2025-08-12 14:37:45 +01:00
|
|
|
},
|
|
|
|
set: () => {
|
|
|
|
console.warn('processedFiles.set is deprecated - use FileRecord updates instead');
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2025-08-11 16:40:38 +01:00
|
|
|
return {
|
2025-08-12 14:37:45 +01:00
|
|
|
processedFiles: compatibilityMap, // Map-like interface for backward compatibility
|
|
|
|
getProcessedFile: (file: File) => {
|
2025-08-14 18:07:18 +01:00
|
|
|
console.warn('getProcessedFile is deprecated - File objects no longer have stable IDs');
|
|
|
|
return null;
|
2025-08-12 14:37:45 +01:00
|
|
|
},
|
|
|
|
updateProcessedFile: () => {
|
|
|
|
console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord');
|
|
|
|
}
|
2025-08-11 16:40:38 +01:00
|
|
|
};
|
|
|
|
}
|
2025-08-12 14:37:45 +01:00
|
|
|
|
|
|
|
export function useFileManagement() {
|
|
|
|
const { actions } = useFileActions();
|
|
|
|
return {
|
|
|
|
addFiles: actions.addFiles,
|
|
|
|
removeFiles: actions.removeFiles,
|
|
|
|
clearAllFiles: actions.clearAllFiles
|
|
|
|
};
|
|
|
|
}
|