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:
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
+111 -55
View File
@@ -1,84 +1,125 @@
import { useState, useCallback } from 'react';
import { fileStorage } from '../services/fileStorage';
import { FileWithUrl } from '../types/file';
import { createEnhancedFileFromStored } from '../utils/fileUtils';
import { useIndexedDB } from '../contexts/IndexedDBContext';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
const indexedDB = useIndexedDB();
const convertToFile = useCallback(async (fileWithUrl: FileWithUrl): Promise<File> => {
if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) {
const response = await fetch(fileWithUrl.url);
const data = await response.arrayBuffer();
const file = new File([data], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
// Preserve the ID if it exists
if (fileWithUrl.id) {
Object.defineProperty(file, 'id', { value: fileWithUrl.id, writable: false });
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
if (!indexedDB) {
throw new Error('IndexedDB context not available');
}
// Handle drafts differently from regular files
if (fileMetadata.isDraft) {
// Load draft from the drafts database
try {
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
return new Promise((resolve, reject) => {
const transaction = db.transaction(['drafts'], 'readonly');
const store = transaction.objectStore('drafts');
const request = store.get(fileMetadata.id);
request.onsuccess = () => {
const draft = request.result;
if (draft && draft.pdfData) {
const file = new File([draft.pdfData], fileMetadata.name, {
type: 'application/pdf',
lastModified: fileMetadata.lastModified
});
resolve(file);
} else {
reject(new Error('Draft data not found'));
}
};
request.onerror = () => reject(request.error);
});
} catch (error) {
throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`);
}
return file;
}
// Always use ID first, fallback to name only if ID doesn't exist
const lookupKey = fileWithUrl.id || fileWithUrl.name;
const storedFile = await fileStorage.getFile(lookupKey);
if (storedFile) {
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
return file;
// Regular file loading
if (fileMetadata.id) {
const file = await indexedDB.loadFile(fileMetadata.id);
if (file) {
return file;
}
}
throw new Error(`File not found in storage: ${fileMetadata.name} (ID: ${fileMetadata.id})`);
}, [indexedDB]);
throw new Error('File not found in storage');
}, []);
const loadRecentFiles = useCallback(async (): Promise<FileWithUrl[]> => {
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => {
setLoading(true);
try {
const files = await fileStorage.getAllFiles();
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
return sortedFiles.map(file => createEnhancedFileFromStored(file));
if (!indexedDB) {
return [];
}
// Load regular files metadata only
const storedFileMetadata = await indexedDB.loadAllMetadata();
// For now, only regular files - drafts will be handled separately in the future
const allFiles = storedFileMetadata;
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
return sortedFiles;
} catch (error) {
console.error('Failed to load recent files:', error);
return [];
} finally {
setLoading(false);
}
}, []);
}, [indexedDB]);
const handleRemoveFile = useCallback(async (index: number, files: FileWithUrl[], setFiles: (files: FileWithUrl[]) => void) => {
const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => {
const file = files[index];
if (!file.id) {
throw new Error('File ID is required for removal');
}
if (!indexedDB) {
throw new Error('IndexedDB context not available');
}
try {
await fileStorage.deleteFile(file.id || file.name);
await indexedDB.deleteFile(file.id);
setFiles(files.filter((_, i) => i !== index));
} catch (error) {
console.error('Failed to remove file:', error);
throw error;
}
}, []);
}, [indexedDB]);
const storeFile = useCallback(async (file: File) => {
const storeFile = useCallback(async (file: File, fileId: string) => {
if (!indexedDB) {
throw new Error('IndexedDB context not available');
}
try {
// Generate thumbnail for the file
const thumbnail = await generateThumbnailForFile(file);
// Store file with provided UUID from FileContext (thumbnail generated internally)
const metadata = await indexedDB.saveFile(file, fileId);
// Store file with thumbnail
const storedFile = await fileStorage.storeFile(file, thumbnail);
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
return storedFile;
// Convert file to ArrayBuffer for StoredFile interface compatibility
const arrayBuffer = await file.arrayBuffer();
// Return StoredFile format for compatibility with old API
return {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
data: arrayBuffer,
thumbnail: metadata.thumbnail
};
} catch (error) {
console.error('Failed to store file:', error);
throw error;
}
}, []);
}, [indexedDB]);
const createFileSelectionHandlers = useCallback((
selectedFiles: string[],
@@ -96,14 +137,23 @@ export const useFileManager = () => {
setSelectedFiles([]);
};
const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => {
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void) => {
if (selectedFiles.length === 0) return;
try {
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id || f.name));
const filePromises = selectedFileObjects.map(convertToFile);
const convertedFiles = await Promise.all(filePromises);
onFilesSelect(convertedFiles);
// Filter by UUID and convert to File objects
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
// Use stored files flow that preserves IDs
const filesWithMetadata = await Promise.all(
selectedFileObjects.map(async (metadata) => ({
file: await convertToFile(metadata),
originalId: metadata.id,
metadata
}))
);
onStoredFilesSelect(filesWithMetadata);
clearSelection();
} catch (error) {
console.error('Failed to load selected files:', error);
@@ -119,12 +169,18 @@ export const useFileManager = () => {
}, [convertToFile]);
const touchFile = useCallback(async (id: string) => {
if (!indexedDB) {
console.warn('IndexedDB context not available for touch operation');
return;
}
try {
await fileStorage.touchFile(id);
// Update access time - this will be handled by the cache in IndexedDBContext
// when the file is loaded, so we can just load it briefly to "touch" it
await indexedDB.loadFile(id);
} catch (error) {
console.error('Failed to touch file:', error);
}
}, []);
}, [indexedDB]);
return {
loading,