diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 54c5f7b19..376869a1a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,9 +13,11 @@ "Bash(npx tsc:*)", "Bash(node:*)", "Bash(npm run dev:*)", - "Bash(sed:*)" + "Bash(sed:*)", + "Bash(cp:*)", + "Bash(rm:*)" ], "deny": [], "defaultMode": "acceptEdits" } -} +} \ No newline at end of file diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 5919a54a6..db828bdd2 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -9,426 +9,28 @@ import { useFileState, useFileActions, useCurrentFile, useFileSelection } from " import { ModeType } from "../../contexts/NavigationContext"; 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"; -import { pdfWorkerManager } from "../../services/pdfWorkerManager"; // Thumbnail generation is now handled by individual PageThumbnail components -import { fileStorage } from "../../services/fileStorage"; -import { indexedDBManager, DATABASE_CONFIGS } from "../../services/indexedDBManager"; import './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; import DragDropGrid from './DragDropGrid'; import SkeletonLoader from '../shared/SkeletonLoader'; import NavigationWarningModal from '../shared/NavigationWarningModal'; -// V1-style DOM-first command system (replaces the old React state commands) -abstract class DOMCommand { - abstract execute(): void; - abstract undo(): void; - abstract description: string; -} - -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'}`; - } -} - -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)`; - } -} - -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)`; - } -} - -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}`; - } -} - -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'}`; - } -} - -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)`; - } -} - -// Simple undo manager for DOM commands -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?.(); - } - - 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?.(); - } -} +import { + DOMCommand, + RotatePageCommand, + DeletePagesCommand, + ReorderPagesCommand, + SplitCommand, + BulkRotateCommand, + BulkSplitCommand, + SplitAllCommand, + UndoManager +} from './commands/pageCommands'; +import { usePageDocument } from './hooks/usePageDocument'; +import { usePageEditorState } from './hooks/usePageEditorState'; export interface PageEditorProps { onFunctionsReady?: (functions: { @@ -485,127 +87,20 @@ const PageEditor = ({ // DOM-first undo manager (replaces the old React state undo system) const undoManagerRef = useRef(new UndoManager()); - // Thumbnail generation is now handled on-demand by individual PageThumbnail components using modern services + // Document state management + const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument(); - // Get primary file record outside useMemo to track processedFile changes - const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; - const processedFilePages = primaryFileRecord?.processedFile?.pages; - const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; - - // Compute merged document with stable signature (prevents infinite loops) - const mergedPdfDocument = useMemo((): PDFDocument | null => { - if (activeFileIds.length === 0) return null; - - const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; - - // If we have file IDs but no file record, something is wrong - return null to show loading - if (!primaryFileRecord) { - console.log('🎬 PageEditor: No primary file record found, showing loading'); - return null; - } - - const name = - activeFileIds.length === 1 - ? (primaryFileRecord.name ?? 'document.pdf') - : activeFileIds - .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) - .join(' + '); - - // Debug logging for merged document creation - console.log(`🎬 PageEditor: Building merged document for ${name} with ${activeFileIds.length} files`); - - // Collect pages from ALL active files, not just the primary file - let pages: PDFPage[] = []; - let totalPageCount = 0; - - activeFileIds.forEach((fileId, fileIndex) => { - const fileRecord = selectors.getFileRecord(fileId); - if (!fileRecord) { - console.warn(`🎬 PageEditor: No record found for file ${fileId}`); - return; - } - - const processedFile = fileRecord.processedFile; - console.log(`🎬 PageEditor: Processing file ${fileIndex + 1}/${activeFileIds.length} (${fileRecord.name})`); - console.log(`🎬 ProcessedFile exists:`, !!processedFile); - console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0); - console.log(`🎬 ProcessedFile totalPages:`, processedFile?.totalPages || 'unknown'); - - let filePages: PDFPage[] = []; - - if (processedFile?.pages && processedFile.pages.length > 0) { - // Use fully processed pages with thumbnails - filePages = processedFile.pages.map((page, pageIndex) => ({ - id: `${fileId}-${page.pageNumber}`, - pageNumber: totalPageCount + pageIndex + 1, - thumbnail: page.thumbnail || null, - rotation: page.rotation || 0, - selected: false, - splitAfter: page.splitAfter || false, - originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1, - originalFileId: fileId, - })); - } else if (processedFile?.totalPages) { - // Fallback: create pages without thumbnails but with correct count - console.log(`🎬 PageEditor: Creating placeholder pages for ${fileRecord.name} (${processedFile.totalPages} pages)`); - filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({ - id: `${fileId}-${pageIndex + 1}`, - pageNumber: totalPageCount + pageIndex + 1, - originalPageNumber: pageIndex + 1, - originalFileId: fileId, - rotation: 0, - thumbnail: null, // Will be generated later - selected: false, - splitAfter: false, - })); - } - - pages = pages.concat(filePages); - totalPageCount += filePages.length; - }); - - if (pages.length === 0) { - console.warn('🎬 PageEditor: No pages found in any files'); - return null; - } - - console.log(`🎬 PageEditor: Created merged document with ${pages.length} total pages`); - - const mergedDoc: PDFDocument = { - id: activeFileIds.join('-'), - name, - file: primaryFile!, - pages, - totalPages: pages.length, - }; - - return mergedDoc; - }, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]); - - // Large document detection for smart loading - const isVeryLargeDocument = useMemo(() => { - return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false; - }, [mergedPdfDocument?.totalPages]); - - // Thumbnails are now generated on-demand by PageThumbnail components - // No bulk generation needed - modern thumbnail service handles this efficiently - - // Selection and UI state management - const [selectionMode, setSelectionMode] = useState(false); - const [selectedPageNumbers, setSelectedPageNumbers] = useState([]); - const [movingPage, setMovingPage] = useState(null); - const [isAnimating, setIsAnimating] = useState(false); - - // Position-based split tracking (replaces page-based splitAfter) - const [splitPositions, setSplitPositions] = useState>(new Set()); + // UI state management + const { + selectionMode, selectedPageNumbers, movingPage, isAnimating, splitPositions, exportLoading, + setSelectionMode, setSelectedPageNumbers, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading, + togglePage, toggleSelectAll, animateReorder + } = usePageEditorState(); // Grid container ref for positioning split indicators const gridContainerRef = useRef(null); - // Export state - const [exportLoading, setExportLoading] = useState(false); - // Undo/Redo state const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); @@ -624,53 +119,28 @@ const PageEditor = ({ }, [updateUndoRedoState]); + // Interface functions for parent component + const displayDocument = editedDocument || mergedPdfDocument; + // DOM-first command handlers const handleRotatePages = useCallback((pageIds: string[], rotation: number) => { const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); undoManagerRef.current.executeCommand(bulkRotateCommand); }, []); - // Page selection handlers - const togglePage = useCallback((pageNumber: number) => { - setSelectedPageNumbers(prev => - prev.includes(pageNumber) - ? prev.filter(n => n !== pageNumber) - : [...prev, pageNumber] - ); - }, []); - - const toggleSelectAll = useCallback(() => { - if (!mergedPdfDocument) return; - - const allPageNumbers = mergedPdfDocument.pages.map(p => p.pageNumber); - setSelectedPageNumbers(prev => - prev.length === allPageNumbers.length ? [] : allPageNumbers - ); - }, [mergedPdfDocument]); - - - // Animation helpers - const animateReorder = useCallback(() => { - setIsAnimating(true); - setTimeout(() => setIsAnimating(false), 500); - }, []); - - // Placeholder command classes for PageThumbnail compatibility - class RotatePagesCommand { - constructor(public pageIds: string[], public rotation: number) {} - execute() { - const bulkRotateCommand = new BulkRotateCommand(this.pageIds, this.rotation); + // Command factory functions for PageThumbnail + const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({ + execute: () => { + const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); undoManagerRef.current.executeCommand(bulkRotateCommand); } - } + }), []); - class DeletePagesWrapper { - constructor(public pageIds: string[]) {} - execute() { - // Convert page IDs to page numbers for the real delete command + const createDeleteCommand = useCallback((pageIds: string[]) => ({ + execute: () => { if (!displayDocument) return; - const pagesToDelete = this.pageIds.map(pageId => { + const pagesToDelete = pageIds.map(pageId => { const page = displayDocument.pages.find(p => p.id === pageId); return page?.pageNumber || 0; }).filter(num => num > 0); @@ -688,19 +158,18 @@ const PageEditor = ({ undoManagerRef.current.executeCommand(deleteCommand); } } - } + }), [displayDocument, splitPositions, selectedPageNumbers]); - class ToggleSplitCommand { - constructor(public position: number) {} - execute() { + const createSplitCommand = useCallback((position: number) => ({ + execute: () => { const splitCommand = new SplitCommand( - this.position, + position, () => splitPositions, setSplitPositions ); undoManagerRef.current.executeCommand(splitCommand); } - } + }), [splitPositions]); // Command executor for PageThumbnail const executeCommand = useCallback((command: any) => { @@ -708,9 +177,6 @@ const PageEditor = ({ command.execute(); } }, []); - - // Interface functions for parent component - const displayDocument = editedDocument || mergedPdfDocument; const handleUndo = useCallback(() => { @@ -792,47 +258,11 @@ const PageEditor = ({ const handleSplitAll = useCallback(() => { if (!displayDocument) return; - // Create a command that toggles all splits - class SplitAllCommand extends DOMCommand { - private originalSplitPositions: Set = new Set(); - private allPossibleSplits: Set = new Set(); - - constructor() { - super(); - // Calculate all possible split positions - for (let i = 0; i < displayDocument!.pages.length - 1; i++) { - this.allPossibleSplits.add(i); - } - } - - execute(): void { - // Store original state for undo - this.originalSplitPositions = new Set(splitPositions); - - // Check if all splits are already active - const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => splitPositions.has(pos)); - - if (hasAllSplits) { - // Remove all splits - setSplitPositions(new Set()); - } else { - // Add all splits - setSplitPositions(this.allPossibleSplits); - } - } - - undo(): void { - // Restore original split positions - setSplitPositions(this.originalSplitPositions); - } - - get description(): string { - const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => splitPositions.has(pos)); - return hasAllSplits ? 'Remove all splits' : 'Split all pages'; - } - } - - const splitAllCommand = new SplitAllCommand(); + const splitAllCommand = new SplitAllCommand( + displayDocument.pages.length, + () => splitPositions, + setSplitPositions + ); undoManagerRef.current.executeCommand(splitAllCommand); }, [displayDocument, splitPositions]); @@ -1059,7 +489,7 @@ const PageEditor = ({ {selectionMode && ( <> - @@ -1139,9 +569,9 @@ const PageEditor = ({ onSetStatus={() => {}} onSetMovingPage={setMovingPage} onDeletePage={handleDeletePage} - RotatePagesCommand={RotatePagesCommand} - DeletePagesCommand={DeletePagesWrapper} - ToggleSplitCommand={ToggleSplitCommand} + createRotateCommand={createRotateCommand} + createDeleteCommand={createDeleteCommand} + createSplitCommand={createSplitCommand} pdfDocument={displayDocument} setPdfDocument={setEditedDocument} splitPositions={splitPositions} diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index f59ae0969..1de6a4fa4 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -11,12 +11,6 @@ import { PDFPage, PDFDocument } from '../../types/pageEditor'; import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration'; import styles from './PageEditor.module.css'; -// DOM Command types (match what PageEditor expects) -abstract class DOMCommand { - abstract execute(): void; - abstract undo(): void; - abstract description: string; -} interface PageThumbnailProps { page: PDFPage; @@ -31,13 +25,13 @@ interface PageThumbnailProps { onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void; onTogglePage: (pageNumber: number) => void; onAnimateReorder: () => void; - onExecuteCommand: (command: DOMCommand) => void; + onExecuteCommand: (command: { execute: () => void }) => void; onSetStatus: (status: string) => void; onSetMovingPage: (page: number | null) => void; onDeletePage: (pageNumber: number) => void; - RotatePagesCommand: any; - DeletePagesCommand: any; - ToggleSplitCommand: any; + createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void }; + createDeleteCommand: (pageIds: string[]) => { execute: () => void }; + createSplitCommand: (position: number) => { execute: () => void }; pdfDocument: PDFDocument; setPdfDocument: (doc: PDFDocument) => void; splitPositions: Set; @@ -60,18 +54,18 @@ const PageThumbnail: React.FC = ({ onSetStatus, onSetMovingPage, onDeletePage, - RotatePagesCommand, - DeletePagesCommand, - ToggleSplitCommand, + createRotateCommand, + createDeleteCommand, + createSplitCommand, pdfDocument, setPdfDocument, splitPositions, }: PageThumbnailProps) => { - const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const [isDragging, setIsDragging] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); const dragElementRef = useRef(null); + const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); // Update thumbnail URL when page prop changes @@ -79,9 +73,9 @@ const PageThumbnail: React.FC = ({ if (page.thumbnail && page.thumbnail !== thumbnailUrl) { setThumbnailUrl(page.thumbnail); } - }, [page.thumbnail, page.id]); + }, [page.thumbnail, thumbnailUrl]); - // Request thumbnail on-demand using modern service + // Request thumbnail if missing (on-demand, virtualized approach) useEffect(() => { let isCancelled = false; @@ -100,8 +94,7 @@ const PageThumbnail: React.FC = ({ // Request thumbnail generation if we have the original file if (originalFile) { - // Extract page number from page.id (format: fileId-pageNumber) - const pageNumber = parseInt(page.id.split('-').pop() || '1'); + const pageNumber = page.originalPageNumber; requestThumbnail(page.id, originalFile, pageNumber) .then(thumbnail => { @@ -153,6 +146,8 @@ const PageThumbnail: React.FC = ({ const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber) ? selectedPages : undefined; + // Trigger animation for drag & drop + onAnimateReorder(); onReorderPages(page.pageNumber, targetIndex, pagesToMove); } } @@ -186,18 +181,18 @@ const PageThumbnail: React.FC = ({ const handleRotateLeft = useCallback((e: React.MouseEvent) => { e.stopPropagation(); // Use the command system for undo/redo support - const command = new RotatePagesCommand([page.id], -90); + const command = createRotateCommand([page.id], -90); onExecuteCommand(command); onSetStatus(`Rotated page ${page.pageNumber} left`); - }, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, RotatePagesCommand]); + }, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]); const handleRotateRight = useCallback((e: React.MouseEvent) => { e.stopPropagation(); // Use the command system for undo/redo support - const command = new RotatePagesCommand([page.id], 90); + const command = createRotateCommand([page.id], 90); onExecuteCommand(command); onSetStatus(`Rotated page ${page.pageNumber} right`); - }, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, RotatePagesCommand]); + }, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]); const handleDelete = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -209,13 +204,13 @@ const PageThumbnail: React.FC = ({ e.stopPropagation(); // Create a command to toggle split at this position - const command = new ToggleSplitCommand(index); + const command = createSplitCommand(index); onExecuteCommand(command); const hasSplit = splitPositions.has(index); const action = hasSplit ? 'removed' : 'added'; onSetStatus(`Split marker ${action} after position ${index + 1}`); - }, [index, splitPositions, onExecuteCommand, onSetStatus, ToggleSplitCommand]); + }, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]); // Handle click vs drag differentiation const handleMouseDown = useCallback((e: React.MouseEvent) => { @@ -254,8 +249,8 @@ const PageThumbnail: React.FC = ({ return (
= ({ e.stopPropagation(); if (index > 0 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); + // Trigger animation onAnimateReorder(); - setTimeout(() => onSetMovingPage(null), 500); + // Actually move the page left (swap with previous page) + onReorderPages(page.pageNumber, index - 1); + setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} left`); } }} @@ -422,8 +420,11 @@ const PageThumbnail: React.FC = ({ e.stopPropagation(); if (index < totalPages - 1 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); + // Trigger animation onAnimateReorder(); - setTimeout(() => onSetMovingPage(null), 500); + // Actually move the page right (swap with next page) + onReorderPages(page.pageNumber, index + 1); + setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} right`); } }} diff --git a/frontend/src/components/pageEditor/commands/pageCommands.ts b/frontend/src/components/pageEditor/commands/pageCommands.ts new file mode 100644 index 000000000..0cb7d0c15 --- /dev/null +++ b/frontend/src/components/pageEditor/commands/pageCommands.ts @@ -0,0 +1,451 @@ +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'; + } +} + +// 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?.(); + } + + 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?.(); + } +} \ No newline at end of file diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts new file mode 100644 index 000000000..837ac4720 --- /dev/null +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -0,0 +1,136 @@ +import { useMemo } from 'react'; +import { useFileState } from '../../../contexts/FileContext'; +import { PDFDocument, PDFPage } from '../../../types/pageEditor'; + +export interface PageDocumentHook { + document: PDFDocument | null; + isVeryLargeDocument: boolean; + isLoading: boolean; +} + +/** + * Hook for managing PDF document state and metadata in PageEditor + * Handles document merging, large document detection, and loading states + */ +export function usePageDocument(): PageDocumentHook { + const { state, selectors } = useFileState(); + + // Prefer IDs + selectors to avoid array identity churn + const activeFileIds = state.files.ids; + const primaryFileId = activeFileIds[0] ?? null; + + // Stable signature for effects (prevents loops) + const filesSignature = selectors.getFilesSignature(); + + // UI state + const globalProcessing = state.ui.isProcessing; + + // Get primary file record outside useMemo to track processedFile changes + const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; + const processedFilePages = primaryFileRecord?.processedFile?.pages; + const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; + + // Compute merged document with stable signature (prevents infinite loops) + const mergedPdfDocument = useMemo((): PDFDocument | null => { + if (activeFileIds.length === 0) return null; + + const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; + + // If we have file IDs but no file record, something is wrong - return null to show loading + if (!primaryFileRecord) { + console.log('🎬 PageEditor: No primary file record found, showing loading'); + return null; + } + + const name = + activeFileIds.length === 1 + ? (primaryFileRecord.name ?? 'document.pdf') + : activeFileIds + .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) + .join(' + '); + + // Debug logging for merged document creation + console.log(`🎬 PageEditor: Building merged document for ${name} with ${activeFileIds.length} files`); + + // Collect pages from ALL active files, not just the primary file + let pages: PDFPage[] = []; + let totalPageCount = 0; + + activeFileIds.forEach((fileId, fileIndex) => { + const fileRecord = selectors.getFileRecord(fileId); + if (!fileRecord) { + console.warn(`🎬 PageEditor: No record found for file ${fileId}`); + return; + } + + const processedFile = fileRecord.processedFile; + console.log(`🎬 PageEditor: Processing file ${fileIndex + 1}/${activeFileIds.length} (${fileRecord.name})`); + console.log(`🎬 ProcessedFile exists:`, !!processedFile); + console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0); + console.log(`🎬 ProcessedFile totalPages:`, processedFile?.totalPages || 'unknown'); + + let filePages: PDFPage[] = []; + + if (processedFile?.pages && processedFile.pages.length > 0) { + // Use fully processed pages with thumbnails + filePages = processedFile.pages.map((page, pageIndex) => ({ + id: `${fileId}-${page.pageNumber}`, + pageNumber: totalPageCount + pageIndex + 1, + thumbnail: page.thumbnail || null, + rotation: page.rotation || 0, + selected: false, + splitAfter: page.splitAfter || false, + originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1, + originalFileId: fileId, + })); + } else if (processedFile?.totalPages) { + // Fallback: create pages without thumbnails but with correct count + console.log(`🎬 PageEditor: Creating placeholder pages for ${fileRecord.name} (${processedFile.totalPages} pages)`); + filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({ + id: `${fileId}-${pageIndex + 1}`, + pageNumber: totalPageCount + pageIndex + 1, + originalPageNumber: pageIndex + 1, + originalFileId: fileId, + rotation: 0, + thumbnail: null, // Will be generated later + selected: false, + splitAfter: false, + })); + } + + pages = pages.concat(filePages); + totalPageCount += filePages.length; + }); + + if (pages.length === 0) { + console.warn('🎬 PageEditor: No pages found in any files'); + return null; + } + + console.log(`🎬 PageEditor: Created merged document with ${pages.length} total pages`); + + const mergedDoc: PDFDocument = { + id: activeFileIds.join('-'), + name, + file: primaryFile!, + pages, + totalPages: pages.length, + }; + + return mergedDoc; + }, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]); + + // Large document detection for smart loading + const isVeryLargeDocument = useMemo(() => { + return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false; + }, [mergedPdfDocument?.totalPages]); + + // Loading state + const isLoading = globalProcessing && !mergedPdfDocument; + + return { + document: mergedPdfDocument, + isVeryLargeDocument, + isLoading + }; +} \ No newline at end of file diff --git a/frontend/src/components/pageEditor/hooks/usePageEditorState.ts b/frontend/src/components/pageEditor/hooks/usePageEditorState.ts new file mode 100644 index 000000000..18b0adafb --- /dev/null +++ b/frontend/src/components/pageEditor/hooks/usePageEditorState.ts @@ -0,0 +1,96 @@ +import { useState, useCallback } from 'react'; + +export interface PageEditorState { + // Selection state + selectionMode: boolean; + selectedPageNumbers: number[]; + + // Animation state + movingPage: number | null; + isAnimating: boolean; + + // Split state + splitPositions: Set; + + // Export state + exportLoading: boolean; + + // Actions + setSelectionMode: (mode: boolean) => void; + setSelectedPageNumbers: (pages: number[]) => void; + setMovingPage: (pageNumber: number | null) => void; + setIsAnimating: (animating: boolean) => void; + setSplitPositions: (positions: Set) => void; + setExportLoading: (loading: boolean) => void; + + // Helper functions + togglePage: (pageNumber: number) => void; + toggleSelectAll: (totalPages: number) => void; + animateReorder: () => void; +} + +/** + * Hook for managing PageEditor UI state + * Handles selection, animation, splits, and export states + */ +export function usePageEditorState(): PageEditorState { + // Selection state + const [selectionMode, setSelectionMode] = useState(false); + const [selectedPageNumbers, setSelectedPageNumbers] = useState([]); + + // Animation state + const [movingPage, setMovingPage] = useState(null); + const [isAnimating, setIsAnimating] = useState(false); + + // Split state - position-based split tracking (replaces page-based splitAfter) + const [splitPositions, setSplitPositions] = useState>(new Set()); + + // Export state + const [exportLoading, setExportLoading] = useState(false); + + // Helper functions + const togglePage = useCallback((pageNumber: number) => { + setSelectedPageNumbers(prev => + prev.includes(pageNumber) + ? prev.filter(n => n !== pageNumber) + : [...prev, pageNumber] + ); + }, []); + + const toggleSelectAll = useCallback((totalPages: number) => { + if (!totalPages) return; + + const allPageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); + setSelectedPageNumbers(prev => + prev.length === allPageNumbers.length ? [] : allPageNumbers + ); + }, []); + + const animateReorder = useCallback(() => { + setIsAnimating(true); + setTimeout(() => setIsAnimating(false), 500); + }, []); + + return { + // State + selectionMode, + selectedPageNumbers, + movingPage, + isAnimating, + splitPositions, + exportLoading, + + // Setters + setSelectionMode, + setSelectedPageNumbers, + setMovingPage, + setIsAnimating, + setSplitPositions, + setExportLoading, + + // Helpers + togglePage, + toggleSelectAll, + animateReorder, + }; +} \ No newline at end of file