Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1214 lines
42 KiB
TypeScript
Raw Normal View History

2025-06-10 11:19:54 +01:00
import React, { useState, useCallback, useRef, useEffect } from "react";
import {
2025-06-16 15:11:00 +01:00
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
2025-06-10 11:19:54 +01:00
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group, Paper, SimpleGrid
} from "@mantine/core";
2025-06-10 11:19:54 +01:00
import { Dropzone } from "@mantine/dropzone";
2025-05-29 17:26:32 +01:00
import { useTranslation } from "react-i18next";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import AddIcon from "@mui/icons-material/Add";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import DownloadIcon from "@mui/icons-material/Download";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
2025-06-10 11:19:54 +01:00
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import ConstructionIcon from "@mui/icons-material/Construction";
import EventListIcon from "@mui/icons-material/EventList";
import DeselectIcon from "@mui/icons-material/Deselect";
import SelectAllIcon from "@mui/icons-material/SelectAll";
2025-06-16 19:57:50 +01:00
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
2025-06-18 18:12:15 +01:00
import CloseIcon from "@mui/icons-material/Close";
2025-06-10 11:19:54 +01:00
import { usePDFProcessor } from "../hooks/usePDFProcessor";
import { PDFDocument, PDFPage } from "../types/pageEditor";
import { fileStorage } from "../services/fileStorage";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
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-10 11:19:54 +01:00
} from "../commands/pageCommands";
import { pdfExportService } from "../services/pdfExportService";
export interface PageEditorProps {
file: { file: File; url: string } | null;
setFile?: (file: { file: File; url: string } | null) => void;
downloadUrl?: string | null;
setDownloadUrl?: (url: string | null) => void;
}
const PageEditor: React.FC<PageEditorProps> = ({
file,
setFile,
downloadUrl,
setDownloadUrl,
}) => {
2025-05-29 17:26:32 +01:00
const { t } = useTranslation();
2025-06-10 11:19:54 +01:00
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
const [pdfDocument, setPdfDocument] = useState<PDFDocument | null>(null);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
2025-06-10 11:19:54 +01:00
const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>("");
2025-06-18 18:12:15 +01:00
const [selectionMode, setSelectionMode] = useState(false);
2025-06-10 11:19:54 +01:00
const [filename, setFilename] = useState<string>("");
const [draggedPage, setDraggedPage] = useState<string | null>(null);
2025-06-16 15:11:00 +01:00
const [dropTarget, setDropTarget] = useState<string | null>(null);
2025-06-18 18:12:15 +01:00
const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
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-16 19:57:50 +01:00
const [movingPage, setMovingPage] = useState<string | 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();
// Process uploaded file
const handleFileUpload = useCallback(async (uploadedFile: File) => {
if (!uploadedFile || uploadedFile.type !== 'application/pdf') {
setError('Please upload a valid PDF file');
return;
}
setLoading(true);
setError(null);
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
try {
const document = await processPDFFile(uploadedFile);
setPdfDocument(document);
setFilename(uploadedFile.name.replace(/\.pdf$/i, ''));
setSelectedPages([]);
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
if (document.pages.length > 0) {
const thumbnail = await generateThumbnailForFile(uploadedFile);
await fileStorage.storeFile(uploadedFile, thumbnail);
}
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
if (setFile) {
const fileUrl = URL.createObjectURL(uploadedFile);
setFile({ file: uploadedFile, url: fileUrl });
}
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF';
setError(errorMessage);
console.error('PDF processing error:', err);
} finally {
setLoading(false);
}
}, [processPDFFile, setFile]);
useEffect(() => {
if (file?.file && !pdfDocument) {
handleFileUpload(file.file);
}
}, [file, pdfDocument, handleFileUpload]);
2025-06-18 18:12:15 +01:00
// Global drag cleanup to handle drops outside valid areas
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 avoid browser navigation on invalid drops
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(() => {
if (pdfDocument) {
setSelectedPages(pdfDocument.pages.map(p => p.id));
}
}, [pdfDocument]);
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
const deselectAll = useCallback(() => setSelectedPages([]), []);
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
const togglePage = useCallback((pageId: string) => {
2025-06-16 15:11:00 +01:00
setSelectedPages(prev =>
prev.includes(pageId)
? prev.filter(id => id !== pageId)
2025-06-10 11:19:54 +01:00
: [...prev, pageId]
);
}, []);
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) => {
if (!pdfDocument) return [];
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
const pageIds: string[] = [];
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()));
for (let i = start; i <= end && i <= pdfDocument.totalPages; i++) {
if (i > 0) {
const page = pdfDocument.pages.find(p => p.pageNumber === i);
if (page) pageIds.push(page.id);
}
}
} else {
const pageNum = parseInt(range);
if (pageNum > 0 && pageNum <= pdfDocument.totalPages) {
const page = pdfDocument.pages.find(p => p.pageNumber === pageNum);
if (page) pageIds.push(page.id);
}
}
});
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
return pageIds;
}, [pdfDocument]);
const updatePagesFromCSV = useCallback(() => {
const pageIds = parseCSVInput(csvInput);
setSelectedPages(pageIds);
}, [csvInput, parseCSVInput]);
const handleDragStart = useCallback((pageId: string) => {
setDraggedPage(pageId);
2025-06-18 18:12:15 +01:00
// Check if this is a multi-page drag in selection mode
if (selectionMode && selectedPages.includes(pageId) && selectedPages.length > 1) {
setMultiPageDrag({
pageIds: selectedPages,
count: selectedPages.length
});
} else {
setMultiPageDrag(null);
}
}, [selectionMode, selectedPages]);
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-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-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
const pageContainer = elementUnderCursor.closest('[data-page-id]');
if (pageContainer) {
const pageId = pageContainer.getAttribute('data-page-id');
if (pageId && pageId !== draggedPage) {
setDropTarget(pageId);
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
const handleDragEnter = useCallback((pageId: string) => {
if (draggedPage && pageId !== draggedPage) {
setDropTarget(pageId);
}
}, [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-06-18 18:12:15 +01:00
const animateReorder = useCallback((pageId: string, targetIndex: number) => {
if (!pdfDocument || isAnimating) return;
// In selection mode, if the dragged page is selected, move all selected pages
const pagesToMove = selectionMode && selectedPages.includes(pageId)
? selectedPages
: [pageId];
const originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
if (originalIndex === -1 || originalIndex === targetIndex) return;
setIsAnimating(true);
// Get current positions of all pages
const currentPositions = new Map<string, { x: number; y: number }>();
pdfDocument.pages.forEach((page) => {
const element = pageRefs.current.get(page.id);
if (element) {
const rect = element.getBoundingClientRect();
currentPositions.set(page.id, { x: rect.left, y: rect.top });
}
});
// Execute the reorder - for multi-page, we use a different command
if (pagesToMove.length > 1) {
// Multi-page move - use MovePagesCommand
const command = new MovePagesCommand(pdfDocument, setPdfDocument, pagesToMove, targetIndex);
executeCommand(command);
} else {
// Single page move
const command = new ReorderPageCommand(pdfDocument, setPdfDocument, pageId, targetIndex);
executeCommand(command);
}
// Wait for DOM to update, then get new positions and animate
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newPositions = new Map<string, { x: number; y: number }>();
// Get the updated document from the state after command execution
// The command has already updated the document, so we need to get the new order
const currentDoc = pdfDocument; // This should be the updated version after command
currentDoc.pages.forEach((page) => {
const element = pageRefs.current.get(page.id);
if (element) {
const rect = element.getBoundingClientRect();
newPositions.set(page.id, { x: rect.left, y: rect.top });
}
});
// Calculate and apply animations
currentDoc.pages.forEach((page) => {
const element = pageRefs.current.get(page.id);
const currentPos = currentPositions.get(page.id);
const newPos = newPositions.get(page.id);
if (element && currentPos && newPos) {
const deltaX = currentPos.x - newPos.x;
const deltaY = currentPos.y - newPos.y;
// Apply initial transform (from new position back to old position)
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.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
element.style.transform = 'translate(0px, 0px)';
}
});
// Clean up after animation
setTimeout(() => {
currentDoc.pages.forEach((page) => {
const element = pageRefs.current.get(page.id);
if (element) {
element.style.transform = '';
element.style.transition = '';
}
});
setIsAnimating(false);
}, 400);
});
});
}, [pdfDocument, isAnimating, executeCommand, selectionMode, selectedPages]);
2025-06-16 15:11:00 +01:00
const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => {
2025-06-10 11:19:54 +01:00
e.preventDefault();
if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return;
2025-06-16 15:11:00 +01:00
let targetIndex: number;
if (targetPageId === 'end') {
targetIndex = pdfDocument.pages.length;
} else {
targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId);
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-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);
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
}, [draggedPage, pdfDocument, 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-06-18 18:12:15 +01:00
if (!pdfDocument) 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-18 18:12:15 +01:00
const pagesToRotate = selectionMode
? selectedPages
: pdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return;
2025-06-10 11:19:54 +01:00
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
2025-06-18 18:12:15 +01:00
pagesToRotate,
2025-06-10 11:19:54 +01:00
rotation
);
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
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
setStatus(`Rotated ${pageCount} pages ${direction}`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
2025-06-10 11:19:54 +01:00
const handleDelete = useCallback(() => {
2025-06-18 18:12:15 +01:00
if (!pdfDocument) return;
const pagesToDelete = selectionMode
? selectedPages
: pdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return;
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
const command = new DeletePagesCommand(
pdfDocument,
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([]);
}
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
2025-06-10 11:19:54 +01:00
const handleSplit = useCallback(() => {
2025-06-18 18:12:15 +01:00
if (!pdfDocument) return;
const pagesToSplit = selectionMode
? selectedPages
: pdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return;
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
const command = new ToggleSplitCommand(
pdfDocument,
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-06-18 18:12:15 +01:00
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
setStatus(`Split markers toggled for ${pageCount} pages`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
2025-06-10 11:19:54 +01:00
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!pdfDocument) return;
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
const exportPageIds = selectedOnly ? selectedPages : [];
const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly);
setExportPreview(preview);
setShowExportModal(true);
}, [pdfDocument, selectedPages]);
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!pdfDocument) return;
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
setExportLoading(true);
try {
const exportPageIds = selectedOnly ? selectedPages : [];
const errors = pdfExportService.validateExport(pdfDocument, exportPageIds, selectedOnly);
if (errors.length > 0) {
setError(errors.join(', '));
return;
}
const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore);
2025-06-16 15:11:00 +01:00
2025-06-10 11:19:54 +01:00
if (hasSplitMarkers) {
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
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 {
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
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-06-10 11:19:54 +01:00
}, [pdfDocument, selectedPages, filename]);
const handleUndo = useCallback(() => {
if (undo()) {
setStatus('Operation undone');
}
}, [undo]);
const handleRedo = useCallback(() => {
if (redo()) {
setStatus('Operation redone');
}
2025-06-10 11:19:54 +01:00
}, [redo]);
if (!pdfDocument) {
return (
2025-06-16 15:11:00 +01:00
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading || pdfLoading} />
2025-06-16 19:57:50 +01:00
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Dropzone
onDrop={(files) => files[0] && handleFileUpload(files[0])}
accept={["application/pdf"]}
multiple={false}
h="60vh"
style={{ minHeight: 400 }}
>
<Center h="100%">
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64 }} />
<Text size="xl" fw={500}>
Drop a PDF file here or click to upload
</Text>
<Text size="md" c="dimmed">
Supports PDF files only
</Text>
</Stack>
</Center>
</Dropzone>
</Container>
2025-06-16 15:11:00 +01:00
</Box>
);
}
return (
2025-06-16 15:11:00 +01:00
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<style>
{`
.page-container:hover .page-number {
opacity: 1 !important;
}
.page-container:hover .page-hover-controls {
opacity: 1 !important;
}
.page-container {
transition: transform 0.2s ease-in-out;
}
.page-container:hover {
transform: scale(1.02);
}
2025-06-18 18:12:15 +01:00
.checkbox-container {
transform: none !important;
transition: none !important;
}
2025-06-16 19:57:50 +01:00
.page-move-animation {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.page-moving {
z-index: 10;
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
2025-06-18 18:12:15 +01:00
.multi-drag-indicator {
position: fixed;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
backdrop-filter: blur(4px);
}
2025-06-16 15:11:00 +01:00
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
`}
</style>
<LoadingOverlay visible={loading || pdfLoading} />
2025-06-18 18:12:15 +01:00
<Box p="md" pt="xl">
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-18 18:12:15 +01:00
<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"}
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-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 && (
<Paper p="md" mb="md" withBorder>
<Group>
<TextInput
value={csvInput}
onChange={(e) => setCsvInput(e.target.value)}
placeholder="1,3,5-10"
label="Page Selection"
onBlur={updatePagesFromCSV}
onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()}
style={{ flex: 1 }}
/>
<Button onClick={updatePagesFromCSV} mt="xl">
Apply
</Button>
</Group>
{selectedPages.length > 0 && (
<Text size="sm" c="dimmed" mt="sm">
Selected: {selectedPages.length} pages
</Text>
)}
</Paper>
)}
2025-06-10 11:19:54 +01:00
2025-06-16 15:11:00 +01:00
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
2025-06-18 18:12:15 +01:00
justifyContent: 'flex-start',
paddingBottom: '100px' // Add space for floating control bar
2025-06-16 15:11:00 +01:00
}}
>
{pdfDocument.pages.map((page, index) => (
<React.Fragment key={page.id}>
{page.splitBefore && index > 0 && (
<div
style={{
2025-06-16 19:57:50 +01:00
width: '2px',
height: '20rem',
borderLeft: '2px dashed #3b82f6',
2025-06-16 15:11:00 +01:00
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
2025-06-16 19:57:50 +01:00
/>
2025-06-16 15:11:00 +01:00
)}
<div
2025-06-18 18:12:15 +01:00
ref={(el) => {
if (el) {
pageRefs.current.set(page.id, el);
} else {
pageRefs.current.delete(page.id);
}
}}
2025-06-16 15:11:00 +01:00
data-page-id={page.id}
className={`
!rounded-lg
cursor-grab
select-none
2025-06-16 19:57:50 +01:00
w-[20rem]
h-[20rem]
2025-06-16 15:11:00 +01:00
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
2025-06-18 18:12:15 +01:00
${selectionMode
? 'bg-white hover:bg-gray-50'
2025-06-16 15:11:00 +01:00
: 'bg-white hover:bg-gray-50'}
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
2025-06-16 19:57:50 +01:00
${movingPage === page.id ? 'page-moving' : ''}
2025-06-16 15:11:00 +01:00
`}
2025-06-18 18:12:15 +01:00
style={{
transform: (() => {
// Only apply drop target indication during drag
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
2025-06-10 11:19:54 +01:00
draggable
onDragStart={() => handleDragStart(page.id)}
2025-06-18 18:12:15 +01:00
onDragEnd={handleDragEnd}
2025-06-10 11:19:54 +01:00
onDragOver={handleDragOver}
2025-06-16 15:11:00 +01:00
onDragEnter={() => handleDragEnter(page.id)}
onDragLeave={handleDragLeave}
2025-06-10 11:19:54 +01:00
onDrop={(e) => handleDrop(e, page.id)}
>
2025-06-18 18:12:15 +01:00
{/* Selection mode checkbox - positioned outside page-container to avoid transform inheritance */}
{selectionMode && (
<div
className="checkbox-container"
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 4,
backgroundColor: 'white',
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto' // Ensure checkbox can be clicked
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent drag from starting
}}
onDragStart={(e) => {
e.preventDefault(); // Prevent drag on checkbox
e.stopPropagation();
}}
>
<Checkbox
checked={selectedPages.includes(page.id)}
onChange={(event) => {
event.stopPropagation();
togglePage(page.id);
}}
onClick={(e) => {
e.stopPropagation();
}}
size="sm"
/>
</div>
)}
2025-06-16 15:11:00 +01:00
<div className="page-container w-[90%] h-[90%]">
<img
src={page.thumbnail}
alt={`Page ${page.pageNumber}`}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: 4,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
}}
/>
{/* Page number overlay - shows on hover */}
<Text
className="page-number"
size="sm"
fw={500}
c="white"
style={{
position: 'absolute',
top: 5,
left: 5,
background: 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
borderRadius: 8,
zIndex: 2,
opacity: 0,
transition: 'opacity 0.2s ease-in-out'
}}
>
{page.pageNumber}
2025-06-10 11:19:54 +01:00
</Text>
2025-06-16 15:11:00 +01:00
{/* Hover controls */}
<div
className="page-hover-controls"
style={{
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '6px 12px',
borderRadius: 20,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
zIndex: 3,
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap'
}}
>
2025-06-16 19:57:50 +01:00
<Tooltip label="Move Left">
<ActionIcon
size="md"
variant="subtle"
c="white"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
2025-06-18 18:12:15 +01:00
if (index > 0 && !movingPage && !isAnimating) {
2025-06-16 19:57:50 +01:00
setMovingPage(page.id);
2025-06-18 18:12:15 +01:00
animateReorder(page.id, index - 1);
setTimeout(() => setMovingPage(null), 500);
setStatus(`Moved page ${page.pageNumber} left`);
2025-06-16 19:57:50 +01:00
}
}}
>
<ArrowBackIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move Right">
<ActionIcon
size="md"
variant="subtle"
c="white"
disabled={index === pdfDocument.pages.length - 1}
onClick={(e) => {
e.stopPropagation();
2025-06-18 18:12:15 +01:00
if (index < pdfDocument.pages.length - 1 && !movingPage && !isAnimating) {
2025-06-16 19:57:50 +01:00
setMovingPage(page.id);
2025-06-18 18:12:15 +01:00
animateReorder(page.id, index + 1);
setTimeout(() => setMovingPage(null), 500);
setStatus(`Moved page ${page.pageNumber} right`);
2025-06-16 19:57:50 +01:00
}
}}
>
<ArrowForwardIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
2025-06-16 15:11:00 +01:00
<Tooltip label="Rotate Left">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
-90
);
executeCommand(command);
setStatus(`Rotated page ${page.pageNumber} left`);
}}
>
<RotateLeftIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Right">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
90
);
executeCommand(command);
setStatus(`Rotated page ${page.pageNumber} right`);
}}
>
<RotateRightIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Page">
<ActionIcon
size="md"
variant="subtle"
c="red"
onClick={(e) => {
e.stopPropagation();
const command = new DeletePagesCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
executeCommand(command);
setStatus(`Deleted page ${page.pageNumber}`);
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
2025-06-16 19:57:50 +01:00
{index > 0 && (
<Tooltip label="Split Here">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new ToggleSplitCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
executeCommand(command);
setStatus(`Split marker toggled for page ${page.pageNumber}`);
}}
>
<ContentCutIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
)}
2025-06-16 15:11:00 +01:00
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
</div>
</React.Fragment>
2025-06-10 11:19:54 +01:00
))}
2025-06-16 19:57:50 +01:00
2025-06-16 15:11:00 +01:00
{/* Landing zone at the end */}
2025-06-16 19:57:50 +01:00
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
<div
data-drop-zone="end"
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${dropTarget === 'end' ? 'ring-2 ring-green-500 bg-green-50' : 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'}`}
style={{
borderRadius: '12px'
}}
onDragOver={handleDragOver}
onDragEnter={handleEndZoneDragEnter}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, 'end')}
>
<Text c="dimmed" size="sm" ta="center" fw={500}>
Drop here to<br />move to end
</Text>
</div>
2025-06-16 15:11:00 +01:00
</div>
</div>
2025-06-18 18:12:15 +01:00
{/* Floating control bar */}
<div
style={{
position: 'fixed',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
zIndex: 50,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
background: 'transparent',
}}
>
<Paper
radius="xl"
shadow="lg"
p={16}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
borderRadius: 32,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
pointerEvents: 'auto',
minWidth: 400,
justifyContent: 'center'
}}
>
{/* Close PDF */}
<Tooltip label="Close PDF">
<ActionIcon
onClick={() => {
setPdfDocument(null);
setFile && setFile(null);
}}
color="red"
variant="light"
size="lg"
2025-06-16 15:11:00 +01:00
>
2025-06-18 18:12:15 +01:00
<CloseIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={handleUndo} disabled={!canUndo} size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={handleRedo} disabled={!canRedo} size="lg">
<RedoIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Page Operations */}
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
<ActionIcon
onClick={() => handleRotate('left')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
<ActionIcon
onClick={() => handleRotate('right')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
<ActionIcon
onClick={handleDelete}
disabled={selectionMode && selectedPages.length === 0}
color="red"
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
<ActionIcon
onClick={handleSplit}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Export Controls */}
{selectionMode && selectedPages.length > 0 && (
<Tooltip label="Export Selected">
<ActionIcon
onClick={() => showExportPreview(true)}
disabled={exportLoading}
color="blue"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Export All">
<ActionIcon
2025-06-16 15:11:00 +01:00
onClick={() => showExportPreview(false)}
2025-06-18 18:12:15 +01:00
disabled={exportLoading}
color="green"
variant="light"
size="lg"
2025-06-16 15:11:00 +01:00
>
2025-06-18 18:12:15 +01:00
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Paper>
</div>
2025-06-16 15:11:00 +01:00
</Box>
2025-06-10 11:19:54 +01:00
2025-06-16 15:11:00 +01:00
<Modal
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-10 11:19:54 +01:00
{pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && (
<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);
const selectedOnly = exportPreview.pageCount < (pdfDocument?.totalPages || 0);
handleExport(selectedOnly);
}}
>
Export PDF
</Button>
</Group>
</Stack>
)}
</Modal>
<FileInput
ref={fileInputRef}
accept="application/pdf"
onChange={(file) => file && handleFileUpload(file)}
style={{ display: 'none' }}
/>
2025-06-16 15:11:00 +01:00
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
2025-06-18 18:12:15 +01:00
{/* Multi-page drag indicator */}
{multiPageDrag && dragPosition && (
<div
className="multi-drag-indicator"
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiPageDrag.count} pages
</div>
)}
2025-06-16 15:11:00 +01:00
</Box>
);
};
2025-06-16 15:11:00 +01:00
export default PageEditor;