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
parent a33e51351b
commit 949ffa01ad
90 changed files with 5416 additions and 4164 deletions

View File

@@ -12,6 +12,7 @@ import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat,
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
import { useCallback, useMemo } from 'react';
export interface ConvertParameters extends BaseParameters {
fromExtension: string;
@@ -121,11 +122,13 @@ const getEndpointName = (params: ConvertParameters): string => {
};
export const useConvertParameters = (): ConvertParametersHook => {
const baseHook = useBaseParameters({
const config = useMemo(() => ({
defaultParameters,
endpointName: getEndpointName,
validateFn: validateParameters,
});
}), []);
const baseHook = useBaseParameters(config);
const getEndpoint = () => {
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = baseHook.parameters;
@@ -178,15 +181,22 @@ export const useConvertParameters = (): ConvertParametersHook => {
};
const analyzeFileTypes = (files: Array<{name: string}>) => {
const analyzeFileTypes = useCallback((files: Array<{name: string}>) => {
if (files.length === 0) {
// No files - only reset smart detection, keep user's format choices
baseHook.setParameters(prev => ({
...prev,
isSmartDetection: false,
smartDetectionType: 'none'
// Don't reset fromExtension and toExtension - let user keep their choices
}));
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: false,
smartDetectionType: 'none'
// Don't reset fromExtension and toExtension - let user keep their choices
};
});
return;
}
@@ -221,13 +231,25 @@ export const useConvertParameters = (): ConvertParametersHook => {
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
}
return {
const newState = {
...prev,
isSmartDetection: false,
smartDetectionType: 'none',
smartDetectionType: 'none' as const,
fromExtension: fromExt,
toExtension: newToExtension
};
// Only update if something actually changed
if (
prev.isSmartDetection === newState.isSmartDetection &&
prev.smartDetectionType === newState.smartDetectionType &&
prev.fromExtension === newState.fromExtension &&
prev.toExtension === newState.toExtension
) {
return prev; // Return the same object to prevent re-render
}
return newState;
});
return;
}
@@ -262,13 +284,25 @@ export const useConvertParameters = (): ConvertParametersHook => {
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
}
return {
const newState = {
...prev,
isSmartDetection: false,
smartDetectionType: 'none',
smartDetectionType: 'none' as const,
fromExtension: fromExt,
toExtension: newToExtension
};
// Only update if something actually changed
if (
prev.isSmartDetection === newState.isSmartDetection &&
prev.smartDetectionType === newState.smartDetectionType &&
prev.fromExtension === newState.fromExtension &&
prev.toExtension === newState.toExtension
) {
return prev; // Return the same object to prevent re-render
}
return newState;
});
} else {
// Mixed file types
@@ -277,34 +311,64 @@ export const useConvertParameters = (): ConvertParametersHook => {
if (allImages) {
// All files are images - use image-to-pdf conversion
baseHook.setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'images',
fromExtension: 'image',
toExtension: 'pdf'
}));
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'images' &&
prev.fromExtension === 'image' &&
prev.toExtension === 'pdf') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: 'images',
fromExtension: 'image',
toExtension: 'pdf'
};
});
} else if (allWeb) {
// All files are web files - use html-to-pdf conversion
baseHook.setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'web',
fromExtension: 'html',
toExtension: 'pdf'
}));
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'web' &&
prev.fromExtension === 'html' &&
prev.toExtension === 'pdf') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: 'web',
fromExtension: 'html',
toExtension: 'pdf'
};
});
} else {
// Mixed non-image types - use file-to-pdf conversion
baseHook.setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'mixed',
fromExtension: 'any',
toExtension: 'pdf'
}));
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'mixed' &&
prev.fromExtension === 'any' &&
prev.toExtension === 'pdf') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: 'mixed',
fromExtension: 'any',
toExtension: 'pdf'
};
});
}
}
};
}, [baseHook.setParameters]);
return {
...baseHook,

View File

@@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
@@ -198,8 +198,9 @@ export const useToolOperation = <TParams = void>(
actions.setThumbnails(thumbnails);
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Consume input files and add output files (will replace unpinned inputs)
await consumeFiles(validFiles, processedFiles);
// Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as string[];
await consumeFiles(inputFileIds, processedFiles);
markOperationApplied(fileId, operationId);
}
@@ -213,7 +214,7 @@ export const useToolOperation = <TParams = void>(
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
const cancelOperation = useCallback(() => {
cancelApiCalls();

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { useState, useCallback, useEffect, useRef } from 'react';
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { zipFileService } from '../../../services/zipFileService';
@@ -11,20 +11,28 @@ export const useToolResources = () => {
}, []);
const cleanupBlobUrls = useCallback(() => {
blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL:', error);
}
setBlobUrls(prev => {
prev.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL:', error);
}
});
return [];
});
setBlobUrls([]);
}, [blobUrls]);
}, []); // No dependencies - use functional update pattern
// Cleanup on unmount
// Cleanup on unmount - use ref to avoid dependency on blobUrls state
const blobUrlsRef = useRef<string[]>([]);
useEffect(() => {
blobUrlsRef.current = blobUrls;
}, [blobUrls]);
useEffect(() => {
return () => {
blobUrls.forEach(url => {
blobUrlsRef.current.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
@@ -32,19 +40,20 @@ export const useToolResources = () => {
}
});
};
}, [blobUrls]);
}, []); // No dependencies - use ref to access current URLs
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
console.log(`🖼️ useToolResources.generateThumbnails: Starting for ${files.length} files`);
const thumbnails: string[] = [];
for (const file of files) {
try {
console.log(`🖼️ Generating thumbnail for: ${file.name} (${file.type}, ${file.size} bytes)`);
const thumbnail = await generateThumbnailForFile(file);
if (thumbnail) {
thumbnails.push(thumbnail);
}
console.log(`🖼️ Generated thumbnail for ${file.name}: SUCCESS`);
thumbnails.push(thumbnail);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
console.warn(`🖼️ Failed to generate thumbnail for ${file.name}:`, error);
thumbnails.push('');
}
}
@@ -52,6 +61,26 @@ export const useToolResources = () => {
return thumbnails;
}, []);
const generateThumbnailsWithMetadata = useCallback(async (files: File[]): Promise<ThumbnailWithMetadata[]> => {
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Starting for ${files.length} files`);
const results: ThumbnailWithMetadata[] = [];
for (const file of files) {
try {
console.log(`🖼️ Generating thumbnail with metadata for: ${file.name} (${file.type}, ${file.size} bytes)`);
const result = await generateThumbnailWithMetadata(file);
console.log(`🖼️ Generated thumbnail with metadata for ${file.name}: SUCCESS, ${result.pageCount} pages`);
results.push(result);
} catch (error) {
console.warn(`🖼️ Failed to generate thumbnail with metadata for ${file.name}:`, error);
results.push({ thumbnail: '', pageCount: 1 });
}
}
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Complete. Generated ${results.length}/${files.length} thumbnails with metadata`);
return results;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
try {
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
@@ -108,6 +137,7 @@ export const useToolResources = () => {
return {
generateThumbnails,
generateThumbnailsWithMetadata,
createDownloadInfo,
extractZipFiles,
extractAllZipFiles,

View File

@@ -88,6 +88,8 @@ export const useToolState = () => {
}, []);
const setThumbnails = useCallback((thumbnails: string[]) => {
console.log(`🔧 useToolState.setThumbnails: Setting ${thumbnails.length} thumbnails:`,
thumbnails.map((t, i) => `[${i}]: ${t ? 'PRESENT' : 'MISSING'}`));
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
}, []);

View File

@@ -1,27 +1,38 @@
import { useCallback } from 'react';
import { useFileContext } from '../contexts/FileContext';
import { useFileState, useFileActions } from '../contexts/FileContext';
import { FileMetadata } from '../types/file';
export const useFileHandler = () => {
const { activeFiles, addFiles } = useFileContext();
const { state } = useFileState(); // Still needed for addStoredFiles
const { actions } = useFileActions();
const addToActiveFiles = useCallback(async (file: File) => {
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
if (!exists) {
await addFiles([file]);
}
}, [activeFiles, addFiles]);
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles([file]);
}, [actions.addFiles]);
const addMultipleFiles = useCallback(async (files: File[]) => {
const newFiles = files.filter(file =>
!activeFiles.some(f => f.name === file.name && f.size === file.size)
);
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles(files);
}, [actions.addFiles]);
// Add stored files preserving their original IDs to prevent session duplicates
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
// Filter out files that already exist with the same ID (exact match)
const newFiles = filesWithMetadata.filter(({ originalId }) => {
return state.files.byId[originalId] === undefined;
});
if (newFiles.length > 0) {
await addFiles(newFiles);
await actions.addStoredFiles(newFiles);
}
}, [activeFiles, addFiles]);
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
}, [state.files.byId, actions.addStoredFiles]);
return {
addToActiveFiles,
addMultipleFiles,
addStoredFiles,
};
};

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,

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { FileWithUrl } from "../types/file";
import { fileStorage } from "../services/fileStorage";
import { FileMetadata } from "../types/file";
import { useIndexedDB } from "../contexts/IndexedDBContext";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
/**
@@ -22,12 +22,13 @@ function calculateThumbnailScale(pageViewport: { width: number; height: number }
* Hook for IndexedDB-aware thumbnail loading
* Handles thumbnail generation for files not in IndexedDB
*/
export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
thumbnail: string | null;
isGenerating: boolean
} {
const [thumb, setThumb] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const indexedDB = useIndexedDB();
useEffect(() => {
let cancelled = false;
@@ -44,46 +45,36 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
return;
}
// Second priority: generate thumbnail for any file type
// Second priority: generate thumbnail for files under 100MB
if (file.size < 100 * 1024 * 1024 && !generating) {
setGenerating(true);
try {
let fileObject: File;
// Handle IndexedDB files vs regular File objects
if (file.storedInIndexedDB && file.id) {
// For IndexedDB files, recreate File object from stored data
const storedFile = await fileStorage.getFile(file.id);
if (!storedFile) {
// Try to load file from IndexedDB using new context
if (file.id && indexedDB) {
const loadedFile = await indexedDB.loadFile(file.id);
if (!loadedFile) {
throw new Error('File not found in IndexedDB');
}
fileObject = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
} else if ((file as any /* Fix me */).file) {
// For FileWithUrl objects that have a File object
fileObject = (file as any /* Fix me */).file;
} else if (file.id) {
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
const storedFile = await fileStorage.getFile(file.id);
if (!storedFile) {
throw new Error('File not found in IndexedDB and no File object available');
}
fileObject = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
fileObject = loadedFile;
} else {
throw new Error('File object not available and no ID for IndexedDB lookup');
throw new Error('File ID not available or IndexedDB context not available');
}
// Use the universal thumbnail generator
const thumbnail = await generateThumbnailForFile(fileObject);
if (!cancelled && thumbnail) {
if (!cancelled) {
setThumb(thumbnail);
} else if (!cancelled) {
setThumb(null);
// Save thumbnail to IndexedDB for persistence
if (file.id && indexedDB && thumbnail) {
try {
await indexedDB.updateThumbnail(file.id, thumbnail);
} catch (error) {
console.warn('Failed to save thumbnail to IndexedDB:', error);
}
}
}
} catch (error) {
console.warn('Failed to generate thumbnail for file', file.name, error);
@@ -92,14 +83,14 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
if (!cancelled) setGenerating(false);
}
} else {
// Large files - generate placeholder
// Large files - no thumbnail
setThumb(null);
}
}
loadThumbnail();
return () => { cancelled = true; };
}, [file, file?.thumbnail, file?.id]);
}, [file, file?.thumbnail, file?.id, indexedDB, generating]);
return { thumbnail: thumb, isGenerating: generating };
}

View File

@@ -1,30 +0,0 @@
import { useCallback } from 'react';
import { useFileContext } from '../contexts/FileContext';
/**
* Hook for components that need to register resources with centralized memory management
*/
export function useMemoryManagement() {
const { trackBlobUrl, trackPdfDocument, scheduleCleanup } = useFileContext();
const registerBlobUrl = useCallback((url: string) => {
trackBlobUrl(url);
return url;
}, [trackBlobUrl]);
const registerPdfDocument = useCallback((fileId: string, pdfDoc: any) => {
trackPdfDocument(fileId, pdfDoc);
return pdfDoc;
}, [trackPdfDocument]);
const cancelCleanup = useCallback((fileId: string) => {
// Cancel scheduled cleanup (user is actively using the file)
scheduleCleanup(fileId, -1); // -1 cancels the timer
}, [scheduleCleanup]);
return {
registerBlobUrl,
registerPdfDocument,
cancelCleanup
};
}

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { getDocument } from 'pdfjs-dist';
import { PDFDocument, PDFPage } from '../types/pageEditor';
import { pdfWorkerManager } from '../services/pdfWorkerManager';
export function usePDFProcessor() {
const [loading, setLoading] = useState(false);
@@ -13,7 +13,7 @@ export function usePDFProcessor() {
): Promise<string> => {
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
@@ -29,8 +29,8 @@ export function usePDFProcessor() {
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
// Clean up
pdf.destroy();
// Clean up using worker manager
pdfWorkerManager.destroyDocument(pdf);
return thumbnail;
} catch (error) {
@@ -39,13 +39,35 @@ export function usePDFProcessor() {
}
}, []);
// Internal function to generate thumbnail from already-opened PDF
const generateThumbnailFromPDF = useCallback(async (
pdf: any,
pageNumber: number,
scale: number = 0.5
): Promise<string> => {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
return canvas.toDataURL();
}, []);
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
setLoading(true);
setError(null);
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const totalPages = pdf.numPages;
const pages: PDFPage[] = [];
@@ -61,19 +83,19 @@ export function usePDFProcessor() {
});
}
// Generate thumbnails for first 10 pages immediately for better UX
// Generate thumbnails for first 10 pages immediately using the same PDF instance
const priorityPages = Math.min(10, totalPages);
for (let i = 1; i <= priorityPages; i++) {
try {
const thumbnail = await generatePageThumbnail(file, i);
const thumbnail = await generateThumbnailFromPDF(pdf, i);
pages[i - 1].thumbnail = thumbnail;
} catch (error) {
console.warn(`Failed to generate thumbnail for page ${i}:`, error);
}
}
// Clean up
pdf.destroy();
// Clean up using worker manager
pdfWorkerManager.destroyDocument(pdf);
const document: PDFDocument = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
@@ -91,7 +113,7 @@ export function usePDFProcessor() {
} finally {
setLoading(false);
}
}, [generatePageThumbnail]);
}, [generateThumbnailFromPDF]);
return {
processPDFFile,

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import { pdfWorkerManager } from '../services/pdfWorkerManager';
export interface PdfSignatureDetectionResult {
hasDigitalSignatures: boolean;
@@ -21,14 +22,12 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
let foundSignature = false;
try {
// Set up PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-legacy/pdf.worker.mjs';
for (const file of files) {
const arrayBuffer = await file.arrayBuffer();
try {
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
@@ -42,6 +41,9 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
if (foundSignature) break;
}
// Clean up PDF document using worker manager
pdfWorkerManager.destroyDocument(pdf);
} catch (error) {
console.warn('Error analyzing PDF for signatures:', error);
}

View File

@@ -1,12 +1,121 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
// Request queue to handle concurrent thumbnail requests
interface ThumbnailRequest {
pageId: string;
file: File;
pageNumber: number;
resolve: (thumbnail: string | null) => void;
reject: (error: Error) => void;
}
// Global request queue (shared across all hook instances)
const requestQueue: ThumbnailRequest[] = [];
let isProcessingQueue = false;
let batchTimer: number | null = null;
// Track active thumbnail requests to prevent duplicates across components
const activeRequests = new Map<string, Promise<string | null>>();
// Batch processing configuration
const BATCH_SIZE = 20; // Process thumbnails in batches of 20 for better UI responsiveness
const BATCH_DELAY = 100; // Wait 100ms to collect requests before processing
const PRIORITY_BATCH_DELAY = 50; // Faster processing for the first batch (visible pages)
// Process the queue in batches for better performance
async function processRequestQueue() {
if (isProcessingQueue || requestQueue.length === 0) {
return;
}
isProcessingQueue = true;
try {
while (requestQueue.length > 0) {
// Sort queue by page number to prioritize visible pages first
requestQueue.sort((a, b) => a.pageNumber - b.pageNumber);
// Take a batch of requests (same file only for efficiency)
const batchSize = Math.min(BATCH_SIZE, requestQueue.length);
const batch = requestQueue.splice(0, batchSize);
// Group by file to process efficiently
const fileGroups = new Map<File, ThumbnailRequest[]>();
// First, resolve any cached thumbnails immediately
const uncachedRequests: ThumbnailRequest[] = [];
for (const request of batch) {
const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId);
if (cached) {
request.resolve(cached);
} else {
uncachedRequests.push(request);
if (!fileGroups.has(request.file)) {
fileGroups.set(request.file, []);
}
fileGroups.get(request.file)!.push(request);
}
}
// Process each file group with batch thumbnail generation
for (const [file, requests] of fileGroups) {
if (requests.length === 0) continue;
try {
const pageNumbers = requests.map(req => req.pageNumber);
const arrayBuffer = await file.arrayBuffer();
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
// Use file name as fileId for PDF document caching
const fileId = file.name + '_' + file.size + '_' + file.lastModified;
const results = await thumbnailGenerationService.generateThumbnails(
fileId,
arrayBuffer,
pageNumbers,
{ scale: 1.0, quality: 0.8, batchSize: BATCH_SIZE },
(progress) => {
// Optional: Could emit progress events here for UI feedback
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
}
);
// Match results back to requests and resolve
for (const request of requests) {
const result = results.find(r => r.pageNumber === request.pageNumber);
if (result && result.success && result.thumbnail) {
thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail);
request.resolve(result.thumbnail);
} else {
console.warn(`No result for page ${request.pageNumber}`);
request.resolve(null);
}
}
} catch (error) {
console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error);
// Reject all requests in this batch
requests.forEach(request => request.reject(error as Error));
}
}
}
} finally {
isProcessingQueue = false;
}
}
/**
* Hook for tools that want to use thumbnail generation
* Tools can choose whether to include visual features
*/
export function useThumbnailGeneration() {
const generateThumbnails = useCallback(async (
fileId: string,
pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[],
options: {
@@ -18,6 +127,7 @@ export function useThumbnailGeneration() {
onProgress?: (progress: { completed: number; total: number; thumbnails: any[] }) => void
) => {
return thumbnailGenerationService.generateThumbnails(
fileId,
pdfArrayBuffer,
pageNumbers,
options,
@@ -42,15 +152,88 @@ export function useThumbnailGeneration() {
}, []);
const destroyThumbnails = useCallback(() => {
// Clear any pending batch timer
if (batchTimer) {
clearTimeout(batchTimer);
batchTimer = null;
}
// Clear the queue and active requests
requestQueue.length = 0;
activeRequests.clear();
isProcessingQueue = false;
thumbnailGenerationService.destroy();
}, []);
const clearPDFCacheForFile = useCallback((fileId: string) => {
thumbnailGenerationService.clearPDFCacheForFile(fileId);
}, []);
const requestThumbnail = useCallback(async (
pageId: string,
file: File,
pageNumber: number
): Promise<string | null> => {
// Check cache first for immediate return
const cached = thumbnailGenerationService.getThumbnailFromCache(pageId);
if (cached) {
return cached;
}
// Check if this request is already being processed globally
const activeRequest = activeRequests.get(pageId);
if (activeRequest) {
return activeRequest;
}
// Create new request promise and track it globally
const requestPromise = new Promise<string | null>((resolve, reject) => {
requestQueue.push({
pageId,
file,
pageNumber,
resolve: (result: string | null) => {
activeRequests.delete(pageId);
resolve(result);
},
reject: (error: Error) => {
activeRequests.delete(pageId);
reject(error);
}
});
// Schedule batch processing with a small delay to collect more requests
if (batchTimer) {
clearTimeout(batchTimer);
}
// Use shorter delay for the first batch (pages 1-50) to show visible content faster
const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE);
const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY;
batchTimer = window.setTimeout(() => {
processRequestQueue().catch(error => {
console.error('Error processing thumbnail request queue:', error);
});
batchTimer = null;
}, delay);
});
// Track this request to prevent duplicates
activeRequests.set(pageId, requestPromise);
return requestPromise;
}, []);
return {
generateThumbnails,
addThumbnailToCache,
getThumbnailFromCache,
getCacheStats,
stopGeneration,
destroyThumbnails
destroyThumbnails,
clearPDFCacheForFile,
requestThumbnail
};
}

View File

@@ -0,0 +1,125 @@
/**
* URL synchronization hooks for tool routing
*/
import { useEffect, useCallback } from 'react';
import { ModeType } from '../contexts/NavigationContext';
import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting';
/**
* Hook to sync navigation mode with URL
*/
export function useNavigationUrlSync(
currentMode: ModeType,
setMode: (mode: ModeType) => void,
enableSync: boolean = true
) {
// Initialize mode from URL on mount
useEffect(() => {
if (!enableSync) return;
const route = parseToolRoute();
if (route.mode !== currentMode) {
setMode(route.mode);
}
}, []); // Only run on mount
// Update URL when mode changes
useEffect(() => {
if (!enableSync) return;
if (currentMode === 'pageEditor') {
clearToolRoute();
} else {
updateToolRoute(currentMode, currentMode);
}
}, [currentMode, enableSync]);
// Handle browser back/forward navigation
useEffect(() => {
if (!enableSync) return;
const handlePopState = () => {
const route = parseToolRoute();
if (route.mode !== currentMode) {
setMode(route.mode);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [currentMode, setMode, enableSync]);
}
/**
* Hook to sync tool workflow with URL
*/
export function useToolWorkflowUrlSync(
selectedToolKey: string | null,
selectTool: (toolKey: string) => void,
clearTool: () => void,
enableSync: boolean = true
) {
// Initialize tool from URL on mount
useEffect(() => {
if (!enableSync) return;
const route = parseToolRoute();
if (route.toolKey && route.toolKey !== selectedToolKey) {
selectTool(route.toolKey);
} else if (!route.toolKey && selectedToolKey) {
clearTool();
}
}, []); // Only run on mount
// Update URL when tool changes
useEffect(() => {
if (!enableSync) return;
if (selectedToolKey) {
const route = parseToolRoute();
if (route.toolKey !== selectedToolKey) {
updateToolRoute(selectedToolKey as ModeType, selectedToolKey);
}
}
}, [selectedToolKey, enableSync]);
}
/**
* Hook to get current URL route information
*/
export function useCurrentRoute() {
const getCurrentRoute = useCallback(() => {
return parseToolRoute();
}, []);
return getCurrentRoute;
}
/**
* Hook to programmatically navigate to tools
*/
export function useToolNavigation() {
const navigateToTool = useCallback((toolKey: string) => {
updateToolRoute(toolKey as ModeType, toolKey);
// Dispatch a custom event to notify other components
window.dispatchEvent(new CustomEvent('toolNavigation', {
detail: { toolKey }
}));
}, []);
const navigateToHome = useCallback(() => {
clearToolRoute();
// Dispatch a custom event to notify other components
window.dispatchEvent(new CustomEvent('toolNavigation', {
detail: { toolKey: null }
}));
}, []);
return {
navigateToTool,
navigateToHome
};
}