mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 12:19:24 +00:00

# Description of Changes Currently, the `tsconfig.json` file enforces strict type checking, but nothing in CI checks that the code is actually correctly typed. [Vite only transpiles TypeScript code](https://vite.dev/guide/features.html#transpile-only) so doesn't ensure that the TS code we're running is correct. This PR adds running of the type checker to CI and fixes the type errors that have already crept into the codebase. Note that many of the changes I've made to 'fix the types' are just using `any` to disable the type checker because the code is under too much churn to fix anything properly at the moment. I still think enabling the type checker now is the best course of action though because otherwise we'll never be able to fix all of them, and it should at least help us not break things when adding new code. Co-authored-by: James <james@crosscourtanalytics.com>
1416 lines
49 KiB
TypeScript
1416 lines
49 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 { useFileContext, useCurrentFile } from "../../contexts/FileContext";
|
|
import { ViewType, ToolType } from "../../types/fileContext";
|
|
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
|
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
|
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
|
import {
|
|
RotatePagesCommand,
|
|
DeletePagesCommand,
|
|
ReorderPageCommand,
|
|
MovePagesCommand,
|
|
ToggleSplitCommand
|
|
} from "../../commands/pageCommands";
|
|
import { pdfExportService } from "../../services/pdfExportService";
|
|
import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration";
|
|
import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils";
|
|
import { fileStorage } from "../../services/fileStorage";
|
|
import './PageEditor.module.css';
|
|
import PageThumbnail from './PageThumbnail';
|
|
import BulkSelectionPanel from './BulkSelectionPanel';
|
|
import DragDropGrid from './DragDropGrid';
|
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
|
import NavigationWarningModal from '../shared/NavigationWarningModal';
|
|
|
|
export interface PageEditorProps {
|
|
// Optional callbacks to expose internal functions for PageEditorControls
|
|
onFunctionsReady?: (functions: {
|
|
handleUndo: () => void;
|
|
handleRedo: () => void;
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
handleRotate: (direction: 'left' | 'right') => void;
|
|
handleDelete: () => void;
|
|
handleSplit: () => void;
|
|
showExportPreview: (selectedOnly: boolean) => void;
|
|
onExportSelected: () => void;
|
|
onExportAll: () => void;
|
|
exportLoading: boolean;
|
|
selectionMode: boolean;
|
|
selectedPages: number[];
|
|
closePdf: () => void;
|
|
}) => void;
|
|
}
|
|
|
|
const PageEditor = ({
|
|
onFunctionsReady,
|
|
}: PageEditorProps) => {
|
|
const { t } = useTranslation();
|
|
|
|
// Get file context
|
|
const fileContext = useFileContext();
|
|
const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
|
|
|
|
// Use file context state
|
|
const {
|
|
activeFiles,
|
|
processedFiles,
|
|
selectedPageNumbers,
|
|
setSelectedPages,
|
|
updateProcessedFile,
|
|
setHasUnsavedChanges,
|
|
hasUnsavedChanges,
|
|
isProcessing: globalProcessing,
|
|
processingProgress,
|
|
clearAllFiles
|
|
} = fileContext;
|
|
|
|
// Edit state management
|
|
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
|
const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false);
|
|
const [showResumeModal, setShowResumeModal] = useState(false);
|
|
const [foundDraft, setFoundDraft] = useState<any>(null);
|
|
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Simple computed document from processed files (no caching needed)
|
|
const mergedPdfDocument = useMemo(() => {
|
|
if (activeFiles.length === 0) return null;
|
|
|
|
if (activeFiles.length === 1) {
|
|
// Single file
|
|
const processedFile = processedFiles.get(activeFiles[0]);
|
|
if (!processedFile) return null;
|
|
|
|
return {
|
|
id: processedFile.id,
|
|
name: activeFiles[0].name,
|
|
file: activeFiles[0],
|
|
pages: processedFile.pages.map(page => ({
|
|
...page,
|
|
rotation: page.rotation || 0,
|
|
splitBefore: page.splitBefore || false
|
|
})),
|
|
totalPages: processedFile.totalPages
|
|
};
|
|
} else {
|
|
// Multiple files - merge them
|
|
const allPages: PDFPage[] = [];
|
|
let totalPages = 0;
|
|
const filenames: string[] = [];
|
|
|
|
activeFiles.forEach((file, i) => {
|
|
const processedFile = processedFiles.get(file);
|
|
if (processedFile) {
|
|
filenames.push(file.name.replace(/\.pdf$/i, ''));
|
|
|
|
processedFile.pages.forEach((page, pageIndex) => {
|
|
const newPage: PDFPage = {
|
|
...page,
|
|
id: `${i}-${page.id}`, // Unique ID across all files
|
|
pageNumber: totalPages + pageIndex + 1,
|
|
rotation: page.rotation || 0,
|
|
splitBefore: page.splitBefore || false
|
|
};
|
|
allPages.push(newPage);
|
|
});
|
|
|
|
totalPages += processedFile.pages.length;
|
|
}
|
|
});
|
|
|
|
if (allPages.length === 0) return null;
|
|
|
|
return {
|
|
id: `merged-${Date.now()}`,
|
|
name: filenames.join(' + '),
|
|
file: activeFiles[0], // Use first file as reference
|
|
pages: allPages,
|
|
totalPages: totalPages
|
|
};
|
|
}
|
|
}, [activeFiles, processedFiles]);
|
|
|
|
// Display document: Use edited version if exists, otherwise original
|
|
const displayDocument = editedDocument || mergedPdfDocument;
|
|
|
|
const [filename, setFilename] = useState<string>("");
|
|
|
|
|
|
// Page editor state (use context for selectedPages)
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
const [csvInput, setCsvInput] = useState<string>("");
|
|
const [selectionMode, setSelectionMode] = useState(false);
|
|
|
|
// Drag and drop state
|
|
const [draggedPage, setDraggedPage] = useState<number | null>(null);
|
|
const [dropTarget, setDropTarget] = useState<number | 'end' | null>(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<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
|
|
useEffect(() => {
|
|
if (mergedPdfDocument) {
|
|
if (activeFiles.length === 1) {
|
|
setFilename(activeFiles[0].name.replace(/\.pdf$/i, ''));
|
|
} else {
|
|
const filenames = activeFiles.map(f => f.name.replace(/\.pdf$/i, ''));
|
|
setFilename(filenames.join('_'));
|
|
}
|
|
}
|
|
}, [mergedPdfDocument, activeFiles]);
|
|
|
|
// Handle file upload from FileUploadSelector (now using context)
|
|
const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
|
if (!uploadedFiles || uploadedFiles.length === 0) {
|
|
setStatus('No files provided');
|
|
return;
|
|
}
|
|
|
|
// Add files to context
|
|
await fileContext.addFiles(uploadedFiles);
|
|
setStatus(`Added ${uploadedFiles.length} file(s) for processing`);
|
|
}, [fileContext]);
|
|
|
|
|
|
// PageEditor no longer handles cleanup - it's centralized in FileContext
|
|
|
|
// Shared PDF instance for thumbnail generation
|
|
const [sharedPdfInstance, setSharedPdfInstance] = useState<any>(null);
|
|
const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false);
|
|
|
|
// Thumbnail generation (opt-in for visual tools)
|
|
const {
|
|
generateThumbnails,
|
|
addThumbnailToCache,
|
|
getThumbnailFromCache,
|
|
stopGeneration,
|
|
destroyThumbnails
|
|
} = useThumbnailGeneration();
|
|
|
|
// Start thumbnail generation process (separate from document loading)
|
|
const startThumbnailGeneration = useCallback(() => {
|
|
console.log('🎬 PageEditor: startThumbnailGeneration called');
|
|
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted);
|
|
|
|
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) {
|
|
console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions');
|
|
return;
|
|
}
|
|
|
|
const file = activeFiles[0];
|
|
const totalPages = mergedPdfDocument.totalPages;
|
|
|
|
console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages');
|
|
setThumbnailGenerationStarted(true);
|
|
|
|
// Run everything asynchronously to avoid blocking the main thread
|
|
setTimeout(async () => {
|
|
try {
|
|
// Load PDF array buffer for Web Workers
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
|
|
// Generate page numbers for pages that don't have thumbnails yet
|
|
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
.filter(pageNum => {
|
|
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
|
return !page?.thumbnail; // Only generate for pages without thumbnails
|
|
});
|
|
|
|
console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : '');
|
|
|
|
// If no pages need thumbnails, we're done
|
|
if (pageNumbers.length === 0) {
|
|
console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed');
|
|
return;
|
|
}
|
|
|
|
// Calculate quality scale based on file size
|
|
const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2;
|
|
|
|
// Start parallel thumbnail generation WITHOUT blocking the main thread
|
|
const generationPromise = generateThumbnails(
|
|
arrayBuffer,
|
|
pageNumbers,
|
|
{
|
|
scale, // Dynamic quality based on file size
|
|
quality: 0.8,
|
|
batchSize: 15, // Smaller batches per worker for smoother UI
|
|
parallelBatches: 3 // Use 3 Web Workers in parallel
|
|
},
|
|
// Progress callback (throttled for better performance)
|
|
(progress) => {
|
|
console.log(`🎬 PageEditor: Progress - ${progress.completed}/${progress.total} pages, ${progress.thumbnails.length} new thumbnails`);
|
|
// Batch process thumbnails to reduce main thread work
|
|
requestAnimationFrame(() => {
|
|
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
|
|
// Check cache first, then send thumbnail
|
|
const pageId = `${file.name}-page-${pageNumber}`;
|
|
const cached = getThumbnailFromCache(pageId);
|
|
|
|
if (!cached) {
|
|
// Cache and send to component
|
|
addThumbnailToCache(pageId, thumbnail);
|
|
|
|
window.dispatchEvent(new CustomEvent('thumbnailReady', {
|
|
detail: { pageNumber, thumbnail, pageId }
|
|
}));
|
|
console.log(`✓ PageEditor: Dispatched thumbnail for page ${pageNumber}`);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
);
|
|
|
|
// Handle completion properly
|
|
generationPromise
|
|
.then((allThumbnails) => {
|
|
console.log(`✅ PageEditor: Thumbnail generation completed! Generated ${allThumbnails.length} thumbnails`);
|
|
// Don't reset thumbnailGenerationStarted here - let it stay true to prevent restarts
|
|
})
|
|
.catch(error => {
|
|
console.error('✗ PageEditor: Web Worker thumbnail generation failed:', error);
|
|
setThumbnailGenerationStarted(false);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Failed to start Web Worker thumbnail generation:', error);
|
|
setThumbnailGenerationStarted(false);
|
|
}
|
|
}, 0); // setTimeout with 0ms to defer to next tick
|
|
}, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted, getThumbnailFromCache, addThumbnailToCache]);
|
|
|
|
// Start thumbnail generation after document loads
|
|
useEffect(() => {
|
|
console.log('🎬 PageEditor: Thumbnail generation effect triggered');
|
|
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted);
|
|
|
|
if (mergedPdfDocument && !thumbnailGenerationStarted) {
|
|
// Check if ALL pages already have thumbnails from processed files
|
|
const totalPages = mergedPdfDocument.pages.length;
|
|
const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length;
|
|
const hasAllThumbnails = pagesWithThumbnails === totalPages;
|
|
|
|
console.log('🎬 PageEditor: Thumbnail status:', {
|
|
totalPages,
|
|
pagesWithThumbnails,
|
|
hasAllThumbnails,
|
|
missingThumbnails: totalPages - pagesWithThumbnails
|
|
});
|
|
|
|
if (hasAllThumbnails) {
|
|
console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist');
|
|
return; // Skip generation if ALL thumbnails already exist
|
|
}
|
|
|
|
console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation');
|
|
// Small delay to let document render, then start thumbnail generation
|
|
console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms');
|
|
const timer = setTimeout(startThumbnailGeneration, 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]);
|
|
|
|
// Cleanup shared PDF instance when component unmounts (but preserve cache)
|
|
useEffect(() => {
|
|
return () => {
|
|
if (sharedPdfInstance) {
|
|
sharedPdfInstance.destroy();
|
|
setSharedPdfInstance(null);
|
|
}
|
|
setThumbnailGenerationStarted(false);
|
|
// DON'T stop generation on file changes - preserve cache for view switching
|
|
// stopGeneration();
|
|
};
|
|
}, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles
|
|
|
|
// Clear selections when files change
|
|
useEffect(() => {
|
|
setSelectedPages([]);
|
|
setCsvInput("");
|
|
setSelectionMode(false);
|
|
}, [activeFiles, setSelectedPages]);
|
|
|
|
// Sync csvInput with selectedPageNumbers changes
|
|
useEffect(() => {
|
|
// Simply sort the page numbers and join them
|
|
const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b);
|
|
const newCsvInput = sortedPageNumbers.join(', ');
|
|
setCsvInput(newCsvInput);
|
|
}, [selectedPageNumbers]);
|
|
|
|
useEffect(() => {
|
|
const handleGlobalDragEnd = () => {
|
|
// Clean up drag state when drag operation ends anywhere
|
|
setDraggedPage(null);
|
|
setDropTarget(null);
|
|
setMultiPageDrag(null);
|
|
setDragPosition(null);
|
|
};
|
|
|
|
const handleGlobalDrop = (e: DragEvent) => {
|
|
// Prevent default to handle invalid drops
|
|
e.preventDefault();
|
|
};
|
|
|
|
if (draggedPage) {
|
|
document.addEventListener('dragend', handleGlobalDragEnd);
|
|
document.addEventListener('drop', handleGlobalDrop);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('dragend', handleGlobalDragEnd);
|
|
document.removeEventListener('drop', handleGlobalDrop);
|
|
};
|
|
}, [draggedPage]);
|
|
|
|
const selectAll = useCallback(() => {
|
|
if (mergedPdfDocument) {
|
|
setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber));
|
|
}
|
|
}, [mergedPdfDocument, setSelectedPages]);
|
|
|
|
const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]);
|
|
|
|
const togglePage = useCallback((pageNumber: number) => {
|
|
console.log('🔄 Toggling page', pageNumber);
|
|
|
|
// Check if currently selected and update accordingly
|
|
const isCurrentlySelected = selectedPageNumbers.includes(pageNumber);
|
|
|
|
if (isCurrentlySelected) {
|
|
// Remove from selection
|
|
console.log('🔄 Removing page', pageNumber);
|
|
const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber);
|
|
setSelectedPages(newSelectedPageNumbers);
|
|
} else {
|
|
// Add to selection
|
|
console.log('🔄 Adding page', pageNumber);
|
|
const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber];
|
|
setSelectedPages(newSelectedPageNumbers);
|
|
}
|
|
}, [selectedPageNumbers, setSelectedPages]);
|
|
|
|
const toggleSelectionMode = useCallback(() => {
|
|
setSelectionMode(prev => {
|
|
const newMode = !prev;
|
|
if (!newMode) {
|
|
// Clear selections when exiting selection mode
|
|
setSelectedPages([]);
|
|
setCsvInput("");
|
|
}
|
|
return newMode;
|
|
});
|
|
}, []);
|
|
|
|
const parseCSVInput = useCallback((csv: string) => {
|
|
if (!mergedPdfDocument) return [];
|
|
|
|
const pageNumbers: number[] = [];
|
|
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
|
|
|
ranges.forEach(range => {
|
|
if (range.includes('-')) {
|
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
|
for (let i = start; i <= end && i <= mergedPdfDocument.totalPages; i++) {
|
|
if (i > 0) {
|
|
pageNumbers.push(i);
|
|
}
|
|
}
|
|
} else {
|
|
const pageNum = parseInt(range);
|
|
if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) {
|
|
pageNumbers.push(pageNum);
|
|
}
|
|
}
|
|
});
|
|
|
|
return pageNumbers;
|
|
}, [mergedPdfDocument]);
|
|
|
|
const updatePagesFromCSV = useCallback(() => {
|
|
const pageNumbers = parseCSVInput(csvInput);
|
|
setSelectedPages(pageNumbers);
|
|
}, [csvInput, parseCSVInput, setSelectedPages]);
|
|
|
|
const handleDragStart = useCallback((pageNumber: number) => {
|
|
setDraggedPage(pageNumber);
|
|
|
|
// Check if this is a multi-page drag in selection mode
|
|
if (selectionMode && selectedPageNumbers.includes(pageNumber) && selectedPageNumbers.length > 1) {
|
|
setMultiPageDrag({
|
|
pageNumbers: selectedPageNumbers,
|
|
count: selectedPageNumbers.length
|
|
});
|
|
} else {
|
|
setMultiPageDrag(null);
|
|
}
|
|
}, [selectionMode, selectedPageNumbers]);
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
// Clean up drag state regardless of where the drop happened
|
|
setDraggedPage(null);
|
|
setDropTarget(null);
|
|
setMultiPageDrag(null);
|
|
setDragPosition(null);
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!draggedPage) return;
|
|
|
|
// Update drag position for multi-page indicator
|
|
if (multiPageDrag) {
|
|
setDragPosition({ x: e.clientX, y: e.clientY });
|
|
}
|
|
|
|
// Get the element under the mouse cursor
|
|
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
|
if (!elementUnderCursor) return;
|
|
|
|
// Find the closest page container
|
|
const pageContainer = elementUnderCursor.closest('[data-page-number]');
|
|
if (pageContainer) {
|
|
const pageNumberStr = pageContainer.getAttribute('data-page-number');
|
|
const pageNumber = pageNumberStr ? parseInt(pageNumberStr) : null;
|
|
if (pageNumber && pageNumber !== draggedPage) {
|
|
setDropTarget(pageNumber);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check if over the end zone
|
|
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
|
if (endZone) {
|
|
setDropTarget('end');
|
|
return;
|
|
}
|
|
|
|
// If not over any valid drop target, clear it
|
|
setDropTarget(null);
|
|
}, [draggedPage, multiPageDrag]);
|
|
|
|
const handleDragEnter = useCallback((pageNumber: number) => {
|
|
if (draggedPage && pageNumber !== draggedPage) {
|
|
setDropTarget(pageNumber);
|
|
}
|
|
}, [draggedPage]);
|
|
|
|
const handleDragLeave = useCallback(() => {
|
|
// Don't clear drop target on drag leave - let dragover handle it
|
|
}, []);
|
|
|
|
// Update PDF document state with edit tracking
|
|
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
|
|
console.log('setPdfDocument called - setting edited state');
|
|
|
|
// Update local edit state for immediate visual feedback
|
|
setEditedDocument(updatedDoc);
|
|
setHasUnsavedChanges(true); // Use global state
|
|
setHasUnsavedDraft(true); // Mark that we have unsaved draft changes
|
|
|
|
// Auto-save to drafts (debounced) - only if we have new changes
|
|
if (autoSaveTimer.current) {
|
|
clearTimeout(autoSaveTimer.current);
|
|
}
|
|
|
|
autoSaveTimer.current = setTimeout(() => {
|
|
if (hasUnsavedDraft) {
|
|
saveDraftToIndexedDB(updatedDoc);
|
|
setHasUnsavedDraft(false); // Mark draft as saved
|
|
}
|
|
}, 30000); // Auto-save after 30 seconds of inactivity
|
|
|
|
return updatedDoc;
|
|
}, [setHasUnsavedChanges, hasUnsavedDraft]);
|
|
|
|
// Save draft to separate IndexedDB location
|
|
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
|
|
try {
|
|
const draftKey = `draft-${doc.id || 'merged'}`;
|
|
const draftData = {
|
|
document: doc,
|
|
timestamp: Date.now(),
|
|
originalFiles: activeFiles.map(f => f.name)
|
|
};
|
|
|
|
// Save to 'pdf-drafts' store in IndexedDB
|
|
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains('drafts')) {
|
|
db.createObjectStore('drafts');
|
|
}
|
|
};
|
|
|
|
request.onsuccess = () => {
|
|
const db = request.result;
|
|
const transaction = db.transaction('drafts', 'readwrite');
|
|
const store = transaction.objectStore('drafts');
|
|
store.put(draftData, draftKey);
|
|
console.log('Draft auto-saved to IndexedDB');
|
|
};
|
|
} catch (error) {
|
|
console.warn('Failed to auto-save draft:', error);
|
|
}
|
|
}, [activeFiles]);
|
|
|
|
// Clean up draft from IndexedDB
|
|
const cleanupDraft = useCallback(async () => {
|
|
try {
|
|
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
|
|
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
|
|
|
request.onsuccess = () => {
|
|
const db = request.result;
|
|
const transaction = db.transaction('drafts', 'readwrite');
|
|
const store = transaction.objectStore('drafts');
|
|
store.delete(draftKey);
|
|
};
|
|
} catch (error) {
|
|
console.warn('Failed to cleanup draft:', error);
|
|
}
|
|
}, [mergedPdfDocument]);
|
|
|
|
// Apply changes to create new processed file
|
|
const applyChanges = useCallback(async () => {
|
|
if (!editedDocument || !mergedPdfDocument) return;
|
|
|
|
try {
|
|
if (activeFiles.length === 1) {
|
|
const file = activeFiles[0];
|
|
const currentProcessedFile = processedFiles.get(file);
|
|
|
|
if (currentProcessedFile) {
|
|
const updatedProcessedFile = {
|
|
...currentProcessedFile,
|
|
id: `${currentProcessedFile.id}-edited-${Date.now()}`,
|
|
pages: editedDocument.pages.map(page => ({
|
|
...page,
|
|
rotation: page.rotation || 0,
|
|
splitBefore: page.splitBefore || false
|
|
})),
|
|
totalPages: editedDocument.pages.length,
|
|
lastModified: Date.now()
|
|
};
|
|
|
|
updateProcessedFile(file, updatedProcessedFile);
|
|
}
|
|
} else if (activeFiles.length > 1) {
|
|
setStatus('Apply changes for multiple files not yet supported');
|
|
return;
|
|
}
|
|
|
|
// Wait for the processed file update to complete before clearing edit state
|
|
setTimeout(() => {
|
|
setEditedDocument(null);
|
|
setHasUnsavedChanges(false);
|
|
setHasUnsavedDraft(false);
|
|
cleanupDraft();
|
|
setStatus('Changes applied successfully');
|
|
}, 100);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to apply changes:', error);
|
|
setStatus('Failed to apply changes');
|
|
}
|
|
}, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, updateProcessedFile, setHasUnsavedChanges, setStatus, cleanupDraft]);
|
|
|
|
const animateReorder = useCallback((pageNumber: number, targetIndex: number) => {
|
|
if (!displayDocument || isAnimating) return;
|
|
|
|
// In selection mode, if the dragged page is selected, move all selected pages
|
|
const pagesToMove = selectionMode && selectedPageNumbers.includes(pageNumber)
|
|
? selectedPageNumbers.map(num => {
|
|
const page = displayDocument.pages.find(p => p.pageNumber === num);
|
|
return page?.id || '';
|
|
}).filter(id => id)
|
|
: [displayDocument.pages.find(p => p.pageNumber === pageNumber)?.id || ''].filter(id => id);
|
|
|
|
const originalIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNumber);
|
|
if (originalIndex === -1 || originalIndex === targetIndex) return;
|
|
|
|
// Skip animation for large documents (500+ pages) to improve performance
|
|
const isLargeDocument = displayDocument.pages.length > 500;
|
|
|
|
if (isLargeDocument) {
|
|
// For large documents, just execute the command without animation
|
|
if (pagesToMove.length > 1) {
|
|
const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex);
|
|
executeCommand(command);
|
|
} else {
|
|
const pageId = pagesToMove[0];
|
|
const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex);
|
|
executeCommand(command);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setIsAnimating(true);
|
|
|
|
// For smaller documents, determine which pages might be affected by the move
|
|
const startIndex = Math.min(originalIndex, targetIndex);
|
|
const endIndex = Math.max(originalIndex, targetIndex);
|
|
const affectedPageIds = displayDocument.pages
|
|
.slice(Math.max(0, startIndex - 5), Math.min(displayDocument.pages.length, endIndex + 5))
|
|
.map(p => p.id);
|
|
|
|
// Only capture positions for potentially affected pages
|
|
const currentPositions = new Map<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 handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => {
|
|
e.preventDefault();
|
|
if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return;
|
|
|
|
let targetIndex: number;
|
|
if (targetPageNumber === 'end') {
|
|
targetIndex = displayDocument.pages.length;
|
|
} else {
|
|
targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
|
if (targetIndex === -1) return;
|
|
}
|
|
|
|
animateReorder(draggedPage, targetIndex);
|
|
|
|
setDraggedPage(null);
|
|
setDropTarget(null);
|
|
setMultiPageDrag(null);
|
|
setDragPosition(null);
|
|
|
|
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
|
|
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
|
}, [draggedPage, displayDocument, animateReorder, multiPageDrag]);
|
|
|
|
const handleEndZoneDragEnter = useCallback(() => {
|
|
if (draggedPage) {
|
|
setDropTarget('end');
|
|
}
|
|
}, [draggedPage]);
|
|
|
|
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
|
if (!displayDocument) return;
|
|
|
|
const rotation = direction === 'left' ? -90 : 90;
|
|
const pagesToRotate = selectionMode
|
|
? selectedPageNumbers.map(pageNum => {
|
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
|
return page?.id || '';
|
|
}).filter(id => id)
|
|
: displayDocument.pages.map(p => p.id);
|
|
|
|
if (selectionMode && selectedPageNumbers.length === 0) return;
|
|
|
|
const command = new RotatePagesCommand(
|
|
displayDocument,
|
|
setPdfDocument,
|
|
pagesToRotate,
|
|
rotation
|
|
);
|
|
|
|
executeCommand(command);
|
|
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
|
setStatus(`Rotated ${pageCount} pages ${direction}`);
|
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
|
|
|
const handleDelete = useCallback(() => {
|
|
if (!displayDocument) return;
|
|
|
|
const pagesToDelete = selectionMode
|
|
? selectedPageNumbers.map(pageNum => {
|
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
|
return page?.id || '';
|
|
}).filter(id => id)
|
|
: displayDocument.pages.map(p => p.id);
|
|
|
|
if (selectionMode && selectedPageNumbers.length === 0) return;
|
|
|
|
const command = new DeletePagesCommand(
|
|
displayDocument,
|
|
setPdfDocument,
|
|
pagesToDelete
|
|
);
|
|
|
|
executeCommand(command);
|
|
if (selectionMode) {
|
|
setSelectedPages([]);
|
|
}
|
|
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
|
setStatus(`Deleted ${pageCount} pages`);
|
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]);
|
|
|
|
const handleSplit = useCallback(() => {
|
|
if (!displayDocument) return;
|
|
|
|
const pagesToSplit = selectionMode
|
|
? selectedPageNumbers.map(pageNum => {
|
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
|
return page?.id || '';
|
|
}).filter(id => id)
|
|
: displayDocument.pages.map(p => p.id);
|
|
|
|
if (selectionMode && selectedPageNumbers.length === 0) return;
|
|
|
|
const command = new ToggleSplitCommand(
|
|
displayDocument,
|
|
setPdfDocument,
|
|
pagesToSplit
|
|
);
|
|
|
|
executeCommand(command);
|
|
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
|
setStatus(`Split markers toggled for ${pageCount} pages`);
|
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
|
|
|
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
|
if (!mergedPdfDocument) return;
|
|
|
|
// Convert page numbers to page IDs for export service
|
|
const exportPageIds = selectedOnly
|
|
? selectedPageNumbers.map(pageNum => {
|
|
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
|
return page?.id || '';
|
|
}).filter(id => id)
|
|
: [];
|
|
|
|
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
|
|
setExportPreview(preview);
|
|
setShowExportModal(true);
|
|
}, [mergedPdfDocument, selectedPageNumbers]);
|
|
|
|
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
|
if (!mergedPdfDocument) return;
|
|
|
|
setExportLoading(true);
|
|
try {
|
|
// Convert page numbers to page IDs for export service
|
|
const exportPageIds = selectedOnly
|
|
? selectedPageNumbers.map(pageNum => {
|
|
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
|
return page?.id || '';
|
|
}).filter(id => id)
|
|
: [];
|
|
|
|
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
|
|
if (errors.length > 0) {
|
|
setStatus(errors.join(', '));
|
|
return;
|
|
}
|
|
|
|
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
|
|
|
|
if (hasSplitMarkers) {
|
|
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
|
selectedOnly,
|
|
filename,
|
|
splitDocuments: true
|
|
}) as { blobs: Blob[]; filenames: string[] };
|
|
|
|
result.blobs.forEach((blob, index) => {
|
|
setTimeout(() => {
|
|
pdfExportService.downloadFile(blob, result.filenames[index]);
|
|
}, index * 500);
|
|
});
|
|
|
|
setStatus(`Exported ${result.blobs.length} split documents`);
|
|
} else {
|
|
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
|
selectedOnly,
|
|
filename
|
|
}) as { blob: Blob; filename: string };
|
|
|
|
pdfExportService.downloadFile(result.blob, result.filename);
|
|
setStatus('PDF exported successfully');
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Export failed';
|
|
setStatus(errorMessage);
|
|
} finally {
|
|
setExportLoading(false);
|
|
}
|
|
}, [mergedPdfDocument, selectedPageNumbers, filename]);
|
|
|
|
const handleUndo = useCallback(() => {
|
|
if (undo()) {
|
|
setStatus('Operation undone');
|
|
}
|
|
}, [undo]);
|
|
|
|
const handleRedo = useCallback(() => {
|
|
if (redo()) {
|
|
setStatus('Operation redone');
|
|
}
|
|
}, [redo]);
|
|
|
|
const closePdf = useCallback(() => {
|
|
// Use global navigation guard system
|
|
fileContext.requestNavigation(() => {
|
|
clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
|
|
setSelectedPages([]);
|
|
});
|
|
}, [fileContext, clearAllFiles, setSelectedPages]);
|
|
|
|
// PageEditorControls needs onExportSelected and onExportAll
|
|
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
|
|
const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]);
|
|
|
|
// Expose functions to parent component for PageEditorControls
|
|
useEffect(() => {
|
|
if (onFunctionsReady) {
|
|
onFunctionsReady({
|
|
handleUndo,
|
|
handleRedo,
|
|
canUndo,
|
|
canRedo,
|
|
handleRotate,
|
|
handleDelete,
|
|
handleSplit,
|
|
showExportPreview,
|
|
onExportSelected,
|
|
onExportAll,
|
|
exportLoading,
|
|
selectionMode,
|
|
selectedPages: selectedPageNumbers,
|
|
closePdf,
|
|
});
|
|
}
|
|
}, [
|
|
onFunctionsReady,
|
|
handleUndo,
|
|
handleRedo,
|
|
canUndo,
|
|
canRedo,
|
|
handleRotate,
|
|
handleDelete,
|
|
handleSplit,
|
|
showExportPreview,
|
|
onExportSelected,
|
|
onExportAll,
|
|
exportLoading,
|
|
selectionMode,
|
|
selectedPageNumbers,
|
|
closePdf
|
|
]);
|
|
|
|
// Show loading or empty state instead of blocking
|
|
const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0);
|
|
const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0;
|
|
// Functions for global NavigationWarningModal
|
|
const handleApplyAndContinue = useCallback(async () => {
|
|
if (editedDocument) {
|
|
await applyChanges();
|
|
}
|
|
}, [editedDocument, applyChanges]);
|
|
|
|
const handleExportAndContinue = useCallback(async () => {
|
|
if (editedDocument) {
|
|
await applyChanges();
|
|
await handleExport(false);
|
|
}
|
|
}, [editedDocument, applyChanges, handleExport]);
|
|
|
|
// Check for existing drafts
|
|
const checkForDrafts = useCallback(async () => {
|
|
if (!mergedPdfDocument) return;
|
|
|
|
try {
|
|
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
|
|
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
|
|
|
request.onsuccess = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains('drafts')) return;
|
|
|
|
const transaction = db.transaction('drafts', 'readonly');
|
|
const store = transaction.objectStore('drafts');
|
|
const getRequest = store.get(draftKey);
|
|
|
|
getRequest.onsuccess = () => {
|
|
const draft = getRequest.result;
|
|
if (draft && draft.timestamp) {
|
|
// Check if draft is recent (within last 24 hours)
|
|
const draftAge = Date.now() - draft.timestamp;
|
|
const twentyFourHours = 24 * 60 * 60 * 1000;
|
|
|
|
if (draftAge < twentyFourHours) {
|
|
setFoundDraft(draft);
|
|
setShowResumeModal(true);
|
|
}
|
|
}
|
|
};
|
|
};
|
|
} catch (error) {
|
|
console.warn('Failed to check for drafts:', error);
|
|
}
|
|
}, [mergedPdfDocument]);
|
|
|
|
// Resume work from draft
|
|
const resumeWork = useCallback(() => {
|
|
if (foundDraft && foundDraft.document) {
|
|
setEditedDocument(foundDraft.document);
|
|
setHasUnsavedChanges(true);
|
|
setFoundDraft(null);
|
|
setShowResumeModal(false);
|
|
setStatus('Resumed previous work');
|
|
}
|
|
}, [foundDraft]);
|
|
|
|
// Start fresh (ignore draft)
|
|
const startFresh = useCallback(() => {
|
|
if (foundDraft) {
|
|
// Clean up the draft
|
|
cleanupDraft();
|
|
}
|
|
setFoundDraft(null);
|
|
setShowResumeModal(false);
|
|
}, [foundDraft, cleanupDraft]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
console.log('PageEditor unmounting - cleaning up resources');
|
|
|
|
// Clear auto-save timer
|
|
if (autoSaveTimer.current) {
|
|
clearTimeout(autoSaveTimer.current);
|
|
}
|
|
|
|
// Clean up draft if component unmounts with unsaved changes
|
|
if (hasUnsavedChanges) {
|
|
cleanupDraft();
|
|
}
|
|
};
|
|
}, [hasUnsavedChanges, cleanupDraft]);
|
|
|
|
// Check for drafts when document loads
|
|
useEffect(() => {
|
|
if (mergedPdfDocument && !editedDocument && !hasUnsavedChanges) {
|
|
// Small delay to let the component settle
|
|
setTimeout(checkForDrafts, 1000);
|
|
}
|
|
}, [mergedPdfDocument, editedDocument, hasUnsavedChanges, checkForDrafts]);
|
|
|
|
// Global navigation intercept - listen for navigation events
|
|
useEffect(() => {
|
|
if (!hasUnsavedChanges) return;
|
|
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
e.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
|
|
return 'You have unsaved changes. Are you sure you want to leave?';
|
|
};
|
|
|
|
// Intercept browser navigation
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
return () => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
};
|
|
}, [hasUnsavedChanges]);
|
|
|
|
// Display all pages - use edited or original document
|
|
const displayedPages = displayDocument?.pages || [];
|
|
|
|
return (
|
|
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
|
<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="md" pt="xl">
|
|
<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="md" pt="xl">
|
|
{/* 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}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={handleDragOver}
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onEndZoneDragEnter={handleEndZoneDragEnter}
|
|
draggedItem={draggedPage}
|
|
dropTarget={dropTarget}
|
|
multiItemDrag={multiPageDrag}
|
|
dragPosition={dragPosition}
|
|
renderItem={(page, index, refs) => (
|
|
<PageThumbnail
|
|
page={page}
|
|
index={index}
|
|
totalPages={displayDocument.pages.length}
|
|
originalFile={activeFiles.length === 1 ? activeFiles[0] : undefined}
|
|
selectedPages={selectedPageNumbers}
|
|
selectionMode={selectionMode}
|
|
draggedPage={draggedPage}
|
|
dropTarget={dropTarget === 'end' ? null : dropTarget}
|
|
movingPage={movingPage}
|
|
isAnimating={isAnimating}
|
|
pageRefs={refs}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={handleDragOver}
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
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?.totalPages || 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>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default PageEditor;
|