diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index b24fb819a..ee77d142d 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -5,6 +5,8 @@ import { useNavigationGuard } from "../../contexts/NavigationContext"; import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor"; import { pdfExportService } from "../../services/pdfExportService"; import { documentManipulationService } from "../../services/documentManipulationService"; +import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers"; +import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers"; // Thumbnail generation is now handled by individual PageThumbnail components import './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; @@ -524,66 +526,38 @@ const PageEditor = ({ 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 + mergedPdfDocument || displayDocument, + displayDocument, + splitPositions ); - // 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[] = []; + // Step 2: Export to files + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename); - 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 + // Step 3: Download + if (files.length > 1) { + // Multiple files - create ZIP const JSZip = await import('jszip'); const zip = new JSZip.default(); - blobs.forEach((blob, index) => { - zip.file(filenames[index], blob); + files.forEach((file) => { + zip.file(file.name, file); }); const zipBlob = await zip.generateAsync({ type: 'blob' }); - const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip'); + const exportFilename = getExportFilename(); + const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip'); pdfExportService.downloadFile(zipBlob, zipFilename); - setHasUnsavedChanges(false); // Clear unsaved changes after successful export } 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); - setHasUnsavedChanges(false); // Clear unsaved changes after successful export + // Single file - download directly + const file = files[0]; + pdfExportService.downloadFile(file, file.name); } + setHasUnsavedChanges(false); setExportLoading(false); } catch (error) { console.error('Export failed:', error); @@ -592,21 +566,39 @@ const PageEditor = ({ }, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]); // Apply DOM changes to document state using dedicated service - const applyChanges = useCallback(() => { + const applyChanges = useCallback(async () => { 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 - ); + setExportLoading(true); + try { + // Step 1: Apply DOM changes to document state first + const processedDocuments = documentManipulationService.applyDOMChangesToDocument( + mergedPdfDocument || displayDocument, + displayDocument, + splitPositions + ); - // 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); + // Step 2: Export to files + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename); - }, [displayDocument, mergedPdfDocument, splitPositions]); + // Step 3: Create StirlingFiles and stubs for version history + const parentStub = selectors.getStirlingFileStub(activeFileIds[0]); + if (!parentStub) throw new Error('Parent stub not found'); + + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool'); + + // Step 4: Consume files (replace in context) + await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); + + setHasUnsavedChanges(false); + setExportLoading(false); + } catch (error) { + console.error('Apply changes failed:', error); + setExportLoading(false); + } + }, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]); const closePdf = useCallback(() => { @@ -793,7 +785,7 @@ const PageEditor = ({ { - applyChanges(); + await applyChanges(); }} onExportAndContinue={async () => { await onExportAll(); diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 6abda0c78..59e5819d9 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -375,6 +375,7 @@ const PageThumbnail: React.FC = ({ src={thumbnailUrl} alt={`Page ${page.pageNumber}`} draggable={false} + data-original-rotation={page.rotation} style={{ width: '100%', height: '100%', diff --git a/frontend/src/components/pageEditor/commands/pageCommands.ts b/frontend/src/components/pageEditor/commands/pageCommands.ts index 26cb9e09c..7ac6a8377 100644 --- a/frontend/src/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/components/pageEditor/commands/pageCommands.ts @@ -17,32 +17,34 @@ export class RotatePageCommand extends DOMCommand { } 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; + let newRotation = currentRotation + this.degrees; + + newRotation = ((newRotation % 360) + 360) % 360; + 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; + let previousRotation = currentRotation - this.degrees; + + previousRotation = ((previousRotation % 360) + 360) % 360; + img.style.transform = `rotate(${previousRotation}deg)`; } } diff --git a/frontend/src/components/shared/NavigationWarningModal.tsx b/frontend/src/components/shared/NavigationWarningModal.tsx index 203e66ff7..b1b935738 100644 --- a/frontend/src/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/components/shared/NavigationWarningModal.tsx @@ -8,7 +8,7 @@ interface NavigationWarningModalProps { } const NavigationWarningModal = ({ - onApplyAndContinue: _onApplyAndContinue, + onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => { @@ -30,6 +30,13 @@ const NavigationWarningModal = ({ confirmNavigation(); }; + const handleApplyAndContinue = async () => { + if (onApplyAndContinue) { + await onApplyAndContinue(); + } + setHasUnsavedChanges(false); + confirmNavigation(); + }; const handleExportAndContinue = async () => { if (onExportAndContinue) { @@ -49,26 +56,25 @@ const NavigationWarningModal = ({ onClose={handleKeepWorking} title={t("unsavedChangesTitle", "Unsaved Changes")} centered - size="lg" + size="xl" closeOnClickOutside={false} closeOnEscape={false} > - - + + {t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")} + + + - - - - + - {/* TODO:: Add this back in when it works */} - {/* {_onApplyAndContinue && ( + + {onExportAndContinue && ( + + )} + + {onApplyAndContinue && ( - )} */} - - {onExportAndContinue && ( - )} diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index f8a7102fa..f8c6d0e4f 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -1,16 +1,18 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Box, Center, Text, ActionIcon } from '@mantine/core'; import { useMantineTheme, useMantineColorScheme } from '@mantine/core'; import CloseIcon from '@mui/icons-material/Close'; -import { useFileState } from "../../contexts/FileContext"; +import { useFileState, useFileActions } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { useViewer } from "../../contexts/ViewerContext"; import { LocalEmbedPDF } from './LocalEmbedPDF'; import { PdfViewerToolbar } from './PdfViewerToolbar'; import { ThumbnailSidebar } from './ThumbnailSidebar'; -import { useNavigationState } from '../../contexts/NavigationContext'; +import { useNavigationGuard, useNavigationState } from '../../contexts/NavigationContext'; import { useSignature } from '../../contexts/SignatureContext'; +import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers'; +import NavigationWarningModal from '../shared/NavigationWarningModal'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -29,11 +31,33 @@ const EmbedPdfViewerContent = ({ const { colorScheme: _colorScheme } = useMantineColorScheme(); const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer(); + + const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); const scrollState = getScrollState(); const zoomState = getZoomState(); const spreadState = getSpreadState(); + const rotationState = getRotationState(); + + // Track initial rotation to detect changes + const initialRotationRef = useRef(null); + useEffect(() => { + if (initialRotationRef.current === null && rotationState.rotation !== undefined) { + initialRotationRef.current = rotationState.rotation; + } + }, [rotationState.rotation]); + + // Get signature context + const { signatureApiRef, historyApiRef } = useSignature(); + + // Get current file from FileContext + const { selectors } = useFileState(); + const { actions } = useFileActions(); + const activeFiles = selectors.getFiles(); + const activeFileIds = activeFiles.map(f => f.fileId); + + // Navigation guard for unsaved changes + const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); // Check if we're in signature mode OR viewer annotation mode const { selectedTool } = useNavigationState(); @@ -42,13 +66,6 @@ const EmbedPdfViewerContent = ({ // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; - // Get signature context - const { signatureApiRef, historyApiRef } = useSignature(); - - // Get current file from FileContext - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); - // Determine which file to display const currentFile = React.useMemo(() => { if (previewFile) { @@ -134,6 +151,65 @@ const EmbedPdfViewerContent = ({ }; }, [isViewerHovered]); + // Register checker for unsaved changes (annotations only for now) + useEffect(() => { + if (previewFile) { + return; + } + + const checkForChanges = () => { + // Check for annotation changes via history + const hasAnnotationChanges = historyApiRef.current?.canUndo() || false; + + console.log('[Viewer] Checking for unsaved changes:', { + hasAnnotationChanges + }); + return hasAnnotationChanges; + }; + + console.log('[Viewer] Registering unsaved changes checker'); + registerUnsavedChangesChecker(checkForChanges); + + return () => { + console.log('[Viewer] Unregistering unsaved changes checker'); + unregisterUnsavedChangesChecker(); + }; + }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); + + // Apply changes - save annotations to new file version + const applyChanges = useCallback(async () => { + if (!currentFile || activeFileIds.length === 0) return; + + try { + console.log('[Viewer] Applying changes - exporting PDF with annotations'); + + // Step 1: Export PDF with annotations using EmbedPDF + const arrayBuffer = await exportActions.saveAsCopy(); + if (!arrayBuffer) { + throw new Error('Failed to export PDF'); + } + + console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength); + + // Step 2: Convert ArrayBuffer to File + const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); + const filename = currentFile.name || 'document.pdf'; + const file = new File([blob], filename, { type: 'application/pdf' }); + + // Step 3: Create StirlingFiles and stubs for version history + const parentStub = selectors.getStirlingFileStub(activeFileIds[0]); + if (!parentStub) throw new Error('Parent stub not found'); + + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool'); + + // Step 4: Consume files (replace in context) + await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); + + setHasUnsavedChanges(false); + } catch (error) { + console.error('Apply changes failed:', error); + } + }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); return ( + + {/* Navigation Warning Modal */} + {!previewFile && ( + { + await applyChanges(); + }} + /> + )} ); }; diff --git a/frontend/src/components/viewer/SignatureAPIBridge.tsx b/frontend/src/components/viewer/SignatureAPIBridge.tsx index 59fbe43e6..6ff048575 100644 --- a/frontend/src/components/viewer/SignatureAPIBridge.tsx +++ b/frontend/src/components/viewer/SignatureAPIBridge.tsx @@ -28,7 +28,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI useEffect(() => { if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return; - const handleKeyDown = (event: KeyboardEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Delete' || event.key === 'Backspace') { const selectedAnnotation = annotationApi.getSelectedAnnotation?.(); diff --git a/frontend/src/contexts/NavigationContext.tsx b/frontend/src/contexts/NavigationContext.tsx index b71664b6b..3d9b4849c 100644 --- a/frontend/src/contexts/NavigationContext.tsx +++ b/frontend/src/contexts/NavigationContext.tsx @@ -74,6 +74,8 @@ export interface NavigationContextActions { setSelectedTool: (toolId: ToolId | null) => void; setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; + registerUnsavedChangesChecker: (checker: () => boolean) => void; + unregisterUnsavedChangesChecker: () => void; showNavigationWarning: (show: boolean) => void; requestNavigation: (navigationFn: () => void) => void; confirmNavigation: () => void; @@ -106,11 +108,29 @@ export const NavigationProvider: React.FC<{ }> = ({ children }) => { const [state, dispatch] = useReducer(navigationReducer, initialState); const toolRegistry = useFlatToolRegistry(); + const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null); const actions: NavigationContextActions = { setWorkbench: useCallback((workbench: WorkbenchType) => { - // If we're leaving pageEditor workbench and have unsaved changes, request navigation - if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) { + // Check for unsaved changes using registered checker or state + const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; + console.log('[NavigationContext] setWorkbench:', { + from: state.workbench, + to: workbench, + hasChecker: !!unsavedChangesCheckerRef.current, + hasUnsavedChanges + }); + + // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + const leavingWorkbenchWithChanges = + (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + + if (leavingWorkbenchWithChanges) { + // Update state to reflect unsaved changes so modal knows + if (!state.hasUnsavedChanges) { + dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } }); + } const performWorkbenchChange = () => { dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); }; @@ -126,8 +146,15 @@ export const NavigationProvider: React.FC<{ }, []), setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => { - // If we're leaving pageEditor workbench and have unsaved changes, request navigation - if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) { + // Check for unsaved changes using registered checker or state + const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; + + // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + const leavingWorkbenchWithChanges = + (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + + if (leavingWorkbenchWithChanges) { const performWorkbenchChange = () => { dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } }); }; @@ -142,6 +169,14 @@ export const NavigationProvider: React.FC<{ dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []), + registerUnsavedChangesChecker: useCallback((checker: () => boolean) => { + unsavedChangesCheckerRef.current = checker; + }, []), + + unregisterUnsavedChangesChecker: useCallback(() => { + unsavedChangesCheckerRef.current = null; + }, []), + showNavigationWarning: useCallback((show: boolean) => { dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } }); }, []), @@ -254,6 +289,8 @@ export const useNavigationGuard = () => { confirmNavigation: actions.confirmNavigation, cancelNavigation: actions.cancelNavigation, setHasUnsavedChanges: actions.setHasUnsavedChanges, - setShowNavigationWarning: actions.showNavigationWarning + setShowNavigationWarning: actions.showNavigationWarning, + registerUnsavedChangesChecker: actions.registerUnsavedChangesChecker, + unregisterUnsavedChangesChecker: actions.unregisterUnsavedChangesChecker }; }; diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 1c817132a..3f3ec07c7 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -57,13 +57,13 @@ const addFilesMutex = new SimpleMutex(); /** * Helper to create ProcessedFile metadata structure */ -export function createProcessedFile(pageCount: number, thumbnail?: string) { +export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) { return { totalPages: pageCount, pages: Array.from({ length: pageCount }, (_, index) => ({ pageNumber: index + 1, thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially - rotation: 0, + rotation: pageRotations?.[index] ?? 0, splitBefore: false })), thumbnailUrl: thumbnail, @@ -82,8 +82,22 @@ export async function generateProcessedFileMetadata(file: File): Promise): 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)); @@ -57,32 +51,25 @@ export class DocumentManipulationService { private createSplitDocuments(document: PDFDocument): PDFDocument[] { const documents: PDFDocument[] = []; const splitPoints: number[] = []; - + // Find split points 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, @@ -93,11 +80,10 @@ export class DocumentManipulationService { }); partNumber++; } - + startIndex = endIndex; } - - console.log(`Created ${documents.length} split documents`); + return documents; } @@ -108,7 +94,6 @@ export class DocumentManipulationService { // 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; } @@ -116,8 +101,7 @@ export class DocumentManipulationService { // Apply rotation changes from DOM updatedPage.rotation = this.getRotationFromDOM(pageElement, page); - - + return updatedPage; } @@ -126,16 +110,21 @@ export class DocumentManipulationService { */ 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; + if (img) { + const originalRotation = parseInt(img.getAttribute('data-original-rotation') || '0'); + + const currentTransform = img.style.transform || ''; + const rotationMatch = currentTransform.match(/rotate\((-?\d+)deg\)/); + const visualRotation = rotationMatch ? parseInt(rotationMatch[1]) : originalRotation; + + const userChange = ((visualRotation - originalRotation) % 360 + 360) % 360; + + let finalRotation = (originalPage.rotation + userChange) % 360; + if (finalRotation === 360) finalRotation = 0; + + return finalRotation; } - - console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`); + return originalPage.rotation; } diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index bee6e200a..be58d0a6e 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -200,11 +200,13 @@ export class EnhancedPDFProcessingService { const page = await pdf.getPage(i); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, - rotation: 0, + rotation, selected: false }); @@ -254,7 +256,7 @@ export class EnhancedPDFProcessingService { id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, - rotation: 0, + rotation: page.rotate || 0, selected: false }); @@ -265,11 +267,15 @@ export class EnhancedPDFProcessingService { // Create placeholder pages for remaining pages for (let i = priorityCount + 1; i <= totalPages; i++) { + // Load page just to get rotation + const page = await pdf.getPage(i); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, // Will be loaded lazily - rotation: 0, + rotation, selected: false }); } @@ -316,7 +322,7 @@ export class EnhancedPDFProcessingService { id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, - rotation: 0, + rotation: page.rotate || 0, selected: false }); @@ -333,11 +339,15 @@ export class EnhancedPDFProcessingService { // Create placeholders for remaining pages for (let i = firstChunkEnd + 1; i <= totalPages; i++) { + // Load page just to get rotation + const page = await pdf.getPage(i); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, - rotation: 0, + rotation, selected: false }); } @@ -367,11 +377,15 @@ export class EnhancedPDFProcessingService { // Create placeholder pages without thumbnails const pages: PDFPage[] = []; for (let i = 1; i <= totalPages; i++) { + // Load page just to get rotation + const page = await pdf.getPage(i); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, - rotation: 0, + rotation, selected: false }); } @@ -390,7 +404,7 @@ export class EnhancedPDFProcessingService { const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor const scale = scales[quality]; - const viewport = page.getViewport({ scale }); + const viewport = page.getViewport({ scale, rotation: 0 }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; diff --git a/frontend/src/services/fileStubHelpers.ts b/frontend/src/services/fileStubHelpers.ts new file mode 100644 index 000000000..5f5545e76 --- /dev/null +++ b/frontend/src/services/fileStubHelpers.ts @@ -0,0 +1,34 @@ +import { StirlingFile, StirlingFileStub } from '../types/fileContext'; +import { createChildStub, generateProcessedFileMetadata } from '../contexts/file/fileActions'; +import { createStirlingFile } from '../types/fileContext'; +import { ToolId } from '../types/toolId'; + +/** + * Create StirlingFiles and StirlingFileStubs from exported files + * Used when saving page editor changes to create version history + */ +export async function createStirlingFilesAndStubs( + files: File[], + parentStub: StirlingFileStub, + toolId: ToolId +): Promise<{ stirlingFiles: StirlingFile[], stubs: StirlingFileStub[] }> { + const stirlingFiles: StirlingFile[] = []; + const stubs: StirlingFileStub[] = []; + + for (const file of files) { + const processedFileMetadata = await generateProcessedFileMetadata(file); + const childStub = createChildStub( + parentStub, + { toolId, timestamp: Date.now() }, + file, + processedFileMetadata?.thumbnailUrl, + processedFileMetadata + ); + + const stirlingFile = createStirlingFile(file, childStub.id); + stirlingFiles.push(stirlingFile); + stubs.push(childStub); + } + + return { stirlingFiles, stubs }; +} diff --git a/frontend/src/services/pdfExportHelpers.ts b/frontend/src/services/pdfExportHelpers.ts new file mode 100644 index 000000000..fa6d4775f --- /dev/null +++ b/frontend/src/services/pdfExportHelpers.ts @@ -0,0 +1,46 @@ +import { PDFDocument } from '../types/pageEditor'; +import { pdfExportService } from './pdfExportService'; +import { FileId } from '../types/file'; + +/** + * Export processed documents to File objects + * Handles both single documents and split documents (multiple PDFs) + */ +export async function exportProcessedDocumentsToFiles( + processedDocuments: PDFDocument | PDFDocument[], + sourceFiles: Map | null, + exportFilename: string +): Promise { + console.log('exportProcessedDocumentsToFiles called with:', { + isArray: Array.isArray(processedDocuments), + numDocs: Array.isArray(processedDocuments) ? processedDocuments.length : 1, + hasSourceFiles: sourceFiles !== null, + sourceFilesSize: sourceFiles?.size + }); + + if (Array.isArray(processedDocuments)) { + // Multiple documents (splits) + const files: File[] = []; + const baseName = exportFilename.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, [], { selectedOnly: false, filename: partFilename }) + : await pdfExportService.exportPDF(doc, [], { selectedOnly: false, filename: partFilename }); + + files.push(new File([result.blob], result.filename, { type: 'application/pdf' })); + } + + return files; + } else { + // Single document + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile(processedDocuments, sourceFiles, [], { selectedOnly: false, filename: exportFilename }) + : await pdfExportService.exportPDF(processedDocuments, [], { selectedOnly: false, filename: exportFilename }); + + return [new File([result.blob], result.filename, { type: 'application/pdf' })]; + } +} diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index 42ad672d2..d42eeeab2 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -98,10 +98,7 @@ export class PDFExportService { // Create a blank page const blankPage = newDoc.addPage(PageSizes.A4); - // Apply rotation if needed - if (page.rotation !== 0) { - blankPage.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)!; @@ -111,10 +108,7 @@ export class PDFExportService { // 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)); - } + copiedPage.setRotation(degrees(page.rotation)); newDoc.addPage(copiedPage); } @@ -147,10 +141,7 @@ export class PDFExportService { // Create a blank page const blankPage = newDoc.addPage(PageSizes.A4); - // Apply rotation if needed - if (page.rotation !== 0) { - blankPage.setRotation(degrees(page.rotation)); - } + blankPage.setRotation(degrees(page.rotation)); } else { // Get the original page from source document using originalPageNumber const sourcePageIndex = page.originalPageNumber - 1; @@ -159,10 +150,7 @@ export class PDFExportService { // Copy the page const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - // Apply rotation - if (page.rotation !== 0) { - copiedPage.setRotation(degrees(page.rotation)); - } + copiedPage.setRotation(degrees(page.rotation)); newDoc.addPage(copiedPage); } diff --git a/frontend/src/services/thumbnailGenerationService.ts b/frontend/src/services/thumbnailGenerationService.ts index fa0f17bdd..2d76a623f 100644 --- a/frontend/src/services/thumbnailGenerationService.ts +++ b/frontend/src/services/thumbnailGenerationService.ts @@ -164,7 +164,7 @@ export class ThumbnailGenerationService { for (const pageNumber of batch) { try { const page = await pdf.getPage(pageNumber); - const viewport = page.getViewport({ scale }); + const viewport = page.getViewport({ scale, rotation: 0 }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index d00b67413..84dc64457 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -3,6 +3,7 @@ import { pdfWorkerManager } from '../services/pdfWorkerManager'; export interface ThumbnailWithMetadata { thumbnail: string; // Always returns a thumbnail (placeholder if needed) pageCount: number; + pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270) } interface ColorScheme { @@ -377,8 +378,10 @@ export async function generateThumbnailForFile(file: File): Promise { /** * Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail + * @param applyRotation - If true, render thumbnail with PDF rotation applied (for static display). + * If false, render without rotation (for CSS-based rotation in PageEditor) */ -export async function generateThumbnailWithMetadata(file: File): Promise { +export async function generateThumbnailWithMetadata(file: File, applyRotation: boolean = true): Promise { // Non-PDF files have no page count if (!file.type.startsWith('application/pdf')) { const thumbnail = await generateThumbnailForFile(file); @@ -399,7 +402,13 @@ export async function generateThumbnailWithMetadata(file: File): Promise