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-10 21:08:32 +01:00
|
|
|
import React, { createContext, useContext, useReducer, useRef, useMemo, useCallback, useEffect } from 'react';
|
2025-07-16 17:53:50 +01:00
|
|
|
import {
|
|
|
|
FileContextState,
|
2025-08-10 21:08:32 +01:00
|
|
|
FileContextAction,
|
|
|
|
FileContextValue,
|
2025-07-16 17:53:50 +01:00
|
|
|
FileContextProviderProps,
|
2025-08-10 21:08:32 +01:00
|
|
|
FileContextSelectors,
|
|
|
|
FileContextStateValue,
|
|
|
|
FileContextActionsValue,
|
|
|
|
FileContextActions,
|
2025-07-16 17:53:50 +01:00
|
|
|
ModeType,
|
2025-08-10 21:08:32 +01:00
|
|
|
FileId,
|
|
|
|
FileRecord,
|
|
|
|
toFileRecord,
|
|
|
|
revokeFileResources,
|
|
|
|
createStableFileId
|
2025-07-16 17:53:50 +01:00
|
|
|
} from '../types/fileContext';
|
2025-08-10 21:08:32 +01:00
|
|
|
import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
|
2025-07-16 17:53:50 +01:00
|
|
|
|
|
|
|
// Initial state
|
2025-08-10 21:08:32 +01:00
|
|
|
const initialFileContextState: FileContextState = {
|
|
|
|
files: {
|
|
|
|
ids: [],
|
|
|
|
byId: {}
|
|
|
|
},
|
|
|
|
ui: {
|
|
|
|
currentMode: 'fileEditor',
|
|
|
|
selectedFileIds: [],
|
|
|
|
selectedPageNumbers: [],
|
|
|
|
viewerConfig: {
|
|
|
|
zoom: 1.0,
|
|
|
|
currentPage: 1,
|
|
|
|
viewMode: 'single',
|
|
|
|
sidebarOpen: false
|
|
|
|
},
|
|
|
|
// Tool selection state (replaces FileSelectionContext)
|
|
|
|
toolMode: false,
|
|
|
|
maxFiles: -1, // -1 = unlimited
|
|
|
|
currentTool: undefined,
|
|
|
|
isProcessing: false,
|
|
|
|
processingProgress: 0,
|
|
|
|
lastExportConfig: undefined,
|
|
|
|
hasUnsavedChanges: false,
|
|
|
|
pendingNavigation: null,
|
|
|
|
showNavigationWarning: false
|
|
|
|
},
|
|
|
|
history: {
|
|
|
|
fileEditHistory: new Map(),
|
|
|
|
globalFileOperations: [],
|
|
|
|
fileOperationHistory: new Map()
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
// Pure reducer function
|
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': {
|
|
|
|
const { files } = action.payload;
|
|
|
|
const newIds: FileId[] = [];
|
|
|
|
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
|
|
|
|
|
|
|
files.forEach(file => {
|
|
|
|
const stableId = createStableFileId(file);
|
|
|
|
// Only add if not already present (dedupe by stable ID)
|
|
|
|
if (!newById[stableId]) {
|
|
|
|
const record = toFileRecord(file, stableId);
|
|
|
|
newIds.push(record.id);
|
|
|
|
newById[record.id] = record;
|
|
|
|
}
|
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-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
|
|
|
}
|
|
|
|
|
|
|
|
case 'CLEAR_ALL_FILES': {
|
|
|
|
// Clean up all file resources
|
|
|
|
Object.values(state.files.byId).forEach(revokeFileResources);
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
files: {
|
|
|
|
ids: [],
|
|
|
|
byId: {}
|
|
|
|
},
|
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
selectedFileIds: [],
|
|
|
|
selectedPageNumbers: []
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_MODE': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
currentMode: action.payload.mode
|
|
|
|
}
|
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,
|
|
|
|
processingProgress: action.payload.progress
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// Tool selection cases (replaces FileSelectionContext)
|
|
|
|
case 'SET_TOOL_MODE': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
toolMode: action.payload.toolMode
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_MAX_FILES': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
maxFiles: action.payload.maxFiles
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_CURRENT_TOOL': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
currentTool: action.payload.currentTool
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'UPDATE_VIEWER_CONFIG': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
viewerConfig: {
|
|
|
|
...state.ui.viewerConfig,
|
|
|
|
...action.payload.config
|
|
|
|
}
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'SET_EXPORT_CONFIG': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
lastExportConfig: action.payload.config
|
|
|
|
}
|
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 'CONFIRM_NAVIGATION': {
|
|
|
|
const pendingNavigation = state.ui.pendingNavigation;
|
|
|
|
if (pendingNavigation) {
|
|
|
|
pendingNavigation();
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
pendingNavigation: null,
|
|
|
|
showNavigationWarning: false
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
case 'CANCEL_NAVIGATION': {
|
2025-07-16 17:53:50 +01:00
|
|
|
return {
|
|
|
|
...state,
|
2025-08-10 21:08:32 +01:00
|
|
|
ui: {
|
|
|
|
...state.ui,
|
|
|
|
pendingNavigation: null,
|
|
|
|
showNavigationWarning: false
|
|
|
|
}
|
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
|
|
|
|
|
|
|
// Provider component
|
|
|
|
export function FileContextProvider({
|
|
|
|
children,
|
|
|
|
enableUrlSync = true,
|
2025-08-10 21:08:32 +01:00
|
|
|
enablePersistence = true
|
2025-07-16 17:53:50 +01:00
|
|
|
}: FileContextProviderProps) {
|
2025-08-10 21:08:32 +01:00
|
|
|
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
// Refs for stable references
|
|
|
|
const stateRef = useRef(state);
|
|
|
|
stateRef.current = state;
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
// Stable selector functions that don't recreate on every state change
|
|
|
|
const stableSelectors = useMemo(() => {
|
|
|
|
const getFileById = (id: FileId) => stateRef.current.files.byId[id];
|
|
|
|
const getFilesByIds = (ids: FileId[]) => ids.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
|
|
|
const getAllFiles = () => stateRef.current.files.ids.map(id => stateRef.current.files.byId[id]);
|
|
|
|
const getSelectedFiles = () => stateRef.current.ui.selectedFileIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
|
|
|
|
|
|
|
// Convenience file helpers
|
|
|
|
const getFile = (id: FileId) => stateRef.current.files.byId[id]?.file;
|
|
|
|
const getFiles = (ids?: FileId[]) => {
|
|
|
|
const fileIds = ids || stateRef.current.files.ids;
|
|
|
|
return fileIds.map(id => stateRef.current.files.byId[id]?.file).filter(Boolean);
|
|
|
|
};
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
const getCurrentMode = () => stateRef.current.ui.currentMode;
|
|
|
|
const getSelectedFileIds = () => stateRef.current.ui.selectedFileIds;
|
|
|
|
const getSelectedPageNumbers = () => stateRef.current.ui.selectedPageNumbers;
|
|
|
|
const getViewerConfig = () => stateRef.current.ui.viewerConfig;
|
|
|
|
const getProcessingState = () => ({
|
|
|
|
isProcessing: stateRef.current.ui.isProcessing,
|
|
|
|
progress: stateRef.current.ui.processingProgress
|
|
|
|
});
|
|
|
|
const getHasUnsavedChanges = () => stateRef.current.ui.hasUnsavedChanges;
|
|
|
|
const getShowNavigationWarning = () => stateRef.current.ui.showNavigationWarning;
|
|
|
|
const getFileHistory = (fileId: string) => stateRef.current.history.fileOperationHistory.get(fileId);
|
|
|
|
const getAppliedOperations = (fileId: string) => {
|
|
|
|
const history = stateRef.current.history.fileOperationHistory.get(fileId);
|
|
|
|
return history ? history.operations.filter(op => op.status === 'applied') : [];
|
|
|
|
};
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
return {
|
|
|
|
getFileById,
|
|
|
|
getFilesByIds,
|
|
|
|
getAllFiles,
|
|
|
|
getSelectedFiles,
|
|
|
|
getFile,
|
|
|
|
getFiles,
|
|
|
|
getCurrentMode,
|
|
|
|
getSelectedFileIds,
|
|
|
|
getSelectedPageNumbers,
|
|
|
|
getViewerConfig,
|
|
|
|
getProcessingState,
|
|
|
|
getHasUnsavedChanges,
|
|
|
|
getShowNavigationWarning,
|
|
|
|
getFileHistory,
|
|
|
|
getAppliedOperations
|
|
|
|
};
|
|
|
|
}, []); // Empty dependency array - selectors use stateRef
|
|
|
|
|
|
|
|
// Stable action callbacks (using stateRef to prevent recreation)
|
|
|
|
const actions: FileContextActions = useMemo(() => ({
|
|
|
|
addFiles: async (files: File[]): Promise<File[]> => {
|
|
|
|
dispatch({ type: 'ADD_FILES', payload: { files } });
|
|
|
|
|
|
|
|
// Process PDF files asynchronously for PageEditor compatibility
|
|
|
|
const pdfProcessingService = EnhancedPDFProcessingService.getInstance();
|
|
|
|
|
|
|
|
files.forEach(async (file) => {
|
|
|
|
if (file.type === 'application/pdf') {
|
2025-07-16 17:53:50 +01:00
|
|
|
try {
|
2025-08-10 21:08:32 +01:00
|
|
|
console.log(`🔄 Processing PDF: ${file.name} (${file.size} bytes)`);
|
|
|
|
const stableId = createStableFileId(file);
|
|
|
|
|
|
|
|
// Add timeout to prevent indefinite processing
|
|
|
|
const processingPromise = pdfProcessingService.processFile(file);
|
|
|
|
const timeoutPromise = new Promise<null>((_, reject) => {
|
|
|
|
setTimeout(() => reject(new Error('Processing timeout')), 30000); // 30 second timeout
|
|
|
|
});
|
|
|
|
|
|
|
|
const processedFile = await Promise.race([processingPromise, timeoutPromise]);
|
|
|
|
|
|
|
|
console.log(`✅ PDF processed: ${file.name}, result:`, processedFile ? 'success' : 'null');
|
|
|
|
if (processedFile) {
|
|
|
|
// Update file record with processed data
|
|
|
|
dispatch({
|
|
|
|
type: 'UPDATE_FILE_RECORD',
|
|
|
|
payload: {
|
|
|
|
id: stableId,
|
|
|
|
updates: { processedFile }
|
|
|
|
}
|
|
|
|
});
|
|
|
|
console.log(`📁 Updated FileRecord for ${file.name} with processed data`);
|
|
|
|
} else {
|
|
|
|
console.warn(`⚠️ Processing returned null for ${file.name}, file will use fallback page counting`);
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
} catch (error) {
|
2025-08-10 21:08:32 +01:00
|
|
|
console.error(`❌ Failed to process PDF ${file.name}:`, error);
|
|
|
|
// Continue without processed data - FileEditor will use fallback page counting
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
2025-08-10 21:08:32 +01:00
|
|
|
|
|
|
|
return files;
|
|
|
|
},
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
removeFiles: (fileIds: string[], deleteFromStorage: boolean = true) => {
|
|
|
|
dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
|
|
|
},
|
2025-08-08 15:15:09 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
replaceFile: async (oldFileId: string, newFile: File) => {
|
|
|
|
dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [oldFileId] } });
|
|
|
|
dispatch({ type: 'ADD_FILES', payload: { files: [newFile] } });
|
|
|
|
},
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
clearAllFiles: () => {
|
|
|
|
dispatch({ type: 'CLEAR_ALL_FILES' });
|
|
|
|
},
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
setMode: (mode: ModeType) => {
|
|
|
|
dispatch({ type: 'SET_MODE', payload: { mode } });
|
|
|
|
},
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
setSelectedFiles: (fileIds: string[]) => {
|
|
|
|
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } });
|
|
|
|
},
|
|
|
|
|
|
|
|
setSelectedPages: (pageNumbers: number[]) => {
|
|
|
|
dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } });
|
|
|
|
},
|
|
|
|
|
|
|
|
clearSelections: () => {
|
|
|
|
dispatch({ type: 'CLEAR_SELECTIONS' });
|
|
|
|
},
|
|
|
|
|
|
|
|
// Tool selection actions (replaces FileSelectionContext)
|
|
|
|
setToolMode: (toolMode: boolean) => {
|
|
|
|
dispatch({ type: 'SET_TOOL_MODE', payload: { toolMode } });
|
|
|
|
},
|
|
|
|
|
|
|
|
setMaxFiles: (maxFiles: number) => {
|
|
|
|
dispatch({ type: 'SET_MAX_FILES', payload: { maxFiles } });
|
|
|
|
},
|
|
|
|
|
|
|
|
setCurrentTool: (currentTool?: string) => {
|
|
|
|
dispatch({ type: 'SET_CURRENT_TOOL', payload: { currentTool } });
|
|
|
|
},
|
|
|
|
|
|
|
|
setProcessing: (isProcessing: boolean, progress: number) => {
|
|
|
|
dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } });
|
|
|
|
},
|
|
|
|
|
|
|
|
updateViewerConfig: (config: Partial<FileContextState['ui']['viewerConfig']>) => {
|
|
|
|
dispatch({ type: 'UPDATE_VIEWER_CONFIG', payload: { config } });
|
|
|
|
},
|
|
|
|
|
|
|
|
setExportConfig: (config: FileContextState['ui']['lastExportConfig']) => {
|
|
|
|
dispatch({ type: 'SET_EXPORT_CONFIG', payload: { config } });
|
|
|
|
},
|
|
|
|
|
|
|
|
setHasUnsavedChanges: (hasChanges: boolean) => {
|
|
|
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
|
|
|
},
|
|
|
|
|
|
|
|
requestNavigation: (navigationFn: () => void): boolean => {
|
|
|
|
const currentState = stateRef.current;
|
|
|
|
if (currentState.ui.hasUnsavedChanges) {
|
|
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
|
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
|
|
|
return false;
|
|
|
|
} else {
|
|
|
|
navigationFn();
|
|
|
|
return true;
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
2025-08-10 21:08:32 +01:00
|
|
|
},
|
|
|
|
|
|
|
|
confirmNavigation: () => {
|
|
|
|
dispatch({ type: 'CONFIRM_NAVIGATION' });
|
|
|
|
},
|
|
|
|
|
|
|
|
cancelNavigation: () => {
|
|
|
|
dispatch({ type: 'CANCEL_NAVIGATION' });
|
|
|
|
},
|
|
|
|
|
|
|
|
resetContext: () => {
|
|
|
|
dispatch({ type: 'RESET_CONTEXT' });
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
2025-08-10 21:08:32 +01:00
|
|
|
}), []);
|
|
|
|
|
|
|
|
// Context values
|
|
|
|
const stateValue: FileContextStateValue = useMemo(() => ({
|
|
|
|
state,
|
|
|
|
selectors: stableSelectors
|
|
|
|
}), [state, stableSelectors]);
|
|
|
|
|
|
|
|
const actionsValue: FileContextActionsValue = useMemo(() => ({
|
|
|
|
actions,
|
|
|
|
dispatch
|
|
|
|
}), [actions]);
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Cleanup on unmount
|
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
2025-08-10 21:08:32 +01:00
|
|
|
Object.values(state.files.byId).forEach(revokeFileResources);
|
2025-07-16 17:53:50 +01:00
|
|
|
};
|
2025-08-10 21:08:32 +01:00
|
|
|
}, []);
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
return (
|
2025-08-10 21:08:32 +01:00
|
|
|
<FileStateContext.Provider value={stateValue}>
|
|
|
|
<FileActionsContext.Provider value={actionsValue}>
|
|
|
|
{children}
|
|
|
|
</FileActionsContext.Provider>
|
|
|
|
</FileStateContext.Provider>
|
2025-07-16 17:53:50 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
// Hooks for accessing contexts
|
|
|
|
export function useFileState(): FileContextStateValue {
|
|
|
|
const context = useContext(FileStateContext);
|
2025-07-16 17:53:50 +01:00
|
|
|
if (!context) {
|
2025-08-10 21:08:32 +01:00
|
|
|
throw new Error('useFileState must be used within a FileContextProvider');
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
|
|
|
return context;
|
|
|
|
}
|
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
export function useFileActions(): FileContextActionsValue {
|
|
|
|
const context = useContext(FileActionsContext);
|
|
|
|
if (!context) {
|
|
|
|
throw new Error('useFileActions must be used within a FileContextProvider');
|
|
|
|
}
|
|
|
|
return context;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Individual optimized hooks
|
|
|
|
export function useFileRecord(id: FileId): FileRecord | undefined {
|
|
|
|
const { state } = useFileState();
|
|
|
|
return state.files.byId[id];
|
|
|
|
}
|
|
|
|
|
|
|
|
export function useFileRecords(ids?: FileId[]): FileRecord[] {
|
|
|
|
const { state } = useFileState();
|
|
|
|
if (!ids) {
|
|
|
|
return state.files.ids.map(id => state.files.byId[id]);
|
|
|
|
}
|
|
|
|
return ids.map(id => state.files.byId[id]).filter(Boolean);
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function useFileSelection() {
|
2025-08-10 21:08:32 +01:00
|
|
|
const { state } = useFileState();
|
|
|
|
const { actions } = useFileActions();
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
return useMemo(() => ({
|
|
|
|
selectedFileIds: state.ui.selectedFileIds,
|
|
|
|
selectedFiles: state.ui.selectedFileIds.map(id => state.files.byId[id]).filter(Boolean),
|
|
|
|
selectedPageNumbers: state.ui.selectedPageNumbers,
|
|
|
|
setSelectedFiles: actions.setSelectedFiles,
|
|
|
|
setSelectedPages: actions.setSelectedPages,
|
|
|
|
clearSelections: actions.clearSelections
|
|
|
|
}), [state.ui.selectedFileIds, state.ui.selectedPageNumbers, state.files.byId]);
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|
|
|
|
|
2025-08-10 21:08:32 +01:00
|
|
|
// Tool selection hook (replaces FileSelectionContext functionality)
|
|
|
|
export function useToolFileSelection() {
|
|
|
|
const { state } = useFileState();
|
|
|
|
const { actions } = useFileActions();
|
|
|
|
|
|
|
|
const selectedFiles = useMemo(() =>
|
|
|
|
state.ui.selectedFileIds
|
|
|
|
.map(id => state.files.byId[id]?.file)
|
|
|
|
.filter(Boolean) as File[],
|
|
|
|
[state.ui.selectedFileIds, state.files.byId]
|
|
|
|
);
|
|
|
|
|
|
|
|
const selectionCount = selectedFiles.length;
|
|
|
|
const canSelectMore = state.ui.maxFiles === -1 || selectionCount < state.ui.maxFiles;
|
|
|
|
const isAtLimit = state.ui.maxFiles > 0 && selectionCount >= state.ui.maxFiles;
|
|
|
|
const isMultiFileMode = state.ui.maxFiles !== 1;
|
|
|
|
|
|
|
|
return useMemo(() => ({
|
|
|
|
// Core selection state (matches FileSelectionContext interface)
|
|
|
|
selectedFiles,
|
|
|
|
maxFiles: state.ui.maxFiles,
|
|
|
|
isToolMode: state.ui.toolMode,
|
|
|
|
|
|
|
|
// Selection actions
|
|
|
|
setSelectedFiles: (files: File[]) => {
|
|
|
|
const fileIds = files.map(file => createStableFileId(file));
|
|
|
|
actions.setSelectedFiles(fileIds);
|
|
|
|
},
|
|
|
|
setMaxFiles: actions.setMaxFiles,
|
|
|
|
setIsToolMode: actions.setToolMode,
|
|
|
|
clearSelection: () => actions.setSelectedFiles([]),
|
|
|
|
|
|
|
|
// Computed values (matches FileSelectionContext interface)
|
|
|
|
canSelectMore,
|
|
|
|
isAtLimit,
|
|
|
|
selectionCount,
|
|
|
|
isMultiFileMode
|
|
|
|
}), [selectedFiles, state.ui.maxFiles, state.ui.toolMode, canSelectMore, isAtLimit, selectionCount, isMultiFileMode]);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function useFileManagement() {
|
|
|
|
const { actions } = useFileActions();
|
|
|
|
return useMemo(() => ({
|
|
|
|
addFiles: actions.addFiles,
|
|
|
|
removeFiles: actions.removeFiles,
|
|
|
|
replaceFile: actions.replaceFile,
|
|
|
|
clearAllFiles: actions.clearAllFiles
|
|
|
|
}), []);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function useViewerActions() {
|
|
|
|
const { state } = useFileState();
|
|
|
|
const { actions } = useFileActions();
|
|
|
|
return useMemo(() => ({
|
|
|
|
viewerConfig: state.ui.viewerConfig,
|
|
|
|
updateViewerConfig: actions.updateViewerConfig
|
|
|
|
}), [state.ui.viewerConfig]);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function useCurrentFile() {
|
|
|
|
const { state } = useFileState();
|
|
|
|
const selectedFiles = useMemo(() =>
|
|
|
|
state.ui.selectedFileIds.map(id => state.files.byId[id]?.file).filter(Boolean),
|
|
|
|
[state.ui.selectedFileIds, state.files.byId]
|
|
|
|
);
|
|
|
|
const allFiles = useMemo(() =>
|
|
|
|
state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean),
|
|
|
|
[state.files.ids, state.files.byId]
|
|
|
|
);
|
|
|
|
|
|
|
|
// Get the current file record to access processed data
|
|
|
|
const currentRecord = useMemo(() => {
|
|
|
|
const fileId = state.ui.selectedFileIds[0] || state.files.ids[0];
|
|
|
|
return fileId ? state.files.byId[fileId] : undefined;
|
|
|
|
}, [state.ui.selectedFileIds, state.files.ids, state.files.byId]);
|
|
|
|
|
|
|
|
return useMemo(() => ({
|
|
|
|
file: selectedFiles[0] || allFiles[0],
|
|
|
|
processedFile: currentRecord?.processedFile // Now returns actual processed file
|
|
|
|
}), [selectedFiles, allFiles, currentRecord]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// New hook for accessing processed files - stable reference
|
|
|
|
export function useProcessedFiles() {
|
|
|
|
const { state } = useFileState();
|
|
|
|
const mapRef = useRef(new Map());
|
|
|
|
const lastStateRef = useRef<string>('');
|
|
|
|
|
|
|
|
// Create a stable hash of the processed files state
|
|
|
|
const currentStateHash = useMemo(() => {
|
|
|
|
return state.files.ids
|
|
|
|
.map(id => {
|
|
|
|
const record = state.files.byId[id];
|
|
|
|
return record?.processedFile ? `${id}:${record.processedFile.id}` : `${id}:null`;
|
|
|
|
})
|
|
|
|
.join('|');
|
|
|
|
}, [state.files.ids, state.files.byId]);
|
|
|
|
|
|
|
|
// Only recreate map when processed file state actually changes
|
|
|
|
if (currentStateHash !== lastStateRef.current) {
|
|
|
|
const processedMap = new Map();
|
|
|
|
state.files.ids.forEach(id => {
|
|
|
|
const record = state.files.byId[id];
|
|
|
|
if (record?.processedFile) {
|
|
|
|
processedMap.set(record.file, record.processedFile);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
mapRef.current = processedMap;
|
|
|
|
lastStateRef.current = currentStateHash;
|
|
|
|
}
|
|
|
|
|
|
|
|
return mapRef.current;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Hook to check if files are still being processed
|
|
|
|
export function useFileProcessingState() {
|
|
|
|
const { state } = useFileState();
|
|
|
|
|
|
|
|
return useMemo(() => {
|
|
|
|
let totalFiles = 0;
|
|
|
|
let processedFiles = 0;
|
|
|
|
let pendingFiles = 0;
|
|
|
|
|
|
|
|
state.files.ids.forEach(id => {
|
|
|
|
const record = state.files.byId[id];
|
|
|
|
if (record?.file.type === 'application/pdf') {
|
|
|
|
totalFiles++;
|
|
|
|
if (record.processedFile) {
|
|
|
|
processedFiles++;
|
|
|
|
} else {
|
|
|
|
pendingFiles++;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
totalFiles,
|
|
|
|
processedFiles,
|
|
|
|
pendingFiles,
|
|
|
|
isProcessing: pendingFiles > 0,
|
|
|
|
progress: totalFiles > 0 ? (processedFiles / totalFiles) * 100 : 0
|
|
|
|
};
|
|
|
|
}, [state.files.ids, state.files.byId]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Legacy combined hook (causes rerenders on any state change)
|
|
|
|
export function useFileContext(): FileContextValue {
|
|
|
|
const { state, selectors } = useFileState();
|
|
|
|
const { actions } = useFileActions();
|
|
|
|
const processedFilesMap = useProcessedFiles();
|
|
|
|
|
|
|
|
// Memoize the returned object to prevent infinite re-renders
|
|
|
|
return useMemo(() => ({
|
|
|
|
// Legacy state structure compatibility
|
|
|
|
activeFiles: state.files.ids
|
|
|
|
.map(id => state.files.byId[id])
|
|
|
|
.filter(record => record?.file)
|
|
|
|
.map(record => record!.file),
|
|
|
|
processedFiles: processedFilesMap, // Now provides actual processed files
|
|
|
|
currentMode: state.ui.currentMode,
|
|
|
|
currentView: state.ui.currentMode,
|
|
|
|
currentTool: null,
|
|
|
|
fileEditHistory: state.history.fileEditHistory,
|
|
|
|
globalFileOperations: state.history.globalFileOperations,
|
|
|
|
fileOperationHistory: state.history.fileOperationHistory,
|
|
|
|
selectedFileIds: state.ui.selectedFileIds,
|
|
|
|
selectedPageNumbers: state.ui.selectedPageNumbers,
|
|
|
|
viewerConfig: state.ui.viewerConfig,
|
|
|
|
isProcessing: state.ui.isProcessing,
|
|
|
|
processingProgress: state.ui.processingProgress,
|
|
|
|
lastExportConfig: state.ui.lastExportConfig,
|
|
|
|
hasUnsavedChanges: state.ui.hasUnsavedChanges,
|
|
|
|
pendingNavigation: state.ui.pendingNavigation,
|
|
|
|
showNavigationWarning: state.ui.showNavigationWarning,
|
|
|
|
|
|
|
|
// Actions
|
|
|
|
addFiles: actions.addFiles,
|
|
|
|
removeFiles: actions.removeFiles,
|
|
|
|
replaceFile: actions.replaceFile,
|
|
|
|
clearAllFiles: actions.clearAllFiles,
|
|
|
|
setCurrentMode: actions.setMode,
|
|
|
|
setCurrentView: actions.setMode,
|
|
|
|
setCurrentTool: () => {}, // Legacy compatibility
|
|
|
|
setSelectedFiles: actions.setSelectedFiles,
|
|
|
|
setSelectedPages: actions.setSelectedPages,
|
|
|
|
clearSelections: actions.clearSelections,
|
|
|
|
updateViewerConfig: actions.updateViewerConfig,
|
|
|
|
setExportConfig: actions.setExportConfig,
|
|
|
|
setHasUnsavedChanges: actions.setHasUnsavedChanges,
|
|
|
|
requestNavigation: actions.requestNavigation,
|
|
|
|
confirmNavigation: actions.confirmNavigation,
|
|
|
|
cancelNavigation: actions.cancelNavigation,
|
|
|
|
resetContext: actions.resetContext,
|
|
|
|
|
|
|
|
// Legacy operations compatibility
|
|
|
|
recordOperation: () => {}, // Placeholder
|
|
|
|
markOperationApplied: () => {}, // Placeholder
|
|
|
|
markOperationFailed: () => {}, // Placeholder
|
|
|
|
applyPageOperations: () => {}, // Placeholder
|
|
|
|
applyFileOperation: () => {}, // Placeholder
|
|
|
|
undoLastOperation: () => {}, // Placeholder
|
|
|
|
updateProcessedFile: () => {}, // Placeholder
|
|
|
|
getProcessedFileById: () => undefined, // Placeholder
|
|
|
|
getCurrentProcessedFile: () => undefined, // Placeholder
|
|
|
|
saveContext: async () => {}, // Placeholder
|
|
|
|
loadContext: async () => {}, // Placeholder
|
|
|
|
|
|
|
|
// Memory management placeholders
|
|
|
|
trackBlobUrl: () => {},
|
|
|
|
trackPdfDocument: () => {},
|
|
|
|
cleanupFile: async () => {},
|
|
|
|
scheduleCleanup: () => {},
|
|
|
|
|
|
|
|
// History placeholders
|
|
|
|
getFileHistory: () => undefined,
|
|
|
|
getAppliedOperations: () => [],
|
|
|
|
clearFileHistory: () => {},
|
|
|
|
|
|
|
|
// Selectors
|
|
|
|
getFileById: (id: string) => selectors.getFileById(id)?.file,
|
|
|
|
getCurrentFile: () => {
|
|
|
|
const selectedFiles = selectors.getSelectedFiles();
|
|
|
|
return selectedFiles[0]?.file || selectors.getAllFiles()[0]?.file;
|
|
|
|
}
|
|
|
|
}), [state, actions, selectors, processedFilesMap]);
|
2025-07-16 17:53:50 +01:00
|
|
|
}
|