From 28c5e675ac51e263fb95e17fb02e4e32674700d3 Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Wed, 20 Aug 2025 12:53:02 +0100 Subject: [PATCH] bug fixes --- frontend/src/components/FileManager.tsx | 2 +- .../src/components/fileEditor/FileEditor.tsx | 164 +++++----------- .../components/pageEditor/FileThumbnail.tsx | 107 +++++----- .../src/components/pageEditor/PageEditor.tsx | 102 ++++++---- frontend/src/contexts/IndexedDBContext.tsx | 31 ++- frontend/src/contexts/file/fileActions.ts | 49 +++-- .../services/enhancedPDFProcessingService.ts | 37 ++-- .../src/services/fileProcessingService.ts | 19 +- frontend/src/services/pdfWorkerManager.ts | 182 ++++++++++++++++++ 9 files changed, 453 insertions(+), 240 deletions(-) create mode 100644 frontend/src/services/pdfWorkerManager.ts diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 0a4081e30..4ea5c7a2a 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -137,7 +137,7 @@ const FileManager: React.FC = ({ selectedTool }) => { onDrop={handleNewFileUpload} onDragEnter={() => setIsDragging(true)} onDragLeave={() => setIsDragging(false)} - accept={["*/*"] as any} + accept={{}} multiple={true} activateOnClick={false} style={{ diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 70d1eb53d..626358b2f 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -14,7 +14,6 @@ import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; import styles from '../pageEditor/PageEditor.module.css'; import FileThumbnail from '../pageEditor/FileThumbnail'; -import DragDropGrid from '../pageEditor/DragDropGrid'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; @@ -110,11 +109,6 @@ const FileEditor = ({ setSelectionMode(true); } }, [toolMode]); - const [draggedFile, setDraggedFile] = useState(null); - const [dropTarget, setDropTarget] = useState(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 [conversionProgress, setConversionProgress] = useState(0); const [zipExtractionProgress, setZipExtractionProgress] = useState<{ @@ -130,7 +124,6 @@ const FileEditor = ({ extractedCount: 0, totalFiles: 0 }); - const fileRefs = useRef>(new Map()); const lastActiveFilesRef = useRef([]); const lastProcessedFilesRef = useRef(0); @@ -452,113 +445,57 @@ const FileEditor = ({ }); }, [setContextSelectedFiles]); - - // Drag and drop handlers - const handleDragStart = useCallback((fileId: string) => { - setDraggedFile(fileId); - - if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) { - setMultiFileDrag({ - fileIds: localSelectedIds, - 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; + // File reordering handler for drag and drop + const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => { + setFiles(prevFiles => { + const newFiles = [...prevFiles]; + + // Find original source and target indices + const sourceIndex = newFiles.findIndex(f => f.id === sourceFileId); + const targetIndex = newFiles.findIndex(f => f.id === targetFileId); + + if (sourceIndex === -1 || targetIndex === -1) { + console.warn('Could not find source or target file for reordering'); + return prevFiles; } - } - const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); - if (endZone) { - setDropTarget('end'); - return; - } + // Handle multi-file selection reordering + const filesToMove = selectedFileIds.length > 1 + ? selectedFileIds.map(id => newFiles.find(f => f.id === id)!).filter(Boolean) + : [newFiles[sourceIndex]]; - setDropTarget(null); - }, [draggedFile, multiFileDrag]); + // Calculate the correct target position before removing files + let insertIndex = targetIndex; + + // If we're moving forward (right), we need to adjust for the files we're removing + const sourceIndices = filesToMove.map(f => newFiles.findIndex(nf => nf.id === f.id)); + const minSourceIndex = Math.min(...sourceIndices); + + if (minSourceIndex < targetIndex) { + // 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 handleDragEnter = useCallback((fileId: string) => { - if (draggedFile && fileId !== draggedFile) { - setDropTarget(fileId); - } - }, [draggedFile]); + // Remove files to move from their current positions (in reverse order to maintain indices) + sourceIndices + .sort((a, b) => b - a) // Sort descending to remove from end first + .forEach(index => { + newFiles.splice(index, 1); + }); - const handleDragLeave = useCallback(() => { - // Let dragover handle this - }, []); + // Insert files at the calculated position + newFiles.splice(insertIndex, 0, ...filesToMove); - const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => { - e.preventDefault(); - if (!draggedFile || draggedFile === targetFileId) return; + // Update status + const moveCount = filesToMove.length; + setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - let targetIndex: number; - if (targetFileId === 'end') { - targetIndex = files.length; - } else { - targetIndex = files.findIndex(f => f.id === targetFileId); - if (targetIndex === -1) return; - } - - const filesToMove = selectionMode && localSelectedIds.includes(draggedFile) - ? localSelectedIds - : [draggedFile]; - - // 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; }); + }, [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 const handleDeleteFile = useCallback((fileId: string) => { @@ -751,7 +688,15 @@ const FileEditor = ({ ) : ( -
+
{files.map((file, index) => ( diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index 2babb2e70..3fbf31c76 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -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 { useTranslation } from 'react-i18next'; 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 PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; 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 { useFileContext } from '../../contexts/FileContext'; @@ -25,20 +26,11 @@ interface FileThumbnailProps { totalFiles: number; selectedFiles: string[]; selectionMode: boolean; - draggedFile: string | null; - dropTarget: string | null; - isAnimating: boolean; - fileRefs: React.MutableRefObject>; - 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; onDeleteFile: (fileId: string) => void; onViewFile: (fileId: string) => void; onSetStatus: (status: string) => void; + onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; toolMode?: boolean; isSupported?: boolean; } @@ -49,25 +41,20 @@ const FileThumbnail = ({ totalFiles, selectedFiles, selectionMode, - draggedFile, - dropTarget, - isAnimating, - fileRefs, - onDragStart, - onDragEnd, - onDragOver, - onDragEnter, - onDragLeave, - onDrop, onToggleFile, onDeleteFile, onViewFile, onSetStatus, + onReorderFiles, toolMode = false, isSupported = true, }: FileThumbnailProps) => { const { t } = useTranslation(); const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + + // Drag and drop state + const [isDragging, setIsDragging] = useState(false); + const dragElementRef = useRef(null); // Find the actual File object that corresponds to this FileItem 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]; }; - // Memoize ref callback to prevent infinite loop - const refCallback = useCallback((el: HTMLDivElement | null) => { - if (el) { - fileRefs.current.set(file.id, el); - } else { - fileRefs.current.delete(file.id); - } - }, [file.id, fileRefs]); + // Setup drag and drop using @atlaskit/pragmatic-drag-and-drop + const fileElementRef = useCallback((element: HTMLDivElement | null) => { + if (!element) return; + + dragElementRef.current = element; + + const dragCleanup = draggable({ + 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 (
{ - 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, + opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5, 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 && (
{ return () => { - // Stop any ongoing thumbnail generation + // Stop all PDF.js background processing on unmount if (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 useEffect(() => { @@ -557,37 +570,35 @@ const PageEditor = ({ const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { 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 { - const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils'); - thumbnail = await generateThumbnailForFile(tempFile); - } catch (error) { - console.warn('Failed to generate thumbnail for draft:', error); - } - - const draftData = { - id: draftKey, - name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`, - pdfData: pdfBytes, - size: pdfBytes.length, - timestamp: Date.now(), - thumbnail, - originalFiles: originalFileNames - }; + // Export the current document state as PDF bytes + const exportedFile = await pdfExportService.exportPDF(doc, []); + const pdfBytes = 'blob' in exportedFile ? await exportedFile.blob.arrayBuffer() : await exportedFile.blobs[0].arrayBuffer(); + const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean); + + // Generate thumbnail for the draft + let thumbnail: string | undefined; + try { + const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils'); + const blob = 'blob' in exportedFile ? exportedFile.blob : exportedFile.blobs[0]; + const filename = 'filename' in exportedFile ? exportedFile.filename : exportedFile.filenames[0]; + const file = new File([blob], filename, { type: 'application/pdf' }); + 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 transaction = db.transaction('drafts', 'readwrite'); const store = transaction.objectStore('drafts'); @@ -956,10 +967,27 @@ const PageEditor = ({ }, [redo]); const closePdf = useCallback(() => { - // Use actions from context - actions.clearAllFiles(); + // Stop all PDF.js background processing immediately + 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]); + }, [actions, selectors, stopGeneration, destroyThumbnails]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); @@ -1102,10 +1130,8 @@ const PageEditor = ({ } - // Clean up draft if component unmounts with unsaved changes - if (hasUnsavedChanges) { - cleanupDraft(); - } + // Note: We intentionally do NOT clean up drafts on unmount + // Drafts should persist when navigating away so users can resume later }; }, [hasUnsavedChanges, cleanupDraft]); diff --git a/frontend/src/contexts/IndexedDBContext.tsx b/frontend/src/contexts/IndexedDBContext.tsx index ab3ed09f5..727ce2c3e 100644 --- a/frontend/src/contexts/IndexedDBContext.tsx +++ b/frontend/src/contexts/IndexedDBContext.tsx @@ -59,17 +59,39 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { }, []); const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise => { + // 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 if (DEBUG && file.type === 'application/pdf') { try { const { getDocument } = await import('pdfjs-dist'); const arrayBuffer = await file.arrayBuffer(); const pdf = await getDocument({ data: arrayBuffer }).promise; - console.log(`🔍 Saving file to IndexedDB:`, { + console.log(`🔍 BEFORE SAVE - Original file:`, { name: file.name, size: file.size, + arrayBufferSize: arrayBuffer.byteLength, pages: pdf.numPages }); + pdf.destroy(); } catch (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 if (DEBUG && file.type === 'application/pdf') { - console.log(`🔍 File loaded from IndexedDB:`, { + console.log(`🔍 AFTER LOAD - Reconstructed file:`, { name: file.name, originalSize: storedFile.size, reconstructedSize: file.size, @@ -133,9 +155,10 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { const { getDocument } = await import('pdfjs-dist'); const arrayBuffer = await file.arrayBuffer(); 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) { - console.error(`🔍 PDF reconstruction error:`, error); + console.error(`🔍 AFTER LOAD - PDF reconstruction error:`, error); } } diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index f9b848841..ee73deba2 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -66,6 +66,7 @@ export async function addFiles( // Build quickKey lookup from existing files for deduplication const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); + if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys)); switch (kind) { case 'raw': { @@ -77,9 +78,10 @@ export async function addFiles( // Soft deduplication: Check if file already exists by metadata 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; } + if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`); const fileId = createFileId(); filesRef.current.set(fileId, file); @@ -114,19 +116,8 @@ export async function addFiles( fileRecords.push(record); addedFiles.push({ file, id: fileId, thumbnail }); - // Start background processing for validation only (we already have thumbnail and page count) - fileProcessingService.processFile(file, fileId).then(result => { - // 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}`); - } - } - } - }); + // Note: No background fileProcessingService call needed - we already have immediate thumbnail and page count + // This avoids cancellation conflicts with cleanup operations } break; } @@ -172,9 +163,10 @@ export async function addFiles( const quickKey = createQuickKey(file); 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; } + if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`); // Try to preserve original ID, but generate new if it conflicts let fileId = originalId; @@ -192,12 +184,35 @@ export async function addFiles( record.thumbnailUrl = metadata.thumbnail; } - // Note: For stored files, processedFile will be restored from FileRecord if it exists - // The metadata here is just basic file info, not processed file data + // Generate processedFile metadata for stored files using PDF worker manager + // 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); fileRecords.push(record); addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); + + // Note: No background fileProcessingService call for stored files - we already processed them above } break; } diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index aa7802084..58fb783e7 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -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 { ProcessingCache } from './processingCache'; import { FileHasher } from '../utils/fileHash'; import { FileAnalyzer } from './fileAnalyzer'; import { ProcessingErrorHandler } from './processingErrorHandler'; - -// Set up PDF.js worker -GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; +import { pdfWorkerManager } from './pdfWorkerManager'; export class EnhancedPDFProcessingService { private static instance: EnhancedPDFProcessingService; @@ -183,7 +181,7 @@ export class EnhancedPDFProcessingService { state: ProcessingState ): Promise { const arrayBuffer = await file.arrayBuffer(); - const pdf = await getDocument({ data: arrayBuffer }).promise; + const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const totalPages = pdf.numPages; state.progress = 10; @@ -194,7 +192,7 @@ export class EnhancedPDFProcessingService { for (let i = 1; i <= totalPages; i++) { // Check for cancellation if (state.cancellationToken?.signal.aborted) { - pdf.destroy(); + pdfWorkerManager.destroyDocument(pdf); throw new Error('Processing cancelled'); } @@ -215,7 +213,7 @@ export class EnhancedPDFProcessingService { this.notifyListeners(); } - pdf.destroy(); + pdfWorkerManager.destroyDocument(pdf); state.progress = 100; this.notifyListeners(); @@ -231,7 +229,7 @@ export class EnhancedPDFProcessingService { state: ProcessingState ): Promise { const arrayBuffer = await file.arrayBuffer(); - const pdf = await getDocument({ data: arrayBuffer }).promise; + const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const totalPages = pdf.numPages; state.progress = 10; @@ -243,7 +241,7 @@ export class EnhancedPDFProcessingService { // Process priority pages first for (let i = 1; i <= priorityCount; i++) { if (state.cancellationToken?.signal.aborted) { - pdf.destroy(); + pdfWorkerManager.destroyDocument(pdf); throw new Error('Processing cancelled'); } @@ -274,7 +272,7 @@ export class EnhancedPDFProcessingService { }); } - pdf.destroy(); + pdfWorkerManager.destroyDocument(pdf); state.progress = 100; this.notifyListeners(); @@ -290,7 +288,7 @@ export class EnhancedPDFProcessingService { state: ProcessingState ): Promise { const arrayBuffer = await file.arrayBuffer(); - const pdf = await getDocument({ data: arrayBuffer }).promise; + const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const totalPages = pdf.numPages; state.progress = 10; @@ -305,7 +303,7 @@ export class EnhancedPDFProcessingService { for (let i = 1; i <= firstChunkEnd; i++) { if (state.cancellationToken?.signal.aborted) { - pdf.destroy(); + pdfWorkerManager.destroyDocument(pdf); throw new Error('Processing cancelled'); } @@ -342,7 +340,7 @@ export class EnhancedPDFProcessingService { }); } - pdf.destroy(); + pdfWorkerManager.destroyDocument(pdf); state.progress = 100; this.notifyListeners(); @@ -358,7 +356,7 @@ export class EnhancedPDFProcessingService { state: ProcessingState ): Promise { const arrayBuffer = await file.arrayBuffer(); - const pdf = await getDocument({ data: arrayBuffer }).promise; + const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const totalPages = pdf.numPages; state.progress = 50; @@ -376,7 +374,7 @@ export class EnhancedPDFProcessingService { }); } - pdf.destroy(); + pdfWorkerManager.destroyDocument(pdf); state.progress = 100; this.notifyListeners(); @@ -540,6 +538,15 @@ export class EnhancedPDFProcessingService { this.processing.clear(); this.notifyListeners(); } + + /** + * Emergency cleanup - destroy all PDF workers + */ + emergencyCleanup(): void { + this.clearAllProcessing(); + this.clearAll(); + pdfWorkerManager.destroyAllDocuments(); + } } // Export singleton instance diff --git a/frontend/src/services/fileProcessingService.ts b/frontend/src/services/fileProcessingService.ts index fe87a6ac8..93630a5c6 100644 --- a/frontend/src/services/fileProcessingService.ts +++ b/frontend/src/services/fileProcessingService.ts @@ -4,8 +4,9 @@ * 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 { pdfWorkerManager } from './pdfWorkerManager'; export interface ProcessedFileMetadata { totalPages: number; @@ -90,17 +91,16 @@ class FileProcessingService { // Discover page count using PDF.js (most accurate) try { - const pdfDoc = await getDocument({ - data: arrayBuffer, + const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, { disableAutoFetch: true, disableStream: true - }).promise; + }); totalPages = pdfDoc.numPages; console.log(`📁 FileProcessingService: PDF.js discovered ${totalPages} pages for ${file.name}`); // Clean up immediately - pdfDoc.destroy(); + pdfWorkerManager.destroyDocument(pdfDoc); // Check for cancellation after PDF.js processing if (abortController.signal.aborted) { @@ -204,6 +204,15 @@ class FileProcessingService { }); 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 diff --git a/frontend/src/services/pdfWorkerManager.ts b/frontend/src/services/pdfWorkerManager.ts new file mode 100644 index 000000000..bad382109 --- /dev/null +++ b/frontend/src/services/pdfWorkerManager.ts @@ -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(); + 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 { + // 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 { + 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(); \ No newline at end of file