mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
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:
parent
f353d3404c
commit
4a0c577312
@ -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 />}
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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(() => {
|
||||
|
@ -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 &&
|
||||
|
@ -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');
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
} {
|
||||
|
@ -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 />;
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user