diff --git a/DeveloperGuide.md b/DeveloperGuide.md index 65c26d36a..5506ec2f9 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -246,7 +246,7 @@ Stirling-PDF uses different Docker images for various configurations. The build Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase -## 6. Testing +## 7. Testing ### Comprehensive Testing Script @@ -311,7 +311,7 @@ Important notes: - There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!) - Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup. -## 7. Contributing +## 8. Contributing 1. Fork the repository on GitHub. 2. Create a new branch for your feature or bug fix. @@ -336,11 +336,11 @@ When you raise a PR: Address any issues that arise from these checks before finalizing your pull request. -## 8. API Documentation +## 9. API Documentation API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/). -## 9. Customization +## 10. Customization Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include: @@ -359,7 +359,7 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full Refer to the main README for a full list of customization options. -## 10. Language Translations +## 11. Language Translations For managing language translations that affect multiple files, Stirling-PDF provides a helper script: diff --git a/frontend/src/commands/pageCommands.ts b/frontend/src/commands/pageCommands.ts index 7d06c567c..d0ecd699b 100644 --- a/frontend/src/commands/pageCommands.ts +++ b/frontend/src/commands/pageCommands.ts @@ -43,7 +43,7 @@ export class RotatePagesCommand extends PageCommand { execute(): void { const updatedPages = this.pdfDocument.pages.map(page => { if (this.pageIds.includes(page.id)) { - return { ...page, rotation: (page.rotation + this.rotation) % 360 }; + return { ...page, rotation: page.rotation + this.rotation }; } return page; }); diff --git a/frontend/src/components/PageEditor.tsx b/frontend/src/components/PageEditor.tsx index 81ea7417f..a34b088f1 100644 --- a/frontend/src/components/PageEditor.tsx +++ b/frontend/src/components/PageEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import { - Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon, + Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon, Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container, Stack, Group, Paper, SimpleGrid } from "@mantine/core"; @@ -25,11 +25,11 @@ import { PDFDocument, PDFPage } from "../types/pageEditor"; import { fileStorage } from "../services/fileStorage"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { useUndoRedo } from "../hooks/useUndoRedo"; -import { - RotatePagesCommand, - DeletePagesCommand, - ReorderPageCommand, - ToggleSplitCommand +import { + RotatePagesCommand, + DeletePagesCommand, + ReorderPageCommand, + ToggleSplitCommand } from "../commands/pageCommands"; import { pdfExportService } from "../services/pdfExportService"; @@ -48,7 +48,7 @@ const PageEditor: React.FC = ({ }) => { const { t } = useTranslation(); const { processPDFFile, loading: pdfLoading } = usePDFProcessor(); - + const [pdfDocument, setPdfDocument] = useState(null); const [selectedPages, setSelectedPages] = useState([]); const [status, setStatus] = useState(null); @@ -58,11 +58,12 @@ const PageEditor: React.FC = ({ const [showPageSelect, setShowPageSelect] = useState(false); const [filename, setFilename] = useState(""); const [draggedPage, setDraggedPage] = useState(null); + const [dropTarget, setDropTarget] = useState(null); const [exportLoading, setExportLoading] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); const fileInputRef = useRef<() => void>(null); - + // Undo/Redo system const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); @@ -75,23 +76,23 @@ const PageEditor: React.FC = ({ setLoading(true); setError(null); - + try { const document = await processPDFFile(uploadedFile); setPdfDocument(document); setFilename(uploadedFile.name.replace(/\.pdf$/i, '')); setSelectedPages([]); - + if (document.pages.length > 0) { const thumbnail = await generateThumbnailForFile(uploadedFile); await fileStorage.storeFile(uploadedFile, thumbnail); } - + if (setFile) { const fileUrl = URL.createObjectURL(uploadedFile); setFile({ file: uploadedFile, url: fileUrl }); } - + setStatus(`PDF loaded successfully with ${document.totalPages} pages`); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF'; @@ -113,23 +114,23 @@ const PageEditor: React.FC = ({ setSelectedPages(pdfDocument.pages.map(p => p.id)); } }, [pdfDocument]); - + const deselectAll = useCallback(() => setSelectedPages([]), []); - + const togglePage = useCallback((pageId: string) => { - setSelectedPages(prev => - prev.includes(pageId) - ? prev.filter(id => id !== pageId) + setSelectedPages(prev => + prev.includes(pageId) + ? prev.filter(id => id !== pageId) : [...prev, pageId] ); }, []); const parseCSVInput = useCallback((csv: string) => { if (!pdfDocument) return []; - + const pageIds: string[] = []; const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); - + ranges.forEach(range => { if (range.includes('-')) { const [start, end] = range.split('-').map(n => parseInt(n.trim())); @@ -147,7 +148,7 @@ const PageEditor: React.FC = ({ } } }); - + return pageIds; }, [pdfDocument]); @@ -162,14 +163,55 @@ const PageEditor: React.FC = ({ const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); + + if (!draggedPage) return; + + // Get the element under the mouse cursor + const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); + if (!elementUnderCursor) return; + + // Find the closest page container + const pageContainer = elementUnderCursor.closest('[data-page-id]'); + if (pageContainer) { + const pageId = pageContainer.getAttribute('data-page-id'); + if (pageId && pageId !== draggedPage) { + setDropTarget(pageId); + return; + } + } + + // Check if over the end zone + const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); + if (endZone) { + setDropTarget('end'); + return; + } + + // If not over any valid drop target, clear it + setDropTarget(null); + }, [draggedPage]); + + 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 }, []); - const handleDrop = useCallback((e: React.DragEvent, targetPageId: string) => { + const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => { e.preventDefault(); if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return; - const targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId); - if (targetIndex === -1) return; + let targetIndex: number; + if (targetPageId === 'end') { + targetIndex = pdfDocument.pages.length; + } else { + targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId); + if (targetIndex === -1) return; + } const command = new ReorderPageCommand( pdfDocument, @@ -177,15 +219,22 @@ const PageEditor: React.FC = ({ draggedPage, targetIndex ); - + executeCommand(command); setDraggedPage(null); + setDropTarget(null); setStatus('Page reordered'); }, [draggedPage, pdfDocument, executeCommand]); + const handleEndZoneDragEnter = useCallback(() => { + if (draggedPage) { + setDropTarget('end'); + } + }, [draggedPage]); + const handleRotate = useCallback((direction: 'left' | 'right') => { if (!pdfDocument || selectedPages.length === 0) return; - + const rotation = direction === 'left' ? -90 : 90; const command = new RotatePagesCommand( pdfDocument, @@ -193,20 +242,20 @@ const PageEditor: React.FC = ({ selectedPages, rotation ); - + executeCommand(command); setStatus(`Rotated ${selectedPages.length} pages ${direction}`); }, [pdfDocument, selectedPages, executeCommand]); const handleDelete = useCallback(() => { if (!pdfDocument || selectedPages.length === 0) return; - + const command = new DeletePagesCommand( pdfDocument, setPdfDocument, selectedPages ); - + executeCommand(command); setSelectedPages([]); setStatus(`Deleted ${selectedPages.length} pages`); @@ -214,20 +263,20 @@ const PageEditor: React.FC = ({ const handleSplit = useCallback(() => { if (!pdfDocument || selectedPages.length === 0) return; - + const command = new ToggleSplitCommand( pdfDocument, setPdfDocument, selectedPages ); - + executeCommand(command); setStatus(`Split markers toggled for ${selectedPages.length} pages`); }, [pdfDocument, selectedPages, executeCommand]); const showExportPreview = useCallback((selectedOnly: boolean = false) => { if (!pdfDocument) return; - + const exportPageIds = selectedOnly ? selectedPages : []; const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly); setExportPreview(preview); @@ -236,7 +285,7 @@ const PageEditor: React.FC = ({ const handleExport = useCallback(async (selectedOnly: boolean = false) => { if (!pdfDocument) return; - + setExportLoading(true); try { const exportPageIds = selectedOnly ? selectedPages : []; @@ -247,27 +296,27 @@ const PageEditor: React.FC = ({ } const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore); - + if (hasSplitMarkers) { const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, { selectedOnly, filename, splitDocuments: true }) as { blobs: Blob[]; filenames: string[] }; - + result.blobs.forEach((blob, index) => { setTimeout(() => { pdfExportService.downloadFile(blob, result.filenames[index]); }, index * 500); }); - + setStatus(`Exported ${result.blobs.length} split documents`); } else { const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, { selectedOnly, filename }) as { blob: Blob; filename: string }; - + pdfExportService.downloadFile(result.blob, result.filename); setStatus('PDF exported successfully'); } @@ -293,67 +342,82 @@ const PageEditor: React.FC = ({ if (!pdfDocument) { return ( - - - - - - - PDF Multitool - - + + + + {error && ( setError(null)}> {error} )} - + files[0] && handleFileUpload(files[0])} accept={["application/pdf"]} multiple={false} - h={300} + h="60vh" + style={{ minHeight: 400 }} > -
+
- - + + Drop a PDF file here or click to upload - + Supports PDF files only
- - + + ); } return ( - - - - - - - PDF Multitool - - - - setFilename(e.target.value)} - placeholder="Enter filename" - style={{ minWidth: 200 }} - /> - - - - + + + + + + + setFilename(e.target.value)} + placeholder="Enter filename" + style={{ minWidth: 200 }} + /> + + + + {showPageSelect && ( @@ -412,121 +476,308 @@ const PageEditor: React.FC = ({ - - {pdfDocument.pages.map((page) => ( - + {pdfDocument.pages.map((page, index) => ( + + {page.splitBefore && index > 0 && ( +
+ +
+ )} +
{ + if (!draggedPage || page.id === draggedPage) return 'translateX(0)'; + + if (dropTarget === page.id) { + return 'translateX(20px)'; // Move slightly right to indicate drop position + } + return 'translateX(0)'; + })(), + transition: 'transform 0.2s ease-in-out' + }} draggable onDragStart={() => handleDragStart(page.id)} onDragOver={handleDragOver} + onDragEnter={() => handleDragEnter(page.id)} + onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, page.id)} > - - {showPageSelect && ( - togglePage(page.id)} - size="sm" - /> - )} - - - {`Page - - - {page.pageNumber} - - - - - - - Page {page.pageNumber} +
+ {`Page + + {/* Page number overlay - shows on hover */} + + {page.pageNumber} - - + + {/* Hover controls */} +
+ + { + e.stopPropagation(); + const command = new RotatePagesCommand( + pdfDocument, + setPdfDocument, + [page.id], + -90 + ); + executeCommand(command); + setStatus(`Rotated page ${page.pageNumber} left`); + }} + > + + + + + + { + e.stopPropagation(); + const command = new RotatePagesCommand( + pdfDocument, + setPdfDocument, + [page.id], + 90 + ); + executeCommand(command); + setStatus(`Rotated page ${page.pageNumber} right`); + }} + > + + + + + + { + e.stopPropagation(); + const command = new DeletePagesCommand( + pdfDocument, + setPdfDocument, + [page.id] + ); + executeCommand(command); + setStatus(`Deleted page ${page.pageNumber}`); + }} + > + + + + + + { + e.stopPropagation(); + const command = new ToggleSplitCommand( + pdfDocument, + setPdfDocument, + [page.id] + ); + executeCommand(command); + setStatus(`Split marker toggled for page ${page.pageNumber}`); + }} + > + + + + + + togglePage(page.id)} + styles={{ + input: { backgroundColor: 'white' } + }} + /> + +
+ + +
+
+
))} -
- - - - - - - - + {/* Landing zone at the end */} +
handleDrop(e, 'end')} + > + + Drop here to
move to end +
+
+ - + + + + + + + +
+ + setShowExportModal(false)} title="Export Preview" > @@ -536,33 +787,33 @@ const PageEditor: React.FC = ({ Pages to export: {exportPreview.pageCount} - + {exportPreview.splitCount > 1 && ( Split into documents: {exportPreview.splitCount} )} - + Estimated size: {exportPreview.estimatedSize} - + {pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && ( This will create multiple PDF files based on split markers. )} - + - -