Refactor file management and context handling to utilize UUIDs for file identification, enhance file deduplication, and improve content hashing. Update related components and hooks for better performance and stability.

This commit is contained in:
Reece Browne 2025-08-14 18:07:18 +01:00
parent f353d3404c
commit 4a0c577312
18 changed files with 621 additions and 244 deletions

View File

@ -1,9 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { FileWithUrl } from '../types/file';
import { FileMetadata } from '../types/file';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { createFileId } from '../types/fileContext';
import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
@ -16,12 +17,18 @@ interface FileManagerProps {
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// Wrapper for storeFile that generates UUID
const storeFileWithId = useCallback(async (file: File) => {
const fileId = createFileId(); // Generate UUID for storage
return await storeFile(file, fileId);
}, [storeFile]);
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
@ -34,7 +41,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
setRecentFiles(files);
}, [loadRecentFiles]);
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
try {
const fileObjects = await Promise.all(
files.map(async (fileWithUrl) => {
@ -82,14 +89,11 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Cleanup any blob URLs when component unmounts
useEffect(() => {
return () => {
// Clean up blob URLs from recent files
recentFiles.forEach(file => {
if (file.url && file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
});
// FileMetadata doesn't have blob URLs, so no cleanup needed
// Blob URLs are managed by FileContext and tool operations
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
};
}, [recentFiles]);
}, []);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
@ -152,7 +156,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
isOpen={isFilesModalOpen}
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
storeFile={storeFile}
storeFile={storeFileWithId}
refreshRecentFiles={refreshRecentFiles}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}

View File

@ -10,6 +10,7 @@ import { useToolFileSelection, useProcessedFiles, useFileState, useFileManagemen
import { FileOperation, createStableFileId } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from '../pageEditor/PageEditor.module.css';
@ -47,6 +48,9 @@ const FileEditor = ({
}: FileEditorProps) => {
const { t } = useTranslation();
// Thumbnail cache for sharing with PageEditor
const { getThumbnailFromCache, addThumbnailToCache } = useThumbnailGeneration();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
@ -60,6 +64,7 @@ const FileEditor = ({
// Extract needed values from state (memoized to prevent infinite loops)
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing;
@ -145,33 +150,48 @@ const FileEditor = ({
// Map context selections to local file IDs for UI display
const localSelectedIds = files
.filter(file => {
const contextFileId = createStableFileId(file.file);
return contextSelectedIds.includes(contextFileId);
// file.id is already the correct UUID from FileContext
return contextSelectedIds.includes(file.id);
})
.map(file => file.id);
// Convert shared files to FileEditor format
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Generate thumbnail if not already available
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
let thumbnail = sharedFile.thumbnail;
if (!thumbnail) {
// Check cache first using the file ID
const fileId = sharedFile.id || `file-${Date.now()}-${Math.random()}`;
const page1CacheKey = `${fileId}-page-1`;
thumbnail = getThumbnailFromCache(page1CacheKey);
if (!thumbnail) {
// Generate and cache thumbnail
thumbnail = await generateThumbnailForFile(sharedFile.file || sharedFile);
if (thumbnail) {
addThumbnailToCache(page1CacheKey, thumbnail);
console.log(`📸 FileEditor: Cached page-1 thumbnail for legacy file (key: ${page1CacheKey})`);
}
}
}
return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
pageCount: sharedFile.pageCount || 1, // Default to 1 page if unknown
thumbnail,
thumbnail: thumbnail || '',
size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile,
};
}, []);
}, [getThumbnailFromCache, addThumbnailToCache]);
// Convert activeFiles to FileItem format using context (async to avoid blocking)
useEffect(() => {
// Check if the actual content has changed, not just references
const currentActiveFileNames = activeFiles.map(f => f.name);
const currentActiveFileIds = activeFileRecords.map(r => r.id);
const currentProcessedFilesSize = processedFiles.processedFiles.size;
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
const activeFilesChanged = JSON.stringify(currentActiveFileIds) !== JSON.stringify(lastActiveFilesRef.current);
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
if (!activeFilesChanged && !processedFilesChanged) {
@ -179,49 +199,59 @@ const FileEditor = ({
}
// Update refs
lastActiveFilesRef.current = currentActiveFileNames;
lastActiveFilesRef.current = currentActiveFileIds;
lastProcessedFilesRef.current = currentProcessedFilesSize;
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
if (activeFileRecords.length > 0) {
setLocalLoading(true);
try {
// Process files in chunks to avoid blocking UI
const convertedFiles: FileItem[] = [];
for (let i = 0; i < activeFiles.length; i++) {
const file = activeFiles[i];
for (let i = 0; i < activeFileRecords.length; i++) {
const record = activeFileRecords[i];
const file = selectors.getFile(record.id);
// Try to get thumbnail from processed file first
const processedFile = processedFiles.processedFiles.get(file);
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
// If no thumbnail from processed file, try to generate one
if (!file) continue; // Skip if file not found
// Use record's thumbnail if available, otherwise check cache, then generate
let thumbnail: string | undefined = record.thumbnailUrl;
if (!thumbnail) {
try {
thumbnail = await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
thumbnail = undefined; // Use placeholder
// Check if PageEditor has already cached a page-1 thumbnail for this file
const page1CacheKey = `${record.id}-page-1`;
thumbnail = getThumbnailFromCache(page1CacheKey) || undefined;
if (!thumbnail) {
try {
thumbnail = await generateThumbnailForFile(file);
// Store in cache for PageEditor to reuse
if (thumbnail) {
addThumbnailToCache(page1CacheKey, thumbnail);
console.log(`📸 FileEditor: Cached page-1 thumbnail for ${file.name} (key: ${page1CacheKey})`);
}
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
thumbnail = undefined; // Use placeholder
}
} else {
console.log(`📸 FileEditor: Reused cached page-1 thumbnail for ${file.name} (key: ${page1CacheKey})`);
}
}
// Page count estimation for display purposes only
let pageCount = 1; // Default for non-PDFs and display in FileEditor
// Get actual page count from processed file
let pageCount = 1; // Default for non-PDFs
if (processedFile) {
pageCount = processedFile.pages?.length || processedFile.totalPages || 1;
} else if (file.type === 'application/pdf') {
// For PDFs without processed data, try to get a quick page count estimate
// If processing is taking too long, show a reasonable default
if (file.type === 'application/pdf') {
// Quick page count estimation for FileEditor display only
// PageEditor will do its own more thorough page detection
try {
// Quick and dirty page count using PDF structure analysis
const arrayBuffer = await file.arrayBuffer();
const text = new TextDecoder('latin1').decode(arrayBuffer);
const pageMatches = text.match(/\/Type\s*\/Page[^s]/g);
pageCount = pageMatches ? pageMatches.length : 1;
console.log(`📄 Quick page count for ${file.name}: ${pageCount} pages (estimated)`);
console.log(`📄 FileEditor estimated page count for ${file.name}: ${pageCount} pages (display only)`);
} catch (error) {
console.warn(`Failed to estimate page count for ${file.name}:`, error);
pageCount = 1; // Safe fallback
@ -229,7 +259,7 @@ const FileEditor = ({
}
const convertedFile = {
id: createStableFileId(file), // Use same ID function as context
id: record.id, // Use the record's UUID from FileContext
name: file.name,
pageCount: pageCount,
thumbnail: thumbnail || '',
@ -240,10 +270,10 @@ const FileEditor = ({
convertedFiles.push(convertedFile);
// Update progress
setConversionProgress(((i + 1) / activeFiles.length) * 100);
setConversionProgress(((i + 1) / activeFileRecords.length) * 100);
// Yield to main thread between files
if (i < activeFiles.length - 1) {
if (i < activeFileRecords.length - 1) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
@ -264,7 +294,7 @@ const FileEditor = ({
};
convertActiveFiles();
}, [activeFiles, processedFiles]);
}, [activeFileRecords, processedFiles, selectors]);
// Process uploaded files using context
@ -422,25 +452,25 @@ const FileEditor = ({
}, [addFiles]);
const selectAll = useCallback(() => {
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
setContextSelectedFiles(files.map(f => f.id)); // Use FileEditor file IDs which are now correct UUIDs
}, [files, setContextSelectedFiles]);
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFiles.length === 0) return;
if (activeFileRecords.length === 0) return;
// Record close all operation for each file
// Legacy operation tracking - now handled by FileContext
console.log('Close all operation for', activeFiles.length, 'files');
console.log('Close all operation for', activeFileRecords.length, 'files');
// Remove all files from context but keep in storage
const fileIds = activeFiles.map(f => createStableFileId(f));
const fileIds = activeFileRecords.map(r => r.id); // Use record IDs directly
removeFiles(fileIds, false);
// Clear selections
setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles]);
}, [activeFileRecords, removeFiles, setContextSelectedFiles]);
const toggleFile = useCallback((fileId: string) => {
const currentFiles = filesDataRef.current;
@ -449,7 +479,8 @@ const FileEditor = ({
const targetFile = currentFiles.find(f => f.id === fileId);
if (!targetFile) return;
const contextFileId = createStableFileId(targetFile.file);
// The fileId from FileEditor is already the correct UUID from FileContext
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: string[];
@ -611,7 +642,7 @@ const FileEditor = ({
// Record close operation
const fileName = file.file.name;
const fileId = (file.file as any).id || fileName;
const contextFileId = file.id; // Use the correct file ID (UUID from FileContext)
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
@ -633,13 +664,13 @@ const FileEditor = ({
console.log('Close operation recorded:', operation);
// Remove file from context but keep in storage (close, don't delete)
console.log('Calling removeFiles with:', [fileId]);
removeFiles([fileId], false);
console.log('Calling removeFiles with:', [contextFileId]);
removeFiles([contextFileId], false);
// Remove from context selections
setContextSelectedFiles((prev: string[]) => {
const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== fileId);
return safePrev.filter(id => id !== contextFileId);
});
} else {
console.log('File not found for fileId:', fileId);
@ -650,7 +681,7 @@ const FileEditor = ({
const file = files.find(f => f.id === fileId);
if (file) {
// Set the file as selected in context and switch to viewer for preview
const contextFileId = createStableFileId(file.file);
const contextFileId = file.id; // Use the correct file ID (UUID from FileContext)
setContextSelectedFiles([contextFileId]);
setCurrentView('viewer');
}

View File

@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface CompactFileDetailsProps {
currentFile: FileWithUrl | null;
currentFile: FileMetadata | null;
thumbnail: string | null;
selectedFiles: FileWithUrl[];
selectedFiles: FileMetadata[];
currentFileIndex: number;
numberOfFiles: number;
isAnimating: boolean;

View File

@ -2,10 +2,10 @@ import React from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface FileInfoCardProps {
currentFile: FileWithUrl | null;
currentFile: FileMetadata | null;
modalHeight: string;
}

View File

@ -2,10 +2,10 @@ import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface FileListItemProps {
file: FileWithUrl;
file: FileMetadata;
isSelected: boolean;
isSupported: boolean;
onSelect: () => void;

View File

@ -3,10 +3,10 @@ import { Box, Center, ActionIcon, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface FilePreviewProps {
currentFile: FileWithUrl | null;
currentFile: FileMetadata | null;
thumbnail: string | null;
numberOfFiles: number;
isAnimating: boolean;

View File

@ -84,6 +84,19 @@ const PageEditor = ({
* Using this instead of direct file arrays prevents unnecessary re-renders.
*/
// Thumbnail generation (opt-in for visual tools) - MUST be before mergedPdfDocument
const {
generateThumbnails,
addThumbnailToCache,
getThumbnailFromCache,
stopGeneration,
destroyThumbnails
} = useThumbnailGeneration();
// State for discovered page document
const [discoveredDocument, setDiscoveredDocument] = useState<PDFDocument | null>(null);
const [isDiscoveringPages, setIsDiscoveringPages] = useState(false);
// Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => {
if (activeFileIds.length === 0) return null;
@ -107,24 +120,64 @@ const PageEditor = ({
// Get pages from processed file data
const processedFile = primaryFileRecord.processedFile;
// Convert processed pages to PageEditor format, or create placeholder if not processed yet
const pages = processedFile?.pages?.length > 0
? processedFile.pages.map((page, index) => ({
id: `${primaryFileId}-page-${index + 1}`,
// Debug logging for processed file data
console.log(`🎬 PageEditor: Building document for ${name}`);
console.log(`🎬 ProcessedFile exists:`, !!processedFile);
console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0);
if (processedFile?.pages) {
console.log(`🎬 Pages structure:`, processedFile.pages.map(p => ({ pageNumber: p.pageNumber || 'unknown', hasThumbnail: !!p.thumbnail })));
}
// Convert processed pages to PageEditor format, or discover pages if not processed yet
let pages: PDFPage[];
if (processedFile?.pages && processedFile.pages.length > 0) {
// Use existing processed data
pages = processedFile.pages.map((page, index) => {
const pageId = `${primaryFileId}-page-${index + 1}`;
// Try multiple sources for thumbnails in order of preference:
// 1. Processed data thumbnail
// 2. Cached thumbnail from previous generation
// 3. For page 1: FileEditor's thumbnailUrl (sharing optimization)
let thumbnail = page.thumbnail || null;
if (!thumbnail) {
thumbnail = getThumbnailFromCache(pageId) || null;
}
if (!thumbnail && index === 0) {
// For page 1, also check if FileEditor has already generated a thumbnail
thumbnail = primaryFileRecord.thumbnailUrl || null;
// If we found a FileEditor thumbnail, cache it for consistency
if (thumbnail) {
addThumbnailToCache(pageId, thumbnail);
console.log(`📸 PageEditor: Reused FileEditor thumbnail for page 1 (${pageId})`);
}
}
return {
id: pageId,
pageNumber: index + 1,
thumbnail: page.thumbnail || null,
thumbnail,
rotation: page.rotation || 0,
selected: false,
splitBefore: page.splitBefore || false,
}))
: [{
id: `${primaryFileId}-page-1`,
pageNumber: 1,
thumbnail: null,
rotation: 0,
selected: false,
splitBefore: false,
}]; // Fallback: single page placeholder
};
});
} else if (discoveredDocument && discoveredDocument.id === (primaryFileId ?? 'unknown')) {
// Use discovered document if available and matches current file
pages = discoveredDocument.pages;
} else {
// No processed data and no discovered data yet - show placeholder while discovering
console.log(`🎬 PageEditor: No processedFile data, showing placeholder while discovering pages for ${name}`);
pages = [{
id: `${primaryFileId}-page-1`,
pageNumber: 1,
thumbnail: getThumbnailFromCache(`${primaryFileId}-page-1`) || primaryFileRecord.thumbnailUrl || null,
rotation: 0,
selected: false,
splitBefore: false,
}];
}
// Create document with determined pages
@ -136,7 +189,123 @@ const PageEditor = ({
totalPages: pages.length,
destroy: () => {} // Optional cleanup function
};
}, [filesSignature, activeFileIds, primaryFileId, selectors]);
}, [filesSignature, activeFileIds, primaryFileId, selectors, getThumbnailFromCache, addThumbnailToCache, discoveredDocument]);
// Async page discovery effect
useEffect(() => {
const discoverPages = async () => {
if (!primaryFileId) return;
const record = selectors.getFileRecord(primaryFileId);
const primaryFile = selectors.getFile(primaryFileId);
if (!record || !primaryFile) return;
// Skip if we already have processed data or are currently discovering
if (record.processedFile?.pages || isDiscoveringPages) return;
// Only discover for PDF files
if (primaryFile.type !== 'application/pdf') return;
console.log(`🎬 PageEditor: Starting async page discovery for ${primaryFile.name}`);
setIsDiscoveringPages(true);
try {
let discoveredPageCount = 1;
// Try PDF.js first (more accurate)
try {
const arrayBuffer = await primaryFile.arrayBuffer();
const pdfDoc = await import('pdfjs-dist').then(pdfjs => pdfjs.getDocument({
data: arrayBuffer,
disableAutoFetch: true,
disableStream: true
}).promise);
discoveredPageCount = pdfDoc.numPages;
console.log(`🎬 PageEditor: Discovered ${discoveredPageCount} pages using PDF.js`);
// Clean up PDF document immediately
pdfDoc.destroy();
} catch (pdfError) {
console.warn(`🎬 PageEditor: PDF.js failed, trying text analysis:`, pdfError);
// Fallback to text analysis
try {
const arrayBuffer = await primaryFile.arrayBuffer();
const text = new TextDecoder('latin1').decode(arrayBuffer);
const pageMatches = text.match(/\/Type\s*\/Page[^s]/g);
discoveredPageCount = pageMatches ? pageMatches.length : 1;
console.log(`🎬 PageEditor: Discovered ${discoveredPageCount} pages using text analysis`);
} catch (textError) {
console.warn(`🎬 PageEditor: Text analysis also failed:`, textError);
discoveredPageCount = 1;
}
}
// Create page structure
const pages = Array.from({ length: discoveredPageCount }, (_, index) => {
const pageId = `${primaryFileId}-page-${index + 1}`;
let thumbnail = getThumbnailFromCache(pageId) || null;
// For page 1, also check FileEditor's thumbnail
if (!thumbnail && index === 0) {
thumbnail = record.thumbnailUrl || null;
if (thumbnail) {
addThumbnailToCache(pageId, thumbnail);
console.log(`📸 PageEditor: Reused FileEditor thumbnail for page 1 (${pageId})`);
}
}
return {
id: pageId,
pageNumber: index + 1,
thumbnail,
rotation: 0,
selected: false,
splitBefore: false,
};
});
// Create discovered document
const discoveredDoc: PDFDocument = {
id: primaryFileId,
name: primaryFile.name,
file: primaryFile,
pages,
totalPages: pages.length,
destroy: () => {}
};
// Save to state for immediate UI update
setDiscoveredDocument(discoveredDoc);
// Save to FileContext for persistence
const processedFileData = {
pages: pages.map(page => ({
pageNumber: page.pageNumber,
thumbnail: page.thumbnail || undefined,
rotation: page.rotation,
splitBefore: page.splitBefore
})),
totalPages: discoveredPageCount,
lastProcessed: Date.now()
};
actions.updateFileRecord(primaryFileId, {
processedFile: processedFileData
});
console.log(`🎬 PageEditor: Page discovery complete - ${discoveredPageCount} pages saved to FileContext`);
} catch (error) {
console.error(`🎬 PageEditor: Page discovery failed:`, error);
} finally {
setIsDiscoveringPages(false);
}
};
discoverPages();
}, [primaryFileId, selectors, isDiscoveringPages, getThumbnailFromCache, addThumbnailToCache, actions]);
// Display document: Use edited version if exists, otherwise original
const displayDocument = editedDocument || mergedPdfDocument;
@ -209,15 +378,6 @@ const PageEditor = ({
*/
const thumbnailGenerationStarted = useRef(false);
// Thumbnail generation (opt-in for visual tools)
const {
generateThumbnails,
addThumbnailToCache,
getThumbnailFromCache,
stopGeneration,
destroyThumbnails
} = useThumbnailGeneration();
// Start thumbnail generation process (guards against re-entry) - stable version
const startThumbnailGeneration = useCallback(() => {
// Access current values directly - avoid stale closures
@ -280,6 +440,28 @@ const PageEditor = ({
if (!cached) {
addThumbnailToCache(pageId, thumbnail);
// Persist thumbnail to FileContext for durability
const fileRecord = selectors.getFileRecord(currentPrimaryFileId);
if (fileRecord) {
const updatedProcessedFile = {
...fileRecord.processedFile,
pages: fileRecord.processedFile?.pages?.map((page, index) =>
index + 1 === pageNumber
? { ...page, thumbnail }
: page
) || [{ thumbnail }] // Create pages array if it doesn't exist
};
// For page 1, also update the file record's thumbnailUrl so FileEditor can use it directly
const updates: any = { processedFile: updatedProcessedFile };
if (pageNumber === 1) {
updates.thumbnailUrl = thumbnail;
console.log(`📸 PageEditor: Set thumbnailUrl for FileEditor reuse (${currentPrimaryFileId})`);
}
actions.updateFileRecord(currentPrimaryFileId, updates);
}
window.dispatchEvent(new CustomEvent('thumbnailReady', {
detail: { pageNumber, thumbnail, pageId }
}));
@ -304,7 +486,7 @@ const PageEditor = ({
thumbnailGenerationStarted.current = false;
}
}, 0); // setTimeout with 0ms to defer to next tick
}, [generateThumbnails, getThumbnailFromCache, addThumbnailToCache]); // Only stable function dependencies
}, [generateThumbnails, getThumbnailFromCache, addThumbnailToCache, selectors, actions]); // Only stable function dependencies
// Start thumbnail generation when files change (stable signature prevents loops)
useEffect(() => {

View File

@ -10,15 +10,13 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo';
import { useFileState } from '../../contexts/FileContext';
import styles from './PageEditor.module.css';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
// Ensure PDF.js worker is available
if (!GlobalWorkerOptions.workerSrc) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
console.log('📸 PageThumbnail: Set PDF.js worker source to /pdf.worker.js');
} else {
console.log('📸 PageThumbnail: PDF.js worker source already set to', GlobalWorkerOptions.workerSrc);
}
interface PageThumbnailProps {
@ -81,15 +79,15 @@ const PageThumbnail = React.memo(({
setPdfDocument,
}: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
const { state, selectors } = useFileState();
// Update thumbnail URL when page prop changes
// Update thumbnail URL when page prop changes - prevent redundant updates
useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
setThumbnailUrl(page.thumbnail);
}
}, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]);
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
useEffect(() => {
@ -100,8 +98,15 @@ const PageThumbnail = React.memo(({
const handleThumbnailReady = (event: CustomEvent) => {
const { pageNumber, thumbnail, pageId } = event.detail;
// Guard: check if this component is still mounted and page still exists
if (pageNumber === page.pageNumber && pageId === page.id) {
setThumbnailUrl(thumbnail);
// Additional safety: check if the file still exists in FileContext
const fileId = page.id.split('-page-')[0]; // Extract fileId from pageId
const fileExists = selectors.getAllFileIds().includes(fileId);
if (fileExists) {
setThumbnailUrl(thumbnail);
}
}
};
@ -109,7 +114,7 @@ const PageThumbnail = React.memo(({
return () => {
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
};
}, [page.pageNumber, page.id, thumbnailUrl]);
}, [page.pageNumber, page.id]); // Remove thumbnailUrl dependency to stabilize effect
// Register this component with pageRefs for animations
@ -225,11 +230,6 @@ const PageThumbnail = React.memo(({
transition: 'transform 0.3s ease-in-out'
}}
/>
) : isLoadingThumbnail ? (
<div style={{ textAlign: 'center' }}>
<Loader size="sm" />
<Text size="xs" c="dimmed" mt={4}>Loading...</Text>
</div>
) : (
<div style={{ textAlign: 'center' }}>
<Text size="lg" c="dimmed">📄</Text>
@ -416,13 +416,20 @@ const PageThumbnail = React.memo(({
</div>
);
}, (prevProps, nextProps) => {
// Helper for shallow array comparison
const arraysEqual = (a: number[], b: number[]) => {
return a.length === b.length && a.every((val, i) => val === b[i]);
};
// Only re-render if essential props change
return (
prevProps.page.id === nextProps.page.id &&
prevProps.page.pageNumber === nextProps.page.pageNumber &&
prevProps.page.rotation === nextProps.page.rotation &&
prevProps.page.thumbnail === nextProps.page.thumbnail &&
prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
// Shallow compare selectedPages array for better stability
(prevProps.selectedPages === nextProps.selectedPages ||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
prevProps.selectionMode === nextProps.selectionMode &&
prevProps.draggedPage === nextProps.draggedPage &&
prevProps.dropTarget === nextProps.dropTarget &&

View File

@ -36,23 +36,17 @@ import {
FileRecord,
toFileRecord,
revokeFileResources,
createStableFileId
createFileId,
computeContentHash
} from '../types/fileContext';
// Mock services - these will need proper implementation
const enhancedPDFProcessingService = {
clearAllProcessing: () => {},
cancelProcessing: (fileId: string) => {}
};
// Import real services
import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { fileStorage } from '../services/fileStorage';
const thumbnailGenerationService = {
destroy: () => {},
stopGeneration: () => {}
};
const fileStorage = {
deleteFile: async (fileId: string) => {}
};
// Get service instances
const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
// Initial state
const initialFileContextState: FileContextState = {
@ -76,15 +70,13 @@ const initialFileContextState: FileContextState = {
function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) {
case 'ADD_FILES': {
const { files } = action.payload;
const { fileRecords } = action.payload;
const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
files.forEach(file => {
const stableId = createStableFileId(file);
fileRecords.forEach(record => {
// Only add if not already present (dedupe by stable ID)
if (!newById[stableId]) {
const record = toFileRecord(file, stableId);
if (!newById[record.id]) {
newIds.push(record.id);
newById[record.id] = record;
}
@ -131,6 +123,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
const existingRecord = state.files.byId[id];
if (!existingRecord) return state;
// Immutable merge supports all FileRecord fields including contentHash, hashStatus
return {
...state,
files: {
@ -368,12 +361,14 @@ export function FileContextProvider({
});
pdfDocuments.current.clear();
// Revoke all blob URLs
// Revoke all blob URLs (only blob: scheme)
blobUrls.current.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Error revoking blob URL:', error);
if (url.startsWith('blob:')) {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Error revoking blob URL:', error);
}
}
});
blobUrls.current.clear();
@ -418,31 +413,75 @@ export function FileContextProvider({
// Action implementations
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
// Store Files in ref map with stable IDs
const fileIds: FileId[] = [];
// Generate UUID-based IDs and create records
const fileRecords: FileRecord[] = [];
const addedFiles: File[] = [];
for (const file of files) {
const stableId = createStableFileId(file);
// Dedupe - only add if not already present
if (!filesRef.current.has(stableId)) {
filesRef.current.set(stableId, file);
fileIds.push(stableId);
const fileId = createFileId(); // UUID-based, zero collisions
// Store File in ref map
filesRef.current.set(fileId, file);
// Create record with pending hash status
const record = toFileRecord(file, fileId);
record.hashStatus = 'pending';
fileRecords.push(record);
addedFiles.push(file);
// Optional: Persist to IndexedDB if enabled
if (enablePersistence) {
try {
// Generate thumbnail and store in IndexedDB with our UUID
import('../utils/thumbnailUtils').then(({ generateThumbnailForFile }) => {
return generateThumbnailForFile(file);
}).then(thumbnail => {
return fileStorage.storeFile(file, fileId, thumbnail);
}).then(() => {
console.log('File persisted to IndexedDB:', fileId);
}).catch(error => {
console.warn('Failed to persist file to IndexedDB:', error);
});
} catch (error) {
console.warn('Failed to initiate file persistence:', error);
}
}
// Start async content hashing (don't block add operation)
computeContentHash(file).then(contentHash => {
// Only update if file still exists in context
if (filesRef.current.has(fileId)) {
updateFileRecord(fileId, {
contentHash: contentHash || undefined, // Convert null to undefined
hashStatus: contentHash ? 'completed' : 'failed'
});
}
}).catch(() => {
// Hash failed, update status if file still exists
if (filesRef.current.has(fileId)) {
updateFileRecord(fileId, { hashStatus: 'failed' });
}
});
}
// Dispatch only the file metadata to state
dispatch({ type: 'ADD_FILES', payload: { files } });
// Only dispatch if we have new files
if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
}
// Return files with their IDs assigned
return files;
}, [enablePersistence]);
// Return only the newly added files
return addedFiles;
}, [enablePersistence]); // Include enablePersistence for persistence logic
const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => {
// Clean up Files from ref map
// Clean up Files from ref map first
fileIds.forEach(fileId => {
filesRef.current.delete(fileId);
cleanupFile(fileId);
});
// Update state
dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
// Remove from IndexedDB only if requested
@ -457,13 +496,19 @@ export function FileContextProvider({
}
}, [enablePersistence, cleanupFile]);
const updateFileRecord = useCallback((id: FileId, updates: Partial<FileRecord>) => {
// Ensure immutable merge by dispatching action
dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id, updates } });
}, []);
// Navigation guard system functions
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []);
const requestNavigation = useCallback((navigationFn: () => void): boolean => {
if (state.ui.hasUnsavedChanges) {
// Use stateRef to avoid stale closure issues with rapid state changes
if (stateRef.current.ui.hasUnsavedChanges) {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
return false;
@ -471,15 +516,16 @@ export function FileContextProvider({
navigationFn();
return true;
}
}, [state.ui.hasUnsavedChanges]);
}, []); // No dependencies - uses stateRef for current state
const confirmNavigation = useCallback(() => {
if (state.ui.pendingNavigation) {
state.ui.pendingNavigation();
// Use stateRef to get current navigation function
if (stateRef.current.ui.pendingNavigation) {
stateRef.current.ui.pendingNavigation();
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
}
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, [state.ui.pendingNavigation]);
}, []); // No dependencies - uses stateRef
const cancelNavigation = useCallback(() => {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
@ -490,6 +536,7 @@ export function FileContextProvider({
const actions = useMemo<FileContextActions>(() => ({
addFiles,
removeFiles,
updateFileRecord,
clearAllFiles: () => {
cleanupAllFiles();
filesRef.current.clear();
@ -530,6 +577,7 @@ export function FileContextProvider({
// Action compatibility layer
addFiles,
removeFiles,
updateFileRecord,
clearAllFiles: actions.clearAllFiles,
setCurrentMode: actions.setCurrentMode,
setSelectedFiles: actions.setSelectedFiles,
@ -551,7 +599,7 @@ export function FileContextProvider({
get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render
// Selectors
...selectors
}), [state, actions, addFiles, removeFiles, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency
}), [state, actions, addFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency
// Cleanup on unmount
useEffect(() => {
@ -654,12 +702,12 @@ export function useProcessedFiles() {
const compatibilityMap = {
size: state.files.ids.length,
get: (file: File) => {
const id = createStableFileId(file);
return selectors.getFileRecord(id)?.processedFile;
console.warn('useProcessedFiles.get is deprecated - File objects no longer have stable IDs');
return null;
},
has: (file: File) => {
const id = createStableFileId(file);
return !!selectors.getFileRecord(id)?.processedFile;
console.warn('useProcessedFiles.has is deprecated - File objects no longer have stable IDs');
return false;
},
set: () => {
console.warn('processedFiles.set is deprecated - use FileRecord updates instead');
@ -669,8 +717,8 @@ export function useProcessedFiles() {
return {
processedFiles: compatibilityMap, // Map-like interface for backward compatibility
getProcessedFile: (file: File) => {
const id = createStableFileId(file);
return selectors.getFileRecord(id)?.processedFile;
console.warn('getProcessedFile is deprecated - File objects no longer have stable IDs');
return null;
},
updateProcessedFile: () => {
console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord');

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { FileWithUrl } from '../types/file';
import { FileWithUrl, FileMetadata } from '../types/file';
import { StoredFile } from '../services/fileStorage';
// Type for the context value - now contains everything directly
@ -8,22 +8,22 @@ interface FileManagerContextValue {
activeSource: 'recent' | 'local' | 'drive';
selectedFileIds: string[];
searchTerm: string;
selectedFiles: FileWithUrl[];
filteredFiles: FileWithUrl[];
selectedFiles: FileMetadata[];
filteredFiles: FileMetadata[];
fileInputRef: React.RefObject<HTMLInputElement | null>;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void;
onFileSelect: (file: FileWithUrl) => void;
onFileSelect: (file: FileMetadata) => void;
onFileRemove: (index: number) => void;
onFileDoubleClick: (file: FileWithUrl) => void;
onFileDoubleClick: (file: FileMetadata) => void;
onOpenFiles: () => void;
onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
// External props
recentFiles: FileWithUrl[];
recentFiles: FileMetadata[];
isFileSupported: (fileName: string) => boolean;
modalHeight: string;
}
@ -34,14 +34,14 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
// Provider component props
interface FileManagerProviderProps {
children: React.ReactNode;
recentFiles: FileWithUrl[];
onFilesSelected: (files: FileWithUrl[]) => void;
recentFiles: FileMetadata[];
onFilesSelected: (files: FileMetadata[]) => void;
onClose: () => void;
isFileSupported: (fileName: string) => boolean;
isOpen: boolean;
onFileRemove: (index: number) => void;
modalHeight: string;
storeFile: (file: File) => Promise<StoredFile>;
storeFile: (file: File, fileId: string) => Promise<StoredFile>;
refreshRecentFiles: () => Promise<void>;
}
@ -83,7 +83,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef.current?.click();
}, []);
const handleFileSelect = useCallback((file: FileWithUrl) => {
const handleFileSelect = useCallback((file: FileMetadata) => {
setSelectedFileIds(prev => {
if (file.id) {
if (prev.includes(file.id)) {
@ -105,7 +105,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onFileRemove(index);
}, [filteredFiles, onFileRemove]);
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
if (isFileSupported(file.name)) {
onFilesSelected([file]);
onClose();
@ -127,22 +127,22 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const files = Array.from(event.target.files || []);
if (files.length > 0) {
try {
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
const fileWithUrls = files.map(file => {
// Create FileMetadata objects - FileContext will handle storage and ID assignment
const fileMetadatas = files.map(file => {
const url = URL.createObjectURL(file);
createdBlobUrls.current.add(url);
return {
// No ID assigned here - FileContext will handle storage and ID assignment
id: `temp-${Date.now()}-${Math.random()}`, // Temporary ID until stored
name: file.name,
file,
url,
size: file.size,
lastModified: file.lastModified,
type: file.type,
thumbnail: undefined,
};
});
onFilesSelected(fileWithUrls as any /* FIX ME */);
onFilesSelected(fileMetadatas);
await refreshRecentFiles();
onClose();
} catch (error) {

View File

@ -1,49 +1,46 @@
import { useState, useCallback } from 'react';
import { fileStorage } from '../services/fileStorage';
import { FileWithUrl } from '../types/file';
import { createEnhancedFileFromStored } from '../utils/fileUtils';
import { FileWithUrl, FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
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 });
}
return file;
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
// Always use ID - no fallback to names to prevent identity drift
if (!fileMetadata.id) {
throw new Error('File ID is required - cannot convert file without stable ID');
}
// 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);
const storedFile = await fileStorage.getFile(fileMetadata.id);
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 });
// NO FILE MUTATION - Return clean File, let FileContext manage ID
return file;
}
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));
// Get metadata only (no file data) for performance
const storedFileMetadata = await fileStorage.getAllFileMetadata();
const sortedFiles = storedFileMetadata.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
// Convert StoredFile metadata to FileMetadata format
return sortedFiles.map(stored => ({
id: stored.id, // UUID from FileContext
name: stored.name,
type: stored.type,
size: stored.size,
lastModified: stored.lastModified,
thumbnail: stored.thumbnail,
storedInIndexedDB: true
}));
} catch (error) {
console.error('Failed to load recent files:', error);
return [];
@ -52,10 +49,13 @@ export const useFileManager = () => {
}
}, []);
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');
}
try {
await fileStorage.deleteFile(file.id || file.name);
await fileStorage.deleteFile(file.id);
setFiles(files.filter((_, i) => i !== index));
} catch (error) {
console.error('Failed to remove file:', error);
@ -63,16 +63,15 @@ export const useFileManager = () => {
}
}, []);
const storeFile = useCallback(async (file: File) => {
const storeFile = useCallback(async (file: File, fileId: string) => {
try {
// Generate thumbnail for the file
const thumbnail = await generateThumbnailForFile(file);
// Store file with thumbnail
const storedFile = await fileStorage.storeFile(file, thumbnail);
// Store file with provided UUID from FileContext
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
// NO FILE MUTATION - Return StoredFile, FileContext manages mapping
return storedFile;
} catch (error) {
console.error('Failed to store file:', error);
@ -96,14 +95,15 @@ export const useFileManager = () => {
setSelectedFiles([]);
};
const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => {
const selectMultipleFiles = async (files: FileMetadata[], onFilesSelect: (files: File[]) => void) => {
if (selectedFiles.length === 0) return;
try {
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id || f.name));
// Filter by UUID and convert to File objects
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
const filePromises = selectedFileObjects.map(convertToFile);
const convertedFiles = await Promise.all(filePromises);
onFilesSelect(convertedFiles);
onFilesSelect(convertedFiles); // FileContext will assign new UUIDs
clearSelection();
} catch (error) {
console.error('Failed to load selected files:', error);

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { FileWithUrl } from "../types/file";
import { FileMetadata } from "../types/file";
import { fileStorage } from "../services/fileStorage";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
@ -22,7 +22,7 @@ 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
} {

View File

@ -29,7 +29,7 @@ function HomePageContent() {
} else {
setMaxFiles(-1);
setIsToolMode(false);
setSelectedFiles([]);
// Don't clear selections when exiting tool mode - preserve selections for file/page editor
}
}, [selectedTool]); // Remove action dependencies to prevent loops
@ -48,8 +48,9 @@ function HomePageContent() {
);
}
export default function HomePage() {
function HomePageWithProviders() {
const { actions } = useFileActions();
return (
<ToolWorkflowProvider onViewChange={actions.setMode as any /* FIX ME */}>
<SidebarProvider>
@ -58,3 +59,7 @@ export default function HomePage() {
</ToolWorkflowProvider>
);
}
export default function HomePage() {
return <HomePageWithProviders />;
}

View File

@ -1,8 +1,9 @@
import { FileWithUrl } from "../types/file";
import { FileWithUrl, FileMetadata } from "../types/file";
import { fileStorage, StorageStats } from "./fileStorage";
import { loadFilesFromIndexedDB, createEnhancedFileFromStored, cleanupFileUrls } from "../utils/fileUtils";
import { loadFilesFromIndexedDB, cleanupFileUrls } from "../utils/fileUtils";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import { updateStorageStatsIncremental } from "../utils/storageUtils";
import { createFileId } from "../types/fileContext";
/**
* Service for file storage operations
@ -79,31 +80,35 @@ export const fileOperationsService = {
// Generate thumbnail only during upload
const thumbnail = await generateThumbnailForFile(file);
const storedFile = await fileStorage.storeFile(file, thumbnail);
const fileId = createFileId(); // Generate UUID
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
console.log('File stored with ID:', storedFile.id);
const baseFile = fileStorage.createFileFromStored(storedFile);
const enhancedFile = createEnhancedFileFromStored(storedFile, thumbnail);
// Copy File interface methods from baseFile
enhancedFile.arrayBuffer = baseFile.arrayBuffer.bind(baseFile);
enhancedFile.slice = baseFile.slice.bind(baseFile);
enhancedFile.stream = baseFile.stream.bind(baseFile);
enhancedFile.text = baseFile.text.bind(baseFile);
// Create FileWithUrl that extends the original File
const enhancedFile: FileWithUrl = Object.assign(file, {
id: fileId,
url: URL.createObjectURL(file),
thumbnail,
storedInIndexedDB: true
});
newFiles.push(enhancedFile);
} catch (error) {
console.error('Failed to store file in IndexedDB:', error);
// Fallback to RAM storage
// Fallback to RAM storage with UUID
const fileId = createFileId();
const enhancedFile: FileWithUrl = Object.assign(file, {
id: fileId,
url: URL.createObjectURL(file),
storedInIndexedDB: false
});
newFiles.push(enhancedFile);
}
} else {
// IndexedDB disabled - use RAM
// IndexedDB disabled - use RAM with UUID
const fileId = createFileId();
const enhancedFile: FileWithUrl = Object.assign(file, {
id: fileId,
url: URL.createObjectURL(file),
storedInIndexedDB: false
});
@ -167,7 +172,13 @@ export const fileOperationsService = {
}
}
// Fallback for files not in IndexedDB
// Fallback for files not in IndexedDB - use existing URL if available
if (file.url) {
return file.url;
}
// Last resort - create new blob URL (but this shouldn't happen with FileWithUrl)
console.warn('Creating blob URL for file without existing URL - this may indicate a type issue');
return URL.createObjectURL(file);
},

View File

@ -81,16 +81,15 @@ class FileStorageService {
}
/**
* Store a file in IndexedDB
* Store a file in IndexedDB with external UUID
*/
async storeFile(file: File, thumbnail?: string): Promise<StoredFile> {
async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> {
if (!this.db) await this.init();
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const arrayBuffer = await file.arrayBuffer();
const storedFile: StoredFile = {
id,
id: fileId, // Use provided UUID
name: file.name,
type: file.type,
size: file.size,
@ -106,8 +105,8 @@ class FileStorageService {
// Debug logging
console.log('Object store keyPath:', store.keyPath);
console.log('Storing file:', {
id: storedFile.id,
console.log('Storing file with UUID:', {
id: storedFile.id, // Now a UUID from FileContext
name: storedFile.name,
hasData: !!storedFile.data,
dataSize: storedFile.data.byteLength

View File

@ -1,11 +1,29 @@
/**
* Enhanced file types for IndexedDB storage
* Enhanced file types for IndexedDB storage with UUID system
* Extends File interface for compatibility with existing utilities
*/
export interface FileWithUrl extends File {
id?: string;
url?: string;
id: string; // Required UUID from FileContext
url?: string; // Blob URL for display
thumbnail?: string;
contentHash?: string; // SHA-256 content hash
hashStatus?: 'pending' | 'completed' | 'failed';
storedInIndexedDB?: boolean;
}
/**
* Metadata-only version for efficient recent files loading
*/
export interface FileMetadata {
id: string;
name: string;
type: string;
size: number;
lastModified: number;
thumbnail?: string;
contentHash?: string;
hashStatus?: 'pending' | 'completed' | 'failed';
storedInIndexedDB?: boolean;
}

View File

@ -19,6 +19,8 @@ export interface FileRecord {
thumbnailUrl?: string;
blobUrl?: string;
createdAt: number;
contentHash?: string; // Optional content hash for deduplication
hashStatus?: 'pending' | 'completed' | 'failed'; // Hash computation status
processedFile?: {
pages: Array<{
thumbnail?: string;
@ -34,10 +36,66 @@ export interface FileContextNormalizedFiles {
byId: Record<FileId, FileRecord>;
}
// Helper functions
// 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);
});
}
// Legacy support - now just delegates to createFileId
export function createStableFileId(file: File): FileId {
// Use existing ID if file already has one, otherwise create stable ID from metadata
return (file as any).id || `${file.name}-${file.size}-${file.lastModified}`;
// Don't mutate File objects - always return new UUID
return createFileId();
}
// Multi-region content hash for deduplication (head + middle + tail)
export async function computeContentHash(file: File): Promise<string | null> {
try {
const fileSize = file.size;
const chunkSize = 32 * 1024; // 32KB chunks
const chunks: ArrayBuffer[] = [];
// Head chunk (first 32KB)
chunks.push(await file.slice(0, Math.min(chunkSize, fileSize)).arrayBuffer());
// Middle chunk (if file is large enough)
if (fileSize > chunkSize * 2) {
const middleStart = Math.floor(fileSize / 2) - Math.floor(chunkSize / 2);
chunks.push(await file.slice(middleStart, middleStart + chunkSize).arrayBuffer());
}
// Tail chunk (last 32KB, if different from head)
if (fileSize > chunkSize) {
const tailStart = Math.max(chunkSize, fileSize - chunkSize);
chunks.push(await file.slice(tailStart, fileSize).arrayBuffer());
}
// Combine all chunks
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
combined.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
// Hash the combined chunks
const hashBuffer = await window.crypto.subtle.digest('SHA-256', combined);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (error) {
console.warn('Content hash calculation failed:', error);
return null;
}
}
export function toFileRecord(file: File, id?: FileId): FileRecord {
@ -53,17 +111,30 @@ export function toFileRecord(file: File, id?: FileId): FileRecord {
}
export function revokeFileResources(record: FileRecord): void {
if (record.thumbnailUrl) {
URL.revokeObjectURL(record.thumbnailUrl);
// 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) {
URL.revokeObjectURL(record.blobUrl);
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:')) {
URL.revokeObjectURL(page.thumbnail);
try {
URL.revokeObjectURL(page.thumbnail);
} catch (error) {
console.warn('Failed to revoke page thumbnail URL:', error);
}
}
});
}
@ -132,7 +203,7 @@ export interface FileContextState {
// Action types for reducer pattern
export type FileContextAction =
// File management actions
| { type: 'ADD_FILES'; payload: { files: File[] } }
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
@ -155,6 +226,7 @@ export interface FileContextActions {
// File management - lightweight actions only
addFiles: (files: File[]) => Promise<File[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
clearAllFiles: () => void;
// Navigation

View File

@ -19,7 +19,7 @@ export function formatFileSize(bytes: number): string {
/**
* Get file date as string
*/
export function getFileDate(file: File): string {
export function getFileDate(file: File | { lastModified: number }): string {
if (file.lastModified) {
return new Date(file.lastModified).toLocaleString();
}
@ -29,7 +29,7 @@ export function getFileDate(file: File): string {
/**
* Get file size as string (legacy method for backward compatibility)
*/
export function getFileSize(file: File): string {
export function getFileSize(file: File | { size: number }): string {
if (!file.size) return "Unknown";
return formatFileSize(file.size);
}