import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon, Notification, TextInput, LoadingOverlay, Modal, Alert, Stack, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; import { ViewType, ToolType } from "../../types/fileContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { useUndoRedo } from "../../hooks/useUndoRedo"; import { RotatePagesCommand, DeletePagesCommand, ReorderPageCommand, MovePagesCommand, ToggleSplitCommand } from "../../commands/pageCommands"; import { pdfExportService } from "../../services/pdfExportService"; import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; import { fileStorage } from "../../services/fileStorage"; import './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; import BulkSelectionPanel from './BulkSelectionPanel'; import DragDropGrid from './DragDropGrid'; import SkeletonLoader from '../shared/SkeletonLoader'; import NavigationWarningModal from '../shared/NavigationWarningModal'; export interface PageEditorProps { // 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: number[]; closePdf: () => void; }) => void; } const PageEditor = ({ onFunctionsReady, }: PageEditorProps) => { const { t } = useTranslation(); // Get file context const fileContext = useFileContext(); const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile(); // Use file context state const { activeFiles, processedFiles, selectedPageNumbers, setSelectedPages, updateProcessedFile, setHasUnsavedChanges, hasUnsavedChanges, isProcessing: globalProcessing, processingProgress, clearAllFiles } = fileContext; // Edit state management const [editedDocument, setEditedDocument] = useState(null); const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false); const [foundDraft, setFoundDraft] = useState(null); const autoSaveTimer = useRef(null); // Simple computed document from processed files (no caching needed) const mergedPdfDocument = useMemo(() => { if (activeFiles.length === 0) return null; if (activeFiles.length === 1) { // Single file const processedFile = processedFiles.get(activeFiles[0]); if (!processedFile) return null; return { id: processedFile.id, name: activeFiles[0].name, file: activeFiles[0], pages: processedFile.pages.map(page => ({ ...page, rotation: page.rotation || 0, splitBefore: page.splitBefore || false })), totalPages: processedFile.totalPages }; } else { // Multiple files - merge them const allPages: PDFPage[] = []; let totalPages = 0; const filenames: string[] = []; activeFiles.forEach((file, i) => { const processedFile = processedFiles.get(file); if (processedFile) { filenames.push(file.name.replace(/\.pdf$/i, '')); processedFile.pages.forEach((page, pageIndex) => { const newPage: PDFPage = { ...page, id: `${i}-${page.id}`, // Unique ID across all files pageNumber: totalPages + pageIndex + 1, rotation: page.rotation || 0, splitBefore: page.splitBefore || false }; allPages.push(newPage); }); totalPages += processedFile.pages.length; } }); if (allPages.length === 0) return null; return { id: `merged-${Date.now()}`, name: filenames.join(' + '), file: activeFiles[0], // Use first file as reference pages: allPages, totalPages: totalPages }; } }, [activeFiles, processedFiles]); // Display document: Use edited version if exists, otherwise original const displayDocument = editedDocument || mergedPdfDocument; const [filename, setFilename] = useState(""); // Page editor state (use context for selectedPages) const [status, setStatus] = 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<{pageNumbers: number[], 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(); // Set initial filename when document changes useEffect(() => { if (mergedPdfDocument) { if (activeFiles.length === 1) { setFilename(activeFiles[0].name.replace(/\.pdf$/i, '')); } else { const filenames = activeFiles.map(f => f.name.replace(/\.pdf$/i, '')); setFilename(filenames.join('_')); } } }, [mergedPdfDocument, activeFiles]); // Handle file upload from FileUploadSelector (now using context) const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => { if (!uploadedFiles || uploadedFiles.length === 0) { setStatus('No files provided'); return; } // Add files to context await fileContext.addFiles(uploadedFiles); setStatus(`Added ${uploadedFiles.length} file(s) for processing`); }, [fileContext]); // PageEditor no longer handles cleanup - it's centralized in FileContext // Shared PDF instance for thumbnail generation const [sharedPdfInstance, setSharedPdfInstance] = useState(null); const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false); // Thumbnail generation (opt-in for visual tools) const { generateThumbnails, addThumbnailToCache, getThumbnailFromCache, stopGeneration, destroyThumbnails } = useThumbnailGeneration(); // Start thumbnail generation process (separate from document loading) const startThumbnailGeneration = useCallback(() => { console.log('🎬 PageEditor: startThumbnailGeneration called'); console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted); if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) { console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions'); return; } const file = activeFiles[0]; const totalPages = mergedPdfDocument.totalPages; console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages'); setThumbnailGenerationStarted(true); // Run everything asynchronously to avoid blocking the main thread setTimeout(async () => { try { // Load PDF array buffer for Web Workers const arrayBuffer = await file.arrayBuffer(); // Generate page numbers for pages that don't have thumbnails yet const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1) .filter(pageNum => { const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); return !page?.thumbnail; // Only generate for pages without thumbnails }); console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : ''); // If no pages need thumbnails, we're done if (pageNumbers.length === 0) { console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed'); return; } // Calculate quality scale based on file size const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2; // Start parallel thumbnail generation WITHOUT blocking the main thread const generationPromise = generateThumbnails( arrayBuffer, pageNumbers, { scale, // Dynamic quality based on file size quality: 0.8, batchSize: 15, // Smaller batches per worker for smoother UI parallelBatches: 3 // Use 3 Web Workers in parallel }, // Progress callback (throttled for better performance) (progress) => { console.log(`🎬 PageEditor: Progress - ${progress.completed}/${progress.total} pages, ${progress.thumbnails.length} new thumbnails`); // Batch process thumbnails to reduce main thread work requestAnimationFrame(() => { progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { // Check cache first, then send thumbnail const pageId = `${file.name}-page-${pageNumber}`; const cached = getThumbnailFromCache(pageId); if (!cached) { // Cache and send to component addThumbnailToCache(pageId, thumbnail); window.dispatchEvent(new CustomEvent('thumbnailReady', { detail: { pageNumber, thumbnail, pageId } })); console.log(`✓ PageEditor: Dispatched thumbnail for page ${pageNumber}`); } }); }); } ); // Handle completion properly generationPromise .then((allThumbnails) => { console.log(`✅ PageEditor: Thumbnail generation completed! Generated ${allThumbnails.length} thumbnails`); // Don't reset thumbnailGenerationStarted here - let it stay true to prevent restarts }) .catch(error => { console.error('✗ PageEditor: Web Worker thumbnail generation failed:', error); setThumbnailGenerationStarted(false); }); } catch (error) { console.error('Failed to start Web Worker thumbnail generation:', error); setThumbnailGenerationStarted(false); } }, 0); // setTimeout with 0ms to defer to next tick }, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted, getThumbnailFromCache, addThumbnailToCache]); // Start thumbnail generation after document loads useEffect(() => { console.log('🎬 PageEditor: Thumbnail generation effect triggered'); console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted); if (mergedPdfDocument && !thumbnailGenerationStarted) { // Check if ALL pages already have thumbnails from processed files const totalPages = mergedPdfDocument.pages.length; const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length; const hasAllThumbnails = pagesWithThumbnails === totalPages; console.log('🎬 PageEditor: Thumbnail status:', { totalPages, pagesWithThumbnails, hasAllThumbnails, missingThumbnails: totalPages - pagesWithThumbnails }); if (hasAllThumbnails) { console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist'); return; // Skip generation if ALL thumbnails already exist } console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation'); // Small delay to let document render, then start thumbnail generation console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms'); const timer = setTimeout(startThumbnailGeneration, 500); return () => clearTimeout(timer); } }, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]); // Cleanup shared PDF instance when component unmounts (but preserve cache) useEffect(() => { return () => { if (sharedPdfInstance) { sharedPdfInstance.destroy(); setSharedPdfInstance(null); } setThumbnailGenerationStarted(false); // DON'T stop generation on file changes - preserve cache for view switching // stopGeneration(); }; }, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles // Clear selections when files change useEffect(() => { setSelectedPages([]); setCsvInput(""); setSelectionMode(false); }, [activeFiles, setSelectedPages]); // Sync csvInput with selectedPageNumbers changes useEffect(() => { // Simply sort the page numbers and join them const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b); const newCsvInput = sortedPageNumbers.join(', '); setCsvInput(newCsvInput); }, [selectedPageNumbers]); 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 handle 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 (mergedPdfDocument) { setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); } }, [mergedPdfDocument, setSelectedPages]); const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]); const togglePage = useCallback((pageNumber: number) => { console.log('🔄 Toggling page', pageNumber); // Check if currently selected and update accordingly const isCurrentlySelected = selectedPageNumbers.includes(pageNumber); if (isCurrentlySelected) { // Remove from selection console.log('🔄 Removing page', pageNumber); const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber); setSelectedPages(newSelectedPageNumbers); } else { // Add to selection console.log('🔄 Adding page', pageNumber); const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber]; setSelectedPages(newSelectedPageNumbers); } }, [selectedPageNumbers, setSelectedPages]); 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 (!mergedPdfDocument) return []; const pageNumbers: number[] = []; 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 <= mergedPdfDocument.totalPages; i++) { if (i > 0) { pageNumbers.push(i); } } } else { const pageNum = parseInt(range); if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) { pageNumbers.push(pageNum); } } }); return pageNumbers; }, [mergedPdfDocument]); const updatePagesFromCSV = useCallback(() => { const pageNumbers = parseCSVInput(csvInput); setSelectedPages(pageNumbers); }, [csvInput, parseCSVInput, setSelectedPages]); const handleDragStart = useCallback((pageNumber: number) => { setDraggedPage(pageNumber); // Check if this is a multi-page drag in selection mode if (selectionMode && selectedPageNumbers.includes(pageNumber) && selectedPageNumbers.length > 1) { setMultiPageDrag({ pageNumbers: selectedPageNumbers, count: selectedPageNumbers.length }); } else { setMultiPageDrag(null); } }, [selectionMode, selectedPageNumbers]); 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-number]'); if (pageContainer) { const pageNumberStr = pageContainer.getAttribute('data-page-number'); const pageNumber = pageNumberStr ? parseInt(pageNumberStr) : null; if (pageNumber && pageNumber !== draggedPage) { setDropTarget(pageNumber); 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((pageNumber: number) => { if (draggedPage && pageNumber !== draggedPage) { setDropTarget(pageNumber); } }, [draggedPage]); const handleDragLeave = useCallback(() => { // Don't clear drop target on drag leave - let dragover handle it }, []); // Update PDF document state with edit tracking const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { console.log('setPdfDocument called - setting edited state'); // Update local edit state for immediate visual feedback setEditedDocument(updatedDoc); setHasUnsavedChanges(true); // Use global state setHasUnsavedDraft(true); // Mark that we have unsaved draft changes // Auto-save to drafts (debounced) - only if we have new changes if (autoSaveTimer.current) { clearTimeout(autoSaveTimer.current); } autoSaveTimer.current = setTimeout(() => { if (hasUnsavedDraft) { saveDraftToIndexedDB(updatedDoc); setHasUnsavedDraft(false); // Mark draft as saved } }, 30000); // Auto-save after 30 seconds of inactivity return updatedDoc; }, [setHasUnsavedChanges, hasUnsavedDraft]); // Save draft to separate IndexedDB location const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { try { const draftKey = `draft-${doc.id || 'merged'}`; const draftData = { document: doc, timestamp: Date.now(), originalFiles: activeFiles.map(f => f.name) }; // Save to 'pdf-drafts' store in IndexedDB const request = indexedDB.open('stirling-pdf-drafts', 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('drafts')) { db.createObjectStore('drafts'); } }; request.onsuccess = () => { const db = request.result; const transaction = db.transaction('drafts', 'readwrite'); const store = transaction.objectStore('drafts'); store.put(draftData, draftKey); console.log('Draft auto-saved to IndexedDB'); }; } catch (error) { console.warn('Failed to auto-save draft:', error); } }, [activeFiles]); // Clean up draft from IndexedDB const cleanupDraft = useCallback(async () => { try { const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; const request = indexedDB.open('stirling-pdf-drafts', 1); request.onsuccess = () => { const db = request.result; const transaction = db.transaction('drafts', 'readwrite'); const store = transaction.objectStore('drafts'); store.delete(draftKey); }; } catch (error) { console.warn('Failed to cleanup draft:', error); } }, [mergedPdfDocument]); // Apply changes to create new processed file const applyChanges = useCallback(async () => { if (!editedDocument || !mergedPdfDocument) return; try { if (activeFiles.length === 1) { const file = activeFiles[0]; const currentProcessedFile = processedFiles.get(file); if (currentProcessedFile) { const updatedProcessedFile = { ...currentProcessedFile, id: `${currentProcessedFile.id}-edited-${Date.now()}`, pages: editedDocument.pages.map(page => ({ ...page, rotation: page.rotation || 0, splitBefore: page.splitBefore || false })), totalPages: editedDocument.pages.length, lastModified: Date.now() }; updateProcessedFile(file, updatedProcessedFile); } } else if (activeFiles.length > 1) { setStatus('Apply changes for multiple files not yet supported'); return; } // Wait for the processed file update to complete before clearing edit state setTimeout(() => { setEditedDocument(null); setHasUnsavedChanges(false); setHasUnsavedDraft(false); cleanupDraft(); setStatus('Changes applied successfully'); }, 100); } catch (error) { console.error('Failed to apply changes:', error); setStatus('Failed to apply changes'); } }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, updateProcessedFile, setHasUnsavedChanges, setStatus, cleanupDraft]); const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { if (!displayDocument || isAnimating) return; // In selection mode, if the dragged page is selected, move all selected pages const pagesToMove = selectionMode && selectedPageNumbers.includes(pageNumber) ? selectedPageNumbers.map(num => { const page = displayDocument.pages.find(p => p.pageNumber === num); return page?.id || ''; }).filter(id => id) : [displayDocument.pages.find(p => p.pageNumber === pageNumber)?.id || ''].filter(id => id); const originalIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNumber); if (originalIndex === -1 || originalIndex === targetIndex) return; // Skip animation for large documents (500+ pages) to improve performance const isLargeDocument = displayDocument.pages.length > 500; if (isLargeDocument) { // For large documents, just execute the command without animation if (pagesToMove.length > 1) { const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); executeCommand(command); } else { const pageId = pagesToMove[0]; const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); executeCommand(command); } return; } setIsAnimating(true); // For smaller documents, determine which pages might be affected by the move const startIndex = Math.min(originalIndex, targetIndex); const endIndex = Math.max(originalIndex, targetIndex); const affectedPageIds = displayDocument.pages .slice(Math.max(0, startIndex - 5), Math.min(displayDocument.pages.length, endIndex + 5)) .map(p => p.id); // Only capture positions for potentially affected pages const currentPositions = new Map(); affectedPageIds.forEach(pageId => { const element = document.querySelector(`[data-page-number="${pageId}"]`); if (element) { const rect = element.getBoundingClientRect(); currentPositions.set(pageId, { x: rect.left, y: rect.top }); } }); // Execute the reorder command if (pagesToMove.length > 1) { const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); executeCommand(command); } else { const pageId = pagesToMove[0]; const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); executeCommand(command); } // Animate only the affected pages setTimeout(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { const newPositions = new Map(); // Get new positions only for affected pages affectedPageIds.forEach(pageId => { const element = document.querySelector(`[data-page-number="${pageId}"]`); if (element) { const rect = element.getBoundingClientRect(); newPositions.set(pageId, { x: rect.left, y: rect.top }); } }); const elementsToAnimate: HTMLElement[] = []; // Apply animations only to pages that actually moved affectedPageIds.forEach(pageId => { const element = document.querySelector(`[data-page-number="${pageId}"]`) as HTMLElement; if (!element) return; const currentPos = currentPositions.get(pageId); const newPos = newPositions.get(pageId); if (currentPos && newPos) { const deltaX = currentPos.x - newPos.x; const deltaY = currentPos.y - newPos.y; if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { elementsToAnimate.push(element); // Apply initial transform 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.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; element.style.transform = 'translate(0px, 0px)'; } } }); // Clean up after animation (only for animated elements) setTimeout(() => { elementsToAnimate.forEach((element) => { element.style.transform = ''; element.style.transition = ''; }); setIsAnimating(false); }, 300); }); }); }, 10); // Small delay to allow state update }, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]); const handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => { e.preventDefault(); if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return; let targetIndex: number; if (targetPageNumber === 'end') { targetIndex = displayDocument.pages.length; } else { targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); 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, displayDocument, animateReorder, multiPageDrag]); const handleEndZoneDragEnter = useCallback(() => { if (draggedPage) { setDropTarget('end'); } }, [draggedPage]); const handleRotate = useCallback((direction: 'left' | 'right') => { if (!displayDocument) return; const rotation = direction === 'left' ? -90 : 90; const pagesToRotate = selectionMode ? selectedPageNumbers.map(pageNum => { const page = displayDocument.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : displayDocument.pages.map(p => p.id); if (selectionMode && selectedPageNumbers.length === 0) return; const command = new RotatePagesCommand( displayDocument, setPdfDocument, pagesToRotate, rotation ); executeCommand(command); const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; setStatus(`Rotated ${pageCount} pages ${direction}`); }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); const handleDelete = useCallback(() => { if (!displayDocument) return; const pagesToDelete = selectionMode ? selectedPageNumbers.map(pageNum => { const page = displayDocument.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : displayDocument.pages.map(p => p.id); if (selectionMode && selectedPageNumbers.length === 0) return; const command = new DeletePagesCommand( displayDocument, setPdfDocument, pagesToDelete ); executeCommand(command); if (selectionMode) { setSelectedPages([]); } const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]); const handleSplit = useCallback(() => { if (!displayDocument) return; const pagesToSplit = selectionMode ? selectedPageNumbers.map(pageNum => { const page = displayDocument.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : displayDocument.pages.map(p => p.id); if (selectionMode && selectedPageNumbers.length === 0) return; const command = new ToggleSplitCommand( displayDocument, setPdfDocument, pagesToSplit ); executeCommand(command); const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; setStatus(`Split markers toggled for ${pageCount} pages`); }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); const showExportPreview = useCallback((selectedOnly: boolean = false) => { if (!mergedPdfDocument) return; // Convert page numbers to page IDs for export service const exportPageIds = selectedOnly ? selectedPageNumbers.map(pageNum => { const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : []; const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); setExportPreview(preview); setShowExportModal(true); }, [mergedPdfDocument, selectedPageNumbers]); const handleExport = useCallback(async (selectedOnly: boolean = false) => { if (!mergedPdfDocument) return; setExportLoading(true); try { // Convert page numbers to page IDs for export service const exportPageIds = selectedOnly ? selectedPageNumbers.map(pageNum => { const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : []; const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); if (errors.length > 0) { setStatus(errors.join(', ')); return; } const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore); if (hasSplitMarkers) { const result = await pdfExportService.exportPDF(mergedPdfDocument, 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(mergedPdfDocument, 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'; setStatus(errorMessage); } finally { setExportLoading(false); } }, [mergedPdfDocument, selectedPageNumbers, filename]); const handleUndo = useCallback(() => { if (undo()) { setStatus('Operation undone'); } }, [undo]); const handleRedo = useCallback(() => { if (redo()) { setStatus('Operation redone'); } }, [redo]); const closePdf = useCallback(() => { // Use global navigation guard system fileContext.requestNavigation(() => { clearAllFiles(); // This now handles all cleanup centrally (including merged docs) setSelectedPages([]); }); }, [fileContext, clearAllFiles, setSelectedPages]); // 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: selectedPageNumbers, closePdf, }); } }, [ onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, selectedPageNumbers, closePdf ]); // Show loading or empty state instead of blocking const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0; // Functions for global NavigationWarningModal const handleApplyAndContinue = useCallback(async () => { if (editedDocument) { await applyChanges(); } }, [editedDocument, applyChanges]); const handleExportAndContinue = useCallback(async () => { if (editedDocument) { await applyChanges(); await handleExport(false); } }, [editedDocument, applyChanges, handleExport]); // Check for existing drafts const checkForDrafts = useCallback(async () => { if (!mergedPdfDocument) return; try { const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; const request = indexedDB.open('stirling-pdf-drafts', 1); request.onsuccess = () => { const db = request.result; if (!db.objectStoreNames.contains('drafts')) return; const transaction = db.transaction('drafts', 'readonly'); const store = transaction.objectStore('drafts'); const getRequest = store.get(draftKey); getRequest.onsuccess = () => { const draft = getRequest.result; if (draft && draft.timestamp) { // Check if draft is recent (within last 24 hours) const draftAge = Date.now() - draft.timestamp; const twentyFourHours = 24 * 60 * 60 * 1000; if (draftAge < twentyFourHours) { setFoundDraft(draft); setShowResumeModal(true); } } }; }; } catch (error) { console.warn('Failed to check for drafts:', error); } }, [mergedPdfDocument]); // Resume work from draft const resumeWork = useCallback(() => { if (foundDraft && foundDraft.document) { setEditedDocument(foundDraft.document); setHasUnsavedChanges(true); setFoundDraft(null); setShowResumeModal(false); setStatus('Resumed previous work'); } }, [foundDraft]); // Start fresh (ignore draft) const startFresh = useCallback(() => { if (foundDraft) { // Clean up the draft cleanupDraft(); } setFoundDraft(null); setShowResumeModal(false); }, [foundDraft, cleanupDraft]); // Cleanup on unmount useEffect(() => { return () => { console.log('PageEditor unmounting - cleaning up resources'); // Clear auto-save timer if (autoSaveTimer.current) { clearTimeout(autoSaveTimer.current); } // Clean up draft if component unmounts with unsaved changes if (hasUnsavedChanges) { cleanupDraft(); } }; }, [hasUnsavedChanges, cleanupDraft]); // Check for drafts when document loads useEffect(() => { if (mergedPdfDocument && !editedDocument && !hasUnsavedChanges) { // Small delay to let the component settle setTimeout(checkForDrafts, 1000); } }, [mergedPdfDocument, editedDocument, hasUnsavedChanges, checkForDrafts]); // Global navigation intercept - listen for navigation events useEffect(() => { if (!hasUnsavedChanges) return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; return 'You have unsaved changes. Are you sure you want to leave?'; }; // Intercept browser navigation window.addEventListener('beforeunload', handleBeforeUnload); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); }; }, [hasUnsavedChanges]); // Display all pages - use edited or original document const displayedPages = displayDocument?.pages || []; return ( {showEmpty && (
📄 No PDF files loaded Add files to start editing pages
)} {showLoading && ( {/* Progress indicator */} Processing PDF files... {Math.round(processingProgress || 0)}%
)} {displayDocument && ( {/* Enhanced Processing Status */} {globalProcessing && processingProgress < 100 && ( Processing thumbnails... {Math.round(processingProgress || 0)}%
)} setFilename(e.target.value)} placeholder="Enter filename" style={{ minWidth: 200 }} /> {selectionMode && ( <> )} {/* Apply Changes Button */} {hasUnsavedChanges && ( )} {selectionMode && ( )} ( )} renderSplitMarker={(page, index) => (
)} /> )} {/* Modal should be outside the conditional but inside the main container */} setShowExportModal(false)} title="Export Preview" > {exportPreview && ( Pages to export: {exportPreview.pageCount} {exportPreview.splitCount > 1 && ( Split into documents: {exportPreview.splitCount} )} Estimated size: {exportPreview.estimatedSize} {mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && ( This will create multiple PDF files based on split markers. )} )} {/* Global Navigation Warning Modal */} {/* Resume Work Modal */} We found unsaved changes from a previous session. Would you like to resume where you left off? {foundDraft && ( Last saved: {new Date(foundDraft.timestamp).toLocaleString()} )} {status && ( setStatus(null)} style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} > {status} )} ); }; export default PageEditor;