mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
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:
parent
1730402eff
commit
9b14609236
@ -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"
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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";
|
||||||
|
@ -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) {
|
||||||
|
@ -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
218
frontend/src/contexts/NavigationContext.tsx
Normal file
218
frontend/src/contexts/NavigationContext.tsx
Normal 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
|
164
frontend/src/contexts/file/FileReducer.ts
Normal file
164
frontend/src/contexts/file/FileReducer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
13
frontend/src/contexts/file/contexts.ts
Normal file
13
frontend/src/contexts/file/contexts.ts
Normal 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 };
|
225
frontend/src/contexts/file/fileActions.ts
Normal file
225
frontend/src/contexts/file/fileActions.ts
Normal 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' })
|
||||||
|
});
|
228
frontend/src/contexts/file/fileHooks.ts
Normal file
228
frontend/src/contexts/file/fileHooks.ts
Normal 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]);
|
||||||
|
}
|
86
frontend/src/contexts/file/fileSelectors.ts
Normal file
86
frontend/src/contexts/file/fileSelectors.ts
Normal 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]
|
||||||
|
};
|
||||||
|
}
|
257
frontend/src/contexts/file/lifecycle.ts
Normal file
257
frontend/src/contexts/file/lifecycle.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
||||||
|
@ -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();
|
||||||
|
@ -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);
|
||||||
|
@ -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();
|
||||||
|
@ -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();
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,17 +179,14 @@ export type FileContextAction =
|
|||||||
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
||||||
| { 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user