diff --git a/frontend/src/core/components/AppLayout.tsx b/frontend/src/core/components/AppLayout.tsx index 39de5dc650..9bcd31e6db 100644 --- a/frontend/src/core/components/AppLayout.tsx +++ b/frontend/src/core/components/AppLayout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import { useBanner } from '@app/contexts/BannerContext'; +import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; interface AppLayoutProps { children: ReactNode; @@ -26,6 +27,7 @@ export function AppLayout({ children }: AppLayoutProps) { {children} + ); } diff --git a/frontend/src/core/components/pageEditor/PageEditor.tsx b/frontend/src/core/components/pageEditor/PageEditor.tsx index b182c9d3a8..1b965367d5 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/src/core/components/pageEditor/PageEditor.tsx @@ -9,7 +9,6 @@ import '@app/components/pageEditor/PageEditor.module.css'; import PageThumbnail from '@app/components/pageEditor/PageThumbnail'; import DragDropGrid from '@app/components/pageEditor/DragDropGrid'; import SkeletonLoader from '@app/components/shared/SkeletonLoader'; -import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; import { FileId } from "@app/types/file"; import { GRID_CONSTANTS } from '@app/components/pageEditor/constants'; import { useInitialPageDocument } from '@app/components/pageEditor/hooks/useInitialPageDocument'; @@ -39,7 +38,7 @@ const PageEditor = ({ const { actions } = useFileActions(); // Navigation guard for unsaved changes - const { setHasUnsavedChanges } = useNavigationGuard(); + const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard(); const navigationState = useNavigationState(); // Get PageEditor coordination functions @@ -393,6 +392,19 @@ const PageEditor = ({ updateCurrentPages, }); + // Register navigation warning handlers for the global modal + useEffect(() => { + registerNavigationWarningHandlers({ + onApplyAndContinue: async () => { + await applyChanges(); + }, + onExportAndContinue: async () => { + await onExportAll(); + }, + }); + return () => unregisterNavigationWarningHandlers(); + }, [applyChanges, onExportAll, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers]); + // Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument) const selectedPageCount = selectedPageIds.length; const activeFileIds = selectedFileIds; @@ -704,14 +716,6 @@ const PageEditor = ({ )} - { - await applyChanges(); - }} - onExportAndContinue={async () => { - await onExportAll(); - }} - /> ); }; diff --git a/frontend/src/core/components/shared/NavigationWarningModal.tsx b/frontend/src/core/components/shared/NavigationWarningModal.tsx index 6e143ccd18..8e80b5d771 100644 --- a/frontend/src/core/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/core/components/shared/NavigationWarningModal.tsx @@ -1,3 +1,4 @@ +import { useRef, useEffect } from "react"; import { Modal, Text, Button, Group, Stack } from "@mantine/core"; import { useNavigationGuard } from "@app/contexts/NavigationContext"; import { useTranslation } from "react-i18next"; @@ -6,51 +7,69 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; import { Z_INDEX_TOAST } from "@app/styles/zIndex"; -interface NavigationWarningModalProps { - onApplyAndContinue?: () => Promise; - onExportAndContinue?: () => Promise; - /** Called when discarding - allows saving applied changes while discarding pending ones */ - onDiscardAndContinue?: () => Promise; -} - -const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue, onDiscardAndContinue }: NavigationWarningModalProps) => { +const NavigationWarningModal = () => { const { t } = useTranslation(); - const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } = - useNavigationGuard(); + const { + showNavigationWarning, + hasUnsavedChanges, + pendingNavigation, + cancelNavigation, + setHasUnsavedChanges, + navigationWarningHandlersRef, + } = useNavigationGuard(); + + // Store pendingNavigation in a ref so async handlers always have the latest, + // not a stale closure captured before an await. + const pendingNavigationRef = useRef(pendingNavigation); + useEffect(() => { + pendingNavigationRef.current = pendingNavigation; + }, [pendingNavigation]); const handleKeepWorking = () => { cancelNavigation(); }; - const handleDiscardChanges = async () => { - // If a discard handler is provided, call it to save any already-applied changes, then discard the unsaved changes - if (onDiscardAndContinue) { - await onDiscardAndContinue(); - } + const finishAndNavigate = () => { + const nav = pendingNavigationRef.current; setHasUnsavedChanges(false); - confirmNavigation(); + cancelNavigation(); + if (nav) { + nav(); + } + }; + + const handleDiscardChanges = async () => { + const handlers = navigationWarningHandlersRef.current; + if (handlers?.onDiscardAndContinue) { + await handlers.onDiscardAndContinue(); + } + finishAndNavigate(); }; const handleApplyAndContinue = async () => { - if (onApplyAndContinue) { - await onApplyAndContinue(); + const handlers = navigationWarningHandlersRef.current; + if (handlers?.onApplyAndContinue) { + await handlers.onApplyAndContinue(); } - setHasUnsavedChanges(false); - confirmNavigation(); + finishAndNavigate(); }; const handleExportAndContinue = async () => { - if (onExportAndContinue) { - await onExportAndContinue(); + const handlers = navigationWarningHandlersRef.current; + if (handlers?.onExportAndContinue) { + await handlers.onExportAndContinue(); } - setHasUnsavedChanges(false); - confirmNavigation(); + finishAndNavigate(); }; + // Read handler availability at render time for button visibility + const handlers = navigationWarningHandlersRef.current; + const hasApply = !!handlers?.onApplyAndContinue; + const hasExport = !!handlers?.onExportAndContinue; + const BUTTON_WIDTH = "12rem"; // Only show modal if there are unsaved changes AND there's an actual pending navigation - // This prevents the modal from showing due to spurious state updates if (!hasUnsavedChanges || !pendingNavigation) { return null; } @@ -87,12 +106,12 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue, onDis - {onApplyAndContinue && ( + {hasApply && ( )} - {onExportAndContinue && ( + {hasExport && ( @@ -108,12 +127,12 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue, onDis - {onApplyAndContinue && ( + {hasApply && ( )} - {onExportAndContinue && ( + {hasExport && ( diff --git a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx index 99cd6ad99a..119ec4e381 100644 --- a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx +++ b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -28,7 +28,7 @@ import CallSplitIcon from '@mui/icons-material/CallSplit'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import UploadFileIcon from '@mui/icons-material/UploadFileOutlined'; import { Rnd } from 'react-rnd'; -import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; +import { useNavigationGuard } from '@app/contexts/NavigationContext'; import { useFileContext } from '@app/contexts/FileContext'; import { @@ -415,6 +415,15 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { } : null, }); + // Register navigation warning handlers for the global modal + const { registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard(); + useEffect(() => { + registerNavigationWarningHandlers({ + onApplyAndContinue: onSaveToWorkbench, + }); + return () => unregisterNavigationWarningHandlers(); + }, [onSaveToWorkbench, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers]); + const clearSelection = useCallback(() => { setSelectedGroupIds(new Set()); lastSelectedGroupIdRef.current = null; @@ -2385,10 +2394,6 @@ const selectionToolbarPosition = useMemo(() => { )} - {/* Navigation Warning Modal */} - ); }; diff --git a/frontend/src/core/components/tools/redact/ManualRedactionControls.tsx b/frontend/src/core/components/tools/redact/ManualRedactionControls.tsx index 4c0fa41ef5..dd75d926d6 100644 --- a/frontend/src/core/components/tools/redact/ManualRedactionControls.tsx +++ b/frontend/src/core/components/tools/redact/ManualRedactionControls.tsx @@ -1,10 +1,10 @@ import { useTranslation } from 'react-i18next'; import { useEffect, useRef, useCallback } from 'react'; import { Button, Stack, Text, Divider, ColorInput } from '@mantine/core'; -import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext'; import { useViewer } from '@app/contexts/ViewerContext'; import { useSignature } from '@app/contexts/SignatureContext'; +import { useNavigationGuard } from '@app/contexts/NavigationContext'; interface ManualRedactionControlsProps { disabled?: boolean; @@ -27,40 +27,45 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda // Get signature context to deactivate annotation tools when switching to redaction const { signatureApiRef } = useSignature(); - // Check if redaction mode is active - const isRedactActive = isRedacting; - - // Track if we've auto-activated for the current bridge session - const hasAutoActivated = useRef(false); + // Check if user is navigating away (modal shown) — don't fight the save/leave process + const { showNavigationWarning } = useNavigationGuard(); // Track the previous file index to detect file switches const prevFileIndexRef = useRef(activeFileIndex); - // Auto-activate selection mode when the API bridge becomes ready - // This ensures Mark Text is pre-selected when entering manual redaction mode + // Guard: pause auto-reactivation during save/export to avoid interfering with EmbedPDF + const isSavingRef = useRef(false); + + // Keep redaction tool active at all times while this component is mounted. + // If anything deactivates it (annotation tools, text selection, file switch, etc.) + // this re-enables it automatically — no manual "Activate" button needed. useEffect(() => { - if (isBridgeReady && !disabled && !hasAutoActivated.current) { - hasAutoActivated.current = true; - // Small delay to ensure EmbedPDF is fully ready - const timer = setTimeout(() => { - // Deactivate annotation mode to show redaction layer + if (disabled || !isBridgeReady || isSavingRef.current || showNavigationWarning) return; + + if (!isRedacting || isAnnotationMode) { + // Kill annotation mode if it stole focus + if (isAnnotationMode) { setAnnotationMode(false); - // Pre-select the Redaction tool - activateManualRedact(); - }, 150); + if (signatureApiRef?.current) { + try { + signatureApiRef.current.deactivateTools(); + } catch (error) { + console.log('Unable to deactivate annotation tools:', error); + } + } + } + // Small delay to avoid racing with EmbedPDF's own state updates + const timer = setTimeout(() => { + if (!isSavingRef.current) { + activateManualRedact(); + } + }, 50); return () => clearTimeout(timer); } - }, [isBridgeReady, disabled, activateManualRedact, setAnnotationMode]); - - // Reset auto-activation flag when disabled changes or bridge becomes not ready - useEffect(() => { - if (disabled || !isBridgeReady) { - hasAutoActivated.current = false; - } - }, [disabled, isBridgeReady]); + }, [isRedacting, isAnnotationMode, disabled, isBridgeReady, showNavigationWarning, setAnnotationMode, signatureApiRef, activateManualRedact]); // Reset redaction tool when switching between files - // The new PDF gets a fresh EmbedPDF instance - forcing user to re-select tool ensures it works properly + // The new PDF gets a fresh EmbedPDF instance useEffect(() => { if (prevFileIndexRef.current !== activeFileIndex) { prevFileIndexRef.current = activeFileIndex; @@ -69,41 +74,24 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda if (activeType) { setActiveType(null); } - - // Reset auto-activation flag so new file can auto-activate - hasAutoActivated.current = false; } }, [activeFileIndex, activeType, setActiveType]); - const handleRedactClick = () => { - // Deactivate annotation mode and tools to switch to redaction layer - if (isAnnotationMode) { - setAnnotationMode(false); - // Deactivate any active annotation tools (like draw) - if (signatureApiRef?.current) { - try { - signatureApiRef.current.deactivateTools(); - } catch (error) { - console.log('Unable to deactivate annotation tools:', error); - } - } - } - - activateManualRedact(); - }; - // Handle saving changes - this will apply pending redactions and save to file const handleSaveChanges = useCallback(async () => { if (applyChanges) { - await applyChanges(); + isSavingRef.current = true; + try { + await applyChanges(); + } finally { + isSavingRef.current = false; + } } }, [applyChanges]); // Check if there are unsaved changes to save (pending redactions OR applied redactions) - // Save Changes button will apply pending redactions and then save everything const hasUnsavedChanges = pendingCount > 0 || redactionsApplied; - // Check if API is available - use isBridgeReady state instead of ref (refs don't trigger re-renders) const isApiReady = isBridgeReady; return ( @@ -128,18 +116,6 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda popoverProps={{ withinPortal: true }} /> - - {/* Save Changes Button - applies pending redactions and saves to file */}