import React, { useState, useCallback, useRef, useEffect } from "react"; import { Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon, Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container, Stack, Group, Paper, SimpleGrid } from "@mantine/core"; import { useTranslation } from "react-i18next"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { usePDFProcessor } from "../../hooks/usePDFProcessor"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { fileStorage } from "../../services/fileStorage"; import { generateThumbnailForFile } from "../../utils/thumbnailUtils"; import { useUndoRedo } from "../../hooks/useUndoRedo"; import { RotatePagesCommand, DeletePagesCommand, ReorderPageCommand, MovePagesCommand, ToggleSplitCommand } from "../../commands/pageCommands"; import { pdfExportService } from "../../services/pdfExportService"; import styles from './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; import BulkSelectionPanel from './BulkSelectionPanel'; import DragDropGrid from './shared/DragDropGrid'; import FilePickerModal from '../shared/FilePickerModal'; import FileUploadSelector from '../shared/FileUploadSelector'; export interface PageEditorProps { activeFiles: File[]; setActiveFiles: (files: File[]) => void; downloadUrl?: string | null; setDownloadUrl?: (url: string | null) => void; sharedFiles?: any[]; // For FileUploadSelector when no files loaded // Optional callbacks to expose internal functions for PageEditorControls onFunctionsReady?: (functions: { handleUndo: () => void; handleRedo: () => void; canUndo: boolean; canRedo: boolean; handleRotate: (direction: 'left' | 'right') => void; handleDelete: () => void; handleSplit: () => void; showExportPreview: (selectedOnly: boolean) => void; onExportSelected: () => void; onExportAll: () => void; exportLoading: boolean; selectionMode: boolean; selectedPages: string[]; closePdf: () => void; }) => void; } const PageEditor = ({ activeFiles, setActiveFiles, downloadUrl, setDownloadUrl, sharedFiles = [], onFunctionsReady, }: PageEditorProps) => { const { t } = useTranslation(); const { processPDFFile, loading: pdfLoading } = usePDFProcessor(); // Multi-file state const [currentFileIndex, setCurrentFileIndex] = useState(0); const [processedFiles, setProcessedFiles] = useState>(new Map()); // Current file references const currentFile = activeFiles[currentFileIndex] || null; const currentFileKey = currentFile ? `${currentFile.name}-${currentFile.size}` : null; const currentPdfDocument = currentFileKey ? processedFiles.get(currentFileKey) : null; const [filename, setFilename] = useState(""); // Page editor state const [selectedPages, setSelectedPages] = useState([]); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [csvInput, setCsvInput] = useState(""); const [selectionMode, setSelectionMode] = useState(false); // Drag and drop state const [draggedPage, setDraggedPage] = useState(null); const [dropTarget, setDropTarget] = useState(null); const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null); const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); // Export state const [exportLoading, setExportLoading] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); // Animation state const [movingPage, setMovingPage] = useState(null); const [pagePositions, setPagePositions] = useState>(new Map()); const [isAnimating, setIsAnimating] = useState(false); const pageRefs = useRef>(new Map()); const fileInputRef = useRef<() => void>(null); // Undo/Redo system const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); // Process uploaded file const handleFileUpload = useCallback(async (uploadedFile: File | any) => { if (!uploadedFile) { setError('No file provided'); return; } let fileToProcess: File; // Handle FileWithUrl objects from storage if (uploadedFile.storedInIndexedDB && uploadedFile.arrayBuffer) { try { console.log('Converting FileWithUrl to File:', uploadedFile.name); const arrayBuffer = await uploadedFile.arrayBuffer(); const blob = new Blob([arrayBuffer], { type: uploadedFile.type || 'application/pdf' }); fileToProcess = new File([blob], uploadedFile.name, { type: uploadedFile.type || 'application/pdf', lastModified: uploadedFile.lastModified || Date.now() }); } catch (error) { console.error('Error converting FileWithUrl:', error); setError('Unable to load file from storage'); return; } } else if (uploadedFile instanceof File) { fileToProcess = uploadedFile; } else { setError('Invalid file object'); console.error('handleFileUpload received unsupported object:', uploadedFile); return; } if (fileToProcess.type !== 'application/pdf') { setError('Please upload a valid PDF file'); return; } const fileKey = `${fileToProcess.name}-${fileToProcess.size}`; // Skip if already processed if (processedFiles.has(fileKey)) return; setLoading(true); setError(null); try { const document = await processPDFFile(fileToProcess); // Store processed document setProcessedFiles(prev => new Map(prev).set(fileKey, document)); setFilename(fileToProcess.name.replace(/\.pdf$/i, '')); setSelectedPages([]); // Add to activeFiles if not already there if (!activeFiles.some(f => f.name === fileToProcess.name && f.size === fileToProcess.size)) { setActiveFiles([...activeFiles, fileToProcess]); } if (document.pages.length > 0) { // Only store if it's a new file (not from storage) if (!uploadedFile.storedInIndexedDB) { const thumbnail = await generateThumbnailForFile(fileToProcess); await fileStorage.storeFile(fileToProcess, thumbnail); } } setStatus(`PDF loaded successfully with ${document.totalPages} pages`); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF'; setError(errorMessage); console.error('PDF processing error:', err); } finally { setLoading(false); } }, [processPDFFile, activeFiles, setActiveFiles, processedFiles]); // Auto-process files from activeFiles useEffect(() => { activeFiles.forEach(file => { const fileKey = `${file.name}-${file.size}`; if (!processedFiles.has(fileKey)) { handleFileUpload(file); } }); }, [activeFiles, processedFiles, handleFileUpload]); // Reset current file index when activeFiles changes useEffect(() => { if (currentFileIndex >= activeFiles.length) { setCurrentFileIndex(0); } }, [activeFiles.length, currentFileIndex]); // Clear selections when switching files useEffect(() => { setSelectedPages([]); setCsvInput(""); setSelectionMode(false); }, [currentFileIndex]); // Update filename when current file changes useEffect(() => { if (currentFile) { setFilename(currentFile.name.replace(/\.pdf$/i, '')); } }, [currentFile]); // Global drag cleanup to handle drops outside valid areas useEffect(() => { const handleGlobalDragEnd = () => { // Clean up drag state when drag operation ends anywhere setDraggedPage(null); setDropTarget(null); setMultiPageDrag(null); setDragPosition(null); }; const handleGlobalDrop = (e: DragEvent) => { // Prevent default to avoid browser navigation on invalid drops e.preventDefault(); }; if (draggedPage) { document.addEventListener('dragend', handleGlobalDragEnd); document.addEventListener('drop', handleGlobalDrop); } return () => { document.removeEventListener('dragend', handleGlobalDragEnd); document.removeEventListener('drop', handleGlobalDrop); }; }, [draggedPage]); const selectAll = useCallback(() => { if (currentPdfDocument) { setSelectedPages(currentPdfDocument.pages.map(p => p.id)); } }, [currentPdfDocument]); const deselectAll = useCallback(() => setSelectedPages([]), []); const togglePage = useCallback((pageId: string) => { setSelectedPages(prev => prev.includes(pageId) ? prev.filter(id => id !== pageId) : [...prev, pageId] ); }, []); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { // Clear selections when exiting selection mode setSelectedPages([]); setCsvInput(""); } return newMode; }); }, []); const parseCSVInput = useCallback((csv: string) => { if (!currentPdfDocument) return []; const pageIds: string[] = []; const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); ranges.forEach(range => { if (range.includes('-')) { const [start, end] = range.split('-').map(n => parseInt(n.trim())); for (let i = start; i <= end && i <= currentPdfDocument.totalPages; i++) { if (i > 0) { const page = currentPdfDocument.pages.find(p => p.pageNumber === i); if (page) pageIds.push(page.id); } } } else { const pageNum = parseInt(range); if (pageNum > 0 && pageNum <= currentPdfDocument.totalPages) { const page = currentPdfDocument.pages.find(p => p.pageNumber === pageNum); if (page) pageIds.push(page.id); } } }); return pageIds; }, [currentPdfDocument]); const updatePagesFromCSV = useCallback(() => { const pageIds = parseCSVInput(csvInput); setSelectedPages(pageIds); }, [csvInput, parseCSVInput]); const handleDragStart = useCallback((pageId: string) => { setDraggedPage(pageId); // Check if this is a multi-page drag in selection mode if (selectionMode && selectedPages.includes(pageId) && selectedPages.length > 1) { setMultiPageDrag({ pageIds: selectedPages, count: selectedPages.length }); } else { setMultiPageDrag(null); } }, [selectionMode, selectedPages]); const handleDragEnd = useCallback(() => { // Clean up drag state regardless of where the drop happened setDraggedPage(null); setDropTarget(null); setMultiPageDrag(null); setDragPosition(null); }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); if (!draggedPage) return; // Update drag position for multi-page indicator if (multiPageDrag) { setDragPosition({ x: e.clientX, y: e.clientY }); } // Get the element under the mouse cursor const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); if (!elementUnderCursor) return; // Find the closest page container const pageContainer = elementUnderCursor.closest('[data-page-id]'); if (pageContainer) { const pageId = pageContainer.getAttribute('data-page-id'); if (pageId && pageId !== draggedPage) { setDropTarget(pageId); return; } } // Check if over the end zone const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); if (endZone) { setDropTarget('end'); return; } // If not over any valid drop target, clear it setDropTarget(null); }, [draggedPage, multiPageDrag]); const handleDragEnter = useCallback((pageId: string) => { if (draggedPage && pageId !== draggedPage) { setDropTarget(pageId); } }, [draggedPage]); const handleDragLeave = useCallback(() => { // Don't clear drop target on drag leave - let dragover handle it }, []); // Create setPdfDocument wrapper for current file const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { if (currentFileKey) { setProcessedFiles(prev => new Map(prev).set(currentFileKey, updatedDoc)); } }, [currentFileKey]); const animateReorder = useCallback((pageId: string, targetIndex: number) => { if (!currentPdfDocument || isAnimating) return; // In selection mode, if the dragged page is selected, move all selected pages const pagesToMove = selectionMode && selectedPages.includes(pageId) ? selectedPages : [pageId]; const originalIndex = currentPdfDocument.pages.findIndex(p => p.id === pageId); if (originalIndex === -1 || originalIndex === targetIndex) return; setIsAnimating(true); // Get current positions of all pages const currentPositions = new Map(); currentPdfDocument.pages.forEach((page) => { const element = pageRefs.current.get(page.id); if (element) { const rect = element.getBoundingClientRect(); currentPositions.set(page.id, { x: rect.left, y: rect.top }); } }); // Execute the reorder - for multi-page, we use a different command if (pagesToMove.length > 1) { // Multi-page move - use MovePagesCommand const command = new MovePagesCommand(currentPdfDocument, setPdfDocument, pagesToMove, targetIndex); executeCommand(command); } else { // Single page move const command = new ReorderPageCommand(currentPdfDocument, setPdfDocument, pageId, targetIndex); executeCommand(command); } // Wait for DOM to update, then get new positions and animate requestAnimationFrame(() => { requestAnimationFrame(() => { const newPositions = new Map(); // Get the updated document from the state after command execution const currentDoc = currentPdfDocument; // This should be the updated version after command currentDoc.pages.forEach((page) => { const element = pageRefs.current.get(page.id); if (element) { const rect = element.getBoundingClientRect(); newPositions.set(page.id, { x: rect.left, y: rect.top }); } }); // Calculate and apply animations currentDoc.pages.forEach((page) => { const element = pageRefs.current.get(page.id); const currentPos = currentPositions.get(page.id); const newPos = newPositions.get(page.id); if (element && currentPos && newPos) { const deltaX = currentPos.x - newPos.x; const deltaY = currentPos.y - newPos.y; // Apply initial transform (from new position back to old position) element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; element.style.transition = 'none'; // Force reflow element.offsetHeight; // Animate to final position element.style.transition = 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; element.style.transform = 'translate(0px, 0px)'; } }); // Clean up after animation setTimeout(() => { currentDoc.pages.forEach((page) => { const element = pageRefs.current.get(page.id); if (element) { element.style.transform = ''; element.style.transition = ''; } }); setIsAnimating(false); }, 400); }); }); }, [currentPdfDocument, isAnimating, executeCommand, selectionMode, selectedPages, setPdfDocument]); const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => { e.preventDefault(); if (!draggedPage || !currentPdfDocument || draggedPage === targetPageId) return; let targetIndex: number; if (targetPageId === 'end') { targetIndex = currentPdfDocument.pages.length; } else { targetIndex = currentPdfDocument.pages.findIndex(p => p.id === targetPageId); if (targetIndex === -1) return; } animateReorder(draggedPage, targetIndex); setDraggedPage(null); setDropTarget(null); setMultiPageDrag(null); setDragPosition(null); const moveCount = multiPageDrag ? multiPageDrag.count : 1; setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); }, [draggedPage, currentPdfDocument, animateReorder, multiPageDrag]); const handleEndZoneDragEnter = useCallback(() => { if (draggedPage) { setDropTarget('end'); } }, [draggedPage]); const handleRotate = useCallback((direction: 'left' | 'right') => { if (!currentPdfDocument) return; const rotation = direction === 'left' ? -90 : 90; const pagesToRotate = selectionMode ? selectedPages : currentPdfDocument.pages.map(p => p.id); if (selectionMode && selectedPages.length === 0) return; const command = new RotatePagesCommand( currentPdfDocument, setPdfDocument, pagesToRotate, rotation ); executeCommand(command); const pageCount = selectionMode ? selectedPages.length : currentPdfDocument.pages.length; setStatus(`Rotated ${pageCount} pages ${direction}`); }, [currentPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); const handleDelete = useCallback(() => { if (!currentPdfDocument) return; const pagesToDelete = selectionMode ? selectedPages : currentPdfDocument.pages.map(p => p.id); if (selectionMode && selectedPages.length === 0) return; const command = new DeletePagesCommand( currentPdfDocument, setPdfDocument, pagesToDelete ); executeCommand(command); if (selectionMode) { setSelectedPages([]); } const pageCount = selectionMode ? selectedPages.length : currentPdfDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); }, [currentPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); const handleSplit = useCallback(() => { if (!currentPdfDocument) return; const pagesToSplit = selectionMode ? selectedPages : currentPdfDocument.pages.map(p => p.id); if (selectionMode && selectedPages.length === 0) return; const command = new ToggleSplitCommand( currentPdfDocument, setPdfDocument, pagesToSplit ); executeCommand(command); const pageCount = selectionMode ? selectedPages.length : currentPdfDocument.pages.length; setStatus(`Split markers toggled for ${pageCount} pages`); }, [currentPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]); const showExportPreview = useCallback((selectedOnly: boolean = false) => { if (!currentPdfDocument) return; const exportPageIds = selectedOnly ? selectedPages : []; const preview = pdfExportService.getExportInfo(currentPdfDocument, exportPageIds, selectedOnly); setExportPreview(preview); setShowExportModal(true); }, [currentPdfDocument, selectedPages]); const handleExport = useCallback(async (selectedOnly: boolean = false) => { if (!currentPdfDocument) return; setExportLoading(true); try { const exportPageIds = selectedOnly ? selectedPages : []; const errors = pdfExportService.validateExport(currentPdfDocument, exportPageIds, selectedOnly); if (errors.length > 0) { setError(errors.join(', ')); return; } const hasSplitMarkers = currentPdfDocument.pages.some(page => page.splitBefore); if (hasSplitMarkers) { const result = await pdfExportService.exportPDF(currentPdfDocument, exportPageIds, { selectedOnly, filename, splitDocuments: true }) as { blobs: Blob[]; filenames: string[] }; result.blobs.forEach((blob, index) => { setTimeout(() => { pdfExportService.downloadFile(blob, result.filenames[index]); }, index * 500); }); setStatus(`Exported ${result.blobs.length} split documents`); } else { const result = await pdfExportService.exportPDF(currentPdfDocument, exportPageIds, { selectedOnly, filename }) as { blob: Blob; filename: string }; pdfExportService.downloadFile(result.blob, result.filename); setStatus('PDF exported successfully'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Export failed'; setError(errorMessage); } finally { setExportLoading(false); } }, [currentPdfDocument, selectedPages, filename]); const handleUndo = useCallback(() => { if (undo()) { setStatus('Operation undone'); } }, [undo]); const handleRedo = useCallback(() => { if (redo()) { setStatus('Operation redone'); } }, [redo]); const closePdf = useCallback(() => { setCurrentFileIndex(0); setActiveFiles([]); setProcessedFiles(new Map()); setSelectedPages([]); }, [setActiveFiles]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); // Expose functions to parent component for PageEditorControls useEffect(() => { if (onFunctionsReady) { onFunctionsReady({ handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, selectedPages, closePdf, }); } }, [ onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, selectedPages, closePdf ]); if (!currentPdfDocument) { return ( ); } return ( {/* File Switcher Tabs */} {activeFiles.length > 1 && ( {activeFiles.map((file, index) => { const isActive = index === currentFileIndex; return ( ); })} )} setFilename(e.target.value)} placeholder="Enter filename" style={{ minWidth: 200 }} /> {selectionMode && ( <> )} {selectionMode && ( )} ( )} renderSplitMarker={(page, index) => (
)} /> setShowExportModal(false)} title="Export Preview" > {exportPreview && ( Pages to export: {exportPreview.pageCount} {exportPreview.splitCount > 1 && ( Split into documents: {exportPreview.splitCount} )} Estimated size: {exportPreview.estimatedSize} {currentPdfDocument && currentPdfDocument.pages.some(p => p.splitBefore) && ( This will create multiple PDF files based on split markers. )} )} file && handleFileUpload(file)} style={{ display: 'none' }} /> {status && ( setStatus(null)} style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} > {status} )} {error && ( setError(null)} style={{ position: 'fixed', bottom: 70, right: 20, zIndex: 1000 }} > {error} )} ); }; export default PageEditor;