Page editor continued improvements

This commit is contained in:
Reece Browne 2025-08-22 15:39:30 +01:00
parent 383d227dea
commit abed82cc6b
6 changed files with 314 additions and 155 deletions

View File

@ -11,6 +11,7 @@ import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
import { enhancedPDFProcessingService } from "../../services/enhancedPDFProcessingService";
import { fileProcessingService } from "../../services/fileProcessingService";
import { pdfProcessingService } from "../../services/pdfProcessingService";
@ -42,7 +43,7 @@ class RotatePageCommand extends DOMCommand {
}
execute(): void {
// Find the page thumbnail and rotate it directly in the DOM
// Only update DOM for immediate visual feedback
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
@ -55,6 +56,7 @@ class RotatePageCommand extends DOMCommand {
}
undo(): void {
// Only update DOM
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
@ -128,6 +130,7 @@ export interface PageEditorProps {
showExportPreview: (selectedOnly: boolean) => void;
onExportSelected: () => void;
onExportAll: () => void;
applyChanges: () => void;
exportLoading: boolean;
selectionMode: boolean;
selectedPages: number[];
@ -267,8 +270,8 @@ const PageEditor = ({
thumbnail: page.thumbnail || null,
rotation: page.rotation || 0,
selected: false,
splitBefore: page.splitBefore || false,
originalPageNumber: page.pageNumber,
splitAfter: page.splitAfter || false,
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
originalFileId: fileId,
}));
} else if (processedFile?.totalPages) {
@ -282,7 +285,7 @@ const PageEditor = ({
rotation: 0,
thumbnail: null, // Will be generated later
selected: false,
splitBefore: false,
splitAfter: false,
}));
}
@ -442,9 +445,30 @@ const PageEditor = ({
}
class ToggleSplitCommand {
constructor(public pageId: string) {}
constructor(public pageIds: string[]) {}
execute() {
console.log('Toggle split:', this.pageId);
if (!displayDocument) return;
console.log('Toggle split:', this.pageIds);
// Create new pages array with toggled split markers
const newPages = displayDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return {
...page,
splitAfter: !page.splitAfter
};
}
return page;
});
// Update the document with new split markers
const updatedDocument: PDFDocument = {
...displayDocument,
pages: newPages,
};
setEditedDocument(updatedDocument);
}
}
@ -484,8 +508,21 @@ const PageEditor = ({
}, [selectedPageNumbers]);
const handleSplit = useCallback(() => {
console.log('Split at selected pages:', selectedPageNumbers);
}, [selectedPageNumbers]);
if (!displayDocument || selectedPageNumbers.length === 0) return;
console.log('Toggle split markers at selected pages:', selectedPageNumbers);
// Get page IDs for selected pages
const selectedPageIds = selectedPageNumbers.map(pageNum => {
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id);
if (selectedPageIds.length > 0) {
const command = new ToggleSplitCommand(selectedPageIds);
command.execute();
}
}, [selectedPageNumbers, displayDocument]);
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => {
if (!displayDocument) return;
@ -535,6 +572,9 @@ const PageEditor = ({
totalPages: newPages.length,
};
console.log('Reordered document page numbers:', newPages.map(p => p.pageNumber));
console.log('Reordered document page IDs:', newPages.map(p => p.id));
// Update the edited document state
setEditedDocument(reorderedDocument);
@ -542,42 +582,23 @@ const PageEditor = ({
}, [displayDocument]);
// Helper function to read DOM state and update document with current rotations
const updateDocumentWithDOMState = useCallback((pdfDocument: PDFDocument): PDFDocument => {
const updatedPages = pdfDocument.pages.map(page => {
// Find the DOM element for this page
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img && img.style.rotate) {
// Parse rotation from DOM (e.g., "90deg" -> 90)
const rotationMatch = img.style.rotate.match(/-?\d+/);
const domRotation = rotationMatch ? parseInt(rotationMatch[0]) : 0;
return {
...page,
rotation: domRotation // Update page rotation from DOM state
};
}
}
return page;
});
return {
...pdfDocument,
pages: updatedPages
};
}, []);
const onExportSelected = useCallback(async () => {
if (!displayDocument || selectedPageNumbers.length === 0) return;
setExportLoading(true);
try {
// Step 1: Update document with current DOM state (rotations)
const documentWithDOMState = updateDocumentWithDOMState(displayDocument);
// Step 1: Apply DOM changes to document state first
console.log('Applying DOM changes before export...');
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument // Current display order (includes reordering)
);
// Step 2: Get page IDs for selected pages
// For selected pages export, we work with the first document (or single document)
const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
// Step 2: Convert selected page numbers to page IDs from the document with DOM state
const selectedPageIds = selectedPageNumbers.map(pageNum => {
const page = documentWithDOMState.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
@ -592,35 +613,61 @@ const PageEditor = ({
);
// Step 4: Download the result
if ('blob' in result) {
pdfExportService.downloadFile(result.blob, result.filename);
}
pdfExportService.downloadFile(result.blob, result.filename);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, selectedPageNumbers, updateDocumentWithDOMState]);
}, [displayDocument, selectedPageNumbers, mergedPdfDocument]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Update document with current DOM state (rotations)
const documentWithDOMState = updateDocumentWithDOMState(displayDocument);
// Step 2: Export all pages with pdfExportService
console.log('Exporting all pages with DOM rotations applied');
const result = await pdfExportService.exportPDF(
documentWithDOMState,
[],
{ selectedOnly: false, filename: documentWithDOMState.name }
// Step 1: Apply DOM changes to document state first
console.log('Applying DOM changes before export...');
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument // Current display order (includes reordering)
);
// Step 2: Check if we have multiple documents (splits) or single document
if (Array.isArray(processedDocuments)) {
// Multiple documents (splits) - export as ZIP
console.log('Exporting multiple split documents:', processedDocuments.length);
const blobs: Blob[] = [];
const filenames: string[] = [];
for (const doc of processedDocuments) {
const result = await pdfExportService.exportPDF(doc, [], { filename: doc.name });
blobs.push(result.blob);
filenames.push(result.filename);
}
// Create ZIP file
const JSZip = await import('jszip');
const zip = new JSZip.default();
blobs.forEach((blob, index) => {
zip.file(filenames[index], blob);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFilename = displayDocument.name.replace(/\.pdf$/i, '_split.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
// Single document - regular export
console.log('Exporting as single PDF');
const result = await pdfExportService.exportPDF(
processedDocuments,
[],
{ selectedOnly: false, filename: processedDocuments.name }
);
// Step 3: Download the result
if ('blob' in result) {
pdfExportService.downloadFile(result.blob, result.filename);
}
@ -629,7 +676,25 @@ const PageEditor = ({
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, updateDocumentWithDOMState]);
}, [displayDocument, mergedPdfDocument]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(() => {
if (!displayDocument) return;
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument // Current display order (includes reordering)
);
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
setEditedDocument(documentToSet);
console.log('Changes applied to document');
}, [displayDocument, mergedPdfDocument]);
const closePdf = useCallback(() => {
actions.clearAllFiles();
@ -665,6 +730,7 @@ const PageEditor = ({
showExportPreview: handleExportPreview,
onExportSelected,
onExportAll,
applyChanges,
exportLoading,
selectionMode,
selectedPages: selectedPageNumbers,
@ -673,7 +739,7 @@ const PageEditor = ({
}
}, [
onFunctionsReady, handleUndo, handleRedo, handleRotate, handleDelete, handleSplit,
handleExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, selectedPageNumbers, closePdf
handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading, selectionMode, selectedPageNumbers, closePdf
]);
// Display all pages - use edited or original document

View File

@ -194,9 +194,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const handleSplit = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
console.log('Split at page:', page.pageNumber);
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
}, [page.pageNumber, onSetStatus]);
// Create a command to toggle split marker
const command = new ToggleSplitCommand([page.id]);
onExecuteCommand(command);
const action = page.splitAfter ? 'removed' : 'added';
onSetStatus(`Split marker ${action} after page ${page.pageNumber}`);
}, [page.pageNumber, page.id, page.splitAfter, onExecuteCommand, onSetStatus, ToggleSplitCommand]);
return (
<div
@ -411,8 +416,8 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
</ActionIcon>
</Tooltip>
{index > 0 && (
<Tooltip label="Split Here">
{index < totalPages - 1 && (
<Tooltip label="Split After">
<ActionIcon
size="md"
variant="subtle"
@ -427,16 +432,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
</div>
{/* Split indicator */}
{page.splitBefore && (
{/* Split indicator - shows where document will be split */}
{page.splitAfter && (
<div
style={{
position: 'absolute',
top: '-1px',
left: '50%',
transform: 'translateX(-50%)',
width: '100px',
height: '2px',
right: '-8px',
top: '50%',
transform: 'translateY(-50%)',
width: '2px',
height: '60px',
backgroundColor: '#3b82f6',
zIndex: 5,
}}

View File

@ -77,6 +77,7 @@ export function usePDFProcessor() {
pages.push({
id: `${file.name}-page-${i}`,
pageNumber: i,
originalPageNumber: i,
thumbnail: null, // Will be loaded lazily
rotation: 0,
selected: false

View File

@ -0,0 +1,162 @@
import { PDFDocument, PDFPage } from '../types/pageEditor';
/**
* Service for applying DOM changes to PDF document state
* Reads current DOM state and updates the document accordingly
*/
export class DocumentManipulationService {
/**
* Apply all DOM changes (rotations, splits, reordering) to document state
* Returns single document or multiple documents if splits are present
*/
applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument): PDFDocument | PDFDocument[] {
console.log('DocumentManipulationService: Applying DOM changes to document');
console.log('Original document page order:', pdfDocument.pages.map(p => p.pageNumber));
console.log('Current display order:', currentDisplayOrder?.pages.map(p => p.pageNumber) || 'none provided');
// Use current display order (from React state) if provided, otherwise use original order
const baseDocument = currentDisplayOrder || pdfDocument;
console.log('Using page order:', baseDocument.pages.map(p => p.pageNumber));
// Apply DOM changes to each page (rotation, split markers)
const updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page));
// Create final document with reordered pages and applied changes
const finalDocument = {
...pdfDocument, // Use original document metadata but updated pages
pages: updatedPages // Use reordered pages with applied changes
};
// Check for splits and return multiple documents if needed
if (this.hasSplitMarkers(finalDocument)) {
return this.createSplitDocuments(finalDocument);
}
return finalDocument;
}
/**
* Check if document has split markers
*/
private hasSplitMarkers(document: PDFDocument): boolean {
return document.pages.some(page => page.splitAfter);
}
/**
* Create multiple documents from split markers
*/
private createSplitDocuments(document: PDFDocument): PDFDocument[] {
const documents: PDFDocument[] = [];
const splitPoints: number[] = [];
// Find split points - pages with splitAfter create split points AFTER them
document.pages.forEach((page, index) => {
if (page.splitAfter) {
splitPoints.push(index + 1);
}
});
// Add end point if not already there
if (splitPoints.length === 0 || splitPoints[splitPoints.length - 1] !== document.pages.length) {
splitPoints.push(document.pages.length);
}
let startIndex = 0;
let partNumber = 1;
for (const endIndex of splitPoints) {
const segmentPages = document.pages.slice(startIndex, endIndex);
if (segmentPages.length > 0) {
documents.push({
...document,
id: `${document.id}_part_${partNumber}`,
name: `${document.name.replace(/\.pdf$/i, '')}_part_${partNumber}.pdf`,
pages: segmentPages,
totalPages: segmentPages.length
});
partNumber++;
}
startIndex = endIndex;
}
console.log(`Created ${documents.length} split documents`);
return documents;
}
/**
* Apply DOM changes for a single page
*/
private applyPageChanges(page: PDFPage): PDFPage {
// Find the DOM element for this page
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (!pageElement) {
console.log(`Page ${page.pageNumber}: No DOM element found, keeping original state`);
return page;
}
const updatedPage = { ...page };
// Apply rotation changes from DOM
updatedPage.rotation = this.getRotationFromDOM(pageElement, page);
// Apply split marker changes from document state (already handled by commands)
// Split markers are already updated by ToggleSplitCommand, so no DOM reading needed
return updatedPage;
}
/**
* Read rotation from DOM element
*/
private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number {
const img = pageElement.querySelector('img');
if (img && img.style.rotate) {
// Parse rotation from DOM (e.g., "90deg" -> 90)
const rotationMatch = img.style.rotate.match(/-?\d+/);
const domRotation = rotationMatch ? parseInt(rotationMatch[0]) : 0;
console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`);
return domRotation;
}
console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`);
return originalPage.rotation;
}
/**
* Reset all DOM changes (useful for "discard changes" functionality)
*/
resetDOMToDocumentState(pdfDocument: PDFDocument): void {
console.log('DocumentManipulationService: Resetting DOM to match document state');
pdfDocument.pages.forEach(page => {
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Reset rotation to match document state
img.style.rotate = `${page.rotation}deg`;
}
}
});
}
/**
* Check if DOM state differs from document state
*/
hasUnsavedChanges(pdfDocument: PDFDocument): boolean {
return pdfDocument.pages.some(page => {
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (pageElement) {
const domRotation = this.getRotationFromDOM(pageElement, page);
return domRotation !== page.rotation;
}
return false;
});
}
}
// Export singleton instance
export const documentManipulationService = new DocumentManipulationService();

View File

@ -4,7 +4,6 @@ import { PDFDocument, PDFPage } from '../types/pageEditor';
export interface ExportOptions {
selectedOnly?: boolean;
filename?: string;
splitDocuments?: boolean;
}
export class PDFExportService {
@ -15,8 +14,8 @@ export class PDFExportService {
pdfDocument: PDFDocument,
selectedPageIds: string[] = [],
options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
const { selectedOnly = false, filename, splitDocuments = false } = options;
): Promise<{ blob: Blob; filename: string }> {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
@ -28,17 +27,13 @@ export class PDFExportService {
throw new Error('No pages to export');
}
// Load original PDF once
// Load original PDF and create new document
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 };
}
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'}`);
@ -55,8 +50,8 @@ export class PDFExportService {
const newDoc = await PDFLibDocument.create();
for (const page of pages) {
// Get the original page from source document
const sourcePageIndex = page.pageNumber - 1;
// Get the original page from source document using originalPageNumber
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page
@ -81,70 +76,6 @@ export class PDFExportService {
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
@ -155,13 +86,6 @@ export class PDFExportService {
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
@ -185,7 +109,7 @@ export class PDFExportService {
* Download multiple files as a ZIP
*/
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
// For now, download files wherindividually
// For now, download files individually
blobs.forEach((blob, index) => {
setTimeout(() => {
this.downloadFile(blob, filenames[index]);
@ -230,8 +154,8 @@ export class PDFExportService {
? 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);
const splitCount = pagesToExport.reduce((count, page) => {
return count + (page.splitAfter ? 1 : 0);
}, 1); // At least 1 document
// Rough size estimation (very approximate)

View File

@ -1,10 +1,11 @@
export interface PDFPage {
id: string;
pageNumber: number;
originalPageNumber: number;
thumbnail: string | null;
rotation: number;
selected: boolean;
splitBefore?: boolean;
splitAfter?: boolean;
}
export interface PDFDocument {