import { FileId } from '../../../types/file'; import { PDFDocument, PDFPage } from '../../../types/pageEditor'; // V1-style DOM-first command system (replaces the old React state commands) export abstract class DOMCommand { abstract execute(): void; abstract undo(): void; abstract description: string; } export class RotatePageCommand extends DOMCommand { constructor( private pageId: string, private degrees: number ) { super(); } execute(): void { // Only update DOM for immediate visual feedback const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); if (pageElement) { const img = pageElement.querySelector('img'); if (img) { // Extract current rotation from transform property to match the animated CSS const currentTransform = img.style.transform || ''; const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; const newRotation = currentRotation + this.degrees; img.style.transform = `rotate(${newRotation}deg)`; } } } undo(): void { // Only update DOM const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); if (pageElement) { const img = pageElement.querySelector('img'); if (img) { // Extract current rotation from transform property const currentTransform = img.style.transform || ''; const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; const previousRotation = currentRotation - this.degrees; img.style.transform = `rotate(${previousRotation}deg)`; } } } get description(): string { return `Rotate page ${this.degrees > 0 ? 'right' : 'left'}`; } } export class DeletePagesCommand extends DOMCommand { private originalDocument: PDFDocument | null = null; private originalSplitPositions: Set = new Set(); private originalSelectedPages: number[] = []; private hasExecuted: boolean = false; private pageIdsToDelete: string[] = []; constructor( private pagesToDelete: number[], private getCurrentDocument: () => PDFDocument | null, private setDocument: (doc: PDFDocument) => void, private setSelectedPages: (pages: number[]) => void, private getSplitPositions: () => Set, private setSplitPositions: (positions: Set) => void, private getSelectedPages: () => number[] ) { super(); } execute(): void { const currentDoc = this.getCurrentDocument(); if (!currentDoc || this.pagesToDelete.length === 0) return; // Store complete original state for undo (only on first execution) if (!this.hasExecuted) { this.originalDocument = { ...currentDoc, pages: currentDoc.pages.map(page => ({...page})) // Deep copy pages }; this.originalSplitPositions = new Set(this.getSplitPositions()); this.originalSelectedPages = [...this.getSelectedPages()]; // Convert page numbers to page IDs for stable identification this.pageIdsToDelete = this.pagesToDelete.map(pageNum => { const page = currentDoc.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id); this.hasExecuted = true; } // Filter out deleted pages by ID (stable across undo/redo) const remainingPages = currentDoc.pages.filter(page => !this.pageIdsToDelete.includes(page.id) ); if (remainingPages.length === 0) return; // Safety check // Renumber remaining pages remainingPages.forEach((page, index) => { page.pageNumber = index + 1; }); // Update document const updatedDocument: PDFDocument = { ...currentDoc, pages: remainingPages, totalPages: remainingPages.length, }; // Adjust split positions const currentSplitPositions = this.getSplitPositions(); const newPositions = new Set(); currentSplitPositions.forEach(pos => { if (pos < remainingPages.length - 1) { newPositions.add(pos); } }); // Apply changes this.setDocument(updatedDocument); this.setSelectedPages([]); this.setSplitPositions(newPositions); } undo(): void { if (!this.originalDocument) return; // Simply restore the complete original document state this.setDocument(this.originalDocument); this.setSplitPositions(this.originalSplitPositions); this.setSelectedPages(this.originalSelectedPages); } get description(): string { return `Delete ${this.pagesToDelete.length} page(s)`; } } export class ReorderPagesCommand extends DOMCommand { private originalPages: PDFPage[] = []; constructor( private sourcePageNumber: number, private targetIndex: number, private selectedPages: number[] | undefined, private getCurrentDocument: () => PDFDocument | null, private setDocument: (doc: PDFDocument) => void ) { super(); } execute(): void { const currentDoc = this.getCurrentDocument(); if (!currentDoc) return; // Store original state for undo this.originalPages = currentDoc.pages.map(page => ({...page})); // Perform the reorder const sourceIndex = currentDoc.pages.findIndex(p => p.pageNumber === this.sourcePageNumber); if (sourceIndex === -1) return; const newPages = [...currentDoc.pages]; if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) { // Multi-page reorder const selectedPageObjects = this.selectedPages .map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum)) .filter(page => page !== undefined) as PDFPage[]; const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber)); remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects); remainingPages.forEach((page, index) => { page.pageNumber = index + 1; }); newPages.splice(0, newPages.length, ...remainingPages); } else { // Single page reorder const [movedPage] = newPages.splice(sourceIndex, 1); newPages.splice(this.targetIndex, 0, movedPage); newPages.forEach((page, index) => { page.pageNumber = index + 1; }); } const reorderedDocument: PDFDocument = { ...currentDoc, pages: newPages, totalPages: newPages.length, }; this.setDocument(reorderedDocument); } undo(): void { const currentDoc = this.getCurrentDocument(); if (!currentDoc || this.originalPages.length === 0) return; // Restore original page order const restoredDocument: PDFDocument = { ...currentDoc, pages: this.originalPages, totalPages: this.originalPages.length, }; this.setDocument(restoredDocument); } get description(): string { return `Reorder page(s)`; } } export class SplitCommand extends DOMCommand { private originalSplitPositions: Set = new Set(); constructor( private position: number, private getSplitPositions: () => Set, private setSplitPositions: (positions: Set) => void ) { super(); } execute(): void { // Store original state for undo this.originalSplitPositions = new Set(this.getSplitPositions()); // Toggle the split position const currentPositions = this.getSplitPositions(); const newPositions = new Set(currentPositions); if (newPositions.has(this.position)) { newPositions.delete(this.position); } else { newPositions.add(this.position); } this.setSplitPositions(newPositions); } undo(): void { // Restore original split positions this.setSplitPositions(this.originalSplitPositions); } get description(): string { const currentPositions = this.getSplitPositions(); const willAdd = !currentPositions.has(this.position); return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`; } } export class BulkRotateCommand extends DOMCommand { private originalRotations: Map = new Map(); constructor( private pageIds: string[], private degrees: number ) { super(); } execute(): void { this.pageIds.forEach(pageId => { const pageElement = document.querySelector(`[data-page-id="${pageId}"]`); if (pageElement) { const img = pageElement.querySelector('img'); if (img) { // Store original rotation for undo (only on first execution) if (!this.originalRotations.has(pageId)) { const currentTransform = img.style.transform || ''; const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; this.originalRotations.set(pageId, currentRotation); } // Apply rotation using transform to trigger CSS animation const currentTransform = img.style.transform || ''; const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; const newRotation = currentRotation + this.degrees; img.style.transform = `rotate(${newRotation}deg)`; } } }); } undo(): void { this.pageIds.forEach(pageId => { const pageElement = document.querySelector(`[data-page-id="${pageId}"]`); if (pageElement) { const img = pageElement.querySelector('img'); if (img && this.originalRotations.has(pageId)) { img.style.transform = `rotate(${this.originalRotations.get(pageId)}deg)`; } } }); } get description(): string { return `Rotate ${this.pageIds.length} page(s) ${this.degrees > 0 ? 'right' : 'left'}`; } } export class BulkSplitCommand extends DOMCommand { private originalSplitPositions: Set = new Set(); constructor( private positions: number[], private getSplitPositions: () => Set, private setSplitPositions: (positions: Set) => void ) { super(); } execute(): void { // Store original state for undo (only on first execution) if (this.originalSplitPositions.size === 0) { this.originalSplitPositions = new Set(this.getSplitPositions()); } // Toggle each position const currentPositions = new Set(this.getSplitPositions()); this.positions.forEach(position => { if (currentPositions.has(position)) { currentPositions.delete(position); } else { currentPositions.add(position); } }); this.setSplitPositions(currentPositions); } undo(): void { // Restore original split positions this.setSplitPositions(this.originalSplitPositions); } get description(): string { return `Toggle ${this.positions.length} split position(s)`; } } export class SplitAllCommand extends DOMCommand { private originalSplitPositions: Set = new Set(); private allPossibleSplits: Set = new Set(); constructor( private totalPages: number, private getSplitPositions: () => Set, private setSplitPositions: (positions: Set) => void ) { super(); // Calculate all possible split positions (between pages, not after last page) for (let i = 0; i < this.totalPages - 1; i++) { this.allPossibleSplits.add(i); } } execute(): void { // Store original state for undo this.originalSplitPositions = new Set(this.getSplitPositions()); // Check if all splits are already active const currentSplits = this.getSplitPositions(); const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); if (hasAllSplits) { // Remove all splits this.setSplitPositions(new Set()); } else { // Add all splits this.setSplitPositions(this.allPossibleSplits); } } undo(): void { // Restore original split positions this.setSplitPositions(this.originalSplitPositions); } get description(): string { const currentSplits = this.getSplitPositions(); const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); return hasAllSplits ? 'Remove all splits' : 'Split all pages'; } } export class PageBreakCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; constructor( private selectedPageNumbers: number[], private getCurrentDocument: () => PDFDocument | null, private setDocument: (doc: PDFDocument) => void ) { super(); } execute(): void { const currentDoc = this.getCurrentDocument(); if (!currentDoc || this.selectedPageNumbers.length === 0) return; // Store original state for undo this.originalDocument = { ...currentDoc, pages: currentDoc.pages.map(page => ({...page})) }; // Create new pages array with blank pages inserted const newPages: PDFPage[] = []; this.insertedPages = []; let pageNumberCounter = 1; currentDoc.pages.forEach((page, index) => { // Add the current page const updatedPage = { ...page, pageNumber: pageNumberCounter++ }; newPages.push(updatedPage); // If this page is selected for page break insertion, add a blank page after it if (this.selectedPageNumbers.includes(page.pageNumber)) { const blankPage: PDFPage = { id: `blank-${Date.now()}-${index}`, pageNumber: pageNumberCounter++, originalPageNumber: -1, // Mark as blank page thumbnail: null, rotation: 0, selected: false, splitAfter: false, isBlankPage: true // Custom flag for blank pages }; newPages.push(blankPage); this.insertedPages.push(blankPage); } }); // Update document const updatedDocument: PDFDocument = { ...currentDoc, pages: newPages, totalPages: newPages.length, }; this.setDocument(updatedDocument); // No need to maintain selection - page IDs remain stable, so selection persists automatically } undo(): void { if (!this.originalDocument) return; this.setDocument(this.originalDocument); } get description(): string { return `Insert ${this.selectedPageNumbers.length} page break(s)`; } } export class BulkPageBreakCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; private originalSelectedPages: number[] = []; constructor( private getCurrentDocument: () => PDFDocument | null, private setDocument: (doc: PDFDocument) => void, private setSelectedPages: (pages: number[]) => void, private getSelectedPages: () => number[] ) { super(); } execute(): void { const currentDoc = this.getCurrentDocument(); if (!currentDoc) return; // Store original selection to restore later this.originalSelectedPages = this.getSelectedPages(); // Store original state for undo this.originalDocument = { ...currentDoc, pages: currentDoc.pages.map(page => ({...page})) }; // Create new pages array with blank pages inserted after each page (except the last) const newPages: PDFPage[] = []; this.insertedPages = []; let pageNumberCounter = 1; currentDoc.pages.forEach((page, index) => { // Add the current page const updatedPage = { ...page, pageNumber: pageNumberCounter++ }; newPages.push(updatedPage); // Add blank page after each page except the last one if (index < currentDoc.pages.length - 1) { const blankPage: PDFPage = { id: `blank-${Date.now()}-${index}`, pageNumber: pageNumberCounter++, originalPageNumber: -1, thumbnail: null, rotation: 0, selected: false, splitAfter: false, isBlankPage: true }; newPages.push(blankPage); this.insertedPages.push(blankPage); } }); // Update document const updatedDocument: PDFDocument = { ...currentDoc, pages: newPages, totalPages: newPages.length, }; this.setDocument(updatedDocument); // Maintain existing selection by mapping original selected pages to their new positions const updatedSelection: number[] = []; this.originalSelectedPages.forEach(originalPageNum => { // Find the original page by matching the page ID from the original document const originalPage = this.originalDocument?.pages[originalPageNum - 1]; if (originalPage) { const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage); if (foundPage) { updatedSelection.push(foundPage.pageNumber); } } }); this.setSelectedPages(updatedSelection); } undo(): void { if (!this.originalDocument) return; this.setDocument(this.originalDocument); } get description(): string { return `Insert page breaks after all pages`; } } export class InsertFilesCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; private fileDataMap = new Map(); // Store file data for thumbnail generation private originalProcessedFile: any = null; // Store original ProcessedFile for undo private insertedFileMap = new Map(); // Store inserted files for export constructor( private files: File[], private insertAfterPageNumber: number, private getCurrentDocument: () => PDFDocument | null, private setDocument: (doc: PDFDocument) => void, private setSelectedPages: (pages: number[]) => void, private getSelectedPages: () => number[], private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map) => void ) { super(); } async execute(): Promise { const currentDoc = this.getCurrentDocument(); if (!currentDoc || this.files.length === 0) return; // Store original state for undo this.originalDocument = { ...currentDoc, pages: currentDoc.pages.map(page => ({...page})) }; try { // Process each file to extract pages and wait for all to complete const allNewPages: PDFPage[] = []; // Process all files and wait for their completion const baseTimestamp = Date.now(); const extractionPromises = this.files.map(async (file, index) => { const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId; // Store inserted file for export this.insertedFileMap.set(fileId, file); // Use base timestamp + index to ensure unique but predictable file IDs return await this.extractPagesFromFile(file, baseTimestamp + index); }); const extractedPageArrays = await Promise.all(extractionPromises); // Flatten all extracted pages for (const pages of extractedPageArrays) { allNewPages.push(...pages); } if (allNewPages.length === 0) return; // Find insertion point (after the specified page) const insertIndex = this.insertAfterPageNumber; // Insert after page N means insert at index N // Create new pages array with inserted pages const newPages: PDFPage[] = []; let pageNumberCounter = 1; // Add pages before insertion point for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) { const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ }; newPages.push(page); } // Add inserted pages for (const newPage of allNewPages) { const insertedPage: PDFPage = { ...newPage, pageNumber: pageNumberCounter++, selected: false, splitAfter: false }; newPages.push(insertedPage); this.insertedPages.push(insertedPage); } // Add remaining pages after insertion point for (let i = insertIndex; i < currentDoc.pages.length; i++) { const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ }; newPages.push(page); } // Update document const updatedDocument: PDFDocument = { ...currentDoc, pages: newPages, totalPages: newPages.length, }; this.setDocument(updatedDocument); // Update FileContext with the new document structure and inserted files if (this.updateFileContext) { this.updateFileContext(updatedDocument, this.insertedFileMap); } // Generate thumbnails for inserted pages (all files should be read by now) this.generateThumbnailsForInsertedPages(updatedDocument); // Maintain existing selection by mapping original selected pages to their new positions const originalSelection = this.getSelectedPages(); const updatedSelection: number[] = []; originalSelection.forEach(originalPageNum => { if (originalPageNum <= this.insertAfterPageNumber) { // Pages before insertion point keep same number updatedSelection.push(originalPageNum); } else { // Pages after insertion point are shifted by number of inserted pages updatedSelection.push(originalPageNum + allNewPages.length); } }); this.setSelectedPages(updatedSelection); } catch (error) { console.error('Failed to insert files:', error); // Revert to original state if error occurs if (this.originalDocument) { this.setDocument(this.originalDocument); } } } private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise { try { const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService'); // Group pages by file ID to generate thumbnails efficiently const pagesByFileId = new Map(); for (const page of this.insertedPages) { const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */; if (!pagesByFileId.has(fileId)) { pagesByFileId.set(fileId, []); } pagesByFileId.get(fileId)!.push(page); } // Generate thumbnails for each file for (const [fileId, pages] of pagesByFileId) { const arrayBuffer = this.fileDataMap.get(fileId); console.log('Generating thumbnails for file:', fileId); console.log('Pages:', pages.length); console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); if (arrayBuffer && arrayBuffer.byteLength > 0) { // Extract page numbers for all pages from this file const pageNumbers = pages.map(page => { const pageNumMatch = page.id.match(/-page-(\d+)$/); return pageNumMatch ? parseInt(pageNumMatch[1]) : 1; }); console.log('Generating thumbnails for page numbers:', pageNumbers); // Generate thumbnails for all pages from this file at once const results = await thumbnailGenerationService.generateThumbnails( fileId, arrayBuffer, pageNumbers, { scale: 0.2, quality: 0.8 } ); console.log('Thumbnail generation results:', results.length, 'thumbnails generated'); // Update pages with generated thumbnails for (let i = 0; i < results.length && i < pages.length; i++) { const result = results[i]; const page = pages[i]; if (result.success) { const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id); if (pageIndex >= 0) { updatedDocument.pages[pageIndex].thumbnail = result.thumbnail; console.log('Updated thumbnail for page:', page.id); } } } // Trigger re-render by updating the document this.setDocument({ ...updatedDocument }); } else { console.error('No valid ArrayBuffer found for file ID:', fileId); } } } catch (error) { console.error('Failed to generate thumbnails for inserted pages:', error); } } private async extractPagesFromFile(file: File, baseTimestamp: number): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async (event) => { try { const arrayBuffer = event.target?.result as ArrayBuffer; console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); if (!arrayBuffer) { reject(new Error('Failed to read file')); return; } // Clone the ArrayBuffer before passing to PDF.js (it might consume it) const clonedArrayBuffer = arrayBuffer.slice(0); // Use PDF.js via the worker manager to extract pages const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager'); const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer); const pageCount = pdf.numPages; const pages: PDFPage[] = []; const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId; console.log('Original ArrayBuffer size:', arrayBuffer.byteLength); console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength); // Store the original ArrayBuffer for thumbnail generation this.fileDataMap.set(fileId, arrayBuffer); console.log('After storing - fileDataMap size:', this.fileDataMap.size); console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined'); for (let i = 1; i <= pageCount; i++) { const pageId = `${fileId}-page-${i}`; pages.push({ id: pageId, pageNumber: i, // Will be renumbered in execute() originalPageNumber: i, thumbnail: null, // Will be generated after insertion rotation: 0, selected: false, splitAfter: false, isBlankPage: false }); } // Clean up PDF document pdfWorkerManager.destroyDocument(pdf); resolve(pages); } catch (error) { reject(error); } }; reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsArrayBuffer(file); }); } undo(): void { if (!this.originalDocument) return; this.setDocument(this.originalDocument); } get description(): string { return `Insert ${this.files.length} file(s) after page ${this.insertAfterPageNumber}`; } } // Simple undo manager for DOM commands export class UndoManager { private undoStack: DOMCommand[] = []; private redoStack: DOMCommand[] = []; private onStateChange?: () => void; setStateChangeCallback(callback: () => void): void { this.onStateChange = callback; } executeCommand(command: DOMCommand): void { command.execute(); this.undoStack.push(command); this.redoStack = []; this.onStateChange?.(); } // For async commands that need to be executed manually addToUndoStack(command: DOMCommand): void { this.undoStack.push(command); this.redoStack = []; this.onStateChange?.(); } undo(): boolean { const command = this.undoStack.pop(); if (command) { command.undo(); this.redoStack.push(command); this.onStateChange?.(); return true; } return false; } redo(): boolean { const command = this.redoStack.pop(); if (command) { command.execute(); this.undoStack.push(command); this.onStateChange?.(); return true; } return false; } canUndo(): boolean { return this.undoStack.length > 0; } canRedo(): boolean { return this.redoStack.length > 0; } clear(): void { this.undoStack = []; this.redoStack = []; this.onStateChange?.(); } }