diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js index 681b06728..0fd42a4df 100644 --- a/frontend/scripts/generate-icons.js +++ b/frontend/scripts/generate-icons.js @@ -1,7 +1,6 @@ #!/usr/bin/env node const { icons } = require('@iconify-json/material-symbols'); -const { getIcons } = require('@iconify/utils'); const fs = require('fs'); const path = require('path'); @@ -89,68 +88,73 @@ function scanForUsedIcons() { return iconArray; } -// Auto-detect used icons -const usedIcons = scanForUsedIcons(); +// Main async function +async function main() { + // Auto-detect used icons + const usedIcons = scanForUsedIcons(); -// Check if we need to regenerate (compare with existing) -const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json'); -let needsRegeneration = true; + // Check if we need to regenerate (compare with existing) + const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json'); + let needsRegeneration = true; -if (fs.existsSync(outputPath)) { - try { - const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8')); - const existingIcons = Object.keys(existingSet.icons || {}).sort(); - const currentIcons = [...usedIcons].sort(); - - if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) { - needsRegeneration = false; - info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); + if (fs.existsSync(outputPath)) { + try { + const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const existingIcons = Object.keys(existingSet.icons || {}).sort(); + const currentIcons = [...usedIcons].sort(); + + if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) { + needsRegeneration = false; + info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); + } + } catch (error) { + // If we can't parse existing file, regenerate + needsRegeneration = true; } - } catch (error) { - // If we can't parse existing file, regenerate - needsRegeneration = true; } -} -if (!needsRegeneration) { - info('🎉 No regeneration needed!'); - process.exit(0); -} + if (!needsRegeneration) { + info('🎉 No regeneration needed!'); + process.exit(0); + } -info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`); + info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`); -// Extract only our used icons from the full set -const extractedIcons = getIcons(icons, usedIcons); + // Dynamic import of ES module + const { getIcons } = await import('@iconify/utils'); + + // Extract only our used icons from the full set + const extractedIcons = getIcons(icons, usedIcons); -if (!extractedIcons) { - console.error('❌ Failed to extract icons'); - process.exit(1); -} + if (!extractedIcons) { + console.error('❌ Failed to extract icons'); + process.exit(1); + } -// Check for missing icons -const extractedIconNames = Object.keys(extractedIcons.icons || {}); -const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon)); + // Check for missing icons + const extractedIconNames = Object.keys(extractedIcons.icons || {}); + const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon)); -if (missingIcons.length > 0) { - info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`); - info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.'); -} + if (missingIcons.length > 0) { + info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`); + info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.'); + } -// Create output directory -const outputDir = path.join(__dirname, '..', 'src', 'assets'); -if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); -} + // Create output directory + const outputDir = path.join(__dirname, '..', 'src', 'assets'); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } -// Write the extracted icon set to a file (outputPath already defined above) -fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2)); + // Write the extracted icon set to a file (outputPath already defined above) + fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2)); -info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`); -info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`); -info(`💾 Saved to: ${outputPath}`); + info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`); + info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`); + info(`💾 Saved to: ${outputPath}`); -// Generate TypeScript types -const typesContent = `// Auto-generated icon types + // Generate TypeScript types + const typesContent = `// Auto-generated icon types // This file is automatically generated by scripts/generate-icons.js // Do not edit manually - changes will be overwritten @@ -168,8 +172,15 @@ declare const iconSet: IconSet; export default iconSet; `; -const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts'); -fs.writeFileSync(typesPath, typesContent); + const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts'); + fs.writeFileSync(typesPath, typesContent); -info(`📝 Generated types: ${typesPath}`); -info(`🎉 Icon extraction complete!`); \ No newline at end of file + info(`📝 Generated types: ${typesPath}`); + info(`🎉 Icon extraction complete!`); +} + +// Run the main function +main().catch(error => { + console.error('❌ Script failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/frontend/src/commands/pageCommands.ts b/frontend/src/commands/pageCommands.ts deleted file mode 100644 index 92a9c9a73..000000000 --- a/frontend/src/commands/pageCommands.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { Command, CommandSequence } from '../hooks/useUndoRedo'; -import { PDFDocument, PDFPage } from '../types/pageEditor'; - -// Base class for page operations -abstract class PageCommand implements Command { - protected pdfDocument: PDFDocument; - protected setPdfDocument: (doc: PDFDocument) => void; - protected previousState: PDFDocument; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void - ) { - this.pdfDocument = pdfDocument; - this.setPdfDocument = setPdfDocument; - this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone - } - - abstract execute(): void; - abstract description: string; - - undo(): void { - this.setPdfDocument(this.previousState); - } -} - -// Rotate pages command -export class RotatePagesCommand extends PageCommand { - private pageIds: string[]; - private rotation: number; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[], - rotation: number - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.rotation = rotation; - } - - execute(): void { - const updatedPages = this.pdfDocument.pages.map(page => { - if (this.pageIds.includes(page.id)) { - return { ...page, rotation: page.rotation + this.rotation }; - } - return page; - }); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - const direction = this.rotation > 0 ? 'right' : 'left'; - return `Rotate ${this.pageIds.length} page(s) ${direction}`; - } -} - -// Delete pages command -export class DeletePagesCommand extends PageCommand { - private pageIds: string[]; - private deletedPages: PDFPage[]; - private deletedPositions: Map; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[] - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.deletedPages = []; - this.deletedPositions = new Map(); - } - - execute(): void { - // Store deleted pages and their positions for undo - this.deletedPages = this.pdfDocument.pages.filter(page => - this.pageIds.includes(page.id) - ); - - this.deletedPages.forEach(page => { - const index = this.pdfDocument.pages.findIndex(p => p.id === page.id); - this.deletedPositions.set(page.id, index); - }); - - const updatedPages = this.pdfDocument.pages - .filter(page => !this.pageIds.includes(page.id)) - .map((page, index) => ({ ...page, pageNumber: index + 1 })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - undo(): void { - // Simply restore to the previous state (before deletion) - this.setPdfDocument(this.previousState); - } - - get description(): string { - return `Delete ${this.pageIds.length} page(s)`; - } -} - -// Move pages command -export class MovePagesCommand extends PageCommand { - private pageIds: string[]; - private targetIndex: number; - private originalIndices: Map; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[], - targetIndex: number - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.targetIndex = targetIndex; - this.originalIndices = new Map(); - } - - execute(): void { - // Store original positions - this.pageIds.forEach(pageId => { - const index = this.pdfDocument.pages.findIndex(p => p.id === pageId); - this.originalIndices.set(pageId, index); - }); - - let newPages = [...this.pdfDocument.pages]; - const pagesToMove = this.pageIds - .map(id => this.pdfDocument.pages.find(p => p.id === id)) - .filter((page): page is PDFPage => page !== undefined); - - // Remove pages to move - newPages = newPages.filter(page => !this.pageIds.includes(page.id)); - - // Insert pages at target position - newPages.splice(this.targetIndex, 0, ...pagesToMove); - - // Update page numbers - newPages = newPages.map((page, index) => ({ - ...page, - pageNumber: index + 1 - })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: newPages, - totalPages: newPages.length - }); - } - - get description(): string { - return `Move ${this.pageIds.length} page(s)`; - } -} - -// Reorder single page command (for drag-and-drop) -export class ReorderPageCommand extends PageCommand { - private pageId: string; - private targetIndex: number; - private originalIndex: number; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageId: string, - targetIndex: number - ) { - super(pdfDocument, setPdfDocument); - this.pageId = pageId; - this.targetIndex = targetIndex; - this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId); - } - - execute(): void { - const newPages = [...this.pdfDocument.pages]; - const [movedPage] = newPages.splice(this.originalIndex, 1); - newPages.splice(this.targetIndex, 0, movedPage); - - // Update page numbers - const updatedPages = newPages.map((page, index) => ({ - ...page, - pageNumber: index + 1 - })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`; - } -} - -// Toggle split markers command -export class ToggleSplitCommand extends PageCommand { - private pageIds: string[]; - private previousSplitStates: Map; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[] - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.previousSplitStates = new Map(); - } - - execute(): void { - // Store previous split states - this.pageIds.forEach(pageId => { - const page = this.pdfDocument.pages.find(p => p.id === pageId); - if (page) { - this.previousSplitStates.set(pageId, !!page.splitBefore); - } - }); - - const updatedPages = this.pdfDocument.pages.map(page => { - if (this.pageIds.includes(page.id)) { - return { ...page, splitBefore: !page.splitBefore }; - } - return page; - }); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - undo(): void { - const updatedPages = this.pdfDocument.pages.map(page => { - if (this.pageIds.includes(page.id)) { - const previousState = this.previousSplitStates.get(page.id); - return { ...page, splitBefore: previousState }; - } - return page; - }); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - return `Toggle split markers for ${this.pageIds.length} page(s)`; - } -} - -// Add pages command (for inserting new files) -export class AddPagesCommand extends PageCommand { - private newPages: PDFPage[]; - private insertIndex: number; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - newPages: PDFPage[], - insertIndex: number = -1 // -1 means append to end - ) { - super(pdfDocument, setPdfDocument); - this.newPages = newPages; - this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex; - } - - execute(): void { - const newPagesArray = [...this.pdfDocument.pages]; - newPagesArray.splice(this.insertIndex, 0, ...this.newPages); - - // Update page numbers for all pages - const updatedPages = newPagesArray.map((page, index) => ({ - ...page, - pageNumber: index + 1 - })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - undo(): void { - const updatedPages = this.pdfDocument.pages - .filter(page => !this.newPages.some(newPage => newPage.id === page.id)) - .map((page, index) => ({ ...page, pageNumber: index + 1 })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - return `Add ${this.newPages.length} page(s)`; - } -} - -// Command sequence for bulk operations -export class PageCommandSequence implements CommandSequence { - commands: Command[]; - description: string; - - constructor(commands: Command[], description?: string) { - this.commands = commands; - this.description = description || `Execute ${commands.length} operations`; - } - - execute(): void { - this.commands.forEach(command => command.execute()); - } - - undo(): void { - // Undo in reverse order - [...this.commands].reverse().forEach(command => command.undo()); - } -} \ No newline at end of file diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index a98b19b99..44a4f0271 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -111,11 +111,15 @@ export default function Workbench() { onRotate={pageEditorFunctions.handleRotate} onDelete={pageEditorFunctions.handleDelete} onSplit={pageEditorFunctions.handleSplit} - onExportSelected={pageEditorFunctions.onExportSelected} + onSplitAll={pageEditorFunctions.handleSplitAll} + onPageBreak={pageEditorFunctions.handlePageBreak} + onPageBreakAll={pageEditorFunctions.handlePageBreakAll} onExportAll={pageEditorFunctions.onExportAll} exportLoading={pageEditorFunctions.exportLoading} selectionMode={pageEditorFunctions.selectionMode} selectedPages={pageEditorFunctions.selectedPages} + splitPositions={pageEditorFunctions.splitPositions} + totalPages={pageEditorFunctions.totalPages} /> )} diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 5829d0375..8928e1a34 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -3,10 +3,11 @@ import { Box } from '@mantine/core'; import { useVirtualizer } from '@tanstack/react-virtual'; import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import styles from './PageEditor.module.css'; +import { GRID_CONSTANTS } from './constants'; interface DragDropItem { id: string; - splitBefore?: boolean; + splitAfter?: boolean; } interface DragDropGridProps { @@ -33,10 +34,7 @@ const DragDropGrid = ({ // Responsive grid configuration const [itemsPerRow, setItemsPerRow] = useState(4); - const ITEM_WIDTH = 320; // 20rem (page width) - const ITEM_GAP = 24; // 1.5rem gap between items - const ITEM_HEIGHT = 340; // 20rem + gap - const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents + const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL; // Calculate items per row based on container width const calculateItemsPerRow = useCallback(() => { @@ -45,6 +43,11 @@ const DragDropGrid = ({ const containerWidth = containerRef.current.offsetWidth; if (containerWidth === 0) return 4; // Container not measured yet + // Convert rem to pixels for calculation + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; + const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + // Calculate how many items fit: (width - gap) / (itemWidth + gap) const availableWidth = containerWidth - ITEM_GAP; // Account for first gap const itemWithGap = ITEM_WIDTH + ITEM_GAP; @@ -82,12 +85,21 @@ const DragDropGrid = ({ const rowVirtualizer = useVirtualizer({ count: Math.ceil(items.length / itemsPerRow), getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element, - estimateSize: () => ITEM_HEIGHT, + estimateSize: () => { + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; + }, overscan: OVERSCAN, }); + // Calculate optimal width for centering + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; + const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap; + return ( ({ height: `${rowVirtualizer.getTotalSize()}px`, width: '100%', position: 'relative', + margin: '0 auto', + maxWidth: `${gridWidth}px`, }} > {rowVirtualizer.getVirtualItems().map((virtualRow) => { @@ -124,18 +138,17 @@ const DragDropGrid = ({
{rowItems.map((item, itemIndex) => { const actualIndex = startIndex + itemIndex; return ( - {/* Split marker */} - {renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)} {/* Item */} {renderItem(item, actualIndex, itemRefs)} diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index 8b1c84638..b172aff23 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -16,7 +16,7 @@ } .pageContainer:hover .pageHoverControls { - opacity: 1 !important; + opacity: 0.95 !important; } /* Checkbox container - prevent transform inheritance */ diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 543778d9e..39c6c706b 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -8,32 +8,34 @@ import { useTranslation } from "react-i18next"; import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; import { ModeType } from "../../contexts/NavigationContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; -import { useUndoRedo } from "../../hooks/useUndoRedo"; -import { - RotatePagesCommand, - DeletePagesCommand, - ReorderPageCommand, - MovePagesCommand, - ToggleSplitCommand -} from "../../commands/pageCommands"; +import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { pdfExportService } from "../../services/pdfExportService"; -import { enhancedPDFProcessingService } from "../../services/enhancedPDFProcessingService"; -import { fileProcessingService } from "../../services/fileProcessingService"; -import { pdfProcessingService } from "../../services/pdfProcessingService"; -import { pdfWorkerManager } from "../../services/pdfWorkerManager"; -import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; -import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; -import { fileStorage } from "../../services/fileStorage"; -import { indexedDBManager, DATABASE_CONFIGS } from "../../services/indexedDBManager"; +import { documentManipulationService } from "../../services/documentManipulationService"; +// Thumbnail generation is now handled by individual PageThumbnail components import './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; -import BulkSelectionPanel from './BulkSelectionPanel'; import DragDropGrid from './DragDropGrid'; import SkeletonLoader from '../shared/SkeletonLoader'; import NavigationWarningModal from '../shared/NavigationWarningModal'; +import { + DOMCommand, + RotatePageCommand, + DeletePagesCommand, + ReorderPagesCommand, + SplitCommand, + BulkRotateCommand, + BulkSplitCommand, + SplitAllCommand, + PageBreakCommand, + BulkPageBreakCommand, + UndoManager +} from './commands/pageCommands'; +import { GRID_CONSTANTS } from './constants'; +import { usePageDocument } from './hooks/usePageDocument'; +import { usePageEditorState } from './hooks/usePageEditorState'; + export interface PageEditorProps { - // Optional callbacks to expose internal functions for PageEditorControls onFunctionsReady?: (functions: { handleUndo: () => void; handleRedo: () => void; @@ -42,12 +44,21 @@ export interface PageEditorProps { handleRotate: (direction: 'left' | 'right') => void; handleDelete: () => void; handleSplit: () => void; + handleSplitAll: () => void; + handlePageBreak: () => void; + handlePageBreakAll: () => void; + handleSelectAll: () => void; + handleDeselectAll: () => void; + handleSetSelectedPages: (pageNumbers: number[]) => void; showExportPreview: (selectedOnly: boolean) => void; onExportSelected: () => void; onExportAll: () => void; + applyChanges: () => void; exportLoading: boolean; selectionMode: boolean; selectedPages: number[]; + splitPositions: Set; + totalPages: number; closePdf: () => void; }) => void; } @@ -59,20 +70,19 @@ const PageEditor = ({ // Use split contexts to prevent re-renders const { state, selectors } = useFileState(); const { actions } = useFileActions(); - + // Prefer IDs + selectors to avoid array identity churn const activeFileIds = state.files.ids; const primaryFileId = activeFileIds[0] ?? null; const selectedFiles = selectors.getSelectedFiles(); - + // Stable signature for effects (prevents loops) const filesSignature = selectors.getFilesSignature(); - + // UI state const globalProcessing = state.ui.isProcessing; const processingProgress = state.ui.processingProgress; const hasUnsavedChanges = state.ui.hasUnsavedChanges; - const selectedPageNumbers = state.ui.selectedPageNumbers; // Edit state management const [editedDocument, setEditedDocument] = useState(null); @@ -81,1108 +91,581 @@ const PageEditor = ({ const [foundDraft, setFoundDraft] = useState(null); const autoSaveTimer = useRef(null); - /** - * Create stable files signature to prevent infinite re-computation. - * This signature only changes when files are actually added/removed or processing state changes. - * Using this instead of direct file arrays prevents unnecessary re-renders. - */ - - // Thumbnail generation (opt-in for visual tools) - MUST be before mergedPdfDocument + // DOM-first undo manager (replaces the old React state undo system) + const undoManagerRef = useRef(new UndoManager()); + + // Document state management + const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument(); + + + // UI state management const { - generateThumbnails, - addThumbnailToCache, - getThumbnailFromCache, - stopGeneration, - destroyThumbnails - } = useThumbnailGeneration(); + 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); + // State to trigger re-renders when container size changes + const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); - // 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; + // Undo/Redo state + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); - // 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(' + '); - - // Get pages from processed file data - const processedFile = primaryFileRecord.processedFile; - - // Debug logging for processed file data - console.log(`🎬 PageEditor: Building document for ${name}`); - console.log(`🎬 ProcessedFile exists:`, !!processedFile); - console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0); - console.log(`🎬 ProcessedFile totalPages:`, processedFile?.totalPages || 'unknown'); - if (processedFile?.pages) { - console.log(`🎬 Pages structure:`, processedFile.pages.map(p => ({ pageNumber: p.pageNumber || 'unknown', hasThumbnail: !!p.thumbnail }))); - } - console.log(`🎬 Will use ${(processedFile?.pages?.length || 0) > 0 ? 'PROCESSED' : 'FALLBACK'} pages`); - - // Convert processed pages to PageEditor format or create placeholders from metadata - let pages: PDFPage[] = []; - - if (processedFile?.pages && processedFile.pages.length > 0) { - // Use fully processed pages with thumbnails - pages = processedFile.pages.map((page, index) => { - const pageId = `${primaryFileId}-page-${index + 1}`; - // Try multiple sources for thumbnails in order of preference: - // 1. Processed data thumbnail - // 2. Cached thumbnail from previous generation - // 3. For page 1: FileRecord's thumbnailUrl (from FileProcessingService) - let thumbnail = page.thumbnail || null; - const cachedThumbnail = getThumbnailFromCache(pageId); - if (!thumbnail && cachedThumbnail) { - thumbnail = cachedThumbnail; - console.log(`📸 PageEditor: Using cached thumbnail for page ${index + 1} (${pageId})`); - } - if (!thumbnail && index === 0) { - // For page 1, use the thumbnail from FileProcessingService - thumbnail = primaryFileRecord.thumbnailUrl || null; - if (thumbnail) { - addThumbnailToCache(pageId, thumbnail); - console.log(`📸 PageEditor: Using FileProcessingService thumbnail for page 1 (${pageId})`); - } - } - - return { - id: pageId, - pageNumber: index + 1, - thumbnail, - rotation: page.rotation || 0, - selected: false, - splitBefore: page.splitBefore || false, - }; - }); - } else if (processedFile?.totalPages && processedFile.totalPages > 0) { - // Create placeholder pages from metadata while thumbnails are being generated - console.log(`🎬 PageEditor: Creating ${processedFile.totalPages} placeholder pages from metadata`); - pages = Array.from({ length: processedFile.totalPages }, (_, index) => { - const pageId = `${primaryFileId}-page-${index + 1}`; - - // Check for existing cached thumbnail - let thumbnail = getThumbnailFromCache(pageId) || null; - - // For page 1, try to use the FileRecord thumbnail - if (!thumbnail && index === 0) { - thumbnail = primaryFileRecord.thumbnailUrl || null; - if (thumbnail) { - addThumbnailToCache(pageId, thumbnail); - console.log(`📸 PageEditor: Using FileProcessingService thumbnail for placeholder page 1 (${pageId})`); - } - } - - return { - id: pageId, - pageNumber: index + 1, - thumbnail, // Will be null initially, populated by PageThumbnail components - rotation: 0, - selected: false, - splitBefore: false, - }; - }); - } else { - // Ultimate fallback - single page while we wait for metadata - pages = [{ - id: `${primaryFileId}-page-1`, - pageNumber: 1, - thumbnail: getThumbnailFromCache(`${primaryFileId}-page-1`) || primaryFileRecord.thumbnailUrl || null, - rotation: 0, - selected: false, - splitBefore: false, - }]; - } - - // Create document with determined pages - - return { - id: activeFileIds.length === 1 ? (primaryFileId ?? 'unknown') : `merged:${filesSignature}`, - name, - file: primaryFile || new File([], primaryFileRecord.name), // Create minimal File if needed - pages, - totalPages: pages.length, - destroy: () => {} // Optional cleanup function - }; - }, [filesSignature, primaryFileId, primaryFileRecord]); - - - // Display document: Use edited version if exists, otherwise original - const displayDocument = editedDocument || mergedPdfDocument; - - const [filename, setFilename] = useState(""); - - - - // Page editor state (use context for selectedPages) - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [csvInput, setCsvInput] = useState(""); - const [selectionMode, setSelectionMode] = useState(false); - - - // Export state - const [exportLoading, setExportLoading] = useState(false); - const [showExportModal, setShowExportModal] = useState(false); - const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); - const [exportSelectedOnly, setExportSelectedOnly] = useState(false); - - // Animation state - const [movingPage, setMovingPage] = useState(null); - const [isAnimating, setIsAnimating] = useState(false); - - // Undo/Redo system - const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); - - // Track whether the user has manually edited the filename to avoid auto-overwrites - const userEditedFilename = useRef(false); - - // Reset user edit flag when the active files change, so defaults can be applied for new docs - useEffect(() => { - userEditedFilename.current = false; - }, [filesSignature]); - - // Set initial filename when document changes - use stable signature - useEffect(() => { - if (userEditedFilename.current) return; // Do not overwrite user-typed filename - if (mergedPdfDocument) { - if (activeFileIds.length === 1 && primaryFileId) { - const record = selectors.getFileRecord(primaryFileId); - if (record) { - setFilename(record.name.replace(/\.pdf$/i, '')); - } - } else { - const filenames = activeFileIds - .map(id => selectors.getFileRecord(id)?.name.replace(/\.pdf$/i, '') || 'file') - .filter(Boolean); - setFilename(filenames.join('_')); - } - } - }, [mergedPdfDocument, filesSignature, primaryFileId, selectors]); - - // Handle file upload from FileUploadSelector (now using context) - const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => { - if (!uploadedFiles || uploadedFiles.length === 0) { - setStatus('No files provided'); - return; - } - - // Add files to context - await actions.addFiles(uploadedFiles); - setStatus(`Added ${uploadedFiles.length} file(s) for processing`); - }, [actions]); - - - // PageEditor no longer handles cleanup - it's centralized in FileContext - - // Simple cache-first thumbnail generation (no complex detection needed) - - // Lazy thumbnail generation - only generate when needed, with intelligent batching - const generateMissingThumbnails = useCallback(async () => { - if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) { - return; - } - - const file = selectors.getFile(primaryFileId); - if (!file) return; - - const totalPages = mergedPdfDocument.totalPages; - if (totalPages <= 1) return; // Only page 1, nothing to generate - - // For very large documents (2000+ pages), be much more conservative - const isVeryLargeDocument = totalPages > 2000; - - if (isVeryLargeDocument) { - console.log(`📸 PageEditor: Very large document (${totalPages} pages) - using minimal thumbnail generation`); - // For very large docs, only generate the next visible batch (pages 2-25) to avoid UI blocking - const pageNumbersToGenerate = []; - for (let pageNum = 2; pageNum <= Math.min(25, totalPages); pageNum++) { - const pageId = `${primaryFileId}-page-${pageNum}`; - if (!getThumbnailFromCache(pageId)) { - pageNumbersToGenerate.push(pageNum); - } - } - - if (pageNumbersToGenerate.length > 0) { - console.log(`📸 PageEditor: Generating initial batch for large doc: pages [${pageNumbersToGenerate.join(', ')}]`); - await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); - } - - // Schedule remaining thumbnails with delay to avoid blocking - setTimeout(() => { - generateRemainingThumbnailsLazily(file, primaryFileId, totalPages, 26); - }, 2000); // 2 second delay before starting background generation - - return; - } - - // For smaller documents, check which pages 2+ need thumbnails - const pageNumbersToGenerate = []; - for (let pageNum = 2; pageNum <= totalPages; pageNum++) { - const pageId = `${primaryFileId}-page-${pageNum}`; - if (!getThumbnailFromCache(pageId)) { - pageNumbersToGenerate.push(pageNum); - } - } - - if (pageNumbersToGenerate.length === 0) { - console.log(`📸 PageEditor: All pages 2+ already cached, skipping generation`); - return; - } - - console.log(`📸 PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.slice(0, 5).join(', ')}${pageNumbersToGenerate.length > 5 ? '...' : ''}]`); - await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); - }, [mergedPdfDocument, primaryFileId, activeFileIds, selectors]); - - // Helper function to generate thumbnails in batches - const generateThumbnailBatch = useCallback(async (file: File, fileId: string, pageNumbers: number[]) => { - try { - // Load PDF array buffer for Web Workers - const arrayBuffer = await file.arrayBuffer(); - - // Calculate quality scale based on file size - const scale = calculateScaleFromFileSize(selectors.getFileRecord(fileId)?.size || 0); - - // Start parallel thumbnail generation WITHOUT blocking the main thread - await generateThumbnails( - fileId, // Add fileId as first parameter - arrayBuffer, - pageNumbers, - { - scale, // Dynamic quality based on file size - quality: 0.8, - batchSize: 15, // Smaller batches per worker for smoother UI - parallelBatches: 3 // Use 3 Web Workers in parallel - }, - // Progress callback for thumbnail updates - (progress: { completed: number; total: number; thumbnails: Array<{ pageNumber: number; thumbnail: string }> }) => { - // Batch process thumbnails to reduce main thread work - requestAnimationFrame(() => { - progress.thumbnails.forEach(({ pageNumber, thumbnail }: { pageNumber: number; thumbnail: string }) => { - // Use stable fileId for cache key - const pageId = `${fileId}-page-${pageNumber}`; - addThumbnailToCache(pageId, thumbnail); - - // Don't update context state - thumbnails stay in cache only - // This eliminates per-page context rerenders - // PageThumbnail will find thumbnails via cache polling - }); - }); - } - ); - - // Removed verbose logging - only log errors - } catch (error) { - console.error('PageEditor: Thumbnail generation failed:', error); - } - }, [generateThumbnails, addThumbnailToCache, selectors]); - - // Background generation for remaining pages in very large documents - const generateRemainingThumbnailsLazily = useCallback(async (file: File, fileId: string, totalPages: number, startPage: number) => { - console.log(`📸 PageEditor: Starting background thumbnail generation from page ${startPage} to ${totalPages}`); - - // Generate in small chunks to avoid blocking - const CHUNK_SIZE = 50; - for (let start = startPage; start <= totalPages; start += CHUNK_SIZE) { - const end = Math.min(start + CHUNK_SIZE - 1, totalPages); - const chunkPageNumbers = []; - - for (let pageNum = start; pageNum <= end; pageNum++) { - const pageId = `${fileId}-page-${pageNum}`; - if (!getThumbnailFromCache(pageId)) { - chunkPageNumbers.push(pageNum); - } - } - - if (chunkPageNumbers.length > 0) { - // Background thumbnail generation in progress (removed verbose logging) - await generateThumbnailBatch(file, fileId, chunkPageNumbers); - - // Small delay between chunks to keep UI responsive - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - console.log(`📸 PageEditor: Background thumbnail generation completed for ${totalPages} pages`); - }, [getThumbnailFromCache, generateThumbnailBatch]); - - // Simple useEffect - just generate missing thumbnails when document is ready - useEffect(() => { - if (mergedPdfDocument && mergedPdfDocument.totalPages > 1) { - console.log(`📸 PageEditor: Document ready with ${mergedPdfDocument.totalPages} pages, checking for missing thumbnails`); - generateMissingThumbnails(); - } - }, [mergedPdfDocument, generateMissingThumbnails]); - - // Cleanup thumbnail generation when component unmounts - useEffect(() => { - return () => { - // Stop all PDF.js background processing on unmount - if (stopGeneration) { - stopGeneration(); - } - if (destroyThumbnails) { - destroyThumbnails(); - } - // Stop all processing services and destroy workers - enhancedPDFProcessingService.emergencyCleanup(); - fileProcessingService.emergencyCleanup(); - pdfProcessingService.clearAll(); - // Final emergency cleanup of all workers - pdfWorkerManager.emergencyCleanup(); - }; - }, [stopGeneration, destroyThumbnails]); - - // Clear selections when files change - use stable signature - useEffect(() => { - actions.setSelectedPages([]); - setCsvInput(""); - setSelectionMode(false); - }, [filesSignature, actions]); - - // Sync csvInput with selectedPageNumbers changes - useEffect(() => { - // Simply sort the page numbers and join them - const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b); - const newCsvInput = sortedPageNumbers.join(', '); - setCsvInput(newCsvInput); - }, [selectedPageNumbers]); - - - const selectAll = useCallback(() => { - if (mergedPdfDocument) { - actions.setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); - } - }, [mergedPdfDocument, actions]); - - const deselectAll = useCallback(() => actions.setSelectedPages([]), [actions]); - - const togglePage = useCallback((pageNumber: number) => { - console.log('🔄 Toggling page', pageNumber); - - - // Check if currently selected and update accordingly - const isCurrentlySelected = selectedPageNumbers.includes(pageNumber); - - - if (isCurrentlySelected) { - // Remove from selection - console.log('🔄 Removing page', pageNumber); - const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber); - actions.setSelectedPages(newSelectedPageNumbers); - } else { - // Add to selection - console.log('🔄 Adding page', pageNumber); - const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber]; - actions.setSelectedPages(newSelectedPageNumbers); - } - }, [selectedPageNumbers, actions]); - - const toggleSelectionMode = useCallback(() => { - setSelectionMode(prev => { - const newMode = !prev; - if (!newMode) { - // Clear selections when exiting selection mode - actions.setSelectedPages([]); - setCsvInput(""); - } - return newMode; - }); + // Update undo/redo state + const updateUndoRedoState = useCallback(() => { + setCanUndo(undoManagerRef.current.canUndo()); + setCanRedo(undoManagerRef.current.canRedo()); }, []); - const parseCSVInput = useCallback((csv: string) => { - if (!mergedPdfDocument) return []; + // Set up undo manager callback + useEffect(() => { + undoManagerRef.current.setStateChangeCallback(updateUndoRedoState); + // Initialize state + updateUndoRedoState(); + }, [updateUndoRedoState]); - const pageNumbers: number[] = []; - const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); + // Watch for container size changes to update split line positions + useEffect(() => { + const container = gridContainerRef.current; + if (!container) return; - ranges.forEach(range => { - if (range.includes('-')) { - const [start, end] = range.split('-').map(n => parseInt(n.trim())); - for (let i = start; i <= end && i <= mergedPdfDocument.pages.length; i++) { - if (i > 0) { - pageNumbers.push(i); - } - } - } else { - const pageNum = parseInt(range); - if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) { - pageNumbers.push(pageNum); - } - } - }); - - return pageNumbers; - }, [mergedPdfDocument]); - - const updatePagesFromCSV = useCallback(() => { - const pageNumbers = parseCSVInput(csvInput); - actions.setSelectedPages(pageNumbers); - }, [csvInput, parseCSVInput, actions]); - - - - - // Update PDF document state with edit tracking - const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { - console.log('setPdfDocument called - setting edited state'); - - - // Update local edit state for immediate visual feedback - setEditedDocument(updatedDoc); - actions.setHasUnsavedChanges(true); // Use actions from context - setHasUnsavedDraft(true); // Mark that we have unsaved draft changes - - - // Auto-save to drafts (debounced) - only if we have new changes - - // Enhanced auto-save to drafts with proper error handling - if (autoSaveTimer.current) { - clearTimeout(autoSaveTimer.current); - } - - autoSaveTimer.current = window.setTimeout(async () => { - if (hasUnsavedDraft) { - try { - await saveDraftToIndexedDB(updatedDoc); - setHasUnsavedDraft(false); // Mark draft as saved - console.log('Auto-save completed successfully'); - } catch (error) { - console.warn('Auto-save failed, will retry on next change:', error); - // Don't set hasUnsavedDraft to false so it will retry - } - } - }, 30000); // Auto-save after 30 seconds of inactivity - - - return updatedDoc; - }, [actions, hasUnsavedDraft]); - - // Enhanced draft save using centralized IndexedDB manager - const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { - const draftKey = `draft-${doc.id || 'merged'}`; - - try { - // Export the current document state as PDF bytes - const exportedFile = await pdfExportService.exportPDF(doc, []); - const pdfBytes = 'blob' in exportedFile ? await exportedFile.blob.arrayBuffer() : await exportedFile.blobs[0].arrayBuffer(); - const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean); - - // Generate thumbnail for the draft - let thumbnail: string | undefined; - try { - const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils'); - const blob = 'blob' in exportedFile ? exportedFile.blob : exportedFile.blobs[0]; - const filename = 'filename' in exportedFile ? exportedFile.filename : exportedFile.filenames[0]; - const file = new File([blob], filename, { type: 'application/pdf' }); - thumbnail = await generateThumbnailForFile(file); - } catch (error) { - console.warn('Failed to generate thumbnail for draft:', error); - } - - const draftData = { - id: draftKey, - name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`, - pdfData: pdfBytes, - size: pdfBytes.byteLength, - timestamp: Date.now(), - thumbnail, - originalFiles: originalFileNames - }; - - // Use centralized IndexedDB manager - const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - - const putRequest = store.put(draftData, draftKey); - putRequest.onsuccess = () => { - console.log('Draft auto-saved to IndexedDB'); - }; - putRequest.onerror = () => { - console.warn('Failed to put draft data:', putRequest.error); - }; - - } catch (error) { - console.warn('Failed to auto-save draft:', error); - } - }, [activeFileIds, selectors]); - - // Enhanced draft cleanup using centralized IndexedDB manager - const cleanupDraft = useCallback(async () => { - const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; - - try { - // Use centralized IndexedDB manager - const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - - const deleteRequest = store.delete(draftKey); - deleteRequest.onsuccess = () => { - console.log('Draft cleaned up successfully'); - }; - deleteRequest.onerror = () => { - console.warn('Failed to delete draft:', deleteRequest.error); - }; - - } catch (error) { - console.warn('Failed to cleanup draft:', error); - } - }, [mergedPdfDocument]); - - // Apply changes to create new processed file - const applyChanges = useCallback(async () => { - if (!editedDocument || !mergedPdfDocument) return; - - - try { - if (activeFileIds.length === 1 && primaryFileId) { - const file = selectors.getFile(primaryFileId); - if (!file) return; - - // Apply changes simplified - no complex dispatch loops - setStatus('Changes applied successfully'); - } else if (activeFileIds.length > 1) { - setStatus('Apply changes for multiple files not yet supported'); - return; - } - - // Clear edit state immediately - setEditedDocument(null); - actions.setHasUnsavedChanges(false); - setHasUnsavedDraft(false); - cleanupDraft(); - - } catch (error) { - console.error('Failed to apply changes:', error); - setStatus('Failed to apply changes'); - } - }, [editedDocument, mergedPdfDocument, activeFileIds, primaryFileId, selectors, actions, cleanupDraft]); - - const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { - if (!displayDocument || isAnimating) return; - - // In selection mode, if the dragged page is selected, move all selected pages - const pagesToMove = selectionMode && selectedPageNumbers.includes(pageNumber) - ? selectedPageNumbers.map(num => { - const page = displayDocument.pages.find(p => p.pageNumber === num); - return page?.id || ''; - }).filter(id => id) - : [displayDocument.pages.find(p => p.pageNumber === pageNumber)?.id || ''].filter(id => id); - - const originalIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNumber); - if (originalIndex === -1 || originalIndex === targetIndex) return; - - // Skip animation for large documents (500+ pages) to improve performance - const isLargeDocument = displayDocument.pages.length > 500; - - - if (isLargeDocument) { - // For large documents, just execute the command without animation - if (pagesToMove.length > 1) { - const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); - executeCommand(command); - } else { - const pageId = pagesToMove[0]; - const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); - executeCommand(command); - } - return; - } - - setIsAnimating(true); - - // For smaller documents, determine which pages might be affected by the move - const startIndex = Math.min(originalIndex, targetIndex); - const endIndex = Math.max(originalIndex, targetIndex); - const affectedPageIds = displayDocument.pages - .slice(Math.max(0, startIndex - 5), Math.min(displayDocument.pages.length, endIndex + 5)) - .map(p => p.id); - - // Only capture positions for potentially affected pages - const currentPositions = new Map(); - - - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`); - if (element) { - const rect = element.getBoundingClientRect(); - currentPositions.set(pageId, { x: rect.left, y: rect.top }); - } - }); - - // Execute the reorder command - if (pagesToMove.length > 1) { - const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); - executeCommand(command); - } else { - const pageId = pagesToMove[0]; - const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); - executeCommand(command); - } - - // Animate only the affected pages - setTimeout(() => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const newPositions = new Map(); - - // Get new positions only for affected pages - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`); - if (element) { - const rect = element.getBoundingClientRect(); - newPositions.set(pageId, { x: rect.left, y: rect.top }); - } - }); - - const elementsToAnimate: HTMLElement[] = []; - - // Apply animations only to pages that actually moved - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`) as HTMLElement; - if (!element) return; - - const currentPos = currentPositions.get(pageId); - const newPos = newPositions.get(pageId); - - if (currentPos && newPos) { - const deltaX = currentPos.x - newPos.x; - const deltaY = currentPos.y - newPos.y; - - if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { - elementsToAnimate.push(element); - - - // Apply initial transform - element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; - element.style.transition = 'none'; - - - // Force reflow - element.offsetHeight; - - - // Animate to final position - element.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; - element.style.transform = 'translate(0px, 0px)'; - } - } - }); - - // Clean up after animation (only for animated elements) - setTimeout(() => { - elementsToAnimate.forEach((element) => { - element.style.transform = ''; - element.style.transition = ''; - }); - setIsAnimating(false); - }, 300); + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setContainerDimensions({ + width: entry.contentRect.width, + height: entry.contentRect.height }); - }); - }, 10); // Small delay to allow state update - }, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]); + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Interface functions for parent component + const displayDocument = editedDocument || mergedPdfDocument; + + // Select all pages by default when document initially loads + const hasInitializedSelection = useRef(false); + useEffect(() => { + if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) { + const allPageNumbers = Array.from({ length: displayDocument.pages.length }, (_, i) => i + 1); + setSelectedPageNumbers(allPageNumbers); + setSelectionMode(true); + hasInitializedSelection.current = true; + } + }, [displayDocument, setSelectedPageNumbers, setSelectionMode]); + + // DOM-first command handlers + const handleRotatePages = useCallback((pageIds: string[], rotation: number) => { + const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); + undoManagerRef.current.executeCommand(bulkRotateCommand); + }, []); + + // Command factory functions for PageThumbnail + const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({ + execute: () => { + const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); + undoManagerRef.current.executeCommand(bulkRotateCommand); + } + }), []); + + const createDeleteCommand = useCallback((pageIds: string[]) => ({ + execute: () => { + if (!displayDocument) return; + + const pagesToDelete = pageIds.map(pageId => { + const page = displayDocument.pages.find(p => p.id === pageId); + return page?.pageNumber || 0; + }).filter(num => num > 0); + + if (pagesToDelete.length > 0) { + const deleteCommand = new DeletePagesCommand( + pagesToDelete, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers, + () => splitPositions, + setSplitPositions, + () => selectedPageNumbers + ); + undoManagerRef.current.executeCommand(deleteCommand); + } + } + }), [displayDocument, splitPositions, selectedPageNumbers]); + + const createSplitCommand = useCallback((position: number) => ({ + execute: () => { + const splitCommand = new SplitCommand( + position, + () => splitPositions, + setSplitPositions + ); + undoManagerRef.current.executeCommand(splitCommand); + } + }), [splitPositions]); + + // Command executor for PageThumbnail + const executeCommand = useCallback((command: any) => { + if (command && typeof command.execute === 'function') { + command.execute(); + } + }, []); + + + const handleUndo = useCallback(() => { + undoManagerRef.current.undo(); + }, []); + + const handleRedo = useCallback(() => { + undoManagerRef.current.redo(); + }, []); + + const handleRotate = useCallback((direction: 'left' | 'right') => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + const rotation = direction === 'left' ? -90 : 90; + const pagesToRotate = selectedPageNumbers.map(pageNum => { + const page = displayDocument.pages.find(p => p.pageNumber === pageNum); + return page?.id || ''; + }).filter(id => id); + + handleRotatePages(pagesToRotate, rotation); + }, [displayDocument, selectedPageNumbers, handleRotatePages]); + + const handleDelete = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + const deleteCommand = new DeletePagesCommand( + selectedPageNumbers, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers, + () => splitPositions, + setSplitPositions, + () => selectedPageNumbers + ); + undoManagerRef.current.executeCommand(deleteCommand); + }, [selectedPageNumbers, displayDocument, splitPositions]); + + const handleDeletePage = useCallback((pageNumber: number) => { + if (!displayDocument) return; + + const deleteCommand = new DeletePagesCommand( + [pageNumber], + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers, + () => splitPositions, + setSplitPositions, + () => selectedPageNumbers + ); + undoManagerRef.current.executeCommand(deleteCommand); + }, [displayDocument, splitPositions, selectedPageNumbers]); + + const handleSplit = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + // Convert selected page numbers to split positions (0-based indices) + const selectedPositions: number[] = []; + selectedPageNumbers.forEach(pageNum => { + const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum); + if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { + // Only allow splits before the last page + selectedPositions.push(pageIndex); + } + }); + + if (selectedPositions.length === 0) return; + + // Smart toggle logic: follow the majority, default to adding splits if equal + const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; + const noSplitsCount = selectedPositions.length - existingSplitsCount; + + // Remove splits only if majority already have splits + // If equal (50/50), default to adding splits + const shouldRemoveSplits = existingSplitsCount > noSplitsCount; + + + const newSplitPositions = new Set(splitPositions); + + if (shouldRemoveSplits) { + // Remove splits from all selected positions + selectedPositions.forEach(pos => newSplitPositions.delete(pos)); + } else { + // Add splits to all selected positions + selectedPositions.forEach(pos => newSplitPositions.add(pos)); + } + + // Create a custom command that sets the final state directly + const smartSplitCommand = { + execute: () => setSplitPositions(newSplitPositions), + undo: () => setSplitPositions(splitPositions), + description: shouldRemoveSplits + ? `Remove ${selectedPositions.length} split(s)` + : `Add ${selectedPositions.length - existingSplitsCount} split(s)` + }; + + undoManagerRef.current.executeCommand(smartSplitCommand); + }, [selectedPageNumbers, displayDocument, splitPositions, setSplitPositions]); + + const handleSplitAll = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + // Convert selected page numbers to split positions (0-based indices) + const selectedPositions: number[] = []; + selectedPageNumbers.forEach(pageNum => { + const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum); + if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { + // Only allow splits before the last page + selectedPositions.push(pageIndex); + } + }); + + if (selectedPositions.length === 0) return; + + // Smart toggle logic: follow the majority, default to adding splits if equal + const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; + const noSplitsCount = selectedPositions.length - existingSplitsCount; + + // Remove splits only if majority already have splits + // If equal (50/50), default to adding splits + const shouldRemoveSplits = existingSplitsCount > noSplitsCount; + + const newSplitPositions = new Set(splitPositions); + + if (shouldRemoveSplits) { + // Remove splits from all selected positions + selectedPositions.forEach(pos => newSplitPositions.delete(pos)); + } else { + // Add splits to all selected positions + selectedPositions.forEach(pos => newSplitPositions.add(pos)); + } + + // Create a custom command that sets the final state directly + const smartSplitCommand = { + execute: () => setSplitPositions(newSplitPositions), + undo: () => setSplitPositions(splitPositions), + description: shouldRemoveSplits + ? `Remove ${selectedPositions.length} split(s)` + : `Add ${selectedPositions.length - existingSplitsCount} split(s)` + }; + + undoManagerRef.current.executeCommand(smartSplitCommand); + }, [selectedPageNumbers, displayDocument, splitPositions, setSplitPositions]); + + const handlePageBreak = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + const pageBreakCommand = new PageBreakCommand( + selectedPageNumbers, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers + ); + undoManagerRef.current.executeCommand(pageBreakCommand); + }, [selectedPageNumbers, displayDocument]); + + const handlePageBreakAll = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + const pageBreakCommand = new PageBreakCommand( + selectedPageNumbers, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers + ); + undoManagerRef.current.executeCommand(pageBreakCommand); + }, [selectedPageNumbers, displayDocument]); + + const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => { + if (!displayDocument || files.length === 0) return; + + try { + const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage); + if (!targetPage) return; + + await actions.addFiles(files, { insertAfterPageId: targetPage.id }); + } catch (error) { + console.error('Failed to insert files:', error); + } + }, [displayDocument, actions]); + + const handleSelectAll = useCallback(() => { + if (!displayDocument) return; + const allPageNumbers = Array.from({ length: displayDocument.pages.length }, (_, i) => i + 1); + setSelectedPageNumbers(allPageNumbers); + }, [displayDocument]); + + const handleDeselectAll = useCallback(() => { + setSelectedPageNumbers([]); + }, []); + + const handleSetSelectedPages = useCallback((pageNumbers: number[]) => { + setSelectedPageNumbers(pageNumbers); + }, []); const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => { if (!displayDocument) return; - const pagesToMove = selectedPages && selectedPages.length > 1 - ? selectedPages - : [sourcePageNumber]; - - const sourceIndex = displayDocument.pages.findIndex(p => p.pageNumber === sourcePageNumber); - if (sourceIndex === -1 || sourceIndex === targetIndex) return; - - animateReorder(sourcePageNumber, targetIndex); - - const moveCount = pagesToMove.length; - setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); - }, [displayDocument, animateReorder]); - - - const handleRotate = useCallback((direction: 'left' | 'right') => { - if (!displayDocument) return; - - const rotation = direction === 'left' ? -90 : 90; - const pagesToRotate = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : displayDocument.pages.map(p => p.id); - - if (selectionMode && selectedPageNumbers.length === 0) return; - - const command = new RotatePagesCommand( - displayDocument, - setPdfDocument, - pagesToRotate, - rotation + const reorderCommand = new ReorderPagesCommand( + sourcePageNumber, + targetIndex, + selectedPages, + () => displayDocument, + setEditedDocument ); + undoManagerRef.current.executeCommand(reorderCommand); + }, [displayDocument]); - executeCommand(command); - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Rotated ${pageCount} pages ${direction}`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); + // Helper function to collect source files for multi-file export + const getSourceFiles = useCallback((): Map | null => { + const sourceFiles = new Map(); - const handleDelete = useCallback(() => { - if (!displayDocument) return; + // Always include original files + activeFileIds.forEach(fileId => { + const file = selectors.getFile(fileId); + if (file) { + sourceFiles.set(fileId, file); + } + }); - const hasSelectedPages = selectedPageNumbers.length > 0; - - const pagesToDelete = (selectionMode || hasSelectedPages) - ? selectedPageNumbers - .map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }) - .filter(id => id) - : displayDocument.pages.map(p => p.id); - - if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return; - - const command = new DeletePagesCommand( - displayDocument, - setPdfDocument, - pagesToDelete - ); - - executeCommand(command); - if (selectionMode) { - actions.setSelectedPages([]); + // Use multi-file export if we have multiple original files + const hasInsertedFiles = false; + const hasMultipleOriginalFiles = activeFileIds.length > 1; + + if (!hasInsertedFiles && !hasMultipleOriginalFiles) { + return null; // Use single-file export method } - const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Deleted ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]); - const handleSplit = useCallback(() => { - if (!displayDocument) return; + return sourceFiles.size > 0 ? sourceFiles : null; + }, [activeFileIds, selectors]); - const pagesToSplit = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : displayDocument.pages.map(p => p.id); + // Helper function to generate proper filename for exports + const getExportFilename = useCallback((): string => { + if (activeFileIds.length <= 1) { + // Single file - use original name + return displayDocument?.name || 'document.pdf'; + } - if (selectionMode && selectedPageNumbers.length === 0) return; + // Multiple files - use first file name with " (merged)" suffix + const firstFile = selectors.getFile(activeFileIds[0]); + if (firstFile) { + const baseName = firstFile.name.replace(/\.pdf$/i, ''); + return `${baseName} (merged).pdf`; + } - const command = new ToggleSplitCommand( - displayDocument, - setPdfDocument, - pagesToSplit - ); + return 'merged-document.pdf'; + }, [activeFileIds, selectors, displayDocument]); - executeCommand(command); - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Split markers toggled for ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); - - const showExportPreview = useCallback((selectedOnly: boolean = false) => { - const doc = editedDocument || mergedPdfDocument; - if (!doc) return; - - // Convert page numbers to page IDs for export service - const exportPageIds = selectedOnly - ? selectedPageNumbers.map(pageNum => { - const page = doc.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : []; - - const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly); - setExportPreview(preview); - setExportSelectedOnly(selectedOnly); - setShowExportModal(true); - }, [editedDocument, mergedPdfDocument, selectedPageNumbers]); - - const handleExport = useCallback(async (selectedOnly: boolean = false) => { - const doc = editedDocument || mergedPdfDocument; - if (!doc) return; + const onExportSelected = useCallback(async () => { + if (!displayDocument || selectedPageNumbers.length === 0) return; setExportLoading(true); try { - // Convert page numbers to page IDs for export service - const exportPageIds = selectedOnly - ? selectedPageNumbers.map(pageNum => { - const page = doc.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : []; + // Step 1: Apply DOM changes to document state first + const processedDocuments = documentManipulationService.applyDOMChangesToDocument( + mergedPdfDocument || displayDocument, // Original order + displayDocument, // Current display order (includes reordering) + splitPositions // Position-based splits + ); + // For selected pages export, we work with the first document (or single document) + const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments; - const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly); - if (errors.length > 0) { - setStatus(errors.join(', ')); - return; - } + // 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 || ''; + }).filter(id => id); - const hasSplitMarkers = doc.pages.some(page => page.splitBefore); + // Step 3: Export with pdfExportService - if (hasSplitMarkers) { - const result = await pdfExportService.exportPDF(doc, exportPageIds, { - selectedOnly, - filename, - splitDocuments: true, - appendSuffix: false - }) as { blobs: Blob[]; filenames: string[] }; + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile( + documentWithDOMState, + sourceFiles, + selectedPageIds, + { selectedOnly: true, filename: exportFilename } + ) + : await pdfExportService.exportPDF( + documentWithDOMState, + selectedPageIds, + { selectedOnly: true, filename: exportFilename } + ); - result.blobs.forEach((blob, index) => { - setTimeout(() => { - pdfExportService.downloadFile(blob, result.filenames[index]); - }, index * 500); - }); + // Step 4: Download the result + pdfExportService.downloadFile(result.blob, result.filename); - setStatus(`Exported ${result.blobs.length} split documents`); - } else { - const result = await pdfExportService.exportPDF(doc, exportPageIds, { - selectedOnly, - filename, - appendSuffix: false - }) as { blob: Blob; filename: string }; - - pdfExportService.downloadFile(result.blob, result.filename); - setStatus('PDF exported successfully'); - } + setExportLoading(false); } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Export failed'; - setStatus(errorMessage); - setStatus(errorMessage); - } finally { + console.error('Export failed:', error); setExportLoading(false); } - }, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]); + }, [displayDocument, selectedPageNumbers, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]); - const handleUndo = useCallback(() => { - if (undo()) { - setStatus('Operation undone'); - } - }, [undo]); + const onExportAll = useCallback(async () => { + if (!displayDocument) return; - const handleRedo = useCallback(() => { - if (redo()) { - setStatus('Operation redone'); + setExportLoading(true); + try { + // Step 1: Apply DOM changes to document state first + const processedDocuments = documentManipulationService.applyDOMChangesToDocument( + mergedPdfDocument || displayDocument, // Original order + displayDocument, // Current display order (includes reordering) + splitPositions // Position-based splits + ); + + // Step 2: Check if we have multiple documents (splits) or single document + if (Array.isArray(processedDocuments)) { + // Multiple documents (splits) - export as ZIP + const blobs: Blob[] = []; + const filenames: string[] = []; + + const sourceFiles = getSourceFiles(); + const baseExportFilename = getExportFilename(); + const baseName = baseExportFilename.replace(/\.pdf$/i, ''); + + for (let i = 0; i < processedDocuments.length; i++) { + const doc = processedDocuments[i]; + const partFilename = `${baseName}_part_${i + 1}.pdf`; + + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename }) + : await pdfExportService.exportPDF(doc, [], { filename: partFilename }); + 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 = baseExportFilename.replace(/\.pdf$/i, '.zip'); + + pdfExportService.downloadFile(zipBlob, zipFilename); + } else { + // Single document - regular export + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile( + processedDocuments, + sourceFiles, + [], + { selectedOnly: false, filename: exportFilename } + ) + : await pdfExportService.exportPDF( + processedDocuments, + [], + { selectedOnly: false, filename: exportFilename } + ); + + pdfExportService.downloadFile(result.blob, result.filename); + } + + setExportLoading(false); + } catch (error) { + console.error('Export failed:', error); + setExportLoading(false); } - }, [redo]); + }, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]); + + // 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) + splitPositions // Position-based splits + ); + + // 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); + + }, [displayDocument, mergedPdfDocument, splitPositions]); + const closePdf = useCallback(() => { - // Stop all PDF.js background processing immediately - if (stopGeneration) { - stopGeneration(); + actions.clearAllFiles(); + undoManagerRef.current.clear(); + setSelectedPageNumbers([]); + setSelectionMode(false); + }, [actions]); + + // Export preview function - defined after export functions to avoid circular dependency + const handleExportPreview = useCallback((selectedOnly: boolean = false) => { + if (!displayDocument) return; + + // For now, trigger the actual export directly + // In the original, this would show a preview modal first + if (selectedOnly) { + onExportSelected(); + } else { + onExportAll(); } - if (destroyThumbnails) { - destroyThumbnails(); - } - // Stop enhanced PDF processing and destroy workers - enhancedPDFProcessingService.emergencyCleanup(); - // Stop file processing service and destroy workers - fileProcessingService.emergencyCleanup(); - // Stop PDF processing service - pdfProcessingService.clearAll(); - // Emergency cleanup - destroy all PDF workers - pdfWorkerManager.emergencyCleanup(); - - // Clear files from memory only (preserves files in storage/recent files) - const allFileIds = selectors.getAllFileIds(); - actions.removeFiles(allFileIds, false); // false = don't delete from storage - actions.setSelectedPages([]); - }, [actions, selectors, stopGeneration, destroyThumbnails]); + }, [displayDocument, onExportSelected, onExportAll]); - // PageEditorControls needs onExportSelected and onExportAll - const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); - const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); - - /** - * Stable function proxy pattern to prevent infinite loops. - * - * Problem: If we include selectedPages in useEffect dependencies, every page selection - * change triggers onFunctionsReady → parent re-renders → PageEditor unmounts/remounts → infinite loop - * - * Solution: Create a stable proxy object that uses getters to access current values - * without triggering parent re-renders when values change. - */ - const pageEditorFunctionsRef = useRef({ - handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, - showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, - selectedPages: selectedPageNumbers, closePdf, - }); - - // Update ref with current values (no parent notification) - pageEditorFunctionsRef.current = { - handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, - showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, - selectedPages: selectedPageNumbers, closePdf, - }; - - // Only call onFunctionsReady once - use stable proxy for live updates + // Expose functions to parent component useEffect(() => { if (onFunctionsReady) { - const stableFunctions = { - get handleUndo() { return pageEditorFunctionsRef.current.handleUndo; }, - get handleRedo() { return pageEditorFunctionsRef.current.handleRedo; }, - get canUndo() { return pageEditorFunctionsRef.current.canUndo; }, - get canRedo() { return pageEditorFunctionsRef.current.canRedo; }, - get handleRotate() { return pageEditorFunctionsRef.current.handleRotate; }, - get handleDelete() { return pageEditorFunctionsRef.current.handleDelete; }, - get handleSplit() { return pageEditorFunctionsRef.current.handleSplit; }, - get showExportPreview() { return pageEditorFunctionsRef.current.showExportPreview; }, - get onExportSelected() { return pageEditorFunctionsRef.current.onExportSelected; }, - get onExportAll() { return pageEditorFunctionsRef.current.onExportAll; }, - get exportLoading() { return pageEditorFunctionsRef.current.exportLoading; }, - get selectionMode() { return pageEditorFunctionsRef.current.selectionMode; }, - get selectedPages() { return pageEditorFunctionsRef.current.selectedPages; }, - get closePdf() { return pageEditorFunctionsRef.current.closePdf; }, - }; - onFunctionsReady(stableFunctions); + onFunctionsReady({ + handleUndo, + handleRedo, + canUndo, + canRedo, + handleRotate, + handleDelete, + handleSplit, + handleSplitAll, + handlePageBreak, + handlePageBreakAll, + handleSelectAll, + handleDeselectAll, + handleSetSelectedPages, + showExportPreview: handleExportPreview, + onExportSelected, + onExportAll, + applyChanges, + exportLoading, + selectionMode, + selectedPages: selectedPageNumbers, + splitPositions, + totalPages: displayDocument?.pages.length || 0, + closePdf, + }); } - }, [onFunctionsReady]); - - // Show loading or empty state instead of blocking - const showLoading = !mergedPdfDocument && (globalProcessing || activeFileIds.length > 0); - const showEmpty = !mergedPdfDocument && !globalProcessing && activeFileIds.length === 0; - // Functions for global NavigationWarningModal - const handleApplyAndContinue = useCallback(async () => { - if (editedDocument) { - await applyChanges(); - } - }, [editedDocument, applyChanges]); - - const handleExportAndContinue = useCallback(async () => { - if (editedDocument) { - await applyChanges(); - await handleExport(false); - } - }, [editedDocument, applyChanges, handleExport]); - - // Enhanced draft checking using centralized IndexedDB manager - const checkForDrafts = useCallback(async () => { - if (!mergedPdfDocument) return; - - - try { - const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; - // Use centralized IndexedDB manager - const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); - - // Check if the drafts object store exists before using it - if (!db.objectStoreNames.contains('drafts')) { - console.log('📝 Drafts object store not found, skipping draft check'); - return; - } - - const transaction = db.transaction('drafts', 'readonly'); - const store = transaction.objectStore('drafts'); - const getRequest = store.get(draftKey); - - getRequest.onsuccess = () => { - const draft = getRequest.result; - if (draft && draft.timestamp) { - // Check if draft is recent (within last 24 hours) - const draftAge = Date.now() - draft.timestamp; - const twentyFourHours = 24 * 60 * 60 * 1000; - - if (draftAge < twentyFourHours) { - setFoundDraft(draft); - setShowResumeModal(true); - } - } - }; - - getRequest.onerror = () => { - console.warn('Failed to get draft:', getRequest.error); - }; - - } catch (error) { - console.warn('Draft check failed:', error); - // Don't throw - draft checking failure shouldn't break the app - } - }, [mergedPdfDocument]); - - // Resume work from draft - const resumeWork = useCallback(() => { - if (foundDraft && foundDraft.document) { - setEditedDocument(foundDraft.document); - actions.setHasUnsavedChanges(true); // Use context action - setFoundDraft(null); - setShowResumeModal(false); - setStatus('Resumed previous work'); - } - }, [foundDraft, actions]); - - // Start fresh (ignore draft) - const startFresh = useCallback(() => { - if (foundDraft) { - // Clean up the draft - cleanupDraft(); - } - setFoundDraft(null); - setShowResumeModal(false); - }, [foundDraft, cleanupDraft]); - - // Cleanup on unmount - useEffect(() => { - return () => { - - // Clear auto-save timer - if (autoSaveTimer.current) { - clearTimeout(autoSaveTimer.current); - } - - - // Note: We intentionally do NOT clean up drafts on unmount - // Drafts should persist when navigating away so users can resume later - }; - }, [hasUnsavedChanges, cleanupDraft]); - - // Check for drafts when document loads - useEffect(() => { - if (mergedPdfDocument && !editedDocument && !hasUnsavedChanges) { - // Small delay to let the component settle - setTimeout(checkForDrafts, 1000); - } - }, [mergedPdfDocument, editedDocument, hasUnsavedChanges, checkForDrafts]); - - // Global navigation intercept - listen for navigation events - useEffect(() => { - if (!hasUnsavedChanges) return; - - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - e.preventDefault(); - e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; - return 'You have unsaved changes. Are you sure you want to leave?'; - }; - - // Intercept browser navigation - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - }; - }, [hasUnsavedChanges]); + }, [ + onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll, + handlePageBreak, handlePageBreakAll, handleSelectAll, handleDeselectAll, handleSetSelectedPages, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading, + selectionMode, selectedPageNumbers, splitPositions, displayDocument?.pages.length, closePdf + ]); // Display all pages - use edited or original document const displayedPages = displayDocument?.pages || []; return ( - + - {showEmpty && ( + {!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
📄 @@ -1192,248 +675,112 @@ const PageEditor = ({
)} - {showLoading && ( + {!mergedPdfDocument && globalProcessing && ( - - - {/* Progress indicator */} - - - - Processing PDF files... - - - {Math.round(processingProgress || 0)}% - - -
-
-
- - - )} {displayDocument && ( - - {/* Enhanced Processing Status */} - {globalProcessing && processingProgress < 100 && ( - - - Processing thumbnails... - {Math.round(processingProgress || 0)}% - -
-
-
- - )} - setFilename(e.target.value)} - placeholder="Enter filename" - style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}} - /> + - ( - - )} - renderSplitMarker={(page, index) => ( -
- )} - /> + {/* Split Lines Overlay */} +
+ {Array.from(splitPositions).map((position) => { + // Match DragDropGrid's layout calculations exactly + const containerWidth = containerDimensions.width; + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; + const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; + const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + + // Calculate items per row using DragDropGrid's logic + const availableWidth = containerWidth - ITEM_GAP; // Account for first gap + const itemWithGap = ITEM_WIDTH + ITEM_GAP; + const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap)); + + // Calculate position within the grid (same as DragDropGrid) + const row = Math.floor(position / itemsPerRow); + const col = position % itemsPerRow; + + // Position split line between pages (after the current page) + // Calculate grid centering offset (same as DragDropGrid) + const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP; + const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2); + + const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2); + const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height) + + return ( +
+ ); + })} +
+ + {/* Pages Grid */} + ( + {}} + onSetMovingPage={setMovingPage} + onDeletePage={handleDeletePage} + createRotateCommand={createRotateCommand} + createDeleteCommand={createDeleteCommand} + createSplitCommand={createSplitCommand} + pdfDocument={displayDocument} + setPdfDocument={setEditedDocument} + splitPositions={splitPositions} + onInsertFiles={handleInsertFiles} + /> + )} + /> )} - {/* Modal should be outside the conditional but inside the main container */} - setShowExportModal(false)} - title="Export Preview" - > - {exportPreview && ( - - - Pages to export: - {exportPreview.pageCount} - - {exportPreview.splitCount > 1 && ( - - Split into documents: - {exportPreview.splitCount} - - )} - - - Estimated size: - {exportPreview.estimatedSize} - - - {mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && ( - - This will create multiple PDF files based on split markers. - - )} - - - - - - - )} - - - {/* Global Navigation Warning Modal */} - - - {/* Resume Work Modal */} - - - - We found unsaved changes from a previous session. Would you like to resume where you left off? - - - - {foundDraft && ( - - Last saved: {new Date(foundDraft.timestamp).toLocaleString()} - - )} - - - - - - - - - - - - {status && ( - - setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }} - > - {status} - - - )} - - {error && ( - setError(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} - > - {error} - - )} + ); }; diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx index 2b0c6ee3c..3789f715a 100644 --- a/frontend/src/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/components/pageEditor/PageEditorControls.tsx @@ -8,6 +8,10 @@ import RedoIcon from "@mui/icons-material/Redo"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; +import DeleteIcon from "@mui/icons-material/Delete"; +import CloseIcon from "@mui/icons-material/Close"; +import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak"; +import DownloadIcon from "@mui/icons-material/Download"; interface PageEditorControlsProps { // Close/Reset functions @@ -23,27 +27,81 @@ interface PageEditorControlsProps { onRotate: (direction: 'left' | 'right') => void; onDelete: () => void; onSplit: () => void; + onSplitAll: () => void; + onPageBreak: () => void; + onPageBreakAll: () => void; - // Export functions - onExportSelected: () => void; + // Export functions (moved to right rail) onExportAll: () => void; exportLoading: boolean; // Selection state selectionMode: boolean; selectedPages: number[]; + + // Split state (for tooltip logic) + splitPositions?: Set; + totalPages?: number; } const PageEditorControls = ({ + onClosePdf, onUndo, onRedo, canUndo, canRedo, onRotate, + onDelete, onSplit, + onSplitAll, + onPageBreak, + onPageBreakAll, + onExportAll, + exportLoading, selectionMode, - selectedPages + selectedPages, + splitPositions, + totalPages }: PageEditorControlsProps) => { + // Calculate split tooltip text using smart toggle logic + const getSplitTooltip = () => { + if (!splitPositions || !totalPages || selectedPages.length === 0) { + return "Split Selected"; + } + + // Convert selected pages to split positions (same logic as handleSplit) + const selectedSplitPositions = selectedPages.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1); + + if (selectedSplitPositions.length === 0) { + return "Split Selected"; + } + + // Smart toggle logic: follow the majority, default to adding splits if equal + const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length; + const noSplitsCount = selectedSplitPositions.length - existingSplitsCount; + + // Remove splits only if majority already have splits + // If equal (50/50), default to adding splits + const willRemoveSplits = existingSplitsCount > noSplitsCount; + + if (willRemoveSplits) { + return existingSplitsCount === selectedSplitPositions.length + ? "Remove All Selected Splits" + : "Remove Selected Splits"; + } else { + return existingSplitsCount === 0 + ? "Split Selected" + : "Complete Selected Splits"; + } + }; + + // Calculate page break tooltip text + const getPageBreakTooltip = () => { + return selectedPages.length > 0 + ? `Insert ${selectedPages.length} Page Break${selectedPages.length > 1 ? 's' : ''}` + : "Insert Page Breaks"; + }; + return (
- + - + @@ -96,40 +154,66 @@ const PageEditorControls = ({
{/* Page Operations */} - + onRotate('left')} - disabled={selectionMode && selectedPages.length === 0} - variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} - color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + disabled={selectedPages.length === 0} + variant="subtle" + style={{ color: 'var(--mantine-color-dimmed)' }} + radius="md" size="lg" > - + onRotate('right')} - disabled={selectionMode && selectedPages.length === 0} - variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} - color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + disabled={selectedPages.length === 0} + variant="subtle" + style={{ color: 'var(--mantine-color-dimmed)' }} + radius="md" size="lg" > - + + + + + + 0 ? "light" : "default"} - color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + disabled={selectedPages.length === 0} + variant="subtle" + style={{ color: 'var(--mantine-color-dimmed)' }} + radius="md" size="lg" > - + + + + +
); diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 7360b4dce..e988b1557 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -1,43 +1,46 @@ import React, { useCallback, useState, useEffect, useRef } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core'; +import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import RotateLeftIcon from '@mui/icons-material/RotateLeft'; import RotateRightIcon from '@mui/icons-material/RotateRight'; import DeleteIcon from '@mui/icons-material/Delete'; import ContentCutIcon from '@mui/icons-material/ContentCut'; +import AddIcon from '@mui/icons-material/Add'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { PDFPage, PDFDocument } from '../../types/pageEditor'; -import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands'; -import { Command } from '../../hooks/useUndoRedo'; -import { useFileState } from '../../contexts/FileContext'; import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; import styles from './PageEditor.module.css'; + interface PageThumbnailProps { page: PDFPage; index: number; totalPages: number; - originalFile?: File; // For lazy thumbnail generation + originalFile?: File; selectedPages: number[]; selectionMode: boolean; movingPage: number | null; isAnimating: boolean; pageRefs: React.MutableRefObject>; - onTogglePage: (pageNumber: number) => void; - onAnimateReorder: (pageNumber: number, targetIndex: number) => void; - onExecuteCommand: (command: Command) => void; - onSetStatus: (status: string) => void; - onSetMovingPage: (pageNumber: number | null) => void; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void; - RotatePagesCommand: typeof RotatePagesCommand; - DeletePagesCommand: typeof DeletePagesCommand; - ToggleSplitCommand: typeof ToggleSplitCommand; + onTogglePage: (pageNumber: number) => void; + onAnimateReorder: () => void; + onExecuteCommand: (command: { execute: () => void }) => void; + onSetStatus: (status: string) => void; + onSetMovingPage: (page: number | null) => void; + onDeletePage: (pageNumber: number) => void; + 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; + onInsertFiles?: (files: File[], insertAfterPage: number) => void; } -const PageThumbnail = React.memo(({ +const PageThumbnail: React.FC = ({ page, index, totalPages, @@ -47,83 +50,97 @@ const PageThumbnail = React.memo(({ movingPage, isAnimating, pageRefs, + onReorderPages, onTogglePage, onAnimateReorder, onExecuteCommand, onSetStatus, onSetMovingPage, - onReorderPages, - RotatePagesCommand, - DeletePagesCommand, - ToggleSplitCommand, + onDeletePage, + createRotateCommand, + createDeleteCommand, + createSplitCommand, pdfDocument, setPdfDocument, + splitPositions, + onInsertFiles, }: 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 { state, selectors } = useFileState(); + const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); + const { openFilesModal } = useFilesModalContext(); - // Update thumbnail URL when page prop changes - prevent redundant updates + // Calculate document aspect ratio from first non-blank page + const getDocumentAspectRatio = useCallback(() => { + // Find first non-blank page with a thumbnail to get aspect ratio + const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail); + if (firstRealPage?.thumbnail) { + // Try to get aspect ratio from an actual thumbnail image + // For now, default to A4 but could be enhanced to measure image dimensions + return '1 / 1.414'; // A4 ratio as fallback + } + return '1 / 1.414'; // Default A4 ratio + }, [pdfDocument.pages]); + + // Update thumbnail URL when page prop changes useEffect(() => { if (page.thumbnail && page.thumbnail !== thumbnailUrl) { - console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...'); setThumbnailUrl(page.thumbnail); } - }, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles + }, [page.thumbnail, thumbnailUrl]); - // Request thumbnail generation if not available (optimized for performance) + // Request thumbnail if missing (on-demand, virtualized approach) useEffect(() => { - if (thumbnailUrl || !originalFile) { - return; // Skip if we already have a thumbnail or no original file + let isCancelled = false; + + // If we already have a thumbnail, use it + if (page.thumbnail) { + setThumbnailUrl(page.thumbnail); + return; } - // Check cache first without async call + // Check cache first const cachedThumbnail = getThumbnailFromCache(page.id); if (cachedThumbnail) { setThumbnailUrl(cachedThumbnail); return; } - let cancelled = false; + // Request thumbnail generation if we have the original file + if (originalFile) { + const pageNumber = page.originalPageNumber; - const loadThumbnail = async () => { - try { - const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber); - - // Only update if component is still mounted and we got a result - if (!cancelled && thumbnail) { - setThumbnailUrl(thumbnail); - } - } catch (error) { - if (!cancelled) { - console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error); - } - } - }; + requestThumbnail(page.id, originalFile, pageNumber) + .then(thumbnail => { + if (!isCancelled && thumbnail) { + setThumbnailUrl(thumbnail); + } + }) + .catch(error => { + console.warn(`Failed to generate thumbnail for ${page.id}:`, error); + }); + } - loadThumbnail(); - - // Cleanup function to prevent state updates after unmount return () => { - cancelled = true; + isCancelled = true; }; - }, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops - + }, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]); const pageElementRef = useCallback((element: HTMLDivElement | null) => { if (element) { pageRefs.current.set(page.id, element); dragElementRef.current = element; - + const dragCleanup = draggable({ element, getInitialData: () => ({ pageNumber: page.pageNumber, pageId: page.id, - selectedPages: selectionMode && selectedPages.includes(page.pageNumber) - ? selectedPages + selectedPages: selectionMode && selectedPages.includes(page.pageNumber) + ? selectedPages : [page.pageNumber] }), onDragStart: () => { @@ -131,14 +148,14 @@ const PageThumbnail = React.memo(({ }, onDrop: ({ location }) => { setIsDragging(false); - + if (location.current.dropTargets.length === 0) { return; } - + const dropTarget = location.current.dropTargets[0]; const targetData = dropTarget.data; - + if (targetData.type === 'page') { const targetPageNumber = targetData.pageNumber as number; const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); @@ -146,6 +163,8 @@ const PageThumbnail = React.memo(({ const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber) ? selectedPages : undefined; + // Trigger animation for drag & drop + onAnimateReorder(); onReorderPages(page.pageNumber, targetIndex, pagesToMove); } } @@ -153,8 +172,7 @@ const PageThumbnail = React.memo(({ }); element.style.cursor = 'grab'; - - + const dropCleanup = dropTargetForElements({ element, getData: () => ({ @@ -163,7 +181,7 @@ const PageThumbnail = React.memo(({ }), onDrop: ({ source }) => {} }); - + (element as any).__dragCleanup = () => { dragCleanup(); dropCleanup(); @@ -176,15 +194,103 @@ const PageThumbnail = React.memo(({ } }, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]); + // DOM command handlers + const handleRotateLeft = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + // Use the command system for undo/redo support + const command = createRotateCommand([page.id], -90); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} left`); + }, [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 = createRotateCommand([page.id], 90); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} right`); + }, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]); + + const handleDelete = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onDeletePage(page.pageNumber); + onSetStatus(`Deleted page ${page.pageNumber}`); + }, [page.pageNumber, onDeletePage, onSetStatus]); + + const handleSplit = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + // Create a command to toggle split at this position + 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, createSplitCommand]); + + const handleInsertFileAfter = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + if (onInsertFiles) { + // Open file manager modal with custom handler for page insertion + openFilesModal({ + insertAfterPage: page.pageNumber, + customHandler: (files: File[], insertAfterPage?: number) => { + if (insertAfterPage !== undefined) { + onInsertFiles(files, insertAfterPage); + } + } + }); + onSetStatus(`Select files to insert after page ${page.pageNumber}`); + } else { + // Fallback to normal file handling + openFilesModal({ insertAfterPage: page.pageNumber }); + onSetStatus(`Select files to insert after page ${page.pageNumber}`); + } + }, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]); + + // Handle click vs drag differentiation + const handleMouseDown = useCallback((e: React.MouseEvent) => { + setIsMouseDown(true); + setMouseStartPos({ x: e.clientX, y: e.clientY }); + }, []); + + const handleMouseUp = useCallback((e: React.MouseEvent) => { + if (!isMouseDown || !mouseStartPos) { + setIsMouseDown(false); + setMouseStartPos(null); + return; + } + + // Calculate distance moved + const deltaX = Math.abs(e.clientX - mouseStartPos.x); + const deltaY = Math.abs(e.clientY - mouseStartPos.y); + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // If mouse moved less than 5 pixels, consider it a click (not a drag) + if (distance < 5 && !isDragging) { + onTogglePage(page.pageNumber); + } + + setIsMouseDown(false); + setMouseStartPos(null); + }, [isMouseDown, mouseStartPos, isDragging, page.pageNumber, onTogglePage]); + + const handleMouseLeave = useCallback(() => { + setIsMouseDown(false); + setMouseStartPos(null); + }, []); return (
{
e.stopPropagation()} + onMouseDown={(e) => { + e.stopPropagation(); + onTogglePage(page.pageNumber); + }} + onMouseUp={(e) => e.stopPropagation()} onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }} - onClick={(e) => { - e.stopPropagation(); - onTogglePage(page.pageNumber); - }} > { - // onChange is handled by the parent div click + // Selection is handled by container mouseDown }} size="sm" + style={{ pointerEvents: 'none' }} />
} @@ -254,7 +363,23 @@ const PageThumbnail = React.memo(({ justifyContent: 'center' }} > - {thumbnailUrl ? ( + {page.isBlankPage ? ( +
+
+
+ ) : thumbnailUrl ? ( {`Page e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > { e.stopPropagation(); if (index > 0 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); - onAnimateReorder(page.pageNumber, index - 1); - setTimeout(() => onSetMovingPage(null), 500); + // Trigger animation + onAnimateReorder(); + // Actually move the page left (swap with previous page) + onReorderPages(page.pageNumber, index - 1); + setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} left`); } }} @@ -339,14 +471,17 @@ const PageThumbnail = React.memo(({ { e.stopPropagation(); if (index < totalPages - 1 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); - onAnimateReorder(page.pageNumber, index + 1); - setTimeout(() => onSetMovingPage(null), 500); + // Trigger animation + onAnimateReorder(); + // Actually move the page right (swap with next page) + onReorderPages(page.pageNumber, index + 1); + setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} right`); } }} @@ -359,18 +494,8 @@ const PageThumbnail = React.memo(({ { - e.stopPropagation(); - const command = new RotatePagesCommand( - pdfDocument, - setPdfDocument, - [page.id], - -90 - ); - onExecuteCommand(command); - onSetStatus(`Rotated page ${page.pageNumber} left`); - }} + style={{ color: 'var(--mantine-color-dimmed)' }} + onClick={handleRotateLeft} > @@ -380,18 +505,8 @@ const PageThumbnail = React.memo(({ { - e.stopPropagation(); - const command = new RotatePagesCommand( - pdfDocument, - setPdfDocument, - [page.id], - 90 - ); - onExecuteCommand(command); - onSetStatus(`Rotated page ${page.pageNumber} right`); - }} + style={{ color: 'var(--mantine-color-dimmed)' }} + onClick={handleRotateRight} > @@ -402,66 +517,41 @@ const PageThumbnail = React.memo(({ size="md" variant="subtle" c="red" - onClick={(e) => { - e.stopPropagation(); - const command = new DeletePagesCommand( - pdfDocument, - setPdfDocument, - [page.id] - ); - onExecuteCommand(command); - onSetStatus(`Deleted page ${page.pageNumber}`); - }} + onClick={handleDelete} > - {index > 0 && ( - + {index < totalPages - 1 && ( + { - e.stopPropagation(); - const command = new ToggleSplitCommand( - pdfDocument, - setPdfDocument, - [page.id] - ); - onExecuteCommand(command); - onSetStatus(`Split marker toggled for page ${page.pageNumber}`); - }} + style={{ color: 'var(--mantine-color-dimmed)' }} + onClick={handleSplit} > )} + + + + + +
+
); -}, (prevProps, nextProps) => { - // Helper for shallow array comparison - const arraysEqual = (a: number[], b: number[]) => { - return a.length === b.length && a.every((val, i) => val === b[i]); - }; - - // Only re-render if essential props change - return ( - prevProps.page.id === nextProps.page.id && - prevProps.page.pageNumber === nextProps.page.pageNumber && - prevProps.page.rotation === nextProps.page.rotation && - prevProps.page.thumbnail === nextProps.page.thumbnail && - // Shallow compare selectedPages array for better stability - (prevProps.selectedPages === nextProps.selectedPages || - arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) && - prevProps.selectionMode === nextProps.selectionMode && - prevProps.movingPage === nextProps.movingPage && - prevProps.isAnimating === nextProps.isAnimating - ); -}); +}; export default PageThumbnail; diff --git a/frontend/src/components/pageEditor/commands/pageCommands.ts b/frontend/src/components/pageEditor/commands/pageCommands.ts new file mode 100644 index 000000000..1b7cb0932 --- /dev/null +++ b/frontend/src/components/pageEditor/commands/pageCommands.ts @@ -0,0 +1,892 @@ +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, + private setSelectedPages: (pages: number[]) => 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); + + // Maintain existing selection by mapping original selected pages to their new positions + const updatedSelection: number[] = []; + this.selectedPageNumbers.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 ${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}`; + // 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-')); + 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}`; + + 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?.(); + } +} \ No newline at end of file diff --git a/frontend/src/components/pageEditor/constants.ts b/frontend/src/components/pageEditor/constants.ts new file mode 100644 index 000000000..13239d722 --- /dev/null +++ b/frontend/src/components/pageEditor/constants.ts @@ -0,0 +1,8 @@ +// Shared constants for PageEditor grid layout +export const GRID_CONSTANTS = { + ITEM_WIDTH: '20rem', // page width + ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap + ITEM_GAP: '1.5rem', // gap between items + OVERSCAN_SMALL: 4, // Overscan for normal documents + OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages) +} as const; \ 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..5a9d13f9f --- /dev/null +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -0,0 +1,176 @@ +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(' + '); + + // Build page insertion map from files with insertion positions + const insertionMap = new Map(); // insertAfterPageId -> fileIds + const originalFileIds: string[] = []; + + activeFileIds.forEach(fileId => { + const record = selectors.getFileRecord(fileId); + if (record?.insertAfterPageId !== undefined) { + if (!insertionMap.has(record.insertAfterPageId)) { + insertionMap.set(record.insertAfterPageId, []); + } + insertionMap.get(record.insertAfterPageId)!.push(fileId); + } else { + originalFileIds.push(fileId); + } + }); + + // Build pages by interleaving original pages with insertions + let pages: PDFPage[] = []; + let totalPageCount = 0; + + // Helper function to create pages from a file + const createPagesFromFile = (fileId: string, startPageNumber: number): PDFPage[] => { + const fileRecord = selectors.getFileRecord(fileId); + if (!fileRecord) { + return []; + } + + const processedFile = fileRecord.processedFile; + 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: startPageNumber + pageIndex, + 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 + filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({ + id: `${fileId}-${pageIndex + 1}`, + pageNumber: startPageNumber + pageIndex, + originalPageNumber: pageIndex + 1, + originalFileId: fileId, + rotation: 0, + thumbnail: null, + selected: false, + splitAfter: false, + })); + } + + return filePages; + }; + + // Collect all pages from original files (without renumbering yet) + const originalFilePages: PDFPage[] = []; + originalFileIds.forEach(fileId => { + const filePages = createPagesFromFile(fileId, 1); // Temporary numbering + originalFilePages.push(...filePages); + }); + + // Start with all original pages numbered sequentially + pages = originalFilePages.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); + + // Process each insertion by finding the page ID and inserting after it + for (const [insertAfterPageId, fileIds] of insertionMap.entries()) { + const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId); + + if (targetPageIndex === -1) continue; + + // Collect all pages to insert + const allNewPages: PDFPage[] = []; + fileIds.forEach(fileId => { + const insertedPages = createPagesFromFile(fileId, 1); + allNewPages.push(...insertedPages); + }); + + // Insert all new pages after the target page + pages.splice(targetPageIndex + 1, 0, ...allNewPages); + + // Renumber all pages after insertion + pages.forEach((page, index) => { + page.pageNumber = index + 1; + }); + } + + totalPageCount = pages.length; + + if (pages.length === 0) { + return null; + } + + 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 diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index eade0a066..c4b0326b9 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -45,19 +45,14 @@ export default function RightRail() { } if (currentView === 'pageEditor') { - let totalItems = 0; - fileRecords.forEach(rec => { - const pf = rec.processedFile; - if (pf) { - totalItems += (pf.totalPages as number) || (pf.pages?.length || 0); - } - }); - const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0; + // Use PageEditor's own state + const totalItems = pageEditorFunctions?.totalPages || 0; + const selectedCount = pageEditorFunctions?.selectedPages?.length || 0; return { totalItems, selectedCount }; } return { totalItems: 0, selectedCount: 0 }; - }, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]); + }, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]); const { totalItems, selectedCount } = getSelectionState(); @@ -70,19 +65,10 @@ export default function RightRail() { } if (currentView === 'pageEditor') { - let totalPages = 0; - fileRecords.forEach(rec => { - const pf = rec.processedFile; - if (pf) { - totalPages += (pf.totalPages as number) || (pf.pages?.length || 0); - } - }); - - if (totalPages > 0) { - setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1)); - } + // Use PageEditor's select all function + pageEditorFunctions?.handleSelectAll?.(); } - }, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]); + }, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]); const handleDeselectAll = useCallback(() => { if (currentView === 'fileEditor' || currentView === 'viewer') { @@ -90,9 +76,10 @@ export default function RightRail() { return; } if (currentView === 'pageEditor') { - setSelectedPages([]); + // Use PageEditor's deselect all function + pageEditorFunctions?.handleDeselectAll?.(); } - }, [currentView, setSelectedFiles, setSelectedPages]); + }, [currentView, setSelectedFiles, pageEditorFunctions]); const handleExportAll = useCallback(() => { if (currentView === 'fileEditor' || currentView === 'viewer') { @@ -151,24 +138,21 @@ export default function RightRail() { const updatePagesFromCSV = useCallback(() => { const rawPages = parseCSVInput(csvInput); - // Determine max page count from processed records - const maxPages = fileRecords.reduce((sum, rec) => { - const pf = rec.processedFile; - if (!pf) return sum; - return sum + ((pf.totalPages as number) || (pf.pages?.length || 0)); - }, 0); + // Use PageEditor's total pages for validation + const maxPages = pageEditorFunctions?.totalPages || 0; const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b); - setSelectedPages(normalized); - }, [csvInput, parseCSVInput, fileRecords, setSelectedPages]); + // Use PageEditor's function to set selected pages + pageEditorFunctions?.handleSetSelectedPages?.(normalized); + }, [csvInput, parseCSVInput, pageEditorFunctions]); - // Sync csvInput with selectedPageNumbers changes + // Sync csvInput with PageEditor's selected pages useEffect(() => { - const sortedPageNumbers = Array.isArray(selectedPageNumbers) - ? [...selectedPageNumbers].sort((a, b) => a - b) + const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPages) + ? [...pageEditorFunctions.selectedPages].sort((a, b) => a - b) : []; const newCsvInput = sortedPageNumbers.join(', '); setCsvInput(newCsvInput); - }, [selectedPageNumbers]); + }, [pageEditorFunctions?.selectedPages]); // Clear CSV input when files change (use stable signature to avoid ref churn) useEffect(() => { @@ -278,7 +262,7 @@ export default function RightRail() {
@@ -299,8 +283,8 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }} - disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)} + onClick={() => { pageEditorFunctions?.handleDelete?.(); }} + disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPages?.length || 0) === 0} aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'} > @@ -311,6 +295,26 @@ export default function RightRail() { )} + {/* Export Selected Pages - page editor only */} + {pageControlsMounted && ( + +
+
+ { pageEditorFunctions?.onExportSelected?.(); }} + disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPages?.length || 0) === 0 || pageEditorFunctions?.exportLoading} + aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'} + > + + +
+
+
+ )} + {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index 20e7c4d7a..f54f66219 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -25,7 +25,7 @@ const FileStatusIndicator = ({ {t("files.noFiles", "No files uploaded. ")}{" "} openFilesModal()} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }} > @@ -42,7 +42,7 @@ const FileStatusIndicator = ({ {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "} openFilesModal()} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }} > diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 44adf6b28..285291539 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -73,8 +73,8 @@ function FileContextInner({ }, []); // File operations using unified addFiles helper with persistence - const addRawFiles = useCallback(async (files: File[]): Promise => { - const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch, lifecycleManager); + const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise => { + const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); // Persist to IndexedDB if enabled if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx index d7183eabf..2b210ce89 100644 --- a/frontend/src/contexts/FilesModalContext.tsx +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -4,7 +4,7 @@ import { FileMetadata } from '../types/file'; interface FilesModalContextType { isFilesModalOpen: boolean; - openFilesModal: () => void; + openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void; closeFilesModal: () => void; onFileSelect: (file: File) => void; onFilesSelect: (files: File[]) => void; @@ -19,30 +19,55 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler(); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); + const [insertAfterPage, setInsertAfterPage] = useState(); + const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>(); - const openFilesModal = useCallback(() => { + const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => { + setInsertAfterPage(options?.insertAfterPage); + setCustomHandler(() => options?.customHandler); setIsFilesModalOpen(true); }, []); const closeFilesModal = useCallback(() => { setIsFilesModalOpen(false); + setInsertAfterPage(undefined); // Clear insertion position + setCustomHandler(undefined); // Clear custom handler onModalClose?.(); }, [onModalClose]); const handleFileSelect = useCallback((file: File) => { - addToActiveFiles(file); + if (customHandler) { + // Use custom handler for special cases (like page insertion) + customHandler([file], insertAfterPage); + } else { + // Use normal file handling + addToActiveFiles(file); + } closeFilesModal(); - }, [addToActiveFiles, closeFilesModal]); + }, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]); const handleFilesSelect = useCallback((files: File[]) => { - addMultipleFiles(files); + if (customHandler) { + // Use custom handler for special cases (like page insertion) + customHandler(files, insertAfterPage); + } else { + // Use normal file handling + addMultipleFiles(files); + } closeFilesModal(); - }, [addMultipleFiles, closeFilesModal]); + }, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]); const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => { - addStoredFiles(filesWithMetadata); + if (customHandler) { + // Use custom handler for special cases (like page insertion) + const files = filesWithMetadata.map(item => item.file); + customHandler(files, insertAfterPage); + } else { + // Use normal file handling + addStoredFiles(filesWithMetadata); + } closeFilesModal(); - }, [addStoredFiles, closeFilesModal]); + }, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]); const setModalCloseCallback = useCallback((callback: () => void) => { setOnModalClose(() => callback); diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 948b4f011..c8efac3db 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -84,6 +84,9 @@ interface AddFileOptions { // For 'stored' files filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>; + + // Insertion position + insertAfterPageId?: string; } /** @@ -164,6 +167,11 @@ export async function addFiles( } } + // Store insertion position if provided + if (options.insertAfterPageId !== undefined) { + record.insertAfterPageId = options.insertAfterPageId; + } + // Create initial processedFile metadata with page count if (pageCount > 0) { record.processedFile = createProcessedFile(pageCount, thumbnail); diff --git a/frontend/src/hooks/usePDFProcessor.ts b/frontend/src/hooks/usePDFProcessor.ts index a35b777ca..ab3b5e007 100644 --- a/frontend/src/hooks/usePDFProcessor.ts +++ b/frontend/src/hooks/usePDFProcessor.ts @@ -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 diff --git a/frontend/src/services/documentManipulationService.ts b/frontend/src/services/documentManipulationService.ts new file mode 100644 index 000000000..75cf7d27e --- /dev/null +++ b/frontend/src/services/documentManipulationService.ts @@ -0,0 +1,178 @@ +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, splitPositions?: Set): 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'); + console.log('Split positions:', splitPositions ? Array.from(splitPositions).sort() : 'none'); + + // 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 only now, splits are position-based) + let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page)); + + // Convert position-based splits to page-based splits for export + if (splitPositions && splitPositions.size > 0) { + updatedPages = updatedPages.map((page, index) => ({ + ...page, + splitAfter: splitPositions.has(index) + })); + } + + // 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 (splitPositions && splitPositions.size > 0) { + 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) { + console.log(`Found split marker at page ${page.pageNumber} (index ${index}), adding split point at ${index + 1}`); + 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); + } + + console.log('Final split points:', splitPoints); + console.log('Total pages to split:', document.pages.length); + + let startIndex = 0; + let partNumber = 1; + + for (const endIndex of splitPoints) { + const segmentPages = document.pages.slice(startIndex, endIndex); + + console.log(`Creating split document ${partNumber}: pages ${startIndex}-${endIndex-1} (${segmentPages.length} pages)`); + console.log(`Split document ${partNumber} page numbers:`, segmentPages.map(p => p.pageNumber)); + + 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.transform) { + // Parse rotation from transform property (e.g., "rotate(90deg)" -> 90) + const rotationMatch = img.style.transform.match(/rotate\((-?\d+)deg\)/); + const domRotation = rotationMatch ? parseInt(rotationMatch[1]) : 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.transform = `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(); \ No newline at end of file diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index 9345133b8..96b8b8670 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -4,20 +4,18 @@ import { PDFDocument, PDFPage } from '../types/pageEditor'; export interface ExportOptions { selectedOnly?: boolean; filename?: string; - splitDocuments?: boolean; - appendSuffix?: boolean; // when false, do not append _edited/_selected } export class PDFExportService { /** - * Export PDF document with applied operations + * Export PDF document with applied operations (single file source) */ async exportPDF( pdfDocument: PDFDocument, selectedPageIds: string[] = [], options: ExportOptions = {} - ): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> { - const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options; + ): Promise<{ blob: Blob; filename: string }> { + const { selectedOnly = false, filename } = options; try { // Determine which pages to export @@ -29,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); + const blob = await this.createSingleDocument(sourceDoc, pagesToExport); + const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false); - 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, appendSuffix); - return { blob, filename: exportFilename }; - } + 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'}`); @@ -47,28 +41,85 @@ export class PDFExportService { } /** - * Create a single PDF document with all operations applied + * Export PDF document with applied operations (multi-file source) */ - private async createSingleDocument( - sourceDoc: PDFLibDocument, + async exportPDFMultiFile( + pdfDocument: PDFDocument, + sourceFiles: Map, + selectedPageIds: string[] = [], + options: ExportOptions = {} + ): Promise<{ blob: Blob; filename: string }> { + const { selectedOnly = false, filename } = options; + + try { + // Determine which pages to export + const pagesToExport = selectedOnly && selectedPageIds.length > 0 + ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) + : pdfDocument.pages; + + if (pagesToExport.length === 0) { + throw new Error('No pages to export'); + } + + const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport); + const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false); + + return { blob, filename: exportFilename }; + } catch (error) { + console.error('Multi-file PDF export error:', error); + throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Create a PDF document from multiple source files + */ + private async createMultiSourceDocument( + sourceFiles: Map, pages: PDFPage[] ): Promise { const newDoc = await PDFLibDocument.create(); + // Load all source documents once and cache them + const loadedDocs = new Map(); + + for (const [fileId, file] of sourceFiles) { + try { + const arrayBuffer = await file.arrayBuffer(); + const doc = await PDFLibDocument.load(arrayBuffer); + loadedDocs.set(fileId, doc); + } catch (error) { + console.warn(`Failed to load source file ${fileId}:`, error); + } + } + for (const page of pages) { - // Get the original page from source document - const sourcePageIndex = this.getOriginalSourceIndex(page); + if (page.isBlankPage || page.originalPageNumber === -1) { + // Create a blank page + const blankPage = newDoc.addPage(PageSizes.A4); - if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { - // Copy the page - const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - - // Apply rotation + // Apply rotation if needed if (page.rotation !== 0) { - copiedPage.setRotation(degrees(page.rotation)); + blankPage.setRotation(degrees(page.rotation)); } + } else if (page.originalFileId && loadedDocs.has(page.originalFileId)) { + // Get the correct source document for this page + const sourceDoc = loadedDocs.get(page.originalFileId)!; + const sourcePageIndex = page.originalPageNumber - 1; - newDoc.addPage(copiedPage); + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { + // Copy the page from the correct source document + const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); + + // Apply rotation + if (page.rotation !== 0) { + copiedPage.setRotation(degrees(page.rotation)); + } + + newDoc.addPage(copiedPage); + } + } else { + console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`); } } @@ -83,103 +134,60 @@ export class PDFExportService { } /** - * Create multiple PDF documents based on split markers + * Create a single PDF document with all operations applied (single source) */ - private async createSplitDocuments( + private async createSingleDocument( sourceDoc: PDFLibDocument, - pages: PDFPage[], - baseFilename: string - ): Promise<{ blobs: Blob[]; filenames: string[] }> { - const splitPoints: number[] = []; - const blobs: Blob[] = []; - const filenames: string[] = []; + pages: PDFPage[] + ): Promise { + const newDoc = await PDFLibDocument.create(); - // Find split points - pages.forEach((page, index) => { - if (page.splitBefore && index > 0) { - splitPoints.push(index); - } - }); + for (const page of pages) { + if (page.isBlankPage || page.originalPageNumber === -1) { + // Create a blank page + const blankPage = newDoc.addPage(PageSizes.A4); - // 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 = this.getOriginalSourceIndex(page); - - 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); - } + // Apply rotation if needed + if (page.rotation !== 0) { + blankPage.setRotation(degrees(page.rotation)); } + } else { + // Get the original page from source document using originalPageNumber + const sourcePageIndex = page.originalPageNumber - 1; - // Set metadata - newDoc.setCreator('Stirling PDF'); - newDoc.setProducer('Stirling PDF'); - newDoc.setTitle(`${baseFilename} - Part ${partNumber}`); + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { + // Copy the page + const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - const pdfBytes = await newDoc.save(); - const blob = new Blob([pdfBytes], { type: 'application/pdf' }); - const filename = this.generateSplitFilename(baseFilename, partNumber); + // Apply rotation + if (page.rotation !== 0) { + copiedPage.setRotation(degrees(page.rotation)); + } - blobs.push(blob); - filenames.push(filename); - partNumber++; - } - - startIndex = endIndex; - } - - return { blobs, filenames }; - } - - /** - * Derive the original page index from a page's stable id. - * Falls back to the current pageNumber if parsing fails. - */ - private getOriginalSourceIndex(page: PDFPage): number { - const match = page.id.match(/-page-(\d+)$/); - if (match) { - const originalNumber = parseInt(match[1], 10); - if (!Number.isNaN(originalNumber)) { - return originalNumber - 1; // zero-based index for pdf-lib + newDoc.addPage(copiedPage); + } } } - // Fallback to the visible page number - return Math.max(0, page.pageNumber - 1); + + // Set metadata + newDoc.setCreator('Stirling PDF'); + newDoc.setProducer('Stirling PDF'); + newDoc.setCreationDate(new Date()); + newDoc.setModificationDate(new Date()); + + const pdfBytes = await newDoc.save(); + return new Blob([pdfBytes], { type: 'application/pdf' }); } + /** * Generate appropriate filename for export */ private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string { const baseName = originalName.replace(/\.pdf$/i, ''); - if (!appendSuffix) return `${baseName}.pdf`; - const suffix = selectedOnly ? '_selected' : '_edited'; - return `${baseName}${suffix}.pdf`; + return `${baseName}.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 @@ -203,7 +211,7 @@ export class PDFExportService { * Download multiple files as a ZIP */ async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise { - // For now, download files wherindividually + // For now, download files individually blobs.forEach((blob, index) => { setTimeout(() => { this.downloadFile(blob, filenames[index]); @@ -248,8 +256,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) diff --git a/frontend/src/services/pdfWorkerManager.ts b/frontend/src/services/pdfWorkerManager.ts index c31bc5f88..c15e2c207 100644 --- a/frontend/src/services/pdfWorkerManager.ts +++ b/frontend/src/services/pdfWorkerManager.ts @@ -12,7 +12,7 @@ class PDFWorkerManager { private static instance: PDFWorkerManager; private activeDocuments = new Set(); private workerCount = 0; - private maxWorkers = 3; // Limit concurrent workers + private maxWorkers = 10; // Limit concurrent workers private isInitialized = false; private constructor() { @@ -33,7 +33,6 @@ class PDFWorkerManager { if (!this.isInitialized) { GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; this.isInitialized = true; - console.log('🏭 PDF.js worker initialized'); } } @@ -52,7 +51,6 @@ class PDFWorkerManager { ): Promise { // Wait if we've hit the worker limit if (this.activeDocuments.size >= this.maxWorkers) { - console.warn(`🏭 PDF Worker limit reached (${this.maxWorkers}), waiting for available worker...`); await this.waitForAvailableWorker(); } @@ -89,8 +87,6 @@ class PDFWorkerManager { this.activeDocuments.add(pdf); this.workerCount++; - console.log(`🏭 PDF document created (active: ${this.activeDocuments.size}/${this.maxWorkers})`); - return pdf; } catch (error) { // If document creation fails, make sure to clean up the loading task @@ -98,7 +94,7 @@ class PDFWorkerManager { try { loadingTask.destroy(); } catch (destroyError) { - console.warn('🏭 Error destroying failed loading task:', destroyError); + // Silent cleanup failure } } throw error; @@ -114,10 +110,7 @@ class PDFWorkerManager { pdf.destroy(); this.activeDocuments.delete(pdf); this.workerCount = Math.max(0, this.workerCount - 1); - - console.log(`🏭 PDF document destroyed (active: ${this.activeDocuments.size}/${this.maxWorkers})`); } catch (error) { - console.warn('🏭 Error destroying PDF document:', error); // Still remove from tracking even if destroy failed this.activeDocuments.delete(pdf); this.workerCount = Math.max(0, this.workerCount - 1); @@ -129,8 +122,6 @@ class PDFWorkerManager { * Destroy all active PDF documents */ destroyAllDocuments(): void { - console.log(`🏭 Destroying all PDF documents (${this.activeDocuments.size} active)`); - const documentsToDestroy = Array.from(this.activeDocuments); documentsToDestroy.forEach(pdf => { this.destroyDocument(pdf); @@ -138,8 +129,6 @@ class PDFWorkerManager { this.activeDocuments.clear(); this.workerCount = 0; - - console.log('🏭 All PDF documents destroyed'); } /** @@ -173,29 +162,24 @@ class PDFWorkerManager { * Force cleanup of all workers (emergency cleanup) */ emergencyCleanup(): void { - console.warn('🏭 Emergency PDF worker cleanup initiated'); - // Force destroy all documents this.activeDocuments.forEach(pdf => { try { pdf.destroy(); } catch (error) { - console.warn('🏭 Emergency cleanup - error destroying document:', error); + // Silent cleanup } }); this.activeDocuments.clear(); this.workerCount = 0; - - console.warn('🏭 Emergency cleanup completed'); } /** * Set maximum concurrent workers */ setMaxWorkers(max: number): void { - this.maxWorkers = Math.max(1, Math.min(max, 10)); // Between 1-10 workers - console.log(`🏭 Max workers set to ${this.maxWorkers}`); + this.maxWorkers = Math.max(1, Math.min(max, 15)); // Between 1-15 workers for multi-file support } } diff --git a/frontend/src/services/thumbnailGenerationService.ts b/frontend/src/services/thumbnailGenerationService.ts index a1c221269..be508fd1a 100644 --- a/frontend/src/services/thumbnailGenerationService.ts +++ b/frontend/src/services/thumbnailGenerationService.ts @@ -40,7 +40,7 @@ export class ThumbnailGenerationService { private pdfDocumentCache = new Map(); private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached - constructor(private maxWorkers: number = 3) { + constructor(private maxWorkers: number = 10) { // PDF rendering requires DOM access, so we use optimized main thread processing } @@ -207,6 +207,11 @@ export class ThumbnailGenerationService { // Release reference to PDF document (don't destroy - keep in cache) this.releasePDFDocument(fileId); + + // Optionally clean up PDF document from cache to free workers faster + // This can be called after thumbnail generation is complete for a file + this.cleanupCompletedDocument(fileId); + return allResults; } @@ -289,6 +294,18 @@ export class ThumbnailGenerationService { } } + /** + * Clean up a PDF document from cache when thumbnail generation is complete + * This frees up workers faster for better performance + */ + cleanupCompletedDocument(fileId: string): void { + const cached = this.pdfDocumentCache.get(fileId); + if (cached && cached.refCount <= 0) { + pdfWorkerManager.destroyDocument(cached.pdf); + this.pdfDocumentCache.delete(fileId); + } + } + destroy(): void { this.clearCache(); this.clearPDFCache(); diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 0425031c5..653909568 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -55,6 +55,7 @@ export interface FileRecord { blobUrl?: string; createdAt?: number; processedFile?: ProcessedFileMetadata; + insertAfterPageId?: string; // Page ID after which this file should be inserted isPinned?: boolean; // Note: File object stored in provider ref, not in state } @@ -216,7 +217,7 @@ export type FileContextAction = export interface FileContextActions { // File management - lightweight actions only - addFiles: (files: File[]) => Promise; + addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; diff --git a/frontend/src/types/pageEditor.ts b/frontend/src/types/pageEditor.ts index 6ad4318f7..21f803021 100644 --- a/frontend/src/types/pageEditor.ts +++ b/frontend/src/types/pageEditor.ts @@ -1,10 +1,13 @@ export interface PDFPage { id: string; pageNumber: number; + originalPageNumber: number; thumbnail: string | null; rotation: number; selected: boolean; - splitBefore?: boolean; + splitAfter?: boolean; + isBlankPage?: boolean; + originalFileId?: string; } export interface PDFDocument { @@ -47,9 +50,17 @@ export interface PageEditorFunctions { handleRotate: (direction: 'left' | 'right') => void; handleDelete: () => void; handleSplit: () => void; + handleSplitAll: () => void; + handlePageBreak: () => void; + handlePageBreakAll: () => void; + handleSelectAll: () => void; + handleDeselectAll: () => void; + handleSetSelectedPages: (pageNumbers: number[]) => void; onExportSelected: () => void; onExportAll: () => void; exportLoading: boolean; selectionMode: boolean; selectedPages: number[]; + splitPositions: Set; + totalPages: number; }