2025-07-16 17:53:50 +01:00
|
|
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
2025-05-21 21:47:44 +01:00
|
|
|
import {
|
2025-06-16 15:11:00 +01:00
|
|
|
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
2025-07-16 17:53:50 +01:00
|
|
|
Notification, TextInput, LoadingOverlay, Modal, Alert,
|
|
|
|
Stack, Group
|
2025-05-21 21:47:44 +01:00
|
|
|
} from "@mantine/core";
|
2025-05-29 17:26:32 +01:00
|
|
|
import { useTranslation } from "react-i18next";
|
2025-07-16 17:53:50 +01:00
|
|
|
import { useFileContext, useCurrentFile } from "../../contexts/FileContext";
|
|
|
|
import { ViewType, ToolType } from "../../types/fileContext";
|
2025-06-19 22:41:05 +01:00
|
|
|
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
2025-07-16 17:53:50 +01:00
|
|
|
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
2025-06-19 22:41:05 +01:00
|
|
|
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
2025-06-16 15:11:00 +01:00
|
|
|
import {
|
|
|
|
RotatePagesCommand,
|
|
|
|
DeletePagesCommand,
|
|
|
|
ReorderPageCommand,
|
2025-06-18 18:12:15 +01:00
|
|
|
MovePagesCommand,
|
2025-06-16 15:11:00 +01:00
|
|
|
ToggleSplitCommand
|
2025-06-19 22:41:05 +01:00
|
|
|
} from "../../commands/pageCommands";
|
|
|
|
import { pdfExportService } from "../../services/pdfExportService";
|
2025-07-16 17:53:50 +01:00
|
|
|
import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration";
|
|
|
|
import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils";
|
|
|
|
import { fileStorage } from "../../services/fileStorage";
|
2025-07-16 23:09:26 +01:00
|
|
|
import './PageEditor.module.css';
|
2025-06-20 17:51:24 +01:00
|
|
|
import PageThumbnail from './PageThumbnail';
|
|
|
|
import BulkSelectionPanel from './BulkSelectionPanel';
|
2025-06-24 23:31:21 +01:00
|
|
|
import DragDropGrid from './DragDropGrid';
|
2025-07-16 17:53:50 +01:00
|
|
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
|
|
|
import NavigationWarningModal from '../shared/NavigationWarningModal';
|
2025-05-21 21:47:44 +01:00
|
|
|
|
|
|
|
export interface PageEditorProps {
|
2025-06-24 20:25:03 +01:00
|
|
|
// Optional callbacks to expose internal functions for PageEditorControls
|
2025-06-19 19:47:44 +01:00
|
|
|
onFunctionsReady?: (functions: {
|
|
|
|
handleUndo: () => void;
|
|
|
|
handleRedo: () => void;
|
|
|
|
canUndo: boolean;
|
|
|
|
canRedo: boolean;
|
|
|
|
handleRotate: (direction: 'left' | 'right') => void;
|
|
|
|
handleDelete: () => void;
|
|
|
|
handleSplit: () => void;
|
|
|
|
showExportPreview: (selectedOnly: boolean) => void;
|
2025-06-24 20:25:03 +01:00
|
|
|
onExportSelected: () => void;
|
|
|
|
onExportAll: () => void;
|
2025-06-19 19:47:44 +01:00
|
|
|
exportLoading: boolean;
|
|
|
|
selectionMode: boolean;
|
|
|
|
selectedPages: string[];
|
|
|
|
closePdf: () => void;
|
|
|
|
}) => void;
|
2025-05-21 21:47:44 +01:00
|
|
|
}
|
|
|
|
|
2025-06-19 19:47:44 +01:00
|
|
|
const PageEditor = ({
|
|
|
|
onFunctionsReady,
|
|
|
|
}: PageEditorProps) => {
|
2025-05-29 17:26:32 +01:00
|
|
|
const { t } = useTranslation();
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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;
|
|
|
|
|
2025-06-24 20:25:03 +01:00
|
|
|
const [filename, setFilename] = useState<string>("");
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-06-24 20:25:03 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Page editor state (use context for selectedPages)
|
2025-05-21 21:47:44 +01:00
|
|
|
const [status, setStatus] = useState<string | null>(null);
|
2025-06-10 11:19:54 +01:00
|
|
|
const [csvInput, setCsvInput] = useState<string>("");
|
2025-06-18 18:12:15 +01:00
|
|
|
const [selectionMode, setSelectionMode] = useState(false);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-06-24 20:25:03 +01:00
|
|
|
// Drag and drop state
|
2025-07-16 17:53:50 +01:00
|
|
|
const [draggedPage, setDraggedPage] = useState<number | null>(null);
|
|
|
|
const [dropTarget, setDropTarget] = useState<number | null>(null);
|
|
|
|
const [multiPageDrag, setMultiPageDrag] = useState<{pageNumbers: number[], count: number} | null>(null);
|
2025-06-18 18:12:15 +01:00
|
|
|
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-06-24 20:25:03 +01:00
|
|
|
// Export state
|
2025-06-10 11:19:54 +01:00
|
|
|
const [exportLoading, setExportLoading] = useState(false);
|
|
|
|
const [showExportModal, setShowExportModal] = useState(false);
|
|
|
|
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-06-24 20:25:03 +01:00
|
|
|
// Animation state
|
2025-07-16 17:53:50 +01:00
|
|
|
const [movingPage, setMovingPage] = useState<number | null>(null);
|
2025-06-18 18:12:15 +01:00
|
|
|
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());
|
2025-06-10 11:19:54 +01:00
|
|
|
const fileInputRef = useRef<() => void>(null);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
// Undo/Redo system
|
|
|
|
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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('_'));
|
2025-06-20 17:51:24 +01:00
|
|
|
}
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [mergedPdfDocument, activeFiles]);
|
2025-06-20 17:51:24 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Handle file upload from FileUploadSelector (now using context)
|
|
|
|
const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
|
|
|
if (!uploadedFiles || uploadedFiles.length === 0) {
|
|
|
|
setStatus('No files provided');
|
2025-06-10 11:19:54 +01:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Add files to context
|
|
|
|
await fileContext.addFiles(uploadedFiles);
|
|
|
|
setStatus(`Added ${uploadedFiles.length} file(s) for processing`);
|
|
|
|
}, [fileContext]);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// PageEditor no longer handles cleanup - it's centralized in FileContext
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Shared PDF instance for thumbnail generation
|
|
|
|
const [sharedPdfInstance, setSharedPdfInstance] = useState<any>(null);
|
|
|
|
const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Thumbnail generation (opt-in for visual tools)
|
|
|
|
const {
|
|
|
|
generateThumbnails,
|
|
|
|
addThumbnailToCache,
|
|
|
|
getThumbnailFromCache,
|
|
|
|
stopGeneration,
|
|
|
|
destroyThumbnails
|
|
|
|
} = useThumbnailGeneration();
|
2025-06-24 20:25:03 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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');
|
2025-06-24 23:31:21 +01:00
|
|
|
return;
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
|
|
|
|
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
|
2025-06-24 23:31:21 +01:00
|
|
|
});
|
2025-07-16 17:53:50 +01:00
|
|
|
|
|
|
|
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;
|
2025-06-24 23:31:21 +01:00
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
|
|
|
|
// 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]);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Start thumbnail generation after document loads
|
2025-06-24 20:25:03 +01:00
|
|
|
useEffect(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
console.log('🎬 PageEditor: Thumbnail generation effect triggered');
|
|
|
|
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
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
|
2025-06-24 20:25:03 +01:00
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
|
|
|
|
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]);
|
2025-06-24 20:25:03 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Cleanup shared PDF instance when component unmounts (but preserve cache)
|
2025-06-24 20:25:03 +01:00
|
|
|
useEffect(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
return () => {
|
|
|
|
if (sharedPdfInstance) {
|
|
|
|
sharedPdfInstance.destroy();
|
|
|
|
setSharedPdfInstance(null);
|
2025-06-24 23:31:21 +01:00
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
setThumbnailGenerationStarted(false);
|
|
|
|
// DON'T stop generation on file changes - preserve cache for view switching
|
|
|
|
// stopGeneration();
|
|
|
|
};
|
|
|
|
}, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles
|
2025-06-24 20:25:03 +01:00
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
// Clear selections when files change
|
2025-06-24 20:25:03 +01:00
|
|
|
useEffect(() => {
|
|
|
|
setSelectedPages([]);
|
|
|
|
setCsvInput("");
|
|
|
|
setSelectionMode(false);
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [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]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
useEffect(() => {
|
|
|
|
const handleGlobalDragEnd = () => {
|
|
|
|
// Clean up drag state when drag operation ends anywhere
|
|
|
|
setDraggedPage(null);
|
|
|
|
setDropTarget(null);
|
|
|
|
setMultiPageDrag(null);
|
|
|
|
setDragPosition(null);
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleGlobalDrop = (e: DragEvent) => {
|
2025-07-16 17:53:50 +01:00
|
|
|
// Prevent default to handle invalid drops
|
2025-06-18 18:12:15 +01:00
|
|
|
e.preventDefault();
|
|
|
|
};
|
|
|
|
|
|
|
|
if (draggedPage) {
|
|
|
|
document.addEventListener('dragend', handleGlobalDragEnd);
|
|
|
|
document.addEventListener('drop', handleGlobalDrop);
|
|
|
|
}
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
document.removeEventListener('dragend', handleGlobalDragEnd);
|
|
|
|
document.removeEventListener('drop', handleGlobalDrop);
|
|
|
|
};
|
|
|
|
}, [draggedPage]);
|
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const selectAll = useCallback(() => {
|
2025-06-24 23:31:21 +01:00
|
|
|
if (mergedPdfDocument) {
|
2025-07-16 17:53:50 +01:00
|
|
|
setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber));
|
2025-06-10 11:19:54 +01:00
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [mergedPdfDocument, setSelectedPages]);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
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]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
const toggleSelectionMode = useCallback(() => {
|
|
|
|
setSelectionMode(prev => {
|
|
|
|
const newMode = !prev;
|
|
|
|
if (!newMode) {
|
|
|
|
// Clear selections when exiting selection mode
|
|
|
|
setSelectedPages([]);
|
|
|
|
setCsvInput("");
|
|
|
|
}
|
|
|
|
return newMode;
|
|
|
|
});
|
|
|
|
}, []);
|
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const parseCSVInput = useCallback((csv: string) => {
|
2025-06-24 23:31:21 +01:00
|
|
|
if (!mergedPdfDocument) return [];
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageNumbers: number[] = [];
|
2025-06-10 11:19:54 +01:00
|
|
|
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
ranges.forEach(range => {
|
|
|
|
if (range.includes('-')) {
|
|
|
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
2025-06-24 23:31:21 +01:00
|
|
|
for (let i = start; i <= end && i <= mergedPdfDocument.totalPages; i++) {
|
2025-06-10 11:19:54 +01:00
|
|
|
if (i > 0) {
|
2025-07-16 17:53:50 +01:00
|
|
|
pageNumbers.push(i);
|
2025-06-10 11:19:54 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const pageNum = parseInt(range);
|
2025-06-24 23:31:21 +01:00
|
|
|
if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) {
|
2025-07-16 17:53:50 +01:00
|
|
|
pageNumbers.push(pageNum);
|
2025-06-10 11:19:54 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
return pageNumbers;
|
2025-06-24 23:31:21 +01:00
|
|
|
}, [mergedPdfDocument]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
|
|
|
const updatePagesFromCSV = useCallback(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageNumbers = parseCSVInput(csvInput);
|
|
|
|
setSelectedPages(pageNumbers);
|
|
|
|
}, [csvInput, parseCSVInput, setSelectedPages]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
const handleDragStart = useCallback((pageNumber: number) => {
|
|
|
|
setDraggedPage(pageNumber);
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
// Check if this is a multi-page drag in selection mode
|
2025-07-16 17:53:50 +01:00
|
|
|
if (selectionMode && selectedPageNumbers.includes(pageNumber) && selectedPageNumbers.length > 1) {
|
2025-06-18 18:12:15 +01:00
|
|
|
setMultiPageDrag({
|
2025-07-16 17:53:50 +01:00
|
|
|
pageNumbers: selectedPageNumbers,
|
|
|
|
count: selectedPageNumbers.length
|
2025-06-18 18:12:15 +01:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
setMultiPageDrag(null);
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [selectionMode, selectedPageNumbers]);
|
2025-06-18 18:12:15 +01:00
|
|
|
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
|
|
// Clean up drag state regardless of where the drop happened
|
|
|
|
setDraggedPage(null);
|
|
|
|
setDropTarget(null);
|
|
|
|
setMultiPageDrag(null);
|
|
|
|
setDragPosition(null);
|
2025-06-10 11:19:54 +01:00
|
|
|
}, []);
|
2025-05-21 21:47:44 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
|
|
e.preventDefault();
|
2025-06-16 19:57:50 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
if (!draggedPage) return;
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
// Update drag position for multi-page indicator
|
|
|
|
if (multiPageDrag) {
|
|
|
|
setDragPosition({ x: e.clientX, y: e.clientY });
|
|
|
|
}
|
2025-06-16 19:57:50 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
// Get the element under the mouse cursor
|
|
|
|
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
|
|
|
if (!elementUnderCursor) return;
|
2025-06-16 19:57:50 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
// Find the closest page container
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageContainer = elementUnderCursor.closest('[data-page-number]');
|
2025-06-16 15:11:00 +01:00
|
|
|
if (pageContainer) {
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageNumberStr = pageContainer.getAttribute('data-page-number');
|
|
|
|
const pageNumber = pageNumberStr ? parseInt(pageNumberStr) : null;
|
|
|
|
if (pageNumber && pageNumber !== draggedPage) {
|
|
|
|
setDropTarget(pageNumber);
|
2025-06-16 15:11:00 +01:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
2025-06-16 19:57:50 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
// Check if over the end zone
|
|
|
|
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
|
|
|
if (endZone) {
|
|
|
|
setDropTarget('end');
|
|
|
|
return;
|
|
|
|
}
|
2025-06-16 19:57:50 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
// If not over any valid drop target, clear it
|
|
|
|
setDropTarget(null);
|
2025-06-18 18:12:15 +01:00
|
|
|
}, [draggedPage, multiPageDrag]);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
const handleDragEnter = useCallback((pageNumber: number) => {
|
|
|
|
if (draggedPage && pageNumber !== draggedPage) {
|
|
|
|
setDropTarget(pageNumber);
|
2025-06-16 15:11:00 +01:00
|
|
|
}
|
|
|
|
}, [draggedPage]);
|
|
|
|
|
|
|
|
const handleDragLeave = useCallback(() => {
|
|
|
|
// Don't clear drop target on drag leave - let dragover handle it
|
2025-06-10 11:19:54 +01:00
|
|
|
}, []);
|
2025-05-21 21:47:44 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Update PDF document state with edit tracking
|
2025-06-24 20:25:03 +01:00
|
|
|
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
|
2025-07-16 17:53:50 +01:00
|
|
|
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
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
return updatedDoc;
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [setHasUnsavedChanges, hasUnsavedDraft]);
|
2025-06-24 20:25:03 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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]);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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]);
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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;
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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);
|
2025-06-18 18:12:15 +01:00
|
|
|
if (originalIndex === -1 || originalIndex === targetIndex) return;
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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;
|
|
|
|
}
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
setIsAnimating(true);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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);
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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) {
|
2025-06-18 18:12:15 +01:00
|
|
|
const rect = element.getBoundingClientRect();
|
2025-06-24 23:31:21 +01:00
|
|
|
currentPositions.set(pageId, { x: rect.left, y: rect.top });
|
2025-06-18 18:12:15 +01:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Execute the reorder command
|
2025-06-18 18:12:15 +01:00
|
|
|
if (pagesToMove.length > 1) {
|
2025-07-16 17:53:50 +01:00
|
|
|
const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex);
|
2025-06-18 18:12:15 +01:00
|
|
|
executeCommand(command);
|
|
|
|
} else {
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageId = pagesToMove[0];
|
|
|
|
const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex);
|
2025-06-18 18:12:15 +01:00
|
|
|
executeCommand(command);
|
|
|
|
}
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Animate only the affected pages
|
2025-06-24 23:31:21 +01:00
|
|
|
setTimeout(() => {
|
2025-06-18 18:12:15 +01:00
|
|
|
requestAnimationFrame(() => {
|
2025-06-24 23:31:21 +01:00
|
|
|
requestAnimationFrame(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
const newPositions = new Map<string, { x: number; y: number }>();
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Get new positions only for affected pages
|
|
|
|
affectedPageIds.forEach(pageId => {
|
|
|
|
const element = document.querySelector(`[data-page-number="${pageId}"]`);
|
|
|
|
if (element) {
|
2025-06-24 23:31:21 +01:00
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
newPositions.set(pageId, { x: rect.left, y: rect.top });
|
|
|
|
}
|
|
|
|
});
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
const elementsToAnimate: HTMLElement[] = [];
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Apply animations only to pages that actually moved
|
|
|
|
affectedPageIds.forEach(pageId => {
|
|
|
|
const element = document.querySelector(`[data-page-number="${pageId}"]`) as HTMLElement;
|
|
|
|
if (!element) return;
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
const currentPos = currentPositions.get(pageId);
|
|
|
|
const newPos = newPositions.get(pageId);
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
if (currentPos && newPos) {
|
2025-06-24 23:31:21 +01:00
|
|
|
const deltaX = currentPos.x - newPos.x;
|
|
|
|
const deltaY = currentPos.y - newPos.y;
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
|
2025-07-16 17:53:50 +01:00
|
|
|
elementsToAnimate.push(element);
|
|
|
|
|
|
|
|
// Apply initial transform
|
|
|
|
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
|
|
|
element.style.transition = 'none';
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
// Force reflow
|
2025-07-16 17:53:50 +01:00
|
|
|
element.offsetHeight;
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
// Animate to final position
|
2025-07-16 17:53:50 +01:00
|
|
|
element.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
|
|
|
element.style.transform = 'translate(0px, 0px)';
|
2025-06-24 23:31:21 +01:00
|
|
|
}
|
2025-06-18 18:12:15 +01:00
|
|
|
}
|
|
|
|
});
|
2025-06-24 23:31:21 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// Clean up after animation (only for animated elements)
|
2025-06-24 23:31:21 +01:00
|
|
|
setTimeout(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
elementsToAnimate.forEach((element) => {
|
|
|
|
element.style.transform = '';
|
|
|
|
element.style.transition = '';
|
2025-06-24 23:31:21 +01:00
|
|
|
});
|
|
|
|
setIsAnimating(false);
|
2025-07-16 17:53:50 +01:00
|
|
|
}, 300);
|
2025-06-24 23:31:21 +01:00
|
|
|
});
|
2025-06-18 18:12:15 +01:00
|
|
|
});
|
2025-06-24 23:31:21 +01:00
|
|
|
}, 10); // Small delay to allow state update
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]);
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
const handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => {
|
2025-06-10 11:19:54 +01:00
|
|
|
e.preventDefault();
|
2025-07-16 17:53:50 +01:00
|
|
|
if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return;
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
let targetIndex: number;
|
2025-07-16 17:53:50 +01:00
|
|
|
if (targetPageNumber === 'end') {
|
|
|
|
targetIndex = displayDocument.pages.length;
|
2025-06-16 15:11:00 +01:00
|
|
|
} else {
|
2025-07-16 17:53:50 +01:00
|
|
|
targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
2025-06-16 15:11:00 +01:00
|
|
|
if (targetIndex === -1) return;
|
|
|
|
}
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
animateReorder(draggedPage, targetIndex);
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
setDraggedPage(null);
|
2025-06-16 15:11:00 +01:00
|
|
|
setDropTarget(null);
|
2025-06-18 18:12:15 +01:00
|
|
|
setMultiPageDrag(null);
|
|
|
|
setDragPosition(null);
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
|
|
|
|
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [draggedPage, displayDocument, animateReorder, multiPageDrag]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
const handleEndZoneDragEnter = useCallback(() => {
|
|
|
|
if (draggedPage) {
|
|
|
|
setDropTarget('end');
|
|
|
|
}
|
|
|
|
}, [draggedPage]);
|
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
2025-07-16 17:53:50 +01:00
|
|
|
if (!displayDocument) return;
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const rotation = direction === 'left' ? -90 : 90;
|
2025-06-19 19:47:44 +01:00
|
|
|
const pagesToRotate = selectionMode
|
2025-07-16 17:53:50 +01:00
|
|
|
? selectedPageNumbers.map(pageNum => {
|
|
|
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
|
|
|
return page?.id || '';
|
|
|
|
}).filter(id => id)
|
|
|
|
: displayDocument.pages.map(p => p.id);
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
if (selectionMode && selectedPageNumbers.length === 0) return;
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const command = new RotatePagesCommand(
|
2025-07-16 17:53:50 +01:00
|
|
|
displayDocument,
|
2025-06-10 11:19:54 +01:00
|
|
|
setPdfDocument,
|
2025-06-18 18:12:15 +01:00
|
|
|
pagesToRotate,
|
2025-06-10 11:19:54 +01:00
|
|
|
rotation
|
2025-05-21 21:47:44 +01:00
|
|
|
);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
executeCommand(command);
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
2025-06-18 18:12:15 +01:00
|
|
|
setStatus(`Rotated ${pageCount} pages ${direction}`);
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
2025-05-21 21:47:44 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const handleDelete = useCallback(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
if (!displayDocument) return;
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-06-19 19:47:44 +01:00
|
|
|
const pagesToDelete = selectionMode
|
2025-07-16 17:53:50 +01:00
|
|
|
? selectedPageNumbers.map(pageNum => {
|
|
|
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
|
|
|
return page?.id || '';
|
|
|
|
}).filter(id => id)
|
|
|
|
: displayDocument.pages.map(p => p.id);
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
if (selectionMode && selectedPageNumbers.length === 0) return;
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const command = new DeletePagesCommand(
|
2025-07-16 17:53:50 +01:00
|
|
|
displayDocument,
|
2025-06-10 11:19:54 +01:00
|
|
|
setPdfDocument,
|
2025-06-18 18:12:15 +01:00
|
|
|
pagesToDelete
|
2025-06-10 11:19:54 +01:00
|
|
|
);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
executeCommand(command);
|
2025-06-18 18:12:15 +01:00
|
|
|
if (selectionMode) {
|
|
|
|
setSelectedPages([]);
|
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
2025-06-18 18:12:15 +01:00
|
|
|
setStatus(`Deleted ${pageCount} pages`);
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
|
|
|
const handleSplit = useCallback(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
if (!displayDocument) return;
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-06-19 19:47:44 +01:00
|
|
|
const pagesToSplit = selectionMode
|
2025-07-16 17:53:50 +01:00
|
|
|
? selectedPageNumbers.map(pageNum => {
|
|
|
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
|
|
|
return page?.id || '';
|
|
|
|
}).filter(id => id)
|
|
|
|
: displayDocument.pages.map(p => p.id);
|
2025-06-18 18:12:15 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
if (selectionMode && selectedPageNumbers.length === 0) return;
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const command = new ToggleSplitCommand(
|
2025-07-16 17:53:50 +01:00
|
|
|
displayDocument,
|
2025-06-10 11:19:54 +01:00
|
|
|
setPdfDocument,
|
2025-06-18 18:12:15 +01:00
|
|
|
pagesToSplit
|
2025-06-10 11:19:54 +01:00
|
|
|
);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
executeCommand(command);
|
2025-07-16 17:53:50 +01:00
|
|
|
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
2025-06-18 18:12:15 +01:00
|
|
|
setStatus(`Split markers toggled for ${pageCount} pages`);
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
|
|
|
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
2025-06-24 23:31:21 +01:00
|
|
|
if (!mergedPdfDocument) return;
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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)
|
|
|
|
: [];
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
|
2025-06-10 11:19:54 +01:00
|
|
|
setExportPreview(preview);
|
|
|
|
setShowExportModal(true);
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [mergedPdfDocument, selectedPageNumbers]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
|
|
|
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
2025-06-24 23:31:21 +01:00
|
|
|
if (!mergedPdfDocument) return;
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
setExportLoading(true);
|
|
|
|
try {
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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)
|
|
|
|
: [];
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
|
2025-06-10 11:19:54 +01:00
|
|
|
if (errors.length > 0) {
|
|
|
|
setError(errors.join(', '));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
if (hasSplitMarkers) {
|
2025-06-24 23:31:21 +01:00
|
|
|
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
2025-06-10 11:19:54 +01:00
|
|
|
selectedOnly,
|
|
|
|
filename,
|
|
|
|
splitDocuments: true
|
|
|
|
}) as { blobs: Blob[]; filenames: string[] };
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
result.blobs.forEach((blob, index) => {
|
|
|
|
setTimeout(() => {
|
|
|
|
pdfExportService.downloadFile(blob, result.filenames[index]);
|
|
|
|
}, index * 500);
|
|
|
|
});
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
setStatus(`Exported ${result.blobs.length} split documents`);
|
|
|
|
} else {
|
2025-06-24 23:31:21 +01:00
|
|
|
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
2025-06-10 11:19:54 +01:00
|
|
|
selectedOnly,
|
|
|
|
filename
|
|
|
|
}) as { blob: Blob; filename: string };
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
pdfExportService.downloadFile(result.blob, result.filename);
|
|
|
|
setStatus('PDF exported successfully');
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Export failed';
|
|
|
|
setError(errorMessage);
|
|
|
|
} finally {
|
|
|
|
setExportLoading(false);
|
2025-05-21 21:47:44 +01:00
|
|
|
}
|
2025-07-16 17:53:50 +01:00
|
|
|
}, [mergedPdfDocument, selectedPageNumbers, filename]);
|
2025-06-10 11:19:54 +01:00
|
|
|
|
|
|
|
const handleUndo = useCallback(() => {
|
|
|
|
if (undo()) {
|
|
|
|
setStatus('Operation undone');
|
|
|
|
}
|
|
|
|
}, [undo]);
|
|
|
|
|
|
|
|
const handleRedo = useCallback(() => {
|
|
|
|
if (redo()) {
|
|
|
|
setStatus('Operation redone');
|
2025-05-21 21:47:44 +01:00
|
|
|
}
|
2025-06-10 11:19:54 +01:00
|
|
|
}, [redo]);
|
|
|
|
|
2025-06-19 19:47:44 +01:00
|
|
|
const closePdf = useCallback(() => {
|
2025-07-16 17:53:50 +01:00
|
|
|
// Use global navigation guard system
|
|
|
|
fileContext.requestNavigation(() => {
|
|
|
|
clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
|
|
|
|
setSelectedPages([]);
|
|
|
|
});
|
|
|
|
}, [fileContext, clearAllFiles, setSelectedPages]);
|
2025-06-19 19:47:44 +01:00
|
|
|
|
2025-06-24 20:25:03 +01:00
|
|
|
// PageEditorControls needs onExportSelected and onExportAll
|
|
|
|
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
|
|
|
|
const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]);
|
|
|
|
|
|
|
|
// Expose functions to parent component for PageEditorControls
|
2025-06-19 19:47:44 +01:00
|
|
|
useEffect(() => {
|
|
|
|
if (onFunctionsReady) {
|
|
|
|
onFunctionsReady({
|
|
|
|
handleUndo,
|
|
|
|
handleRedo,
|
|
|
|
canUndo,
|
|
|
|
canRedo,
|
|
|
|
handleRotate,
|
|
|
|
handleDelete,
|
|
|
|
handleSplit,
|
|
|
|
showExportPreview,
|
2025-06-24 20:25:03 +01:00
|
|
|
onExportSelected,
|
|
|
|
onExportAll,
|
2025-06-19 19:47:44 +01:00
|
|
|
exportLoading,
|
|
|
|
selectionMode,
|
2025-07-16 17:53:50 +01:00
|
|
|
selectedPages: selectedPageNumbers,
|
2025-06-19 19:47:44 +01:00
|
|
|
closePdf,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}, [
|
2025-06-20 17:51:24 +01:00
|
|
|
onFunctionsReady,
|
|
|
|
handleUndo,
|
|
|
|
handleRedo,
|
|
|
|
canUndo,
|
|
|
|
canRedo,
|
|
|
|
handleRotate,
|
|
|
|
handleDelete,
|
|
|
|
handleSplit,
|
|
|
|
showExportPreview,
|
2025-06-24 20:25:03 +01:00
|
|
|
onExportSelected,
|
|
|
|
onExportAll,
|
2025-06-20 17:51:24 +01:00
|
|
|
exportLoading,
|
|
|
|
selectionMode,
|
2025-07-16 17:53:50 +01:00
|
|
|
selectedPageNumbers,
|
2025-06-19 19:47:44 +01:00
|
|
|
closePdf
|
|
|
|
]);
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
// 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 || [];
|
2025-05-21 21:47:44 +01:00
|
|
|
|
|
|
|
return (
|
2025-06-16 15:11:00 +01:00
|
|
|
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
2025-07-16 17:53:50 +01:00
|
|
|
<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>
|
|
|
|
)}
|
2025-06-24 20:25:03 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
{displayDocument && (
|
2025-06-18 18:12:15 +01:00
|
|
|
<Box p="md" pt="xl">
|
2025-07-16 17:53:50 +01:00
|
|
|
{/* 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>
|
|
|
|
)}
|
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
<Group mb="md">
|
|
|
|
<TextInput
|
|
|
|
value={filename}
|
|
|
|
onChange={(e) => setFilename(e.target.value)}
|
|
|
|
placeholder="Enter filename"
|
|
|
|
style={{ minWidth: 200 }}
|
|
|
|
/>
|
2025-06-19 19:47:44 +01:00
|
|
|
<Button
|
2025-06-18 18:12:15 +01:00
|
|
|
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"}
|
2025-06-16 15:11:00 +01:00
|
|
|
</Button>
|
2025-06-18 18:12:15 +01:00
|
|
|
{selectionMode && (
|
|
|
|
<>
|
|
|
|
<Button onClick={selectAll} variant="light">Select All</Button>
|
|
|
|
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
|
|
|
</>
|
2025-06-10 11:19:54 +01:00
|
|
|
)}
|
2025-07-16 17:53:50 +01:00
|
|
|
|
|
|
|
{/* Apply Changes Button */}
|
|
|
|
{hasUnsavedChanges && (
|
|
|
|
<Button
|
|
|
|
onClick={applyChanges}
|
|
|
|
color="green"
|
|
|
|
variant="filled"
|
|
|
|
style={{ marginLeft: 'auto' }}
|
|
|
|
>
|
|
|
|
Apply Changes
|
|
|
|
</Button>
|
|
|
|
)}
|
2025-06-18 18:12:15 +01:00
|
|
|
</Group>
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
{selectionMode && (
|
2025-06-20 17:51:24 +01:00
|
|
|
<BulkSelectionPanel
|
|
|
|
csvInput={csvInput}
|
|
|
|
setCsvInput={setCsvInput}
|
2025-07-16 17:53:50 +01:00
|
|
|
selectedPages={selectedPageNumbers}
|
2025-06-20 17:51:24 +01:00
|
|
|
onUpdatePagesFromCSV={updatePagesFromCSV}
|
|
|
|
/>
|
2025-06-18 18:12:15 +01:00
|
|
|
)}
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-06-20 17:51:24 +01:00
|
|
|
<DragDropGrid
|
2025-07-16 17:53:50 +01:00
|
|
|
items={displayedPages}
|
|
|
|
selectedItems={selectedPageNumbers}
|
2025-06-20 17:51:24 +01:00
|
|
|
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}
|
2025-07-16 17:53:50 +01:00
|
|
|
totalPages={displayDocument.pages.length}
|
|
|
|
originalFile={activeFiles.length === 1 ? activeFiles[0] : undefined}
|
|
|
|
selectedPages={selectedPageNumbers}
|
2025-06-20 17:51:24 +01:00
|
|
|
selectionMode={selectionMode}
|
|
|
|
draggedPage={draggedPage}
|
|
|
|
dropTarget={dropTarget}
|
|
|
|
movingPage={movingPage}
|
|
|
|
isAnimating={isAnimating}
|
|
|
|
pageRefs={refs}
|
|
|
|
onDragStart={handleDragStart}
|
2025-06-18 18:12:15 +01:00
|
|
|
onDragEnd={handleDragEnd}
|
2025-06-10 11:19:54 +01:00
|
|
|
onDragOver={handleDragOver}
|
2025-06-20 17:51:24 +01:00
|
|
|
onDragEnter={handleDragEnter}
|
2025-06-16 15:11:00 +01:00
|
|
|
onDragLeave={handleDragLeave}
|
2025-06-20 17:51:24 +01:00
|
|
|
onDrop={handleDrop}
|
|
|
|
onTogglePage={togglePage}
|
|
|
|
onAnimateReorder={animateReorder}
|
|
|
|
onExecuteCommand={executeCommand}
|
|
|
|
onSetStatus={setStatus}
|
|
|
|
onSetMovingPage={setMovingPage}
|
|
|
|
RotatePagesCommand={RotatePagesCommand}
|
|
|
|
DeletePagesCommand={DeletePagesCommand}
|
|
|
|
ToggleSplitCommand={ToggleSplitCommand}
|
2025-07-16 17:53:50 +01:00
|
|
|
pdfDocument={displayDocument}
|
2025-06-20 17:51:24 +01:00
|
|
|
setPdfDocument={setPdfDocument}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
renderSplitMarker={(page, index) => (
|
2025-06-16 19:57:50 +01:00
|
|
|
<div
|
|
|
|
style={{
|
2025-06-20 17:51:24 +01:00
|
|
|
width: '2px',
|
|
|
|
height: '20rem',
|
|
|
|
borderLeft: '2px dashed #3b82f6',
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
marginLeft: '-0.75rem',
|
|
|
|
marginRight: '-0.75rem',
|
|
|
|
flexShrink: 0
|
2025-06-16 19:57:50 +01:00
|
|
|
}}
|
2025-06-20 17:51:24 +01:00
|
|
|
/>
|
|
|
|
)}
|
|
|
|
/>
|
2025-06-16 15:11:00 +01:00
|
|
|
|
|
|
|
</Box>
|
2025-07-16 17:53:50 +01:00
|
|
|
)}
|
2025-06-10 11:19:54 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
{/* Modal should be outside the conditional but inside the main container */}
|
|
|
|
<Modal
|
2025-06-16 15:11:00 +01:00
|
|
|
opened={showExportModal}
|
2025-06-10 11:19:54 +01:00
|
|
|
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>
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
{exportPreview.splitCount > 1 && (
|
|
|
|
<Group justify="space-between">
|
|
|
|
<Text>Split into documents:</Text>
|
|
|
|
<Text fw={500}>{exportPreview.splitCount}</Text>
|
|
|
|
</Group>
|
|
|
|
)}
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
<Group justify="space-between">
|
|
|
|
<Text>Estimated size:</Text>
|
|
|
|
<Text fw={500}>{exportPreview.estimatedSize}</Text>
|
|
|
|
</Group>
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
{mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && (
|
2025-06-10 11:19:54 +01:00
|
|
|
<Alert color="blue">
|
|
|
|
This will create multiple PDF files based on split markers.
|
|
|
|
</Alert>
|
|
|
|
)}
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
<Group justify="flex-end" mt="md">
|
2025-06-16 15:11:00 +01:00
|
|
|
<Button
|
|
|
|
variant="light"
|
2025-06-10 11:19:54 +01:00
|
|
|
onClick={() => setShowExportModal(false)}
|
|
|
|
>
|
|
|
|
Cancel
|
|
|
|
</Button>
|
2025-06-16 15:11:00 +01:00
|
|
|
<Button
|
2025-06-10 11:19:54 +01:00
|
|
|
color="green"
|
|
|
|
loading={exportLoading}
|
|
|
|
onClick={() => {
|
|
|
|
setShowExportModal(false);
|
2025-06-24 23:31:21 +01:00
|
|
|
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.totalPages || 0);
|
2025-06-10 11:19:54 +01:00
|
|
|
handleExport(selectedOnly);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Export PDF
|
|
|
|
</Button>
|
|
|
|
</Group>
|
|
|
|
</Stack>
|
|
|
|
)}
|
|
|
|
</Modal>
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
{/* Global Navigation Warning Modal */}
|
|
|
|
<NavigationWarningModal
|
|
|
|
onApplyAndContinue={handleApplyAndContinue}
|
|
|
|
onExportAndContinue={handleExportAndContinue}
|
2025-06-10 11:19:54 +01:00
|
|
|
/>
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
{/* 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>
|
2025-05-21 21:47:44 +01:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
export default PageEditor;
|