mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-27 14:49:23 +00:00

# Description of Changes A new universal file context rather than the splintered ones for the main views, tools and manager we had before (manager still has its own but its better integreated with the core context) File context has been split it into a handful of different files managing various file related issues separately to reduce the monolith - FileReducer.ts - State management fileActions.ts - File operations fileSelectors.ts - Data access patterns lifecycle.ts - Resource cleanup and memory management fileHooks.ts - React hooks interface contexts.ts - Context providers Improved thumbnail generation Improved indexxedb handling Stopped handling files as blobs were not necessary to improve performance A new library handling drag and drop https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes but I broke the old one with the new filecontext and it needed doing so it was a might as well) A new library handling virtualisation on page editor @tanstack/react-virtual, as above. Quickly ripped out the last remnants of the old URL params stuff and replaced with the beginnings of what will later become the new URL navigation system (for now it just restores the tool name in url behavior) Fixed selected file not regestered when opening a tool Fixed png thumbnails Closes #(issue_number) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Reece Browne <you@example.com>
1474 lines
54 KiB
TypeScript
1474 lines
54 KiB
TypeScript
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 "../../contexts/NavigationContext";
|
|
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 { enhancedPDFProcessingService } from "../../services/enhancedPDFProcessingService";
|
|
import { fileProcessingService } from "../../services/fileProcessingService";
|
|
import { pdfProcessingService } from "../../services/pdfProcessingService";
|
|
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
|
import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration";
|
|
import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils";
|
|
import { fileStorage } from "../../services/fileStorage";
|
|
import { indexedDBManager, DATABASE_CONFIGS } from "../../services/indexedDBManager";
|
|
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<PDFDocument | null>(null);
|
|
const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false);
|
|
const [showResumeModal, setShowResumeModal] = useState(false);
|
|
const [foundDraft, setFoundDraft] = useState<any>(null);
|
|
const autoSaveTimer = useRef<number | null>(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 or create placeholders from metadata
|
|
let pages: PDFPage[] = [];
|
|
|
|
if (processedFile?.pages && processedFile.pages.length > 0) {
|
|
// Use fully processed pages with thumbnails
|
|
pages = 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,
|
|
};
|
|
});
|
|
} else if (processedFile?.totalPages && processedFile.totalPages > 0) {
|
|
// Create placeholder pages from metadata while thumbnails are being generated
|
|
console.log(`🎬 PageEditor: Creating ${processedFile.totalPages} placeholder pages from metadata`);
|
|
pages = Array.from({ length: processedFile.totalPages }, (_, index) => {
|
|
const pageId = `${primaryFileId}-page-${index + 1}`;
|
|
|
|
// Check for existing cached thumbnail
|
|
let thumbnail = getThumbnailFromCache(pageId) || null;
|
|
|
|
// For page 1, try to use the FileRecord thumbnail
|
|
if (!thumbnail && index === 0) {
|
|
thumbnail = primaryFileRecord.thumbnailUrl || null;
|
|
if (thumbnail) {
|
|
addThumbnailToCache(pageId, thumbnail);
|
|
console.log(`📸 PageEditor: Using FileProcessingService thumbnail for placeholder page 1 (${pageId})`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: pageId,
|
|
pageNumber: index + 1,
|
|
thumbnail, // Will be null initially, populated by PageThumbnail components
|
|
rotation: 0,
|
|
selected: false,
|
|
splitBefore: false,
|
|
};
|
|
});
|
|
} else {
|
|
// Ultimate fallback - single page while we wait for metadata
|
|
pages = [{
|
|
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, primaryFileId, primaryFileRecord]);
|
|
|
|
|
|
// Display document: Use edited version if exists, otherwise original
|
|
const displayDocument = editedDocument || mergedPdfDocument;
|
|
|
|
const [filename, setFilename] = useState<string>("");
|
|
|
|
|
|
|
|
// Page editor state (use context for selectedPages)
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [csvInput, setCsvInput] = useState<string>("");
|
|
const [selectionMode, setSelectionMode] = useState(false);
|
|
|
|
|
|
// 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<number | null>(null);
|
|
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
|
const [isAnimating, setIsAnimating] = useState(false);
|
|
const pageRefs = useRef<Map<string, HTMLDivElement>>(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)
|
|
|
|
// Lazy thumbnail generation - only generate when needed, with intelligent batching
|
|
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
|
|
|
|
// For very large documents (2000+ pages), be much more conservative
|
|
const isVeryLargeDocument = totalPages > 2000;
|
|
|
|
if (isVeryLargeDocument) {
|
|
console.log(`📸 PageEditor: Very large document (${totalPages} pages) - using minimal thumbnail generation`);
|
|
// For very large docs, only generate the next visible batch (pages 2-25) to avoid UI blocking
|
|
const pageNumbersToGenerate = [];
|
|
for (let pageNum = 2; pageNum <= Math.min(25, totalPages); pageNum++) {
|
|
const pageId = `${primaryFileId}-page-${pageNum}`;
|
|
if (!getThumbnailFromCache(pageId)) {
|
|
pageNumbersToGenerate.push(pageNum);
|
|
}
|
|
}
|
|
|
|
if (pageNumbersToGenerate.length > 0) {
|
|
console.log(`📸 PageEditor: Generating initial batch for large doc: pages [${pageNumbersToGenerate.join(', ')}]`);
|
|
await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate);
|
|
}
|
|
|
|
// Schedule remaining thumbnails with delay to avoid blocking
|
|
setTimeout(() => {
|
|
generateRemainingThumbnailsLazily(file, primaryFileId, totalPages, 26);
|
|
}, 2000); // 2 second delay before starting background generation
|
|
|
|
return;
|
|
}
|
|
|
|
// For smaller documents, check which pages 2+ need thumbnails
|
|
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.slice(0, 5).join(', ')}${pageNumbersToGenerate.length > 5 ? '...' : ''}]`);
|
|
await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate);
|
|
}, [mergedPdfDocument, primaryFileId, activeFileIds, selectors]);
|
|
|
|
// Helper function to generate thumbnails in batches
|
|
const generateThumbnailBatch = useCallback(async (file: File, fileId: string, pageNumbers: number[]) => {
|
|
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(fileId)?.size || 0);
|
|
|
|
// Start parallel thumbnail generation WITHOUT blocking the main thread
|
|
await generateThumbnails(
|
|
fileId, // Add fileId as first parameter
|
|
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 for thumbnail updates
|
|
(progress: { completed: number; total: number; thumbnails: Array<{ pageNumber: number; thumbnail: string }> }) => {
|
|
// Batch process thumbnails to reduce main thread work
|
|
requestAnimationFrame(() => {
|
|
progress.thumbnails.forEach(({ pageNumber, thumbnail }: { pageNumber: number; thumbnail: string }) => {
|
|
// Use stable fileId for cache key
|
|
const pageId = `${fileId}-page-${pageNumber}`;
|
|
addThumbnailToCache(pageId, thumbnail);
|
|
|
|
// Don't update context state - thumbnails stay in cache only
|
|
// This eliminates per-page context rerenders
|
|
// PageThumbnail will find thumbnails via cache polling
|
|
});
|
|
});
|
|
}
|
|
);
|
|
|
|
// Removed verbose logging - only log errors
|
|
} catch (error) {
|
|
console.error('PageEditor: Thumbnail generation failed:', error);
|
|
}
|
|
}, [generateThumbnails, addThumbnailToCache, selectors]);
|
|
|
|
// Background generation for remaining pages in very large documents
|
|
const generateRemainingThumbnailsLazily = useCallback(async (file: File, fileId: string, totalPages: number, startPage: number) => {
|
|
console.log(`📸 PageEditor: Starting background thumbnail generation from page ${startPage} to ${totalPages}`);
|
|
|
|
// Generate in small chunks to avoid blocking
|
|
const CHUNK_SIZE = 50;
|
|
for (let start = startPage; start <= totalPages; start += CHUNK_SIZE) {
|
|
const end = Math.min(start + CHUNK_SIZE - 1, totalPages);
|
|
const chunkPageNumbers = [];
|
|
|
|
for (let pageNum = start; pageNum <= end; pageNum++) {
|
|
const pageId = `${fileId}-page-${pageNum}`;
|
|
if (!getThumbnailFromCache(pageId)) {
|
|
chunkPageNumbers.push(pageNum);
|
|
}
|
|
}
|
|
|
|
if (chunkPageNumbers.length > 0) {
|
|
// Background thumbnail generation in progress (removed verbose logging)
|
|
await generateThumbnailBatch(file, fileId, chunkPageNumbers);
|
|
|
|
// Small delay between chunks to keep UI responsive
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
}
|
|
}
|
|
|
|
console.log(`📸 PageEditor: Background thumbnail generation completed for ${totalPages} pages`);
|
|
}, [getThumbnailFromCache, generateThumbnailBatch]);
|
|
|
|
// 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 all PDF.js background processing on unmount
|
|
if (stopGeneration) {
|
|
stopGeneration();
|
|
}
|
|
if (destroyThumbnails) {
|
|
destroyThumbnails();
|
|
}
|
|
// Stop all processing services and destroy workers
|
|
enhancedPDFProcessingService.emergencyCleanup();
|
|
fileProcessingService.emergencyCleanup();
|
|
pdfProcessingService.clearAll();
|
|
// Final emergency cleanup of all workers
|
|
pdfWorkerManager.emergencyCleanup();
|
|
};
|
|
}, [stopGeneration, destroyThumbnails]);
|
|
|
|
// 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]);
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
// 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 using centralized IndexedDB manager
|
|
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
|
|
const draftKey = `draft-${doc.id || 'merged'}`;
|
|
|
|
try {
|
|
// Export the current document state as PDF bytes
|
|
const exportedFile = await pdfExportService.exportPDF(doc, []);
|
|
const pdfBytes = 'blob' in exportedFile ? await exportedFile.blob.arrayBuffer() : await exportedFile.blobs[0].arrayBuffer();
|
|
const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean);
|
|
|
|
// Generate thumbnail for the draft
|
|
let thumbnail: string | undefined;
|
|
try {
|
|
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
|
const blob = 'blob' in exportedFile ? exportedFile.blob : exportedFile.blobs[0];
|
|
const filename = 'filename' in exportedFile ? exportedFile.filename : exportedFile.filenames[0];
|
|
const file = new File([blob], filename, { type: 'application/pdf' });
|
|
thumbnail = await generateThumbnailForFile(file);
|
|
} catch (error) {
|
|
console.warn('Failed to generate thumbnail for draft:', error);
|
|
}
|
|
|
|
const draftData = {
|
|
id: draftKey,
|
|
name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`,
|
|
pdfData: pdfBytes,
|
|
size: pdfBytes.byteLength,
|
|
timestamp: Date.now(),
|
|
thumbnail,
|
|
originalFiles: originalFileNames
|
|
};
|
|
|
|
// Use centralized IndexedDB manager
|
|
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
|
const transaction = db.transaction('drafts', 'readwrite');
|
|
const store = transaction.objectStore('drafts');
|
|
|
|
const putRequest = store.put(draftData, draftKey);
|
|
putRequest.onsuccess = () => {
|
|
console.log('Draft auto-saved to IndexedDB');
|
|
};
|
|
putRequest.onerror = () => {
|
|
console.warn('Failed to put draft data:', putRequest.error);
|
|
};
|
|
|
|
} catch (error) {
|
|
console.warn('Failed to auto-save draft:', error);
|
|
}
|
|
}, [activeFileIds, selectors]);
|
|
|
|
// Enhanced draft cleanup using centralized IndexedDB manager
|
|
const cleanupDraft = useCallback(async () => {
|
|
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
|
|
|
|
try {
|
|
// Use centralized IndexedDB manager
|
|
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
|
const transaction = db.transaction('drafts', 'readwrite');
|
|
const store = transaction.objectStore('drafts');
|
|
|
|
const deleteRequest = store.delete(draftKey);
|
|
deleteRequest.onsuccess = () => {
|
|
console.log('Draft cleaned up successfully');
|
|
};
|
|
deleteRequest.onerror = () => {
|
|
console.warn('Failed to delete draft:', deleteRequest.error);
|
|
};
|
|
|
|
} 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 (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<string, { x: number; y: number }>();
|
|
|
|
|
|
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<string, { x: number; y: number }>();
|
|
|
|
// 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 handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => {
|
|
if (!displayDocument) return;
|
|
|
|
const pagesToMove = selectedPages && selectedPages.length > 1
|
|
? selectedPages
|
|
: [sourcePageNumber];
|
|
|
|
const sourceIndex = displayDocument.pages.findIndex(p => p.pageNumber === sourcePageNumber);
|
|
if (sourceIndex === -1 || sourceIndex === targetIndex) return;
|
|
|
|
animateReorder(sourcePageNumber, targetIndex);
|
|
|
|
const moveCount = pagesToMove.length;
|
|
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
|
}, [displayDocument, animateReorder]);
|
|
|
|
|
|
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);
|
|
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(() => {
|
|
// Stop all PDF.js background processing immediately
|
|
if (stopGeneration) {
|
|
stopGeneration();
|
|
}
|
|
if (destroyThumbnails) {
|
|
destroyThumbnails();
|
|
}
|
|
// Stop enhanced PDF processing and destroy workers
|
|
enhancedPDFProcessingService.emergencyCleanup();
|
|
// Stop file processing service and destroy workers
|
|
fileProcessingService.emergencyCleanup();
|
|
// Stop PDF processing service
|
|
pdfProcessingService.clearAll();
|
|
// Emergency cleanup - destroy all PDF workers
|
|
pdfWorkerManager.emergencyCleanup();
|
|
|
|
// Clear files from memory only (preserves files in storage/recent files)
|
|
const allFileIds = selectors.getAllFileIds();
|
|
actions.removeFiles(allFileIds, false); // false = don't delete from storage
|
|
actions.setSelectedPages([]);
|
|
}, [actions, selectors, stopGeneration, destroyThumbnails]);
|
|
|
|
// 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 using centralized IndexedDB manager
|
|
const checkForDrafts = useCallback(async () => {
|
|
if (!mergedPdfDocument) return;
|
|
|
|
|
|
try {
|
|
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
|
|
// Use centralized IndexedDB manager
|
|
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
|
|
|
// Check if the drafts object store exists before using it
|
|
if (!db.objectStoreNames.contains('drafts')) {
|
|
console.log('📝 Drafts object store not found, skipping draft check');
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
|
|
getRequest.onerror = () => {
|
|
console.warn('Failed to get draft:', getRequest.error);
|
|
};
|
|
|
|
} 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);
|
|
}
|
|
|
|
|
|
// Note: We intentionally do NOT clean up drafts on unmount
|
|
// Drafts should persist when navigating away so users can resume later
|
|
};
|
|
}, [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 (
|
|
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }} data-scrolling-container="true">
|
|
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
|
|
|
{showEmpty && (
|
|
<Center h="100vh">
|
|
<Stack align="center" gap="md">
|
|
<Text size="lg" c="dimmed">📄</Text>
|
|
<Text c="dimmed">No PDF files loaded</Text>
|
|
<Text size="sm" c="dimmed">Add files to start editing pages</Text>
|
|
</Stack>
|
|
</Center>
|
|
)}
|
|
|
|
{showLoading && (
|
|
<Box p={0}>
|
|
<SkeletonLoader type="controls" />
|
|
|
|
|
|
{/* Progress indicator */}
|
|
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="sm" fw={500}>
|
|
Processing PDF files...
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{Math.round(processingProgress || 0)}%
|
|
</Text>
|
|
</Group>
|
|
<div style={{
|
|
width: '100%',
|
|
height: '4px',
|
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
|
borderRadius: '2px',
|
|
overflow: 'hidden'
|
|
}}>
|
|
<div style={{
|
|
width: `${Math.round(processingProgress || 0)}%`,
|
|
height: '100%',
|
|
backgroundColor: 'var(--mantine-color-blue-6)',
|
|
transition: 'width 0.3s ease'
|
|
}} />
|
|
</div>
|
|
</Box>
|
|
|
|
|
|
<SkeletonLoader type="pageGrid" count={8} />
|
|
</Box>
|
|
)}
|
|
|
|
{displayDocument && (
|
|
<Box p={0}>
|
|
{/* Enhanced Processing Status */}
|
|
{globalProcessing && processingProgress < 100 && (
|
|
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="sm" fw={500}>Processing thumbnails...</Text>
|
|
<Text size="sm" c="dimmed">{Math.round(processingProgress || 0)}%</Text>
|
|
</Group>
|
|
<div style={{
|
|
width: '100%',
|
|
height: '4px',
|
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
|
borderRadius: '2px',
|
|
overflow: 'hidden'
|
|
}}>
|
|
<div style={{
|
|
width: `${Math.round(processingProgress || 0)}%`,
|
|
height: '100%',
|
|
backgroundColor: 'var(--mantine-color-blue-6)',
|
|
transition: 'width 0.3s ease'
|
|
}} />
|
|
</div>
|
|
</Box>
|
|
)}
|
|
|
|
<Group mb="md">
|
|
<TextInput
|
|
value={filename}
|
|
onChange={(e) => setFilename(e.target.value)}
|
|
placeholder="Enter filename"
|
|
style={{ minWidth: 200 }}
|
|
/>
|
|
<Button
|
|
onClick={toggleSelectionMode}
|
|
variant={selectionMode ? "filled" : "outline"}
|
|
color={selectionMode ? "blue" : "gray"}
|
|
styles={{
|
|
root: {
|
|
transition: 'all 0.2s ease',
|
|
...(selectionMode && {
|
|
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
{selectionMode ? "Exit Selection" : "Select Pages"}
|
|
</Button>
|
|
{selectionMode && (
|
|
<>
|
|
<Button onClick={selectAll} variant="light">Select All</Button>
|
|
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
|
</>
|
|
)}
|
|
|
|
|
|
{/* Apply Changes Button */}
|
|
{hasUnsavedChanges && (
|
|
<Button
|
|
onClick={applyChanges}
|
|
color="green"
|
|
variant="filled"
|
|
style={{ marginLeft: 'auto' }}
|
|
>
|
|
Apply Changes
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
|
|
{selectionMode && (
|
|
<BulkSelectionPanel
|
|
csvInput={csvInput}
|
|
setCsvInput={setCsvInput}
|
|
selectedPages={selectedPageNumbers}
|
|
onUpdatePagesFromCSV={updatePagesFromCSV}
|
|
/>
|
|
)}
|
|
|
|
|
|
|
|
<DragDropGrid
|
|
items={displayedPages}
|
|
selectedItems={selectedPageNumbers}
|
|
selectionMode={selectionMode}
|
|
isAnimating={isAnimating}
|
|
onReorderPages={handleReorderPages}
|
|
renderItem={(page, index, refs) => (
|
|
<PageThumbnail
|
|
page={page}
|
|
index={index}
|
|
totalPages={displayDocument.pages.length}
|
|
originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined}
|
|
selectedPages={selectedPageNumbers}
|
|
selectionMode={selectionMode}
|
|
movingPage={movingPage}
|
|
isAnimating={isAnimating}
|
|
pageRefs={refs}
|
|
onReorderPages={handleReorderPages}
|
|
onTogglePage={togglePage}
|
|
onAnimateReorder={animateReorder}
|
|
onExecuteCommand={executeCommand}
|
|
onSetStatus={setStatus}
|
|
onSetMovingPage={setMovingPage}
|
|
RotatePagesCommand={RotatePagesCommand}
|
|
DeletePagesCommand={DeletePagesCommand}
|
|
ToggleSplitCommand={ToggleSplitCommand}
|
|
pdfDocument={displayDocument}
|
|
setPdfDocument={setPdfDocument}
|
|
/>
|
|
)}
|
|
renderSplitMarker={(page, index) => (
|
|
<div
|
|
style={{
|
|
width: '2px',
|
|
height: '20rem',
|
|
borderLeft: '2px dashed #3b82f6',
|
|
backgroundColor: 'transparent',
|
|
marginLeft: '-0.75rem',
|
|
marginRight: '-0.75rem',
|
|
flexShrink: 0
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
</Box>
|
|
)}
|
|
|
|
{/* Modal should be outside the conditional but inside the main container */}
|
|
<Modal
|
|
opened={showExportModal}
|
|
onClose={() => setShowExportModal(false)}
|
|
title="Export Preview"
|
|
>
|
|
{exportPreview && (
|
|
<Stack gap="md">
|
|
<Group justify="space-between">
|
|
<Text>Pages to export:</Text>
|
|
<Text fw={500}>{exportPreview.pageCount}</Text>
|
|
</Group>
|
|
|
|
{exportPreview.splitCount > 1 && (
|
|
<Group justify="space-between">
|
|
<Text>Split into documents:</Text>
|
|
<Text fw={500}>{exportPreview.splitCount}</Text>
|
|
</Group>
|
|
)}
|
|
|
|
<Group justify="space-between">
|
|
<Text>Estimated size:</Text>
|
|
<Text fw={500}>{exportPreview.estimatedSize}</Text>
|
|
</Group>
|
|
|
|
{mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && (
|
|
<Alert color="blue">
|
|
This will create multiple PDF files based on split markers.
|
|
</Alert>
|
|
)}
|
|
|
|
<Group justify="flex-end" mt="md">
|
|
<Button
|
|
variant="light"
|
|
onClick={() => setShowExportModal(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="green"
|
|
loading={exportLoading}
|
|
onClick={() => {
|
|
setShowExportModal(false);
|
|
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
|
|
handleExport(selectedOnly);
|
|
}}
|
|
>
|
|
Export PDF
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Global Navigation Warning Modal */}
|
|
<NavigationWarningModal
|
|
onApplyAndContinue={handleApplyAndContinue}
|
|
onExportAndContinue={handleExportAndContinue}
|
|
/>
|
|
|
|
{/* Resume Work Modal */}
|
|
<Modal
|
|
opened={showResumeModal}
|
|
onClose={startFresh}
|
|
title="Resume Work"
|
|
centered
|
|
closeOnClickOutside={false}
|
|
closeOnEscape={false}
|
|
>
|
|
<Stack gap="md">
|
|
<Text>
|
|
We found unsaved changes from a previous session. Would you like to resume where you left off?
|
|
</Text>
|
|
|
|
|
|
{foundDraft && (
|
|
<Text size="sm" c="dimmed">
|
|
Last saved: {new Date(foundDraft.timestamp).toLocaleString()}
|
|
</Text>
|
|
)}
|
|
|
|
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
variant="light"
|
|
color="gray"
|
|
onClick={startFresh}
|
|
>
|
|
Start Fresh
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
color="blue"
|
|
onClick={resumeWork}
|
|
>
|
|
Resume Work
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{status && (
|
|
<Notification
|
|
color="blue"
|
|
mt="md"
|
|
onClose={() => setStatus(null)}
|
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
|
>
|
|
{status}
|
|
</Notification>
|
|
)}
|
|
|
|
{error && (
|
|
<Notification
|
|
color="red"
|
|
mt="md"
|
|
onClose={() => setError(null)}
|
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
|
>
|
|
{error}
|
|
</Notification>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default PageEditor;
|