From 33420c7e80662a3eafff11cc957269bc2f1134ad Mon Sep 17 00:00:00 2001 From: Reece Date: Tue, 8 Jul 2025 16:00:25 +0100 Subject: [PATCH] Implement navigation warning modal and unsaved changes management --- .../src/components/pageEditor/PageEditor.tsx | 252 ++++++------------ frontend/src/components/shared/FileGrid.tsx | 12 +- .../shared/NavigationWarningModal.tsx | 106 ++++++++ frontend/src/contexts/FileContext.tsx | 97 +++++-- frontend/src/types/fileContext.ts | 11 + 5 files changed, 281 insertions(+), 197 deletions(-) create mode 100644 frontend/src/components/shared/NavigationWarningModal.tsx diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 5ad376881..f1a5dfd3c 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -6,7 +6,7 @@ import { } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; -import { ViewType } from "../../types/fileContext"; +import { ViewType, ToolType } from "../../types/fileContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { useUndoRedo } from "../../hooks/useUndoRedo"; @@ -26,6 +26,7 @@ import PageThumbnail from './PageThumbnail'; import BulkSelectionPanel from './BulkSelectionPanel'; import DragDropGrid from './DragDropGrid'; import SkeletonLoader from '../shared/SkeletonLoader'; +import NavigationWarningModal from '../shared/NavigationWarningModal'; export interface PageEditorProps { // Optional callbacks to expose internal functions for PageEditorControls @@ -63,7 +64,8 @@ const PageEditor = ({ selectedPageNumbers, setSelectedPages, updateProcessedFile, - setCurrentView: originalSetCurrentView, + setHasUnsavedChanges, + hasUnsavedChanges, isProcessing: globalProcessing, processingProgress, clearAllFiles @@ -71,24 +73,11 @@ const PageEditor = ({ // Edit state management const [editedDocument, setEditedDocument] = useState(null); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [showUnsavedModal, setShowUnsavedModal] = useState(false); + const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false); const [foundDraft, setFoundDraft] = useState(null); - const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null); const autoSaveTimer = useRef(null); - // Override setCurrentView to check for unsaved changes - const setCurrentView = useCallback((view: ViewType) => { - if (hasUnsavedChanges && view !== 'pageEditor') { - // Show warning modal instead of immediately switching views - setPendingNavigation(() => () => originalSetCurrentView(view)); - setShowUnsavedModal(true); - } else { - originalSetCurrentView(view); - } - }, [hasUnsavedChanges, originalSetCurrentView]); - // Simple computed document from processed files (no caching needed) const mergedPdfDocument = useMemo(() => { if (activeFiles.length === 0) return null; @@ -152,14 +141,6 @@ const PageEditor = ({ const [filename, setFilename] = useState(""); - // Debug render performance - const renderStartTime = useRef(performance.now()); - - useEffect(() => { - const renderTime = performance.now() - renderStartTime.current; - console.log('PageEditor: Component render:', renderTime.toFixed(2) + 'ms'); - renderStartTime.current = performance.now(); - }); // Page editor state (use context for selectedPages) const [status, setStatus] = useState(null); @@ -546,19 +527,23 @@ const PageEditor = ({ // Update local edit state for immediate visual feedback setEditedDocument(updatedDoc); - setHasUnsavedChanges(true); + setHasUnsavedChanges(true); // Use global state + setHasUnsavedDraft(true); // Mark that we have unsaved draft changes - // Auto-save to drafts (debounced) + // Auto-save to drafts (debounced) - only if we have new changes if (autoSaveTimer.current) { clearTimeout(autoSaveTimer.current); } autoSaveTimer.current = setTimeout(() => { - saveDraftToIndexedDB(updatedDoc); - }, 2000); // Auto-save after 2 seconds of inactivity + if (hasUnsavedDraft) { + saveDraftToIndexedDB(updatedDoc); + setHasUnsavedDraft(false); // Mark draft as saved + } + }, 30000); // Auto-save after 30 seconds of inactivity return updatedDoc; - }, []); + }, [setHasUnsavedChanges, hasUnsavedDraft]); // Save draft to separate IndexedDB location const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { @@ -591,27 +576,36 @@ const PageEditor = ({ } }, [activeFiles]); + // Clean up draft from IndexedDB + const cleanupDraft = useCallback(async () => { + try { + const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; + const request = indexedDB.open('stirling-pdf-drafts', 1); + + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction('drafts', 'readwrite'); + const store = transaction.objectStore('drafts'); + store.delete(draftKey); + }; + } 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; - console.log('Applying changes - creating new processed file'); - - // Create new filename with (edited) suffix - const originalName = mergedPdfDocument.name.replace(/\.pdf$/i, ''); - const newName = `${originalName}(edited).pdf`; - try { - // Convert edited document back to processedFiles format if (activeFiles.length === 1) { - // Single file - update the existing processed file const file = activeFiles[0]; const currentProcessedFile = processedFiles.get(file); if (currentProcessedFile) { const updatedProcessedFile = { ...currentProcessedFile, - id: `${currentProcessedFile.id}-edited`, + id: `${currentProcessedFile.id}-edited-${Date.now()}`, pages: editedDocument.pages.map(page => ({ ...page, rotation: page.rotation || 0, @@ -621,28 +615,27 @@ const PageEditor = ({ lastModified: Date.now() }; - // Use the proper FileContext action to update updateProcessedFile(file, updatedProcessedFile); - - // Also save the updated file to IndexedDB for persistence - await fileStorage.storeProcessedFile(file, updatedProcessedFile); } + } else if (activeFiles.length > 1) { + setStatus('Apply changes for multiple files not yet supported'); + return; } - // Clear edit state - setEditedDocument(null); - setHasUnsavedChanges(false); - - // Clean up auto-save draft - cleanupDraft(); - - setStatus('Changes applied successfully'); + // Wait for the processed file update to complete before clearing edit state + setTimeout(() => { + setEditedDocument(null); + setHasUnsavedChanges(false); + setHasUnsavedDraft(false); + cleanupDraft(); + setStatus('Changes applied successfully'); + }, 100); } catch (error) { console.error('Failed to apply changes:', error); setStatus('Failed to apply changes'); } - }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, updateProcessedFile]); + }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, updateProcessedFile, setHasUnsavedChanges, setStatus, cleanupDraft]); const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { if (!displayDocument || isAnimating) return; @@ -947,18 +940,12 @@ const PageEditor = ({ }, [redo]); const closePdf = useCallback(() => { - if (hasUnsavedChanges) { - // Show warning modal instead of immediately closing - setPendingNavigation(() => () => { - clearAllFiles(); // This now handles all cleanup centrally (including merged docs) - setSelectedPages([]); - }); - setShowUnsavedModal(true); - } else { + // Use global navigation guard system + fileContext.requestNavigation(() => { clearAllFiles(); // This now handles all cleanup centrally (including merged docs) setSelectedPages([]); - } - }, [hasUnsavedChanges, clearAllFiles, setSelectedPages]); + }); + }, [fileContext, clearAllFiles, setSelectedPages]); // PageEditorControls needs onExportSelected and onExportAll const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); @@ -1005,64 +992,19 @@ const PageEditor = ({ // Show loading or empty state instead of blocking const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0; - - // Clean up draft from IndexedDB - const cleanupDraft = useCallback(async () => { - try { - const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; - const request = indexedDB.open('stirling-pdf-drafts', 1); - - request.onsuccess = () => { - const db = request.result; - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - store.delete(draftKey); - console.log('Draft cleaned up from IndexedDB'); - }; - } catch (error) { - console.warn('Failed to cleanup draft:', error); + // Functions for global NavigationWarningModal + const handleApplyAndContinue = useCallback(async () => { + if (editedDocument) { + await applyChanges(); } - }, [mergedPdfDocument]); + }, [editedDocument, applyChanges]); - // Export and continue - const exportAndContinue = useCallback(async () => { - if (!editedDocument) return; - - // First apply changes - await applyChanges(); - - // Then export - await handleExport(false); - - // Continue with navigation if pending - if (pendingNavigation) { - pendingNavigation(); - setPendingNavigation(null); + const handleExportAndContinue = useCallback(async () => { + if (editedDocument) { + await applyChanges(); + await handleExport(false); } - - setShowUnsavedModal(false); - }, [editedDocument, applyChanges, handleExport, pendingNavigation]); - - // Discard changes - const discardChanges = useCallback(() => { - setEditedDocument(null); - setHasUnsavedChanges(false); - cleanupDraft(); - - if (pendingNavigation) { - pendingNavigation(); - setPendingNavigation(null); - } - - setShowUnsavedModal(false); - setStatus('Changes discarded'); - }, [cleanupDraft, pendingNavigation]); - - // Keep working (stay on page editor) - const keepWorking = useCallback(() => { - setShowUnsavedModal(false); - setPendingNavigation(null); - }, []); + }, [editedDocument, applyChanges, handleExport]); // Check for existing drafts const checkForDrafts = useCallback(async () => { @@ -1145,6 +1087,24 @@ const PageEditor = ({ } }, [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]); + // Display all pages - use edited or original document const displayedPages = displayDocument?.pages || []; @@ -1393,61 +1353,11 @@ const PageEditor = ({ )} - {/* Unsaved Changes Modal */} - - - - You have unsaved changes to your PDF. What would you like to do? - - - - - - - - - - - - - + {/* Global Navigation Warning Modal */} + {/* Resume Work Modal */} )} - {/* File Count Badge */}3 - gap: 'md' - } - }} - h="30rem" style={{ overflowY: "auto", width: "100%" }} + {/* File Grid */} + {displayFiles.map((file, idx) => { const originalIdx = files.findIndex(f => (f.id || f.name) === (file.id || file.name)); diff --git a/frontend/src/components/shared/NavigationWarningModal.tsx b/frontend/src/components/shared/NavigationWarningModal.tsx new file mode 100644 index 000000000..a3d3983d2 --- /dev/null +++ b/frontend/src/components/shared/NavigationWarningModal.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Modal, Text, Button, Group, Stack } from '@mantine/core'; +import { useFileContext } from '../../contexts/FileContext'; + +interface NavigationWarningModalProps { + onApplyAndContinue?: () => Promise; + onExportAndContinue?: () => Promise; +} + +const NavigationWarningModal = ({ + onApplyAndContinue, + onExportAndContinue +}: NavigationWarningModalProps) => { + const { + showNavigationWarning, + hasUnsavedChanges, + confirmNavigation, + cancelNavigation, + setHasUnsavedChanges + } = useFileContext(); + + const handleKeepWorking = () => { + cancelNavigation(); + }; + + const handleDiscardChanges = () => { + setHasUnsavedChanges(false); + confirmNavigation(); + }; + + const handleApplyAndContinue = async () => { + if (onApplyAndContinue) { + await onApplyAndContinue(); + } + setHasUnsavedChanges(false); + confirmNavigation(); + }; + + const handleExportAndContinue = async () => { + if (onExportAndContinue) { + await onExportAndContinue(); + } + setHasUnsavedChanges(false); + confirmNavigation(); + }; + + if (!hasUnsavedChanges) { + return null; + } + + return ( + + + + You have unsaved changes to your PDF. What would you like to do? + + + + + + + + {onApplyAndContinue && ( + + )} + + {onExportAndContinue && ( + + )} + + + + ); +}; + +export default NavigationWarningModal; \ No newline at end of file diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 337020fea..1dfc37ef8 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -42,7 +42,10 @@ const initialState: FileContextState = { viewerConfig: initialViewerConfig, isProcessing: false, processingProgress: 0, - lastExportConfig: undefined + lastExportConfig: undefined, + hasUnsavedChanges: false, + pendingNavigation: null, + showNavigationWarning: false }; // Action types @@ -62,6 +65,9 @@ type FileContextAction = | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } } | { type: 'ADD_FILE_OPERATION'; payload: FileOperation } | { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] } + | { type: 'SET_UNSAVED_CHANGES'; payload: boolean } + | { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null } + | { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean } | { type: 'RESET_CONTEXT' } | { type: 'LOAD_STATE'; payload: Partial }; @@ -182,6 +188,24 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): lastExportConfig: action.payload }; + case 'SET_UNSAVED_CHANGES': + return { + ...state, + hasUnsavedChanges: action.payload + }; + + case 'SET_PENDING_NAVIGATION': + return { + ...state, + pendingNavigation: action.payload + }; + + case 'SHOW_NAVIGATION_WARNING': + return { + ...state, + showNavigationWarning: action.payload + }; + case 'RESET_CONTEXT': return { ...initialState @@ -471,29 +495,54 @@ export function FileContextProvider({ dispatch({ type: 'CLEAR_SELECTIONS' }); }, [cleanupAllFiles]); - const setCurrentView = useCallback((view: ViewType) => { - // Update view immediately for instant UI response - dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); - - // REMOVED: Aggressive cleanup on view switch - // This was destroying cached processed files and causing re-processing - // We should only cleanup when files are actually removed or app closes - - // Optional: Light memory pressure relief only for very large docs - if (state.currentView !== view && state.activeFiles.length > 0) { - // Only hint at garbage collection, don't destroy caches - if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { - window.requestIdleCallback(() => { - // Very light cleanup - just GC hint, no cache destruction - window.gc(); - }, { timeout: 5000 }); - } + // Navigation guard system functions + const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { + dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges }); + }, []); + + const requestNavigation = useCallback((navigationFn: () => void): boolean => { + if (state.hasUnsavedChanges) { + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: navigationFn }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: true }); + return false; + } else { + navigationFn(); + return true; } - }, [state.currentView, state.activeFiles]); + }, [state.hasUnsavedChanges]); + + const confirmNavigation = useCallback(() => { + if (state.pendingNavigation) { + state.pendingNavigation(); + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); + } + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); + }, [state.pendingNavigation]); + + const cancelNavigation = useCallback(() => { + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); + }, []); + + const setCurrentView = useCallback((view: ViewType) => { + requestNavigation(() => { + dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); + + if (state.currentView !== view && state.activeFiles.length > 0) { + if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { + window.requestIdleCallback(() => { + window.gc(); + }, { timeout: 5000 }); + } + } + }); + }, [requestNavigation, state.currentView, state.activeFiles]); const setCurrentTool = useCallback((tool: ToolType) => { - dispatch({ type: 'SET_CURRENT_TOOL', payload: tool }); - }, []); + requestNavigation(() => { + dispatch({ type: 'SET_CURRENT_TOOL', payload: tool }); + }); + }, [requestNavigation]); const setSelectedFiles = useCallback((fileIds: string[]) => { dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds }); @@ -646,6 +695,12 @@ export function FileContextProvider({ loadContext, resetContext, + // Navigation guard system + setHasUnsavedChanges, + requestNavigation, + confirmNavigation, + cancelNavigation, + // Memory management trackBlobUrl, trackPdfDocument, diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 82c094356..50c46039b 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -58,6 +58,11 @@ export interface FileContextState { selectedOnly: boolean; splitDocuments: boolean; }; + + // Navigation guard system + hasUnsavedChanges: boolean; + pendingNavigation: (() => void) | null; + showNavigationWarning: boolean; } export interface FileContextActions { @@ -100,6 +105,12 @@ export interface FileContextActions { loadContext: () => Promise; resetContext: () => void; + // Navigation guard system + setHasUnsavedChanges: (hasChanges: boolean) => void; + requestNavigation: (navigationFn: () => void) => boolean; + confirmNavigation: () => void; + cancelNavigation: () => void; + // Memory management trackBlobUrl: (url: string) => void; trackPdfDocument: (fileId: string, pdfDoc: any) => void;