diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 22db08803..0e33392bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -1394,6 +1395,22 @@ "node": ">= 8" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -4213,6 +4230,11 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4278,6 +4300,22 @@ "node": ">=8" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/pdfjs-dist": { "version": "3.11.174", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2864724dc..fa7a0b5d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/frontend/src/commands/pageCommands.ts b/frontend/src/commands/pageCommands.ts new file mode 100644 index 000000000..7d06c567c --- /dev/null +++ b/frontend/src/commands/pageCommands.ts @@ -0,0 +1,334 @@ +import { Command, CommandSequence } from '../hooks/useUndoRedo'; +import { PDFDocument, PDFPage } from '../types/pageEditor'; + +// Base class for page operations +abstract class PageCommand implements Command { + protected pdfDocument: PDFDocument; + protected setPdfDocument: (doc: PDFDocument) => void; + protected previousState: PDFDocument; + + constructor( + pdfDocument: PDFDocument, + setPdfDocument: (doc: PDFDocument) => void + ) { + this.pdfDocument = pdfDocument; + this.setPdfDocument = setPdfDocument; + this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone + } + + abstract execute(): void; + abstract description: string; + + undo(): void { + this.setPdfDocument(this.previousState); + } +} + +// Rotate pages command +export class RotatePagesCommand extends PageCommand { + private pageIds: string[]; + private rotation: number; + + constructor( + pdfDocument: PDFDocument, + setPdfDocument: (doc: PDFDocument) => void, + pageIds: string[], + rotation: number + ) { + super(pdfDocument, setPdfDocument); + this.pageIds = pageIds; + this.rotation = rotation; + } + + 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; + }); + + this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + } + + get description(): string { + const direction = this.rotation > 0 ? 'right' : 'left'; + return `Rotate ${this.pageIds.length} page(s) ${direction}`; + } +} + +// Delete pages command +export class DeletePagesCommand extends PageCommand { + private pageIds: string[]; + private deletedPages: PDFPage[]; + private deletedPositions: Map; + + constructor( + pdfDocument: PDFDocument, + setPdfDocument: (doc: PDFDocument) => void, + pageIds: string[] + ) { + super(pdfDocument, setPdfDocument); + this.pageIds = pageIds; + this.deletedPages = []; + this.deletedPositions = new Map(); + } + + execute(): void { + // Store deleted pages and their positions for undo + this.deletedPages = this.pdfDocument.pages.filter(page => + this.pageIds.includes(page.id) + ); + + this.deletedPages.forEach(page => { + const index = this.pdfDocument.pages.findIndex(p => p.id === page.id); + this.deletedPositions.set(page.id, index); + }); + + const updatedPages = this.pdfDocument.pages + .filter(page => !this.pageIds.includes(page.id)) + .map((page, index) => ({ ...page, pageNumber: index + 1 })); + + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); + } + + undo(): void { + let restoredPages = [...this.pdfDocument.pages]; + + // Insert deleted pages back at their original positions + this.deletedPages + .sort((a, b) => (this.deletedPositions.get(a.id) || 0) - (this.deletedPositions.get(b.id) || 0)) + .forEach(page => { + const originalIndex = this.deletedPositions.get(page.id) || 0; + restoredPages.splice(originalIndex, 0, page); + }); + + // Update page numbers + restoredPages = restoredPages.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); + + this.setPdfDocument({ + ...this.pdfDocument, + pages: restoredPages, + totalPages: restoredPages.length + }); + } + + get description(): string { + return `Delete ${this.pageIds.length} page(s)`; + } +} + +// Move pages command +export class MovePagesCommand extends PageCommand { + private pageIds: string[]; + private targetIndex: number; + private originalIndices: Map; + + constructor( + pdfDocument: PDFDocument, + setPdfDocument: (doc: PDFDocument) => void, + pageIds: string[], + targetIndex: number + ) { + super(pdfDocument, setPdfDocument); + this.pageIds = pageIds; + this.targetIndex = targetIndex; + this.originalIndices = new Map(); + } + + execute(): void { + // Store original positions + this.pageIds.forEach(pageId => { + const index = this.pdfDocument.pages.findIndex(p => p.id === pageId); + this.originalIndices.set(pageId, index); + }); + + let newPages = [...this.pdfDocument.pages]; + const pagesToMove = this.pageIds + .map(id => this.pdfDocument.pages.find(p => p.id === id)) + .filter((page): page is PDFPage => page !== undefined); + + // Remove pages to move + newPages = newPages.filter(page => !this.pageIds.includes(page.id)); + + // Insert pages at target position + newPages.splice(this.targetIndex, 0, ...pagesToMove); + + // Update page numbers + newPages = newPages.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); + + this.setPdfDocument({ ...this.pdfDocument, pages: newPages }); + } + + get description(): string { + return `Move ${this.pageIds.length} page(s)`; + } +} + +// Reorder single page command (for drag-and-drop) +export class ReorderPageCommand extends PageCommand { + private pageId: string; + private targetIndex: number; + private originalIndex: number; + + constructor( + pdfDocument: PDFDocument, + setPdfDocument: (doc: PDFDocument) => void, + pageId: string, + targetIndex: number + ) { + super(pdfDocument, setPdfDocument); + this.pageId = pageId; + this.targetIndex = targetIndex; + this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId); + } + + execute(): void { + const newPages = [...this.pdfDocument.pages]; + const [movedPage] = newPages.splice(this.originalIndex, 1); + newPages.splice(this.targetIndex, 0, movedPage); + + // Update page numbers + const updatedPages = newPages.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); + + this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + } + + get description(): string { + return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`; + } +} + +// Toggle split markers command +export class ToggleSplitCommand extends PageCommand { + private pageIds: string[]; + private previousSplitStates: Map; + + constructor( + pdfDocument: PDFDocument, + setPdfDocument: (doc: PDFDocument) => void, + pageIds: string[] + ) { + super(pdfDocument, setPdfDocument); + this.pageIds = pageIds; + this.previousSplitStates = new Map(); + } + + execute(): void { + // Store previous split states + this.pageIds.forEach(pageId => { + const page = this.pdfDocument.pages.find(p => p.id === pageId); + if (page) { + this.previousSplitStates.set(pageId, !!page.splitBefore); + } + }); + + const updatedPages = this.pdfDocument.pages.map(page => { + if (this.pageIds.includes(page.id)) { + return { ...page, splitBefore: !page.splitBefore }; + } + return page; + }); + + this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + } + + undo(): void { + const updatedPages = this.pdfDocument.pages.map(page => { + if (this.pageIds.includes(page.id)) { + const previousState = this.previousSplitStates.get(page.id); + return { ...page, splitBefore: previousState }; + } + return page; + }); + + this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); + } + + get description(): string { + return `Toggle split markers for ${this.pageIds.length} page(s)`; + } +} + +// Add pages command (for inserting new files) +export class AddPagesCommand extends PageCommand { + private newPages: PDFPage[]; + private insertIndex: number; + + constructor( + pdfDocument: PDFDocument, + setPdfDocument: (doc: PDFDocument) => void, + newPages: PDFPage[], + insertIndex: number = -1 // -1 means append to end + ) { + super(pdfDocument, setPdfDocument); + this.newPages = newPages; + this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex; + } + + execute(): void { + const newPagesArray = [...this.pdfDocument.pages]; + newPagesArray.splice(this.insertIndex, 0, ...this.newPages); + + // Update page numbers for all pages + const updatedPages = newPagesArray.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); + + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); + } + + undo(): void { + const updatedPages = this.pdfDocument.pages + .filter(page => !this.newPages.some(newPage => newPage.id === page.id)) + .map((page, index) => ({ ...page, pageNumber: index + 1 })); + + this.setPdfDocument({ + ...this.pdfDocument, + pages: updatedPages, + totalPages: updatedPages.length + }); + } + + get description(): string { + return `Add ${this.newPages.length} page(s)`; + } +} + +// Command sequence for bulk operations +export class PageCommandSequence implements CommandSequence { + commands: Command[]; + description: string; + + constructor(commands: Command[], description?: string) { + this.commands = commands; + this.description = description || `Execute ${commands.length} operations`; + } + + execute(): void { + this.commands.forEach(command => command.execute()); + } + + undo(): void { + // Undo in reverse order + [...this.commands].reverse().forEach(command => command.undo()); + } +} \ No newline at end of file diff --git a/frontend/src/components/PageEditor.tsx b/frontend/src/components/PageEditor.tsx index 3b8b56b13..81ea7417f 100644 --- a/frontend/src/components/PageEditor.tsx +++ b/frontend/src/components/PageEditor.tsx @@ -1,7 +1,10 @@ -import React, { useState } from "react"; +import React, { useState, useCallback, useRef, useEffect } from "react"; import { - Paper, Button, Group, Text, Stack, Center, Checkbox, ScrollArea, Box, Tooltip, ActionIcon, Notification + Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon, + Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container, + Stack, Group, Paper, SimpleGrid } from "@mantine/core"; +import { Dropzone } from "@mantine/dropzone"; import { useTranslation } from "react-i18next"; import UndoIcon from "@mui/icons-material/Undo"; import RedoIcon from "@mui/icons-material/Redo"; @@ -11,8 +14,24 @@ 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"; -import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; -import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; +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"; +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"; +import { + RotatePagesCommand, + DeletePagesCommand, + ReorderPageCommand, + ToggleSplitCommand +} from "../commands/pageCommands"; +import { pdfExportService } from "../services/pdfExportService"; export interface PageEditorProps { file: { file: File; url: string } | null; @@ -21,8 +40,6 @@ export interface PageEditorProps { setDownloadUrl?: (url: string | null) => void; } -const DUMMY_PAGE_COUNT = 8; // Replace with real page count from PDF - const PageEditor: React.FC = ({ file, setFile, @@ -30,169 +47,557 @@ const PageEditor: React.FC = ({ setDownloadUrl, }) => { const { t } = useTranslation(); - const [selectedPages, setSelectedPages] = useState([]); + const { processPDFFile, loading: pdfLoading } = usePDFProcessor(); + + const [pdfDocument, setPdfDocument] = useState(null); + const [selectedPages, setSelectedPages] = useState([]); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); + const [error, setError] = useState(null); + const [csvInput, setCsvInput] = useState(""); + const [showPageSelect, setShowPageSelect] = useState(false); + const [filename, setFilename] = useState(""); + const [draggedPage, setDraggedPage] = 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(); - // Dummy page thumbnails - const pages = Array.from({ length: DUMMY_PAGE_COUNT }, (_, i) => i + 1); + // Process uploaded file + const handleFileUpload = useCallback(async (uploadedFile: File) => { + if (!uploadedFile || uploadedFile.type !== 'application/pdf') { + setError('Please upload a valid PDF file'); + return; + } - const selectAll = () => setSelectedPages(pages); - const deselectAll = () => setSelectedPages([]); - const togglePage = (page: number) => - setSelectedPages((prev) => - prev.includes(page) ? prev.filter((p) => p !== page) : [...prev, page] + 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'; + 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]); + + const deselectAll = useCallback(() => setSelectedPages([]), []); + + const togglePage = useCallback((pageId: string) => { + setSelectedPages(prev => + prev.includes(pageId) + ? prev.filter(id => id !== pageId) + : [...prev, pageId] ); + }, []); - // Undo/redo logic for selection - const handleUndo = () => { - if (undoStack.length > 0) { - setRedoStack([selectedPages, ...redoStack]); - setSelectedPages(undoStack[0]); - setUndoStack(undoStack.slice(1)); + 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())); + 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); + } + } + }); + + return pageIds; + }, [pdfDocument]); + + const updatePagesFromCSV = useCallback(() => { + const pageIds = parseCSVInput(csvInput); + setSelectedPages(pageIds); + }, [csvInput, parseCSVInput]); + + const handleDragStart = useCallback((pageId: string) => { + setDraggedPage(pageId); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetPageId: string) => { + e.preventDefault(); + if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return; + + const targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId); + if (targetIndex === -1) return; + + const command = new ReorderPageCommand( + pdfDocument, + setPdfDocument, + draggedPage, + targetIndex + ); + + executeCommand(command); + setDraggedPage(null); + setStatus('Page reordered'); + }, [draggedPage, pdfDocument, executeCommand]); + + const handleRotate = useCallback((direction: 'left' | 'right') => { + if (!pdfDocument || selectedPages.length === 0) return; + + const rotation = direction === 'left' ? -90 : 90; + const command = new RotatePagesCommand( + pdfDocument, + setPdfDocument, + 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`); + }, [pdfDocument, selectedPages, executeCommand]); + + 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); + setShowExportModal(true); + }, [pdfDocument, selectedPages]); + + const handleExport = useCallback(async (selectedOnly: boolean = false) => { + if (!pdfDocument) return; + + 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); + + 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'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Export failed'; + setError(errorMessage); + } finally { + setExportLoading(false); } - }; - const handleRedo = () => { - if (redoStack.length > 0) { - setUndoStack([selectedPages, ...undoStack]); - setSelectedPages(redoStack[0]); - setRedoStack(redoStack.slice(1)); + }, [pdfDocument, selectedPages, filename]); + + const handleUndo = useCallback(() => { + if (undo()) { + setStatus('Operation undone'); } - }; + }, [undo]); - // Example action handlers (replace with real API calls) - const handleRotateLeft = () => setStatus(t("pageEditor.rotatedLeft", "Rotated left: ") + selectedPages.join(", ")); - const handleRotateRight = () => setStatus(t("pageEditor.rotatedRight", "Rotated right: ") + selectedPages.join(", ")); - const handleDelete = () => setStatus(t("pageEditor.deleted", "Deleted: ") + selectedPages.join(", ")); - const handleMoveLeft = () => setStatus(t("pageEditor.movedLeft", "Moved left: ") + selectedPages.join(", ")); - const handleMoveRight = () => setStatus(t("pageEditor.movedRight", "Moved right: ") + selectedPages.join(", ")); - const handleSplit = () => setStatus(t("pageEditor.splitAt", "Split at: ") + selectedPages.join(", ")); - const handleInsertPageBreak = () => setStatus(t("pageEditor.insertedPageBreak", "Inserted page break at: ") + selectedPages.join(", ")); - const handleAddFile = () => setStatus(t("pageEditor.addFileNotImplemented", "Add file not implemented in demo")); + const handleRedo = useCallback(() => { + if (redo()) { + setStatus('Operation redone'); + } + }, [redo]); - if (!file) { + if (!pdfDocument) { return ( - -
- {t("pageEditor.noPdfLoaded", "No PDF loaded. Please upload a PDF to edit.")} -
-
+ + + + + + + PDF Multitool + + + {error && ( + setError(null)}> + {error} + + )} + + files[0] && handleFileUpload(files[0])} + accept={["application/pdf"]} + multiple={false} + h={300} + > +
+ + + + Drop a PDF file here or click to upload + + + Supports PDF files only + + +
+
+
+
); } return ( - - - {/* Sidebar */} - - {t("pageEditor.title", "PDF Multitool")} - - - - - - - - - - - + + + - {/* Main multitool area */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {showPageSelect && ( + - {pages.map((page) => ( - - togglePage(page)} - label={t("page", "Page") + ` ${page}`} - /> - - {/* Replace with real thumbnail */} -
- - {page} - -
-
-
- ))} + setCsvInput(e.target.value)} + placeholder="1,3,5-10" + label="Page Selection" + onBlur={updatePagesFromCSV} + onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()} + style={{ flex: 1 }} + /> +
-
-
- + {selectedPages.length > 0 && ( + + Selected: {selectedPages.length} pages + + )} +
+ )} + + + + + + + + + + + + + + handleRotate('left')} disabled={selectedPages.length === 0}> + + + + + handleRotate('right')} disabled={selectedPages.length === 0}> + + + + + + + + + + + + + + + + + {pdfDocument.pages.map((page) => ( + handleDragStart(page.id)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, page.id)} + > + + {showPageSelect && ( + togglePage(page.id)} + size="sm" + /> + )} + + + {`Page + + + {page.pageNumber} + + + + + + + Page {page.pageNumber} + + + + ))} + + + + + + + + + + + + setShowExportModal(false)} + title="Export Preview" + > + {exportPreview && ( + + + 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. + + )} + + + + + + + )} + + + file && handleFileUpload(file)} + style={{ display: 'none' }} + /> + + {status && ( - setStatus(null)}> + setStatus(null)} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} + > {status} )} - + ); }; -export default PageEditor; +export default PageEditor; \ No newline at end of file diff --git a/frontend/src/hooks/usePDFProcessor.ts b/frontend/src/hooks/usePDFProcessor.ts new file mode 100644 index 000000000..7b1cc0c4b --- /dev/null +++ b/frontend/src/hooks/usePDFProcessor.ts @@ -0,0 +1,92 @@ +import { useState, useCallback } from 'react'; +import { getDocument } from 'pdfjs-dist'; +import { PDFDocument, PDFPage } from '../types/pageEditor'; + +export function usePDFProcessor() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const generatePageThumbnail = useCallback(async ( + file: File, + pageNumber: number, + scale: number = 0.5 + ): Promise => { + try { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await getDocument({ data: arrayBuffer }).promise; + const page = await pdf.getPage(pageNumber); + + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not get canvas context'); + } + + await page.render({ canvasContext: context, viewport }).promise; + const thumbnail = canvas.toDataURL(); + + // Clean up + pdf.destroy(); + + return thumbnail; + } catch (error) { + console.error('Failed to generate thumbnail:', error); + throw error; + } + }, []); + + const processPDFFile = useCallback(async (file: File): Promise => { + setLoading(true); + setError(null); + + try { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await getDocument({ data: arrayBuffer }).promise; + const totalPages = pdf.numPages; + + const pages: PDFPage[] = []; + + // Generate thumbnails for all pages + for (let i = 1; i <= totalPages; i++) { + const thumbnail = await generatePageThumbnail(file, i); + pages.push({ + id: `${file.name}-page-${i}`, + pageNumber: i, + thumbnail, + rotation: 0, + selected: false + }); + } + + // Clean up + pdf.destroy(); + + const document: PDFDocument = { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + name: file.name, + file, + pages, + totalPages + }; + + return document; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to process PDF'; + setError(errorMessage); + throw error; + } finally { + setLoading(false); + } + }, [generatePageThumbnail]); + + return { + processPDFFile, + generatePageThumbnail, + loading, + error + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useUndoRedo.ts b/frontend/src/hooks/useUndoRedo.ts new file mode 100644 index 000000000..6ce914a89 --- /dev/null +++ b/frontend/src/hooks/useUndoRedo.ts @@ -0,0 +1,68 @@ +import { useState, useCallback } from 'react'; + +export interface Command { + execute(): void; + undo(): void; + description: string; +} + +export interface CommandSequence { + commands: Command[]; + execute(): void; + undo(): void; + description: string; +} + +export function useUndoRedo() { + const [undoStack, setUndoStack] = useState<(Command | CommandSequence)[]>([]); + const [redoStack, setRedoStack] = useState<(Command | CommandSequence)[]>([]); + + const executeCommand = useCallback((command: Command | CommandSequence) => { + command.execute(); + setUndoStack(prev => [command, ...prev]); + setRedoStack([]); // Clear redo stack when new command is executed + }, []); + + const undo = useCallback(() => { + if (undoStack.length === 0) return false; + + const command = undoStack[0]; + command.undo(); + + setUndoStack(prev => prev.slice(1)); + setRedoStack(prev => [command, ...prev]); + + return true; + }, [undoStack]); + + const redo = useCallback(() => { + if (redoStack.length === 0) return false; + + const command = redoStack[0]; + command.execute(); + + setRedoStack(prev => prev.slice(1)); + setUndoStack(prev => [command, ...prev]); + + return true; + }, [redoStack]); + + const clear = useCallback(() => { + setUndoStack([]); + setRedoStack([]); + }, []); + + const canUndo = undoStack.length > 0; + const canRedo = redoStack.length > 0; + + return { + executeCommand, + undo, + redo, + clear, + canUndo, + canRedo, + undoStack, + redoStack + }; +} \ No newline at end of file diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts new file mode 100644 index 000000000..e26037c66 --- /dev/null +++ b/frontend/src/services/pdfExportService.ts @@ -0,0 +1,263 @@ +import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib'; +import { PDFDocument, PDFPage } from '../types/pageEditor'; + +export interface ExportOptions { + selectedOnly?: boolean; + filename?: string; + splitDocuments?: boolean; +} + +export class PDFExportService { + /** + * Export PDF document with applied operations + */ + async exportPDF( + pdfDocument: PDFDocument, + selectedPageIds: string[] = [], + options: ExportOptions = {} + ): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> { + const { selectedOnly = false, filename, splitDocuments = false } = options; + + try { + // Determine which pages to export + const pagesToExport = selectedOnly && selectedPageIds.length > 0 + ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) + : pdfDocument.pages; + + if (pagesToExport.length === 0) { + throw new Error('No pages to export'); + } + + // Load original PDF once + const originalPDFBytes = await pdfDocument.file.arrayBuffer(); + const sourceDoc = await PDFLibDocument.load(originalPDFBytes); + + if (splitDocuments) { + return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name); + } else { + const blob = await this.createSingleDocument(sourceDoc, pagesToExport); + const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly); + return { blob, filename: exportFilename }; + } + } catch (error) { + console.error('PDF export error:', error); + throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Create a single PDF document with all operations applied + */ + private async createSingleDocument( + sourceDoc: PDFLibDocument, + pages: PDFPage[] + ): Promise { + const newDoc = await PDFLibDocument.create(); + + for (const page of pages) { + // Get the original page from source document + const sourcePageIndex = page.pageNumber - 1; + + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { + // Copy the page + const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); + + // Apply rotation + if (page.rotation !== 0) { + copiedPage.setRotation(degrees(page.rotation)); + } + + newDoc.addPage(copiedPage); + } + } + + // Set metadata + newDoc.setCreator('Stirling PDF'); + newDoc.setProducer('Stirling PDF'); + newDoc.setCreationDate(new Date()); + newDoc.setModificationDate(new Date()); + + const pdfBytes = await newDoc.save(); + return new Blob([pdfBytes], { type: 'application/pdf' }); + } + + /** + * Create multiple PDF documents based on split markers + */ + private async createSplitDocuments( + sourceDoc: PDFLibDocument, + pages: PDFPage[], + baseFilename: string + ): Promise<{ blobs: Blob[]; filenames: string[] }> { + const splitPoints: number[] = []; + const blobs: Blob[] = []; + const filenames: string[] = []; + + // Find split points + pages.forEach((page, index) => { + if (page.splitBefore && index > 0) { + splitPoints.push(index); + } + }); + + // Add end point + splitPoints.push(pages.length); + + let startIndex = 0; + let partNumber = 1; + + for (const endIndex of splitPoints) { + const segmentPages = pages.slice(startIndex, endIndex); + + if (segmentPages.length > 0) { + const newDoc = await PDFLibDocument.create(); + + for (const page of segmentPages) { + const sourcePageIndex = page.pageNumber - 1; + + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { + const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); + + if (page.rotation !== 0) { + copiedPage.setRotation(degrees(page.rotation)); + } + + newDoc.addPage(copiedPage); + } + } + + // Set metadata + newDoc.setCreator('Stirling PDF'); + newDoc.setProducer('Stirling PDF'); + newDoc.setTitle(`${baseFilename} - Part ${partNumber}`); + + const pdfBytes = await newDoc.save(); + const blob = new Blob([pdfBytes], { type: 'application/pdf' }); + const filename = this.generateSplitFilename(baseFilename, partNumber); + + blobs.push(blob); + filenames.push(filename); + partNumber++; + } + + startIndex = endIndex; + } + + return { blobs, filenames }; + } + + /** + * Generate appropriate filename for export + */ + private generateFilename(originalName: string, selectedOnly: boolean): string { + const baseName = originalName.replace(/\.pdf$/i, ''); + const suffix = selectedOnly ? '_selected' : '_edited'; + return `${baseName}${suffix}.pdf`; + } + + /** + * Generate filename for split documents + */ + private generateSplitFilename(baseName: string, partNumber: number): string { + const cleanBaseName = baseName.replace(/\.pdf$/i, ''); + return `${cleanBaseName}_part_${partNumber}.pdf`; + } + + /** + * Download a single file + */ + downloadFile(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up the URL after a short delay + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + /** + * Download multiple files as a ZIP + */ + async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise { + // For now, download files individually + // TODO: Implement ZIP creation when needed + blobs.forEach((blob, index) => { + setTimeout(() => { + this.downloadFile(blob, filenames[index]); + }, index * 500); // Stagger downloads + }); + } + + /** + * Validate PDF operations before export + */ + validateExport(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): string[] { + const errors: string[] = []; + + if (selectedOnly && selectedPageIds.length === 0) { + errors.push('No pages selected for export'); + } + + if (pdfDocument.pages.length === 0) { + errors.push('No pages available to export'); + } + + const pagesToExport = selectedOnly + ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) + : pdfDocument.pages; + + if (pagesToExport.length === 0) { + errors.push('No valid pages to export after applying filters'); + } + + return errors; + } + + /** + * Get export preview information + */ + getExportInfo(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): { + pageCount: number; + splitCount: number; + estimatedSize: string; + } { + const pagesToExport = selectedOnly + ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) + : pdfDocument.pages; + + const splitCount = pagesToExport.reduce((count, page, index) => { + return count + (page.splitBefore && index > 0 ? 1 : 0); + }, 1); // At least 1 document + + // Rough size estimation (very approximate) + const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages; + const estimatedBytes = avgPageSize * pagesToExport.length; + const estimatedSize = this.formatFileSize(estimatedBytes); + + return { + pageCount: pagesToExport.length, + splitCount, + estimatedSize + }; + } + + /** + * Format file size for display + */ + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + +// Export singleton instance +export const pdfExportService = new PDFExportService(); \ No newline at end of file diff --git a/frontend/src/types/pageEditor.ts b/frontend/src/types/pageEditor.ts new file mode 100644 index 000000000..c4fc19bdd --- /dev/null +++ b/frontend/src/types/pageEditor.ts @@ -0,0 +1,27 @@ +export interface PDFPage { + id: string; + pageNumber: number; + thumbnail: string; + rotation: number; + selected: boolean; + splitBefore?: boolean; +} + +export interface PDFDocument { + id: string; + name: string; + file: File; + pages: PDFPage[]; + totalPages: number; +} + +export interface PageOperation { + type: 'rotate' | 'delete' | 'move' | 'split' | 'insert'; + pageIds: string[]; + data?: any; +} + +export interface UndoRedoState { + operations: PageOperation[]; + currentIndex: number; +} \ No newline at end of file