mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02: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 <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
parent
a33e51351b
commit
949ffa01ad
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user