mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/v2/filemanager (#4121)
FileManager Component Overview
Purpose: Modal component for selecting and managing PDF files with
preview capabilities
Architecture:
- Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx
(3-column)
- Central State: FileManagerContext handles file operations, selection,
and modal state
- File Storage: IndexedDB persistence with thumbnail caching
Key Components:
- FileSourceButtons: Switch between Recent/Local/Drive sources
- FileListArea: Scrollable file grid with search functionality
- FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow
pages based on file count)
- FileDetails: File info card with metadata
- CompactFileDetails: Mobile-optimized file info layout
File Flow:
1. Users select source → browse/search files → select multiple files →
preview with navigation → open in
tools
2. Files persist across tool switches via FileContext integration
3. Memory management handles large PDFs (up to 100GB+)
```mermaid
graph TD
FM[FileManager] --> ML[MobileLayout]
FM --> DL[DesktopLayout]
ML --> FSB[FileSourceButtons<br/>Recent/Local/Drive]
ML --> FLA[FileListArea]
ML --> FD[FileDetails]
DL --> FSB
DL --> FLA
DL --> FD
FLA --> FLI[FileListItem]
FD --> FP[FilePreview]
FD --> CFD[CompactFileDetails]
```
---------
Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
@@ -358,7 +358,10 @@ export const useConvertOperation = (): ConvertOperationHook => {
|
||||
setDownloadFilename(convertedFile.name);
|
||||
setStatus(t("downloadComplete"));
|
||||
|
||||
await processResults(new Blob([convertedFile]), convertedFile.name);
|
||||
// Update local files state for hook consumers
|
||||
setFiles([convertedFile]);
|
||||
|
||||
await addFiles([convertedFile]);
|
||||
markOperationApplied(fileId, operationId);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -63,7 +64,12 @@ export const useFileManager = () => {
|
||||
|
||||
const storeFile = useCallback(async (file: File) => {
|
||||
try {
|
||||
const storedFile = await fileStorage.storeFile(file);
|
||||
// Generate thumbnail for the file
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
|
||||
// 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;
|
||||
@@ -111,12 +117,21 @@ export const useFileManager = () => {
|
||||
};
|
||||
}, [convertToFile]);
|
||||
|
||||
const touchFile = useCallback(async (id: string) => {
|
||||
try {
|
||||
await fileStorage.touchFile(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to touch file:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
convertToFile,
|
||||
loadRecentFiles,
|
||||
handleRemoveFile,
|
||||
storeFile,
|
||||
touchFile,
|
||||
createFileSelectionHandlers
|
||||
};
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface UseFilesModalReturn {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: () => void;
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect?: (file: File) => void;
|
||||
onFilesSelect?: (files: File[]) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
interface UseFilesModalProps {
|
||||
onFileSelect?: (file: File) => void;
|
||||
onFilesSelect?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export const useFilesModal = ({
|
||||
onFileSelect,
|
||||
onFilesSelect
|
||||
}: UseFilesModalProps = {}): UseFilesModalReturn => {
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
|
||||
const openFilesModal = useCallback(() => {
|
||||
setIsFilesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeFilesModal = useCallback(() => {
|
||||
setIsFilesModalOpen(false);
|
||||
onModalClose?.();
|
||||
}, [onModalClose]);
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
onFileSelect?.(file);
|
||||
closeFilesModal();
|
||||
}, [onFileSelect, closeFilesModal]);
|
||||
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
onFilesSelect?.(files);
|
||||
closeFilesModal();
|
||||
}, [onFilesSelect, closeFilesModal]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,22 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getDocument } from "pdfjs-dist";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
|
||||
/**
|
||||
* Calculate optimal scale for thumbnail generation
|
||||
* Ensures high quality while preventing oversized renders
|
||||
*/
|
||||
function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
|
||||
const maxWidth = 400; // Max thumbnail width
|
||||
const maxHeight = 600; // Max thumbnail height
|
||||
|
||||
const scaleX = maxWidth / pageViewport.width;
|
||||
const scaleY = maxHeight / pageViewport.height;
|
||||
|
||||
// Don't upscale, only downscale if needed
|
||||
return Math.min(scaleX, scaleY, 1.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for IndexedDB-aware thumbnail loading
|
||||
@@ -28,38 +44,55 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
return;
|
||||
}
|
||||
|
||||
// Second priority: for IndexedDB files without stored thumbnails, just use placeholder
|
||||
if (file.storedInIndexedDB && file.id) {
|
||||
// Don't generate thumbnails for files loaded from IndexedDB - just use placeholder
|
||||
setThumb(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Third priority: generate from blob for regular files during upload (small files only)
|
||||
if (!file.storedInIndexedDB && file.size < 50 * 1024 * 1024 && !generating) {
|
||||
// Second priority: generate thumbnail for any file type
|
||||
if (file.size < 100 * 1024 * 1024 && !generating) {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 0.2 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context && !cancelled) {
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
if (!cancelled) setThumb(canvas.toDataURL());
|
||||
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) {
|
||||
throw new Error('File not found in IndexedDB');
|
||||
}
|
||||
fileObject = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
} else if (file.file) {
|
||||
// For FileWithUrl objects that have a File object
|
||||
fileObject = file.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
|
||||
});
|
||||
} else {
|
||||
throw new Error('File object not available and no ID for IndexedDB lookup');
|
||||
}
|
||||
|
||||
// Use the universal thumbnail generator
|
||||
const thumbnail = await generateThumbnailForFile(fileObject);
|
||||
if (!cancelled && thumbnail) {
|
||||
setThumb(thumbnail);
|
||||
} else if (!cancelled) {
|
||||
setThumb(null);
|
||||
}
|
||||
pdf.destroy(); // Clean up memory
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate thumbnail for regular file', file.name, error);
|
||||
console.warn('Failed to generate thumbnail for file', file.name, error);
|
||||
if (!cancelled) setThumb(null);
|
||||
} finally {
|
||||
if (!cancelled) setGenerating(false);
|
||||
}
|
||||
} else {
|
||||
// Large files or files without proper conditions - show placeholder
|
||||
// Large files - generate placeholder
|
||||
setThumb(null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user