mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
Restructure frontend code to allow for extensions (#4721)
# Description of Changes Move frontend code into `core` folder and add infrastructure for `proprietary` folder to include premium, non-OSS features
This commit is contained in:
107
frontend/src/core/contexts/AppConfigContext.tsx
Normal file
107
frontend/src/core/contexts/AppConfigContext.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
|
||||
|
||||
export interface AppConfig {
|
||||
baseUrl?: string;
|
||||
contextPath?: string;
|
||||
serverPort?: number;
|
||||
appName?: string;
|
||||
appNameNavbar?: string;
|
||||
homeDescription?: string;
|
||||
languages?: string[];
|
||||
enableLogin?: boolean;
|
||||
enableAlphaFunctionality?: boolean;
|
||||
enableAnalytics?: boolean | null;
|
||||
enablePosthog?: boolean | null;
|
||||
enableScarf?: boolean | null;
|
||||
premiumEnabled?: boolean;
|
||||
premiumKey?: string;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
accessibilityStatement?: string;
|
||||
runningProOrHigher?: boolean;
|
||||
runningEE?: boolean;
|
||||
license?: string;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AppConfigContextValue {
|
||||
config: AppConfig | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Create context
|
||||
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provider component that fetches and provides app configuration
|
||||
* Should be placed at the top level of the app, before any components that need config
|
||||
*/
|
||||
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const headers = useRequestHeaders();
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/v1/config/app-config', {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: AppConfig = await response.json();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
console.error('[AppConfig] Failed to fetch app config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const value: AppConfigContextValue = {
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</AppConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access application configuration
|
||||
* Must be used within AppConfigProvider
|
||||
*/
|
||||
export function useAppConfig(): AppConfigContextValue {
|
||||
const context = useContext(AppConfigContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAppConfig must be used within AppConfigProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
287
frontend/src/core/contexts/FileContext.tsx
Normal file
287
frontend/src/core/contexts/FileContext.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* FileContext - Manages PDF files for Stirling PDF multi-tool workflow
|
||||
*
|
||||
* Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+).
|
||||
* Users upload PDFs once and chain tools (split → merge → compress → view) without reloading.
|
||||
*
|
||||
* Key hooks:
|
||||
* - useFileState() - access file state and UI state
|
||||
* - useFileActions() - file operations (add/remove/update)
|
||||
* - useFileSelection() - for file selection state and actions
|
||||
*
|
||||
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
|
||||
*/
|
||||
|
||||
import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
FileContextProviderProps,
|
||||
FileContextSelectors,
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue,
|
||||
FileContextActions,
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
} from '@app/types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer';
|
||||
import { createFileSelectors } from '@app/contexts/file/fileSelectors';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions';
|
||||
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
|
||||
// Inner provider component that has access to IndexedDB
|
||||
function FileContextInner({
|
||||
children,
|
||||
enablePersistence = true
|
||||
}: FileContextProviderProps) {
|
||||
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
||||
|
||||
// IndexedDB context for persistence
|
||||
const indexedDB = enablePersistence ? useIndexedDB() : null;
|
||||
|
||||
// File ref map - stores File objects outside React state
|
||||
const filesRef = useRef<Map<FileId, File>>(new Map());
|
||||
|
||||
// Stable state reference for selectors
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
// Create lifecycle manager
|
||||
const lifecycleManagerRef = useRef<FileLifecycleManager | null>(null);
|
||||
if (!lifecycleManagerRef.current) {
|
||||
lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch);
|
||||
}
|
||||
const lifecycleManager = lifecycleManagerRef.current;
|
||||
|
||||
// Create stable selectors (memoized once to avoid re-renders)
|
||||
const selectors = useMemo<FileContextSelectors>(() =>
|
||||
createFileSelectors(stateRef, filesRef),
|
||||
[] // Empty deps - selectors are stable
|
||||
);
|
||||
|
||||
// Navigation management removed - moved to NavigationContext
|
||||
|
||||
// Navigation guard system functions
|
||||
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []);
|
||||
|
||||
const selectFiles = (stirlingFiles: StirlingFile[]) => {
|
||||
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||
const newFileIds = stirlingFiles.map(stirlingFile => stirlingFile.fileId);
|
||||
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } });
|
||||
};
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean; skipAutoUnzip?: boolean }): Promise<StirlingFile[]> => {
|
||||
const stirlingFiles = await addFiles(
|
||||
{
|
||||
files,
|
||||
...options,
|
||||
// For direct file uploads: ALWAYS unzip (except HTML ZIPs)
|
||||
// skipAutoUnzip bypasses preference checks - HTML detection still applies
|
||||
skipAutoUnzip: true
|
||||
},
|
||||
stateRef,
|
||||
filesRef,
|
||||
dispatch,
|
||||
lifecycleManager,
|
||||
enablePersistence
|
||||
);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && stirlingFiles.length > 0) {
|
||||
selectFiles(stirlingFiles);
|
||||
}
|
||||
|
||||
return stirlingFiles;
|
||||
}, [enablePersistence]);
|
||||
|
||||
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
|
||||
const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && result.length > 0) {
|
||||
selectFiles(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
|
||||
// Action creators
|
||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||
|
||||
// Helper functions for pinned files
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
|
||||
}, []);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
|
||||
// File pinning functions - use StirlingFile directly
|
||||
const pinFileWrapper = useCallback((file: StirlingFile) => {
|
||||
baseActions.pinFile(file.fileId);
|
||||
}, [baseActions]);
|
||||
|
||||
const unpinFileWrapper = useCallback((file: StirlingFile) => {
|
||||
baseActions.unpinFile(file.fileId);
|
||||
}, [baseActions]);
|
||||
|
||||
// Complete actions object
|
||||
const actions = useMemo<FileContextActions>(() => ({
|
||||
...baseActions,
|
||||
addFiles: addRawFiles,
|
||||
addStirlingFileStubs: addStirlingFileStubsAction,
|
||||
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
||||
// Remove from memory and cleanup resources
|
||||
lifecycleManager.removeFiles(fileIds, stateRef);
|
||||
|
||||
// Remove from IndexedDB if enabled
|
||||
if (indexedDB && enablePersistence && deleteFromStorage !== false) {
|
||||
try {
|
||||
await indexedDB.deleteMultiple(fileIds);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from IndexedDB:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
|
||||
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
|
||||
reorderFiles: (orderedFileIds: FileId[]) => {
|
||||
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
||||
},
|
||||
clearAllFiles: async () => {
|
||||
lifecycleManager.cleanupAllFiles();
|
||||
filesRef.current.clear();
|
||||
dispatch({ type: 'RESET_CONTEXT' });
|
||||
|
||||
// Don't clear IndexedDB automatically - only clear in-memory state
|
||||
// IndexedDB should only be cleared when explicitly requested by user
|
||||
},
|
||||
clearAllData: async () => {
|
||||
// First clear all files from memory
|
||||
lifecycleManager.cleanupAllFiles();
|
||||
filesRef.current.clear();
|
||||
dispatch({ type: 'RESET_CONTEXT' });
|
||||
|
||||
// Then clear IndexedDB storage
|
||||
if (indexedDB && enablePersistence) {
|
||||
try {
|
||||
await indexedDB.clearAll();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear IndexedDB:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Pinned files functionality with File object wrappers
|
||||
pinFile: pinFileWrapper,
|
||||
unpinFile: unpinFileWrapper,
|
||||
consumeFiles: consumeFilesWrapper,
|
||||
undoConsumeFiles: undoConsumeFilesWrapper,
|
||||
setHasUnsavedChanges,
|
||||
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
||||
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||
scheduleCleanup: (fileId: FileId, delay?: number) =>
|
||||
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
|
||||
}), [
|
||||
baseActions,
|
||||
addRawFiles,
|
||||
addStirlingFileStubsAction,
|
||||
lifecycleManager,
|
||||
setHasUnsavedChanges,
|
||||
consumeFilesWrapper,
|
||||
undoConsumeFilesWrapper,
|
||||
pinFileWrapper,
|
||||
unpinFileWrapper,
|
||||
indexedDB,
|
||||
enablePersistence
|
||||
]);
|
||||
|
||||
// Split context values to minimize re-renders
|
||||
const stateValue = useMemo<FileContextStateValue>(() => ({
|
||||
state,
|
||||
selectors
|
||||
}), [state, selectors]);
|
||||
|
||||
const actionsValue = useMemo<FileContextActionsValue>(() => ({
|
||||
actions,
|
||||
dispatch
|
||||
}), [actions]);
|
||||
|
||||
// Persistence loading disabled - files only loaded on explicit user action
|
||||
// useEffect(() => {
|
||||
// if (!enablePersistence || !indexedDB) return;
|
||||
// const loadFromPersistence = async () => { /* loading logic removed */ };
|
||||
// loadFromPersistence();
|
||||
// }, [enablePersistence, indexedDB]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (DEBUG) console.log('FileContext unmounting - cleaning up all resources');
|
||||
lifecycleManager.destroy();
|
||||
};
|
||||
}, [lifecycleManager]);
|
||||
|
||||
return (
|
||||
<FileStateContext.Provider value={stateValue}>
|
||||
<FileActionsContext.Provider value={actionsValue}>
|
||||
{children}
|
||||
</FileActionsContext.Provider>
|
||||
</FileStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Outer provider component that wraps with IndexedDBProvider
|
||||
export function FileContextProvider({
|
||||
children,
|
||||
enableUrlSync = true,
|
||||
enablePersistence = true
|
||||
}: FileContextProviderProps) {
|
||||
if (enablePersistence) {
|
||||
return (
|
||||
<IndexedDBProvider>
|
||||
<FileContextInner
|
||||
enableUrlSync={enableUrlSync}
|
||||
enablePersistence={enablePersistence}
|
||||
>
|
||||
{children}
|
||||
</FileContextInner>
|
||||
</IndexedDBProvider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<FileContextInner
|
||||
enableUrlSync={enableUrlSync}
|
||||
enablePersistence={enablePersistence}
|
||||
>
|
||||
{children}
|
||||
</FileContextInner>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export all hooks from the fileHooks module
|
||||
export {
|
||||
useFileState,
|
||||
useFileActions,
|
||||
useCurrentFile,
|
||||
useFileSelection,
|
||||
useFileManagement,
|
||||
useFileUI,
|
||||
useStirlingFileStub,
|
||||
useAllFiles,
|
||||
useSelectedFiles,
|
||||
// Primary API hooks for tools
|
||||
useFileContext
|
||||
} from '@app/contexts/file/fileHooks';
|
||||
702
frontend/src/core/contexts/FileManagerContext.tsx
Normal file
702
frontend/src/core/contexts/FileManagerContext.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { downloadFiles } from '@app/utils/downloadUtils';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
// State
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: FileId[];
|
||||
searchTerm: string;
|
||||
selectedFiles: StirlingFileStub[];
|
||||
filteredFiles: StirlingFileStub[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<FileId>;
|
||||
expandedFileIds: Set<FileId>;
|
||||
fileGroups: Map<FileId, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
isLoading: boolean;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onToggleExpansion: (fileId: FileId) => void;
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
onGoogleDriveSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: StirlingFileStub[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
recentFiles: StirlingFileStub[];
|
||||
onRecentFilesSelected: (files: StirlingFileStub[]) => void; // For selecting stored files
|
||||
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
|
||||
onClose: () => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
isOpen: boolean;
|
||||
onFileRemove: (index: number) => void;
|
||||
modalHeight: string;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onRecentFilesSelected,
|
||||
onNewFilesSelect,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
isOpen,
|
||||
onFileRemove,
|
||||
modalHeight,
|
||||
refreshRecentFiles,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
|
||||
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
|
||||
// Group files by original file ID for version management
|
||||
const fileGroups = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return new Map();
|
||||
|
||||
// Convert StirlingFileStub to FileRecord-like objects for grouping utility
|
||||
const recordsForGrouping = recentFiles.map(file => ({
|
||||
...file,
|
||||
originalFileId: file.originalFileId,
|
||||
versionNumber: file.versionNumber || 1
|
||||
}));
|
||||
|
||||
return groupFilesByOriginal(recordsForGrouping);
|
||||
}, [recentFiles]);
|
||||
|
||||
// Get files to display with expansion logic
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return [];
|
||||
|
||||
// Only return leaf files - history files will be handled by separate components
|
||||
return recentFiles;
|
||||
}, [recentFiles]);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
||||
|
||||
const filteredFiles = !searchTerm ? displayFiles :
|
||||
displayFiles.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
|
||||
setActiveSource(source);
|
||||
if (source !== 'recent') {
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocalFileClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
if (!fileId) return;
|
||||
|
||||
if (shiftKey && lastClickedIndex !== null) {
|
||||
// Range selection with shift-click
|
||||
const startIndex = Math.min(lastClickedIndex, currentIndex);
|
||||
const endIndex = Math.max(lastClickedIndex, currentIndex);
|
||||
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
// Add all files in the range to selection
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const rangeFileId = filteredFiles[i]?.id;
|
||||
if (rangeFileId) {
|
||||
selectedSet.add(rangeFileId);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
} else {
|
||||
// Normal click behavior - optimized with Set for O(1) lookup
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
if (selectedSet.has(fileId)) {
|
||||
selectedSet.delete(fileId);
|
||||
} else {
|
||||
selectedSet.add(fileId);
|
||||
}
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
|
||||
// Update last clicked index for future range selections
|
||||
setLastClickedIndex(currentIndex);
|
||||
}
|
||||
}, [filteredFiles, lastClickedIndex]);
|
||||
|
||||
// Helper function to safely determine which files can be deleted
|
||||
const getSafeFilesToDelete = useCallback((
|
||||
fileIds: FileId[],
|
||||
allStoredStubs: StirlingFileStub[]
|
||||
): FileId[] => {
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
||||
const filesToDelete = new Set<FileId>();
|
||||
const filesToPreserve = new Set<FileId>();
|
||||
|
||||
// First, identify all files in the lineages of the leaf files being deleted
|
||||
for (const leafFileId of fileIds) {
|
||||
const currentFile = fileMap.get(leafFileId);
|
||||
if (!currentFile) continue;
|
||||
|
||||
// Always include the leaf file itself for deletion
|
||||
filesToDelete.add(leafFileId);
|
||||
|
||||
// If this is a processed file with history, trace back through its lineage
|
||||
if (currentFile.versionNumber && currentFile.versionNumber > 1) {
|
||||
const originalFileId = currentFile.originalFileId || currentFile.id;
|
||||
|
||||
// Find all files in this history chain
|
||||
const chainFiles = allStoredStubs.filter((file: StirlingFileStub) =>
|
||||
(file.originalFileId || file.id) === originalFileId
|
||||
);
|
||||
|
||||
// Add all files in this lineage as candidates for deletion
|
||||
chainFiles.forEach(file => filesToDelete.add(file.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Now identify files that must be preserved because they're referenced by OTHER lineages
|
||||
for (const file of allStoredStubs) {
|
||||
const fileOriginalId = file.originalFileId || file.id;
|
||||
|
||||
// If this file is a leaf node (not being deleted) and its lineage overlaps with files we want to delete
|
||||
if (file.isLeaf !== false && !fileIds.includes(file.id)) {
|
||||
// Find all files in this preserved lineage
|
||||
const preservedChainFiles = allStoredStubs.filter((chainFile: StirlingFileStub) =>
|
||||
(chainFile.originalFileId || chainFile.id) === fileOriginalId
|
||||
);
|
||||
|
||||
// Mark all files in this preserved lineage as must-preserve
|
||||
preservedChainFiles.forEach(chainFile => filesToPreserve.add(chainFile.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Final list: files to delete minus files that must be preserved
|
||||
let safeToDelete = Array.from(filesToDelete).filter(fileId => !filesToPreserve.has(fileId));
|
||||
|
||||
// Check for orphaned non-leaf files after main deletion
|
||||
const remainingFiles = allStoredStubs.filter(file => !safeToDelete.includes(file.id));
|
||||
const orphanedNonLeafFiles: FileId[] = [];
|
||||
|
||||
for (const file of remainingFiles) {
|
||||
// Only check non-leaf files (files that have been processed and have children)
|
||||
if (file.isLeaf === false) {
|
||||
const fileOriginalId = file.originalFileId || file.id;
|
||||
|
||||
// Check if this non-leaf file has any living descendants
|
||||
const hasLivingDescendants = remainingFiles.some(otherFile => {
|
||||
// Check if otherFile is a descendant of this file
|
||||
const otherOriginalId = otherFile.originalFileId || otherFile.id;
|
||||
return (
|
||||
// Direct parent relationship
|
||||
otherFile.parentFileId === file.id ||
|
||||
// Same lineage but different from this file
|
||||
(otherOriginalId === fileOriginalId && otherFile.id !== file.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasLivingDescendants) {
|
||||
orphanedNonLeafFiles.push(file.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orphaned non-leaf files to deletion list
|
||||
safeToDelete = [...safeToDelete, ...orphanedNonLeafFiles];
|
||||
|
||||
return safeToDelete;
|
||||
}, []);
|
||||
|
||||
// Shared internal delete logic
|
||||
const performFileDelete = useCallback(async (fileToRemove: StirlingFileStub, fileIndex: number) => {
|
||||
const deletedFileId = fileToRemove.id;
|
||||
|
||||
// Get all stored files to analyze lineages
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// Get safe files to delete (respecting shared lineages)
|
||||
const filesToDelete = getSafeFilesToDelete([deletedFileId], allStoredStubs);
|
||||
|
||||
// Clear from selection immediately
|
||||
setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id)));
|
||||
|
||||
// Clear from expanded state to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
filesToDelete.forEach(id => newExpanded.delete(id));
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear from history cache - remove all files in the chain
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
// Remove cache entries for all deleted files
|
||||
filesToDelete.forEach(id => newCache.delete(id as FileId));
|
||||
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id));
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Delete safe files from IndexedDB
|
||||
try {
|
||||
for (const fileId of filesToDelete) {
|
||||
await fileStorage.deleteStirlingFile(fileId as FileId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from chain:', error);
|
||||
}
|
||||
|
||||
// Call the parent's deletion logic for the main file only
|
||||
onFileRemove(fileIndex);
|
||||
|
||||
// Refresh to ensure consistent state
|
||||
await refreshRecentFiles();
|
||||
}, [getSafeFilesToDelete, setSelectedFileIds, setExpandedFileIds, setLoadedHistoryFiles, onFileRemove, refreshRecentFiles]);
|
||||
|
||||
const handleFileRemove = useCallback(async (index: number) => {
|
||||
const fileToRemove = filteredFiles[index];
|
||||
if (fileToRemove) {
|
||||
await performFileDelete(fileToRemove, index);
|
||||
}
|
||||
}, [filteredFiles, performFileDelete]);
|
||||
|
||||
// Handle deletion by fileId (more robust than index-based)
|
||||
const handleFileRemoveById = useCallback(async (fileId: FileId) => {
|
||||
// Find the file and its index in filteredFiles
|
||||
const fileIndex = filteredFiles.findIndex(file => file.id === fileId);
|
||||
const fileToRemove = filteredFiles[fileIndex];
|
||||
|
||||
if (fileToRemove && fileIndex !== -1) {
|
||||
await performFileDelete(fileToRemove, fileIndex);
|
||||
}
|
||||
}, [filteredFiles, performFileDelete]);
|
||||
|
||||
// Handle deletion of specific history files (not index-based)
|
||||
const handleHistoryFileRemove = useCallback(async (fileToRemove: StirlingFileStub) => {
|
||||
const deletedFileId = fileToRemove.id;
|
||||
|
||||
// Clear from expanded state to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
newExpanded.delete(deletedFileId);
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear from history cache - remove all files in the chain
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
// Remove cache entries for all deleted files
|
||||
newCache.delete(deletedFileId);
|
||||
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => deletedFileId != histFile.id);
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Delete safe files from IndexedDB
|
||||
try {
|
||||
await fileStorage.deleteStirlingFile(deletedFileId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from chain:', error);
|
||||
}
|
||||
|
||||
// Refresh to ensure consistent state
|
||||
await refreshRecentFiles();
|
||||
}, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: StirlingFileStub) => {
|
||||
if (isFileSupported(file.name)) {
|
||||
onRecentFilesSelected([file]);
|
||||
onClose();
|
||||
}
|
||||
}, [isFileSupported, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
onRecentFilesSelected(selectedFiles);
|
||||
onClose();
|
||||
}
|
||||
}, [selectedFiles, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
}, []);
|
||||
|
||||
const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// For local file uploads, pass File objects directly to FileContext
|
||||
onNewFilesSelect(files);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to process selected files:', error);
|
||||
}
|
||||
}
|
||||
event.target.value = '';
|
||||
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||
if (allFilesSelected) {
|
||||
// Deselect all
|
||||
setSelectedFileIds([]);
|
||||
setLastClickedIndex(null);
|
||||
} else {
|
||||
// Select all filtered files
|
||||
setSelectedFileIds(filteredFiles.map(file => file.id).filter(Boolean));
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [filteredFiles, selectedFileIds]);
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
// Delete each selected file using the proven single delete logic
|
||||
for (const fileId of selectedFileIds) {
|
||||
await handleFileRemoveById(fileId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete selected files:', error);
|
||||
}
|
||||
}, [selectedFileIds, handleFileRemoveById]);
|
||||
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
// Get selected files
|
||||
const selectedFilesToDownload = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
// Use generic download utility
|
||||
await downloadFiles(selectedFilesToDownload, {
|
||||
zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected files:', error);
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles]);
|
||||
|
||||
const handleDownloadSingle = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
await downloadFiles([file]);
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleExpansion = useCallback(async (fileId: FileId) => {
|
||||
const isCurrentlyExpanded = expandedFileIds.has(fileId);
|
||||
|
||||
// Update expansion state
|
||||
setExpandedFileIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(fileId)) {
|
||||
newSet.delete(fileId);
|
||||
} else {
|
||||
newSet.add(fileId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Load complete history chain if expanding
|
||||
if (!isCurrentlyExpanded) {
|
||||
const currentFileMetadata = recentFiles.find(f => f.id === fileId);
|
||||
if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
|
||||
try {
|
||||
// Get all stored file metadata for chain traversal
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
||||
|
||||
// Get the current file's IndexedDB data
|
||||
const currentStoredStub = fileMap.get(fileId as FileId);
|
||||
if (!currentStoredStub) {
|
||||
console.warn(`No stored file found for ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build complete history chain using IndexedDB metadata
|
||||
const historyFiles: StirlingFileStub[] = [];
|
||||
|
||||
// Find the original file
|
||||
|
||||
// Collect only files in this specific branch (ancestors of current file)
|
||||
const chainFiles: StirlingFileStub[] = [];
|
||||
const allFiles = Array.from(fileMap.values());
|
||||
|
||||
// Build a map for fast parent lookups
|
||||
const fileIdMap = new Map<FileId, StirlingFileStub>();
|
||||
allFiles.forEach(f => fileIdMap.set(f.id, f));
|
||||
|
||||
// Trace back from current file through parent chain
|
||||
let currentFile = fileIdMap.get(fileId);
|
||||
while (currentFile?.parentFileId) {
|
||||
const parentFile = fileIdMap.get(currentFile.parentFileId);
|
||||
if (parentFile) {
|
||||
chainFiles.push(parentFile);
|
||||
currentFile = parentFile;
|
||||
} else {
|
||||
break; // Parent not found, stop tracing
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by version number (oldest first for history display)
|
||||
chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
|
||||
|
||||
// StirlingFileStubs already have all the data we need - no conversion required!
|
||||
historyFiles.push(...chainFiles);
|
||||
|
||||
// Cache the loaded history files
|
||||
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load history chain for file ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear loaded history when collapsing
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(fileId as FileId);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [expandedFileIds, recentFiles]);
|
||||
|
||||
const handleAddToRecents = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
// Mark the file as a leaf node so it appears in recent files
|
||||
await fileStorage.markFileAsLeaf(file.id);
|
||||
|
||||
// Refresh the recent files list to show updated state
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to add to recents:', error);
|
||||
}
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
const handleGoogleDriveSelect = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Process Google Drive files same as local files
|
||||
onNewFilesSelect(files);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to process Google Drive files:', error);
|
||||
}
|
||||
}
|
||||
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
// Load the full file from storage
|
||||
const stirlingFile = await fileStorage.getStirlingFile(file.id);
|
||||
if (!stirlingFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(stirlingFile, file);
|
||||
|
||||
if (result.success) {
|
||||
// Refresh file manager to show new files
|
||||
await refreshRecentFiles();
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.error('Errors during unzip:', result.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unzip file:', error);
|
||||
}
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clean up all created blob URLs
|
||||
createdBlobUrls.current.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
createdBlobUrls.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveSource('recent');
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const contextValue: FileManagerContextValue = useMemo(() => ({
|
||||
// State
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
searchTerm,
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
selectedFilesSet,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
onLocalFileClick: handleLocalFileClick,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFileRemove: handleFileRemove,
|
||||
onHistoryFileRemove: handleHistoryFileRemove,
|
||||
onFileDoubleClick: handleFileDoubleClick,
|
||||
onOpenFiles: handleOpenFiles,
|
||||
onSearchChange: handleSearchChange,
|
||||
onFileInputChange: handleFileInputChange,
|
||||
onSelectAll: handleSelectAll,
|
||||
onDeleteSelected: handleDeleteSelected,
|
||||
onDownloadSelected: handleDownloadSelected,
|
||||
onDownloadSingle: handleDownloadSingle,
|
||||
onToggleExpansion: handleToggleExpansion,
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onUnzipFile: handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
onGoogleDriveSelect: handleGoogleDriveSelect,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
}), [
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
searchTerm,
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleFileRemoveById,
|
||||
performFileDelete,
|
||||
handleFileDoubleClick,
|
||||
handleOpenFiles,
|
||||
handleSearchChange,
|
||||
handleFileInputChange,
|
||||
handleSelectAll,
|
||||
handleDeleteSelected,
|
||||
handleDownloadSelected,
|
||||
handleToggleExpansion,
|
||||
handleAddToRecents,
|
||||
handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
handleGoogleDriveSelect,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FileManagerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FileManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook to use the context
|
||||
export const useFileManagerContext = (): FileManagerContextValue => {
|
||||
const context = useContext(FileManagerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileManagerContext must be used within a FileManagerProvider. ' +
|
||||
'Make sure you wrap your component with <FileManagerProvider>.'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
// Export the context for advanced use cases
|
||||
export { FileManagerContext };
|
||||
115
frontend/src/core/contexts/FilesModalContext.tsx
Normal file
115
frontend/src/core/contexts/FilesModalContext.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
|
||||
closeFilesModal: () => void;
|
||||
onFileUpload: (files: File[]) => void;
|
||||
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addFiles } = useFileHandler();
|
||||
const { actions } = useFileActions();
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
||||
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
|
||||
|
||||
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => {
|
||||
setInsertAfterPage(options?.insertAfterPage);
|
||||
setCustomHandler(() => options?.customHandler);
|
||||
setIsFilesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeFilesModal = useCallback(() => {
|
||||
setIsFilesModalOpen(false);
|
||||
setInsertAfterPage(undefined); // Clear insertion position
|
||||
setCustomHandler(undefined); // Clear custom handler
|
||||
onModalClose?.();
|
||||
}, [onModalClose]);
|
||||
|
||||
const handleFileUpload = useCallback((files: File[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler(files, insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addFiles(files);
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
||||
if (customHandler) {
|
||||
// Load the actual files from storage for custom handler
|
||||
try {
|
||||
const loadedFiles: File[] = [];
|
||||
for (const stub of stirlingFileStubs) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (stirlingFile) {
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
customHandler(loadedFiles, insertAfterPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load files for custom handler:', error);
|
||||
}
|
||||
} else {
|
||||
// Normal case - use addStirlingFileStubs to preserve metadata
|
||||
if (actions.addStirlingFileStubs) {
|
||||
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
|
||||
} else {
|
||||
console.error('addStirlingFileStubs action not available');
|
||||
}
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
}, []);
|
||||
|
||||
const contextValue: FilesModalContextType = useMemo(() => ({
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileUpload: handleFileUpload,
|
||||
onRecentFileSelect: handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
}), [
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
handleFileUpload,
|
||||
handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setModalCloseCallback,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FilesModalContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FilesModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFilesModalContext = () => {
|
||||
const context = useContext(FilesModalContext);
|
||||
if (!context) {
|
||||
throw new Error('useFilesModalContext must be used within FilesModalProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
205
frontend/src/core/contexts/HotkeyContext.tsx
Normal file
205
frontend/src/core/contexts/HotkeyContext.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '@app/utils/hotkeys';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { ToolCategoryId, ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
|
||||
type Bindings = Partial<Record<ToolId, HotkeyBinding>>;
|
||||
|
||||
interface HotkeyContextValue {
|
||||
hotkeys: Bindings;
|
||||
defaults: Bindings;
|
||||
isMac: boolean;
|
||||
updateHotkey: (toolId: ToolId, binding: HotkeyBinding) => void;
|
||||
resetHotkey: (toolId: ToolId) => void;
|
||||
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: ToolId) => boolean;
|
||||
pauseHotkeys: () => void;
|
||||
resumeHotkeys: () => void;
|
||||
areHotkeysPaused: boolean;
|
||||
getDisplayParts: (binding: HotkeyBinding | null | undefined) => string[];
|
||||
}
|
||||
|
||||
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf.hotkeys';
|
||||
|
||||
const generateDefaultHotkeys = (toolEntries: [ToolId, ToolRegistryEntry][], macLike: boolean): Bindings => {
|
||||
const defaults: Bindings = {};
|
||||
|
||||
// Get Quick Access tools (RECOMMENDED_TOOLS category) from registry
|
||||
const quickAccessTools = toolEntries
|
||||
.filter(([_, tool]) => tool.categoryId === ToolCategoryId.RECOMMENDED_TOOLS)
|
||||
.map(([toolId, _]) => toolId);
|
||||
|
||||
// Assign Cmd+Option+Number (Mac) or Ctrl+Alt+Number (Windows) to Quick Access tools
|
||||
quickAccessTools.forEach((toolId, index) => {
|
||||
if (index < 9) { // Limit to Digit1-9
|
||||
const digitNumber = index + 1;
|
||||
defaults[toolId] = {
|
||||
code: `Digit${digitNumber}`,
|
||||
alt: true,
|
||||
shift: false,
|
||||
meta: macLike,
|
||||
ctrl: !macLike,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// All other tools have no default (will be undefined in the record)
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const editable = target.closest('input, textarea, [contenteditable="true"], [role="textbox"]');
|
||||
return Boolean(editable);
|
||||
};
|
||||
|
||||
export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { toolRegistry, handleToolSelect } = useToolWorkflow();
|
||||
const isMac = useMemo(() => isMacLike(), []);
|
||||
const [customBindings, setCustomBindings] = useState<Bindings>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
return deserializeBindings(window.localStorage?.getItem(STORAGE_KEY));
|
||||
});
|
||||
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
|
||||
|
||||
const toolEntries = useMemo(() => Object.entries(toolRegistry), [toolRegistry]) as [ToolId, ToolRegistryEntry][];
|
||||
|
||||
const defaults = useMemo(() => generateDefaultHotkeys(toolEntries, isMac), [toolRegistry, isMac]);
|
||||
|
||||
// Remove bindings for tools that are no longer present
|
||||
useEffect(() => {
|
||||
setCustomBindings(prev => {
|
||||
const next: Bindings = {};
|
||||
let changed = false;
|
||||
(Object.entries(prev) as [ToolId, HotkeyBinding][]).forEach(([toolId, binding]) => {
|
||||
if (toolRegistry[toolId]) {
|
||||
next[toolId] = binding;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [toolRegistry]);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
const merged: Bindings = {};
|
||||
toolEntries.forEach(([toolId, _]) => {
|
||||
const custom = customBindings[toolId];
|
||||
const defaultBinding = defaults[toolId];
|
||||
|
||||
// Only add to resolved if there's a custom binding or a default binding
|
||||
if (custom) {
|
||||
merged[toolId] = normalizeBinding(custom);
|
||||
} else if (defaultBinding) {
|
||||
merged[toolId] = defaultBinding;
|
||||
}
|
||||
// If neither exists, don't add to merged (tool has no hotkey)
|
||||
});
|
||||
return merged;
|
||||
}, [customBindings, defaults, toolEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
|
||||
}, [customBindings]);
|
||||
|
||||
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: ToolId) => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
return Object.entries(resolved).every(([toolId, existing]) => {
|
||||
if (toolId === excludeToolId) {
|
||||
return true;
|
||||
}
|
||||
return !bindingEquals(existing, normalized);
|
||||
});
|
||||
}, [resolved]);
|
||||
|
||||
const updateHotkey = useCallback((toolId: ToolId, binding: HotkeyBinding) => {
|
||||
setCustomBindings(prev => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
const defaultsForTool = defaults[toolId];
|
||||
const next = { ...prev };
|
||||
if (defaultsForTool && bindingEquals(defaultsForTool, normalized)) {
|
||||
delete next[toolId];
|
||||
} else {
|
||||
next[toolId] = normalized;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [defaults]);
|
||||
|
||||
const resetHotkey = useCallback((toolId: ToolId) => {
|
||||
setCustomBindings(prev => {
|
||||
if (!(toolId in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[toolId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pauseHotkeys = useCallback(() => setHotkeysPaused(true), []);
|
||||
const resumeHotkeys = useCallback(() => setHotkeysPaused(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (areHotkeysPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (shouldIgnoreTarget(event.target)) return;
|
||||
|
||||
const entries = Object.entries(resolved) as [ToolId, HotkeyBinding][];
|
||||
for (const [toolId, binding] of entries) {
|
||||
if (bindingMatchesEvent(binding, event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleToolSelect(toolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
}, [resolved, areHotkeysPaused, handleToolSelect]);
|
||||
|
||||
const contextValue = useMemo<HotkeyContextValue>(() => ({
|
||||
hotkeys: resolved,
|
||||
defaults,
|
||||
isMac,
|
||||
updateHotkey,
|
||||
resetHotkey,
|
||||
isBindingAvailable,
|
||||
pauseHotkeys,
|
||||
resumeHotkeys,
|
||||
areHotkeysPaused,
|
||||
getDisplayParts: (binding) => getDisplayParts(binding ?? null, isMac),
|
||||
}), [resolved, defaults, isMac, updateHotkey, resetHotkey, isBindingAvailable, pauseHotkeys, resumeHotkeys, areHotkeysPaused]);
|
||||
|
||||
return (
|
||||
<HotkeyContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HotkeyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useHotkeys = (): HotkeyContextValue => {
|
||||
const context = useContext(HotkeyContext);
|
||||
if (!context) {
|
||||
throw new Error('useHotkeys must be used within a HotkeyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
205
frontend/src/core/contexts/IndexedDBContext.tsx
Normal file
205
frontend/src/core/contexts/IndexedDBContext.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* IndexedDBContext - Clean persistence layer for file storage
|
||||
* Integrates with FileContext to provide transparent file persistence
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { StirlingFileStub, createStirlingFile, createQuickKey } from '@app/types/fileContext';
|
||||
import { generateThumbnailForFile } from '@app/utils/thumbnailUtils';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
interface IndexedDBContextValue {
|
||||
// Core CRUD operations
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StirlingFileStub>;
|
||||
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<StirlingFileStub | null>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
loadAllMetadata: () => Promise<StirlingFileStub[]>;
|
||||
loadLeafMetadata: () => Promise<StirlingFileStub[]>; // Only leaf files for recent files list
|
||||
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// Utilities
|
||||
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
||||
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
||||
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
|
||||
|
||||
interface IndexedDBProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
// LRU File cache to avoid repeated ArrayBuffer→File conversions
|
||||
const fileCache = useRef(new Map<FileId, { file: File; lastAccessed: number }>());
|
||||
const MAX_CACHE_SIZE = 50; // Maximum number of files to cache
|
||||
|
||||
// LRU cache management
|
||||
const evictLRUEntries = useCallback(() => {
|
||||
if (fileCache.current.size <= MAX_CACHE_SIZE) return;
|
||||
|
||||
// Convert to array and sort by last accessed time (oldest first)
|
||||
const entries = Array.from(fileCache.current.entries())
|
||||
.sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed);
|
||||
|
||||
// Remove the least recently used entries
|
||||
const toRemove = entries.slice(0, fileCache.current.size - MAX_CACHE_SIZE);
|
||||
toRemove.forEach(([fileId]) => {
|
||||
fileCache.current.delete(fileId);
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
|
||||
}, []);
|
||||
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StirlingFileStub> => {
|
||||
// Use existing thumbnail or generate new one if none provided
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
// Store in IndexedDB (no history data - that's handled by direct fileStorage calls now)
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
|
||||
// Create minimal stub for storage
|
||||
const stub: StirlingFileStub = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
quickKey: createQuickKey(file),
|
||||
thumbnailUrl: thumbnail,
|
||||
isLeaf: true,
|
||||
createdAt: Date.now(),
|
||||
versionNumber: 1,
|
||||
originalFileId: fileId,
|
||||
toolHistory: []
|
||||
};
|
||||
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
const storedFile = await fileStorage.getStirlingFileStub(fileId);
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
// Return StirlingFileStub from the stored file (no conversion needed)
|
||||
if (!storedFile) {
|
||||
throw new Error(`Failed to retrieve stored file after saving: ${file.name}`);
|
||||
}
|
||||
|
||||
return storedFile;
|
||||
}, []);
|
||||
|
||||
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
||||
// Check cache first
|
||||
const cached = fileCache.current.get(fileId);
|
||||
if (cached) {
|
||||
// Update last accessed time for LRU
|
||||
cached.lastAccessed = Date.now();
|
||||
return cached.file;
|
||||
}
|
||||
|
||||
// Load from IndexedDB
|
||||
const storedFile = await fileStorage.getStirlingFile(fileId);
|
||||
if (!storedFile) return null;
|
||||
|
||||
// StirlingFile is already a File object, no reconstruction needed
|
||||
const file = storedFile;
|
||||
|
||||
// Cache for future use with LRU eviction
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
return file;
|
||||
}, [evictLRUEntries]);
|
||||
|
||||
const loadMetadata = useCallback(async (fileId: FileId): Promise<StirlingFileStub | null> => {
|
||||
// Load stub directly from storage service
|
||||
return await fileStorage.getStirlingFileStub(fileId);
|
||||
}, []);
|
||||
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteStirlingFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadLeafMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getLeafStirlingFileStubs(); // Only get leaf files
|
||||
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileIds.forEach(id => fileCache.current.delete(id));
|
||||
|
||||
// Remove from IndexedDB in parallel
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteStirlingFile(id)));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
// Clear cache
|
||||
fileCache.current.clear();
|
||||
|
||||
// Clear IndexedDB
|
||||
await fileStorage.clearAll();
|
||||
}, []);
|
||||
|
||||
const getStorageStats = useCallback(async () => {
|
||||
return await fileStorage.getStorageStats();
|
||||
}, []);
|
||||
|
||||
const updateThumbnail = useCallback(async (fileId: FileId, thumbnail: string): Promise<boolean> => {
|
||||
return await fileStorage.updateThumbnail(fileId, thumbnail);
|
||||
}, []);
|
||||
|
||||
const markFileAsProcessed = useCallback(async (fileId: FileId): Promise<boolean> => {
|
||||
return await fileStorage.markFileAsProcessed(fileId);
|
||||
}, []);
|
||||
|
||||
const value: IndexedDBContextValue = {
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
loadLeafMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail,
|
||||
markFileAsProcessed
|
||||
};
|
||||
|
||||
return (
|
||||
<IndexedDBContext.Provider value={value}>
|
||||
{children}
|
||||
</IndexedDBContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useIndexedDB() {
|
||||
const context = useContext(IndexedDBContext);
|
||||
if (!context) {
|
||||
throw new Error('useIndexedDB must be used within an IndexedDBProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
296
frontend/src/core/contexts/NavigationContext.tsx
Normal file
296
frontend/src/core/contexts/NavigationContext.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
import { WorkbenchType, getDefaultWorkbench } from '@app/types/workbench';
|
||||
import { ToolId, isValidToolId } from '@app/types/toolId';
|
||||
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
|
||||
|
||||
/**
|
||||
* 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 state
|
||||
interface NavigationContextState {
|
||||
workbench: WorkbenchType;
|
||||
selectedTool: ToolId | null;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
type NavigationAction =
|
||||
| { type: 'SET_WORKBENCH'; payload: { workbench: WorkbenchType } }
|
||||
| { type: 'SET_SELECTED_TOOL'; payload: { toolId: ToolId | null } }
|
||||
| { type: 'SET_TOOL_AND_WORKBENCH'; payload: { toolId: ToolId | null; workbench: WorkbenchType } }
|
||||
| { 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: NavigationContextState, action: NavigationAction): NavigationContextState => {
|
||||
switch (action.type) {
|
||||
case 'SET_WORKBENCH':
|
||||
return { ...state, workbench: action.payload.workbench };
|
||||
|
||||
case 'SET_SELECTED_TOOL':
|
||||
return { ...state, selectedTool: action.payload.toolId };
|
||||
|
||||
case 'SET_TOOL_AND_WORKBENCH':
|
||||
return {
|
||||
...state,
|
||||
selectedTool: action.payload.toolId,
|
||||
workbench: action.payload.workbench
|
||||
};
|
||||
|
||||
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: NavigationContextState = {
|
||||
workbench: getDefaultWorkbench(),
|
||||
selectedTool: null,
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false
|
||||
};
|
||||
|
||||
// Navigation context actions interface
|
||||
export interface NavigationContextActions {
|
||||
setWorkbench: (workbench: WorkbenchType) => void;
|
||||
setSelectedTool: (toolId: ToolId | null) => void;
|
||||
setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
registerUnsavedChangesChecker: (checker: () => boolean) => void;
|
||||
unregisterUnsavedChangesChecker: () => void;
|
||||
showNavigationWarning: (show: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => void;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
clearToolSelection: () => void;
|
||||
handleToolSelect: (toolId: string) => void;
|
||||
}
|
||||
|
||||
// Context state values
|
||||
export interface NavigationContextStateValue {
|
||||
workbench: WorkbenchType;
|
||||
selectedTool: ToolId | null;
|
||||
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;
|
||||
enableUrlSync?: boolean;
|
||||
}> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||
const { allTools: toolRegistry } = useToolRegistry();
|
||||
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
|
||||
|
||||
const actions: NavigationContextActions = {
|
||||
setWorkbench: useCallback((workbench: WorkbenchType) => {
|
||||
// Check for unsaved changes using registered checker or state
|
||||
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||
console.log('[NavigationContext] setWorkbench:', {
|
||||
from: state.workbench,
|
||||
to: workbench,
|
||||
hasChecker: !!unsavedChangesCheckerRef.current,
|
||||
hasUnsavedChanges
|
||||
});
|
||||
|
||||
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
|
||||
const leavingWorkbenchWithChanges =
|
||||
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
|
||||
|
||||
if (leavingWorkbenchWithChanges) {
|
||||
// Update state to reflect unsaved changes so modal knows
|
||||
if (!state.hasUnsavedChanges) {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } });
|
||||
}
|
||||
const performWorkbenchChange = () => {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
};
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
} else {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
}
|
||||
}, [state.workbench, state.hasUnsavedChanges]),
|
||||
|
||||
setSelectedTool: useCallback((toolId: ToolId | null) => {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
|
||||
}, []),
|
||||
|
||||
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
|
||||
// Check for unsaved changes using registered checker or state
|
||||
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||
|
||||
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
|
||||
const leavingWorkbenchWithChanges =
|
||||
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
|
||||
|
||||
if (leavingWorkbenchWithChanges) {
|
||||
const performWorkbenchChange = () => {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
||||
};
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
} else {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
||||
}
|
||||
}, [state.workbench, state.hasUnsavedChanges]),
|
||||
|
||||
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []),
|
||||
|
||||
registerUnsavedChangesChecker: useCallback((checker: () => boolean) => {
|
||||
unsavedChangesCheckerRef.current = checker;
|
||||
}, []),
|
||||
|
||||
unregisterUnsavedChangesChecker: useCallback(() => {
|
||||
unsavedChangesCheckerRef.current = null;
|
||||
}, []),
|
||||
|
||||
showNavigationWarning: useCallback((show: boolean) => {
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
|
||||
}, []),
|
||||
|
||||
requestNavigation: useCallback((navigationFn: () => void) => {
|
||||
if (!state.hasUnsavedChanges) {
|
||||
navigationFn();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
}, [state.hasUnsavedChanges]),
|
||||
|
||||
confirmNavigation: useCallback(() => {
|
||||
if (state.pendingNavigation) {
|
||||
state.pendingNavigation();
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, [state.pendingNavigation]),
|
||||
|
||||
cancelNavigation: useCallback(() => {
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, []),
|
||||
|
||||
clearToolSelection: useCallback(() => {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
||||
}, []),
|
||||
|
||||
handleToolSelect: useCallback((toolId: string) => {
|
||||
if (toolId === 'allTools') {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolId === 'read' || toolId === 'view-pdf') {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the tool in the registry to get its proper workbench
|
||||
|
||||
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
|
||||
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
|
||||
|
||||
// Validate toolId and convert to ToolId type
|
||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
|
||||
}, [toolRegistry])
|
||||
};
|
||||
|
||||
const stateValue: NavigationContextStateValue = {
|
||||
workbench: state.workbench,
|
||||
selectedTool: state.selectedTool,
|
||||
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,
|
||||
registerUnsavedChangesChecker: actions.registerUnsavedChangesChecker,
|
||||
unregisterUnsavedChangesChecker: actions.unregisterUnsavedChangesChecker
|
||||
};
|
||||
};
|
||||
78
frontend/src/core/contexts/OnboardingContext.tsx
Normal file
78
frontend/src/core/contexts/OnboardingContext.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useShouldShowWelcomeModal } from '@app/hooks/useShouldShowWelcomeModal';
|
||||
|
||||
interface OnboardingContextValue {
|
||||
isOpen: boolean;
|
||||
currentStep: number;
|
||||
setCurrentStep: (step: number) => void;
|
||||
startTour: () => void;
|
||||
closeTour: () => void;
|
||||
completeTour: () => void;
|
||||
resetTour: () => void;
|
||||
showWelcomeModal: boolean;
|
||||
setShowWelcomeModal: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
|
||||
|
||||
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { updatePreference } = usePreferences();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [showWelcomeModal, setShowWelcomeModal] = useState(false);
|
||||
const shouldShow = useShouldShowWelcomeModal();
|
||||
|
||||
// Auto-show welcome modal for first-time users
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
setShowWelcomeModal(true);
|
||||
}
|
||||
}, [shouldShow]);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeTour = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const completeTour = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
}, [updatePreference]);
|
||||
|
||||
const resetTour = useCallback(() => {
|
||||
updatePreference('hasCompletedOnboarding', false);
|
||||
setCurrentStep(0);
|
||||
setIsOpen(true);
|
||||
}, [updatePreference]);
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
startTour,
|
||||
closeTour,
|
||||
completeTour,
|
||||
resetTour,
|
||||
showWelcomeModal,
|
||||
setShowWelcomeModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOnboarding = (): OnboardingContextValue => {
|
||||
const context = useContext(OnboardingContext);
|
||||
if (!context) {
|
||||
throw new Error('useOnboarding must be used within an OnboardingProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
56
frontend/src/core/contexts/PreferencesContext.tsx
Normal file
56
frontend/src/core/contexts/PreferencesContext.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { preferencesService, UserPreferences } from '@app/services/preferencesService';
|
||||
|
||||
interface PreferencesContextValue {
|
||||
preferences: UserPreferences;
|
||||
updatePreference: <K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
) => void;
|
||||
resetPreferences: () => void;
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextValue | undefined>(undefined);
|
||||
|
||||
export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [preferences, setPreferences] = useState<UserPreferences>(() => {
|
||||
// Load preferences synchronously on mount
|
||||
return preferencesService.getAllPreferences();
|
||||
});
|
||||
|
||||
const updatePreference = useCallback(
|
||||
<K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
|
||||
preferencesService.setPreference(key, value);
|
||||
setPreferences((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetPreferences = useCallback(() => {
|
||||
preferencesService.clearAllPreferences();
|
||||
setPreferences(preferencesService.getAllPreferences());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider
|
||||
value={{
|
||||
preferences,
|
||||
updatePreference,
|
||||
resetPreferences,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PreferencesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePreferences = (): PreferencesContextValue => {
|
||||
const context = useContext(PreferencesContext);
|
||||
if (!context) {
|
||||
throw new Error('usePreferences must be used within a PreferencesProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
88
frontend/src/core/contexts/RightRailContext.tsx
Normal file
88
frontend/src/core/contexts/RightRailContext.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { RightRailAction, RightRailButtonConfig } from '@app/types/rightRail';
|
||||
|
||||
interface RightRailContextValue {
|
||||
buttons: RightRailButtonConfig[];
|
||||
actions: Record<string, RightRailAction>;
|
||||
allButtonsDisabled: boolean;
|
||||
registerButtons: (buttons: RightRailButtonConfig[]) => void;
|
||||
unregisterButtons: (ids: string[]) => void;
|
||||
setAction: (id: string, action?: RightRailAction) => void;
|
||||
setAllRightRailButtonsDisabled: (disabled: boolean) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const RightRailContext = createContext<RightRailContextValue | undefined>(undefined);
|
||||
|
||||
export function RightRailProvider({ children }: { children: React.ReactNode }) {
|
||||
const [buttons, setButtons] = useState<RightRailButtonConfig[]>([]);
|
||||
const [actions, setActions] = useState<Record<string, RightRailAction>>({});
|
||||
const [allButtonsDisabled, setAllButtonsDisabled] = useState<boolean>(false);
|
||||
|
||||
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
|
||||
setButtons(prev => {
|
||||
const byId = new Map(prev.map(b => [b.id, b] as const));
|
||||
newButtons.forEach(nb => {
|
||||
const existing = byId.get(nb.id) || ({} as RightRailButtonConfig);
|
||||
byId.set(nb.id, { ...existing, ...nb });
|
||||
});
|
||||
const merged = Array.from(byId.values());
|
||||
merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const ids = newButtons.map(b => b.id);
|
||||
const dupes = ids.filter((id, idx) => ids.indexOf(id) !== idx);
|
||||
if (dupes.length) console.warn('[RightRail] Duplicate ids in registerButtons:', dupes);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unregisterButtons = useCallback((ids: string[]) => {
|
||||
setButtons(prev => prev.filter(b => !ids.includes(b.id)));
|
||||
setActions(prev => Object.fromEntries(Object.entries(prev).filter(([id]) => !ids.includes(id))));
|
||||
}, []);
|
||||
|
||||
const setAction = useCallback((id: string, action?: RightRailAction) => {
|
||||
setActions(prev => {
|
||||
if (!action) {
|
||||
if (!(id in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [id]: action };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAllRightRailButtonsDisabled = useCallback((disabled: boolean) => {
|
||||
setAllButtonsDisabled(disabled);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setButtons([]);
|
||||
setActions({});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<RightRailContextValue>(() => ({
|
||||
buttons,
|
||||
actions,
|
||||
allButtonsDisabled,
|
||||
registerButtons,
|
||||
unregisterButtons,
|
||||
setAction,
|
||||
setAllRightRailButtonsDisabled,
|
||||
clear
|
||||
}), [buttons, actions, allButtonsDisabled, registerButtons, unregisterButtons, setAction, setAllRightRailButtonsDisabled, clear]);
|
||||
|
||||
return (
|
||||
<RightRailContext.Provider value={value}>
|
||||
{children}
|
||||
</RightRailContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRightRail() {
|
||||
const ctx = useContext(RightRailContext);
|
||||
if (!ctx) throw new Error('useRightRail must be used within RightRailProvider');
|
||||
return ctx;
|
||||
}
|
||||
49
frontend/src/core/contexts/SidebarContext.tsx
Normal file
49
frontend/src/core/contexts/SidebarContext.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createContext, useContext, useState, useRef, useMemo } from 'react';
|
||||
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '@app/types/sidebar';
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||
|
||||
export function SidebarProvider({ children }: SidebarProviderProps) {
|
||||
// All sidebar state management
|
||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||
const toolPanelRef = useRef<HTMLDivElement>(null);
|
||||
const rightRailRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||
const [readerMode, setReaderMode] = useState(false);
|
||||
|
||||
const sidebarState: SidebarState = useMemo(() => ({
|
||||
sidebarsVisible,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
}), [sidebarsVisible, leftPanelView, readerMode]);
|
||||
|
||||
const sidebarRefs: SidebarRefs = useMemo(() => ({
|
||||
quickAccessRef,
|
||||
toolPanelRef,
|
||||
rightRailRef,
|
||||
}), [quickAccessRef, toolPanelRef, rightRailRef]);
|
||||
|
||||
const contextValue: SidebarContextValue = useMemo(() => ({
|
||||
sidebarState,
|
||||
sidebarRefs,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
}), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarContext(): SidebarContextValue {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSidebarContext must be used within a SidebarProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
177
frontend/src/core/contexts/SignatureContext.tsx
Normal file
177
frontend/src/core/contexts/SignatureContext.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
|
||||
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
// Signature state interface
|
||||
interface SignatureState {
|
||||
// Current signature configuration from the tool
|
||||
signatureConfig: SignParameters | null;
|
||||
// Whether we're in signature placement mode
|
||||
isPlacementMode: boolean;
|
||||
// Whether signatures have been applied (allows export)
|
||||
signaturesApplied: boolean;
|
||||
}
|
||||
|
||||
// Signature actions interface
|
||||
interface SignatureActions {
|
||||
setSignatureConfig: (config: SignParameters | null) => void;
|
||||
setPlacementMode: (enabled: boolean) => void;
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
setSignaturesApplied: (applied: boolean) => void;
|
||||
}
|
||||
|
||||
// Combined context interface
|
||||
interface SignatureContextValue extends SignatureState, SignatureActions {
|
||||
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||
historyApiRef: React.RefObject<HistoryAPI | null>;
|
||||
}
|
||||
|
||||
// Create context
|
||||
const SignatureContext = createContext<SignatureContextValue | undefined>(undefined);
|
||||
|
||||
// Initial state
|
||||
const initialState: SignatureState = {
|
||||
signatureConfig: null,
|
||||
isPlacementMode: false,
|
||||
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||
};
|
||||
|
||||
// Provider component
|
||||
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<SignatureState>(initialState);
|
||||
const signatureApiRef = useRef<SignatureAPI>(null);
|
||||
const historyApiRef = useRef<HistoryAPI>(null);
|
||||
const imageDataStore = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Actions
|
||||
const setSignatureConfig = useCallback((config: SignParameters | null) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
signatureConfig: config,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setPlacementMode = useCallback((enabled: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isPlacementMode: enabled,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const activateDrawMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
setPlacementMode(true);
|
||||
// Mark signatures as not applied when entering draw mode
|
||||
setState(prev => ({ ...prev, signaturesApplied: false }));
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const deactivateDrawMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
setPlacementMode(false);
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const activateSignaturePlacementMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.activateSignaturePlacementMode();
|
||||
setPlacementMode(true);
|
||||
// Mark signatures as not applied when placing new signatures
|
||||
setState(prev => ({ ...prev, signaturesApplied: false }));
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const activateDeleteMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.activateDeleteMode();
|
||||
setPlacementMode(true);
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const updateDrawSettings = useCallback((color: string, size: number) => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.updateDrawSettings(color, size);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyApiRef.current) {
|
||||
historyApiRef.current.undo();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyApiRef.current) {
|
||||
historyApiRef.current.redo();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const storeImageData = useCallback((id: string, data: string) => {
|
||||
imageDataStore.current.set(id, data);
|
||||
}, []);
|
||||
|
||||
const getImageData = useCallback((id: string) => {
|
||||
return imageDataStore.current.get(id);
|
||||
}, []);
|
||||
|
||||
const setSignaturesApplied = useCallback((applied: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
signaturesApplied: applied,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// No auto-activation - all modes use manual buttons
|
||||
|
||||
const contextValue: SignatureContextValue = {
|
||||
...state,
|
||||
signatureApiRef,
|
||||
historyApiRef,
|
||||
setSignatureConfig,
|
||||
setPlacementMode,
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
};
|
||||
|
||||
return (
|
||||
<SignatureContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SignatureContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use signature context
|
||||
export const useSignature = (): SignatureContextValue => {
|
||||
const context = useContext(SignatureContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSignature must be used within a SignatureProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Hook for components that need to check if signature mode is active
|
||||
export const useSignatureMode = () => {
|
||||
const context = useContext(SignatureContext);
|
||||
return {
|
||||
isSignatureModeActive: context?.isPlacementMode || false,
|
||||
hasSignatureConfig: context?.signatureConfig !== null,
|
||||
};
|
||||
};
|
||||
30
frontend/src/core/contexts/ToolRegistryContext.tsx
Normal file
30
frontend/src/core/contexts/ToolRegistryContext.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type {
|
||||
ToolRegistryEntry,
|
||||
ToolRegistry,
|
||||
RegularToolRegistry,
|
||||
SuperToolRegistry,
|
||||
LinkToolRegistry,
|
||||
} from '@app/data/toolsTaxonomy';
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
|
||||
export interface ToolRegistryCatalog {
|
||||
regularTools: RegularToolRegistry;
|
||||
superTools: SuperToolRegistry;
|
||||
linkTools: LinkToolRegistry;
|
||||
allTools: ToolRegistry;
|
||||
getToolById: (toolId: ToolId | null) => ToolRegistryEntry | null;
|
||||
}
|
||||
|
||||
const ToolRegistryContext = createContext<ToolRegistryCatalog | null>(null);
|
||||
|
||||
export const useToolRegistry = (): ToolRegistryCatalog => {
|
||||
const context = useContext(ToolRegistryContext);
|
||||
if (context === null) {
|
||||
throw new Error('useToolRegistry must be used within a ToolRegistryProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default ToolRegistryContext;
|
||||
44
frontend/src/core/contexts/ToolRegistryProvider.tsx
Normal file
44
frontend/src/core/contexts/ToolRegistryProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
import type { ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||
import ToolRegistryContext, { ToolRegistryCatalog } from '@app/contexts/ToolRegistryContext';
|
||||
import { useTranslatedToolCatalog } from '@app/data/useTranslatedToolRegistry';
|
||||
|
||||
interface ToolRegistryProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ToolRegistryProvider: React.FC<ToolRegistryProviderProps> = ({ children }) => {
|
||||
const catalog = useTranslatedToolCatalog();
|
||||
|
||||
const contextValue = useMemo<ToolRegistryCatalog>(() => {
|
||||
const { regularTools, superTools, linkTools } = catalog;
|
||||
const allTools: ToolRegistry = {
|
||||
...regularTools,
|
||||
...superTools,
|
||||
...linkTools,
|
||||
};
|
||||
|
||||
const getToolById = (toolId: ToolId | null) => {
|
||||
if (!toolId) {
|
||||
return null;
|
||||
}
|
||||
return allTools[toolId] ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
regularTools,
|
||||
superTools,
|
||||
linkTools,
|
||||
allTools,
|
||||
getToolById,
|
||||
};
|
||||
}, [catalog]);
|
||||
|
||||
return (
|
||||
<ToolRegistryContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ToolRegistryContext.Provider>
|
||||
);
|
||||
};
|
||||
424
frontend/src/core/contexts/ToolWorkflowContext.tsx
Normal file
424
frontend/src/core/contexts/ToolWorkflowContext.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* ToolWorkflowContext - Manages tool selection, UI state, and workflow coordination
|
||||
* Eliminates prop drilling with a single, simple context
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useToolManagement } from '@app/hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '@app/types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { ToolId, isValidToolId } from '@app/types/toolId';
|
||||
import { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '@app/types/workbench';
|
||||
import { useNavigationUrlSync } from '@app/hooks/useUrlSync';
|
||||
import { filterToolRegistryByQuery } from '@app/utils/toolSearch';
|
||||
import { useToolHistory } from '@app/hooks/tools/useUserToolActivity';
|
||||
import {
|
||||
ToolWorkflowState,
|
||||
createInitialState,
|
||||
toolWorkflowReducer,
|
||||
} from '@app/contexts/toolWorkflow/toolWorkflowState';
|
||||
import type { ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
|
||||
|
||||
// State interface
|
||||
// Types and reducer/state moved to './toolWorkflow/state'
|
||||
|
||||
// Context value interface
|
||||
export interface CustomWorkbenchViewRegistration {
|
||||
id: string;
|
||||
workbenchId: WorkbenchType;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
component: React.ComponentType<{ data: any }>;
|
||||
}
|
||||
|
||||
export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration {
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
// Tool management (from hook)
|
||||
selectedToolKey: ToolId | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
setLeftPanelView: (view: 'toolPicker' | 'toolContent' | 'hidden') => void;
|
||||
setReaderMode: (mode: boolean) => void;
|
||||
setToolPanelMode: (mode: ToolPanelMode) => void;
|
||||
setPreviewFile: (file: File | null) => void;
|
||||
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
|
||||
|
||||
selectTool: (toolId: ToolId | null) => void;
|
||||
clearToolSelection: () => void;
|
||||
|
||||
// Tool Reset Actions
|
||||
toolResetFunctions: Record<string, () => void>;
|
||||
registerToolReset: (toolId: string, resetFunction: () => void) => void;
|
||||
resetTool: (toolId: string) => void;
|
||||
|
||||
// Workflow Actions (compound actions)
|
||||
handleToolSelect: (toolId: ToolId) => void;
|
||||
handleBackToTools: () => void;
|
||||
handleReaderToggle: () => void;
|
||||
|
||||
// Computed values
|
||||
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
|
||||
isPanelVisible: boolean;
|
||||
|
||||
// Tool History
|
||||
favoriteTools: ToolId[];
|
||||
toggleFavorite: (toolId: ToolId) => void;
|
||||
isFavorite: (toolId: ToolId) => boolean;
|
||||
|
||||
customWorkbenchViews: CustomWorkbenchViewInstance[];
|
||||
registerCustomWorkbenchView: (view: CustomWorkbenchViewRegistration) => void;
|
||||
unregisterCustomWorkbenchView: (id: string) => void;
|
||||
setCustomWorkbenchViewData: (id: string, data: any) => void;
|
||||
clearCustomWorkbenchViewData: (id: string) => void;
|
||||
}
|
||||
|
||||
// Ensure a single context instance across HMR to avoid provider/consumer mismatches
|
||||
const __GLOBAL_CONTEXT_KEY__ = '__ToolWorkflowContext__';
|
||||
const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as React.Context<ToolWorkflowContextValue | undefined> | undefined;
|
||||
const ToolWorkflowContext = existingContext ?? createContext<ToolWorkflowContextValue | undefined>(undefined);
|
||||
if (!existingContext) {
|
||||
(globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
|
||||
}
|
||||
|
||||
// Provider component
|
||||
interface ToolWorkflowProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const [state, dispatch] = useReducer(toolWorkflowReducer, undefined, createInitialState);
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
// Store reset functions for tools
|
||||
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
|
||||
|
||||
const [customViewRegistry, setCustomViewRegistry] = React.useState<Record<string, CustomWorkbenchViewRegistration>>({});
|
||||
const [customViewData, setCustomViewData] = React.useState<Record<string, any>>({});
|
||||
|
||||
// Navigation actions and state are available since we're inside NavigationProvider
|
||||
const { actions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
// Tool management hook
|
||||
const { toolRegistry, getSelectedTool } = useToolManagement();
|
||||
const { allTools } = useToolRegistry();
|
||||
|
||||
// Tool history hook
|
||||
const {
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
} = useToolHistory();
|
||||
|
||||
// Get selected tool from navigation context
|
||||
const selectedTool = getSelectedTool(navigationState.selectedTool);
|
||||
|
||||
// UI Action creators
|
||||
const setSidebarsVisible = useCallback((visible: boolean) => {
|
||||
dispatch({ type: 'SET_SIDEBARS_VISIBLE', payload: visible });
|
||||
}, []);
|
||||
|
||||
const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent' | 'hidden') => {
|
||||
dispatch({ type: 'SET_LEFT_PANEL_VIEW', payload: view });
|
||||
}, []);
|
||||
|
||||
const setReaderMode = useCallback((mode: boolean) => {
|
||||
if (mode) {
|
||||
actions.setWorkbench('viewer');
|
||||
actions.setSelectedTool('read');
|
||||
}
|
||||
dispatch({ type: 'SET_READER_MODE', payload: mode });
|
||||
}, [actions]);
|
||||
|
||||
const setToolPanelMode = useCallback((mode: ToolPanelMode) => {
|
||||
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode });
|
||||
updatePreference('defaultToolPanelMode', mode);
|
||||
}, [updatePreference]);
|
||||
|
||||
|
||||
const setPreviewFile = useCallback((file: File | null) => {
|
||||
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
|
||||
if (file) {
|
||||
actions.setWorkbench('viewer');
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => {
|
||||
dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions });
|
||||
}, []);
|
||||
|
||||
const setSearchQuery = useCallback((query: string) => {
|
||||
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
|
||||
}, []);
|
||||
|
||||
const registerCustomWorkbenchView = useCallback((view: CustomWorkbenchViewRegistration) => {
|
||||
setCustomViewRegistry(prev => ({ ...prev, [view.id]: view }));
|
||||
}, []);
|
||||
|
||||
const unregisterCustomWorkbenchView = useCallback((id: string) => {
|
||||
let removedView: CustomWorkbenchViewRegistration | undefined;
|
||||
|
||||
setCustomViewRegistry(prev => {
|
||||
const existing = prev[id];
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
removedView = existing;
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
|
||||
setCustomViewData(prev => {
|
||||
if (!(id in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (removedView && navigationState.workbench === removedView.workbenchId) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
}, [actions, navigationState.workbench]);
|
||||
|
||||
const setCustomWorkbenchViewData = useCallback((id: string, data: any) => {
|
||||
setCustomViewData(prev => ({ ...prev, [id]: data }));
|
||||
}, []);
|
||||
|
||||
const clearCustomWorkbenchViewData = useCallback((id: string) => {
|
||||
setCustomViewData(prev => {
|
||||
if (!(id in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const customWorkbenchViews = useMemo<CustomWorkbenchViewInstance[]>(() => {
|
||||
return Object.values(customViewRegistry).map(view => ({
|
||||
...view,
|
||||
data: Object.prototype.hasOwnProperty.call(customViewData, view.id) ? customViewData[view.id] : null,
|
||||
}));
|
||||
}, [customViewRegistry, customViewData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBaseWorkbench(navigationState.workbench)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
|
||||
if (!currentCustomView || currentCustomView.data == null) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
}, [actions, customWorkbenchViews, navigationState.workbench]);
|
||||
|
||||
// Persisted via PreferencesContext; no direct localStorage writes needed here
|
||||
|
||||
// Keep tool panel mode in sync with user preference. This ensures the
|
||||
// Config setting (Default tool picker mode) immediately affects the app
|
||||
// and persists across reloads.
|
||||
useEffect(() => {
|
||||
const preferredMode = preferences.defaultToolPanelMode;
|
||||
if (preferredMode !== state.toolPanelMode) {
|
||||
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: preferredMode });
|
||||
}
|
||||
}, [preferences.defaultToolPanelMode, state.toolPanelMode]);
|
||||
|
||||
// Tool reset methods
|
||||
const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => {
|
||||
setToolResetFunctions(prev => ({ ...prev, [toolId]: resetFunction }));
|
||||
}, []);
|
||||
|
||||
const resetTool = useCallback((toolId: string) => {
|
||||
// Use the current state directly instead of depending on the state in the closure
|
||||
setToolResetFunctions(current => {
|
||||
const resetFunction = current[toolId];
|
||||
if (resetFunction) {
|
||||
resetFunction();
|
||||
}
|
||||
return current; // Return the same state to avoid unnecessary updates
|
||||
});
|
||||
}, []); // Empty dependency array makes this stable
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: ToolId) => {
|
||||
// If we're currently on a custom workbench (e.g., Validate Signature report),
|
||||
// selecting any tool should take the user back to the default file manager view.
|
||||
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
|
||||
|
||||
// Handle read tool selection - should behave exactly like QuickAccessBar read button
|
||||
if (toolId === 'read') {
|
||||
setReaderMode(true);
|
||||
actions.setSelectedTool('read');
|
||||
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'viewer');
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multiTool selection - enable page editor workbench
|
||||
if (toolId === 'multiTool') {
|
||||
setReaderMode(false);
|
||||
setLeftPanelView('hidden');
|
||||
actions.setSelectedTool('multiTool');
|
||||
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'pageEditor');
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the selected tool and determine the appropriate workbench
|
||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||
actions.setSelectedTool(validToolId);
|
||||
|
||||
// Get the tool from registry to determine workbench
|
||||
const tool = getSelectedTool(toolId);
|
||||
if (wasInCustomWorkbench) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
} else if (tool && tool.workbench) {
|
||||
actions.setWorkbench(tool.workbench);
|
||||
} else {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
|
||||
// Clear search query when selecting a tool
|
||||
setSearchQuery('');
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
setReaderMode(false);
|
||||
actions.setSelectedTool(null);
|
||||
}, [setLeftPanelView, setReaderMode, actions.setSelectedTool]);
|
||||
|
||||
const handleReaderToggle = useCallback(() => {
|
||||
setReaderMode(true);
|
||||
}, [setReaderMode]);
|
||||
|
||||
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!toolRegistry) return [];
|
||||
return filterToolRegistryByQuery(toolRegistry, state.searchQuery);
|
||||
}, [toolRegistry, state.searchQuery]);
|
||||
|
||||
const isPanelVisible = useMemo(() =>
|
||||
state.sidebarsVisible && !state.readerMode && state.leftPanelView !== 'hidden',
|
||||
[state.sidebarsVisible, state.readerMode, state.leftPanelView]
|
||||
);
|
||||
|
||||
useNavigationUrlSync(
|
||||
navigationState.selectedTool,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
allTools,
|
||||
true
|
||||
);
|
||||
|
||||
// Properly memoized context value
|
||||
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
||||
// State
|
||||
...state,
|
||||
selectedToolKey: navigationState.selectedTool,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
|
||||
// Actions
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setToolPanelMode,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
selectTool: actions.setSelectedTool,
|
||||
clearToolSelection: () => actions.setSelectedTool(null),
|
||||
|
||||
// Tool Reset Actions
|
||||
toolResetFunctions,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
|
||||
// Workflow Actions
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
handleReaderToggle,
|
||||
|
||||
// Computed
|
||||
filteredTools,
|
||||
isPanelVisible,
|
||||
|
||||
// Tool History
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
|
||||
// Custom workbench views
|
||||
customWorkbenchViews,
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
}), [
|
||||
state,
|
||||
navigationState.selectedTool,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setToolPanelMode,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
actions.setSelectedTool,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
handleReaderToggle,
|
||||
filteredTools,
|
||||
isPanelVisible,
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
customWorkbenchViews,
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ToolWorkflowContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ToolWorkflowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom hook to use the context
|
||||
export function useToolWorkflow(): ToolWorkflowContextValue {
|
||||
const context = useContext(ToolWorkflowContext);
|
||||
if (!context) {
|
||||
console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
|
||||
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
207
frontend/src/core/contexts/TourOrchestrationContext.tsx
Normal file
207
frontend/src/core/contexts/TourOrchestrationContext.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useAllFiles, useFileManagement } from '@app/contexts/FileContext';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
|
||||
interface TourOrchestrationContextType {
|
||||
// State management
|
||||
saveWorkbenchState: () => void;
|
||||
restoreWorkbenchState: () => Promise<void>;
|
||||
|
||||
// Tool deselection
|
||||
backToAllTools: () => void;
|
||||
|
||||
// Tool selection
|
||||
selectCropTool: () => void;
|
||||
|
||||
// File operations
|
||||
loadSampleFile: () => Promise<void>;
|
||||
|
||||
// View switching
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
|
||||
// File operations
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
|
||||
// Crop settings (placeholder for now)
|
||||
modifyCropSettings: () => void;
|
||||
|
||||
// Tool execution
|
||||
executeTool: () => void;
|
||||
}
|
||||
|
||||
const TourOrchestrationContext = createContext<TourOrchestrationContextType | undefined>(undefined);
|
||||
|
||||
export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addFiles } = useFileHandler();
|
||||
const { closeFilesModal } = useFilesModalContext();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { handleToolSelect, handleBackToTools } = useToolWorkflow();
|
||||
const { files } = useAllFiles();
|
||||
const { clearAllFiles } = useFileManagement();
|
||||
|
||||
// Store the user's files before tour starts
|
||||
const savedFilesRef = useRef<StirlingFile[]>([]);
|
||||
|
||||
// Keep a ref to always have the latest files
|
||||
const filesRef = useRef<StirlingFile[]>(files);
|
||||
React.useEffect(() => {
|
||||
filesRef.current = files;
|
||||
}, [files]);
|
||||
|
||||
const saveWorkbenchState = useCallback(() => {
|
||||
// Get fresh files from ref
|
||||
const currentFiles = filesRef.current;
|
||||
console.log('Saving workbench state, files count:', currentFiles.length);
|
||||
savedFilesRef.current = [...currentFiles];
|
||||
// Clear all files for clean demo
|
||||
clearAllFiles();
|
||||
}, [clearAllFiles]);
|
||||
|
||||
const restoreWorkbenchState = useCallback(async () => {
|
||||
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
|
||||
|
||||
// Go back to All Tools
|
||||
handleBackToTools();
|
||||
|
||||
// Clear all files (including tour sample)
|
||||
clearAllFiles();
|
||||
|
||||
// Delete all active files from storage (they're just the ones from the tour)
|
||||
const currentFiles = filesRef.current;
|
||||
if (currentFiles.length > 0) {
|
||||
try {
|
||||
await Promise.all(currentFiles.map(file => fileStorage.deleteStirlingFile(file.fileId)));
|
||||
console.log(`Deleted ${currentFiles.length} file(s) from storage`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore saved files
|
||||
if (savedFilesRef.current.length > 0) {
|
||||
// Create fresh File objects from StirlingFile to avoid ID conflicts
|
||||
const filesToRestore = await Promise.all(
|
||||
savedFilesRef.current.map(async (sf) => {
|
||||
const buffer = await sf.arrayBuffer();
|
||||
return new File([buffer], sf.name, { type: sf.type, lastModified: sf.lastModified });
|
||||
})
|
||||
);
|
||||
console.log('Restoring files:', filesToRestore.map(f => f.name));
|
||||
await addFiles(filesToRestore);
|
||||
savedFilesRef.current = [];
|
||||
}
|
||||
}, [clearAllFiles, addFiles, handleBackToTools]);
|
||||
|
||||
const backToAllTools = useCallback(() => {
|
||||
handleBackToTools();
|
||||
}, [handleBackToTools]);
|
||||
|
||||
const selectCropTool = useCallback(() => {
|
||||
handleToolSelect('crop');
|
||||
}, [handleToolSelect]);
|
||||
|
||||
const loadSampleFile = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/samples/Sample.pdf');
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
|
||||
|
||||
await addFiles([file]);
|
||||
closeFilesModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to load sample file:', error);
|
||||
}
|
||||
}, [addFiles, closeFilesModal]);
|
||||
|
||||
const switchToViewer = useCallback(() => {
|
||||
navActions.setWorkbench('viewer');
|
||||
}, [navActions]);
|
||||
|
||||
const switchToPageEditor = useCallback(() => {
|
||||
navActions.setWorkbench('pageEditor');
|
||||
}, [navActions]);
|
||||
|
||||
const switchToActiveFiles = useCallback(() => {
|
||||
navActions.setWorkbench('fileEditor');
|
||||
}, [navActions]);
|
||||
|
||||
const selectFirstFile = useCallback(() => {
|
||||
// File selection is handled by FileCard onClick
|
||||
// This function could trigger a click event on the first file card
|
||||
const firstFileCard = document.querySelector('[data-tour="file-card-checkbox"]') as HTMLElement;
|
||||
if (firstFileCard) {
|
||||
// Check if already selected (data-selected attribute)
|
||||
const isSelected = firstFileCard.getAttribute('data-selected') === 'true';
|
||||
// Only click if not already selected (to avoid toggling off)
|
||||
if (!isSelected) {
|
||||
firstFileCard.click();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pinFile = useCallback(() => {
|
||||
// Click the pin button directly
|
||||
const pinButton = document.querySelector('[data-tour="file-card-pin"]') as HTMLElement;
|
||||
if (pinButton) {
|
||||
pinButton.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modifyCropSettings = useCallback(() => {
|
||||
// Dispatch a custom event to modify crop settings
|
||||
const event = new CustomEvent('tour:setCropArea', {
|
||||
detail: {
|
||||
x: 80,
|
||||
y: 435,
|
||||
width: 440,
|
||||
height: 170,
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
|
||||
const executeTool = useCallback(() => {
|
||||
// Trigger the run button click
|
||||
const runButton = document.querySelector('[data-tour="run-button"]') as HTMLElement;
|
||||
if (runButton) {
|
||||
runButton.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: TourOrchestrationContextType = {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
};
|
||||
|
||||
return (
|
||||
<TourOrchestrationContext.Provider value={value}>
|
||||
{children}
|
||||
</TourOrchestrationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTourOrchestration = (): TourOrchestrationContextType => {
|
||||
const context = useContext(TourOrchestrationContext);
|
||||
if (!context) {
|
||||
throw new Error('useTourOrchestration must be used within TourOrchestrationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
633
frontend/src/core/contexts/ViewerContext.tsx
Normal file
633
frontend/src/core/contexts/ViewerContext.tsx
Normal file
@@ -0,0 +1,633 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||
|
||||
// Bridge API interfaces - these match what the bridges provide
|
||||
interface ScrollAPIWrapper {
|
||||
scrollToPage: (params: { pageNumber: number }) => void;
|
||||
scrollToPreviousPage: () => void;
|
||||
scrollToNextPage: () => void;
|
||||
}
|
||||
|
||||
interface ZoomAPIWrapper {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
toggleMarqueeZoom: () => void;
|
||||
requestZoom: (level: number) => void;
|
||||
}
|
||||
|
||||
interface PanAPIWrapper {
|
||||
enable: () => void;
|
||||
disable: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
interface SelectionAPIWrapper {
|
||||
copyToClipboard: () => void;
|
||||
getSelectedText: () => string | any;
|
||||
getFormattedSelection: () => any;
|
||||
}
|
||||
|
||||
interface SpreadAPIWrapper {
|
||||
setSpreadMode: (mode: SpreadMode) => void;
|
||||
getSpreadMode: () => SpreadMode | null;
|
||||
toggleSpreadMode: () => void;
|
||||
}
|
||||
|
||||
interface RotationAPIWrapper {
|
||||
rotateForward: () => void;
|
||||
rotateBackward: () => void;
|
||||
setRotation: (rotation: number) => void;
|
||||
getRotation: () => number;
|
||||
}
|
||||
|
||||
interface SearchAPIWrapper {
|
||||
search: (query: string) => Promise<any>;
|
||||
clear: () => void;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
}
|
||||
|
||||
interface ThumbnailAPIWrapper {
|
||||
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
|
||||
}
|
||||
|
||||
interface ExportAPIWrapper {
|
||||
download: () => void;
|
||||
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
|
||||
}
|
||||
|
||||
|
||||
// State interfaces - represent the shape of data from each bridge
|
||||
interface ScrollState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface ZoomState {
|
||||
currentZoom: number;
|
||||
zoomPercent: number;
|
||||
}
|
||||
|
||||
interface PanState {
|
||||
isPanning: boolean;
|
||||
}
|
||||
|
||||
interface SelectionState {
|
||||
hasSelection: boolean;
|
||||
}
|
||||
|
||||
interface SpreadState {
|
||||
spreadMode: SpreadMode;
|
||||
isDualPage: boolean;
|
||||
}
|
||||
|
||||
interface RotationState {
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
pageIndex: number;
|
||||
rects: Array<{
|
||||
origin: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
results: SearchResult[] | null;
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
interface ExportState {
|
||||
canExport: boolean;
|
||||
}
|
||||
|
||||
// Bridge registration interface - bridges register with state and API
|
||||
interface BridgeRef<TState = unknown, TApi = unknown> {
|
||||
state: TState;
|
||||
api: TApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewerContext provides a unified interface to EmbedPDF functionality.
|
||||
*
|
||||
* Architecture:
|
||||
* - Bridges store their own state locally and register with this context
|
||||
* - Context provides read-only access to bridge state via getter functions
|
||||
* - Actions call EmbedPDF APIs directly through bridge references
|
||||
* - No circular dependencies - bridges don't call back into this context
|
||||
*/
|
||||
interface ViewerContextType {
|
||||
// UI state managed by this context
|
||||
isThumbnailSidebarVisible: boolean;
|
||||
toggleThumbnailSidebar: () => void;
|
||||
|
||||
// Annotation visibility toggle
|
||||
isAnnotationsVisible: boolean;
|
||||
toggleAnnotationsVisibility: () => void;
|
||||
|
||||
// Annotation/drawing mode for viewer
|
||||
isAnnotationMode: boolean;
|
||||
setAnnotationMode: (enabled: boolean) => void;
|
||||
toggleAnnotationMode: () => void;
|
||||
|
||||
// Active file index for multi-file viewing
|
||||
activeFileIndex: number;
|
||||
setActiveFileIndex: (index: number) => void;
|
||||
|
||||
// State getters - read current state from bridges
|
||||
getScrollState: () => ScrollState;
|
||||
getZoomState: () => ZoomState;
|
||||
getPanState: () => PanState;
|
||||
getSelectionState: () => SelectionState;
|
||||
getSpreadState: () => SpreadState;
|
||||
getRotationState: () => RotationState;
|
||||
getSearchState: () => SearchState;
|
||||
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
|
||||
getExportState: () => ExportState;
|
||||
|
||||
// Immediate update callbacks
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
|
||||
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
|
||||
|
||||
// Internal - for bridges to trigger immediate updates
|
||||
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
|
||||
triggerImmediateZoomUpdate: (zoomPercent: number) => void;
|
||||
|
||||
// Action handlers - call EmbedPDF APIs directly
|
||||
scrollActions: {
|
||||
scrollToPage: (page: number) => void;
|
||||
scrollToFirstPage: () => void;
|
||||
scrollToPreviousPage: () => void;
|
||||
scrollToNextPage: () => void;
|
||||
scrollToLastPage: () => void;
|
||||
};
|
||||
|
||||
zoomActions: {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
toggleMarqueeZoom: () => void;
|
||||
requestZoom: (level: number) => void;
|
||||
};
|
||||
|
||||
panActions: {
|
||||
enablePan: () => void;
|
||||
disablePan: () => void;
|
||||
togglePan: () => void;
|
||||
};
|
||||
|
||||
selectionActions: {
|
||||
copyToClipboard: () => void;
|
||||
getSelectedText: () => string;
|
||||
getFormattedSelection: () => unknown;
|
||||
};
|
||||
|
||||
spreadActions: {
|
||||
setSpreadMode: (mode: SpreadMode) => void;
|
||||
getSpreadMode: () => SpreadMode | null;
|
||||
toggleSpreadMode: () => void;
|
||||
};
|
||||
|
||||
rotationActions: {
|
||||
rotateForward: () => void;
|
||||
rotateBackward: () => void;
|
||||
setRotation: (rotation: number) => void;
|
||||
getRotation: () => number;
|
||||
};
|
||||
|
||||
searchActions: {
|
||||
search: (query: string) => Promise<void>;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
exportActions: {
|
||||
download: () => void;
|
||||
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
||||
};
|
||||
|
||||
// Bridge registration - internal use by bridges
|
||||
registerBridge: (type: string, ref: BridgeRef) => void;
|
||||
}
|
||||
|
||||
export const ViewerContext = createContext<ViewerContextType | null>(null);
|
||||
|
||||
interface ViewerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||
|
||||
// Get current navigation state to check if we're in sign mode
|
||||
useNavigation();
|
||||
|
||||
// Bridge registry - bridges register their state and APIs here
|
||||
const bridgeRefs = useRef({
|
||||
scroll: null as BridgeRef<ScrollState, ScrollAPIWrapper> | null,
|
||||
zoom: null as BridgeRef<ZoomState, ZoomAPIWrapper> | null,
|
||||
pan: null as BridgeRef<PanState, PanAPIWrapper> | null,
|
||||
selection: null as BridgeRef<SelectionState, SelectionAPIWrapper> | null,
|
||||
search: null as BridgeRef<SearchState, SearchAPIWrapper> | null,
|
||||
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
|
||||
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
|
||||
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
|
||||
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
|
||||
});
|
||||
|
||||
// Immediate zoom callback for responsive display updates
|
||||
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
|
||||
|
||||
// Immediate scroll callback for responsive display updates
|
||||
const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null);
|
||||
|
||||
const registerBridge = (type: string, ref: BridgeRef) => {
|
||||
// Type-safe assignment - we know the bridges will provide correct types
|
||||
switch (type) {
|
||||
case 'scroll':
|
||||
bridgeRefs.current.scroll = ref as BridgeRef<ScrollState, ScrollAPIWrapper>;
|
||||
break;
|
||||
case 'zoom':
|
||||
bridgeRefs.current.zoom = ref as BridgeRef<ZoomState, ZoomAPIWrapper>;
|
||||
break;
|
||||
case 'pan':
|
||||
bridgeRefs.current.pan = ref as BridgeRef<PanState, PanAPIWrapper>;
|
||||
break;
|
||||
case 'selection':
|
||||
bridgeRefs.current.selection = ref as BridgeRef<SelectionState, SelectionAPIWrapper>;
|
||||
break;
|
||||
case 'search':
|
||||
bridgeRefs.current.search = ref as BridgeRef<SearchState, SearchAPIWrapper>;
|
||||
break;
|
||||
case 'spread':
|
||||
bridgeRefs.current.spread = ref as BridgeRef<SpreadState, SpreadAPIWrapper>;
|
||||
break;
|
||||
case 'rotation':
|
||||
bridgeRefs.current.rotation = ref as BridgeRef<RotationState, RotationAPIWrapper>;
|
||||
break;
|
||||
case 'thumbnail':
|
||||
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
|
||||
break;
|
||||
case 'export':
|
||||
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleThumbnailSidebar = () => {
|
||||
setIsThumbnailSidebarVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const toggleAnnotationsVisibility = () => {
|
||||
setIsAnnotationsVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const setAnnotationMode = (enabled: boolean) => {
|
||||
setIsAnnotationModeState(enabled);
|
||||
};
|
||||
|
||||
const toggleAnnotationMode = () => {
|
||||
setIsAnnotationModeState(prev => !prev);
|
||||
};
|
||||
|
||||
// State getters - read from bridge refs
|
||||
const getScrollState = (): ScrollState => {
|
||||
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
|
||||
};
|
||||
|
||||
const getZoomState = (): ZoomState => {
|
||||
return bridgeRefs.current.zoom?.state || { currentZoom: 1.4, zoomPercent: 140 };
|
||||
};
|
||||
|
||||
const getPanState = (): PanState => {
|
||||
return bridgeRefs.current.pan?.state || { isPanning: false };
|
||||
};
|
||||
|
||||
const getSelectionState = (): SelectionState => {
|
||||
return bridgeRefs.current.selection?.state || { hasSelection: false };
|
||||
};
|
||||
|
||||
const getSpreadState = (): SpreadState => {
|
||||
return bridgeRefs.current.spread?.state || { spreadMode: SpreadMode.None, isDualPage: false };
|
||||
};
|
||||
|
||||
const getRotationState = (): RotationState => {
|
||||
return bridgeRefs.current.rotation?.state || { rotation: 0 };
|
||||
};
|
||||
|
||||
const getSearchState = (): SearchState => {
|
||||
return bridgeRefs.current.search?.state || { results: null, activeIndex: 0 };
|
||||
};
|
||||
|
||||
const getThumbnailAPI = () => {
|
||||
return bridgeRefs.current.thumbnail?.api || null;
|
||||
};
|
||||
|
||||
const getExportState = (): ExportState => {
|
||||
return bridgeRefs.current.export?.state || { canExport: false };
|
||||
};
|
||||
|
||||
// Action handlers - call APIs directly
|
||||
const scrollActions = {
|
||||
scrollToPage: (page: number) => {
|
||||
const api = bridgeRefs.current.scroll?.api;
|
||||
if (api?.scrollToPage) {
|
||||
api.scrollToPage({ pageNumber: page });
|
||||
}
|
||||
},
|
||||
scrollToFirstPage: () => {
|
||||
const api = bridgeRefs.current.scroll?.api;
|
||||
if (api?.scrollToPage) {
|
||||
api.scrollToPage({ pageNumber: 1 });
|
||||
}
|
||||
},
|
||||
scrollToPreviousPage: () => {
|
||||
const api = bridgeRefs.current.scroll?.api;
|
||||
if (api?.scrollToPreviousPage) {
|
||||
api.scrollToPreviousPage();
|
||||
}
|
||||
},
|
||||
scrollToNextPage: () => {
|
||||
const api = bridgeRefs.current.scroll?.api;
|
||||
if (api?.scrollToNextPage) {
|
||||
api.scrollToNextPage();
|
||||
}
|
||||
},
|
||||
scrollToLastPage: () => {
|
||||
const scrollState = getScrollState();
|
||||
const api = bridgeRefs.current.scroll?.api;
|
||||
if (api?.scrollToPage && scrollState.totalPages > 0) {
|
||||
api.scrollToPage({ pageNumber: scrollState.totalPages });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const zoomActions = {
|
||||
zoomIn: () => {
|
||||
const api = bridgeRefs.current.zoom?.api;
|
||||
if (api?.zoomIn) {
|
||||
// Update display immediately if callback is registered
|
||||
if (immediateZoomUpdateCallback.current) {
|
||||
const currentState = getZoomState();
|
||||
const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300);
|
||||
immediateZoomUpdateCallback.current(newPercent);
|
||||
}
|
||||
api.zoomIn();
|
||||
}
|
||||
},
|
||||
zoomOut: () => {
|
||||
const api = bridgeRefs.current.zoom?.api;
|
||||
if (api?.zoomOut) {
|
||||
// Update display immediately if callback is registered
|
||||
if (immediateZoomUpdateCallback.current) {
|
||||
const currentState = getZoomState();
|
||||
const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20);
|
||||
immediateZoomUpdateCallback.current(newPercent);
|
||||
}
|
||||
api.zoomOut();
|
||||
}
|
||||
},
|
||||
toggleMarqueeZoom: () => {
|
||||
const api = bridgeRefs.current.zoom?.api;
|
||||
if (api?.toggleMarqueeZoom) {
|
||||
api.toggleMarqueeZoom();
|
||||
}
|
||||
},
|
||||
requestZoom: (level: number) => {
|
||||
const api = bridgeRefs.current.zoom?.api;
|
||||
if (api?.requestZoom) {
|
||||
api.requestZoom(level);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const panActions = {
|
||||
enablePan: () => {
|
||||
const api = bridgeRefs.current.pan?.api;
|
||||
if (api?.enable) {
|
||||
api.enable();
|
||||
}
|
||||
},
|
||||
disablePan: () => {
|
||||
const api = bridgeRefs.current.pan?.api;
|
||||
if (api?.disable) {
|
||||
api.disable();
|
||||
}
|
||||
},
|
||||
togglePan: () => {
|
||||
const api = bridgeRefs.current.pan?.api;
|
||||
if (api?.toggle) {
|
||||
api.toggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectionActions = {
|
||||
copyToClipboard: () => {
|
||||
const api = bridgeRefs.current.selection?.api;
|
||||
if (api?.copyToClipboard) {
|
||||
api.copyToClipboard();
|
||||
}
|
||||
},
|
||||
getSelectedText: () => {
|
||||
const api = bridgeRefs.current.selection?.api;
|
||||
if (api?.getSelectedText) {
|
||||
return api.getSelectedText();
|
||||
}
|
||||
return '';
|
||||
},
|
||||
getFormattedSelection: () => {
|
||||
const api = bridgeRefs.current.selection?.api;
|
||||
if (api?.getFormattedSelection) {
|
||||
return api.getFormattedSelection();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const spreadActions = {
|
||||
setSpreadMode: (mode: SpreadMode) => {
|
||||
const api = bridgeRefs.current.spread?.api;
|
||||
if (api?.setSpreadMode) {
|
||||
api.setSpreadMode(mode);
|
||||
}
|
||||
},
|
||||
getSpreadMode: () => {
|
||||
const api = bridgeRefs.current.spread?.api;
|
||||
if (api?.getSpreadMode) {
|
||||
return api.getSpreadMode();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
toggleSpreadMode: () => {
|
||||
const api = bridgeRefs.current.spread?.api;
|
||||
if (api?.toggleSpreadMode) {
|
||||
api.toggleSpreadMode();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const rotationActions = {
|
||||
rotateForward: () => {
|
||||
const api = bridgeRefs.current.rotation?.api;
|
||||
if (api?.rotateForward) {
|
||||
api.rotateForward();
|
||||
}
|
||||
},
|
||||
rotateBackward: () => {
|
||||
const api = bridgeRefs.current.rotation?.api;
|
||||
if (api?.rotateBackward) {
|
||||
api.rotateBackward();
|
||||
}
|
||||
},
|
||||
setRotation: (rotation: number) => {
|
||||
const api = bridgeRefs.current.rotation?.api;
|
||||
if (api?.setRotation) {
|
||||
api.setRotation(rotation);
|
||||
}
|
||||
},
|
||||
getRotation: () => {
|
||||
const api = bridgeRefs.current.rotation?.api;
|
||||
if (api?.getRotation) {
|
||||
return api.getRotation();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const searchActions = {
|
||||
search: async (query: string) => {
|
||||
const api = bridgeRefs.current.search?.api;
|
||||
if (api?.search) {
|
||||
return api.search(query);
|
||||
}
|
||||
},
|
||||
next: () => {
|
||||
const api = bridgeRefs.current.search?.api;
|
||||
if (api?.next) {
|
||||
api.next();
|
||||
}
|
||||
},
|
||||
previous: () => {
|
||||
const api = bridgeRefs.current.search?.api;
|
||||
if (api?.previous) {
|
||||
api.previous();
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
const api = bridgeRefs.current.search?.api;
|
||||
if (api?.clear) {
|
||||
api.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const exportActions = {
|
||||
download: () => {
|
||||
const api = bridgeRefs.current.export?.api;
|
||||
if (api?.download) {
|
||||
api.download();
|
||||
}
|
||||
},
|
||||
saveAsCopy: async () => {
|
||||
const api = bridgeRefs.current.export?.api;
|
||||
if (api?.saveAsCopy) {
|
||||
try {
|
||||
const result = api.saveAsCopy();
|
||||
return await result.toPromise();
|
||||
} catch (error) {
|
||||
console.error('Failed to save PDF copy:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
|
||||
immediateZoomUpdateCallback.current = callback;
|
||||
};
|
||||
|
||||
const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => {
|
||||
immediateScrollUpdateCallback.current = callback;
|
||||
};
|
||||
|
||||
const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => {
|
||||
if (immediateScrollUpdateCallback.current) {
|
||||
immediateScrollUpdateCallback.current(currentPage, totalPages);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerImmediateZoomUpdate = (zoomPercent: number) => {
|
||||
if (immediateZoomUpdateCallback.current) {
|
||||
immediateZoomUpdateCallback.current(zoomPercent);
|
||||
}
|
||||
};
|
||||
|
||||
const value: ViewerContextType = {
|
||||
// UI state
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
|
||||
// Annotation controls
|
||||
isAnnotationsVisible,
|
||||
toggleAnnotationsVisibility,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
toggleAnnotationMode,
|
||||
|
||||
// Active file index
|
||||
activeFileIndex,
|
||||
setActiveFileIndex,
|
||||
|
||||
// State getters
|
||||
getScrollState,
|
||||
getZoomState,
|
||||
getPanState,
|
||||
getSelectionState,
|
||||
getSpreadState,
|
||||
getRotationState,
|
||||
getSearchState,
|
||||
getThumbnailAPI,
|
||||
getExportState,
|
||||
|
||||
// Immediate updates
|
||||
registerImmediateZoomUpdate,
|
||||
registerImmediateScrollUpdate,
|
||||
triggerImmediateScrollUpdate,
|
||||
triggerImmediateZoomUpdate,
|
||||
|
||||
// Actions
|
||||
scrollActions,
|
||||
zoomActions,
|
||||
panActions,
|
||||
selectionActions,
|
||||
spreadActions,
|
||||
rotationActions,
|
||||
searchActions,
|
||||
exportActions,
|
||||
|
||||
// Bridge registration
|
||||
registerBridge,
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewerContext.Provider value={value}>
|
||||
{children}
|
||||
</ViewerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useViewer = (): ViewerContextType => {
|
||||
const context = useContext(ViewerContext);
|
||||
if (!context) {
|
||||
throw new Error('useViewer must be used within a ViewerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
289
frontend/src/core/contexts/file/FileReducer.ts
Normal file
289
frontend/src/core/contexts/file/FileReducer.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* FileContext reducer - Pure state management for file operations
|
||||
*/
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
import {
|
||||
FileContextState,
|
||||
FileContextAction,
|
||||
StirlingFileStub
|
||||
} from '@app/types/fileContext';
|
||||
|
||||
// Initial state
|
||||
export const initialFileContextState: FileContextState = {
|
||||
files: {
|
||||
ids: [],
|
||||
byId: {}
|
||||
},
|
||||
pinnedFiles: new Set(),
|
||||
ui: {
|
||||
selectedFileIds: [],
|
||||
selectedPageNumbers: [],
|
||||
isProcessing: false,
|
||||
processingProgress: 0,
|
||||
hasUnsavedChanges: false,
|
||||
errorFileIds: []
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function for consume/undo operations
|
||||
function processFileSwap(
|
||||
state: FileContextState,
|
||||
filesToRemove: FileId[],
|
||||
filesToAdd: StirlingFileStub[]
|
||||
): FileContextState {
|
||||
// Only remove unpinned files
|
||||
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||
const remainingIds = state.files.ids.filter(id => !unpinnedRemoveIds.includes(id));
|
||||
|
||||
// Remove unpinned files from state
|
||||
const newById = { ...state.files.byId };
|
||||
unpinnedRemoveIds.forEach(id => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Add new files
|
||||
const addedIds: FileId[] = [];
|
||||
filesToAdd.forEach(record => {
|
||||
if (!newById[record.id]) {
|
||||
addedIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files and add new files to selection
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
|
||||
const newSelectedFileIds = [...validSelectedFileIds, ...addedIds];
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...addedIds, ...remainingIds],
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: newSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Pure reducer function
|
||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||
switch (action.type) {
|
||||
case 'ADD_FILES': {
|
||||
const { stirlingFileStubs } = action.payload;
|
||||
const newIds: FileId[] = [];
|
||||
const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
|
||||
|
||||
stirlingFileStubs.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
|
||||
}
|
||||
|
||||
const updatedRecord = {
|
||||
...existingRecord,
|
||||
...updates
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
byId: {
|
||||
...state.files.byId,
|
||||
[id]: updatedRecord
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'REORDER_FILES': {
|
||||
const { orderedFileIds } = action.payload;
|
||||
|
||||
// Validate that all IDs exist in current state
|
||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||
// Reorder selected files by passed order
|
||||
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
ids: validIds
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 'MARK_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
if (state.ui.errorFileIds.includes(fileId)) return state;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [...state.ui.errorFileIds, fileId] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: state.ui.errorFileIds.filter(id => id !== fileId) }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ALL_FILE_ERRORS': {
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'PIN_FILE': {
|
||||
const { fileId } = action.payload;
|
||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||
newPinnedFiles.add(fileId);
|
||||
|
||||
return {
|
||||
...state,
|
||||
pinnedFiles: newPinnedFiles
|
||||
};
|
||||
}
|
||||
|
||||
case 'UNPIN_FILE': {
|
||||
const { fileId } = action.payload;
|
||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||
newPinnedFiles.delete(fileId);
|
||||
|
||||
return {
|
||||
...state,
|
||||
pinnedFiles: newPinnedFiles
|
||||
};
|
||||
}
|
||||
|
||||
case 'CONSUME_FILES': {
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
}
|
||||
|
||||
|
||||
case 'UNDO_CONSUME_FILES': {
|
||||
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||
|
||||
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||
}
|
||||
|
||||
case 'RESET_CONTEXT': {
|
||||
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
||||
return { ...initialFileContextState };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
13
frontend/src/core/contexts/file/contexts.ts
Normal file
13
frontend/src/core/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 '@app/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 };
|
||||
632
frontend/src/core/contexts/file/fileActions.ts
Normal file
632
frontend/src/core/contexts/file/fileActions.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* File actions - Unified file operations with single addFiles helper
|
||||
*/
|
||||
|
||||
import {
|
||||
StirlingFileStub,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
createNewStirlingFileStub,
|
||||
createFileId,
|
||||
createQuickKey,
|
||||
createStirlingFile,
|
||||
ProcessedFileMetadata,
|
||||
} from '@app/types/fileContext';
|
||||
import { FileId, ToolOperation } from '@app/types/file';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
|
||||
import { buildQuickKeySet } from '@app/contexts/file/fileSelectors';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Simple mutex to prevent race conditions in addFiles
|
||||
*/
|
||||
class SimpleMutex {
|
||||
private locked = false;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
async lock(): Promise<void> {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(() => {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
unlock(): void {
|
||||
if (this.queue.length > 0) {
|
||||
const next = this.queue.shift()!;
|
||||
next();
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global mutex for addFiles operations
|
||||
const addFilesMutex = new SimpleMutex();
|
||||
|
||||
/**
|
||||
* Helper to create ProcessedFile metadata structure
|
||||
*/
|
||||
export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) {
|
||||
return {
|
||||
totalPages: pageCount,
|
||||
pages: Array.from({ length: pageCount }, (_, index) => ({
|
||||
pageNumber: index + 1,
|
||||
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
|
||||
rotation: pageRotations?.[index] ?? 0,
|
||||
splitBefore: false
|
||||
})),
|
||||
thumbnailUrl: thumbnail,
|
||||
lastProcessed: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fresh ProcessedFileMetadata for a file
|
||||
* Used when tools process files to ensure metadata matches actual file content
|
||||
*/
|
||||
export async function generateProcessedFileMetadata(file: File): Promise<ProcessedFileMetadata | undefined> {
|
||||
// Only generate metadata for PDF files
|
||||
if (!file.type.startsWith('application/pdf')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate unrotated thumbnails for PageEditor (rotation applied via CSS)
|
||||
const unrotatedResult = await generateThumbnailWithMetadata(file, false);
|
||||
|
||||
// Generate rotated thumbnail for file manager display
|
||||
const rotatedResult = await generateThumbnailWithMetadata(file, true);
|
||||
|
||||
const processedFile = createProcessedFile(
|
||||
unrotatedResult.pageCount,
|
||||
unrotatedResult.thumbnail, // Page thumbnails (unrotated)
|
||||
unrotatedResult.pageRotations
|
||||
);
|
||||
|
||||
// Use rotated thumbnail for file manager
|
||||
processedFile.thumbnailUrl = rotatedResult.thumbnail;
|
||||
|
||||
return processedFile;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child StirlingFileStub from a parent stub with proper history management.
|
||||
* Used when a tool processes an existing file to create a new version with incremented history.
|
||||
*
|
||||
* @param parentStub - The parent StirlingFileStub to create a child from
|
||||
* @param operation - Tool operation information (toolName, timestamp)
|
||||
* @param resultingFile - The processed File object
|
||||
* @param thumbnail - Optional thumbnail for the child
|
||||
* @param processedFileMetadata - Optional fresh metadata for the processed file
|
||||
* @returns New child StirlingFileStub with proper version history
|
||||
*/
|
||||
export function createChildStub(
|
||||
parentStub: StirlingFileStub,
|
||||
operation: ToolOperation,
|
||||
resultingFile: File,
|
||||
thumbnail?: string,
|
||||
processedFileMetadata?: ProcessedFileMetadata
|
||||
): StirlingFileStub {
|
||||
const newFileId = createFileId();
|
||||
|
||||
// Build new tool history by appending to parent's history
|
||||
const parentToolHistory = parentStub.toolHistory || [];
|
||||
const newToolHistory = [...parentToolHistory, operation];
|
||||
|
||||
// Calculate new version number
|
||||
const newVersionNumber = (parentStub.versionNumber || 1) + 1;
|
||||
|
||||
// Determine original file ID (root of the version chain)
|
||||
const originalFileId = parentStub.originalFileId || parentStub.id;
|
||||
|
||||
// Copy parent metadata but exclude processedFile to prevent stale data
|
||||
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
||||
|
||||
return {
|
||||
// Copy parent metadata (excluding processedFile)
|
||||
...parentMetadata,
|
||||
|
||||
// Update identity and version info
|
||||
id: newFileId,
|
||||
versionNumber: newVersionNumber,
|
||||
parentFileId: parentStub.id,
|
||||
originalFileId: originalFileId,
|
||||
toolHistory: newToolHistory,
|
||||
createdAt: Date.now(),
|
||||
isLeaf: true, // New child is the current leaf node
|
||||
name: resultingFile.name,
|
||||
size: resultingFile.size,
|
||||
type: resultingFile.type,
|
||||
lastModified: resultingFile.lastModified,
|
||||
thumbnailUrl: thumbnail,
|
||||
|
||||
// Set fresh processedFile metadata (no inheritance from parent)
|
||||
processedFile: processedFileMetadata
|
||||
};
|
||||
}
|
||||
|
||||
interface AddFileOptions {
|
||||
files?: File[];
|
||||
|
||||
// For 'processed' files
|
||||
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||
|
||||
// Insertion position
|
||||
insertAfterPageId?: string;
|
||||
|
||||
// Auto-selection after adding
|
||||
selectFiles?: boolean;
|
||||
|
||||
// Auto-unzip control
|
||||
autoUnzip?: boolean;
|
||||
autoUnzipFileLimit?: number;
|
||||
skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs.
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified file addition helper - replaces addFiles
|
||||
*/
|
||||
export async function addFiles(
|
||||
options: AddFileOptions,
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
lifecycleManager: FileLifecycleManager,
|
||||
enablePersistence: boolean = false
|
||||
): Promise<StirlingFile[]> {
|
||||
// Acquire mutex to prevent race conditions
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
|
||||
// ZIP pre-processing: Extract ZIP files with configurable behavior
|
||||
// - File uploads: skipAutoUnzip=true → always extract (except HTML)
|
||||
// - Tool outputs: skipAutoUnzip=false → respect user preferences
|
||||
const filesToProcess: File[] = [];
|
||||
const autoUnzip = options.autoUnzip ?? true; // Default to true
|
||||
const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit
|
||||
const skipAutoUnzip = options.skipAutoUnzip ?? false;
|
||||
|
||||
for (const file of files) {
|
||||
// Check if file is a ZIP
|
||||
if (zipFileService.isZipFile(file)) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles: Detected ZIP file: ${file.name}`);
|
||||
|
||||
// Check if ZIP contains HTML files - if so, keep as ZIP
|
||||
const containsHtml = await zipFileService.containsHtmlFiles(file);
|
||||
if (containsHtml) {
|
||||
if (DEBUG) console.log(`📄 addFiles: ZIP contains HTML, keeping as ZIP: ${file.name}`);
|
||||
filesToProcess.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply extraction with preferences
|
||||
const extractedFiles = await zipFileService.extractWithPreferences(file, {
|
||||
autoUnzip,
|
||||
autoUnzipFileLimit,
|
||||
skipAutoUnzip
|
||||
});
|
||||
|
||||
if (extractedFiles.length === 1 && extractedFiles[0] === file) {
|
||||
// ZIP was not extracted (over limit or autoUnzip disabled)
|
||||
if (DEBUG) console.log(`📄 addFiles: ZIP not extracted (preferences): ${file.name}`);
|
||||
} else {
|
||||
// ZIP was extracted
|
||||
if (DEBUG) console.log(`📄 addFiles: Extracted ${extractedFiles.length} files from ZIP: ${file.name}`);
|
||||
}
|
||||
|
||||
filesToProcess.push(...extractedFiles);
|
||||
} catch (error) {
|
||||
console.error(`📄 addFiles: Failed to process ZIP file ${file.name}:`, error);
|
||||
// On error, keep the ZIP file as-is
|
||||
filesToProcess.push(file);
|
||||
}
|
||||
} else {
|
||||
// Not a ZIP file, add as-is
|
||||
filesToProcess.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles: After ZIP processing, ${filesToProcess.length} files to add`);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
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} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate processedFile metadata using centralized function
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
||||
|
||||
// Extract thumbnail for non-PDF files or use from processedFile for PDFs
|
||||
let thumbnail: string | undefined;
|
||||
if (processedFileMetadata) {
|
||||
// PDF file - use thumbnail from processedFile metadata
|
||||
thumbnail = processedFileMetadata.thumbnailUrl;
|
||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${processedFileMetadata.totalPages} pages, thumbnail: SUCCESS`);
|
||||
} else if (!file.type.startsWith('application/pdf')) {
|
||||
// Non-PDF files: simple thumbnail generation, no processedFile metadata
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||
const { generateThumbnailForFile } = await import('@app/utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new filestub with processedFile metadata
|
||||
const fileStub = createNewStirlingFileStub(file, fileId, thumbnail, processedFileMetadata);
|
||||
if (thumbnail) {
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
fileStub.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(fileStub);
|
||||
|
||||
// Create StirlingFile directly
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
stirlingFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
// Persist to storage if enabled using fileStorage service
|
||||
if (enablePersistence && stirlingFiles.length > 0) {
|
||||
await Promise.all(stirlingFiles.map(async (stirlingFile, index) => {
|
||||
try {
|
||||
// Get corresponding stub with all metadata
|
||||
const fileStub = stirlingFileStubs[index];
|
||||
|
||||
// Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly
|
||||
await fileStorage.storeStirlingFile(stirlingFile, fileStub);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, fileStub);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to storage:', stirlingFile.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (stirlingFileStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||
}
|
||||
|
||||
return stirlingFiles;
|
||||
} finally {
|
||||
// Always release mutex even if error occurs
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
* Now accepts pre-created StirlingFiles and StirlingFileStubs to preserve all metadata
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputStirlingFiles: StirlingFile[],
|
||||
outputStirlingFileStubs: StirlingFileStub[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>
|
||||
): Promise<FileId[]> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputStirlingFiles.length} output files with pre-created stubs`);
|
||||
|
||||
// Validate that we have matching files and stubs
|
||||
if (outputStirlingFiles.length !== outputStirlingFileStubs.length) {
|
||||
throw new Error(`Mismatch between output files (${outputStirlingFiles.length}) and stubs (${outputStirlingFileStubs.length})`);
|
||||
}
|
||||
|
||||
// Store StirlingFiles in filesRef using their existing IDs (no ID generation needed)
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
if (stirlingFile.fileId !== stub.id) {
|
||||
console.warn(`📄 consumeFiles: ID mismatch between StirlingFile (${stirlingFile.fileId}) and stub (${stub.id})`);
|
||||
}
|
||||
|
||||
filesRef.current.set(stirlingFile.fileId, stirlingFile);
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Stored StirlingFile ${stirlingFile.name} with ID ${stirlingFile.fileId}`);
|
||||
}
|
||||
|
||||
// Mark input files as processed in storage (no longer leaf nodes)
|
||||
if(!outputStirlingFileStubs.reduce((areAllV1, stub) => areAllV1 && (stub.versionNumber == 1), true)) {
|
||||
await Promise.all(
|
||||
inputFileIds.map(async (fileId) => {
|
||||
try {
|
||||
await fileStorage.markFileAsProcessed(fileId);
|
||||
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Save output files directly to fileStorage with complete metadata
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
try {
|
||||
// Use fileStorage directly with complete metadata from stub
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
|
||||
if (DEBUG) console.log(`📄 Saved StirlingFile ${stirlingFile.name} directly to storage with complete metadata:`, {
|
||||
fileId: stirlingFile.fileId,
|
||||
versionNumber: stub.versionNumber,
|
||||
originalFileId: stub.originalFileId,
|
||||
parentFileId: stub.parentFileId,
|
||||
toolChainLength: stub.toolHistory?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist output file to fileStorage:', stirlingFile.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the consume action with pre-created stubs (no processing needed)
|
||||
dispatch({
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputStirlingFileStubs: outputStirlingFileStubs
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
||||
// Return the output file IDs for undo tracking
|
||||
return outputStirlingFileStubs.map(stub => stub.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||
*/
|
||||
async function restoreFilesAndCleanup(
|
||||
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
|
||||
fileIdsToRemove: FileId[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
): Promise<void> {
|
||||
// Remove files from filesRef
|
||||
fileIdsToRemove.forEach(id => {
|
||||
if (filesRef.current.has(id)) {
|
||||
if (DEBUG) console.log(`📄 Removing file ${id} from filesRef`);
|
||||
filesRef.current.delete(id);
|
||||
} else {
|
||||
if (DEBUG) console.warn(`📄 File ${id} not found in filesRef`);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore files to filesRef
|
||||
filesToRestore.forEach(({ file, record }) => {
|
||||
if (file && record) {
|
||||
// Validate the file before restoring
|
||||
if (file.size === 0) {
|
||||
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the file to filesRef
|
||||
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
||||
filesRef.current.set(record.id, file);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up IndexedDB
|
||||
if (indexedDB) {
|
||||
const indexedDBPromises = fileIdsToRemove.map(fileId =>
|
||||
indexedDB.deleteFile(fileId).catch(error => {
|
||||
console.error('Failed to delete file from IndexedDB:', fileId, error);
|
||||
throw error; // Re-throw to trigger rollback
|
||||
})
|
||||
);
|
||||
|
||||
// Execute all IndexedDB operations
|
||||
await Promise.all(indexedDBPromises);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes a previous consumeFiles operation by restoring input files and removing output files (unless pinned)
|
||||
*/
|
||||
export async function undoConsumeFiles(
|
||||
inputFiles: File[],
|
||||
inputStirlingFileStubs: StirlingFileStub[],
|
||||
outputFileIds: FileId[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
): Promise<void> {
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
|
||||
|
||||
// Validate inputs
|
||||
if (inputFiles.length !== inputStirlingFileStubs.length) {
|
||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
|
||||
}
|
||||
|
||||
// Create a backup of current filesRef state for rollback
|
||||
const backupFilesRef = new Map(filesRef.current);
|
||||
|
||||
try {
|
||||
// Prepare files to restore
|
||||
const filesToRestore = inputFiles.map((file, index) => ({
|
||||
file,
|
||||
record: inputStirlingFileStubs[index]
|
||||
}));
|
||||
|
||||
// Restore input files and clean up output files
|
||||
await restoreFilesAndCleanup(
|
||||
filesToRestore,
|
||||
outputFileIds,
|
||||
filesRef,
|
||||
indexedDB
|
||||
);
|
||||
|
||||
// Dispatch the undo action (only if everything else succeeded)
|
||||
dispatch({
|
||||
type: 'UNDO_CONSUME_FILES',
|
||||
payload: {
|
||||
inputStirlingFileStubs,
|
||||
outputFileIds
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||
} catch (error) {
|
||||
// Rollback filesRef to previous state
|
||||
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||
filesRef.current.clear();
|
||||
backupFilesRef.forEach((file, id) => {
|
||||
filesRef.current.set(id, file);
|
||||
});
|
||||
throw error; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action factory functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add files using existing StirlingFileStubs from storage - preserves all metadata
|
||||
* Use this when loading files that already exist in storage (FileManager, etc.)
|
||||
* StirlingFileStubs come with proper thumbnails, history, processing state
|
||||
*/
|
||||
export async function addStirlingFileStubs(
|
||||
stirlingFileStubs: StirlingFileStub[],
|
||||
options: { insertAfterPageId?: string; selectFiles?: boolean } = {},
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
_lifecycleManager: FileLifecycleManager
|
||||
): Promise<StirlingFile[]> {
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
const validStubs: StirlingFileStub[] = [];
|
||||
const loadedFiles: StirlingFile[] = [];
|
||||
|
||||
for (const stub of stirlingFileStubs) {
|
||||
// Check for duplicates using quickKey
|
||||
if (existingQuickKeys.has(stub.quickKey || '')) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate StirlingFileStub: ${stub.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load the actual StirlingFile from storage
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (!stirlingFile) {
|
||||
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${stub.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the loaded file in filesRef
|
||||
filesRef.current.set(stub.id, stirlingFile);
|
||||
|
||||
// Use the original stub (preserves thumbnails, history, metadata!)
|
||||
const record = { ...stub };
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Check if processedFile data needs regeneration for proper Page Editor support
|
||||
if (stirlingFile.type.startsWith('application/pdf')) {
|
||||
const needsProcessing = !record.processedFile ||
|
||||
!record.processedFile.pages ||
|
||||
record.processedFile.pages.length === 0 ||
|
||||
record.processedFile.totalPages !== record.processedFile.pages.length;
|
||||
|
||||
if (needsProcessing) {
|
||||
|
||||
// Use centralized metadata generation function
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
|
||||
if (processedFileMetadata) {
|
||||
record.processedFile = processedFileMetadata;
|
||||
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
|
||||
} else {
|
||||
// Fallback for files that couldn't be processed
|
||||
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
|
||||
if (!record.processedFile) {
|
||||
record.processedFile = createProcessedFile(1); // Fallback to 1 page
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existingQuickKeys.add(stub.quickKey || '');
|
||||
validStubs.push(record);
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (validStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
|
||||
}
|
||||
|
||||
return loadedFiles;
|
||||
} finally {
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
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 } }),
|
||||
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
||||
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
|
||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' }),
|
||||
markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }),
|
||||
clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }),
|
||||
clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' })
|
||||
});
|
||||
198
frontend/src/core/contexts/file/fileHooks.ts
Normal file
198
frontend/src/core/contexts/file/fileHooks.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Performant file hooks - Clean API using FileContext
|
||||
*/
|
||||
|
||||
import { useContext, useMemo } from 'react';
|
||||
import {
|
||||
FileStateContext,
|
||||
FileActionsContext,
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue
|
||||
} from '@app/contexts/file/contexts';
|
||||
import { StirlingFileStub, StirlingFile } from '@app/types/fileContext';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
/**
|
||||
* 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?: StirlingFileStub } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
const primaryFileId = state.files.ids[0];
|
||||
return useMemo(() => ({
|
||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
|
||||
}), [primaryFileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for file selection state and actions
|
||||
*/
|
||||
export function useFileSelection() {
|
||||
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
|
||||
}), [
|
||||
selectedFiles,
|
||||
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,
|
||||
updateStirlingFileStub: actions.updateStirlingFileStub,
|
||||
reorderFiles: actions.reorderFiles
|
||||
}), [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 useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
|
||||
const { selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
file: selectors.getFile(fileId),
|
||||
record: selectors.getStirlingFileStub(fileId)
|
||||
}), [fileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||
*/
|
||||
export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getFiles(),
|
||||
fileStubs: selectors.getStirlingFileStubs(),
|
||||
fileIds: state.files.ids
|
||||
}), [state.files.ids, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedFileStubs: StirlingFileStub[]; selectedFileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
selectedFiles: selectors.getSelectedFiles(),
|
||||
selectedFileStubs: selectors.getSelectedStirlingFileStubs(),
|
||||
selectedFileIds: 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 { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
return useMemo(() => ({
|
||||
// Lifecycle management
|
||||
trackBlobUrl: actions.trackBlobUrl,
|
||||
scheduleCleanup: actions.scheduleCleanup,
|
||||
setUnsavedChanges: actions.setHasUnsavedChanges,
|
||||
|
||||
// File management
|
||||
addFiles: actions.addFiles,
|
||||
consumeFiles: actions.consumeFiles,
|
||||
undoConsumeFiles: actions.undoConsumeFiles,
|
||||
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
|
||||
// File ID lookup
|
||||
findFileId: (file: File) => {
|
||||
return state.files.ids.find(id => {
|
||||
const record = state.files.byId[id];
|
||||
return record &&
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.lastModified === file.lastModified;
|
||||
});
|
||||
},
|
||||
|
||||
// Pinned files
|
||||
pinnedFiles: state.pinnedFiles,
|
||||
pinFile: actions.pinFile,
|
||||
unpinFile: actions.unpinFile,
|
||||
isFilePinned: selectors.isFilePinned,
|
||||
|
||||
// Active files
|
||||
activeFiles: selectors.getFiles(),
|
||||
|
||||
// Direct access to actions and selectors (for advanced use cases)
|
||||
actions,
|
||||
selectors
|
||||
}), [state, selectors, actions]);
|
||||
}
|
||||
|
||||
|
||||
138
frontend/src/core/contexts/file/fileSelectors.ts
Normal file
138
frontend/src/core/contexts/file/fileSelectors.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* File selectors - Pure functions for accessing file state
|
||||
*/
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
import {
|
||||
StirlingFileStub,
|
||||
FileContextState,
|
||||
FileContextSelectors,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '@app/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) => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
},
|
||||
|
||||
getFiles: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id],
|
||||
|
||||
getStirlingFileStubs: (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 => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getSelectedStirlingFileStubs: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
// Pinned files selectors
|
||||
getPinnedFileIds: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles);
|
||||
},
|
||||
|
||||
getPinnedFiles: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getPinnedStirlingFileStubs: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
isFilePinned: (file: StirlingFile) => {
|
||||
return stateRef.current.pinnedFiles.has(file.fileId);
|
||||
},
|
||||
|
||||
// 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(stirlingFileStubs: Record<FileId, StirlingFileStub>): Set<string> {
|
||||
const quickKeys = new Set<string>();
|
||||
Object.values(stirlingFileStubs).forEach(record => {
|
||||
if (record.quickKey) {
|
||||
quickKeys.add(record.quickKey);
|
||||
}
|
||||
});
|
||||
return quickKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for building quickKey sets from IndexedDB metadata
|
||||
*/
|
||||
export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; size: number; lastModified: number }>): Set<string> {
|
||||
const quickKeys = new Set<string>();
|
||||
metadata.forEach(meta => {
|
||||
// Format: name|size|lastModified (same as createQuickKey)
|
||||
const quickKey = `${meta.name}|${meta.size}|${meta.lastModified}`;
|
||||
quickKeys.add(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?: StirlingFileStub } {
|
||||
const primaryFileId = stateRef.current.files.ids[0];
|
||||
if (!primaryFileId) return {};
|
||||
|
||||
return {
|
||||
file: filesRef.current.get(primaryFileId),
|
||||
record: stateRef.current.files.byId[primaryFileId]
|
||||
};
|
||||
}
|
||||
191
frontend/src/core/contexts/file/lifecycle.ts
Normal file
191
frontend/src/core/contexts/file/lifecycle.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* File lifecycle management - Resource cleanup and memory management
|
||||
*/
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '@app/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 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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clean up resources for a specific file (with stateRef access for complete cleanup)
|
||||
*/
|
||||
cleanupFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// 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 => {
|
||||
// Revoke all blob URLs
|
||||
this.blobUrls.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
});
|
||||
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();
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
|
||||
*/
|
||||
scheduleCleanup = (fileId: FileId, 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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a file immediately with complete resource cleanup
|
||||
*/
|
||||
removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject<any>): void => {
|
||||
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);
|
||||
|
||||
// Cancel cleanup timer and generation
|
||||
const timer = this.cleanupTimers.get(fileId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.cleanupTimers.delete(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) {
|
||||
// Clean up thumbnail blob URLs
|
||||
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.thumbnailUrl);
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.blobUrl);
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up processed file thumbnails
|
||||
if (record.processedFile?.pages) {
|
||||
record.processedFile.pages.forEach((page: ProcessedFilePage) => {
|
||||
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(page.thumbnail);
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update file record with race condition guards
|
||||
*/
|
||||
updateStirlingFileStub = (fileId: FileId, updates: Partial<StirlingFileStub>, 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 => {
|
||||
this.cleanupAllFiles();
|
||||
};
|
||||
}
|
||||
71
frontend/src/core/contexts/toolWorkflow/toolWorkflowState.ts
Normal file
71
frontend/src/core/contexts/toolWorkflow/toolWorkflowState.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { PageEditorFunctions } from '@app/types/pageEditor';
|
||||
import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '@app/constants/toolPanel';
|
||||
|
||||
export interface ToolWorkflowState {
|
||||
// UI State
|
||||
sidebarsVisible: boolean;
|
||||
leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
|
||||
readerMode: boolean;
|
||||
toolPanelMode: ToolPanelMode;
|
||||
|
||||
previewFile: File | null;
|
||||
pageEditorFunctions: PageEditorFunctions | null;
|
||||
|
||||
// Search State
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
// Actions
|
||||
export type ToolWorkflowAction =
|
||||
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
|
||||
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' }
|
||||
| { type: 'SET_READER_MODE'; payload: boolean }
|
||||
| { type: 'SET_TOOL_PANEL_MODE'; payload: ToolPanelMode }
|
||||
| { type: 'SET_PREVIEW_FILE'; payload: File | null }
|
||||
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
|
||||
| { type: 'SET_SEARCH_QUERY'; payload: string }
|
||||
| { type: 'RESET_UI_STATE' };
|
||||
|
||||
|
||||
export const baseState: Omit<ToolWorkflowState, 'toolPanelMode'> = {
|
||||
sidebarsVisible: true,
|
||||
leftPanelView: 'toolPicker',
|
||||
readerMode: false,
|
||||
previewFile: null,
|
||||
pageEditorFunctions: null,
|
||||
searchQuery: '',
|
||||
};
|
||||
|
||||
export const createInitialState = (): ToolWorkflowState => ({
|
||||
...baseState,
|
||||
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
||||
});
|
||||
|
||||
export function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState {
|
||||
switch (action.type) {
|
||||
case 'SET_SIDEBARS_VISIBLE':
|
||||
return { ...state, sidebarsVisible: action.payload };
|
||||
case 'SET_LEFT_PANEL_VIEW':
|
||||
return { ...state, leftPanelView: action.payload };
|
||||
case 'SET_READER_MODE':
|
||||
return { ...state, readerMode: action.payload };
|
||||
case 'SET_TOOL_PANEL_MODE':
|
||||
return { ...state, toolPanelMode: action.payload };
|
||||
case 'SET_PREVIEW_FILE':
|
||||
return { ...state, previewFile: action.payload };
|
||||
case 'SET_PAGE_EDITOR_FUNCTIONS':
|
||||
return { ...state, pageEditorFunctions: action.payload };
|
||||
case 'SET_SEARCH_QUERY':
|
||||
return { ...state, searchQuery: action.payload };
|
||||
case 'RESET_UI_STATE':
|
||||
return {
|
||||
...baseState,
|
||||
toolPanelMode: state.toolPanelMode,
|
||||
searchQuery: state.searchQuery,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user