Implement navigation warning modal and unsaved changes management

This commit is contained in:
Reece 2025-07-08 16:00:25 +01:00
parent 9b63bffb36
commit 33420c7e80
5 changed files with 281 additions and 197 deletions

View File

@ -6,7 +6,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; import { useFileContext, useCurrentFile } from "../../contexts/FileContext";
import { ViewType } from "../../types/fileContext"; import { ViewType, ToolType } from "../../types/fileContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo"; import { useUndoRedo } from "../../hooks/useUndoRedo";
@ -26,6 +26,7 @@ import PageThumbnail from './PageThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel'; import BulkSelectionPanel from './BulkSelectionPanel';
import DragDropGrid from './DragDropGrid'; import DragDropGrid from './DragDropGrid';
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
import NavigationWarningModal from '../shared/NavigationWarningModal';
export interface PageEditorProps { export interface PageEditorProps {
// Optional callbacks to expose internal functions for PageEditorControls // Optional callbacks to expose internal functions for PageEditorControls
@ -63,7 +64,8 @@ const PageEditor = ({
selectedPageNumbers, selectedPageNumbers,
setSelectedPages, setSelectedPages,
updateProcessedFile, updateProcessedFile,
setCurrentView: originalSetCurrentView, setHasUnsavedChanges,
hasUnsavedChanges,
isProcessing: globalProcessing, isProcessing: globalProcessing,
processingProgress, processingProgress,
clearAllFiles clearAllFiles
@ -71,24 +73,11 @@ const PageEditor = ({
// Edit state management // Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null); const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false);
const [showUnsavedModal, setShowUnsavedModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false);
const [foundDraft, setFoundDraft] = useState<any>(null); const [foundDraft, setFoundDraft] = useState<any>(null);
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null);
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null); const autoSaveTimer = useRef<NodeJS.Timeout | null>(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) // Simple computed document from processed files (no caching needed)
const mergedPdfDocument = useMemo(() => { const mergedPdfDocument = useMemo(() => {
if (activeFiles.length === 0) return null; if (activeFiles.length === 0) return null;
@ -152,14 +141,6 @@ const PageEditor = ({
const [filename, setFilename] = useState<string>(""); const [filename, setFilename] = useState<string>("");
// 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) // Page editor state (use context for selectedPages)
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
@ -546,19 +527,23 @@ const PageEditor = ({
// Update local edit state for immediate visual feedback // Update local edit state for immediate visual feedback
setEditedDocument(updatedDoc); 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) { if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current); clearTimeout(autoSaveTimer.current);
} }
autoSaveTimer.current = setTimeout(() => { autoSaveTimer.current = setTimeout(() => {
saveDraftToIndexedDB(updatedDoc); if (hasUnsavedDraft) {
}, 2000); // Auto-save after 2 seconds of inactivity saveDraftToIndexedDB(updatedDoc);
setHasUnsavedDraft(false); // Mark draft as saved
}
}, 30000); // Auto-save after 30 seconds of inactivity
return updatedDoc; return updatedDoc;
}, []); }, [setHasUnsavedChanges, hasUnsavedDraft]);
// Save draft to separate IndexedDB location // Save draft to separate IndexedDB location
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
@ -591,27 +576,36 @@ const PageEditor = ({
} }
}, [activeFiles]); }, [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 // Apply changes to create new processed file
const applyChanges = useCallback(async () => { const applyChanges = useCallback(async () => {
if (!editedDocument || !mergedPdfDocument) return; 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 { try {
// Convert edited document back to processedFiles format
if (activeFiles.length === 1) { if (activeFiles.length === 1) {
// Single file - update the existing processed file
const file = activeFiles[0]; const file = activeFiles[0];
const currentProcessedFile = processedFiles.get(file); const currentProcessedFile = processedFiles.get(file);
if (currentProcessedFile) { if (currentProcessedFile) {
const updatedProcessedFile = { const updatedProcessedFile = {
...currentProcessedFile, ...currentProcessedFile,
id: `${currentProcessedFile.id}-edited`, id: `${currentProcessedFile.id}-edited-${Date.now()}`,
pages: editedDocument.pages.map(page => ({ pages: editedDocument.pages.map(page => ({
...page, ...page,
rotation: page.rotation || 0, rotation: page.rotation || 0,
@ -621,28 +615,27 @@ const PageEditor = ({
lastModified: Date.now() lastModified: Date.now()
}; };
// Use the proper FileContext action to update
updateProcessedFile(file, updatedProcessedFile); 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 // Wait for the processed file update to complete before clearing edit state
setEditedDocument(null); setTimeout(() => {
setHasUnsavedChanges(false); setEditedDocument(null);
setHasUnsavedChanges(false);
// Clean up auto-save draft setHasUnsavedDraft(false);
cleanupDraft(); cleanupDraft();
setStatus('Changes applied successfully');
setStatus('Changes applied successfully'); }, 100);
} catch (error) { } catch (error) {
console.error('Failed to apply changes:', error); console.error('Failed to apply changes:', error);
setStatus('Failed to apply changes'); 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) => { const animateReorder = useCallback((pageNumber: number, targetIndex: number) => {
if (!displayDocument || isAnimating) return; if (!displayDocument || isAnimating) return;
@ -947,18 +940,12 @@ const PageEditor = ({
}, [redo]); }, [redo]);
const closePdf = useCallback(() => { const closePdf = useCallback(() => {
if (hasUnsavedChanges) { // Use global navigation guard system
// Show warning modal instead of immediately closing fileContext.requestNavigation(() => {
setPendingNavigation(() => () => {
clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
setSelectedPages([]);
});
setShowUnsavedModal(true);
} else {
clearAllFiles(); // This now handles all cleanup centrally (including merged docs) clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
setSelectedPages([]); setSelectedPages([]);
} });
}, [hasUnsavedChanges, clearAllFiles, setSelectedPages]); }, [fileContext, clearAllFiles, setSelectedPages]);
// PageEditorControls needs onExportSelected and onExportAll // PageEditorControls needs onExportSelected and onExportAll
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
@ -1005,64 +992,19 @@ const PageEditor = ({
// Show loading or empty state instead of blocking // Show loading or empty state instead of blocking
const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0);
const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0; const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0;
// Functions for global NavigationWarningModal
// Clean up draft from IndexedDB const handleApplyAndContinue = useCallback(async () => {
const cleanupDraft = useCallback(async () => { if (editedDocument) {
try { await applyChanges();
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);
} }
}, [mergedPdfDocument]); }, [editedDocument, applyChanges]);
// Export and continue const handleExportAndContinue = useCallback(async () => {
const exportAndContinue = useCallback(async () => { if (editedDocument) {
if (!editedDocument) return; await applyChanges();
await handleExport(false);
// First apply changes
await applyChanges();
// Then export
await handleExport(false);
// Continue with navigation if pending
if (pendingNavigation) {
pendingNavigation();
setPendingNavigation(null);
} }
}, [editedDocument, applyChanges, handleExport]);
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);
}, []);
// Check for existing drafts // Check for existing drafts
const checkForDrafts = useCallback(async () => { const checkForDrafts = useCallback(async () => {
@ -1145,6 +1087,24 @@ const PageEditor = ({
} }
}, [mergedPdfDocument, editedDocument, hasUnsavedChanges, checkForDrafts]); }, [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 // Display all pages - use edited or original document
const displayedPages = displayDocument?.pages || []; const displayedPages = displayDocument?.pages || [];
@ -1393,61 +1353,11 @@ const PageEditor = ({
)} )}
</Modal> </Modal>
{/* Unsaved Changes Modal */} {/* Global Navigation Warning Modal */}
<Modal <NavigationWarningModal
opened={showUnsavedModal} onApplyAndContinue={handleApplyAndContinue}
onClose={keepWorking} onExportAndContinue={handleExportAndContinue}
title="Unsaved Changes" />
centered
closeOnClickOutside={false}
closeOnEscape={false}
>
<Stack gap="md">
<Text>
You have unsaved changes to your PDF. What would you like to do?
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="gray"
onClick={keepWorking}
>
Keep Working
</Button>
<Button
variant="light"
color="red"
onClick={discardChanges}
>
Discard Changes
</Button>
<Button
variant="light"
color="blue"
onClick={async () => {
await applyChanges();
if (pendingNavigation) {
pendingNavigation();
setPendingNavigation(null);
}
setShowUnsavedModal(false);
}}
>
Apply & Continue
</Button>
<Button
color="green"
onClick={exportAndContinue}
>
Export & Continue
</Button>
</Group>
</Stack>
</Modal>
{/* Resume Work Modal */} {/* Resume Work Modal */}
<Modal <Modal

View File

@ -98,11 +98,13 @@ const FileGrid = ({
</Group> </Group>
)} )}
{/* File Count Badge */}3 {/* File Grid */}
gap: 'md' <Flex
} direction="row"
}} wrap="wrap"
h="30rem" style={{ overflowY: "auto", width: "100%" }} gap="md"
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
> >
{displayFiles.map((file, idx) => { {displayFiles.map((file, idx) => {
const originalIdx = files.findIndex(f => (f.id || f.name) === (file.id || file.name)); const originalIdx = files.findIndex(f => (f.id || f.name) === (file.id || file.name));

View File

@ -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<void>;
onExportAndContinue?: () => Promise<void>;
}
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 (
<Modal
opened={showNavigationWarning}
onClose={handleKeepWorking}
title="Unsaved Changes"
centered
closeOnClickOutside={false}
closeOnEscape={false}
>
<Stack gap="md">
<Text>
You have unsaved changes to your PDF. What would you like to do?
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="gray"
onClick={handleKeepWorking}
>
Keep Working
</Button>
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
Discard Changes
</Button>
{onApplyAndContinue && (
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
>
Apply & Continue
</Button>
)}
{onExportAndContinue && (
<Button
color="green"
onClick={handleExportAndContinue}
>
Export & Continue
</Button>
)}
</Group>
</Stack>
</Modal>
);
};
export default NavigationWarningModal;

View File

@ -42,7 +42,10 @@ const initialState: FileContextState = {
viewerConfig: initialViewerConfig, viewerConfig: initialViewerConfig,
isProcessing: false, isProcessing: false,
processingProgress: 0, processingProgress: 0,
lastExportConfig: undefined lastExportConfig: undefined,
hasUnsavedChanges: false,
pendingNavigation: null,
showNavigationWarning: false
}; };
// Action types // Action types
@ -62,6 +65,9 @@ type FileContextAction =
| { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } } | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
| { type: 'ADD_FILE_OPERATION'; payload: FileOperation } | { type: 'ADD_FILE_OPERATION'; payload: FileOperation }
| { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] } | { 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: 'RESET_CONTEXT' }
| { type: 'LOAD_STATE'; payload: Partial<FileContextState> }; | { type: 'LOAD_STATE'; payload: Partial<FileContextState> };
@ -182,6 +188,24 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
lastExportConfig: action.payload 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': case 'RESET_CONTEXT':
return { return {
...initialState ...initialState
@ -471,29 +495,54 @@ export function FileContextProvider({
dispatch({ type: 'CLEAR_SELECTIONS' }); dispatch({ type: 'CLEAR_SELECTIONS' });
}, [cleanupAllFiles]); }, [cleanupAllFiles]);
const setCurrentView = useCallback((view: ViewType) => { // Navigation guard system functions
// Update view immediately for instant UI response const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
}, []);
// REMOVED: Aggressive cleanup on view switch
// This was destroying cached processed files and causing re-processing const requestNavigation = useCallback((navigationFn: () => void): boolean => {
// We should only cleanup when files are actually removed or app closes if (state.hasUnsavedChanges) {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: navigationFn });
// Optional: Light memory pressure relief only for very large docs dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: true });
if (state.currentView !== view && state.activeFiles.length > 0) { return false;
// Only hint at garbage collection, don't destroy caches } else {
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { navigationFn();
window.requestIdleCallback(() => { return true;
// Very light cleanup - just GC hint, no cache destruction
window.gc();
}, { timeout: 5000 });
}
} }
}, [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) => { 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[]) => { const setSelectedFiles = useCallback((fileIds: string[]) => {
dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds }); dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds });
@ -646,6 +695,12 @@ export function FileContextProvider({
loadContext, loadContext,
resetContext, resetContext,
// Navigation guard system
setHasUnsavedChanges,
requestNavigation,
confirmNavigation,
cancelNavigation,
// Memory management // Memory management
trackBlobUrl, trackBlobUrl,
trackPdfDocument, trackPdfDocument,

View File

@ -58,6 +58,11 @@ export interface FileContextState {
selectedOnly: boolean; selectedOnly: boolean;
splitDocuments: boolean; splitDocuments: boolean;
}; };
// Navigation guard system
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
} }
export interface FileContextActions { export interface FileContextActions {
@ -100,6 +105,12 @@ export interface FileContextActions {
loadContext: () => Promise<void>; loadContext: () => Promise<void>;
resetContext: () => void; resetContext: () => void;
// Navigation guard system
setHasUnsavedChanges: (hasChanges: boolean) => void;
requestNavigation: (navigationFn: () => void) => boolean;
confirmNavigation: () => void;
cancelNavigation: () => void;
// Memory management // Memory management
trackBlobUrl: (url: string) => void; trackBlobUrl: (url: string) => void;
trackPdfDocument: (fileId: string, pdfDoc: any) => void; trackPdfDocument: (fileId: string, pdfDoc: any) => void;