import React, { useState, useCallback, useRef, useEffect } from 'react'; import { Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container, Stack, Group } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import { useFileContext, useToolFileSelection, useProcessedFiles, useFileState, useFileManagement } from '../../contexts/FileContext'; import { FileOperation, createStableFileId } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; 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'; interface FileItem { id: string; name: string; pageCount: number; thumbnail: string; size: number; file: File; splitBefore?: boolean; } interface FileEditorProps { onOpenPageEditor?: (file: File) => void; onMergeFiles?: (files: File[]) => void; toolMode?: boolean; showUpload?: boolean; showBulkActions?: boolean; supportedExtensions?: string[]; } const FileEditor = ({ onOpenPageEditor, onMergeFiles, toolMode = false, showUpload = true, showBulkActions = true, supportedExtensions = ["pdf"] }: FileEditorProps) => { const { t } = useTranslation(); // Utility function to check if a file extension is supported const isFileSupported = useCallback((fileName: string): boolean => { const extension = detectFileExtension(fileName); return extension ? supportedExtensions.includes(extension) : false; }, [supportedExtensions]); // Use optimized FileContext hooks const { state } = useFileState(); const { addFiles, removeFiles } = useFileManagement(); const processedFiles = useProcessedFiles(); // Now gets real processed files // Extract needed values from state const activeFiles = state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean); const selectedFileIds = state.ui.selectedFileIds; const isProcessing = state.ui.isProcessing; // Legacy compatibility for existing code const setContextSelectedFiles = (fileIds: string[]) => { // This function is used for FileEditor's own selection, not tool selection console.log('FileEditor setContextSelectedFiles called with:', fileIds); }; const setCurrentView = (mode: any) => { // Will be handled by parent component actions console.log('FileEditor setCurrentView called with:', mode); }; // Get tool file selection context (replaces FileSelectionContext) const { selectedFiles: toolSelectedFiles, setSelectedFiles: setToolSelectedFiles, maxFiles, isToolMode } = useToolFileSelection(); const [files, setFiles] = useState([]); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [localLoading, setLocalLoading] = useState(false); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode React.useEffect(() => { if (toolMode) { 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<{ isExtracting: boolean; currentFile: string; progress: number; extractedCount: number; totalFiles: number; }>({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); const fileRefs = useRef>(new Map()); const lastActiveFilesRef = useRef([]); const lastProcessedFilesRef = useRef(0); // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; // Map context selections to local file IDs for UI display const localSelectedIds = files .filter(file => { const contextFileId = createStableFileId(file.file); return contextSelectedIds.includes(contextFileId); }) .map(file => file.id); // Convert shared files to FileEditor format const convertToFileItem = useCallback(async (sharedFile: any): Promise => { // Generate thumbnail if not already available const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile); return { id: sharedFile.id || `file-${Date.now()}-${Math.random()}`, name: (sharedFile.file?.name || sharedFile.name || 'unknown'), pageCount: sharedFile.pageCount || 1, // Default to 1 page if unknown thumbnail, size: sharedFile.file?.size || sharedFile.size || 0, file: sharedFile.file || sharedFile, }; }, []); // Convert activeFiles to FileItem format using context (async to avoid blocking) useEffect(() => { // Check if the actual content has changed, not just references const currentActiveFileNames = activeFiles.map(f => f.name); const currentProcessedFilesSize = processedFiles.size; const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current); const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current; if (!activeFilesChanged && !processedFilesChanged) { return; } // Update refs lastActiveFilesRef.current = currentActiveFileNames; lastProcessedFilesRef.current = currentProcessedFilesSize; const convertActiveFiles = async () => { if (activeFiles.length > 0) { setLocalLoading(true); try { // Process files in chunks to avoid blocking UI const convertedFiles: FileItem[] = []; for (let i = 0; i < activeFiles.length; i++) { const file = activeFiles[i]; // Try to get thumbnail from processed file first const processedFile = processedFiles.get(file); let thumbnail = processedFile?.pages?.[0]?.thumbnail; // If no thumbnail from processed file, try to generate one if (!thumbnail) { try { thumbnail = await generateThumbnailForFile(file); } catch (error) { console.warn(`Failed to generate thumbnail for ${file.name}:`, error); thumbnail = undefined; // Use placeholder } } // Get actual page count from processed file let pageCount = 1; // Default for non-PDFs if (processedFile) { pageCount = processedFile.pages?.length || processedFile.totalPages || 1; } else if (file.type === 'application/pdf') { // For PDFs without processed data, try to get a quick page count estimate // If processing is taking too long, show a reasonable default try { // Quick and dirty page count using PDF structure analysis const arrayBuffer = await file.arrayBuffer(); const text = new TextDecoder('latin1').decode(arrayBuffer); const pageMatches = text.match(/\/Type\s*\/Page[^s]/g); pageCount = pageMatches ? pageMatches.length : 1; console.log(`📄 Quick page count for ${file.name}: ${pageCount} pages (estimated)`); } catch (error) { console.warn(`Failed to estimate page count for ${file.name}:`, error); pageCount = 1; // Safe fallback } } const convertedFile = { id: createStableFileId(file), // Use same ID function as context name: file.name, pageCount: pageCount, thumbnail, size: file.size, file, }; convertedFiles.push(convertedFile); // Update progress setConversionProgress(((i + 1) / activeFiles.length) * 100); // Yield to main thread between files if (i < activeFiles.length - 1) { await new Promise(resolve => requestAnimationFrame(resolve)); } } setFiles(convertedFiles); } catch (err) { console.error('Error converting active files:', err); } finally { setLocalLoading(false); setConversionProgress(0); } } else { setFiles([]); setLocalLoading(false); setConversionProgress(0); } }; convertActiveFiles(); }, [activeFiles, processedFiles]); // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { setError(null); try { const allExtractedFiles: File[] = []; const errors: string[] = []; for (const file of uploadedFiles) { if (file.type === 'application/pdf') { // Handle PDF files normally allExtractedFiles.push(file); } else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { // Handle ZIP files - only expand if they contain PDFs try { // Validate ZIP file first const validation = await zipFileService.validateZipFile(file); if (validation.isValid && validation.containsPDFs) { // ZIP contains PDFs - extract them setZipExtractionProgress({ isExtracting: true, currentFile: file.name, progress: 0, extractedCount: 0, totalFiles: validation.fileCount }); const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { setZipExtractionProgress({ isExtracting: true, currentFile: progress.currentFile, progress: progress.progress, extractedCount: progress.extractedCount, totalFiles: progress.totalFiles }); }); // Reset extraction progress setZipExtractionProgress({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); if (extractionResult.success) { allExtractedFiles.push(...extractionResult.extractedFiles); // Record ZIP extraction operation const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, type: 'convert', timestamp: Date.now(), fileIds: extractionResult.extractedFiles.map(f => f.name), status: 'pending', metadata: { originalFileName: file.name, outputFileNames: extractionResult.extractedFiles.map(f => f.name), fileSize: file.size, parameters: { extractionType: 'zip', extractedCount: extractionResult.extractedCount, totalFiles: extractionResult.totalFiles } } }; // Legacy operation tracking removed if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); } } else { errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); } } else { // ZIP doesn't contain PDFs or is invalid - treat as regular file console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`); allExtractedFiles.push(file); } } catch (zipError) { errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); setZipExtractionProgress({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); } } else { console.log(`Adding none PDF file: ${file.name} (${file.type})`); allExtractedFiles.push(file); } } // Show any errors if (errors.length > 0) { setError(errors.join('\n')); } // Process all extracted files if (allExtractedFiles.length > 0) { // Record upload operations for PDF files for (const file of allExtractedFiles) { const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, type: 'upload', timestamp: Date.now(), fileIds: [file.name], status: 'pending', metadata: { originalFileName: file.name, fileSize: file.size, parameters: { uploadMethod: 'drag-drop' } } }; // Legacy operation tracking removed } // Add files to context (they will be processed automatically) await addFiles(allExtractedFiles); setStatus(`Added ${allExtractedFiles.length} files`); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; setError(errorMessage); console.error('File processing error:', err); // Reset extraction progress on error setZipExtractionProgress({ isExtracting: false, currentFile: '', progress: 0, extractedCount: 0, totalFiles: 0 }); } }, [addFiles]); const selectAll = useCallback(() => { setContextSelectedFiles(files.map(f => (f.file as any).id || f.name)); }, [files, setContextSelectedFiles]); const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); const closeAllFiles = useCallback(() => { if (activeFiles.length === 0) return; // Record close all operation for each file activeFiles.forEach(file => { const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, type: 'remove', timestamp: Date.now(), fileIds: [file.name], status: 'pending', metadata: { originalFileName: file.name, fileSize: file.size, parameters: { action: 'close_all', reason: 'user_request' } } }; // Legacy operation tracking removed }); // Remove all files from context but keep in storage removeFiles(activeFiles.map(f => (f as any).id || f.name), false); // Clear selections setContextSelectedFiles([]); }, [activeFiles, removeFiles, setContextSelectedFiles]); const toggleFile = useCallback((fileId: string) => { const targetFile = files.find(f => f.id === fileId); if (!targetFile) return; const contextFileId = createStableFileId(targetFile.file); const isSelected = contextSelectedIds.includes(contextFileId); let newSelection: string[]; if (isSelected) { // Remove file from selection newSelection = contextSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection if (maxFiles === 1) { newSelection = [contextFileId]; } else { // Check if we've hit the selection limit if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) { setStatus(`Maximum ${maxFiles} files can be selected`); return; } newSelection = [...contextSelectedIds, contextFileId]; } } // Update context setContextSelectedFiles(newSelection); // Update tool selection context if in tool mode if (isToolMode || toolMode) { const selectedFiles = files .filter(f => { const fId = createStableFileId(f.file); return newSelection.includes(fId); }) .map(f => f.file); setToolSelectedFiles(selectedFiles); } }, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { setContextSelectedFiles([]); } return newMode; }); }, [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; } } const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); if (endZone) { setDropTarget('end'); return; } setDropTarget(null); }, [draggedFile, multiFileDrag]); const handleDragEnter = useCallback((fileId: string) => { if (draggedFile && fileId !== draggedFile) { setDropTarget(fileId); } }, [draggedFile]); const handleDragLeave = useCallback(() => { // Let dragover handle this }, []); const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => { e.preventDefault(); if (!draggedFile || draggedFile === targetFileId) return; 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; }); 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) => { console.log('handleDeleteFile called with fileId:', fileId); const file = files.find(f => f.id === fileId); console.log('Found file:', file); if (file) { console.log('Attempting to remove file:', file.name); console.log('Actual file object:', file.file); console.log('Actual file.file.name:', file.file.name); // Record close operation const fileName = file.file.name; const fileId = (file.file as any).id || fileName; const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, type: 'remove', timestamp: Date.now(), fileIds: [fileName], status: 'pending', metadata: { originalFileName: fileName, fileSize: file.size, parameters: { action: 'close', reason: 'user_request' } } }; // Legacy operation tracking removed // Remove file from context but keep in storage (close, don't delete) console.log('Calling removeFiles with:', [fileId]); removeFiles([fileId], false); // Remove from context selections setContextSelectedFiles(prev => { const safePrev = Array.isArray(prev) ? prev : []; return safePrev.filter(id => id !== fileId); }); } else { console.log('File not found for fileId:', fileId); } }, [files, removeFiles, setContextSelectedFiles]); const handleViewFile = useCallback((fileId: string) => { const file = files.find(f => f.id === fileId); if (file) { // Set the file as selected in context and switch to viewer for preview const contextFileId = createStableFileId(file.file); setContextSelectedFiles([contextFileId]); setCurrentView('viewer'); } }, [files, setContextSelectedFiles, setCurrentView]); const handleMergeFromHere = useCallback((fileId: string) => { const startIndex = files.findIndex(f => f.id === fileId); if (startIndex === -1) return; const filesToMerge = files.slice(startIndex).map(f => f.file); if (onMergeFiles) { onMergeFiles(filesToMerge); } }, [files, onMergeFiles]); const handleSplitFile = useCallback((fileId: string) => { const file = files.find(f => f.id === fileId); if (file && onOpenPageEditor) { onOpenPageEditor(file.file); } }, [files, onOpenPageEditor]); const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => { if (selectedFiles.length === 0) return; setLocalLoading(true); try { const convertedFiles = await Promise.all( selectedFiles.map(convertToFileItem) ); setFiles(prev => [...prev, ...convertedFiles]); setStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); setError('Failed to load some files from storage'); } finally { setLocalLoading(false); } }, [convertToFileItem]); return ( {showBulkActions && !toolMode && ( <> )} {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
📁 No files loaded Upload PDF files, ZIP archives, or load from storage to get started
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? ( {/* ZIP Extraction Progress */} {zipExtractionProgress.isExtracting && ( Extracting ZIP archive... {Math.round(zipExtractionProgress.progress)}% {zipExtractionProgress.currentFile || 'Processing files...'} {zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
)} {/* Processing indicator */} {localLoading && ( Loading files... {Math.round(conversionProgress)}%
)} ) : ( ( )} renderSplitMarker={(file, index) => (
)} /> )} {/* File Picker Modal */} setShowFilePickerModal(false)} storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent onSelectFiles={handleLoadFromStorage} allowMultiple={true} /> {status && ( setStatus(null)} style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} > {status} )} {error && ( setError(null)} style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }} > {error} )} ); }; export default FileEditor;