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 <[email protected]>
This commit is contained in:
Reece Browne
2025-08-21 17:30:26 +01:00
committed by GitHub
co-authored by Reece Browne
parent a33e51351b
commit 949ffa01ad
90 changed files with 5416 additions and 4164 deletions
+14 -5
View File
@@ -1,12 +1,21 @@
/**
* Enhanced file types for IndexedDB storage
* File types for the new architecture
* FileContext uses pure File objects with separate ID tracking
*/
export interface FileWithUrl extends File {
id?: string;
url?: string;
/**
* File metadata for efficient operations without loading full file data
* Used by IndexedDBContext and FileContext for lazy file loading
*/
export interface FileMetadata {
id: string;
name: string;
type: string;
size: number;
lastModified: number;
thumbnail?: string;
storedInIndexedDB?: boolean;
isDraft?: boolean; // Marks files as draft versions
}
export interface StorageConfig {
+199 -102
View File
@@ -4,6 +4,7 @@
import { ProcessedFile } from './processing';
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
import { FileMetadata } from './file';
export type ModeType =
| 'viewer'
@@ -17,16 +18,116 @@ export type ModeType =
| 'sanitize'
| 'addPassword'
| 'changePermissions'
| 'watermark'
| 'addWatermark'
| 'removePassword'
| 'single-large-page'
| 'repair'
| 'unlockPdfForms'
| 'removeCertificateSign';
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';
// Normalized state types
export type FileId = string;
export type ToolType = 'merge' | 'split' | 'compress' | 'ocr' | 'convert' | 'sanitize';
export interface ProcessedFilePage {
thumbnail?: string;
pageNumber?: number;
rotation?: number;
splitBefore?: boolean;
[key: string]: any;
}
export interface ProcessedFileMetadata {
pages: ProcessedFilePage[];
totalPages?: number;
thumbnailUrl?: string;
lastProcessed?: number;
[key: string]: any;
}
export interface FileRecord {
id: FileId;
name: string;
size: number;
type: string;
lastModified: number;
quickKey?: string; // Fast deduplication key: name|size|lastModified
thumbnailUrl?: string;
blobUrl?: string;
createdAt?: number;
processedFile?: ProcessedFileMetadata;
isPinned?: boolean;
// Note: File object stored in provider ref, not in state
}
export interface FileContextNormalizedFiles {
ids: FileId[];
byId: Record<FileId, FileRecord>;
}
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
export function createFileId(): FileId {
// Use crypto.randomUUID for authoritative primary key
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
// Fallback for environments without randomUUID
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Generate quick deduplication key from file metadata
export function createQuickKey(file: File): string {
// Format: name|size|lastModified for fast duplicate detection
return `${file.name}|${file.size}|${file.lastModified}`;
}
export function toFileRecord(file: File, id?: FileId): FileRecord {
const fileId = id || createFileId();
return {
id: fileId,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
quickKey: createQuickKey(file),
createdAt: Date.now()
};
}
export function revokeFileResources(record: FileRecord): void {
// Only revoke blob: URLs to prevent errors on other schemes
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.thumbnailUrl);
} catch (error) {
console.warn('Failed to revoke thumbnail URL:', error);
}
}
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.blobUrl);
} catch (error) {
console.warn('Failed to revoke blob URL:', error);
}
}
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach(page => {
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
try {
URL.revokeObjectURL(page.thumbnail);
} catch (error) {
console.warn('Failed to revoke page thumbnail URL:', error);
}
}
});
}
}
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr' | 'sanitize';
@@ -69,114 +170,110 @@ export interface FileEditHistory {
}
export interface FileContextState {
// Core file management
activeFiles: File[];
processedFiles: Map<File, ProcessedFile>;
pinnedFiles: Set<File>; // Files that are pinned and won't be consumed
// Current navigation state
currentMode: ModeType;
currentView: ViewType;
currentTool: ToolType | null;
// Edit history and state
fileEditHistory: Map<string, FileEditHistory>;
globalFileOperations: FileOperation[];
// New comprehensive operation history
fileOperationHistory: Map<string, FileOperationHistory>;
// UI state that persists across views
selectedFileIds: string[];
selectedPageNumbers: number[];
viewerConfig: ViewerConfig;
// Processing state
isProcessing: boolean;
processingProgress: number;
// Export state
lastExportConfig?: {
filename: string;
selectedOnly: boolean;
splitDocuments: boolean;
// Core file management - lightweight file IDs only
files: {
ids: FileId[];
byId: Record<FileId, FileRecord>;
};
// Pinned files - files that won't be consumed by tools
pinnedFiles: Set<FileId>;
// UI state - file-related UI state only
ui: {
selectedFileIds: FileId[];
selectedPageNumbers: number[];
isProcessing: boolean;
processingProgress: number;
hasUnsavedChanges: boolean;
};
// Navigation guard system
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
}
// Action types for reducer pattern
export type FileContextAction =
// File management actions
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
// Pinned files actions
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
// UI actions
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
| { type: 'CLEAR_SELECTIONS' }
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
// Navigation guard actions (minimal for file-related unsaved changes only)
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
// Context management
| { type: 'RESET_CONTEXT' };
export interface FileContextActions {
// File management
// File management - lightweight actions only
addFiles: (files: File[]) => Promise<File[]>;
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
clearAllFiles: () => void;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
reorderFiles: (orderedFileIds: FileId[]) => void;
clearAllFiles: () => Promise<void>;
// File pinning
pinFile: (file: File) => void;
unpinFile: (file: File) => void;
isFilePinned: (file: File) => boolean;
// File consumption (replace unpinned files with outputs)
consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise<void>;
// Navigation
setCurrentMode: (mode: ModeType) => void;
setCurrentView: (view: ViewType) => void;
setCurrentTool: (tool: ToolType) => void;
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<void>;
// Selection management
setSelectedFiles: (fileIds: string[]) => void;
setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void;
updateProcessedFile: (file: File, processedFile: ProcessedFile) => void;
clearSelections: () => void;
// Edit operations
applyPageOperations: (fileId: string, operations: PageOperation[]) => void;
applyFileOperation: (operation: FileOperation) => void;
undoLastOperation: (fileId?: string) => void;
// Operation history management
recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void;
markOperationApplied: (fileId: string, operationId: string) => void;
markOperationFailed: (fileId: string, operationId: string, error: string) => void;
getFileHistory: (fileId: string) => FileOperationHistory | undefined;
getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[];
clearFileHistory: (fileId: string) => void;
// Viewer state
updateViewerConfig: (config: Partial<ViewerConfig>) => void;
// Export configuration
setExportConfig: (config: FileContextState['lastExportConfig']) => void;
// Utility
getFileById: (fileId: string) => File | undefined;
getProcessedFileById: (fileId: string) => ProcessedFile | undefined;
getCurrentFile: () => File | undefined;
getCurrentProcessedFile: () => ProcessedFile | undefined;
// Context persistence
saveContext: () => Promise<void>;
loadContext: () => Promise<void>;
resetContext: () => void;
// Navigation guard system
// Processing state - simple flags only
setProcessing: (isProcessing: boolean, progress?: number) => void;
// File-related unsaved changes (minimal navigation guard support)
setHasUnsavedChanges: (hasChanges: boolean) => void;
requestNavigation: (navigationFn: () => void) => boolean;
confirmNavigation: () => void;
cancelNavigation: () => void;
// Memory management
// Context management
resetContext: () => void;
// Resource management
trackBlobUrl: (url: string) => void;
trackPdfDocument: (fileId: string, pdfDoc: any) => void;
cleanupFile: (fileId: string) => Promise<void>;
scheduleCleanup: (fileId: string, delay?: number) => void;
cleanupFile: (fileId: string) => void;
}
export interface FileContextValue extends FileContextState, FileContextActions {}
// File selectors (separate from actions to avoid re-renders)
export interface FileContextSelectors {
// File access - no state dependency, uses ref
getFile: (id: FileId) => File | undefined;
getFiles: (ids?: FileId[]) => File[];
// Record access - uses normalized state
getFileRecord: (id: FileId) => FileRecord | undefined;
getFileRecords: (ids?: FileId[]) => FileRecord[];
// Derived selectors
getAllFileIds: () => FileId[];
getSelectedFiles: () => File[];
getSelectedFileRecords: () => FileRecord[];
// Pinned files selectors
getPinnedFileIds: () => FileId[];
getPinnedFiles: () => File[];
getPinnedFileRecords: () => FileRecord[];
isFilePinned: (file: File) => boolean;
// Stable signature for effect dependencies
getFilesSignature: () => string;
}
export interface FileContextProviderProps {
children: React.ReactNode;
@@ -185,16 +282,16 @@ export interface FileContextProviderProps {
maxCacheSize?: number;
}
// Helper types for component props
export interface WithFileContext {
fileContext: FileContextValue;
// Split context values to minimize re-renders
export interface FileContextStateValue {
state: FileContextState;
selectors: FileContextSelectors;
}
// URL parameter types for deep linking
export interface FileContextUrlParams {
mode?: ModeType;
fileIds?: string[];
pageIds?: string[];
zoom?: number;
page?: number;
export interface FileContextActionsValue {
actions: FileContextActions;
dispatch: (action: FileContextAction) => void;
}
// TODO: URL parameter types will be redesigned for new routing system
-21
View File
@@ -54,24 +54,3 @@ export interface Tool {
export type ToolRegistry = Record<string, Tool>;
export interface FileSelectionState {
selectedFiles: File[];
maxFiles: MaxFiles;
isToolMode: boolean;
}
export interface FileSelectionActions {
setSelectedFiles: (files: File[]) => void;
setMaxFiles: (maxFiles: MaxFiles) => void;
setIsToolMode: (isToolMode: boolean) => void;
clearSelection: () => void;
}
export interface FileSelectionComputed {
canSelectMore: boolean;
isAtLimit: boolean;
selectionCount: number;
isMultiFileMode: boolean;
}
export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {}