2025-06-10 11:19:54 +01:00
|
|
|
import React, { useState, useCallback, useRef, useEffect } 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-06-10 11:19:54 +01:00
|
|
|
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
|
|
|
|
Stack, Group, Paper, SimpleGrid
|
2025-05-21 21:47:44 +01:00
|
|
|
} 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";
|
2025-05-21 21:47:44 +01:00
|
|
|
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-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,
|
|
|
|
ToggleSplitCommand
|
2025-06-10 11:19:54 +01:00
|
|
|
} from "../commands/pageCommands";
|
|
|
|
import { pdfExportService } from "../services/pdfExportService";
|
2025-05-21 21:47:44 +01:00
|
|
|
|
|
|
|
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[]>([]);
|
2025-05-21 21:47:44 +01:00
|
|
|
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>("");
|
|
|
|
const [showPageSelect, setShowPageSelect] = useState(false);
|
|
|
|
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-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-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]);
|
|
|
|
|
|
|
|
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]
|
|
|
|
);
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
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-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-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);
|
|
|
|
}, [draggedPage]);
|
|
|
|
|
|
|
|
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-05-21 21:47:44 +01:00
|
|
|
|
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
|
|
|
|
|
|
|
const command = new ReorderPageCommand(
|
|
|
|
pdfDocument,
|
|
|
|
setPdfDocument,
|
|
|
|
draggedPage,
|
|
|
|
targetIndex
|
|
|
|
);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
executeCommand(command);
|
|
|
|
setDraggedPage(null);
|
2025-06-16 15:11:00 +01:00
|
|
|
setDropTarget(null);
|
2025-06-10 11:19:54 +01:00
|
|
|
setStatus('Page reordered');
|
|
|
|
}, [draggedPage, pdfDocument, executeCommand]);
|
|
|
|
|
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') => {
|
|
|
|
if (!pdfDocument || selectedPages.length === 0) return;
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const rotation = direction === 'left' ? -90 : 90;
|
|
|
|
const command = new RotatePagesCommand(
|
|
|
|
pdfDocument,
|
|
|
|
setPdfDocument,
|
|
|
|
selectedPages,
|
|
|
|
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);
|
|
|
|
setStatus(`Rotated ${selectedPages.length} pages ${direction}`);
|
|
|
|
}, [pdfDocument, selectedPages, executeCommand]);
|
2025-05-21 21:47:44 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
const handleDelete = useCallback(() => {
|
|
|
|
if (!pdfDocument || 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,
|
|
|
|
selectedPages
|
|
|
|
);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
executeCommand(command);
|
|
|
|
setSelectedPages([]);
|
|
|
|
setStatus(`Deleted ${selectedPages.length} pages`);
|
|
|
|
}, [pdfDocument, selectedPages, executeCommand]);
|
|
|
|
|
|
|
|
const handleSplit = useCallback(() => {
|
|
|
|
if (!pdfDocument || 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,
|
|
|
|
selectedPages
|
|
|
|
);
|
2025-06-16 15:11:00 +01:00
|
|
|
|
2025-06-10 11:19:54 +01:00
|
|
|
executeCommand(command);
|
|
|
|
setStatus(`Split markers toggled for ${selectedPages.length} pages`);
|
|
|
|
}, [pdfDocument, selectedPages, executeCommand]);
|
|
|
|
|
|
|
|
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-05-21 21:47:44 +01:00
|
|
|
}
|
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-05-21 21:47:44 +01:00
|
|
|
}
|
2025-06-10 11:19:54 +01:00
|
|
|
}, [redo]);
|
|
|
|
|
|
|
|
if (!pdfDocument) {
|
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' }}>
|
|
|
|
<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>
|
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' }}>
|
|
|
|
<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-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-16 15:11:00 +01:00
|
|
|
@keyframes pulse {
|
|
|
|
0%, 100% {
|
|
|
|
opacity: 1;
|
|
|
|
}
|
|
|
|
50% {
|
|
|
|
opacity: 0.5;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
`}
|
|
|
|
</style>
|
|
|
|
<LoadingOverlay visible={loading || pdfLoading} />
|
|
|
|
|
|
|
|
<Box p="md">
|
|
|
|
<Group mb="md">
|
|
|
|
<TextInput
|
|
|
|
value={filename}
|
|
|
|
onChange={(e) => setFilename(e.target.value)}
|
|
|
|
placeholder="Enter filename"
|
|
|
|
style={{ minWidth: 200 }}
|
|
|
|
/>
|
|
|
|
<Button onClick={() => setShowPageSelect(!showPageSelect)}>
|
|
|
|
Select Pages
|
|
|
|
</Button>
|
|
|
|
<Button onClick={selectAll}>Select All</Button>
|
|
|
|
<Button onClick={deselectAll}>Deselect All</Button>
|
|
|
|
</Group>
|
2025-06-10 11:19:54 +01:00
|
|
|
|
|
|
|
{showPageSelect && (
|
|
|
|
<Paper p="md" mb="md" withBorder>
|
2025-05-21 21:47:44 +01:00
|
|
|
<Group>
|
2025-06-10 11:19:54 +01:00
|
|
|
<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>
|
|
|
|
)}
|
|
|
|
|
|
|
|
<Group mb="md">
|
|
|
|
<Tooltip label="Undo">
|
|
|
|
<ActionIcon onClick={handleUndo} disabled={!canUndo}>
|
|
|
|
<UndoIcon />
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
<Tooltip label="Redo">
|
|
|
|
<ActionIcon onClick={handleRedo} disabled={!canRedo}>
|
|
|
|
<RedoIcon />
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
<Tooltip label="Rotate Left">
|
|
|
|
<ActionIcon onClick={() => handleRotate('left')} disabled={selectedPages.length === 0}>
|
|
|
|
<RotateLeftIcon />
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
<Tooltip label="Rotate Right">
|
|
|
|
<ActionIcon onClick={() => handleRotate('right')} disabled={selectedPages.length === 0}>
|
|
|
|
<RotateRightIcon />
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
<Tooltip label="Delete">
|
|
|
|
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red">
|
|
|
|
<DeleteIcon />
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
<Tooltip label="Split">
|
|
|
|
<ActionIcon onClick={handleSplit} disabled={selectedPages.length === 0}>
|
|
|
|
<ContentCutIcon />
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
</Group>
|
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
<div
|
|
|
|
style={{
|
|
|
|
display: 'flex',
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
gap: '1.5rem',
|
|
|
|
justifyContent: 'flex-start'
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{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
|
|
|
|
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-16 19:57:50 +01:00
|
|
|
page-move-animation
|
2025-06-16 15:11:00 +01:00
|
|
|
${selectedPages.includes(page.id)
|
|
|
|
? 'ring-2 ring-blue-500 bg-blue-50'
|
|
|
|
: '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
|
|
|
`}
|
|
|
|
style={{
|
|
|
|
transform: (() => {
|
|
|
|
if (!draggedPage || page.id === draggedPage) return 'translateX(0)';
|
2025-06-16 19:57:50 +01:00
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
if (dropTarget === page.id) {
|
|
|
|
return 'translateX(20px)'; // Move slightly right to indicate drop position
|
|
|
|
}
|
|
|
|
return 'translateX(0)';
|
|
|
|
})(),
|
|
|
|
transition: 'transform 0.2s ease-in-out'
|
|
|
|
}}
|
2025-06-10 11:19:54 +01:00
|
|
|
draggable
|
|
|
|
onDragStart={() => handleDragStart(page.id)}
|
|
|
|
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-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();
|
|
|
|
if (index > 0 && !movingPage) {
|
|
|
|
setMovingPage(page.id);
|
|
|
|
setTimeout(() => {
|
|
|
|
const command = new ReorderPageCommand(
|
|
|
|
pdfDocument,
|
|
|
|
setPdfDocument,
|
|
|
|
page.id,
|
|
|
|
index - 1
|
|
|
|
);
|
|
|
|
executeCommand(command);
|
|
|
|
setTimeout(() => setMovingPage(null), 100);
|
|
|
|
setStatus(`Moved page ${page.pageNumber} left`);
|
|
|
|
}, 50);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<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();
|
|
|
|
if (index < pdfDocument.pages.length - 1 && !movingPage) {
|
|
|
|
setMovingPage(page.id);
|
|
|
|
setTimeout(() => {
|
|
|
|
const command = new ReorderPageCommand(
|
|
|
|
pdfDocument,
|
|
|
|
setPdfDocument,
|
|
|
|
page.id,
|
|
|
|
index + 1
|
|
|
|
);
|
|
|
|
executeCommand(command);
|
|
|
|
setTimeout(() => setMovingPage(null), 100);
|
|
|
|
setStatus(`Moved page ${page.pageNumber} right`);
|
|
|
|
}, 50);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<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
|
|
|
|
|
|
|
<Tooltip label="Select Page">
|
|
|
|
<Checkbox
|
|
|
|
size="md"
|
|
|
|
checked={selectedPages.includes(page.id)}
|
|
|
|
onChange={() => togglePage(page.id)}
|
|
|
|
styles={{
|
|
|
|
input: { backgroundColor: 'white' }
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</Tooltip>
|
|
|
|
</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>
|
|
|
|
|
|
|
|
<Group justify="space-between" mt="md">
|
2025-06-10 11:19:54 +01:00
|
|
|
<Button
|
2025-06-16 15:11:00 +01:00
|
|
|
color="red"
|
|
|
|
variant="light"
|
|
|
|
onClick={() => {
|
|
|
|
setPdfDocument(null);
|
|
|
|
setFile && setFile(null);
|
|
|
|
}}
|
2025-06-10 11:19:54 +01:00
|
|
|
>
|
2025-06-16 15:11:00 +01:00
|
|
|
Close PDF
|
2025-06-10 11:19:54 +01:00
|
|
|
</Button>
|
2025-06-16 15:11:00 +01:00
|
|
|
|
|
|
|
<Group>
|
|
|
|
<Button
|
|
|
|
leftSection={<DownloadIcon />}
|
|
|
|
disabled={selectedPages.length === 0 || exportLoading}
|
|
|
|
loading={exportLoading}
|
|
|
|
onClick={() => showExportPreview(true)}
|
|
|
|
>
|
|
|
|
Download Selected
|
|
|
|
</Button>
|
|
|
|
<Button
|
|
|
|
leftSection={<DownloadIcon />}
|
|
|
|
color="green"
|
|
|
|
disabled={exportLoading}
|
|
|
|
loading={exportLoading}
|
|
|
|
onClick={() => showExportPreview(false)}
|
|
|
|
>
|
|
|
|
Download All
|
|
|
|
</Button>
|
|
|
|
</Group>
|
2025-06-10 11:19:54 +01:00
|
|
|
</Group>
|
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>
|
|
|
|
)}
|
|
|
|
</Box>
|
2025-05-21 21:47:44 +01:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2025-06-16 15:11:00 +01:00
|
|
|
export default PageEditor;
|