bug fixes

This commit is contained in:
Reece Browne 2025-08-20 12:53:02 +01:00
parent 6aa8e42941
commit 28c5e675ac
9 changed files with 453 additions and 240 deletions

View File

@ -137,7 +137,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onDrop={handleNewFileUpload} onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)} onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)} onDragLeave={() => setIsDragging(false)}
accept={["*/*"] as any} accept={{}}
multiple={true} multiple={true}
activateOnClick={false} activateOnClick={false}
style={{ style={{

View File

@ -14,7 +14,6 @@ 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';
import FileThumbnail from '../pageEditor/FileThumbnail'; import FileThumbnail from '../pageEditor/FileThumbnail';
import DragDropGrid from '../pageEditor/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal'; import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
@ -110,11 +109,6 @@ const FileEditor = ({
setSelectionMode(true); setSelectionMode(true);
} }
}, [toolMode]); }, [toolMode]);
const [draggedFile, setDraggedFile] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false); const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [conversionProgress, setConversionProgress] = useState(0); const [conversionProgress, setConversionProgress] = useState(0);
const [zipExtractionProgress, setZipExtractionProgress] = useState<{ const [zipExtractionProgress, setZipExtractionProgress] = useState<{
@ -130,7 +124,6 @@ const FileEditor = ({
extractedCount: 0, extractedCount: 0,
totalFiles: 0 totalFiles: 0
}); });
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const lastActiveFilesRef = useRef<string[]>([]); const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(0); const lastProcessedFilesRef = useRef<number>(0);
@ -452,113 +445,57 @@ const FileEditor = ({
}); });
}, [setContextSelectedFiles]); }, [setContextSelectedFiles]);
// File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => {
setFiles(prevFiles => {
const newFiles = [...prevFiles];
// Drag and drop handlers // Find original source and target indices
const handleDragStart = useCallback((fileId: string) => { const sourceIndex = newFiles.findIndex(f => f.id === sourceFileId);
setDraggedFile(fileId); const targetIndex = newFiles.findIndex(f => f.id === targetFileId);
if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) { if (sourceIndex === -1 || targetIndex === -1) {
setMultiFileDrag({ console.warn('Could not find source or target file for reordering');
fileIds: localSelectedIds, return prevFiles;
count: localSelectedIds.length
});
} else {
setMultiFileDrag(null);
}
}, [selectionMode, localSelectedIds]);
const handleDragEnd = useCallback(() => {
setDraggedFile(null);
setDropTarget(null);
setMultiFileDrag(null);
setDragPosition(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!draggedFile) return;
if (multiFileDrag) {
setDragPosition({ x: e.clientX, y: e.clientY });
}
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
if (!elementUnderCursor) return;
const fileContainer = elementUnderCursor.closest('[data-file-id]');
if (fileContainer) {
const fileId = fileContainer.getAttribute('data-file-id');
if (fileId && fileId !== draggedFile) {
setDropTarget(fileId);
return;
} }
}
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); // Handle multi-file selection reordering
if (endZone) { const filesToMove = selectedFileIds.length > 1
setDropTarget('end'); ? selectedFileIds.map(id => newFiles.find(f => f.id === id)!).filter(Boolean)
return; : [newFiles[sourceIndex]];
}
setDropTarget(null); // Calculate the correct target position before removing files
}, [draggedFile, multiFileDrag]); let insertIndex = targetIndex;
const handleDragEnter = useCallback((fileId: string) => { // If we're moving forward (right), we need to adjust for the files we're removing
if (draggedFile && fileId !== draggedFile) { const sourceIndices = filesToMove.map(f => newFiles.findIndex(nf => nf.id === f.id));
setDropTarget(fileId); const minSourceIndex = Math.min(...sourceIndices);
}
}, [draggedFile]);
const handleDragLeave = useCallback(() => { if (minSourceIndex < targetIndex) {
// Let dragover handle this // Moving forward: target moves left by the number of files we're removing before it
}, []); const filesBeforeTarget = sourceIndices.filter(idx => idx < targetIndex).length;
insertIndex = targetIndex - filesBeforeTarget + 1; // +1 to insert after target
}
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => { // Remove files to move from their current positions (in reverse order to maintain indices)
e.preventDefault(); sourceIndices
if (!draggedFile || draggedFile === targetFileId) return; .sort((a, b) => b - a) // Sort descending to remove from end first
.forEach(index => {
newFiles.splice(index, 1);
});
let targetIndex: number; // Insert files at the calculated position
if (targetFileId === 'end') { newFiles.splice(insertIndex, 0, ...filesToMove);
targetIndex = files.length;
} else {
targetIndex = files.findIndex(f => f.id === targetFileId);
if (targetIndex === -1) return;
}
const filesToMove = selectionMode && localSelectedIds.includes(draggedFile) // Update status
? localSelectedIds const moveCount = filesToMove.length;
: [draggedFile]; setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
// Update the local files state and sync with activeFiles
setFiles(prev => {
const newFiles = [...prev];
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
// Remove moved files
filesToMove.forEach(id => {
const index = newFiles.findIndex(f => f.id === id);
if (index !== -1) newFiles.splice(index, 1);
});
// Insert at target position
newFiles.splice(targetIndex, 0, ...movedFiles);
// TODO: Update context with reordered files (need to implement file reordering in context)
// For now, just return the reordered local state
return newFiles; return newFiles;
}); });
}, [setStatus]);
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
setDropTarget('end');
}
}, [draggedFile]);
// File operations using context // File operations using context
const handleDeleteFile = useCallback((fileId: string) => { const handleDeleteFile = useCallback((fileId: string) => {
@ -751,7 +688,15 @@ const FileEditor = ({
<SkeletonLoader type="fileGrid" count={6} /> <SkeletonLoader type="fileGrid" count={6} />
</Box> </Box>
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem', padding: '1rem' }}> <div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
{files.map((file, index) => ( {files.map((file, index) => (
<FileThumbnail <FileThumbnail
key={file.id} key={file.id}
@ -760,20 +705,11 @@ const FileEditor = ({
totalFiles={files.length} totalFiles={files.length}
selectedFiles={localSelectedIds} selectedFiles={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
isAnimating={isAnimating}
fileRefs={fileRefs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onToggleFile={toggleFile} onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile} onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile} onViewFile={handleViewFile}
onSetStatus={setStatus} onSetStatus={setStatus}
onReorderFiles={handleReorderFiles}
toolMode={toolMode} toolMode={toolMode}
isSupported={isFileSupported(file.name)} isSupported={isFileSupported(file.name)}
/> />

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core'; import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@ -7,6 +7,7 @@ import PreviewIcon from '@mui/icons-material/Preview';
import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css'; import styles from './PageEditor.module.css';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext } from '../../contexts/FileContext';
@ -25,20 +26,11 @@ interface FileThumbnailProps {
totalFiles: number; totalFiles: number;
selectedFiles: string[]; selectedFiles: string[];
selectionMode: boolean; selectionMode: boolean;
draggedFile: string | null;
dropTarget: string | null;
isAnimating: boolean;
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (fileId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (fileId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, fileId: string) => void;
onToggleFile: (fileId: string) => void; onToggleFile: (fileId: string) => void;
onDeleteFile: (fileId: string) => void; onDeleteFile: (fileId: string) => void;
onViewFile: (fileId: string) => void; onViewFile: (fileId: string) => void;
onSetStatus: (status: string) => void; onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
toolMode?: boolean; toolMode?: boolean;
isSupported?: boolean; isSupported?: boolean;
} }
@ -49,26 +41,21 @@ const FileThumbnail = ({
totalFiles, totalFiles,
selectedFiles, selectedFiles,
selectionMode, selectionMode,
draggedFile,
dropTarget,
isAnimating,
fileRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onToggleFile, onToggleFile,
onDeleteFile, onDeleteFile,
onViewFile, onViewFile,
onSetStatus, onSetStatus,
onReorderFiles,
toolMode = false, toolMode = false,
isSupported = true, isSupported = true,
}: FileThumbnailProps) => { }: FileThumbnailProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// Drag and drop state
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
// Find the actual File object that corresponds to this FileItem // Find the actual File object that corresponds to this FileItem
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size); const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
@ -80,18 +67,59 @@ const FileThumbnail = ({
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}; };
// Memoize ref callback to prevent infinite loop // Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
const refCallback = useCallback((el: HTMLDivElement | null) => { const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (el) { if (!element) return;
fileRefs.current.set(file.id, el);
} else { dragElementRef.current = element;
fileRefs.current.delete(file.id);
} const dragCleanup = draggable({
}, [file.id, fileRefs]); element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: selectionMode && selectedFiles.includes(file.id)
? selectedFiles
: [file.id]
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as string;
const selectedFileIds = sourceData.selectedFiles as string[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
return ( return (
<div <div
ref={refCallback} ref={fileElementRef}
data-file-id={file.id} data-file-id={file.id}
data-testid="file-thumbnail" data-testid="file-thumbnail"
className={` className={`
@ -110,26 +138,12 @@ const FileThumbnail = ({
${selectionMode ${selectionMode
? 'bg-white hover:bg-gray-50' ? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'} : 'bg-white hover:bg-gray-50'}
${draggedFile === file.id ? 'opacity-50 scale-95' : ''} ${isDragging ? 'opacity-50 scale-95' : ''}
`} `}
style={{ style={{
transform: (() => { opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)' filter: isSupported ? 'none' : 'grayscale(50%)'
}} }}
draggable
onDragStart={() => onDragStart(file.id)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(file.id)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, file.id)}
> >
{selectionMode && ( {selectionMode && (
<div <div
@ -188,6 +202,7 @@ const FileThumbnail = ({
<img <img
src={file.thumbnail} src={file.thumbnail}
alt={file.name} alt={file.name}
draggable={false}
style={{ style={{
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',

View File

@ -18,6 +18,10 @@ import {
ToggleSplitCommand ToggleSplitCommand
} from "../../commands/pageCommands"; } from "../../commands/pageCommands";
import { pdfExportService } from "../../services/pdfExportService"; import { pdfExportService } from "../../services/pdfExportService";
import { enhancedPDFProcessingService } from "../../services/enhancedPDFProcessingService";
import { fileProcessingService } from "../../services/fileProcessingService";
import { pdfProcessingService } from "../../services/pdfProcessingService";
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration";
import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils";
import { fileStorage } from "../../services/fileStorage"; import { fileStorage } from "../../services/fileStorage";
@ -421,12 +425,21 @@ const PageEditor = ({
// Cleanup thumbnail generation when component unmounts // Cleanup thumbnail generation when component unmounts
useEffect(() => { useEffect(() => {
return () => { return () => {
// Stop any ongoing thumbnail generation // Stop all PDF.js background processing on unmount
if (stopGeneration) { if (stopGeneration) {
stopGeneration(); stopGeneration();
} }
if (destroyThumbnails) {
destroyThumbnails();
}
// Stop all processing services and destroy workers
enhancedPDFProcessingService.emergencyCleanup();
fileProcessingService.emergencyCleanup();
pdfProcessingService.clearAll();
// Final emergency cleanup of all workers
pdfWorkerManager.emergencyCleanup();
}; };
}, [stopGeneration]); }, [stopGeneration, destroyThumbnails]);
// Clear selections when files change - use stable signature // Clear selections when files change - use stable signature
useEffect(() => { useEffect(() => {
@ -557,36 +570,34 @@ const PageEditor = ({
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
const draftKey = `draft-${doc.id || 'merged'}`; const draftKey = `draft-${doc.id || 'merged'}`;
// Convert PDF document to bytes for storage
const pdfBytes = await doc.save();
const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean);
// Create a temporary file for thumbnail generation
const tempFile = new File([pdfBytes], `Draft - ${originalFileNames.join(', ') || 'Untitled'}.pdf`, {
type: 'application/pdf',
lastModified: Date.now()
});
// Generate thumbnail for the draft
let thumbnail: string | undefined;
try { try {
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils'); // Export the current document state as PDF bytes
thumbnail = await generateThumbnailForFile(tempFile); const exportedFile = await pdfExportService.exportPDF(doc, []);
} catch (error) { const pdfBytes = 'blob' in exportedFile ? await exportedFile.blob.arrayBuffer() : await exportedFile.blobs[0].arrayBuffer();
console.warn('Failed to generate thumbnail for draft:', error); const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean);
}
const draftData = { // Generate thumbnail for the draft
id: draftKey, let thumbnail: string | undefined;
name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`, try {
pdfData: pdfBytes, const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
size: pdfBytes.length, const blob = 'blob' in exportedFile ? exportedFile.blob : exportedFile.blobs[0];
timestamp: Date.now(), const filename = 'filename' in exportedFile ? exportedFile.filename : exportedFile.filenames[0];
thumbnail, const file = new File([blob], filename, { type: 'application/pdf' });
originalFiles: originalFileNames thumbnail = await generateThumbnailForFile(file);
}; } catch (error) {
console.warn('Failed to generate thumbnail for draft:', error);
}
const draftData = {
id: draftKey,
name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`,
pdfData: pdfBytes,
size: pdfBytes.byteLength,
timestamp: Date.now(),
thumbnail,
originalFiles: originalFileNames
};
try {
// Use centralized IndexedDB manager // Use centralized IndexedDB manager
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
const transaction = db.transaction('drafts', 'readwrite'); const transaction = db.transaction('drafts', 'readwrite');
@ -956,10 +967,27 @@ const PageEditor = ({
}, [redo]); }, [redo]);
const closePdf = useCallback(() => { const closePdf = useCallback(() => {
// Use actions from context // Stop all PDF.js background processing immediately
actions.clearAllFiles(); if (stopGeneration) {
stopGeneration();
}
if (destroyThumbnails) {
destroyThumbnails();
}
// Stop enhanced PDF processing and destroy workers
enhancedPDFProcessingService.emergencyCleanup();
// Stop file processing service and destroy workers
fileProcessingService.emergencyCleanup();
// Stop PDF processing service
pdfProcessingService.clearAll();
// Emergency cleanup - destroy all PDF workers
pdfWorkerManager.emergencyCleanup();
// Clear files from memory only (preserves files in storage/recent files)
const allFileIds = selectors.getAllFileIds();
actions.removeFiles(allFileIds, false); // false = don't delete from storage
actions.setSelectedPages([]); actions.setSelectedPages([]);
}, [actions]); }, [actions, selectors, stopGeneration, destroyThumbnails]);
// PageEditorControls needs onExportSelected and onExportAll // PageEditorControls needs onExportSelected and onExportAll
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
@ -1102,10 +1130,8 @@ const PageEditor = ({
} }
// Clean up draft if component unmounts with unsaved changes // Note: We intentionally do NOT clean up drafts on unmount
if (hasUnsavedChanges) { // Drafts should persist when navigating away so users can resume later
cleanupDraft();
}
}; };
}, [hasUnsavedChanges, cleanupDraft]); }, [hasUnsavedChanges, cleanupDraft]);

View File

@ -59,17 +59,39 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
}, []); }, []);
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => { const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
// Check for duplicate at IndexedDB level before saving
const quickKey = `${file.name}|${file.size}|${file.lastModified}`;
const existingFiles = await fileStorage.getAllFileMetadata();
const duplicate = existingFiles.find(stored =>
`${stored.name}|${stored.size}|${stored.lastModified}` === quickKey
);
if (duplicate) {
if (DEBUG) console.log(`🔍 SAVE: Skipping IndexedDB duplicate - using existing record:`, duplicate.name);
// Return the existing file's metadata instead of saving duplicate
return {
id: duplicate.id,
name: duplicate.name,
type: duplicate.type,
size: duplicate.size,
lastModified: duplicate.lastModified,
thumbnail: duplicate.thumbnail
};
}
// DEBUG: Check original file before saving // DEBUG: Check original file before saving
if (DEBUG && file.type === 'application/pdf') { if (DEBUG && file.type === 'application/pdf') {
try { try {
const { getDocument } = await import('pdfjs-dist'); const { getDocument } = await import('pdfjs-dist');
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise; const pdf = await getDocument({ data: arrayBuffer }).promise;
console.log(`🔍 Saving file to IndexedDB:`, { console.log(`🔍 BEFORE SAVE - Original file:`, {
name: file.name, name: file.name,
size: file.size, size: file.size,
arrayBufferSize: arrayBuffer.byteLength,
pages: pdf.numPages pages: pdf.numPages
}); });
pdf.destroy();
} catch (error) { } catch (error) {
console.error(`🔍 Error validating file before save:`, error); console.error(`🔍 Error validating file before save:`, error);
} }
@ -120,7 +142,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// DEBUG: Check if file reconstruction is working // DEBUG: Check if file reconstruction is working
if (DEBUG && file.type === 'application/pdf') { if (DEBUG && file.type === 'application/pdf') {
console.log(`🔍 File loaded from IndexedDB:`, { console.log(`🔍 AFTER LOAD - Reconstructed file:`, {
name: file.name, name: file.name,
originalSize: storedFile.size, originalSize: storedFile.size,
reconstructedSize: file.size, reconstructedSize: file.size,
@ -133,9 +155,10 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const { getDocument } = await import('pdfjs-dist'); const { getDocument } = await import('pdfjs-dist');
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise; const pdf = await getDocument({ data: arrayBuffer }).promise;
console.log(`🔍 PDF validation: ${pdf.numPages} pages in reconstructed file`); console.log(`🔍 AFTER LOAD - PDF validation: ${pdf.numPages} pages in reconstructed file`);
pdf.destroy();
} catch (error) { } catch (error) {
console.error(`🔍 PDF reconstruction error:`, error); console.error(`🔍 AFTER LOAD - PDF reconstruction error:`, error);
} }
} }

View File

@ -66,6 +66,7 @@ export async function addFiles(
// Build quickKey lookup from existing files for deduplication // Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
switch (kind) { switch (kind) {
case 'raw': { case 'raw': {
@ -77,9 +78,10 @@ export async function addFiles(
// Soft deduplication: Check if file already exists by metadata // Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) { if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (already exists)`); if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
continue; continue;
} }
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
const fileId = createFileId(); const fileId = createFileId();
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
@ -114,19 +116,8 @@ export async function addFiles(
fileRecords.push(record); fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
// Start background processing for validation only (we already have thumbnail and page count) // Note: No background fileProcessingService call needed - we already have immediate thumbnail and page count
fileProcessingService.processFile(file, fileId).then(result => { // This avoids cancellation conflicts with cleanup operations
// Only update if file still exists in context
if (filesRef.current.has(fileId)) {
if (result.success && result.metadata) {
// Only log if page count differs from our immediate calculation
const initialPageCount = pageCount;
if (result.metadata.totalPages !== initialPageCount) {
if (DEBUG) console.log(`📄 Background processing found different page count for ${file.name}: ${result.metadata.totalPages} vs immediate ${initialPageCount}`);
}
}
}
});
} }
break; break;
} }
@ -172,9 +163,10 @@ export async function addFiles(
const quickKey = createQuickKey(file); const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) { if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name}`); if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
continue; continue;
} }
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
// Try to preserve original ID, but generate new if it conflicts // Try to preserve original ID, but generate new if it conflicts
let fileId = originalId; let fileId = originalId;
@ -192,12 +184,35 @@ export async function addFiles(
record.thumbnailUrl = metadata.thumbnail; record.thumbnailUrl = metadata.thumbnail;
} }
// Note: For stored files, processedFile will be restored from FileRecord if it exists // Generate processedFile metadata for stored files using PDF worker manager
// The metadata here is just basic file info, not processed file data // This ensures stored files have proper page information and avoids cancellation conflicts
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 addFiles(stored): Generating metadata for stored file ${file.name}`);
// Use PDF worker manager directly for page count (avoids fileProcessingService conflicts)
const arrayBuffer = await file.arrayBuffer();
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
pageCount = pdf.numPages;
pdfWorkerManager.destroyDocument(pdf);
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in stored file ${file.name}`);
} catch (error) {
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate metadata for ${file.name}:`, error);
}
// Create processedFile metadata with correct page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
// Note: No background fileProcessingService call for stored files - we already processed them above
} }
break; break;
} }

View File

@ -1,12 +1,10 @@
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing'; import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing';
import { ProcessingCache } from './processingCache'; import { ProcessingCache } from './processingCache';
import { FileHasher } from '../utils/fileHash'; import { FileHasher } from '../utils/fileHash';
import { FileAnalyzer } from './fileAnalyzer'; import { FileAnalyzer } from './fileAnalyzer';
import { ProcessingErrorHandler } from './processingErrorHandler'; import { ProcessingErrorHandler } from './processingErrorHandler';
import { pdfWorkerManager } from './pdfWorkerManager';
// Set up PDF.js worker
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
export class EnhancedPDFProcessingService { export class EnhancedPDFProcessingService {
private static instance: EnhancedPDFProcessingService; private static instance: EnhancedPDFProcessingService;
@ -183,7 +181,7 @@ export class EnhancedPDFProcessingService {
state: ProcessingState state: ProcessingState
): Promise<ProcessedFile> { ): Promise<ProcessedFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise; const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const totalPages = pdf.numPages; const totalPages = pdf.numPages;
state.progress = 10; state.progress = 10;
@ -194,7 +192,7 @@ export class EnhancedPDFProcessingService {
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
// Check for cancellation // Check for cancellation
if (state.cancellationToken?.signal.aborted) { if (state.cancellationToken?.signal.aborted) {
pdf.destroy(); pdfWorkerManager.destroyDocument(pdf);
throw new Error('Processing cancelled'); throw new Error('Processing cancelled');
} }
@ -215,7 +213,7 @@ export class EnhancedPDFProcessingService {
this.notifyListeners(); this.notifyListeners();
} }
pdf.destroy(); pdfWorkerManager.destroyDocument(pdf);
state.progress = 100; state.progress = 100;
this.notifyListeners(); this.notifyListeners();
@ -231,7 +229,7 @@ export class EnhancedPDFProcessingService {
state: ProcessingState state: ProcessingState
): Promise<ProcessedFile> { ): Promise<ProcessedFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise; const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const totalPages = pdf.numPages; const totalPages = pdf.numPages;
state.progress = 10; state.progress = 10;
@ -243,7 +241,7 @@ export class EnhancedPDFProcessingService {
// Process priority pages first // Process priority pages first
for (let i = 1; i <= priorityCount; i++) { for (let i = 1; i <= priorityCount; i++) {
if (state.cancellationToken?.signal.aborted) { if (state.cancellationToken?.signal.aborted) {
pdf.destroy(); pdfWorkerManager.destroyDocument(pdf);
throw new Error('Processing cancelled'); throw new Error('Processing cancelled');
} }
@ -274,7 +272,7 @@ export class EnhancedPDFProcessingService {
}); });
} }
pdf.destroy(); pdfWorkerManager.destroyDocument(pdf);
state.progress = 100; state.progress = 100;
this.notifyListeners(); this.notifyListeners();
@ -290,7 +288,7 @@ export class EnhancedPDFProcessingService {
state: ProcessingState state: ProcessingState
): Promise<ProcessedFile> { ): Promise<ProcessedFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise; const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const totalPages = pdf.numPages; const totalPages = pdf.numPages;
state.progress = 10; state.progress = 10;
@ -305,7 +303,7 @@ export class EnhancedPDFProcessingService {
for (let i = 1; i <= firstChunkEnd; i++) { for (let i = 1; i <= firstChunkEnd; i++) {
if (state.cancellationToken?.signal.aborted) { if (state.cancellationToken?.signal.aborted) {
pdf.destroy(); pdfWorkerManager.destroyDocument(pdf);
throw new Error('Processing cancelled'); throw new Error('Processing cancelled');
} }
@ -342,7 +340,7 @@ export class EnhancedPDFProcessingService {
}); });
} }
pdf.destroy(); pdfWorkerManager.destroyDocument(pdf);
state.progress = 100; state.progress = 100;
this.notifyListeners(); this.notifyListeners();
@ -358,7 +356,7 @@ export class EnhancedPDFProcessingService {
state: ProcessingState state: ProcessingState
): Promise<ProcessedFile> { ): Promise<ProcessedFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise; const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const totalPages = pdf.numPages; const totalPages = pdf.numPages;
state.progress = 50; state.progress = 50;
@ -376,7 +374,7 @@ export class EnhancedPDFProcessingService {
}); });
} }
pdf.destroy(); pdfWorkerManager.destroyDocument(pdf);
state.progress = 100; state.progress = 100;
this.notifyListeners(); this.notifyListeners();
@ -540,6 +538,15 @@ export class EnhancedPDFProcessingService {
this.processing.clear(); this.processing.clear();
this.notifyListeners(); this.notifyListeners();
} }
/**
* Emergency cleanup - destroy all PDF workers
*/
emergencyCleanup(): void {
this.clearAllProcessing();
this.clearAll();
pdfWorkerManager.destroyAllDocuments();
}
} }
// Export singleton instance // Export singleton instance

View File

@ -4,8 +4,9 @@
* Called when files are added to FileContext, before any view sees them * Called when files are added to FileContext, before any view sees them
*/ */
import { getDocument } from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { pdfWorkerManager } from './pdfWorkerManager';
export interface ProcessedFileMetadata { export interface ProcessedFileMetadata {
totalPages: number; totalPages: number;
@ -90,17 +91,16 @@ class FileProcessingService {
// Discover page count using PDF.js (most accurate) // Discover page count using PDF.js (most accurate)
try { try {
const pdfDoc = await getDocument({ const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
data: arrayBuffer,
disableAutoFetch: true, disableAutoFetch: true,
disableStream: true disableStream: true
}).promise; });
totalPages = pdfDoc.numPages; totalPages = pdfDoc.numPages;
console.log(`📁 FileProcessingService: PDF.js discovered ${totalPages} pages for ${file.name}`); console.log(`📁 FileProcessingService: PDF.js discovered ${totalPages} pages for ${file.name}`);
// Clean up immediately // Clean up immediately
pdfDoc.destroy(); pdfWorkerManager.destroyDocument(pdfDoc);
// Check for cancellation after PDF.js processing // Check for cancellation after PDF.js processing
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
@ -204,6 +204,15 @@ class FileProcessingService {
}); });
console.log(`📁 FileProcessingService: Cancelled ${this.processingCache.size} processing operations`); console.log(`📁 FileProcessingService: Cancelled ${this.processingCache.size} processing operations`);
} }
/**
* Emergency cleanup - cancel all processing and destroy workers
*/
emergencyCleanup(): void {
this.cancelAllProcessing();
this.clearCache();
pdfWorkerManager.destroyAllDocuments();
}
} }
// Export singleton instance // Export singleton instance

View File

@ -0,0 +1,182 @@
/**
* PDF.js Worker Manager - Centralized worker lifecycle management
*
* Prevents infinite worker creation by managing PDF.js workers globally
* and ensuring proper cleanup when operations complete.
*/
import * as pdfjsLib from 'pdfjs-dist';
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
class PDFWorkerManager {
private static instance: PDFWorkerManager;
private activeDocuments = new Set<any>();
private workerCount = 0;
private maxWorkers = 3; // Limit concurrent workers
private isInitialized = false;
private constructor() {
this.initializeWorker();
}
static getInstance(): PDFWorkerManager {
if (!PDFWorkerManager.instance) {
PDFWorkerManager.instance = new PDFWorkerManager();
}
return PDFWorkerManager.instance;
}
/**
* Initialize PDF.js worker once globally
*/
private initializeWorker(): void {
if (!this.isInitialized) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
this.isInitialized = true;
console.log('🏭 PDF.js worker initialized');
}
}
/**
* Create a PDF document with proper lifecycle management
*/
async createDocument(
data: ArrayBuffer | Uint8Array,
options: {
disableAutoFetch?: boolean;
disableStream?: boolean;
stopAtErrors?: boolean;
verbosity?: number;
} = {}
): Promise<any> {
// Wait if we've hit the worker limit
if (this.activeDocuments.size >= this.maxWorkers) {
console.warn(`🏭 PDF Worker limit reached (${this.maxWorkers}), waiting for available worker...`);
await this.waitForAvailableWorker();
}
const loadingTask = getDocument({
data,
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
});
try {
const pdf = await loadingTask.promise;
this.activeDocuments.add(pdf);
this.workerCount++;
console.log(`🏭 PDF document created (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
return pdf;
} catch (error) {
// If document creation fails, make sure to clean up the loading task
if (loadingTask) {
try {
loadingTask.destroy();
} catch (destroyError) {
console.warn('🏭 Error destroying failed loading task:', destroyError);
}
}
throw error;
}
}
/**
* Properly destroy a PDF document and clean up resources
*/
destroyDocument(pdf: any): void {
if (this.activeDocuments.has(pdf)) {
try {
pdf.destroy();
this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1);
console.log(`🏭 PDF document destroyed (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
} catch (error) {
console.warn('🏭 Error destroying PDF document:', error);
// Still remove from tracking even if destroy failed
this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1);
}
}
}
/**
* Destroy all active PDF documents
*/
destroyAllDocuments(): void {
console.log(`🏭 Destroying all PDF documents (${this.activeDocuments.size} active)`);
const documentsToDestroy = Array.from(this.activeDocuments);
documentsToDestroy.forEach(pdf => {
this.destroyDocument(pdf);
});
this.activeDocuments.clear();
this.workerCount = 0;
console.log('🏭 All PDF documents destroyed');
}
/**
* Wait for a worker to become available
*/
private async waitForAvailableWorker(): Promise<void> {
return new Promise((resolve) => {
const checkAvailability = () => {
if (this.activeDocuments.size < this.maxWorkers) {
resolve();
} else {
setTimeout(checkAvailability, 100);
}
};
checkAvailability();
});
}
/**
* Get current worker statistics
*/
getWorkerStats() {
return {
active: this.activeDocuments.size,
max: this.maxWorkers,
total: this.workerCount
};
}
/**
* Force cleanup of all workers (emergency cleanup)
*/
emergencyCleanup(): void {
console.warn('🏭 Emergency PDF worker cleanup initiated');
// Force destroy all documents
this.activeDocuments.forEach(pdf => {
try {
pdf.destroy();
} catch (error) {
console.warn('🏭 Emergency cleanup - error destroying document:', error);
}
});
this.activeDocuments.clear();
this.workerCount = 0;
console.warn('🏭 Emergency cleanup completed');
}
/**
* Set maximum concurrent workers
*/
setMaxWorkers(max: number): void {
this.maxWorkers = Math.max(1, Math.min(max, 10)); // Between 1-10 workers
console.log(`🏭 Max workers set to ${this.maxWorkers}`);
}
}
// Export singleton instance
export const pdfWorkerManager = PDFWorkerManager.getInstance();