Update Page editor styling

This commit is contained in:
Reece 2025-06-16 15:11:00 +01:00
parent ac3da9b7c2
commit 7fc850b138
4 changed files with 458 additions and 207 deletions

View File

@ -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 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 ### 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!) - 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. - 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. 1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix. 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. 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/). 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: 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. 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: For managing language translations that affect multiple files, Stirling-PDF provides a helper script:

View File

@ -43,7 +43,7 @@ export class RotatePagesCommand extends PageCommand {
execute(): void { execute(): void {
const updatedPages = this.pdfDocument.pages.map(page => { const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) { if (this.pageIds.includes(page.id)) {
return { ...page, rotation: (page.rotation + this.rotation) % 360 }; return { ...page, rotation: page.rotation + this.rotation };
} }
return page; return page;
}); });

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useEffect } from "react"; import React, { useState, useCallback, useRef, useEffect } from "react";
import { import {
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon, Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container, Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group, Paper, SimpleGrid Stack, Group, Paper, SimpleGrid
} from "@mantine/core"; } from "@mantine/core";
@ -25,11 +25,11 @@ import { PDFDocument, PDFPage } from "../types/pageEditor";
import { fileStorage } from "../services/fileStorage"; import { fileStorage } from "../services/fileStorage";
import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import { useUndoRedo } from "../hooks/useUndoRedo"; import { useUndoRedo } from "../hooks/useUndoRedo";
import { import {
RotatePagesCommand, RotatePagesCommand,
DeletePagesCommand, DeletePagesCommand,
ReorderPageCommand, ReorderPageCommand,
ToggleSplitCommand ToggleSplitCommand
} from "../commands/pageCommands"; } from "../commands/pageCommands";
import { pdfExportService } from "../services/pdfExportService"; import { pdfExportService } from "../services/pdfExportService";
@ -48,7 +48,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { processPDFFile, loading: pdfLoading } = usePDFProcessor(); const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
const [pdfDocument, setPdfDocument] = useState<PDFDocument | null>(null); const [pdfDocument, setPdfDocument] = useState<PDFDocument | null>(null);
const [selectedPages, setSelectedPages] = useState<string[]>([]); const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
@ -58,11 +58,12 @@ const PageEditor: React.FC<PageEditorProps> = ({
const [showPageSelect, setShowPageSelect] = useState(false); const [showPageSelect, setShowPageSelect] = useState(false);
const [filename, setFilename] = useState<string>(""); const [filename, setFilename] = useState<string>("");
const [draggedPage, setDraggedPage] = useState<string | null>(null); const [draggedPage, setDraggedPage] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
const fileInputRef = useRef<() => void>(null); const fileInputRef = useRef<() => void>(null);
// Undo/Redo system // Undo/Redo system
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
@ -75,23 +76,23 @@ const PageEditor: React.FC<PageEditorProps> = ({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const document = await processPDFFile(uploadedFile); const document = await processPDFFile(uploadedFile);
setPdfDocument(document); setPdfDocument(document);
setFilename(uploadedFile.name.replace(/\.pdf$/i, '')); setFilename(uploadedFile.name.replace(/\.pdf$/i, ''));
setSelectedPages([]); setSelectedPages([]);
if (document.pages.length > 0) { if (document.pages.length > 0) {
const thumbnail = await generateThumbnailForFile(uploadedFile); const thumbnail = await generateThumbnailForFile(uploadedFile);
await fileStorage.storeFile(uploadedFile, thumbnail); await fileStorage.storeFile(uploadedFile, thumbnail);
} }
if (setFile) { if (setFile) {
const fileUrl = URL.createObjectURL(uploadedFile); const fileUrl = URL.createObjectURL(uploadedFile);
setFile({ file: uploadedFile, url: fileUrl }); setFile({ file: uploadedFile, url: fileUrl });
} }
setStatus(`PDF loaded successfully with ${document.totalPages} pages`); setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF'; const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF';
@ -113,23 +114,23 @@ const PageEditor: React.FC<PageEditorProps> = ({
setSelectedPages(pdfDocument.pages.map(p => p.id)); setSelectedPages(pdfDocument.pages.map(p => p.id));
} }
}, [pdfDocument]); }, [pdfDocument]);
const deselectAll = useCallback(() => setSelectedPages([]), []); const deselectAll = useCallback(() => setSelectedPages([]), []);
const togglePage = useCallback((pageId: string) => { const togglePage = useCallback((pageId: string) => {
setSelectedPages(prev => setSelectedPages(prev =>
prev.includes(pageId) prev.includes(pageId)
? prev.filter(id => id !== pageId) ? prev.filter(id => id !== pageId)
: [...prev, pageId] : [...prev, pageId]
); );
}, []); }, []);
const parseCSVInput = useCallback((csv: string) => { const parseCSVInput = useCallback((csv: string) => {
if (!pdfDocument) return []; if (!pdfDocument) return [];
const pageIds: string[] = []; const pageIds: string[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => { ranges.forEach(range => {
if (range.includes('-')) { if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim())); const [start, end] = range.split('-').map(n => parseInt(n.trim()));
@ -147,7 +148,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
} }
} }
}); });
return pageIds; return pageIds;
}, [pdfDocument]); }, [pdfDocument]);
@ -162,14 +163,55 @@ const PageEditor: React.FC<PageEditorProps> = ({
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); 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(); e.preventDefault();
if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return; if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return;
const targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId); let targetIndex: number;
if (targetIndex === -1) return; if (targetPageId === 'end') {
targetIndex = pdfDocument.pages.length;
} else {
targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId);
if (targetIndex === -1) return;
}
const command = new ReorderPageCommand( const command = new ReorderPageCommand(
pdfDocument, pdfDocument,
@ -177,15 +219,22 @@ const PageEditor: React.FC<PageEditorProps> = ({
draggedPage, draggedPage,
targetIndex targetIndex
); );
executeCommand(command); executeCommand(command);
setDraggedPage(null); setDraggedPage(null);
setDropTarget(null);
setStatus('Page reordered'); setStatus('Page reordered');
}, [draggedPage, pdfDocument, executeCommand]); }, [draggedPage, pdfDocument, executeCommand]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedPage) {
setDropTarget('end');
}
}, [draggedPage]);
const handleRotate = useCallback((direction: 'left' | 'right') => { const handleRotate = useCallback((direction: 'left' | 'right') => {
if (!pdfDocument || selectedPages.length === 0) return; if (!pdfDocument || selectedPages.length === 0) return;
const rotation = direction === 'left' ? -90 : 90; const rotation = direction === 'left' ? -90 : 90;
const command = new RotatePagesCommand( const command = new RotatePagesCommand(
pdfDocument, pdfDocument,
@ -193,20 +242,20 @@ const PageEditor: React.FC<PageEditorProps> = ({
selectedPages, selectedPages,
rotation rotation
); );
executeCommand(command); executeCommand(command);
setStatus(`Rotated ${selectedPages.length} pages ${direction}`); setStatus(`Rotated ${selectedPages.length} pages ${direction}`);
}, [pdfDocument, selectedPages, executeCommand]); }, [pdfDocument, selectedPages, executeCommand]);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!pdfDocument || selectedPages.length === 0) return; if (!pdfDocument || selectedPages.length === 0) return;
const command = new DeletePagesCommand( const command = new DeletePagesCommand(
pdfDocument, pdfDocument,
setPdfDocument, setPdfDocument,
selectedPages selectedPages
); );
executeCommand(command); executeCommand(command);
setSelectedPages([]); setSelectedPages([]);
setStatus(`Deleted ${selectedPages.length} pages`); setStatus(`Deleted ${selectedPages.length} pages`);
@ -214,20 +263,20 @@ const PageEditor: React.FC<PageEditorProps> = ({
const handleSplit = useCallback(() => { const handleSplit = useCallback(() => {
if (!pdfDocument || selectedPages.length === 0) return; if (!pdfDocument || selectedPages.length === 0) return;
const command = new ToggleSplitCommand( const command = new ToggleSplitCommand(
pdfDocument, pdfDocument,
setPdfDocument, setPdfDocument,
selectedPages selectedPages
); );
executeCommand(command); executeCommand(command);
setStatus(`Split markers toggled for ${selectedPages.length} pages`); setStatus(`Split markers toggled for ${selectedPages.length} pages`);
}, [pdfDocument, selectedPages, executeCommand]); }, [pdfDocument, selectedPages, executeCommand]);
const showExportPreview = useCallback((selectedOnly: boolean = false) => { const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!pdfDocument) return; if (!pdfDocument) return;
const exportPageIds = selectedOnly ? selectedPages : []; const exportPageIds = selectedOnly ? selectedPages : [];
const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly); const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly);
setExportPreview(preview); setExportPreview(preview);
@ -236,7 +285,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
const handleExport = useCallback(async (selectedOnly: boolean = false) => { const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!pdfDocument) return; if (!pdfDocument) return;
setExportLoading(true); setExportLoading(true);
try { try {
const exportPageIds = selectedOnly ? selectedPages : []; const exportPageIds = selectedOnly ? selectedPages : [];
@ -247,27 +296,27 @@ const PageEditor: React.FC<PageEditorProps> = ({
} }
const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore); const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore);
if (hasSplitMarkers) { if (hasSplitMarkers) {
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, { const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
selectedOnly, selectedOnly,
filename, filename,
splitDocuments: true splitDocuments: true
}) as { blobs: Blob[]; filenames: string[] }; }) as { blobs: Blob[]; filenames: string[] };
result.blobs.forEach((blob, index) => { result.blobs.forEach((blob, index) => {
setTimeout(() => { setTimeout(() => {
pdfExportService.downloadFile(blob, result.filenames[index]); pdfExportService.downloadFile(blob, result.filenames[index]);
}, index * 500); }, index * 500);
}); });
setStatus(`Exported ${result.blobs.length} split documents`); setStatus(`Exported ${result.blobs.length} split documents`);
} else { } else {
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, { const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
selectedOnly, selectedOnly,
filename filename
}) as { blob: Blob; filename: string }; }) as { blob: Blob; filename: string };
pdfExportService.downloadFile(result.blob, result.filename); pdfExportService.downloadFile(result.blob, result.filename);
setStatus('PDF exported successfully'); setStatus('PDF exported successfully');
} }
@ -293,67 +342,82 @@ const PageEditor: React.FC<PageEditorProps> = ({
if (!pdfDocument) { if (!pdfDocument) {
return ( return (
<Container> <Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<Paper shadow="xs" radius="md" p="md" pos="relative"> <LoadingOverlay visible={loading || pdfLoading} />
<LoadingOverlay visible={loading || pdfLoading} />
<Box p="xl">
<Group mb="md">
<ConstructionIcon />
<Text size="lg" fw={600}>PDF Multitool</Text>
</Group>
{error && ( {error && (
<Alert color="red" mb="md" onClose={() => setError(null)}> <Alert color="red" mb="md" onClose={() => setError(null)}>
{error} {error}
</Alert> </Alert>
)} )}
<Dropzone <Dropzone
onDrop={(files) => files[0] && handleFileUpload(files[0])} onDrop={(files) => files[0] && handleFileUpload(files[0])}
accept={["application/pdf"]} accept={["application/pdf"]}
multiple={false} multiple={false}
h={300} h="60vh"
style={{ minHeight: 400 }}
> >
<Center h={250}> <Center h="100%">
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 48 }} /> <UploadFileIcon style={{ fontSize: 64 }} />
<Text size="lg" fw={500}> <Text size="xl" fw={500}>
Drop a PDF file here or click to upload Drop a PDF file here or click to upload
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="md" c="dimmed">
Supports PDF files only Supports PDF files only
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
</Dropzone> </Dropzone>
</Paper> </Box>
</Container> </Box>
); );
} }
return ( return (
<Container> <Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<Paper shadow="xs" radius="md" p="md" pos="relative"> <style>
<LoadingOverlay visible={loading || pdfLoading} /> {`
.page-container:hover .page-number {
<Group mb="md"> opacity: 1 !important;
<ConstructionIcon /> }
<Text size="lg" fw={600}>PDF Multitool</Text> .page-container:hover .page-hover-controls {
</Group> opacity: 1 !important;
}
<Group mb="md"> .page-container {
<TextInput transition: transform 0.2s ease-in-out;
value={filename} }
onChange={(e) => setFilename(e.target.value)} .page-container:hover {
placeholder="Enter filename" transform: scale(1.02);
style={{ minWidth: 200 }} }
/> @keyframes pulse {
<Button onClick={() => setShowPageSelect(!showPageSelect)}> 0%, 100% {
Select Pages opacity: 1;
</Button> }
<Button onClick={selectAll}>Select All</Button> 50% {
<Button onClick={deselectAll}>Deselect All</Button> opacity: 0.5;
</Group> }
}
`}
</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>
{showPageSelect && ( {showPageSelect && (
<Paper p="md" mb="md" withBorder> <Paper p="md" mb="md" withBorder>
@ -412,121 +476,308 @@ const PageEditor: React.FC<PageEditorProps> = ({
</Tooltip> </Tooltip>
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing="md"> <div
{pdfDocument.pages.map((page) => ( style={{
<Box display: 'flex',
key={page.id} flexWrap: 'wrap',
style={{ gap: '1.5rem',
borderRadius: 8, justifyContent: 'flex-start'
padding: 8, }}
position: 'relative', >
cursor: 'grab', {pdfDocument.pages.map((page, index) => (
...(selectedPages.includes(page.id) <React.Fragment key={page.id}>
? { border: '2px solid blue' } {page.splitBefore && index > 0 && (
: { border: '1px solid #ccc' } <div
), style={{
...(page.splitBefore width: '4px',
? { borderLeft: '4px dashed orange' } height: '15rem',
: {} border: '2px dashed #3b82f6',
) backgroundColor: 'transparent',
}} borderRadius: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
position: 'relative',
flexShrink: 0
}}
>
<ContentCutIcon
style={{
fontSize: 18,
color: '#3b82f6',
backgroundColor: 'white',
borderRadius: '50%',
padding: '3px'
}}
/>
</div>
)}
<div
data-page-id={page.id}
className={`
!rounded-lg
cursor-grab
select-none
w-[15rem]
h-[15rem]
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${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' : ''}
`}
style={{
transform: (() => {
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 draggable
onDragStart={() => handleDragStart(page.id)} onDragStart={() => handleDragStart(page.id)}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnter={() => handleDragEnter(page.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, page.id)} onDrop={(e) => handleDrop(e, page.id)}
> >
<Stack align="center" gap={4}> <div className="page-container w-[90%] h-[90%]">
{showPageSelect && ( <img
<Checkbox src={page.thumbnail}
checked={selectedPages.includes(page.id)} alt={`Page ${page.pageNumber}`}
onChange={() => togglePage(page.id)} style={{
size="sm" width: '100%',
/> height: '100%',
)} objectFit: 'contain',
borderRadius: 4,
<Box w={120} h={160} pos="relative"> transform: `rotate(${page.rotation}deg)`,
<img transition: 'transform 0.3s ease-in-out'
src={page.thumbnail} }}
alt={`Page ${page.pageNumber}`} />
style={{
width: '100%', {/* Page number overlay - shows on hover */}
height: '100%', <Text
objectFit: 'contain', className="page-number"
borderRadius: 4, size="sm"
transform: `rotate(${page.rotation}deg)` fw={500}
}} c="white"
/> style={{
position: 'absolute',
<Text top: 5,
size="xs" left: 5,
fw={500} background: 'rgba(162, 201, 255, 0.8)',
c="white" padding: '6px 8px',
style={{ borderRadius: 8,
position: 'absolute', zIndex: 2,
top: 4, opacity: 0,
left: 4, transition: 'opacity 0.2s ease-in-out'
background: 'rgba(0,0,0,0.7)', }}
padding: '2px 6px', >
borderRadius: 4 {page.pageNumber}
}}
>
{page.pageNumber}
</Text>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.5)',
fontSize: 16
}}
/>
</Box>
<Text size="xs" c="dimmed">
Page {page.pageNumber}
</Text> </Text>
</Stack>
</Box> {/* 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'
}}
>
<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>
<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>
<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>
))} ))}
</SimpleGrid>
<Group justify="space-between" mt="md">
<Button
color="red"
variant="light"
onClick={() => {
setPdfDocument(null);
setFile && setFile(null);
}}
>
Close PDF
</Button>
<Group> {/* Landing zone at the end */}
<Button <div
leftSection={<DownloadIcon />} data-drop-zone="end"
disabled={selectedPages.length === 0 || exportLoading} style={{
loading={exportLoading} width: '15rem',
onClick={() => showExportPreview(true)} height: '15rem',
> border: '2px dashed #9ca3af',
Download Selected borderRadius: '8px',
</Button> display: 'flex',
<Button alignItems: 'center',
leftSection={<DownloadIcon />} justifyContent: 'center',
color="green" flexShrink: 0,
disabled={exportLoading} backgroundColor: dropTarget === 'end' ? '#ecfdf5' : 'transparent',
loading={exportLoading} borderColor: dropTarget === 'end' ? '#10b981' : '#9ca3af',
onClick={() => showExportPreview(false)} transition: 'all 0.2s ease-in-out'
> }}
Download All onDragOver={handleDragOver}
</Button> onDragEnter={handleEndZoneDragEnter}
</Group> onDragLeave={handleDragLeave}
</Group> onDrop={(e) => handleDrop(e, 'end')}
>
<Text c="dimmed" size="sm" ta="center">
Drop here to<br />move to end
</Text>
</div>
</div>
<Modal <Group justify="space-between" mt="md">
opened={showExportModal} <Button
color="red"
variant="light"
onClick={() => {
setPdfDocument(null);
setFile && setFile(null);
}}
>
Close PDF
</Button>
<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>
</Group>
</Box>
<Modal
opened={showExportModal}
onClose={() => setShowExportModal(false)} onClose={() => setShowExportModal(false)}
title="Export Preview" title="Export Preview"
> >
@ -536,33 +787,33 @@ const PageEditor: React.FC<PageEditorProps> = ({
<Text>Pages to export:</Text> <Text>Pages to export:</Text>
<Text fw={500}>{exportPreview.pageCount}</Text> <Text fw={500}>{exportPreview.pageCount}</Text>
</Group> </Group>
{exportPreview.splitCount > 1 && ( {exportPreview.splitCount > 1 && (
<Group justify="space-between"> <Group justify="space-between">
<Text>Split into documents:</Text> <Text>Split into documents:</Text>
<Text fw={500}>{exportPreview.splitCount}</Text> <Text fw={500}>{exportPreview.splitCount}</Text>
</Group> </Group>
)} )}
<Group justify="space-between"> <Group justify="space-between">
<Text>Estimated size:</Text> <Text>Estimated size:</Text>
<Text fw={500}>{exportPreview.estimatedSize}</Text> <Text fw={500}>{exportPreview.estimatedSize}</Text>
</Group> </Group>
{pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && ( {pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && (
<Alert color="blue"> <Alert color="blue">
This will create multiple PDF files based on split markers. This will create multiple PDF files based on split markers.
</Alert> </Alert>
)} )}
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button <Button
variant="light" variant="light"
onClick={() => setShowExportModal(false)} onClick={() => setShowExportModal(false)}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
color="green" color="green"
loading={exportLoading} loading={exportLoading}
onClick={() => { onClick={() => {
@ -584,20 +835,19 @@ const PageEditor: React.FC<PageEditorProps> = ({
onChange={(file) => file && handleFileUpload(file)} onChange={(file) => file && handleFileUpload(file)}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
</Paper>
{status && ( {status && (
<Notification <Notification
color="blue" color="blue"
mt="md" mt="md"
onClose={() => setStatus(null)} onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
> >
{status} {status}
</Notification> </Notification>
)} )}
</Container> </Box>
); );
}; };
export default PageEditor; export default PageEditor;

View File

@ -1,4 +1,5 @@
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import './index.css'; // Import Tailwind CSS
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core'; import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core';