mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +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 React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Modal } from '@mantine/core';
|
import { Modal } from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { FileWithUrl } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { useFileManager } from '../hooks/useFileManager';
|
import { useFileManager } from '../hooks/useFileManager';
|
||||||
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||||
|
import { createFileId } from '../types/fileContext';
|
||||||
import { Tool } from '../types/tool';
|
import { Tool } from '../types/tool';
|
||||||
import MobileLayout from './fileManager/MobileLayout';
|
import MobileLayout from './fileManager/MobileLayout';
|
||||||
import DesktopLayout from './fileManager/DesktopLayout';
|
import DesktopLayout from './fileManager/DesktopLayout';
|
||||||
@ -16,12 +17,18 @@ interface FileManagerProps {
|
|||||||
|
|
||||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||||
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
|
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
|
||||||
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
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
|
// File management handlers
|
||||||
const isFileSupported = useCallback((fileName: string) => {
|
const isFileSupported = useCallback((fileName: string) => {
|
||||||
if (!selectedTool?.supportedFormats) return true;
|
if (!selectedTool?.supportedFormats) return true;
|
||||||
@ -34,7 +41,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
setRecentFiles(files);
|
setRecentFiles(files);
|
||||||
}, [loadRecentFiles]);
|
}, [loadRecentFiles]);
|
||||||
|
|
||||||
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
|
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
|
||||||
try {
|
try {
|
||||||
const fileObjects = await Promise.all(
|
const fileObjects = await Promise.all(
|
||||||
files.map(async (fileWithUrl) => {
|
files.map(async (fileWithUrl) => {
|
||||||
@ -82,14 +89,11 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
// Cleanup any blob URLs when component unmounts
|
// Cleanup any blob URLs when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
// Clean up blob URLs from recent files
|
// FileMetadata doesn't have blob URLs, so no cleanup needed
|
||||||
recentFiles.forEach(file => {
|
// Blob URLs are managed by FileContext and tool operations
|
||||||
if (file.url && file.url.startsWith('blob:')) {
|
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
|
||||||
URL.revokeObjectURL(file.url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}, [recentFiles]);
|
}, []);
|
||||||
|
|
||||||
// Modal size constants for consistent scaling
|
// Modal size constants for consistent scaling
|
||||||
const modalHeight = '80vh';
|
const modalHeight = '80vh';
|
||||||
@ -152,7 +156,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
isOpen={isFilesModalOpen}
|
isOpen={isFilesModalOpen}
|
||||||
onFileRemove={handleRemoveFileByIndex}
|
onFileRemove={handleRemoveFileByIndex}
|
||||||
modalHeight={modalHeight}
|
modalHeight={modalHeight}
|
||||||
storeFile={storeFile}
|
storeFile={storeFileWithId}
|
||||||
refreshRecentFiles={refreshRecentFiles}
|
refreshRecentFiles={refreshRecentFiles}
|
||||||
>
|
>
|
||||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||||
|
@ -10,6 +10,7 @@ import { useToolFileSelection, useProcessedFiles, useFileState, useFileManagemen
|
|||||||
import { FileOperation, createStableFileId } from '../../types/fileContext';
|
import { FileOperation, createStableFileId } from '../../types/fileContext';
|
||||||
import { fileStorage } from '../../services/fileStorage';
|
import { fileStorage } from '../../services/fileStorage';
|
||||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||||
|
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||||
import { zipFileService } from '../../services/zipFileService';
|
import { zipFileService } from '../../services/zipFileService';
|
||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
import { detectFileExtension } from '../../utils/fileUtils';
|
||||||
import styles from '../pageEditor/PageEditor.module.css';
|
import styles from '../pageEditor/PageEditor.module.css';
|
||||||
@ -47,6 +48,9 @@ const FileEditor = ({
|
|||||||
}: FileEditorProps) => {
|
}: FileEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Thumbnail cache for sharing with PageEditor
|
||||||
|
const { getThumbnailFromCache, addThumbnailToCache } = useThumbnailGeneration();
|
||||||
|
|
||||||
// Utility function to check if a file extension is supported
|
// Utility function to check if a file extension is supported
|
||||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
const isFileSupported = useCallback((fileName: string): boolean => {
|
||||||
const extension = detectFileExtension(fileName);
|
const extension = detectFileExtension(fileName);
|
||||||
@ -60,6 +64,7 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Extract needed values from state (memoized to prevent infinite loops)
|
// Extract needed values from state (memoized to prevent infinite loops)
|
||||||
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
||||||
|
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
|
||||||
const selectedFileIds = state.ui.selectedFileIds;
|
const selectedFileIds = state.ui.selectedFileIds;
|
||||||
const isProcessing = state.ui.isProcessing;
|
const isProcessing = state.ui.isProcessing;
|
||||||
|
|
||||||
@ -145,33 +150,48 @@ const FileEditor = ({
|
|||||||
// Map context selections to local file IDs for UI display
|
// Map context selections to local file IDs for UI display
|
||||||
const localSelectedIds = files
|
const localSelectedIds = files
|
||||||
.filter(file => {
|
.filter(file => {
|
||||||
const contextFileId = createStableFileId(file.file);
|
// file.id is already the correct UUID from FileContext
|
||||||
return contextSelectedIds.includes(contextFileId);
|
return contextSelectedIds.includes(file.id);
|
||||||
})
|
})
|
||||||
.map(file => file.id);
|
.map(file => file.id);
|
||||||
|
|
||||||
// Convert shared files to FileEditor format
|
// Convert shared files to FileEditor format
|
||||||
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
||||||
// Generate thumbnail if not already available
|
let thumbnail = sharedFile.thumbnail;
|
||||||
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
|
|
||||||
|
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 {
|
return {
|
||||||
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
||||||
name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
|
name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
|
||||||
pageCount: sharedFile.pageCount || 1, // Default to 1 page if unknown
|
pageCount: sharedFile.pageCount || 1, // Default to 1 page if unknown
|
||||||
thumbnail,
|
thumbnail: thumbnail || '',
|
||||||
size: sharedFile.file?.size || sharedFile.size || 0,
|
size: sharedFile.file?.size || sharedFile.size || 0,
|
||||||
file: sharedFile.file || sharedFile,
|
file: sharedFile.file || sharedFile,
|
||||||
};
|
};
|
||||||
}, []);
|
}, [getThumbnailFromCache, addThumbnailToCache]);
|
||||||
|
|
||||||
// Convert activeFiles to FileItem format using context (async to avoid blocking)
|
// Convert activeFiles to FileItem format using context (async to avoid blocking)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if the actual content has changed, not just references
|
// 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 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;
|
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
|
||||||
|
|
||||||
if (!activeFilesChanged && !processedFilesChanged) {
|
if (!activeFilesChanged && !processedFilesChanged) {
|
||||||
@ -179,49 +199,59 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update refs
|
// Update refs
|
||||||
lastActiveFilesRef.current = currentActiveFileNames;
|
lastActiveFilesRef.current = currentActiveFileIds;
|
||||||
lastProcessedFilesRef.current = currentProcessedFilesSize;
|
lastProcessedFilesRef.current = currentProcessedFilesSize;
|
||||||
|
|
||||||
const convertActiveFiles = async () => {
|
const convertActiveFiles = async () => {
|
||||||
|
|
||||||
if (activeFiles.length > 0) {
|
if (activeFileRecords.length > 0) {
|
||||||
setLocalLoading(true);
|
setLocalLoading(true);
|
||||||
try {
|
try {
|
||||||
// Process files in chunks to avoid blocking UI
|
// Process files in chunks to avoid blocking UI
|
||||||
const convertedFiles: FileItem[] = [];
|
const convertedFiles: FileItem[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < activeFiles.length; i++) {
|
for (let i = 0; i < activeFileRecords.length; i++) {
|
||||||
const file = activeFiles[i];
|
const record = activeFileRecords[i];
|
||||||
|
const file = selectors.getFile(record.id);
|
||||||
|
|
||||||
// Try to get thumbnail from processed file first
|
if (!file) continue; // Skip if file not found
|
||||||
const processedFile = processedFiles.processedFiles.get(file);
|
|
||||||
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
|
// Use record's thumbnail if available, otherwise check cache, then generate
|
||||||
|
let thumbnail: string | undefined = record.thumbnailUrl;
|
||||||
// If no thumbnail from processed file, try to generate one
|
|
||||||
if (!thumbnail) {
|
if (!thumbnail) {
|
||||||
try {
|
// Check if PageEditor has already cached a page-1 thumbnail for this file
|
||||||
thumbnail = await generateThumbnailForFile(file);
|
const page1CacheKey = `${record.id}-page-1`;
|
||||||
} catch (error) {
|
thumbnail = getThumbnailFromCache(page1CacheKey) || undefined;
|
||||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
|
||||||
thumbnail = undefined; // Use placeholder
|
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
|
if (file.type === 'application/pdf') {
|
||||||
let pageCount = 1; // Default for non-PDFs
|
// Quick page count estimation for FileEditor display only
|
||||||
if (processedFile) {
|
// PageEditor will do its own more thorough page detection
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
// Quick and dirty page count using PDF structure analysis
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const text = new TextDecoder('latin1').decode(arrayBuffer);
|
const text = new TextDecoder('latin1').decode(arrayBuffer);
|
||||||
const pageMatches = text.match(/\/Type\s*\/Page[^s]/g);
|
const pageMatches = text.match(/\/Type\s*\/Page[^s]/g);
|
||||||
pageCount = pageMatches ? pageMatches.length : 1;
|
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) {
|
} catch (error) {
|
||||||
console.warn(`Failed to estimate page count for ${file.name}:`, error);
|
console.warn(`Failed to estimate page count for ${file.name}:`, error);
|
||||||
pageCount = 1; // Safe fallback
|
pageCount = 1; // Safe fallback
|
||||||
@ -229,7 +259,7 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const convertedFile = {
|
const convertedFile = {
|
||||||
id: createStableFileId(file), // Use same ID function as context
|
id: record.id, // Use the record's UUID from FileContext
|
||||||
name: file.name,
|
name: file.name,
|
||||||
pageCount: pageCount,
|
pageCount: pageCount,
|
||||||
thumbnail: thumbnail || '',
|
thumbnail: thumbnail || '',
|
||||||
@ -240,10 +270,10 @@ const FileEditor = ({
|
|||||||
convertedFiles.push(convertedFile);
|
convertedFiles.push(convertedFile);
|
||||||
|
|
||||||
// Update progress
|
// Update progress
|
||||||
setConversionProgress(((i + 1) / activeFiles.length) * 100);
|
setConversionProgress(((i + 1) / activeFileRecords.length) * 100);
|
||||||
|
|
||||||
// Yield to main thread between files
|
// Yield to main thread between files
|
||||||
if (i < activeFiles.length - 1) {
|
if (i < activeFileRecords.length - 1) {
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -264,7 +294,7 @@ const FileEditor = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
convertActiveFiles();
|
convertActiveFiles();
|
||||||
}, [activeFiles, processedFiles]);
|
}, [activeFileRecords, processedFiles, selectors]);
|
||||||
|
|
||||||
|
|
||||||
// Process uploaded files using context
|
// Process uploaded files using context
|
||||||
@ -422,25 +452,25 @@ const FileEditor = ({
|
|||||||
}, [addFiles]);
|
}, [addFiles]);
|
||||||
|
|
||||||
const selectAll = useCallback(() => {
|
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]);
|
}, [files, setContextSelectedFiles]);
|
||||||
|
|
||||||
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
|
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
|
||||||
|
|
||||||
const closeAllFiles = useCallback(() => {
|
const closeAllFiles = useCallback(() => {
|
||||||
if (activeFiles.length === 0) return;
|
if (activeFileRecords.length === 0) return;
|
||||||
|
|
||||||
// Record close all operation for each file
|
// Record close all operation for each file
|
||||||
// Legacy operation tracking - now handled by FileContext
|
// 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
|
// 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);
|
removeFiles(fileIds, false);
|
||||||
|
|
||||||
// Clear selections
|
// Clear selections
|
||||||
setContextSelectedFiles([]);
|
setContextSelectedFiles([]);
|
||||||
}, [activeFiles, removeFiles, setContextSelectedFiles]);
|
}, [activeFileRecords, removeFiles, setContextSelectedFiles]);
|
||||||
|
|
||||||
const toggleFile = useCallback((fileId: string) => {
|
const toggleFile = useCallback((fileId: string) => {
|
||||||
const currentFiles = filesDataRef.current;
|
const currentFiles = filesDataRef.current;
|
||||||
@ -449,7 +479,8 @@ const FileEditor = ({
|
|||||||
const targetFile = currentFiles.find(f => f.id === fileId);
|
const targetFile = currentFiles.find(f => f.id === fileId);
|
||||||
if (!targetFile) return;
|
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);
|
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||||
|
|
||||||
let newSelection: string[];
|
let newSelection: string[];
|
||||||
@ -611,7 +642,7 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Record close operation
|
// Record close operation
|
||||||
const fileName = file.file.name;
|
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 operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const operation: FileOperation = {
|
const operation: FileOperation = {
|
||||||
id: operationId,
|
id: operationId,
|
||||||
@ -633,13 +664,13 @@ const FileEditor = ({
|
|||||||
console.log('Close operation recorded:', operation);
|
console.log('Close operation recorded:', operation);
|
||||||
|
|
||||||
// Remove file from context but keep in storage (close, don't delete)
|
// Remove file from context but keep in storage (close, don't delete)
|
||||||
console.log('Calling removeFiles with:', [fileId]);
|
console.log('Calling removeFiles with:', [contextFileId]);
|
||||||
removeFiles([fileId], false);
|
removeFiles([contextFileId], false);
|
||||||
|
|
||||||
// Remove from context selections
|
// Remove from context selections
|
||||||
setContextSelectedFiles((prev: string[]) => {
|
setContextSelectedFiles((prev: string[]) => {
|
||||||
const safePrev = Array.isArray(prev) ? prev : [];
|
const safePrev = Array.isArray(prev) ? prev : [];
|
||||||
return safePrev.filter(id => id !== fileId);
|
return safePrev.filter(id => id !== contextFileId);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('File not found for fileId:', fileId);
|
console.log('File not found for fileId:', fileId);
|
||||||
@ -650,7 +681,7 @@ const FileEditor = ({
|
|||||||
const file = files.find(f => f.id === fileId);
|
const file = files.find(f => f.id === fileId);
|
||||||
if (file) {
|
if (file) {
|
||||||
// Set the file as selected in context and switch to viewer for preview
|
// 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]);
|
setContextSelectedFiles([contextFileId]);
|
||||||
setCurrentView('viewer');
|
setCurrentView('viewer');
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
|||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getFileSize } from '../../utils/fileUtils';
|
import { getFileSize } from '../../utils/fileUtils';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
interface CompactFileDetailsProps {
|
interface CompactFileDetailsProps {
|
||||||
currentFile: FileWithUrl | null;
|
currentFile: FileMetadata | null;
|
||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
selectedFiles: FileWithUrl[];
|
selectedFiles: FileMetadata[];
|
||||||
currentFileIndex: number;
|
currentFileIndex: number;
|
||||||
numberOfFiles: number;
|
numberOfFiles: number;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
|
@ -2,10 +2,10 @@ import React from 'react';
|
|||||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
|
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
interface FileInfoCardProps {
|
interface FileInfoCardProps {
|
||||||
currentFile: FileWithUrl | null;
|
currentFile: FileMetadata | null;
|
||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,10 +2,10 @@ import React, { useState } from 'react';
|
|||||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
interface FileListItemProps {
|
interface FileListItemProps {
|
||||||
file: FileWithUrl;
|
file: FileMetadata;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
@ -3,10 +3,10 @@ import { Box, Center, ActionIcon, Image } from '@mantine/core';
|
|||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
currentFile: FileWithUrl | null;
|
currentFile: FileMetadata | null;
|
||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
numberOfFiles: number;
|
numberOfFiles: number;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
|
@ -84,6 +84,19 @@ const PageEditor = ({
|
|||||||
* Using this instead of direct file arrays prevents unnecessary re-renders.
|
* 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)
|
// Compute merged document with stable signature (prevents infinite loops)
|
||||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||||
if (activeFileIds.length === 0) return null;
|
if (activeFileIds.length === 0) return null;
|
||||||
@ -107,24 +120,64 @@ const PageEditor = ({
|
|||||||
// Get pages from processed file data
|
// Get pages from processed file data
|
||||||
const processedFile = primaryFileRecord.processedFile;
|
const processedFile = primaryFileRecord.processedFile;
|
||||||
|
|
||||||
// Convert processed pages to PageEditor format, or create placeholder if not processed yet
|
// Debug logging for processed file data
|
||||||
const pages = processedFile?.pages?.length > 0
|
console.log(`🎬 PageEditor: Building document for ${name}`);
|
||||||
? processedFile.pages.map((page, index) => ({
|
console.log(`🎬 ProcessedFile exists:`, !!processedFile);
|
||||||
id: `${primaryFileId}-page-${index + 1}`,
|
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,
|
pageNumber: index + 1,
|
||||||
thumbnail: page.thumbnail || null,
|
thumbnail,
|
||||||
rotation: page.rotation || 0,
|
rotation: page.rotation || 0,
|
||||||
selected: false,
|
selected: false,
|
||||||
splitBefore: page.splitBefore || false,
|
splitBefore: page.splitBefore || false,
|
||||||
}))
|
};
|
||||||
: [{
|
});
|
||||||
id: `${primaryFileId}-page-1`,
|
} else if (discoveredDocument && discoveredDocument.id === (primaryFileId ?? 'unknown')) {
|
||||||
pageNumber: 1,
|
// Use discovered document if available and matches current file
|
||||||
thumbnail: null,
|
pages = discoveredDocument.pages;
|
||||||
rotation: 0,
|
} else {
|
||||||
selected: false,
|
// No processed data and no discovered data yet - show placeholder while discovering
|
||||||
splitBefore: false,
|
console.log(`🎬 PageEditor: No processedFile data, showing placeholder while discovering pages for ${name}`);
|
||||||
}]; // Fallback: single page placeholder
|
|
||||||
|
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
|
// Create document with determined pages
|
||||||
|
|
||||||
@ -136,7 +189,123 @@ const PageEditor = ({
|
|||||||
totalPages: pages.length,
|
totalPages: pages.length,
|
||||||
destroy: () => {} // Optional cleanup function
|
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
|
// Display document: Use edited version if exists, otherwise original
|
||||||
const displayDocument = editedDocument || mergedPdfDocument;
|
const displayDocument = editedDocument || mergedPdfDocument;
|
||||||
@ -209,15 +378,6 @@ const PageEditor = ({
|
|||||||
*/
|
*/
|
||||||
const thumbnailGenerationStarted = useRef(false);
|
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
|
// Start thumbnail generation process (guards against re-entry) - stable version
|
||||||
const startThumbnailGeneration = useCallback(() => {
|
const startThumbnailGeneration = useCallback(() => {
|
||||||
// Access current values directly - avoid stale closures
|
// Access current values directly - avoid stale closures
|
||||||
@ -280,6 +440,28 @@ const PageEditor = ({
|
|||||||
if (!cached) {
|
if (!cached) {
|
||||||
addThumbnailToCache(pageId, thumbnail);
|
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', {
|
window.dispatchEvent(new CustomEvent('thumbnailReady', {
|
||||||
detail: { pageNumber, thumbnail, pageId }
|
detail: { pageNumber, thumbnail, pageId }
|
||||||
}));
|
}));
|
||||||
@ -304,7 +486,7 @@ const PageEditor = ({
|
|||||||
thumbnailGenerationStarted.current = false;
|
thumbnailGenerationStarted.current = false;
|
||||||
}
|
}
|
||||||
}, 0); // setTimeout with 0ms to defer to next tick
|
}, 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)
|
// Start thumbnail generation when files change (stable signature prevents loops)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -10,15 +10,13 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
|||||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
||||||
import { Command } from '../../hooks/useUndoRedo';
|
import { Command } from '../../hooks/useUndoRedo';
|
||||||
|
import { useFileState } from '../../contexts/FileContext';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||||
|
|
||||||
// Ensure PDF.js worker is available
|
// Ensure PDF.js worker is available
|
||||||
if (!GlobalWorkerOptions.workerSrc) {
|
if (!GlobalWorkerOptions.workerSrc) {
|
||||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
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 {
|
interface PageThumbnailProps {
|
||||||
@ -81,15 +79,15 @@ const PageThumbnail = React.memo(({
|
|||||||
setPdfDocument,
|
setPdfDocument,
|
||||||
}: PageThumbnailProps) => {
|
}: PageThumbnailProps) => {
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
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(() => {
|
useEffect(() => {
|
||||||
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
||||||
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
|
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
|
||||||
setThumbnailUrl(page.thumbnail);
|
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)
|
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -100,8 +98,15 @@ const PageThumbnail = React.memo(({
|
|||||||
const handleThumbnailReady = (event: CustomEvent) => {
|
const handleThumbnailReady = (event: CustomEvent) => {
|
||||||
const { pageNumber, thumbnail, pageId } = event.detail;
|
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) {
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
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
|
// Register this component with pageRefs for animations
|
||||||
@ -225,11 +230,6 @@ const PageThumbnail = React.memo(({
|
|||||||
transition: 'transform 0.3s ease-in-out'
|
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' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<Text size="lg" c="dimmed">📄</Text>
|
<Text size="lg" c="dimmed">📄</Text>
|
||||||
@ -416,13 +416,20 @@ const PageThumbnail = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prevProps, nextProps) => {
|
}, (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
|
// Only re-render if essential props change
|
||||||
return (
|
return (
|
||||||
prevProps.page.id === nextProps.page.id &&
|
prevProps.page.id === nextProps.page.id &&
|
||||||
prevProps.page.pageNumber === nextProps.page.pageNumber &&
|
prevProps.page.pageNumber === nextProps.page.pageNumber &&
|
||||||
prevProps.page.rotation === nextProps.page.rotation &&
|
prevProps.page.rotation === nextProps.page.rotation &&
|
||||||
prevProps.page.thumbnail === nextProps.page.thumbnail &&
|
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.selectionMode === nextProps.selectionMode &&
|
||||||
prevProps.draggedPage === nextProps.draggedPage &&
|
prevProps.draggedPage === nextProps.draggedPage &&
|
||||||
prevProps.dropTarget === nextProps.dropTarget &&
|
prevProps.dropTarget === nextProps.dropTarget &&
|
||||||
|
@ -36,23 +36,17 @@ import {
|
|||||||
FileRecord,
|
FileRecord,
|
||||||
toFileRecord,
|
toFileRecord,
|
||||||
revokeFileResources,
|
revokeFileResources,
|
||||||
createStableFileId
|
createFileId,
|
||||||
|
computeContentHash
|
||||||
} from '../types/fileContext';
|
} from '../types/fileContext';
|
||||||
|
|
||||||
// Mock services - these will need proper implementation
|
// Import real services
|
||||||
const enhancedPDFProcessingService = {
|
import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
|
||||||
clearAllProcessing: () => {},
|
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||||
cancelProcessing: (fileId: string) => {}
|
import { fileStorage } from '../services/fileStorage';
|
||||||
};
|
|
||||||
|
|
||||||
const thumbnailGenerationService = {
|
// Get service instances
|
||||||
destroy: () => {},
|
const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
|
||||||
stopGeneration: () => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileStorage = {
|
|
||||||
deleteFile: async (fileId: string) => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
const initialFileContextState: FileContextState = {
|
const initialFileContextState: FileContextState = {
|
||||||
@ -76,15 +70,13 @@ const initialFileContextState: FileContextState = {
|
|||||||
function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'ADD_FILES': {
|
case 'ADD_FILES': {
|
||||||
const { files } = action.payload;
|
const { fileRecords } = action.payload;
|
||||||
const newIds: FileId[] = [];
|
const newIds: FileId[] = [];
|
||||||
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
||||||
|
|
||||||
files.forEach(file => {
|
fileRecords.forEach(record => {
|
||||||
const stableId = createStableFileId(file);
|
|
||||||
// Only add if not already present (dedupe by stable ID)
|
// Only add if not already present (dedupe by stable ID)
|
||||||
if (!newById[stableId]) {
|
if (!newById[record.id]) {
|
||||||
const record = toFileRecord(file, stableId);
|
|
||||||
newIds.push(record.id);
|
newIds.push(record.id);
|
||||||
newById[record.id] = record;
|
newById[record.id] = record;
|
||||||
}
|
}
|
||||||
@ -131,6 +123,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
|||||||
const existingRecord = state.files.byId[id];
|
const existingRecord = state.files.byId[id];
|
||||||
if (!existingRecord) return state;
|
if (!existingRecord) return state;
|
||||||
|
|
||||||
|
// Immutable merge supports all FileRecord fields including contentHash, hashStatus
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: {
|
files: {
|
||||||
@ -368,12 +361,14 @@ export function FileContextProvider({
|
|||||||
});
|
});
|
||||||
pdfDocuments.current.clear();
|
pdfDocuments.current.clear();
|
||||||
|
|
||||||
// Revoke all blob URLs
|
// Revoke all blob URLs (only blob: scheme)
|
||||||
blobUrls.current.forEach(url => {
|
blobUrls.current.forEach(url => {
|
||||||
try {
|
if (url.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(url);
|
try {
|
||||||
} catch (error) {
|
URL.revokeObjectURL(url);
|
||||||
console.warn('Error revoking blob URL:', error);
|
} catch (error) {
|
||||||
|
console.warn('Error revoking blob URL:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
blobUrls.current.clear();
|
blobUrls.current.clear();
|
||||||
@ -418,31 +413,75 @@ export function FileContextProvider({
|
|||||||
|
|
||||||
// Action implementations
|
// Action implementations
|
||||||
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
||||||
// Store Files in ref map with stable IDs
|
// Generate UUID-based IDs and create records
|
||||||
const fileIds: FileId[] = [];
|
const fileRecords: FileRecord[] = [];
|
||||||
|
const addedFiles: File[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const stableId = createStableFileId(file);
|
const fileId = createFileId(); // UUID-based, zero collisions
|
||||||
// Dedupe - only add if not already present
|
|
||||||
if (!filesRef.current.has(stableId)) {
|
// Store File in ref map
|
||||||
filesRef.current.set(stableId, file);
|
filesRef.current.set(fileId, file);
|
||||||
fileIds.push(stableId);
|
|
||||||
|
// 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
|
// Only dispatch if we have new files
|
||||||
dispatch({ type: 'ADD_FILES', payload: { files } });
|
if (fileRecords.length > 0) {
|
||||||
|
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
||||||
|
}
|
||||||
|
|
||||||
// Return files with their IDs assigned
|
// Return only the newly added files
|
||||||
return files;
|
return addedFiles;
|
||||||
}, [enablePersistence]);
|
}, [enablePersistence]); // Include enablePersistence for persistence logic
|
||||||
|
|
||||||
const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => {
|
const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => {
|
||||||
// Clean up Files from ref map
|
// Clean up Files from ref map first
|
||||||
fileIds.forEach(fileId => {
|
fileIds.forEach(fileId => {
|
||||||
filesRef.current.delete(fileId);
|
filesRef.current.delete(fileId);
|
||||||
cleanupFile(fileId);
|
cleanupFile(fileId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update state
|
||||||
dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
||||||
|
|
||||||
// Remove from IndexedDB only if requested
|
// Remove from IndexedDB only if requested
|
||||||
@ -457,13 +496,19 @@ export function FileContextProvider({
|
|||||||
}
|
}
|
||||||
}, [enablePersistence, cleanupFile]);
|
}, [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
|
// Navigation guard system functions
|
||||||
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
||||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestNavigation = useCallback((navigationFn: () => void): boolean => {
|
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: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
||||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||||
return false;
|
return false;
|
||||||
@ -471,15 +516,16 @@ export function FileContextProvider({
|
|||||||
navigationFn();
|
navigationFn();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}, [state.ui.hasUnsavedChanges]);
|
}, []); // No dependencies - uses stateRef for current state
|
||||||
|
|
||||||
const confirmNavigation = useCallback(() => {
|
const confirmNavigation = useCallback(() => {
|
||||||
if (state.ui.pendingNavigation) {
|
// Use stateRef to get current navigation function
|
||||||
state.ui.pendingNavigation();
|
if (stateRef.current.ui.pendingNavigation) {
|
||||||
|
stateRef.current.ui.pendingNavigation();
|
||||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||||
}
|
}
|
||||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||||
}, [state.ui.pendingNavigation]);
|
}, []); // No dependencies - uses stateRef
|
||||||
|
|
||||||
const cancelNavigation = useCallback(() => {
|
const cancelNavigation = useCallback(() => {
|
||||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||||
@ -490,6 +536,7 @@ export function FileContextProvider({
|
|||||||
const actions = useMemo<FileContextActions>(() => ({
|
const actions = useMemo<FileContextActions>(() => ({
|
||||||
addFiles,
|
addFiles,
|
||||||
removeFiles,
|
removeFiles,
|
||||||
|
updateFileRecord,
|
||||||
clearAllFiles: () => {
|
clearAllFiles: () => {
|
||||||
cleanupAllFiles();
|
cleanupAllFiles();
|
||||||
filesRef.current.clear();
|
filesRef.current.clear();
|
||||||
@ -530,6 +577,7 @@ export function FileContextProvider({
|
|||||||
// Action compatibility layer
|
// Action compatibility layer
|
||||||
addFiles,
|
addFiles,
|
||||||
removeFiles,
|
removeFiles,
|
||||||
|
updateFileRecord,
|
||||||
clearAllFiles: actions.clearAllFiles,
|
clearAllFiles: actions.clearAllFiles,
|
||||||
setCurrentMode: actions.setCurrentMode,
|
setCurrentMode: actions.setCurrentMode,
|
||||||
setSelectedFiles: actions.setSelectedFiles,
|
setSelectedFiles: actions.setSelectedFiles,
|
||||||
@ -551,7 +599,7 @@ export function FileContextProvider({
|
|||||||
get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render
|
get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render
|
||||||
// Selectors
|
// Selectors
|
||||||
...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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -654,12 +702,12 @@ export function useProcessedFiles() {
|
|||||||
const compatibilityMap = {
|
const compatibilityMap = {
|
||||||
size: state.files.ids.length,
|
size: state.files.ids.length,
|
||||||
get: (file: File) => {
|
get: (file: File) => {
|
||||||
const id = createStableFileId(file);
|
console.warn('useProcessedFiles.get is deprecated - File objects no longer have stable IDs');
|
||||||
return selectors.getFileRecord(id)?.processedFile;
|
return null;
|
||||||
},
|
},
|
||||||
has: (file: File) => {
|
has: (file: File) => {
|
||||||
const id = createStableFileId(file);
|
console.warn('useProcessedFiles.has is deprecated - File objects no longer have stable IDs');
|
||||||
return !!selectors.getFileRecord(id)?.processedFile;
|
return false;
|
||||||
},
|
},
|
||||||
set: () => {
|
set: () => {
|
||||||
console.warn('processedFiles.set is deprecated - use FileRecord updates instead');
|
console.warn('processedFiles.set is deprecated - use FileRecord updates instead');
|
||||||
@ -669,8 +717,8 @@ export function useProcessedFiles() {
|
|||||||
return {
|
return {
|
||||||
processedFiles: compatibilityMap, // Map-like interface for backward compatibility
|
processedFiles: compatibilityMap, // Map-like interface for backward compatibility
|
||||||
getProcessedFile: (file: File) => {
|
getProcessedFile: (file: File) => {
|
||||||
const id = createStableFileId(file);
|
console.warn('getProcessedFile is deprecated - File objects no longer have stable IDs');
|
||||||
return selectors.getFileRecord(id)?.processedFile;
|
return null;
|
||||||
},
|
},
|
||||||
updateProcessedFile: () => {
|
updateProcessedFile: () => {
|
||||||
console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord');
|
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 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';
|
import { StoredFile } from '../services/fileStorage';
|
||||||
|
|
||||||
// Type for the context value - now contains everything directly
|
// Type for the context value - now contains everything directly
|
||||||
@ -8,22 +8,22 @@ interface FileManagerContextValue {
|
|||||||
activeSource: 'recent' | 'local' | 'drive';
|
activeSource: 'recent' | 'local' | 'drive';
|
||||||
selectedFileIds: string[];
|
selectedFileIds: string[];
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
selectedFiles: FileWithUrl[];
|
selectedFiles: FileMetadata[];
|
||||||
filteredFiles: FileWithUrl[];
|
filteredFiles: FileMetadata[];
|
||||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||||
onLocalFileClick: () => void;
|
onLocalFileClick: () => void;
|
||||||
onFileSelect: (file: FileWithUrl) => void;
|
onFileSelect: (file: FileMetadata) => void;
|
||||||
onFileRemove: (index: number) => void;
|
onFileRemove: (index: number) => void;
|
||||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
onFileDoubleClick: (file: FileMetadata) => void;
|
||||||
onOpenFiles: () => void;
|
onOpenFiles: () => void;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
recentFiles: FileWithUrl[];
|
recentFiles: FileMetadata[];
|
||||||
isFileSupported: (fileName: string) => boolean;
|
isFileSupported: (fileName: string) => boolean;
|
||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
}
|
}
|
||||||
@ -34,14 +34,14 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
|||||||
// Provider component props
|
// Provider component props
|
||||||
interface FileManagerProviderProps {
|
interface FileManagerProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
recentFiles: FileWithUrl[];
|
recentFiles: FileMetadata[];
|
||||||
onFilesSelected: (files: FileWithUrl[]) => void;
|
onFilesSelected: (files: FileMetadata[]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isFileSupported: (fileName: string) => boolean;
|
isFileSupported: (fileName: string) => boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onFileRemove: (index: number) => void;
|
onFileRemove: (index: number) => void;
|
||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
storeFile: (file: File) => Promise<StoredFile>;
|
storeFile: (file: File, fileId: string) => Promise<StoredFile>;
|
||||||
refreshRecentFiles: () => Promise<void>;
|
refreshRecentFiles: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
const handleFileSelect = useCallback((file: FileMetadata) => {
|
||||||
setSelectedFileIds(prev => {
|
setSelectedFileIds(prev => {
|
||||||
if (file.id) {
|
if (file.id) {
|
||||||
if (prev.includes(file.id)) {
|
if (prev.includes(file.id)) {
|
||||||
@ -105,7 +105,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
onFileRemove(index);
|
onFileRemove(index);
|
||||||
}, [filteredFiles, onFileRemove]);
|
}, [filteredFiles, onFileRemove]);
|
||||||
|
|
||||||
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
|
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
|
||||||
if (isFileSupported(file.name)) {
|
if (isFileSupported(file.name)) {
|
||||||
onFilesSelected([file]);
|
onFilesSelected([file]);
|
||||||
onClose();
|
onClose();
|
||||||
@ -127,22 +127,22 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
const files = Array.from(event.target.files || []);
|
const files = Array.from(event.target.files || []);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
|
// Create FileMetadata objects - FileContext will handle storage and ID assignment
|
||||||
const fileWithUrls = files.map(file => {
|
const fileMetadatas = files.map(file => {
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
createdBlobUrls.current.add(url);
|
createdBlobUrls.current.add(url);
|
||||||
|
|
||||||
return {
|
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,
|
name: file.name,
|
||||||
file,
|
|
||||||
url,
|
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: file.lastModified,
|
lastModified: file.lastModified,
|
||||||
|
type: file.type,
|
||||||
|
thumbnail: undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onFilesSelected(fileWithUrls as any /* FIX ME */);
|
onFilesSelected(fileMetadatas);
|
||||||
await refreshRecentFiles();
|
await refreshRecentFiles();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,49 +1,46 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { fileStorage } from '../services/fileStorage';
|
import { fileStorage } from '../services/fileStorage';
|
||||||
import { FileWithUrl } from '../types/file';
|
import { FileWithUrl, FileMetadata } from '../types/file';
|
||||||
import { createEnhancedFileFromStored } from '../utils/fileUtils';
|
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const convertToFile = useCallback(async (fileWithUrl: FileWithUrl): Promise<File> => {
|
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
|
||||||
if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) {
|
// Always use ID - no fallback to names to prevent identity drift
|
||||||
const response = await fetch(fileWithUrl.url);
|
if (!fileMetadata.id) {
|
||||||
const data = await response.arrayBuffer();
|
throw new Error('File ID is required - cannot convert file without stable ID');
|
||||||
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 storedFile = await fileStorage.getFile(fileMetadata.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);
|
|
||||||
if (storedFile) {
|
if (storedFile) {
|
||||||
const file = new File([storedFile.data], storedFile.name, {
|
const file = new File([storedFile.data], storedFile.name, {
|
||||||
type: storedFile.type,
|
type: storedFile.type,
|
||||||
lastModified: storedFile.lastModified
|
lastModified: storedFile.lastModified
|
||||||
});
|
});
|
||||||
// Add the ID to the file object
|
// NO FILE MUTATION - Return clean File, let FileContext manage ID
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('File not found in storage');
|
throw new Error('File not found in storage');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadRecentFiles = useCallback(async (): Promise<FileWithUrl[]> => {
|
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const files = await fileStorage.getAllFiles();
|
// Get metadata only (no file data) for performance
|
||||||
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
const storedFileMetadata = await fileStorage.getAllFileMetadata();
|
||||||
return sortedFiles.map(file => createEnhancedFileFromStored(file));
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load recent files:', error);
|
console.error('Failed to load recent files:', error);
|
||||||
return [];
|
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];
|
const file = files[index];
|
||||||
|
if (!file.id) {
|
||||||
|
throw new Error('File ID is required for removal');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fileStorage.deleteFile(file.id || file.name);
|
await fileStorage.deleteFile(file.id);
|
||||||
setFiles(files.filter((_, i) => i !== index));
|
setFiles(files.filter((_, i) => i !== index));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove file:', 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 {
|
try {
|
||||||
// Generate thumbnail for the file
|
// Generate thumbnail for the file
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
const thumbnail = await generateThumbnailForFile(file);
|
||||||
|
|
||||||
// Store file with thumbnail
|
// Store file with provided UUID from FileContext
|
||||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
||||||
|
|
||||||
// Add the ID to the file object
|
// NO FILE MUTATION - Return StoredFile, FileContext manages mapping
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
|
||||||
return storedFile;
|
return storedFile;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to store file:', error);
|
console.error('Failed to store file:', error);
|
||||||
@ -96,14 +95,15 @@ export const useFileManager = () => {
|
|||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => {
|
const selectMultipleFiles = async (files: FileMetadata[], onFilesSelect: (files: File[]) => void) => {
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
try {
|
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 filePromises = selectedFileObjects.map(convertToFile);
|
||||||
const convertedFiles = await Promise.all(filePromises);
|
const convertedFiles = await Promise.all(filePromises);
|
||||||
onFilesSelect(convertedFiles);
|
onFilesSelect(convertedFiles); // FileContext will assign new UUIDs
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load selected files:', error);
|
console.error('Failed to load selected files:', error);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FileWithUrl } from "../types/file";
|
import { FileMetadata } from "../types/file";
|
||||||
import { fileStorage } from "../services/fileStorage";
|
import { fileStorage } from "../services/fileStorage";
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ function calculateThumbnailScale(pageViewport: { width: number; height: number }
|
|||||||
* Hook for IndexedDB-aware thumbnail loading
|
* Hook for IndexedDB-aware thumbnail loading
|
||||||
* Handles thumbnail generation for files not in IndexedDB
|
* 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;
|
thumbnail: string | null;
|
||||||
isGenerating: boolean
|
isGenerating: boolean
|
||||||
} {
|
} {
|
||||||
|
@ -29,7 +29,7 @@ function HomePageContent() {
|
|||||||
} else {
|
} else {
|
||||||
setMaxFiles(-1);
|
setMaxFiles(-1);
|
||||||
setIsToolMode(false);
|
setIsToolMode(false);
|
||||||
setSelectedFiles([]);
|
// Don't clear selections when exiting tool mode - preserve selections for file/page editor
|
||||||
}
|
}
|
||||||
}, [selectedTool]); // Remove action dependencies to prevent loops
|
}, [selectedTool]); // Remove action dependencies to prevent loops
|
||||||
|
|
||||||
@ -48,8 +48,9 @@ function HomePageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
function HomePageWithProviders() {
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolWorkflowProvider onViewChange={actions.setMode as any /* FIX ME */}>
|
<ToolWorkflowProvider onViewChange={actions.setMode as any /* FIX ME */}>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
@ -58,3 +59,7 @@ export default function HomePage() {
|
|||||||
</ToolWorkflowProvider>
|
</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 { fileStorage, StorageStats } from "./fileStorage";
|
||||||
import { loadFilesFromIndexedDB, createEnhancedFileFromStored, cleanupFileUrls } from "../utils/fileUtils";
|
import { loadFilesFromIndexedDB, cleanupFileUrls } from "../utils/fileUtils";
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
import { updateStorageStatsIncremental } from "../utils/storageUtils";
|
import { updateStorageStatsIncremental } from "../utils/storageUtils";
|
||||||
|
import { createFileId } from "../types/fileContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for file storage operations
|
* Service for file storage operations
|
||||||
@ -79,31 +80,35 @@ export const fileOperationsService = {
|
|||||||
// Generate thumbnail only during upload
|
// Generate thumbnail only during upload
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
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);
|
console.log('File stored with ID:', storedFile.id);
|
||||||
|
|
||||||
const baseFile = fileStorage.createFileFromStored(storedFile);
|
// Create FileWithUrl that extends the original File
|
||||||
const enhancedFile = createEnhancedFileFromStored(storedFile, thumbnail);
|
const enhancedFile: FileWithUrl = Object.assign(file, {
|
||||||
|
id: fileId,
|
||||||
// Copy File interface methods from baseFile
|
url: URL.createObjectURL(file),
|
||||||
enhancedFile.arrayBuffer = baseFile.arrayBuffer.bind(baseFile);
|
thumbnail,
|
||||||
enhancedFile.slice = baseFile.slice.bind(baseFile);
|
storedInIndexedDB: true
|
||||||
enhancedFile.stream = baseFile.stream.bind(baseFile);
|
});
|
||||||
enhancedFile.text = baseFile.text.bind(baseFile);
|
|
||||||
|
|
||||||
newFiles.push(enhancedFile);
|
newFiles.push(enhancedFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to store file in IndexedDB:', 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, {
|
const enhancedFile: FileWithUrl = Object.assign(file, {
|
||||||
|
id: fileId,
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
storedInIndexedDB: false
|
storedInIndexedDB: false
|
||||||
});
|
});
|
||||||
newFiles.push(enhancedFile);
|
newFiles.push(enhancedFile);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// IndexedDB disabled - use RAM
|
// IndexedDB disabled - use RAM with UUID
|
||||||
|
const fileId = createFileId();
|
||||||
const enhancedFile: FileWithUrl = Object.assign(file, {
|
const enhancedFile: FileWithUrl = Object.assign(file, {
|
||||||
|
id: fileId,
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
storedInIndexedDB: false
|
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);
|
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();
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
const storedFile: StoredFile = {
|
const storedFile: StoredFile = {
|
||||||
id,
|
id: fileId, // Use provided UUID
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
@ -106,8 +105,8 @@ class FileStorageService {
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('Object store keyPath:', store.keyPath);
|
console.log('Object store keyPath:', store.keyPath);
|
||||||
console.log('Storing file:', {
|
console.log('Storing file with UUID:', {
|
||||||
id: storedFile.id,
|
id: storedFile.id, // Now a UUID from FileContext
|
||||||
name: storedFile.name,
|
name: storedFile.name,
|
||||||
hasData: !!storedFile.data,
|
hasData: !!storedFile.data,
|
||||||
dataSize: storedFile.data.byteLength
|
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 {
|
export interface FileWithUrl extends File {
|
||||||
id?: string;
|
id: string; // Required UUID from FileContext
|
||||||
url?: string;
|
url?: string; // Blob URL for display
|
||||||
thumbnail?: string;
|
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;
|
storedInIndexedDB?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,8 @@ export interface FileRecord {
|
|||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
blobUrl?: string;
|
blobUrl?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
contentHash?: string; // Optional content hash for deduplication
|
||||||
|
hashStatus?: 'pending' | 'completed' | 'failed'; // Hash computation status
|
||||||
processedFile?: {
|
processedFile?: {
|
||||||
pages: Array<{
|
pages: Array<{
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
@ -34,10 +36,66 @@ export interface FileContextNormalizedFiles {
|
|||||||
byId: Record<FileId, FileRecord>;
|
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 {
|
export function createStableFileId(file: File): FileId {
|
||||||
// Use existing ID if file already has one, otherwise create stable ID from metadata
|
// Don't mutate File objects - always return new UUID
|
||||||
return (file as any).id || `${file.name}-${file.size}-${file.lastModified}`;
|
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 {
|
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 {
|
export function revokeFileResources(record: FileRecord): void {
|
||||||
if (record.thumbnailUrl) {
|
// Only revoke blob: URLs to prevent errors on other schemes
|
||||||
URL.revokeObjectURL(record.thumbnailUrl);
|
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) {
|
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(record.blobUrl);
|
try {
|
||||||
|
URL.revokeObjectURL(record.blobUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to revoke blob URL:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Clean up processed file thumbnails
|
// Clean up processed file thumbnails
|
||||||
if (record.processedFile?.pages) {
|
if (record.processedFile?.pages) {
|
||||||
record.processedFile.pages.forEach(page => {
|
record.processedFile.pages.forEach(page => {
|
||||||
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
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
|
// Action types for reducer pattern
|
||||||
export type FileContextAction =
|
export type FileContextAction =
|
||||||
// File management actions
|
// File management actions
|
||||||
| { type: 'ADD_FILES'; payload: { files: File[] } }
|
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
|
||||||
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
||||||
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
|
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
|
||||||
|
|
||||||
@ -155,6 +226,7 @@ export interface FileContextActions {
|
|||||||
// File management - lightweight actions only
|
// File management - lightweight actions only
|
||||||
addFiles: (files: File[]) => Promise<File[]>;
|
addFiles: (files: File[]) => Promise<File[]>;
|
||||||
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void;
|
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void;
|
||||||
|
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
||||||
clearAllFiles: () => void;
|
clearAllFiles: () => void;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
|
@ -19,7 +19,7 @@ export function formatFileSize(bytes: number): string {
|
|||||||
/**
|
/**
|
||||||
* Get file date as string
|
* Get file date as string
|
||||||
*/
|
*/
|
||||||
export function getFileDate(file: File): string {
|
export function getFileDate(file: File | { lastModified: number }): string {
|
||||||
if (file.lastModified) {
|
if (file.lastModified) {
|
||||||
return new Date(file.lastModified).toLocaleString();
|
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)
|
* 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";
|
if (!file.size) return "Unknown";
|
||||||
return formatFileSize(file.size);
|
return formatFileSize(file.size);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user