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 { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; import { ModeType } 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(); // Use split contexts to prevent re-renders const { state, selectors } = useFileState(); const { actions } = useFileActions(); // Prefer IDs + selectors to avoid array identity churn const activeFileIds = state.files.ids; const primaryFileId = activeFileIds[0] ?? null; const selectedFiles = selectors.getSelectedFiles(); // Stable signature for effects (prevents loops) const filesSignature = selectors.getFilesSignature(); // UI state const globalProcessing = state.ui.isProcessing; const processingProgress = state.ui.processingProgress; const hasUnsavedChanges = state.ui.hasUnsavedChanges; const selectedPageNumbers = state.ui.selectedPageNumbers; // 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); /** * Create stable files signature to prevent infinite re-computation. * This signature only changes when files are actually added/removed or processing state changes. * Using this instead of direct file arrays prevents unnecessary re-renders. */ // Thumbnail generation (opt-in for visual tools) - MUST be before mergedPdfDocument const { generateThumbnails, addThumbnailToCache, getThumbnailFromCache, stopGeneration, destroyThumbnails } = useThumbnailGeneration(); // Get primary file record outside useMemo to track processedFile changes const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; const processedFilePages = primaryFileRecord?.processedFile?.pages; const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; // Compute merged document with stable signature (prevents infinite loops) const mergedPdfDocument = useMemo((): PDFDocument | null => { if (activeFileIds.length === 0) return null; const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; // If we have file IDs but no file record, something is wrong - return null to show loading if (!primaryFileRecord) { console.log('🎬 PageEditor: No primary file record found, showing loading'); return null; } const name = activeFileIds.length === 1 ? (primaryFileRecord.name ?? 'document.pdf') : activeFileIds .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) .join(' + '); // Get pages from processed file data const processedFile = primaryFileRecord.processedFile; // Debug logging for processed file data console.log(`🎬 PageEditor: Building document for ${name}`); console.log(`🎬 ProcessedFile exists:`, !!processedFile); console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0); console.log(`🎬 ProcessedFile totalPages:`, processedFile?.totalPages || 'unknown'); if (processedFile?.pages) { console.log(`🎬 Pages structure:`, processedFile.pages.map(p => ({ pageNumber: p.pageNumber || 'unknown', hasThumbnail: !!p.thumbnail }))); } console.log(`🎬 Will use ${(processedFile?.pages?.length || 0) > 0 ? 'PROCESSED' : 'FALLBACK'} pages`); // Convert processed pages to PageEditor format // All processing is now handled by FileProcessingService when files are added const pages: PDFPage[] = processedFile?.pages && processedFile.pages.length > 0 ? processedFile.pages.map((page, index) => { const pageId = `${primaryFileId}-page-${index + 1}`; // Try multiple sources for thumbnails in order of preference: // 1. Processed data thumbnail // 2. Cached thumbnail from previous generation // 3. For page 1: FileRecord's thumbnailUrl (from FileProcessingService) let thumbnail = page.thumbnail || null; const cachedThumbnail = getThumbnailFromCache(pageId); if (!thumbnail && cachedThumbnail) { thumbnail = cachedThumbnail; console.log(`📸 PageEditor: Using cached thumbnail for page ${index + 1} (${pageId})`); } if (!thumbnail && index === 0) { // For page 1, use the thumbnail from FileProcessingService thumbnail = primaryFileRecord.thumbnailUrl || null; if (thumbnail) { addThumbnailToCache(pageId, thumbnail); console.log(`📸 PageEditor: Using FileProcessingService thumbnail for page 1 (${pageId})`); } } return { id: pageId, pageNumber: index + 1, thumbnail, rotation: page.rotation || 0, selected: false, splitBefore: page.splitBefore || false, }; }) : [{ // Fallback while FileProcessingService is working id: `${primaryFileId}-page-1`, pageNumber: 1, thumbnail: getThumbnailFromCache(`${primaryFileId}-page-1`) || primaryFileRecord.thumbnailUrl || null, rotation: 0, selected: false, splitBefore: false, }]; // Create document with determined pages return { id: activeFileIds.length === 1 ? (primaryFileId ?? 'unknown') : `merged:${filesSignature}`, name, file: primaryFile || new File([], primaryFileRecord.name), // Create minimal File if needed pages, totalPages: pages.length, destroy: () => {} // Optional cleanup function }; }, [filesSignature, activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, getThumbnailFromCache, addThumbnailToCache]); // 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 [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<{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 - use stable signature useEffect(() => { if (mergedPdfDocument) { if (activeFileIds.length === 1 && primaryFileId) { const record = selectors.getFileRecord(primaryFileId); if (record) { setFilename(record.name.replace(/\.pdf$/i, '')); } } else { const filenames = activeFileIds .map(id => selectors.getFileRecord(id)?.name.replace(/\.pdf$/i, '') || 'file') .filter(Boolean); setFilename(filenames.join('_')); } } }, [mergedPdfDocument, filesSignature, primaryFileId, selectors]); // 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 actions.addFiles(uploadedFiles); setStatus(`Added ${uploadedFiles.length} file(s) for processing`); }, [actions]); // PageEditor no longer handles cleanup - it's centralized in FileContext // Simple cache-first thumbnail generation (no complex detection needed) // Simple thumbnail generation - generate pages 2+ that aren't cached const generateMissingThumbnails = useCallback(async () => { if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) { return; } const file = selectors.getFile(primaryFileId); if (!file) return; const totalPages = mergedPdfDocument.totalPages; if (totalPages <= 1) return; // Only page 1, nothing to generate // Check which pages 2+ need thumbnails (not in cache) const pageNumbersToGenerate = []; for (let pageNum = 2; pageNum <= totalPages; pageNum++) { const pageId = `${primaryFileId}-page-${pageNum}`; if (!getThumbnailFromCache(pageId)) { pageNumbersToGenerate.push(pageNum); } } if (pageNumbersToGenerate.length === 0) { console.log(`📸 PageEditor: All pages 2+ already cached, skipping generation`); return; } console.log(`📸 PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.join(', ')}]`); try { // Load PDF array buffer for Web Workers const arrayBuffer = await file.arrayBuffer(); // Calculate quality scale based on file size const scale = calculateScaleFromFileSize(selectors.getFileRecord(primaryFileId)?.size || 0); // Start parallel thumbnail generation WITHOUT blocking the main thread await generateThumbnails( arrayBuffer, pageNumbersToGenerate, { 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 for thumbnail updates (progress) => { // Batch process thumbnails to reduce main thread work requestAnimationFrame(() => { progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { // Use stable fileId for cache key const pageId = `${primaryFileId}-page-${pageNumber}`; addThumbnailToCache(pageId, thumbnail); // Also update the processedFile so document rebuilds include the thumbnail const fileRecord = selectors.getFileRecord(primaryFileId); if (fileRecord?.processedFile?.pages) { const updatedProcessedFile = { ...fileRecord.processedFile, pages: fileRecord.processedFile.pages.map((page, index) => index + 1 === pageNumber ? { ...page, thumbnail } : page ) }; actions.updateFileRecord(primaryFileId, { processedFile: updatedProcessedFile }); } window.dispatchEvent(new CustomEvent('thumbnailReady', { detail: { pageNumber, thumbnail, pageId } })); }); }); } ); console.log(`📸 PageEditor: Thumbnail generation completed for pages [${pageNumbersToGenerate.join(', ')}]`); } catch (error) { console.error('PageEditor: Thumbnail generation failed:', error); } }, [mergedPdfDocument, primaryFileId, activeFileIds, generateThumbnails, getThumbnailFromCache, addThumbnailToCache, selectors, actions]); // Simple useEffect - just generate missing thumbnails when document is ready useEffect(() => { if (mergedPdfDocument && mergedPdfDocument.totalPages > 1) { console.log(`📸 PageEditor: Document ready with ${mergedPdfDocument.totalPages} pages, checking for missing thumbnails`); generateMissingThumbnails(); } }, [mergedPdfDocument, generateMissingThumbnails]); // Cleanup thumbnail generation when component unmounts useEffect(() => { return () => { // Stop any ongoing thumbnail generation if (stopGeneration) { stopGeneration(); } }; }, [stopGeneration]); // Clear selections when files change - use stable signature useEffect(() => { actions.setSelectedPages([]); setCsvInput(""); setSelectionMode(false); }, [filesSignature, actions]); // 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) { actions.setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); } }, [mergedPdfDocument, actions]); const deselectAll = useCallback(() => actions.setSelectedPages([]), [actions]); 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); actions.setSelectedPages(newSelectedPageNumbers); } else { // Add to selection console.log('🔄 Adding page', pageNumber); const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber]; actions.setSelectedPages(newSelectedPageNumbers); } }, [selectedPageNumbers, actions]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { const newMode = !prev; if (!newMode) { // Clear selections when exiting selection mode actions.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.pages.length; i++) { if (i > 0) { pageNumbers.push(i); } } } else { const pageNum = parseInt(range); if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) { pageNumbers.push(pageNum); } } }); return pageNumbers; }, [mergedPdfDocument]); const updatePagesFromCSV = useCallback(() => { const pageNumbers = parseCSVInput(csvInput); actions.setSelectedPages(pageNumbers); }, [csvInput, parseCSVInput, actions]); 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); actions.setHasUnsavedChanges(true); // Use actions from context setHasUnsavedDraft(true); // Mark that we have unsaved draft changes // Auto-save to drafts (debounced) - only if we have new changes // Enhanced auto-save to drafts with proper error handling if (autoSaveTimer.current) { clearTimeout(autoSaveTimer.current); } autoSaveTimer.current = window.setTimeout(async () => { if (hasUnsavedDraft) { try { await saveDraftToIndexedDB(updatedDoc); setHasUnsavedDraft(false); // Mark draft as saved console.log('Auto-save completed successfully'); } catch (error) { console.warn('Auto-save failed, will retry on next change:', error); // Don't set hasUnsavedDraft to false so it will retry } } }, 30000); // Auto-save after 30 seconds of inactivity return updatedDoc; }, [actions, hasUnsavedDraft]); // Enhanced draft save with proper IndexedDB handling const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { const draftKey = `draft-${doc.id || 'merged'}`; const draftData = { document: doc, timestamp: Date.now(), originalFiles: activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean) }; try { // 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; // Check if the object store exists before trying to access it if (!db.objectStoreNames.contains('drafts')) { console.warn('drafts object store does not exist, skipping auto-save'); return; } 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); // Robust IndexedDB initialization with proper error handling const dbRequest = indexedDB.open('stirling-pdf-drafts', 1); return new Promise((resolve, reject) => { dbRequest.onerror = () => { console.warn('Failed to open draft database:', dbRequest.error); reject(dbRequest.error); }; dbRequest.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; // Create object store if it doesn't exist if (!db.objectStoreNames.contains('drafts')) { const store = db.createObjectStore('drafts'); console.log('Created drafts object store'); } }; dbRequest.onsuccess = () => { const db = dbRequest.result; // Verify object store exists before attempting transaction if (!db.objectStoreNames.contains('drafts')) { console.warn('Drafts object store not found, skipping save'); resolve(); return; } try { const transaction = db.transaction('drafts', 'readwrite'); const store = transaction.objectStore('drafts'); transaction.onerror = () => { console.warn('Draft save transaction failed:', transaction.error); reject(transaction.error); }; transaction.oncomplete = () => { console.log('Draft auto-saved successfully'); resolve(); }; const putRequest = store.put(draftData, draftKey); putRequest.onerror = () => { console.warn('Failed to put draft data:', putRequest.error); reject(putRequest.error); }; } catch (error) { console.warn('Transaction creation failed:', error); reject(error); } finally { db.close(); } }; }); } }, [activeFileIds, selectors]); // Enhanced draft cleanup with proper IndexedDB handling const cleanupDraft = useCallback(async () => { const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; try { 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; // Check if the object store exists before trying to access it if (!db.objectStoreNames.contains('drafts')) { console.warn('drafts object store does not exist, skipping cleanup'); return; } const transaction = db.transaction('drafts', 'readwrite'); const store = transaction.objectStore('drafts'); store.delete(draftKey); }; } catch (error) { console.warn('Failed to cleanup draft:', error); const dbRequest = indexedDB.open('stirling-pdf-drafts', 1); return new Promise((resolve, reject) => { dbRequest.onerror = () => { console.warn('Failed to open draft database for cleanup:', dbRequest.error); resolve(); // Don't fail the whole operation if cleanup fails }; dbRequest.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; // Create object store if it doesn't exist if (!db.objectStoreNames.contains('drafts')) { db.createObjectStore('drafts'); console.log('Created drafts object store during cleanup fallback'); } }; dbRequest.onsuccess = () => { const db = dbRequest.result; // Check if object store exists before attempting cleanup if (!db.objectStoreNames.contains('drafts')) { console.log('No drafts object store found, nothing to cleanup'); resolve(); return; } try { const transaction = db.transaction('drafts', 'readwrite'); const store = transaction.objectStore('drafts'); transaction.onerror = () => { console.warn('Draft cleanup transaction failed:', transaction.error); resolve(); // Don't fail if cleanup fails }; transaction.oncomplete = () => { console.log('Draft cleaned up successfully'); resolve(); }; const deleteRequest = store.delete(draftKey); deleteRequest.onerror = () => { console.warn('Failed to delete draft:', deleteRequest.error); resolve(); // Don't fail if delete fails }; } catch (error) { console.warn('Draft cleanup transaction creation failed:', error); resolve(); // Don't fail if cleanup fails } finally { db.close(); } }; }); } }, [mergedPdfDocument]); // Apply changes to create new processed file const applyChanges = useCallback(async () => { if (!editedDocument || !mergedPdfDocument) return; try { if (activeFileIds.length === 1 && primaryFileId) { const file = selectors.getFile(primaryFileId); if (!file) return; // Apply changes simplified - no complex dispatch loops setStatus('Changes applied successfully'); } else if (activeFileIds.length > 1) { setStatus('Apply changes for multiple files not yet supported'); return; } // Clear edit state immediately setEditedDocument(null); actions.setHasUnsavedChanges(false); setHasUnsavedDraft(false); cleanupDraft(); } catch (error) { console.error('Failed to apply changes:', error); setStatus('Failed to apply changes'); } }, [editedDocument, mergedPdfDocument, activeFileIds, primaryFileId, selectors, actions, 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) { actions.setSelectedPages([]); } const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]); 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 actions from context actions.clearAllFiles(); actions.setSelectedPages([]); }, [actions]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); /** * Stable function proxy pattern to prevent infinite loops. * * Problem: If we include selectedPages in useEffect dependencies, every page selection * change triggers onFunctionsReady → parent re-renders → PageEditor unmounts/remounts → infinite loop * * Solution: Create a stable proxy object that uses getters to access current values * without triggering parent re-renders when values change. */ const pageEditorFunctionsRef = useRef({ handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, selectedPages: selectedPageNumbers, closePdf, }); // Update ref with current values (no parent notification) pageEditorFunctionsRef.current = { handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, selectedPages: selectedPageNumbers, closePdf, }; // Only call onFunctionsReady once - use stable proxy for live updates useEffect(() => { if (onFunctionsReady) { const stableFunctions = { get handleUndo() { return pageEditorFunctionsRef.current.handleUndo; }, get handleRedo() { return pageEditorFunctionsRef.current.handleRedo; }, get canUndo() { return pageEditorFunctionsRef.current.canUndo; }, get canRedo() { return pageEditorFunctionsRef.current.canRedo; }, get handleRotate() { return pageEditorFunctionsRef.current.handleRotate; }, get handleDelete() { return pageEditorFunctionsRef.current.handleDelete; }, get handleSplit() { return pageEditorFunctionsRef.current.handleSplit; }, get showExportPreview() { return pageEditorFunctionsRef.current.showExportPreview; }, get onExportSelected() { return pageEditorFunctionsRef.current.onExportSelected; }, get onExportAll() { return pageEditorFunctionsRef.current.onExportAll; }, get exportLoading() { return pageEditorFunctionsRef.current.exportLoading; }, get selectionMode() { return pageEditorFunctionsRef.current.selectionMode; }, get selectedPages() { return pageEditorFunctionsRef.current.selectedPages; }, get closePdf() { return pageEditorFunctionsRef.current.closePdf; }, }; onFunctionsReady(stableFunctions); } }, [onFunctionsReady]); // Show loading or empty state instead of blocking const showLoading = !mergedPdfDocument && (globalProcessing || activeFileIds.length > 0); const showEmpty = !mergedPdfDocument && !globalProcessing && activeFileIds.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]); // Enhanced draft checking with proper IndexedDB handling 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('Draft check failed:', error); // Don't throw - draft checking failure shouldn't break the app } }, [mergedPdfDocument]); // Resume work from draft const resumeWork = useCallback(() => { if (foundDraft && foundDraft.document) { setEditedDocument(foundDraft.document); actions.setHasUnsavedChanges(true); // Use context action setFoundDraft(null); setShowResumeModal(false); setStatus('Resumed previous work'); } }, [foundDraft, actions]); // 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 () => { // 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} )} {error && ( setError(null)} style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} > {error} )} ); }; export default PageEditor;