mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
280 lines
9.2 KiB
TypeScript
280 lines
9.2 KiB
TypeScript
/**
|
|
* FileContext - Manages PDF files for Stirling PDF multi-tool workflow
|
|
*
|
|
* Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+).
|
|
* Users upload PDFs once and chain tools (split → merge → compress → view) without reloading.
|
|
*
|
|
* Key hooks:
|
|
* - useFileState() - access file state and UI state
|
|
* - useFileActions() - file operations (add/remove/update)
|
|
* - useToolFileSelection() - for tool components
|
|
*
|
|
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
|
|
*/
|
|
|
|
import React, { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
|
|
import {
|
|
FileContextProviderProps,
|
|
FileContextSelectors,
|
|
FileContextStateValue,
|
|
FileContextActionsValue,
|
|
FileContextActions,
|
|
FileId,
|
|
FileRecord
|
|
} from '../types/fileContext';
|
|
|
|
// Import modular components
|
|
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
|
import { createFileSelectors } from './file/fileSelectors';
|
|
import { addFiles, consumeFiles, createFileActions } from './file/fileActions';
|
|
import { FileLifecycleManager } from './file/lifecycle';
|
|
import { FileStateContext, FileActionsContext } from './file/contexts';
|
|
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
|
|
|
const DEBUG = process.env.NODE_ENV === 'development';
|
|
|
|
|
|
// Inner provider component that has access to IndexedDB
|
|
function FileContextInner({
|
|
children,
|
|
enableUrlSync = true,
|
|
enablePersistence = true
|
|
}: FileContextProviderProps) {
|
|
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
|
|
|
// IndexedDB context for persistence
|
|
const indexedDB = enablePersistence ? useIndexedDB() : null;
|
|
|
|
// File ref map - stores File objects outside React state
|
|
const filesRef = useRef<Map<FileId, File>>(new Map());
|
|
|
|
// Stable state reference for selectors
|
|
const stateRef = useRef(state);
|
|
stateRef.current = state;
|
|
|
|
// Create lifecycle manager
|
|
const lifecycleManagerRef = useRef<FileLifecycleManager | null>(null);
|
|
if (!lifecycleManagerRef.current) {
|
|
lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch);
|
|
}
|
|
const lifecycleManager = lifecycleManagerRef.current;
|
|
|
|
// Create stable selectors (memoized once to avoid re-renders)
|
|
const selectors = useMemo<FileContextSelectors>(() =>
|
|
createFileSelectors(stateRef, filesRef),
|
|
[] // Empty deps - selectors are stable
|
|
);
|
|
|
|
// Navigation management removed - moved to NavigationContext
|
|
|
|
// Navigation guard system functions
|
|
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
|
}, []);
|
|
|
|
// File operations using unified addFiles helper with persistence
|
|
const addRawFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
|
const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch, lifecycleManager);
|
|
|
|
// Persist to IndexedDB if enabled
|
|
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
|
|
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
|
|
try {
|
|
await indexedDB.saveFile(file, id, thumbnail);
|
|
} catch (error) {
|
|
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
|
}
|
|
}));
|
|
}
|
|
|
|
return addedFilesWithIds.map(({ file }) => file);
|
|
}, [indexedDB, enablePersistence]);
|
|
|
|
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
|
|
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
|
return result.map(({ file }) => file);
|
|
}, []);
|
|
|
|
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<File[]> => {
|
|
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
|
return result.map(({ file }) => file);
|
|
}, []);
|
|
|
|
// Action creators
|
|
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
|
|
|
// Helper functions for pinned files
|
|
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<void> => {
|
|
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
|
|
}, []);
|
|
|
|
// Helper to find FileId from File object
|
|
const findFileId = useCallback((file: File): FileId | undefined => {
|
|
return Object.keys(stateRef.current.files.byId).find(id => {
|
|
const storedFile = filesRef.current.get(id);
|
|
return storedFile &&
|
|
storedFile.name === file.name &&
|
|
storedFile.size === file.size &&
|
|
storedFile.lastModified === file.lastModified;
|
|
});
|
|
}, []);
|
|
|
|
// File-to-ID wrapper functions for pinning
|
|
const pinFileWrapper = useCallback((file: File) => {
|
|
const fileId = findFileId(file);
|
|
if (fileId) {
|
|
baseActions.pinFile(fileId);
|
|
} else {
|
|
console.warn('File not found for pinning:', file.name);
|
|
}
|
|
}, [baseActions, findFileId]);
|
|
|
|
const unpinFileWrapper = useCallback((file: File) => {
|
|
const fileId = findFileId(file);
|
|
if (fileId) {
|
|
baseActions.unpinFile(fileId);
|
|
} else {
|
|
console.warn('File not found for unpinning:', file.name);
|
|
}
|
|
}, [baseActions, findFileId]);
|
|
|
|
// Complete actions object
|
|
const actions = useMemo<FileContextActions>(() => ({
|
|
...baseActions,
|
|
addFiles: addRawFiles,
|
|
addProcessedFiles,
|
|
addStoredFiles,
|
|
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
|
// Remove from memory and cleanup resources
|
|
lifecycleManager.removeFiles(fileIds, stateRef);
|
|
|
|
// Remove from IndexedDB if enabled
|
|
if (indexedDB && enablePersistence && deleteFromStorage !== false) {
|
|
try {
|
|
await indexedDB.deleteMultiple(fileIds);
|
|
} catch (error) {
|
|
console.error('Failed to delete files from IndexedDB:', error);
|
|
}
|
|
}
|
|
},
|
|
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
|
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
|
reorderFiles: (orderedFileIds: FileId[]) => {
|
|
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
|
},
|
|
clearAllFiles: async () => {
|
|
lifecycleManager.cleanupAllFiles();
|
|
filesRef.current.clear();
|
|
dispatch({ type: 'RESET_CONTEXT' });
|
|
|
|
// Clear IndexedDB if enabled
|
|
if (indexedDB && enablePersistence) {
|
|
try {
|
|
await indexedDB.clearAll();
|
|
} catch (error) {
|
|
console.error('Failed to clear IndexedDB:', error);
|
|
}
|
|
}
|
|
},
|
|
// Pinned files functionality with File object wrappers
|
|
pinFile: pinFileWrapper,
|
|
unpinFile: unpinFileWrapper,
|
|
consumeFiles: consumeFilesWrapper,
|
|
setHasUnsavedChanges,
|
|
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
|
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
|
|
scheduleCleanup: (fileId: string, delay?: number) =>
|
|
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
|
|
}), [
|
|
baseActions,
|
|
addRawFiles,
|
|
addProcessedFiles,
|
|
addStoredFiles,
|
|
lifecycleManager,
|
|
setHasUnsavedChanges,
|
|
consumeFilesWrapper,
|
|
pinFileWrapper,
|
|
unpinFileWrapper,
|
|
indexedDB,
|
|
enablePersistence
|
|
]);
|
|
|
|
// Split context values to minimize re-renders
|
|
const stateValue = useMemo<FileContextStateValue>(() => ({
|
|
state,
|
|
selectors
|
|
}), [state, selectors]);
|
|
|
|
const actionsValue = useMemo<FileContextActionsValue>(() => ({
|
|
actions,
|
|
dispatch
|
|
}), [actions]);
|
|
|
|
// Persistence loading disabled - files only loaded on explicit user action
|
|
// useEffect(() => {
|
|
// if (!enablePersistence || !indexedDB) return;
|
|
// const loadFromPersistence = async () => { /* loading logic removed */ };
|
|
// loadFromPersistence();
|
|
// }, [enablePersistence, indexedDB]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (DEBUG) console.log('FileContext unmounting - cleaning up all resources');
|
|
lifecycleManager.destroy();
|
|
};
|
|
}, [lifecycleManager]);
|
|
|
|
return (
|
|
<FileStateContext.Provider value={stateValue}>
|
|
<FileActionsContext.Provider value={actionsValue}>
|
|
{children}
|
|
</FileActionsContext.Provider>
|
|
</FileStateContext.Provider>
|
|
);
|
|
}
|
|
|
|
// Outer provider component that wraps with IndexedDBProvider
|
|
export function FileContextProvider({
|
|
children,
|
|
enableUrlSync = true,
|
|
enablePersistence = true
|
|
}: FileContextProviderProps) {
|
|
if (enablePersistence) {
|
|
return (
|
|
<IndexedDBProvider>
|
|
<FileContextInner
|
|
enableUrlSync={enableUrlSync}
|
|
enablePersistence={enablePersistence}
|
|
>
|
|
{children}
|
|
</FileContextInner>
|
|
</IndexedDBProvider>
|
|
);
|
|
} else {
|
|
return (
|
|
<FileContextInner
|
|
enableUrlSync={enableUrlSync}
|
|
enablePersistence={enablePersistence}
|
|
>
|
|
{children}
|
|
</FileContextInner>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Export all hooks from the fileHooks module
|
|
export {
|
|
useFileState,
|
|
useFileActions,
|
|
useCurrentFile,
|
|
useFileSelection,
|
|
useFileManagement,
|
|
useFileUI,
|
|
useFileRecord,
|
|
useAllFiles,
|
|
useSelectedFiles,
|
|
// Primary API hooks for tools
|
|
useFileContext,
|
|
useToolFileSelection
|
|
} from './file/fileHooks'; |