mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Feature/v2/file handling improvements (#4222)
# Description of Changes A new universal file context rather than the splintered ones for the main views, tools and manager we had before (manager still has its own but its better integreated with the core context) File context has been split it into a handful of different files managing various file related issues separately to reduce the monolith - FileReducer.ts - State management fileActions.ts - File operations fileSelectors.ts - Data access patterns lifecycle.ts - Resource cleanup and memory management fileHooks.ts - React hooks interface contexts.ts - Context providers Improved thumbnail generation Improved indexxedb handling Stopped handling files as blobs were not necessary to improve performance A new library handling drag and drop https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes but I broke the old one with the new filecontext and it needed doing so it was a might as well) A new library handling virtualisation on page editor @tanstack/react-virtual, as above. Quickly ripped out the last remnants of the old URL params stuff and replaced with the beginnings of what will later become the new URL navigation system (for now it just restores the tool name in url behavior) Fixed selected file not regestered when opening a tool Fixed png thumbnails Closes #(issue_number) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Reece Browne <you@example.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
|
||||
@@ -9,27 +9,27 @@ interface FileManagerContextValue {
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: string[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
selectedFiles: FileMetadata[];
|
||||
filteredFiles: FileMetadata[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void;
|
||||
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||
onFileDoubleClick: (file: FileMetadata) => void;
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDownloadSingle: (file: FileWithUrl) => void;
|
||||
onDownloadSingle: (file: FileMetadata) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: FileWithUrl[];
|
||||
recentFiles: FileMetadata[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
}
|
||||
@@ -40,14 +40,14 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
recentFiles: FileWithUrl[];
|
||||
onFilesSelected: (files: FileWithUrl[]) => void;
|
||||
recentFiles: FileMetadata[];
|
||||
onFilesSelected: (files: FileMetadata[]) => 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;
|
||||
storeFile: (file: File) => Promise<StoredFile>;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -55,12 +55,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onFilesSelected,
|
||||
onNewFilesSelect,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
isOpen,
|
||||
onFileRemove,
|
||||
modalHeight,
|
||||
storeFile,
|
||||
refreshRecentFiles,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
@@ -76,7 +76,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id || file.name));
|
||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
|
||||
|
||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
||||
(recentFiles || []).filter(file =>
|
||||
@@ -96,8 +96,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileWithUrl, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id || file.name;
|
||||
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
if (!fileId) return;
|
||||
|
||||
if (shiftKey && lastClickedIndex !== null) {
|
||||
@@ -110,7 +110,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
|
||||
// Add all files in the range to selection
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const rangeFileId = filteredFiles[i]?.id || filteredFiles[i]?.name;
|
||||
const rangeFileId = filteredFiles[i]?.id;
|
||||
if (rangeFileId) {
|
||||
selectedSet.add(rangeFileId);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onFileRemove(index);
|
||||
}, [filteredFiles, onFileRemove]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
|
||||
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
|
||||
if (isFileSupported(file.name)) {
|
||||
onFilesSelected([file]);
|
||||
onClose();
|
||||
@@ -167,22 +167,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
|
||||
const fileWithUrls = files.map(file => {
|
||||
const url = URL.createObjectURL(file);
|
||||
createdBlobUrls.current.add(url);
|
||||
|
||||
return {
|
||||
// No ID assigned here - FileContext will handle storage and ID assignment
|
||||
name: file.name,
|
||||
file,
|
||||
url,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
};
|
||||
});
|
||||
|
||||
onFilesSelected(fileWithUrls as any /* FIX ME */);
|
||||
// For local file uploads, pass File objects directly to FileContext
|
||||
onNewFilesSelect(files);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -190,7 +176,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}
|
||||
event.target.value = '';
|
||||
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
||||
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||
@@ -200,7 +186,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
setLastClickedIndex(null);
|
||||
} else {
|
||||
// Select all filtered files
|
||||
setSelectedFileIds(filteredFiles.map(file => file.id || file.name));
|
||||
setSelectedFileIds(filteredFiles.map(file => file.id).filter(Boolean));
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [filteredFiles, selectedFileIds]);
|
||||
@@ -211,13 +197,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
try {
|
||||
// Get files to delete based on current filtered view
|
||||
const filesToDelete = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id || file.name)
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
// Delete files from storage
|
||||
for (const file of filesToDelete) {
|
||||
const lookupKey = file.id || file.name;
|
||||
await fileStorage.deleteFile(lookupKey);
|
||||
await fileStorage.deleteFile(file.id);
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
@@ -237,7 +222,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
try {
|
||||
// Get selected files
|
||||
const selectedFilesToDownload = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id || file.name)
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
// Use generic download utility
|
||||
@@ -249,7 +234,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles]);
|
||||
|
||||
const handleDownloadSingle = useCallback(async (file: FileWithUrl) => {
|
||||
const handleDownloadSingle = useCallback(async (file: FileMetadata) => {
|
||||
try {
|
||||
await downloadFiles([file]);
|
||||
} catch (error) {
|
||||
@@ -279,7 +264,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const contextValue: FileManagerContextValue = {
|
||||
const contextValue: FileManagerContextValue = useMemo(() => ({
|
||||
// State
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
@@ -307,7 +292,28 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
};
|
||||
}), [
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
searchTerm,
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleFileDoubleClick,
|
||||
handleOpenFiles,
|
||||
handleSearchChange,
|
||||
handleFileInputChange,
|
||||
handleSelectAll,
|
||||
handleDeleteSelected,
|
||||
handleDownloadSelected,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FileManagerContext.Provider value={contextValue}>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import {
|
||||
MaxFiles,
|
||||
FileSelectionContextValue
|
||||
} from '../types/tool';
|
||||
import { useFileContext } from './FileContext';
|
||||
|
||||
interface FileSelectionProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
|
||||
|
||||
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
|
||||
const { activeFiles } = useFileContext();
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
|
||||
const [isToolMode, setIsToolMode] = useState<boolean>(false);
|
||||
|
||||
// Sync selected files with active files - remove any selected files that are no longer active
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
const activeFileSet = new Set(activeFiles);
|
||||
const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file));
|
||||
|
||||
if (validSelectedFiles.length !== selectedFiles.length) {
|
||||
setSelectedFiles(validSelectedFiles);
|
||||
}
|
||||
}
|
||||
}, [activeFiles, selectedFiles]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
}, []);
|
||||
|
||||
const selectionCount = selectedFiles.length;
|
||||
const canSelectMore = maxFiles === -1 || selectionCount < maxFiles;
|
||||
const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles;
|
||||
const isMultiFileMode = maxFiles !== 1;
|
||||
|
||||
const contextValue: FileSelectionContextValue = {
|
||||
selectedFiles,
|
||||
maxFiles,
|
||||
isToolMode,
|
||||
setSelectedFiles,
|
||||
setMaxFiles,
|
||||
setIsToolMode,
|
||||
clearSelection,
|
||||
canSelectMore,
|
||||
isAtLimit,
|
||||
selectionCount,
|
||||
isMultiFileMode
|
||||
};
|
||||
|
||||
return (
|
||||
<FileSelectionContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FileSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the file selection context.
|
||||
* Throws if used outside a <FileSelectionProvider>.
|
||||
*/
|
||||
export function useFileSelection(): FileSelectionContextValue {
|
||||
const context = useContext(FileSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileSelection must be used within a FileSelectionProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Returns only the file selection values relevant for tools (e.g. merge, split, etc.)
|
||||
// Use this in tool panels/components that need to know which files are selected and selection limits.
|
||||
export function useToolFileSelection(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
|
||||
const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection();
|
||||
return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount };
|
||||
}
|
||||
|
||||
// Returns actions for manipulating file selection state.
|
||||
// Use this in components that need to update the selection, clear it, or change selection mode.
|
||||
export function useFileSelectionActions(): Pick<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
|
||||
const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection();
|
||||
return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode };
|
||||
}
|
||||
|
||||
// Returns the raw file selection state (selected files, max files, tool mode).
|
||||
// Use this for low-level state access, e.g. in context-aware UI.
|
||||
export function useFileSelectionState(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
|
||||
const { selectedFiles, maxFiles, isToolMode } = useFileSelection();
|
||||
return { selectedFiles, maxFiles, isToolMode };
|
||||
}
|
||||
|
||||
// Returns computed values derived from file selection state.
|
||||
// Use this for file selection UI logic (e.g. disabling buttons when at limit).
|
||||
export function useFileSelectionComputed(): Pick<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
|
||||
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
|
||||
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
import { FileMetadata } from '../types/file';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
@@ -7,6 +8,7 @@ interface FilesModalContextType {
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
@@ -14,7 +16,7 @@ interface FilesModalContextType {
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
||||
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
|
||||
@@ -37,19 +39,34 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
closeFilesModal();
|
||||
}, [addMultipleFiles, closeFilesModal]);
|
||||
|
||||
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
|
||||
addStoredFiles(filesWithMetadata);
|
||||
closeFilesModal();
|
||||
}, [addStoredFiles, closeFilesModal]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
}, []);
|
||||
|
||||
const contextValue: FilesModalContextType = {
|
||||
const contextValue: FilesModalContextType = useMemo(() => ({
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onStoredFilesSelect: handleStoredFilesSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
};
|
||||
}), [
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
handleFileSelect,
|
||||
handleFilesSelect,
|
||||
handleStoredFilesSelect,
|
||||
onModalClose,
|
||||
setModalCloseCallback,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FilesModalContext.Provider value={contextValue}>
|
||||
|
||||
207
frontend/src/contexts/IndexedDBContext.tsx
Normal file
207
frontend/src/contexts/IndexedDBContext.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* IndexedDBContext - Clean persistence layer for file storage
|
||||
* Integrates with FileContext to provide transparent file persistence
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
import { fileStorage, StoredFile } from '../services/fileStorage';
|
||||
import { FileId } from '../types/fileContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
interface IndexedDBContextValue {
|
||||
// Core CRUD operations
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>;
|
||||
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
loadAllMetadata: () => Promise<FileMetadata[]>;
|
||||
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// Utilities
|
||||
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
||||
updateThumbnail: (fileId: FileId, thumbnail: string) => 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<FileMetadata> => {
|
||||
// Use existing thumbnail or generate new one if none provided
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
// Store in IndexedDB
|
||||
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
// Return metadata
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
thumbnail
|
||||
};
|
||||
}, []);
|
||||
|
||||
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.getFile(fileId);
|
||||
if (!storedFile) return null;
|
||||
|
||||
// Reconstruct File object
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
|
||||
// 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<FileMetadata | null> => {
|
||||
// Try to get from cache first (no IndexedDB hit)
|
||||
const cached = fileCache.current.get(fileId);
|
||||
if (cached) {
|
||||
const file = cached.file;
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified
|
||||
};
|
||||
}
|
||||
|
||||
// Load metadata from IndexedDB (efficient - no data field)
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const fileMetadata = metadata.find(m => m.id === fileId);
|
||||
|
||||
if (!fileMetadata) return null;
|
||||
|
||||
return {
|
||||
id: fileMetadata.id,
|
||||
name: fileMetadata.name,
|
||||
type: fileMetadata.type,
|
||||
size: fileMetadata.size,
|
||||
lastModified: fileMetadata.lastModified,
|
||||
thumbnail: fileMetadata.thumbnail
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
|
||||
return metadata.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
lastModified: m.lastModified,
|
||||
thumbnail: m.thumbnail
|
||||
}));
|
||||
}, []);
|
||||
|
||||
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.deleteFile(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 value: IndexedDBContextValue = {
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
231
frontend/src/contexts/NavigationContext.tsx
Normal file
231
frontend/src/contexts/NavigationContext.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
|
||||
/**
|
||||
* NavigationContext - Complete navigation management system
|
||||
*
|
||||
* Handles navigation modes, navigation guards for unsaved changes,
|
||||
* and breadcrumb/history navigation. Separated from FileContext to
|
||||
* maintain clear separation of concerns.
|
||||
*/
|
||||
|
||||
// Navigation mode types - complete list to match fileContext.ts
|
||||
export type ModeType =
|
||||
| 'viewer'
|
||||
| 'pageEditor'
|
||||
| 'fileEditor'
|
||||
| 'merge'
|
||||
| 'split'
|
||||
| 'compress'
|
||||
| 'ocr'
|
||||
| 'convert'
|
||||
| 'sanitize'
|
||||
| 'addPassword'
|
||||
| 'changePermissions'
|
||||
| 'addWatermark'
|
||||
| 'removePassword'
|
||||
| 'single-large-page'
|
||||
| 'repair'
|
||||
| 'unlockPdfForms'
|
||||
| 'removeCertificateSign';
|
||||
|
||||
// Navigation state
|
||||
interface NavigationState {
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
type NavigationAction =
|
||||
| { type: 'SET_MODE'; payload: { mode: ModeType } }
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
||||
|
||||
// Navigation reducer
|
||||
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, currentMode: action.payload.mode };
|
||||
|
||||
case 'SET_UNSAVED_CHANGES':
|
||||
return { ...state, hasUnsavedChanges: action.payload.hasChanges };
|
||||
|
||||
case 'SET_PENDING_NAVIGATION':
|
||||
return { ...state, pendingNavigation: action.payload.navigationFn };
|
||||
|
||||
case 'SHOW_NAVIGATION_WARNING':
|
||||
return { ...state, showNavigationWarning: action.payload.show };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: NavigationState = {
|
||||
currentMode: 'pageEditor',
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false
|
||||
};
|
||||
|
||||
// Navigation context actions interface
|
||||
export interface NavigationContextActions {
|
||||
setMode: (mode: ModeType) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
showNavigationWarning: (show: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => void;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
}
|
||||
|
||||
// Split context values
|
||||
export interface NavigationContextStateValue {
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
}
|
||||
|
||||
export interface NavigationContextActionsValue {
|
||||
actions: NavigationContextActions;
|
||||
}
|
||||
|
||||
// Create contexts
|
||||
const NavigationStateContext = createContext<NavigationContextStateValue | undefined>(undefined);
|
||||
const NavigationActionsContext = createContext<NavigationContextActionsValue | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export const NavigationProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
enableUrlSync?: boolean;
|
||||
}> = ({ children, enableUrlSync = true }) => {
|
||||
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||
|
||||
const actions: NavigationContextActions = {
|
||||
setMode: useCallback((mode: ModeType) => {
|
||||
dispatch({ type: 'SET_MODE', payload: { mode } });
|
||||
}, []),
|
||||
|
||||
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []),
|
||||
|
||||
showNavigationWarning: useCallback((show: boolean) => {
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
|
||||
}, []),
|
||||
|
||||
requestNavigation: useCallback((navigationFn: () => void) => {
|
||||
// If no unsaved changes, navigate immediately
|
||||
if (!state.hasUnsavedChanges) {
|
||||
navigationFn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, store the navigation and show warning
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
}, [state.hasUnsavedChanges]),
|
||||
|
||||
confirmNavigation: useCallback(() => {
|
||||
// Execute pending navigation
|
||||
if (state.pendingNavigation) {
|
||||
state.pendingNavigation();
|
||||
}
|
||||
|
||||
// Clear navigation state
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, [state.pendingNavigation]),
|
||||
|
||||
cancelNavigation: useCallback(() => {
|
||||
// Clear navigation without executing
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, [])
|
||||
};
|
||||
|
||||
const stateValue: NavigationContextStateValue = {
|
||||
currentMode: state.currentMode,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning
|
||||
};
|
||||
|
||||
const actionsValue: NavigationContextActionsValue = {
|
||||
actions
|
||||
};
|
||||
|
||||
// Enable URL synchronization
|
||||
useNavigationUrlSync(state.currentMode, actions.setMode, enableUrlSync);
|
||||
|
||||
return (
|
||||
<NavigationStateContext.Provider value={stateValue}>
|
||||
<NavigationActionsContext.Provider value={actionsValue}>
|
||||
{children}
|
||||
</NavigationActionsContext.Provider>
|
||||
</NavigationStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Navigation hooks
|
||||
export const useNavigationState = () => {
|
||||
const context = useContext(NavigationStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNavigationState must be used within NavigationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useNavigationActions = () => {
|
||||
const context = useContext(NavigationActionsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNavigationActions must be used within NavigationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Combined hook for convenience
|
||||
export const useNavigation = () => {
|
||||
const state = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
return { ...state, ...actions };
|
||||
};
|
||||
|
||||
// Navigation guard hook (equivalent to old useFileNavigation)
|
||||
export const useNavigationGuard = () => {
|
||||
const state = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
return {
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
requestNavigation: actions.requestNavigation,
|
||||
confirmNavigation: actions.confirmNavigation,
|
||||
cancelNavigation: actions.cancelNavigation,
|
||||
setHasUnsavedChanges: actions.setHasUnsavedChanges,
|
||||
setShowNavigationWarning: actions.showNavigationWarning
|
||||
};
|
||||
};
|
||||
|
||||
// Utility functions for mode handling
|
||||
export const isValidMode = (mode: string): mode is ModeType => {
|
||||
const validModes: ModeType[] = [
|
||||
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
|
||||
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
|
||||
];
|
||||
return validModes.includes(mode as ModeType);
|
||||
};
|
||||
|
||||
export const getDefaultMode = (): ModeType => 'pageEditor';
|
||||
|
||||
// TODO: This will be expanded for URL-based routing system
|
||||
// - URL parsing utilities
|
||||
// - Route definitions
|
||||
// - Navigation hooks with URL sync
|
||||
// - History management
|
||||
// - Breadcrumb restoration from URL params
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useRef, useMemo } from 'react';
|
||||
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||
@@ -12,24 +12,24 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
|
||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||
const [readerMode, setReaderMode] = useState(false);
|
||||
|
||||
const sidebarState: SidebarState = {
|
||||
const sidebarState: SidebarState = useMemo(() => ({
|
||||
sidebarsVisible,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
};
|
||||
}), [sidebarsVisible, leftPanelView, readerMode]);
|
||||
|
||||
const sidebarRefs: SidebarRefs = {
|
||||
const sidebarRefs: SidebarRefs = useMemo(() => ({
|
||||
quickAccessRef,
|
||||
toolPanelRef,
|
||||
};
|
||||
}), [quickAccessRef, toolPanelRef]);
|
||||
|
||||
const contextValue: SidebarContextValue = {
|
||||
const contextValue: SidebarContextValue = useMemo(() => ({
|
||||
sidebarState,
|
||||
sidebarRefs,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
};
|
||||
}), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, { createContext, useContext, useReducer, useCallback, useMemo } fr
|
||||
import { useToolManagement } from '../hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '../types/pageEditor';
|
||||
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
|
||||
|
||||
// State interface
|
||||
interface ToolWorkflowState {
|
||||
@@ -101,9 +102,11 @@ interface ToolWorkflowProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Handler for view changes (passed from parent) */
|
||||
onViewChange?: (view: string) => void;
|
||||
/** Enable URL synchronization for tool selection */
|
||||
enableUrlSync?: boolean;
|
||||
}
|
||||
|
||||
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
|
||||
export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = true }: ToolWorkflowProviderProps) {
|
||||
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
||||
|
||||
// Tool management hook
|
||||
@@ -182,6 +185,9 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
|
||||
[state.sidebarsVisible, state.readerMode]
|
||||
);
|
||||
|
||||
// Enable URL synchronization for tool selection
|
||||
useToolWorkflowUrlSync(selectedToolKey, selectTool, clearToolSelection, enableUrlSync);
|
||||
|
||||
// Simple context value with basic memoization
|
||||
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
||||
// State
|
||||
|
||||
240
frontend/src/contexts/file/FileReducer.ts
Normal file
240
frontend/src/contexts/file/FileReducer.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* FileContext reducer - Pure state management for file operations
|
||||
*/
|
||||
|
||||
import {
|
||||
FileContextState,
|
||||
FileContextAction,
|
||||
FileId,
|
||||
FileRecord
|
||||
} from '../../types/fileContext';
|
||||
|
||||
// Initial state
|
||||
export const initialFileContextState: FileContextState = {
|
||||
files: {
|
||||
ids: [],
|
||||
byId: {}
|
||||
},
|
||||
pinnedFiles: new Set(),
|
||||
ui: {
|
||||
selectedFileIds: [],
|
||||
selectedPageNumbers: [],
|
||||
isProcessing: false,
|
||||
processingProgress: 0,
|
||||
hasUnsavedChanges: false
|
||||
}
|
||||
};
|
||||
|
||||
// Pure reducer function
|
||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||
switch (action.type) {
|
||||
case 'ADD_FILES': {
|
||||
const { fileRecords } = action.payload;
|
||||
const newIds: FileId[] = [];
|
||||
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
||||
|
||||
fileRecords.forEach(record => {
|
||||
// Only add if not already present (dedupe by stable ID)
|
||||
if (!newById[record.id]) {
|
||||
newIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...state.files.ids, ...newIds],
|
||||
byId: newById
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'REMOVE_FILES': {
|
||||
const { fileIds } = action.payload;
|
||||
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
|
||||
const newById = { ...state.files.byId };
|
||||
|
||||
// Remove files from state (resource cleanup handled by lifecycle manager)
|
||||
fileIds.forEach(id => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: remainingIds,
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'UPDATE_FILE_RECORD': {
|
||||
const { id, updates } = action.payload;
|
||||
const existingRecord = state.files.byId[id];
|
||||
|
||||
if (!existingRecord) {
|
||||
return state; // File doesn't exist, no-op
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
byId: {
|
||||
...state.files.byId,
|
||||
[id]: {
|
||||
...existingRecord,
|
||||
...updates
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'REORDER_FILES': {
|
||||
const { orderedFileIds } = action.payload;
|
||||
|
||||
// Validate that all IDs exist in current state
|
||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
ids: validIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 '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, outputFileRecords } = action.payload;
|
||||
|
||||
// Only remove unpinned input files
|
||||
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
|
||||
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
|
||||
|
||||
// Remove unpinned files from state
|
||||
const newById = { ...state.files.byId };
|
||||
unpinnedInputIds.forEach(id => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Add output files
|
||||
const outputIds: FileId[] = [];
|
||||
outputFileRecords.forEach(record => {
|
||||
if (!newById[record.id]) {
|
||||
outputIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...remainingIds, ...outputIds],
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'RESET_CONTEXT': {
|
||||
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
||||
return { ...initialFileContextState };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
13
frontend/src/contexts/file/contexts.ts
Normal file
13
frontend/src/contexts/file/contexts.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* React contexts for file state and actions
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
import { FileContextStateValue, FileContextActionsValue } from '../../types/fileContext';
|
||||
|
||||
// Split contexts for performance
|
||||
export const FileStateContext = createContext<FileContextStateValue | undefined>(undefined);
|
||||
export const FileActionsContext = createContext<FileContextActionsValue | undefined>(undefined);
|
||||
|
||||
// Export types for use in hooks
|
||||
export type { FileContextStateValue, FileContextActionsValue };
|
||||
370
frontend/src/contexts/file/fileActions.ts
Normal file
370
frontend/src/contexts/file/fileActions.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* File actions - Unified file operations with single addFiles helper
|
||||
*/
|
||||
|
||||
import {
|
||||
FileId,
|
||||
FileRecord,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
toFileRecord,
|
||||
createFileId,
|
||||
createQuickKey
|
||||
} from '../../types/fileContext';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from './lifecycle';
|
||||
import { fileProcessingService } from '../../services/fileProcessingService';
|
||||
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
||||
|
||||
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) {
|
||||
return {
|
||||
totalPages: pageCount,
|
||||
pages: Array.from({ length: pageCount }, (_, index) => ({
|
||||
pageNumber: index + 1,
|
||||
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
|
||||
rotation: 0,
|
||||
splitBefore: false
|
||||
})),
|
||||
thumbnailUrl: thumbnail,
|
||||
lastProcessed: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* File addition types
|
||||
*/
|
||||
type AddFileKind = 'raw' | 'processed' | 'stored';
|
||||
|
||||
interface AddFileOptions {
|
||||
// For 'raw' files
|
||||
files?: File[];
|
||||
|
||||
// For 'processed' files
|
||||
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||
|
||||
// For 'stored' files
|
||||
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
|
||||
*/
|
||||
export async function addFiles(
|
||||
kind: AddFileKind,
|
||||
options: AddFileOptions,
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
lifecycleManager: FileLifecycleManager
|
||||
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
||||
// Acquire mutex to prevent race conditions
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const fileRecords: FileRecord[] = [];
|
||||
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||
|
||||
switch (kind) {
|
||||
case 'raw': {
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
|
||||
for (const file of files) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count immediately
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating PDF metadata for ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${pageCount} pages, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
// Non-PDF files: simple thumbnail generation, no page count
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
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 record with immediate thumbnail and page metadata
|
||||
const record = toFileRecord(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial processedFile metadata with page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'processed': {
|
||||
const { filesWithThumbnails = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
||||
|
||||
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Create processedFile with provided metadata
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stored': {
|
||||
const { filesWithMetadata = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
||||
|
||||
for (const { file, originalId, metadata } of filesWithMetadata) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
// Try to preserve original ID, but generate new if it conflicts
|
||||
let fileId = originalId;
|
||||
if (filesRef.current.has(originalId)) {
|
||||
fileId = createFileId();
|
||||
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
||||
}
|
||||
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
|
||||
// Generate processedFile metadata for stored files
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
||||
|
||||
// Get page count from PDF
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
pageCount = pdf.numPages;
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
||||
}
|
||||
|
||||
// Restore metadata from storage
|
||||
if (metadata.thumbnail) {
|
||||
record.thumbnailUrl = metadata.thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (metadata.thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Create processedFile metadata with correct page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (fileRecords.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
||||
}
|
||||
|
||||
return addedFiles;
|
||||
} finally {
|
||||
// Always release mutex even if error occurs
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>
|
||||
): Promise<void> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
|
||||
// Process output files through the 'processed' path to generate thumbnails
|
||||
const outputFileRecords = await Promise.all(
|
||||
outputFiles.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count for output file
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
}
|
||||
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
}
|
||||
|
||||
return record;
|
||||
})
|
||||
);
|
||||
|
||||
// Dispatch the consume action
|
||||
dispatch({
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputFileRecords
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action factory functions
|
||||
*/
|
||||
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
|
||||
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
|
||||
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
|
||||
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
|
||||
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
||||
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
||||
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
|
||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
|
||||
});
|
||||
193
frontend/src/contexts/file/fileHooks.ts
Normal file
193
frontend/src/contexts/file/fileHooks.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Performant file hooks - Clean API using FileContext
|
||||
*/
|
||||
|
||||
import { useContext, useMemo } from 'react';
|
||||
import {
|
||||
FileStateContext,
|
||||
FileActionsContext,
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue
|
||||
} from './contexts';
|
||||
import { FileId, FileRecord } from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
* Hook for accessing file state (will re-render on any state change)
|
||||
* Use individual selector hooks below for better performance
|
||||
*/
|
||||
export function useFileState(): FileContextStateValue {
|
||||
const context = useContext(FileStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileState must be used within a FileContextProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing file actions (stable - won't cause re-renders)
|
||||
*/
|
||||
export function useFileActions(): FileContextActionsValue {
|
||||
const context = useContext(FileActionsContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileActions must be used within a FileContextProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for current/primary file (first in list)
|
||||
*/
|
||||
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
const primaryFileId = state.files.ids[0];
|
||||
return useMemo(() => ({
|
||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
|
||||
}), [primaryFileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for file selection state and actions
|
||||
*/
|
||||
export function useFileSelection() {
|
||||
const { state, 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,
|
||||
updateFileRecord: actions.updateFileRecord,
|
||||
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 useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
||||
const { selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
file: selectors.getFile(fileId),
|
||||
record: selectors.getFileRecord(fileId)
|
||||
}), [fileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||
*/
|
||||
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getFiles(),
|
||||
records: selectors.getFileRecords(),
|
||||
fileIds: state.files.ids
|
||||
}), [state.files.ids, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getSelectedFiles(),
|
||||
records: selectors.getSelectedFileRecords(),
|
||||
fileIds: state.ui.selectedFileIds
|
||||
}), [state.ui.selectedFileIds, selectors]);
|
||||
}
|
||||
|
||||
// Navigation management removed - moved to NavigationContext
|
||||
|
||||
/**
|
||||
* Primary API hook for file context operations
|
||||
* Used by tools for core file context functionality
|
||||
*/
|
||||
export function useFileContext() {
|
||||
const { 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,
|
||||
recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (fileId: string, 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()
|
||||
}), [state, selectors, actions]);
|
||||
}
|
||||
|
||||
|
||||
130
frontend/src/contexts/file/fileSelectors.ts
Normal file
130
frontend/src/contexts/file/fileSelectors.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* File selectors - Pure functions for accessing file state
|
||||
*/
|
||||
|
||||
import {
|
||||
FileId,
|
||||
FileRecord,
|
||||
FileContextState,
|
||||
FileContextSelectors
|
||||
} from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
* Create stable selectors using stateRef and filesRef
|
||||
*/
|
||||
export function createFileSelectors(
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): FileContextSelectors {
|
||||
return {
|
||||
getFile: (id: FileId) => filesRef.current.get(id),
|
||||
|
||||
getFiles: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
|
||||
},
|
||||
|
||||
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
||||
|
||||
getFileRecords: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||
},
|
||||
|
||||
getAllFileIds: () => stateRef.current.files.ids,
|
||||
|
||||
getSelectedFiles: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
},
|
||||
|
||||
getSelectedFileRecords: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
// Pinned files selectors
|
||||
getPinnedFileIds: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles);
|
||||
},
|
||||
|
||||
getPinnedFiles: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
},
|
||||
|
||||
getPinnedFileRecords: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
isFilePinned: (file: File) => {
|
||||
// Find FileId by matching File object properties
|
||||
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
|
||||
const storedFile = filesRef.current.get(id);
|
||||
return storedFile &&
|
||||
storedFile.name === file.name &&
|
||||
storedFile.size === file.size &&
|
||||
storedFile.lastModified === file.lastModified;
|
||||
});
|
||||
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
||||
},
|
||||
|
||||
// Stable signature for effects - prevents unnecessary re-renders
|
||||
getFilesSignature: () => {
|
||||
return stateRef.current.files.ids
|
||||
.map(id => {
|
||||
const record = stateRef.current.files.byId[id];
|
||||
return record ? `${id}:${record.size}:${record.lastModified}` : '';
|
||||
})
|
||||
.join('|');
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for building quickKey sets for deduplication
|
||||
*/
|
||||
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
||||
const quickKeys = new Set<string>();
|
||||
Object.values(fileRecords).forEach(record => {
|
||||
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?: FileRecord } {
|
||||
const primaryFileId = stateRef.current.files.ids[0];
|
||||
if (!primaryFileId) return {};
|
||||
|
||||
return {
|
||||
file: filesRef.current.get(primaryFileId),
|
||||
record: stateRef.current.files.byId[primaryFileId]
|
||||
};
|
||||
}
|
||||
190
frontend/src/contexts/file/lifecycle.ts
Normal file
190
frontend/src/contexts/file/lifecycle.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* File lifecycle management - Resource cleanup and memory management
|
||||
*/
|
||||
|
||||
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Resource tracking and cleanup utilities
|
||||
*/
|
||||
export class FileLifecycleManager {
|
||||
private cleanupTimers = new Map<string, number>();
|
||||
private blobUrls = new Set<string>();
|
||||
private 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: string, 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 (error) {
|
||||
// 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: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Cancel existing timer
|
||||
const existingTimer = this.cleanupTimers.get(fileId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.cleanupTimers.delete(fileId);
|
||||
}
|
||||
|
||||
// If delay is negative, just cancel (don't reschedule)
|
||||
if (delay < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment generation for this file to invalidate any pending cleanup
|
||||
const currentGen = (this.fileGenerations.get(fileId) || 0) + 1;
|
||||
this.fileGenerations.set(fileId, currentGen);
|
||||
|
||||
// Schedule new cleanup with generation token
|
||||
const timer = window.setTimeout(() => {
|
||||
// Check if this cleanup is still valid (file hasn't been re-added)
|
||||
if (this.fileGenerations.get(fileId) === currentGen) {
|
||||
this.cleanupFile(fileId, stateRef);
|
||||
} else {
|
||||
if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`);
|
||||
}
|
||||
}, delay);
|
||||
|
||||
this.cleanupTimers.set(fileId, timer);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (error) {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.blobUrl);
|
||||
} catch (error) {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up processed file thumbnails
|
||||
if (record.processedFile?.pages) {
|
||||
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
|
||||
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(page.thumbnail);
|
||||
} catch (error) {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update file record with race condition guards
|
||||
*/
|
||||
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Guard against updating removed files (race condition protection)
|
||||
if (!this.filesRef.current.has(fileId)) {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional state guard for rare race conditions
|
||||
if (stateRef && !stateRef.current.files.byId[fileId]) {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup on unmount
|
||||
*/
|
||||
destroy = (): void => {
|
||||
this.cleanupAllFiles();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user