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 { Dropzone } from "@mantine/dropzone"; import { useTranslation } from "react-i18next"; import UndoIcon from "@mui/icons-material/Undo"; import RedoIcon from "@mui/icons-material/Redo"; import AddIcon from "@mui/icons-material/Add"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import DownloadIcon from "@mui/icons-material/Download"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; import DeleteIcon from "@mui/icons-material/Delete"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import ConstructionIcon from "@mui/icons-material/Construction"; import EventListIcon from "@mui/icons-material/EventList"; import DeselectIcon from "@mui/icons-material/Deselect"; import SelectAllIcon from "@mui/icons-material/SelectAll"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import CloseIcon from "@mui/icons-material/Close"; 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"; export interface PageEditorProps { file: { file: File; url: string } | null; setFile?: (file: { file: File; url: string } | null) => void; downloadUrl?: string | null; setDownloadUrl?: (url: string | null) => void; } const PageEditor: React.FC = ({ file, setFile, downloadUrl, setDownloadUrl, }) => { const { t } = useTranslation(); const { processPDFFile, loading: pdfLoading } = usePDFProcessor(); const [pdfDocument, setPdfDocument] = useState(null); 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); const [filename, setFilename] = useState(""); 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); const [exportLoading, setExportLoading] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); 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) => { if (!uploadedFile || uploadedFile.type !== 'application/pdf') { setError('Please upload a valid PDF file'); return; } setLoading(true); setError(null); try { const document = await processPDFFile(uploadedFile); setPdfDocument(document); setFilename(uploadedFile.name.replace(/\.pdf$/i, '')); setSelectedPages([]); if (document.pages.length > 0) { const thumbnail = await generateThumbnailForFile(uploadedFile); await fileStorage.storeFile(uploadedFile, thumbnail); } if (setFile) { const fileUrl = URL.createObjectURL(uploadedFile); setFile({ file: uploadedFile, url: fileUrl }); } 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, setFile]); useEffect(() => { if (file?.file && !pdfDocument) { handleFileUpload(file.file); } }, [file, pdfDocument, handleFileUpload]); // 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 (pdfDocument) { setSelectedPages(pdfDocument.pages.map(p => p.id)); } }, [pdfDocument]); 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 (!pdfDocument) 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 <= pdfDocument.totalPages; i++) { if (i > 0) { const page = pdfDocument.pages.find(p => p.pageNumber === i); if (page) pageIds.push(page.id); } } } else { const pageNum = parseInt(range); if (pageNum > 0 && pageNum <= pdfDocument.totalPages) { const page = pdfDocument.pages.find(p => p.pageNumber === pageNum); if (page) pageIds.push(page.id); } } }); return pageIds; }, [pdfDocument]); 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 }, []); const animateReorder = useCallback((pageId: string, targetIndex: number) => { if (!pdfDocument || 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 = pdfDocument.pages.findIndex(p => p.id === pageId); if (originalIndex === -1 || originalIndex === targetIndex) return; setIsAnimating(true); // Get current positions of all pages const currentPositions = new Map(); pdfDocument.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(pdfDocument, setPdfDocument, pagesToMove, targetIndex); executeCommand(command); } else { // Single page move const command = new ReorderPageCommand(pdfDocument, 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 // The command has already updated the document, so we need to get the new order const currentDoc = pdfDocument; // 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); }); }); }, [pdfDocument, isAnimating, executeCommand, selectionMode, selectedPages]); const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => { e.preventDefault(); if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return; let targetIndex: number; if (targetPageId === 'end') { targetIndex = pdfDocument.pages.length; } else { targetIndex = pdfDocument.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, pdfDocument, animateReorder, multiPageDrag]); const handleEndZoneDragEnter = useCallback(() => { if (draggedPage) { setDropTarget('end'); } }, [draggedPage]); const handleRotate = useCallback((direction: 'left' | 'right') => { if (!pdfDocument) return; const rotation = direction === 'left' ? -90 : 90; const pagesToRotate = selectionMode ? selectedPages : pdfDocument.pages.map(p => p.id); if (selectionMode && selectedPages.length === 0) return; const command = new RotatePagesCommand( pdfDocument, setPdfDocument, pagesToRotate, rotation ); executeCommand(command); const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length; setStatus(`Rotated ${pageCount} pages ${direction}`); }, [pdfDocument, selectedPages, selectionMode, executeCommand]); const handleDelete = useCallback(() => { if (!pdfDocument) return; const pagesToDelete = selectionMode ? selectedPages : pdfDocument.pages.map(p => p.id); if (selectionMode && selectedPages.length === 0) return; const command = new DeletePagesCommand( pdfDocument, setPdfDocument, pagesToDelete ); executeCommand(command); if (selectionMode) { setSelectedPages([]); } const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); }, [pdfDocument, selectedPages, selectionMode, executeCommand]); const handleSplit = useCallback(() => { if (!pdfDocument) return; const pagesToSplit = selectionMode ? selectedPages : pdfDocument.pages.map(p => p.id); if (selectionMode && selectedPages.length === 0) return; const command = new ToggleSplitCommand( pdfDocument, setPdfDocument, pagesToSplit ); executeCommand(command); const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length; setStatus(`Split markers toggled for ${pageCount} pages`); }, [pdfDocument, selectedPages, selectionMode, executeCommand]); const showExportPreview = useCallback((selectedOnly: boolean = false) => { if (!pdfDocument) return; const exportPageIds = selectedOnly ? selectedPages : []; const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly); setExportPreview(preview); setShowExportModal(true); }, [pdfDocument, selectedPages]); const handleExport = useCallback(async (selectedOnly: boolean = false) => { if (!pdfDocument) return; setExportLoading(true); try { const exportPageIds = selectedOnly ? selectedPages : []; const errors = pdfExportService.validateExport(pdfDocument, exportPageIds, selectedOnly); if (errors.length > 0) { setError(errors.join(', ')); return; } const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore); if (hasSplitMarkers) { const result = await pdfExportService.exportPDF(pdfDocument, 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(pdfDocument, 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); } }, [pdfDocument, selectedPages, filename]); const handleUndo = useCallback(() => { if (undo()) { setStatus('Operation undone'); } }, [undo]); const handleRedo = useCallback(() => { if (redo()) { setStatus('Operation redone'); } }, [redo]); if (!pdfDocument) { return ( files[0] && handleFileUpload(files[0])} accept={["application/pdf"]} multiple={false} h="60vh" style={{ minHeight: 400 }} >
Drop a PDF file here or click to upload Supports PDF files only
); } return ( setFilename(e.target.value)} placeholder="Enter filename" style={{ minWidth: 200 }} /> {selectionMode && ( <> )} {selectionMode && ( setCsvInput(e.target.value)} placeholder="1,3,5-10" label="Page Selection" onBlur={updatePagesFromCSV} onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()} style={{ flex: 1 }} /> {selectedPages.length > 0 && ( Selected: {selectedPages.length} pages )} )}
{pdfDocument.pages.map((page, index) => ( {page.splitBefore && index > 0 && (
)}
{ if (el) { pageRefs.current.set(page.id, el); } else { pageRefs.current.delete(page.id); } }} data-page-id={page.id} className={` !rounded-lg cursor-grab select-none w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${selectionMode ? 'bg-white hover:bg-gray-50' : 'bg-white hover:bg-gray-50'} ${draggedPage === page.id ? 'opacity-50 scale-95' : ''} ${movingPage === page.id ? 'page-moving' : ''} `} style={{ transform: (() => { // Only apply drop target indication during drag if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) { return 'translateX(20px)'; } return 'translateX(0)'; })(), transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' }} draggable onDragStart={() => handleDragStart(page.id)} onDragEnd={handleDragEnd} onDragOver={handleDragOver} onDragEnter={() => handleDragEnter(page.id)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, page.id)} > {/* Selection mode checkbox - positioned outside page-container to avoid transform inheritance */} {selectionMode && (
{ e.stopPropagation(); // Prevent drag from starting }} onDragStart={(e) => { e.preventDefault(); // Prevent drag on checkbox e.stopPropagation(); }} > { event.stopPropagation(); togglePage(page.id); }} onClick={(e) => { e.stopPropagation(); }} size="sm" />
)}
{`Page {/* Page number overlay - shows on hover */} {page.pageNumber} {/* Hover controls */}
{ e.stopPropagation(); if (index > 0 && !movingPage && !isAnimating) { setMovingPage(page.id); animateReorder(page.id, index - 1); setTimeout(() => setMovingPage(null), 500); setStatus(`Moved page ${page.pageNumber} left`); } }} > { e.stopPropagation(); if (index < pdfDocument.pages.length - 1 && !movingPage && !isAnimating) { setMovingPage(page.id); animateReorder(page.id, index + 1); setTimeout(() => setMovingPage(null), 500); setStatus(`Moved page ${page.pageNumber} right`); } }} > { e.stopPropagation(); const command = new RotatePagesCommand( pdfDocument, setPdfDocument, [page.id], -90 ); executeCommand(command); setStatus(`Rotated page ${page.pageNumber} left`); }} > { e.stopPropagation(); const command = new RotatePagesCommand( pdfDocument, setPdfDocument, [page.id], 90 ); executeCommand(command); setStatus(`Rotated page ${page.pageNumber} right`); }} > { e.stopPropagation(); const command = new DeletePagesCommand( pdfDocument, setPdfDocument, [page.id] ); executeCommand(command); setStatus(`Deleted page ${page.pageNumber}`); }} > {index > 0 && ( { e.stopPropagation(); const command = new ToggleSplitCommand( pdfDocument, setPdfDocument, [page.id] ); executeCommand(command); setStatus(`Split marker toggled for page ${page.pageNumber}`); }} > )}
))} {/* Landing zone at the end */}
handleDrop(e, 'end')} > Drop here to
move to end
{/* Floating control bar */}
{/* Close PDF */} { setPdfDocument(null); setFile && setFile(null); }} color="red" variant="light" size="lg" >
{/* Undo/Redo */}
{/* Page Operations */} handleRotate('left')} disabled={selectionMode && selectedPages.length === 0} variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} size="lg" > handleRotate('right')} disabled={selectionMode && selectedPages.length === 0} variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} size="lg" > 0 ? "light" : "default"} size="lg" > 0 ? "light" : "default"} color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} size="lg" >
{/* Export Controls */} {selectionMode && selectedPages.length > 0 && ( showExportPreview(true)} disabled={exportLoading} color="blue" variant="light" size="lg" > )} showExportPreview(false)} disabled={exportLoading} color="green" variant="light" size="lg" >
setShowExportModal(false)} title="Export Preview" > {exportPreview && ( Pages to export: {exportPreview.pageCount} {exportPreview.splitCount > 1 && ( Split into documents: {exportPreview.splitCount} )} Estimated size: {exportPreview.estimatedSize} {pdfDocument && pdfDocument.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} )} {/* Multi-page drag indicator */} {multiPageDrag && dragPosition && (
{multiPageDrag.count} pages
)} ); }; export default PageEditor;