mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
Implement navigation warning modal and unsaved changes management
This commit is contained in:
parent
9b63bffb36
commit
33420c7e80
@ -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
|
||||||
|
@ -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));
|
||||||
|
106
frontend/src/components/shared/NavigationWarningModal.tsx
Normal file
106
frontend/src/components/shared/NavigationWarningModal.tsx
Normal 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;
|
@ -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
|
const requestNavigation = useCallback((navigationFn: () => void): boolean => {
|
||||||
// This was destroying cached processed files and causing re-processing
|
if (state.hasUnsavedChanges) {
|
||||||
// We should only cleanup when files are actually removed or app closes
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: navigationFn });
|
||||||
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: true });
|
||||||
// Optional: Light memory pressure relief only for very large docs
|
return false;
|
||||||
if (state.currentView !== view && state.activeFiles.length > 0) {
|
} else {
|
||||||
// Only hint at garbage collection, don't destroy caches
|
navigationFn();
|
||||||
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) {
|
return true;
|
||||||
window.requestIdleCallback(() => {
|
|
||||||
// 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,
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user