feat: Introduce new file context management with hooks and lifecycle management

- Added new contexts for file state and actions to improve performance.
- Implemented unified file actions with a single `addFiles` helper for various file types.
- Created performant hooks for accessing file state and actions, including selection and management.
- Developed file selectors for efficient state access.
- Introduced a lifecycle manager for resource cleanup and memory management.
- Updated HomePage and tool components to utilize new navigation actions.
- Refactored file context types to streamline state management and remove legacy compatibility.
This commit is contained in:
Reece Browne 2025-08-18 21:00:19 +01:00
parent 1730402eff
commit 9b14609236
24 changed files with 1367 additions and 1007 deletions

View File

@ -10,7 +10,8 @@
"Bash(npm test)", "Bash(npm test)",
"Bash(npm test:*)", "Bash(npm test:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(npx tsc:*)" "Bash(npx tsc:*)",
"Bash(node:*)"
], ],
"deny": [], "deny": [],
"defaultMode": "acceptEdits" "defaultMode": "acceptEdits"

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext'; import { FileContextProvider } from './contexts/FileContext';
import { NavigationProvider } from './contexts/NavigationContext';
import { FilesModalProvider } from './contexts/FilesModalContext'; import { FilesModalProvider } from './contexts/FilesModalContext';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
@ -12,9 +13,11 @@ export default function App() {
return ( return (
<RainbowThemeProvider> <RainbowThemeProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}> <FileContextProvider enableUrlSync={true} enablePersistence={true}>
<FilesModalProvider> <NavigationProvider>
<HomePage /> <FilesModalProvider>
</FilesModalProvider> <HomePage />
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider> </FileContextProvider>
</RainbowThemeProvider> </RainbowThemeProvider>
); );

View File

@ -5,6 +5,7 @@ import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext'; import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState, useFileActions } from '../../contexts/FileContext'; import { useFileState, useFileActions } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import TopControls from '../shared/TopControls'; import TopControls from '../shared/TopControls';
import FileEditor from '../fileEditor/FileEditor'; import FileEditor from '../fileEditor/FileEditor';
@ -22,9 +23,10 @@ export default function Workbench() {
// Use context-based hooks to eliminate all prop drilling // Use context-based hooks to eliminate all prop drilling
const { state } = useFileState(); const { state } = useFileState();
const { actions } = useFileActions(); const { actions } = useFileActions();
const { currentMode: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setMode;
const activeFiles = state.files.ids; const activeFiles = state.files.ids;
const currentView = state.ui.currentMode;
const setCurrentView = actions.setCurrentMode;
const { const {
previewFile, previewFile,
pageEditorFunctions, pageEditorFunctions,
@ -51,7 +53,7 @@ export default function Workbench() {
handleToolSelect('convert'); handleToolSelect('convert');
sessionStorage.removeItem('previousMode'); sessionStorage.removeItem('previousMode');
} else { } else {
actions.setMode('fileEditor'); setCurrentView('fileEditor');
} }
}; };
@ -73,11 +75,11 @@ export default function Workbench() {
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolKey && { {...(!selectedToolKey && {
onOpenPageEditor: (file) => { onOpenPageEditor: (file) => {
actions.setMode("pageEditor"); setCurrentView("pageEditor");
}, },
onMergeFiles: (filesToMerge) => { onMergeFiles: (filesToMerge) => {
filesToMerge.forEach(addToActiveFiles); filesToMerge.forEach(addToActiveFiles);
actions.setMode("viewer"); setCurrentView("viewer");
} }
})} })}
/> />

View File

@ -65,7 +65,7 @@ const DragDropGrid = <T extends DragDropItem>({
}, [items.length, isLargeDocument, BUFFER_SIZE]); }, [items.length, isLargeDocument, BUFFER_SIZE]);
// Throttled scroll handler to prevent excessive re-renders // Throttled scroll handler to prevent excessive re-renders
const throttleRef = useRef<number>(); const throttleRef = useRef<number | undefined>(undefined);
// Detect scroll position from parent container // Detect scroll position from parent container
useEffect(() => { useEffect(() => {

View File

@ -6,7 +6,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { ModeType } from "../../types/fileContext"; import { ModeType } from "../../contexts/NavigationContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo"; import { useUndoRedo } from "../../hooks/useUndoRedo";

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core'; import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useFileState, useFileActions } from '../../contexts/FileContext'; import { useNavigationGuard } from '../../contexts/NavigationContext';
interface NavigationWarningModalProps { interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>; onApplyAndContinue?: () => Promise<void>;
@ -11,34 +11,37 @@ const NavigationWarningModal = ({
onApplyAndContinue, onApplyAndContinue,
onExportAndContinue onExportAndContinue
}: NavigationWarningModalProps) => { }: NavigationWarningModalProps) => {
const { state } = useFileState(); const {
const { actions } = useFileActions(); showNavigationWarning,
const showNavigationWarning = state.ui.showNavigationWarning; hasUnsavedChanges,
const hasUnsavedChanges = state.ui.hasUnsavedChanges; cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useNavigationGuard();
const handleKeepWorking = () => { const handleKeepWorking = () => {
actions.cancelNavigation(); cancelNavigation();
}; };
const handleDiscardChanges = () => { const handleDiscardChanges = () => {
actions.setHasUnsavedChanges(false); setHasUnsavedChanges(false);
actions.confirmNavigation(); confirmNavigation();
}; };
const handleApplyAndContinue = async () => { const handleApplyAndContinue = async () => {
if (onApplyAndContinue) { if (onApplyAndContinue) {
await onApplyAndContinue(); await onApplyAndContinue();
} }
actions.setHasUnsavedChanges(false); setHasUnsavedChanges(false);
actions.confirmNavigation(); confirmNavigation();
}; };
const handleExportAndContinue = async () => { const handleExportAndContinue = async () => {
if (onExportAndContinue) { if (onExportAndContinue) {
await onExportAndContinue(); await onExportAndContinue();
} }
actions.setHasUnsavedChanges(false); setHasUnsavedChanges(false);
actions.confirmNavigation(); confirmNavigation();
}; };
if (!hasUnsavedChanges) { if (!hasUnsavedChanges) {

View File

@ -10,7 +10,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote"; import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import { ModeType } from '../../types/fileContext'; import { ModeType } from '../../contexts/NavigationContext';
// Stable view option objects that don't recreate on every render // Stable view option objects that don't recreate on every render
const VIEW_OPTIONS_BASE = [ const VIEW_OPTIONS_BASE = [

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,218 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react';
/**
* NavigationContext - Complete navigation management system
*
* Handles navigation modes, navigation guards for unsaved changes,
* and breadcrumb/history navigation. Separated from FileContext to
* maintain clear separation of concerns.
*/
// Navigation mode types
export type ModeType =
| 'viewer'
| 'pageEditor'
| 'fileEditor'
| 'merge'
| 'split'
| 'compress'
| 'ocr'
| 'convert'
| 'addPassword'
| 'changePermissions'
| 'sanitize';
// Navigation state
interface NavigationState {
currentMode: ModeType;
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
}
// Navigation actions
type NavigationAction =
| { type: 'SET_MODE'; payload: { mode: ModeType } }
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
// Navigation reducer
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
switch (action.type) {
case 'SET_MODE':
return { ...state, currentMode: action.payload.mode };
case 'SET_UNSAVED_CHANGES':
return { ...state, hasUnsavedChanges: action.payload.hasChanges };
case 'SET_PENDING_NAVIGATION':
return { ...state, pendingNavigation: action.payload.navigationFn };
case 'SHOW_NAVIGATION_WARNING':
return { ...state, showNavigationWarning: action.payload.show };
default:
return state;
}
};
// Initial state
const initialState: NavigationState = {
currentMode: 'pageEditor',
hasUnsavedChanges: false,
pendingNavigation: null,
showNavigationWarning: false
};
// Navigation context actions interface
export interface NavigationContextActions {
setMode: (mode: ModeType) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
showNavigationWarning: (show: boolean) => void;
requestNavigation: (navigationFn: () => void) => void;
confirmNavigation: () => void;
cancelNavigation: () => void;
}
// Split context values
export interface NavigationContextStateValue {
currentMode: ModeType;
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
}
export interface NavigationContextActionsValue {
actions: NavigationContextActions;
}
// Create contexts
const NavigationStateContext = createContext<NavigationContextStateValue | undefined>(undefined);
const NavigationActionsContext = createContext<NavigationContextActionsValue | undefined>(undefined);
// Provider component
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(navigationReducer, initialState);
const actions: NavigationContextActions = {
setMode: useCallback((mode: ModeType) => {
dispatch({ type: 'SET_MODE', payload: { mode } });
}, []),
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []),
showNavigationWarning: useCallback((show: boolean) => {
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
}, []),
requestNavigation: useCallback((navigationFn: () => void) => {
// If no unsaved changes, navigate immediately
if (!state.hasUnsavedChanges) {
navigationFn();
return;
}
// Otherwise, store the navigation and show warning
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
}, [state.hasUnsavedChanges]),
confirmNavigation: useCallback(() => {
// Execute pending navigation
if (state.pendingNavigation) {
state.pendingNavigation();
}
// Clear navigation state
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, [state.pendingNavigation]),
cancelNavigation: useCallback(() => {
// Clear navigation without executing
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, [])
};
const stateValue: NavigationContextStateValue = {
currentMode: state.currentMode,
hasUnsavedChanges: state.hasUnsavedChanges,
pendingNavigation: state.pendingNavigation,
showNavigationWarning: state.showNavigationWarning
};
const actionsValue: NavigationContextActionsValue = {
actions
};
return (
<NavigationStateContext.Provider value={stateValue}>
<NavigationActionsContext.Provider value={actionsValue}>
{children}
</NavigationActionsContext.Provider>
</NavigationStateContext.Provider>
);
};
// Navigation hooks
export const useNavigationState = () => {
const context = useContext(NavigationStateContext);
if (context === undefined) {
throw new Error('useNavigationState must be used within NavigationProvider');
}
return context;
};
export const useNavigationActions = () => {
const context = useContext(NavigationActionsContext);
if (context === undefined) {
throw new Error('useNavigationActions must be used within NavigationProvider');
}
return context;
};
// Combined hook for convenience
export const useNavigation = () => {
const state = useNavigationState();
const { actions } = useNavigationActions();
return { ...state, ...actions };
};
// Navigation guard hook (equivalent to old useFileNavigation)
export const useNavigationGuard = () => {
const state = useNavigationState();
const { actions } = useNavigationActions();
return {
pendingNavigation: state.pendingNavigation,
showNavigationWarning: state.showNavigationWarning,
hasUnsavedChanges: state.hasUnsavedChanges,
requestNavigation: actions.requestNavigation,
confirmNavigation: actions.confirmNavigation,
cancelNavigation: actions.cancelNavigation,
setHasUnsavedChanges: actions.setHasUnsavedChanges,
setShowNavigationWarning: actions.showNavigationWarning
};
};
// Utility functions for mode handling
export const isValidMode = (mode: string): mode is ModeType => {
const validModes: ModeType[] = [
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
];
return validModes.includes(mode as ModeType);
};
export const getDefaultMode = (): ModeType => 'pageEditor';
// TODO: This will be expanded for URL-based routing system
// - URL parsing utilities
// - Route definitions
// - Navigation hooks with URL sync
// - History management
// - Breadcrumb restoration from URL params

View File

@ -0,0 +1,164 @@
/**
* FileContext reducer - Pure state management for file operations
*/
import {
FileContextState,
FileContextAction,
FileId,
FileRecord
} from '../../types/fileContext';
// Initial state
export const initialFileContextState: FileContextState = {
files: {
ids: [],
byId: {}
},
ui: {
selectedFileIds: [],
selectedPageNumbers: [],
isProcessing: false,
processingProgress: 0,
hasUnsavedChanges: false
}
};
// Pure reducer function
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) {
case 'ADD_FILES': {
const { fileRecords } = action.payload;
const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
fileRecords.forEach(record => {
// Only add if not already present (dedupe by stable ID)
if (!newById[record.id]) {
newIds.push(record.id);
newById[record.id] = record;
}
});
return {
...state,
files: {
ids: [...state.files.ids, ...newIds],
byId: newById
}
};
}
case 'REMOVE_FILES': {
const { fileIds } = action.payload;
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
const newById = { ...state.files.byId };
// Remove files from state (resource cleanup handled by lifecycle manager)
fileIds.forEach(id => {
delete newById[id];
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
return {
...state,
files: {
ids: remainingIds,
byId: newById
},
ui: {
...state.ui,
selectedFileIds: validSelectedFileIds
}
};
}
case 'UPDATE_FILE_RECORD': {
const { id, updates } = action.payload;
const existingRecord = state.files.byId[id];
if (!existingRecord) {
return state; // File doesn't exist, no-op
}
return {
...state,
files: {
...state.files,
byId: {
...state.files.byId,
[id]: {
...existingRecord,
...updates
}
}
}
};
}
case 'SET_SELECTED_FILES': {
const { fileIds } = action.payload;
return {
...state,
ui: {
...state.ui,
selectedFileIds: fileIds
}
};
}
case 'SET_SELECTED_PAGES': {
const { pageNumbers } = action.payload;
return {
...state,
ui: {
...state.ui,
selectedPageNumbers: pageNumbers
}
};
}
case 'CLEAR_SELECTIONS': {
return {
...state,
ui: {
...state.ui,
selectedFileIds: [],
selectedPageNumbers: []
}
};
}
case 'SET_PROCESSING': {
const { isProcessing, progress } = action.payload;
return {
...state,
ui: {
...state.ui,
isProcessing,
processingProgress: progress
}
};
}
case 'SET_UNSAVED_CHANGES': {
return {
...state,
ui: {
...state.ui,
hasUnsavedChanges: action.payload.hasChanges
}
};
}
case 'RESET_CONTEXT': {
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
return { ...initialFileContextState };
}
default:
return state;
}
}

View File

@ -0,0 +1,13 @@
/**
* React contexts for file state and actions
*/
import { createContext } from 'react';
import { FileContextStateValue, FileContextActionsValue } from '../../types/fileContext';
// Split contexts for performance
export const FileStateContext = createContext<FileContextStateValue | undefined>(undefined);
export const FileActionsContext = createContext<FileContextActionsValue | undefined>(undefined);
// Export types for use in hooks
export type { FileContextStateValue, FileContextActionsValue };

View File

@ -0,0 +1,225 @@
/**
* File actions - Unified file operations with single addFiles helper
*/
import {
FileId,
FileRecord,
FileContextAction,
FileContextState,
toFileRecord,
createFileId,
createQuickKey
} from '../../types/fileContext';
import { FileMetadata } from '../../types/file';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { fileProcessingService } from '../../services/fileProcessingService';
import { buildQuickKeySet } from './fileSelectors';
const DEBUG = process.env.NODE_ENV === 'development';
/**
* Helper to create ProcessedFile metadata structure
*/
export function createProcessedFile(pageCount: number, thumbnail?: string) {
return {
totalPages: pageCount,
pages: Array.from({ length: pageCount }, (_, index) => ({
pageNumber: index + 1,
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
rotation: 0,
splitBefore: false
})),
thumbnailUrl: thumbnail,
lastProcessed: Date.now()
};
}
/**
* File addition types
*/
type AddFileKind = 'raw' | 'processed' | 'stored';
interface AddFileOptions {
// For 'raw' files
files?: File[];
// For 'processed' files
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
// For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
}
/**
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
*/
export async function addFiles(
kind: AddFileKind,
options: AddFileOptions,
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>
): Promise<File[]> {
const fileRecords: FileRecord[] = [];
const addedFiles: File[] = [];
// Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
switch (kind) {
case 'raw': {
const { files = [] } = options;
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
for (const file of files) {
const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (already exists)`);
continue;
}
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count immediately
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 Generating immediate thumbnail and metadata for ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
if (DEBUG) console.log(`📄 Generated immediate metadata for ${file.name}: ${pageCount} pages, thumbnail: ${!!thumbnail}`);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate immediate metadata for ${file.name}:`, error);
}
// Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
// Create initial processedFile metadata with page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push(file);
// Start background processing for validation only (we already have thumbnail and page count)
fileProcessingService.processFile(file, fileId).then(result => {
// Only update if file still exists in context
if (filesRef.current.has(fileId)) {
if (result.success && result.metadata) {
// Only log if page count differs from our immediate calculation
const initialPageCount = pageCount;
if (result.metadata.totalPages !== initialPageCount) {
if (DEBUG) console.log(`📄 Background processing found different page count for ${file.name}: ${result.metadata.totalPages} vs immediate ${initialPageCount}`);
}
}
}
});
}
break;
}
case 'processed': {
const { filesWithThumbnails = [] } = options;
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
continue;
}
const fileId = createFileId();
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
// Create processedFile with provided metadata
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push(file);
}
break;
}
case 'stored': {
const { filesWithMetadata = [] } = options;
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
for (const { file, originalId, metadata } of filesWithMetadata) {
const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name}`);
continue;
}
// Try to preserve original ID, but generate new if it conflicts
let fileId = originalId;
if (filesRef.current.has(originalId)) {
fileId = createFileId();
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
}
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
// Restore metadata from storage
if (metadata.thumbnail) {
record.thumbnailUrl = metadata.thumbnail;
}
// Note: For stored files, processedFile will be restored from FileRecord if it exists
// The metadata here is just basic file info, not processed file data
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push(file);
}
break;
}
}
// Dispatch ADD_FILES action if we have new files
if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
}
return addedFiles;
}
/**
* Action factory functions
*/
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
});

View File

@ -0,0 +1,228 @@
/**
* New performant file hooks - Clean API without legacy compatibility
*/
import { useContext, useMemo } from 'react';
import {
FileStateContext,
FileActionsContext,
FileContextStateValue,
FileContextActionsValue
} from './contexts';
import { FileId, FileRecord } from '../../types/fileContext';
/**
* Hook for accessing file state (will re-render on any state change)
* Use individual selector hooks below for better performance
*/
export function useFileState(): FileContextStateValue {
const context = useContext(FileStateContext);
if (!context) {
throw new Error('useFileState must be used within a FileContextProvider');
}
return context;
}
/**
* Hook for accessing file actions (stable - won't cause re-renders)
*/
export function useFileActions(): FileContextActionsValue {
const context = useContext(FileActionsContext);
if (!context) {
throw new Error('useFileActions must be used within a FileContextProvider');
}
return context;
}
/**
* Hook for current/primary file (first in list)
*/
export function useCurrentFile(): { file?: File; record?: FileRecord } {
const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0];
return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
}), [primaryFileId, selectors]);
}
/**
* Hook for file selection state and actions
*/
export function useFileSelection() {
const { state } = useFileState();
const { actions } = useFileActions();
return useMemo(() => ({
selectedFileIds: state.ui.selectedFileIds,
selectedPageNumbers: state.ui.selectedPageNumbers,
setSelectedFiles: actions.setSelectedFiles,
setSelectedPages: actions.setSelectedPages,
clearSelections: actions.clearSelections
}), [
state.ui.selectedFileIds,
state.ui.selectedPageNumbers,
actions.setSelectedFiles,
actions.setSelectedPages,
actions.clearSelections
]);
}
/**
* Hook for file management operations
*/
export function useFileManagement() {
const { actions } = useFileActions();
return useMemo(() => ({
addFiles: actions.addFiles,
removeFiles: actions.removeFiles,
clearAllFiles: actions.clearAllFiles,
updateFileRecord: actions.updateFileRecord
}), [actions]);
}
/**
* Hook for UI state
*/
export function useFileUI() {
const { state } = useFileState();
const { actions } = useFileActions();
return useMemo(() => ({
isProcessing: state.ui.isProcessing,
processingProgress: state.ui.processingProgress,
hasUnsavedChanges: state.ui.hasUnsavedChanges,
setProcessing: actions.setProcessing,
setUnsavedChanges: actions.setHasUnsavedChanges
}), [state.ui, actions]);
}
/**
* Hook for specific file by ID (optimized for individual file access)
*/
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
const { selectors } = useFileState();
return useMemo(() => ({
file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId)
}), [fileId, selectors]);
}
/**
* Hook for all files (use sparingly - causes re-renders on file list changes)
*/
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getFiles(),
records: selectors.getFileRecords(),
fileIds: state.files.ids
}), [state.files.ids, selectors]);
}
/**
* Hook for selected files (optimized for selection-based UI)
*/
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(),
fileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}
// Navigation management removed - moved to NavigationContext
/**
* Primary API hook for file context operations
* Used by tools for core file context functionality
*/
export function useFileContext() {
const { actions } = useFileActions();
return useMemo(() => ({
trackBlobUrl: actions.trackBlobUrl,
trackPdfDocument: actions.trackPdfDocument,
scheduleCleanup: actions.scheduleCleanup,
setUnsavedChanges: actions.setHasUnsavedChanges
}), [actions]);
}
/**
* Primary API hook for tool file selection workflow
* Used by tools for managing file selection and tool-specific operations
*/
export function useToolFileSelection() {
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Memoize selected files to avoid recreating arrays
const selectedFiles = useMemo(() => {
return selectors.getSelectedFiles();
}, [state.ui.selectedFileIds, selectors]);
return useMemo(() => ({
selectedFiles,
selectedFileIds: state.ui.selectedFileIds,
selectedPageNumbers: state.ui.selectedPageNumbers,
setSelectedFiles: actions.setSelectedFiles,
setSelectedPages: actions.setSelectedPages,
clearSelections: actions.clearSelections,
// Tool workflow properties
maxFiles: 10, // Default value for tools
isToolMode: true,
setMaxFiles: (maxFiles: number) => { /* Tool-specific - can be implemented if needed */ },
setIsToolMode: (isToolMode: boolean) => { /* Tool-specific - can be implemented if needed */ }
}), [
selectedFiles,
state.ui.selectedFileIds,
state.ui.selectedPageNumbers,
actions.setSelectedFiles,
actions.setSelectedPages,
actions.clearSelections
]);
}
/**
* Hook for processed files (compatibility with old FileContext)
* Provides access to files with their processed metadata
*/
export function useProcessedFiles() {
const { state, selectors } = useFileState();
// Create a Map-like interface for backward compatibility
const compatibilityMap = useMemo(() => ({
size: state.files.ids.length,
get: (file: File) => {
// Find file record by matching File object properties
const record = Object.values(state.files.byId).find(r =>
r.name === file.name && r.size === file.size && r.lastModified === file.lastModified
);
return record?.processedFile || null;
},
has: (file: File) => {
// Find file record by matching File object properties
const record = Object.values(state.files.byId).find(r =>
r.name === file.name && r.size === file.size && r.lastModified === file.lastModified
);
return !!record?.processedFile;
},
set: () => {
console.warn('processedFiles.set is deprecated - use FileRecord updates instead');
}
}), [state.files.byId, state.files.ids.length]);
return useMemo(() => ({
processedFiles: compatibilityMap,
getProcessedFile: (file: File) => compatibilityMap.get(file),
updateProcessedFile: () => {
console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord');
}
}), [compatibilityMap]);
}

View File

@ -0,0 +1,86 @@
/**
* File selectors - Pure functions for accessing file state
*/
import {
FileId,
FileRecord,
FileContextState,
FileContextSelectors
} from '../../types/fileContext';
/**
* Create stable selectors using stateRef and filesRef
*/
export function createFileSelectors(
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>
): FileContextSelectors {
return {
getFile: (id: FileId) => filesRef.current.get(id),
getFiles: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
},
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
getFileRecords: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
},
getAllFileIds: () => stateRef.current.files.ids,
getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
},
getSelectedFileRecords: () => {
return stateRef.current.ui.selectedFileIds
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
// Stable signature for effects - prevents unnecessary re-renders
getFilesSignature: () => {
return stateRef.current.files.ids
.map(id => {
const record = stateRef.current.files.byId[id];
return record ? `${id}:${record.size}:${record.lastModified}` : '';
})
.join('|');
},
};
}
/**
* Helper for building quickKey sets for deduplication
*/
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
const quickKeys = new Set<string>();
Object.values(fileRecords).forEach(record => {
quickKeys.add(record.quickKey);
});
return quickKeys;
}
/**
* Get primary file (first in list) - commonly used pattern
*/
export function getPrimaryFile(
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>
): { file?: File; record?: FileRecord } {
const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {};
return {
file: filesRef.current.get(primaryFileId),
record: stateRef.current.files.byId[primaryFileId]
};
}

View File

@ -0,0 +1,257 @@
/**
* File lifecycle management - Resource cleanup and memory management
*/
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development';
/**
* Resource tracking and cleanup utilities
*/
export class FileLifecycleManager {
private cleanupTimers = new Map<string, number>();
private blobUrls = new Set<string>();
private pdfDocuments = new Map<string, any>();
private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup
constructor(
private filesRef: React.MutableRefObject<Map<FileId, File>>,
private dispatch: React.Dispatch<FileContextAction>
) {}
/**
* Track blob URLs for cleanup
*/
trackBlobUrl = (url: string): void => {
// Only track actual blob URLs to avoid trying to revoke other schemes
if (url.startsWith('blob:')) {
this.blobUrls.add(url);
if (DEBUG) console.log(`🗂️ Tracking blob URL: ${url.substring(0, 50)}...`);
} else {
if (DEBUG) console.warn(`🗂️ Attempted to track non-blob URL: ${url.substring(0, 50)}...`);
}
};
/**
* Track PDF documents for cleanup
*/
trackPdfDocument = (key: string, pdfDoc: any): void => {
// Clean up existing PDF document if present
const existing = this.pdfDocuments.get(key);
if (existing && typeof existing.destroy === 'function') {
try {
existing.destroy();
if (DEBUG) console.log(`🗂️ Destroyed existing PDF document for key: ${key}`);
} catch (error) {
if (DEBUG) console.warn('Error destroying existing PDF document:', error);
}
}
this.pdfDocuments.set(key, pdfDoc);
if (DEBUG) console.log(`🗂️ Tracking PDF document for key: ${key}`);
};
/**
* Clean up resources for a specific file (with stateRef access for complete cleanup)
*/
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => {
if (DEBUG) console.log(`🗂️ Cleaning up resources for file: ${fileId}`);
// Use comprehensive cleanup (same as removeFiles)
this.cleanupAllResourcesForFile(fileId, stateRef);
// Remove file from state
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
};
/**
* Clean up all files and resources
*/
cleanupAllFiles = (): void => {
if (DEBUG) console.log('🗂️ Cleaning up all files and resources');
// Clean up all PDF documents
this.pdfDocuments.forEach((pdfDoc, key) => {
if (pdfDoc && typeof pdfDoc.destroy === 'function') {
try {
pdfDoc.destroy();
if (DEBUG) console.log(`🗂️ Destroyed PDF document for key: ${key}`);
} catch (error) {
if (DEBUG) console.warn(`Error destroying PDF document for key ${key}:`, error);
}
}
});
this.pdfDocuments.clear();
// Revoke all blob URLs
this.blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
if (DEBUG) console.log(`🗂️ Revoked blob URL: ${url.substring(0, 50)}...`);
} catch (error) {
if (DEBUG) console.warn('Error revoking blob URL:', error);
}
});
this.blobUrls.clear();
// Clear all cleanup timers and generations
this.cleanupTimers.forEach(timer => clearTimeout(timer));
this.cleanupTimers.clear();
this.fileGenerations.clear();
// Clear files ref
this.filesRef.current.clear();
if (DEBUG) console.log('🗂️ All resources cleaned up');
};
/**
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
*/
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
// Cancel existing timer
const existingTimer = this.cleanupTimers.get(fileId);
if (existingTimer) {
clearTimeout(existingTimer);
this.cleanupTimers.delete(fileId);
}
// If delay is negative, just cancel (don't reschedule)
if (delay < 0) {
return;
}
// Increment generation for this file to invalidate any pending cleanup
const currentGen = (this.fileGenerations.get(fileId) || 0) + 1;
this.fileGenerations.set(fileId, currentGen);
// Schedule new cleanup with generation token
const timer = window.setTimeout(() => {
// Check if this cleanup is still valid (file hasn't been re-added)
if (this.fileGenerations.get(fileId) === currentGen) {
this.cleanupFile(fileId, stateRef);
} else {
if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`);
}
}, delay);
this.cleanupTimers.set(fileId, timer);
if (DEBUG) console.log(`🗂️ Scheduled cleanup for file ${fileId} in ${delay}ms (gen ${currentGen})`);
};
/**
* Remove a file immediately with complete resource cleanup
*/
removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject<any>): void => {
if (DEBUG) console.log(`🗂️ Removing ${fileIds.length} files immediately`);
fileIds.forEach(fileId => {
// Clean up all resources for this file
this.cleanupAllResourcesForFile(fileId, stateRef);
});
// Dispatch removal action once for all files (reducer only updates state)
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
};
/**
* Complete resource cleanup for a single file
*/
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Remove from files ref
this.filesRef.current.delete(fileId);
// Clean up PDF documents (scan all keys that start with fileId)
const keysToDelete: string[] = [];
this.pdfDocuments.forEach((pdfDoc, key) => {
if (key === fileId || key.startsWith(`${fileId}:`)) {
if (pdfDoc && typeof pdfDoc.destroy === 'function') {
try {
pdfDoc.destroy();
keysToDelete.push(key);
if (DEBUG) console.log(`🗂️ Destroyed PDF document for key: ${key}`);
} catch (error) {
if (DEBUG) console.warn(`Error destroying PDF document for key ${key}:`, error);
}
}
}
});
keysToDelete.forEach(key => this.pdfDocuments.delete(key));
// Cancel cleanup timer and generation
const timer = this.cleanupTimers.get(fileId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(fileId);
if (DEBUG) console.log(`🗂️ Cancelled cleanup timer for file: ${fileId}`);
}
this.fileGenerations.delete(fileId);
// Clean up blob URLs from file record if we have access to state
if (stateRef) {
const record = stateRef.current.files.byId[fileId];
if (record) {
// Revoke blob URLs from file record
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.thumbnailUrl);
if (DEBUG) console.log(`🗂️ Revoked thumbnail blob URL for file: ${fileId}`);
} catch (error) {
if (DEBUG) console.warn('Error revoking thumbnail URL:', error);
}
}
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.blobUrl);
if (DEBUG) console.log(`🗂️ Revoked file blob URL for file: ${fileId}`);
} catch (error) {
if (DEBUG) console.warn('Error revoking file URL:', error);
}
}
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
try {
URL.revokeObjectURL(page.thumbnail);
if (DEBUG) console.log(`🗂️ Revoked page ${index} thumbnail for file: ${fileId}`);
} catch (error) {
if (DEBUG) console.warn('Error revoking page thumbnail URL:', error);
}
}
});
}
}
}
};
/**
* Update file record with race condition guards
*/
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
// Guard against updating removed files (race condition protection)
if (!this.filesRef.current.has(fileId)) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
return;
}
// Additional state guard for rare race conditions
if (stateRef && !stateRef.current.files.byId[fileId]) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
return;
}
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
};
/**
* Cleanup on unmount
*/
destroy = (): void => {
if (DEBUG) console.log('🗂️ FileLifecycleManager destroying - cleaning up all resources');
this.cleanupAllFiles();
};
}

View File

@ -1,6 +1,7 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileActions, useToolFileSelection } from "../contexts/FileContext"; import { useFileActions, useToolFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext"; import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
@ -65,10 +66,15 @@ function HomePageContent() {
} }
function HomePageWithProviders() { function HomePageWithProviders() {
const { actions } = useFileActions(); const { actions } = useNavigationActions();
// Wrapper to convert string to ModeType
const handleViewChange = (view: string) => {
actions.setMode(view as any); // ToolWorkflowContext should validate this
};
return ( return (
<ToolWorkflowProvider onViewChange={actions.setMode as any /* FIX ME */}> <ToolWorkflowProvider onViewChange={handleViewChange}>
<SidebarProvider> <SidebarProvider>
<HomePageContent /> <HomePageContent />
</SidebarProvider> </SidebarProvider>

View File

@ -3,7 +3,7 @@ import { Box, Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext, useToolFileSelection } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
@ -22,8 +22,8 @@ import { BaseToolProps } from "../types/tool";
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
// const setCurrentMode = (mode) => console.log('Navigate to:', mode); // TODO: Hook up to URL routing
const [collapsedPermissions, setCollapsedPermissions] = useState(true); const [collapsedPermissions, setCollapsedPermissions] = useState(true);
@ -58,14 +58,11 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleThumbnailClick = (file: File) => { const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'addPassword');
setCurrentMode('viewer');
}; };
const handleSettingsReset = () => { const handleSettingsReset = () => {
addPasswordOperation.resetResults(); addPasswordOperation.resetResults();
onPreviewFile?.(null); onPreviewFile?.(null);
setCurrentMode('addPassword');
}; };
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;

View File

@ -3,7 +3,8 @@ import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext, useToolFileSelection } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
@ -20,7 +21,8 @@ import { BaseToolProps } from "../types/tool";
const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setCurrentMode } = useFileContext(); const { actions } = useNavigationActions();
const setCurrentMode = actions.setMode;
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
const changePermissionsParams = useChangePermissionsParameters(); const changePermissionsParams = useChangePermissionsParameters();

View File

@ -3,8 +3,8 @@ import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileActions } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
@ -21,8 +21,8 @@ import { useCompressTips } from "../components/tooltips/useCompressTips";
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useFileActions(); const { actions } = useNavigationActions();
const setCurrentMode = actions.setCurrentMode; const setCurrentMode = actions.setMode;
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
const compressParams = useCompressParameters(); const compressParams = useCompressParameters();

View File

@ -3,8 +3,9 @@ import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileActions, useFileState } from "../contexts/FileContext"; import { useFileState } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
@ -20,9 +21,9 @@ import { BaseToolProps } from "../types/tool";
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useFileActions();
const { selectors } = useFileState(); const { selectors } = useFileState();
const setCurrentMode = actions.setCurrentMode; const { actions } = useNavigationActions();
const setCurrentMode = actions.setMode;
const activeFiles = selectors.getFiles(); const activeFiles = selectors.getFiles();
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);

View File

@ -3,8 +3,8 @@ import { Button, Stack, Text, Box } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileActions } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
@ -22,8 +22,8 @@ import { useOCRTips } from "../components/tooltips/useOCRTips";
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useFileActions(); const { actions } = useNavigationActions();
const setCurrentMode = actions.setCurrentMode; const setCurrentMode = actions.setMode;
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
const ocrParams = useOCRParameters(); const ocrParams = useOCRParameters();

View File

@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useToolFileSelection } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
@ -15,13 +16,13 @@ import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters"; import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation"; import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
import { BaseToolProps } from "../types/tool"; import { BaseToolProps } from "../types/tool";
import { useFileContext } from "../contexts/FileContext";
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
const { setCurrentMode } = useFileContext(); const { actions } = useNavigationActions();
const setCurrentMode = actions.setMode;
const sanitizeParams = useSanitizeParameters(); const sanitizeParams = useSanitizeParameters();
const sanitizeOperation = useSanitizeOperation(); const sanitizeOperation = useSanitizeOperation();

View File

@ -3,8 +3,8 @@ import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileActions } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
@ -19,8 +19,8 @@ import { BaseToolProps } from "../types/tool";
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useFileActions(); const { actions } = useNavigationActions();
const setCurrentMode = actions.setCurrentMode; const setCurrentMode = actions.setMode;
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
const splitParams = useSplitParameters(); const splitParams = useSplitParameters();

View File

@ -6,11 +6,26 @@ import { ProcessedFile } from './processing';
import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
import { FileMetadata } from './file'; import { FileMetadata } from './file';
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'convert';
// Normalized state types // Normalized state types
export type FileId = string; export type FileId = string;
export interface ProcessedFilePage {
thumbnail?: string;
pageNumber?: number;
rotation?: number;
splitBefore?: boolean;
[key: string]: any;
}
export interface ProcessedFileMetadata {
pages: ProcessedFilePage[];
totalPages?: number;
thumbnailUrl?: string;
lastProcessed?: number;
[key: string]: any;
}
export interface FileRecord { export interface FileRecord {
id: FileId; id: FileId;
name: string; name: string;
@ -21,13 +36,7 @@ export interface FileRecord {
thumbnailUrl?: string; thumbnailUrl?: string;
blobUrl?: string; blobUrl?: string;
createdAt: number; createdAt: number;
processedFile?: { processedFile?: ProcessedFileMetadata;
pages: Array<{
thumbnail?: string;
[key: string]: any;
}>;
[key: string]: any;
};
// Note: File object stored in provider ref, not in state // Note: File object stored in provider ref, not in state
} }
@ -153,16 +162,13 @@ export interface FileContextState {
byId: Record<FileId, FileRecord>; byId: Record<FileId, FileRecord>;
}; };
// UI state - flat structure for performance // UI state - file-related UI state only
ui: { ui: {
currentMode: ModeType;
selectedFileIds: FileId[]; selectedFileIds: FileId[];
selectedPageNumbers: number[]; selectedPageNumbers: number[];
isProcessing: boolean; isProcessing: boolean;
processingProgress: number; processingProgress: number;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
}; };
} }
@ -174,16 +180,13 @@ export type FileContextAction =
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } } | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
// UI actions // UI actions
| { type: 'SET_CURRENT_MODE'; payload: ModeType }
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } } | { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
| { type: 'CLEAR_SELECTIONS' } | { type: 'CLEAR_SELECTIONS' }
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
// Navigation guard actions // Navigation guard actions (minimal for file-related unsaved changes only)
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
// Context management // Context management
| { type: 'RESET_CONTEXT' }; | { type: 'RESET_CONTEXT' };
@ -198,9 +201,6 @@ export interface FileContextActions {
clearAllFiles: () => void; clearAllFiles: () => void;
// Navigation
setCurrentMode: (mode: ModeType) => void;
// Selection management // Selection management
setSelectedFiles: (fileIds: FileId[]) => void; setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void; setSelectedPages: (pageNumbers: number[]) => void;
@ -209,16 +209,17 @@ export interface FileContextActions {
// Processing state - simple flags only // Processing state - simple flags only
setProcessing: (isProcessing: boolean, progress?: number) => void; setProcessing: (isProcessing: boolean, progress?: number) => void;
// Navigation guard system // File-related unsaved changes (minimal navigation guard support)
setHasUnsavedChanges: (hasChanges: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void;
// Context management // Context management
resetContext: () => void; resetContext: () => void;
// Legacy compatibility // Resource management
setMode: (mode: ModeType) => void; trackBlobUrl: (url: string) => void;
confirmNavigation: () => void; trackPdfDocument: (key: string, pdfDoc: any) => void;
cancelNavigation: () => void; scheduleCleanup: (fileId: string, delay?: number) => void;
cleanupFile: (fileId: string) => void;
} }
// File selectors (separate from actions to avoid re-renders) // File selectors (separate from actions to avoid re-renders)
@ -258,12 +259,5 @@ export interface FileContextActionsValue {
dispatch: (action: FileContextAction) => void; dispatch: (action: FileContextAction) => void;
} }
// URL parameter types for deep linking // TODO: URL parameter types will be redesigned for new routing system
export interface FileContextUrlParams {
mode?: ModeType;
fileIds?: string[];
pageIds?: string[];
zoom?: number;
page?: number;
}