diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index a425ad3d5..41a455ac1 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -1,8 +1,11 @@ unsavedChanges = "You have unsaved changes to your PDF." +pendingRedactionsTitle = "Unapplied Redactions" +pendingRedactions = "You have unapplied redactions that will be lost." areYouSure = "Are you sure you want to leave?" unsavedChangesTitle = "Unsaved Changes" keepWorking = "Keep Working" discardChanges = "Discard & Leave" +discardRedactions = "Discard & Leave" applyAndContinue = "Save & Leave" exportAndContinue = "Export & Continue" cancel = "Cancel" @@ -3140,8 +3143,35 @@ text = "Only match complete words, not partial matches. 'John' won't match 'John title = "Convert to PDF-Image" text = "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable." +[redact.tooltip.manual.header] +title = "Manual Redaction Controls" + +[redact.tooltip.manual.markText] +title = "Mark Text Tool" +text = "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact." + +[redact.tooltip.manual.markArea] +title = "Mark Area Tool" +text = "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes." + +[redact.tooltip.manual.apply] +title = "Apply Redactions" +text = "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied." +bullet1 = "Mark as many areas as needed before applying" +bullet2 = "All pending redactions are applied at once" +bullet3 = "Redactions cannot be undone after applying" + [redact.manual] +title = "Redaction Tools" +instructions = "Select text or draw areas on the PDF to mark content for redaction." +markText = "Mark Text" +markArea = "Mark Area" +pendingLabel = "Pending:" +applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted" +apply = "Apply" +noMarks = "No redaction marks. Use the tools above to mark content for redaction." header = "Manual Redaction" +controlsTitle = "Manual Redaction Controls" textBasedRedaction = "Text-based Redaction" pageBasedRedaction = "Page-based Redaction" convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)" @@ -3901,6 +3931,8 @@ toggleAnnotations = "Toggle Annotations Visibility" annotationMode = "Toggle Annotation Mode" print = "Print PDF" draw = "Draw" +redact = "Redact" +exitRedaction = "Exit Redaction Mode" save = "Save" saveChanges = "Save Changes" diff --git a/frontend/src/core/components/shared/NavigationWarningModal.tsx b/frontend/src/core/components/shared/NavigationWarningModal.tsx index faff07427..16cb5e890 100644 --- a/frontend/src/core/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/core/components/shared/NavigationWarningModal.tsx @@ -1,9 +1,11 @@ import { Modal, Text, Button, Group, Stack } from "@mantine/core"; -import { useNavigationGuard } from "@app/contexts/NavigationContext"; +import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext"; import { useTranslation } from "react-i18next"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import { useRedactionMode } from "@app/contexts/RedactionContext"; +import FitText from "@app/components/shared/FitText"; interface NavigationWarningModalProps { onApplyAndContinue?: () => Promise; @@ -14,6 +16,12 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav const { t } = useTranslation(); const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } = useNavigationGuard(); + const { selectedTool } = useNavigationState(); + const { pendingCount } = useRedactionMode(); + + // Check if we're in redact mode with pending redactions + const isRedactMode = selectedTool === 'redact'; + const hasPendingRedactions = pendingCount > 0; const handleKeepWorking = () => { cancelNavigation(); @@ -39,7 +47,7 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav setHasUnsavedChanges(false); confirmNavigation(); }; - const BUTTON_WIDTH = "10rem"; + const BUTTON_WIDTH = "12rem"; if (!hasUnsavedChanges) { return null; @@ -49,7 +57,9 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav - {t("unsavedChanges", "You have unsaved changes to your PDF.")} + {isRedactMode && hasPendingRedactions + ? t("pendingRedactions", "You have unapplied redactions that will be lost.") + : t("unsavedChanges", "You have unsaved changes to your PDF.")} {t("areYouSure", "Are you sure you want to leave?")} @@ -69,16 +81,24 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav {onApplyAndContinue && ( )} @@ -87,14 +107,22 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav {/* Mobile layout: centered stack of 4 buttons */} {onApplyAndContinue && ( )} diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index 822c3c83d..f071142a1 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -12,8 +12,10 @@ import { createProcessedFile } from '@app/contexts/file/fileActions'; import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext'; import { useNavigationState, useNavigationGuard, useNavigationActions } from '@app/contexts/NavigationContext'; import { useSidebarContext } from '@app/contexts/SidebarContext'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; import { useRedactionMode, useRedaction } from '@app/contexts/RedactionContext'; +import { defaultParameters, RedactParameters } from '@app/hooks/tools/redact/useRedactParameters'; interface ViewerAnnotationControlsProps { currentView: string; @@ -23,6 +25,7 @@ interface ViewerAnnotationControlsProps { export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) { const { t } = useTranslation(); const { sidebarRefs } = useSidebarContext(); + const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow(); const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); const [selectedColor, setSelectedColor] = useState('#000000'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); @@ -32,7 +35,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const viewerContext = React.useContext(ViewerContext); // Signature context for accessing drawing API - const { signatureApiRef, isPlacementMode } = useSignature(); + const { signatureApiRef, historyApiRef, isPlacementMode } = useSignature(); // File state for save functionality const { state, selectors } = useFileState(); @@ -40,15 +43,15 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const activeFiles = selectors.getFiles(); // Check if we're in sign mode or redaction mode - const { selectedTool } = useNavigationState(); + const { selectedTool, workbench } = useNavigationState(); const { actions: navActions } = useNavigationActions(); const isSignMode = selectedTool === 'sign'; const isRedactMode = selectedTool === 'redact'; // Get redaction pending state and navigation guard - const { pendingCount: redactionPendingCount, isRedacting } = useRedactionMode(); + const { pendingCount: redactionPendingCount, isRedacting: _isRedacting } = useRedactionMode(); const { requestNavigation } = useNavigationGuard(); - const { setRedactionMode, activateTextSelection } = useRedaction(); + const { setRedactionMode, activateTextSelection, setRedactionConfig } = useRedaction(); // Turn off annotation mode when switching away from viewer useEffect(() => { @@ -62,11 +65,51 @@ export default function ViewerAnnotationControls({ currentView, disabled = false return null; } + // Persist annotations to file if there are unsaved changes + const saveAnnotationsIfNeeded = async () => { + if (!viewerContext?.exportActions?.saveAsCopy || currentView !== 'viewer') return; + const hasUnsavedAnnotations = historyApiRef?.current?.canUndo() || false; + if (!hasUnsavedAnnotations) return; + + try { + const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy(); + if (!pdfArrayBuffer) return; + + const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' }); + const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf'; + const newFile = new File([blob], originalFileName, { type: 'application/pdf' }); + + if (activeFiles.length > 0) { + const thumbnailResult = await generateThumbnailWithMetadata(newFile); + const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail); + + const currentFileIds = state.files.ids; + if (currentFileIds.length > 0) { + const currentFileId = currentFileIds[0]; + const currentRecord = selectors.getStirlingFileStub(currentFileId); + if (!currentRecord) { + console.error('No file record found for:', currentFileId); + return; + } + + const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata); + const outputStirlingFile = createStirlingFile(newFile, outputStub.id); + + await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]); + } + } + } catch (error) { + console.error('Error auto-saving annotations before redaction:', error); + } + }; + // Handle redaction mode toggle - const handleRedactionToggle = () => { + const handleRedactionToggle = async () => { if (isRedactMode) { // If already in redact mode, toggle annotation mode off and show redaction layer if (viewerContext?.isAnnotationMode) { + await saveAnnotationsIfNeeded(); + viewerContext.setAnnotationMode(false); // Deactivate any active annotation tools if (signatureApiRef?.current) { @@ -81,13 +124,33 @@ export default function ViewerAnnotationControls({ currentView, disabled = false activateTextSelection(); }, 100); } else { - // Exit redaction mode - go back to default view - navActions.handleToolSelect('allTools'); + // Exit redaction mode - keep viewer workbench and show all tools in sidebar + navActions.setToolAndWorkbench(null, 'viewer'); + setLeftPanelView('toolPicker'); setRedactionMode(false); } } else { + await saveAnnotationsIfNeeded(); + // Enter redaction mode - select redact tool with manual mode - navActions.handleToolSelect('redact'); + // If we're already in the viewer, keep the viewer workbench and open the tool sidebar + if (workbench === 'viewer') { + // Set redaction config to manual mode when opening from viewer + const manualConfig: RedactParameters = { + ...defaultParameters, + mode: 'manual', + }; + setRedactionConfig(manualConfig); + + // Set tool and keep viewer workbench + navActions.setToolAndWorkbench('redact', 'viewer'); + + // Ensure sidebars are visible and open tool content + setSidebarsVisible(true); + setLeftPanelView('toolContent'); + } else { + navActions.handleToolSelect('redact'); + } setRedactionMode(true); // Activate text selection mode after a short delay setTimeout(() => { @@ -99,7 +162,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false return ( <> {/* Redaction Mode Toggle */} - + {/* Mark Text Selection Tool */} + + {pendingCount === 0 && ( diff --git a/frontend/src/core/components/tooltips/useRedactTips.ts b/frontend/src/core/components/tooltips/useRedactTips.ts index 7689a6a2a..e76cb032f 100644 --- a/frontend/src/core/components/tooltips/useRedactTips.ts +++ b/frontend/src/core/components/tooltips/useRedactTips.ts @@ -77,3 +77,32 @@ export const useRedactAdvancedTips = (): TooltipContent => { ] }; }; + +export const useRedactManualTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("redact.tooltip.manual.header.title", "Manual Redaction Controls") + }, + tips: [ + { + title: t("redact.tooltip.manual.markText.title", "Mark Text Tool"), + description: t("redact.tooltip.manual.markText.text", "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact."), + }, + { + title: t("redact.tooltip.manual.markArea.title", "Mark Area Tool"), + description: t("redact.tooltip.manual.markArea.text", "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes."), + }, + { + title: t("redact.tooltip.manual.apply.title", "Apply Redactions"), + description: t("redact.tooltip.manual.apply.text", "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied."), + bullets: [ + t("redact.tooltip.manual.apply.bullet1", "Mark as many areas as needed before applying"), + t("redact.tooltip.manual.apply.bullet2", "All pending redactions are applied at once"), + t("redact.tooltip.manual.apply.bullet3", "Redactions cannot be undone after applying") + ] + } + ] + }; +}; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 97838c33c..cb14d28e1 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -73,7 +73,7 @@ const EmbedPdfViewerContent = ({ const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature(); // Get redaction context - const { isRedactionMode } = useRedaction(); + const { isRedactionMode, redactionsApplied, setRedactionsApplied } = useRedaction(); // Ref for redaction pending tracker API const redactionTrackerRef = useRef(null); @@ -224,23 +224,39 @@ const EmbedPdfViewerContent = ({ }; }, [isViewerHovered]); - // Register checker for unsaved changes (annotations only for now) + // Register checker for unsaved changes + // In redact mode: check for pending (unapplied) OR applied but not saved redactions + // In other modes: check annotation history useEffect(() => { if (previewFile) { return; } const checkForChanges = () => { - // Check for annotation changes via history - const hasAnnotationChanges = historyApiRef.current?.canUndo() || false; // Check for pending redactions const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0; + // Check for annotation history changes + const hasAnnotationChanges = historyApiRef.current?.canUndo() || false; - console.log('[Viewer] Checking for unsaved changes:', { - hasAnnotationChanges, - hasPendingRedactions - }); - return hasAnnotationChanges || hasPendingRedactions; + // Always consider applied redactions as unsaved until export + const hasAppliedRedactions = redactionsApplied; + + // When in redact mode, still include annotation changes (users may draw) + if (isManualRedactMode) { + console.log('[Viewer] Checking for unsaved changes (redact mode):', { + hasPendingRedactions, + hasAppliedRedactions, + hasAnnotationChanges, + }); + } else { + console.log('[Viewer] Checking for unsaved changes:', { + hasAnnotationChanges, + hasPendingRedactions, + hasAppliedRedactions, + }); + } + + return hasAnnotationChanges || hasPendingRedactions || hasAppliedRedactions; }; console.log('[Viewer] Registering unsaved changes checker'); @@ -250,7 +266,7 @@ const EmbedPdfViewerContent = ({ console.log('[Viewer] Unregistering unsaved changes checker'); unregisterUnsavedChangesChecker(); }; - }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); + }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, isManualRedactMode, redactionsApplied]); // Apply changes - save annotations and redactions to new file version const applyChanges = useCallback(async () => { @@ -289,11 +305,13 @@ const EmbedPdfViewerContent = ({ // Step 4: Consume files (replace in context) await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); + // Clear unsaved changes flags setHasUnsavedChanges(false); + setRedactionsApplied(false); } catch (error) { console.error('Apply changes failed:', error); } - }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); + }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied]); const sidebarWidthRem = 15; const totalRightMargin = diff --git a/frontend/src/core/components/viewer/RedactionAPIBridge.tsx b/frontend/src/core/components/viewer/RedactionAPIBridge.tsx index f930d1281..d79761df3 100644 --- a/frontend/src/core/components/viewer/RedactionAPIBridge.tsx +++ b/frontend/src/core/components/viewer/RedactionAPIBridge.tsx @@ -1,6 +1,6 @@ import { useEffect, useImperativeHandle } from 'react'; import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react'; -import { useRedaction, RedactionAPI } from '@app/contexts/RedactionContext'; +import { useRedaction } from '@app/contexts/RedactionContext'; /** * RedactionAPIBridge connects the EmbedPDF redaction plugin to our RedactionContext. diff --git a/frontend/src/core/components/viewer/RedactionPendingTracker.tsx b/frontend/src/core/components/viewer/RedactionPendingTracker.tsx index 8efbde6f5..2865b49a6 100644 --- a/frontend/src/core/components/viewer/RedactionPendingTracker.tsx +++ b/frontend/src/core/components/viewer/RedactionPendingTracker.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useImperativeHandle, forwardRef } from 'react'; import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react'; -import { useNavigationGuard } from '@app/contexts/NavigationContext'; export interface RedactionPendingTrackerAPI { commitAllPending: () => void; @@ -8,14 +7,16 @@ export interface RedactionPendingTrackerAPI { } /** - * RedactionPendingTracker monitors pending redactions and integrates with - * the navigation guard to warn users about unsaved changes. + * RedactionPendingTracker monitors pending redactions and exposes an API + * for committing and checking pending redactions. * Must be rendered inside the EmbedPDF context. + * + * Note: The unsaved changes checker is registered by EmbedPdfViewer, not here, + * to avoid conflicts and allow the viewer to check both annotations and redactions. */ export const RedactionPendingTracker = forwardRef( function RedactionPendingTracker(_, ref) { const { state, provides } = useEmbedPdfRedaction(); - const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, setHasUnsavedChanges } = useNavigationGuard(); const pendingCountRef = useRef(0); @@ -32,26 +33,7 @@ export const RedactionPendingTracker = forwardRef( // Update ref when pending count changes useEffect(() => { pendingCountRef.current = state?.pendingCount ?? 0; - - // Also update the hasUnsavedChanges state - if (pendingCountRef.current > 0) { - setHasUnsavedChanges(true); - } - }, [state?.pendingCount, setHasUnsavedChanges]); - - // Register checker for pending redactions - useEffect(() => { - const checkForPendingRedactions = () => { - const hasPending = pendingCountRef.current > 0; - return hasPending; - }; - - registerUnsavedChangesChecker(checkForPendingRedactions); - - return () => { - unregisterUnsavedChangesChecker(); - }; - }, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); + }, [state?.pendingCount]); return null; } diff --git a/frontend/src/core/components/viewer/RedactionSelectionMenu.tsx b/frontend/src/core/components/viewer/RedactionSelectionMenu.tsx index 5ef127b09..8328615f1 100644 --- a/frontend/src/core/components/viewer/RedactionSelectionMenu.tsx +++ b/frontend/src/core/components/viewer/RedactionSelectionMenu.tsx @@ -1,64 +1,135 @@ import { useRedaction as useEmbedPdfRedaction, SelectionMenuProps } from '@embedpdf/plugin-redaction/react'; import { ActionIcon, Tooltip, Button, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; +import { useEffect, useState, useRef, useCallback } from 'react'; import DeleteIcon from '@mui/icons-material/Delete'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; /** * Custom menu component that appears when a pending redaction mark is selected. * Allows users to remove or apply individual pending marks. + * Uses a portal to ensure it appears above all content, including next pages. */ export function RedactionSelectionMenu({ item, selected, menuWrapperProps }: SelectionMenuProps) { + const { t } = useTranslation(); const { provides } = useEmbedPdfRedaction(); + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); + const wrapperRef = useRef(null); - if (!selected || !item) return null; - - const handleRemove = () => { - if (provides?.removePending) { + // Merge refs if menuWrapperProps has a ref + const setRef = useCallback((node: HTMLDivElement | null) => { + wrapperRef.current = node; + if (menuWrapperProps?.ref) { + const ref = menuWrapperProps.ref; + if (typeof ref === 'function') { + ref(node); + } else if (ref && 'current' in ref) { + (ref as React.MutableRefObject).current = node; + } + } + }, [menuWrapperProps]); + + const handleRemove = useCallback(() => { + if (provides?.removePending && item) { provides.removePending(item.page, item.id); } - }; + }, [provides, item]); - const handleApply = () => { - if (provides?.commitPending) { + const handleApply = useCallback(() => { + if (provides?.commitPending && item) { provides.commitPending(item.page, item.id); } - }; + }, [provides, item]); - return ( -
-
+ // Calculate position for portal based on wrapper element + useEffect(() => { + if (!selected || !item || !wrapperRef.current) { + setMenuPosition(null); + return; + } + + const updatePosition = () => { + const wrapper = wrapperRef.current; + if (!wrapper) { + setMenuPosition(null); + return; + } + + const rect = wrapper.getBoundingClientRect(); + // Position menu below the wrapper, centered + // Use getBoundingClientRect which gives viewport-relative coordinates + // Since we're using fixed positioning in the portal, we don't need to add scroll offsets + setMenuPosition({ + top: rect.bottom + 8, + left: rect.left + rect.width / 2, + }); + }; + + updatePosition(); + + // Update position on scroll/resize + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [selected, item]); + + // Early return AFTER all hooks have been called + if (!selected || !item) return null; + + const menuContent = menuPosition ? ( +
- +
-
+ ) : null; + + // Extract ref from menuWrapperProps to avoid conflicts + const { ref: _, ...wrapperPropsWithoutRef } = menuWrapperProps || {}; + + return ( + <> +
+ {typeof document !== 'undefined' && menuContent + ? createPortal(menuContent, document.body) + : null} + ); } diff --git a/frontend/src/core/contexts/RedactionContext.tsx b/frontend/src/core/contexts/RedactionContext.tsx index 1d6bb6eda..500c13cc7 100644 --- a/frontend/src/core/contexts/RedactionContext.tsx +++ b/frontend/src/core/contexts/RedactionContext.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react'; +import React, { createContext, useContext, useState, ReactNode, useCallback, useRef, useEffect } from 'react'; import { RedactParameters } from '@app/hooks/tools/redact/useRedactParameters'; +import { useNavigationGuard } from '@app/contexts/NavigationContext'; /** * API interface that the EmbedPDF bridge will implement @@ -73,6 +74,7 @@ const initialState: RedactionState = { export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [state, setState] = useState(initialState); const redactionApiRef = useRef(null); + const { setHasUnsavedChanges } = useNavigationGuard(); // Actions for tool configuration const setRedactionConfig = useCallback((config: RedactParameters | null) => { @@ -118,6 +120,13 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children })); }, []); + // Keep navigation guard aware of pending or applied redactions so we block navigation + useEffect(() => { + if (state.pendingCount > 0 || state.redactionsApplied) { + setHasUnsavedChanges(true); + } + }, [state.pendingCount, state.redactionsApplied, setHasUnsavedChanges]); + // Actions that call through to EmbedPDF API const activateTextSelection = useCallback(() => { if (redactionApiRef.current) { diff --git a/frontend/src/core/hooks/tools/redact/useRedactOperation.test.ts b/frontend/src/core/hooks/tools/redact/useRedactOperation.test.ts index 9e7460d85..a7a46bc3b 100644 --- a/frontend/src/core/hooks/tools/redact/useRedactOperation.test.ts +++ b/frontend/src/core/hooks/tools/redact/useRedactOperation.test.ts @@ -103,13 +103,14 @@ describe('buildRedactFormData', () => { expect(formData.get('convertPDFToImage')).toBe('false'); }); - test('should throw error for manual mode (not implemented)', () => { + test('should return empty form data for manual mode (handled client-side)', () => { const parameters: RedactParameters = { ...defaultParameters, mode: 'manual', }; - expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented'); + const formData = buildRedactFormData(parameters, mockFile); + expect(formData.get('fileInput')).toBeNull(); }); }); diff --git a/frontend/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/src/core/hooks/tools/redact/useRedactOperation.ts index fd3325247..25192d082 100644 --- a/frontend/src/core/hooks/tools/redact/useRedactOperation.ts +++ b/frontend/src/core/hooks/tools/redact/useRedactOperation.ts @@ -6,9 +6,10 @@ import { RedactParameters, defaultParameters } from '@app/hooks/tools/redact/use // Static configuration that can be used by both the hook and automation executor export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => { const formData = new FormData(); - formData.append("fileInput", file); + // For automatic mode we hit the backend and need full payload if (parameters.mode === 'automatic') { + formData.append("fileInput", file); // Convert array to newline-separated string as expected by backend formData.append("listOfText", parameters.wordsToRedact.join('\n')); formData.append("useRegex", parameters.useRegex.toString()); @@ -16,8 +17,10 @@ export const buildRedactFormData = (parameters: RedactParameters, file: File): F formData.append("redactColor", parameters.redactColor.replace('#', '')); formData.append("customPadding", parameters.customPadding.toString()); formData.append("convertPDFToImage", parameters.convertPDFToImage.toString()); + } else { + // Manual redaction uses EmbedPDF in-viewer; we don't call the API. + // Return an empty formData to satisfy shared interfaces without throwing. } - // Note: Manual mode is handled client-side via EmbedPDF, no formData needed return formData; }; @@ -31,8 +34,7 @@ export const redactOperationConfig = { if (parameters.mode === 'automatic') { return '/api/v1/security/auto-redact'; } - // Manual redaction is handled client-side via EmbedPDF - // Return null to indicate no server endpoint is needed + // Manual redaction is handled by EmbedPDF in the viewer; no endpoint call. return null; }, defaultParameters, diff --git a/frontend/src/core/hooks/tools/redact/useRedactParameters.test.ts b/frontend/src/core/hooks/tools/redact/useRedactParameters.test.ts index 40e350010..c11d98de5 100644 --- a/frontend/src/core/hooks/tools/redact/useRedactParameters.test.ts +++ b/frontend/src/core/hooks/tools/redact/useRedactParameters.test.ts @@ -84,14 +84,14 @@ describe('useRedactParameters', () => { expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact'); }); - test('should throw error for manual mode (not implemented)', () => { + test('should return null endpoint for manual mode (handled client-side)', () => { const { result } = renderHook(() => useRedactParameters()); act(() => { result.current.updateParameter('mode', 'manual'); }); - expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented'); + expect(result.current.getEndpointName()).toBeNull(); }); }); diff --git a/frontend/src/core/hooks/tools/redact/useRedactParameters.ts b/frontend/src/core/hooks/tools/redact/useRedactParameters.ts index 890f85721..b91cd2b72 100644 --- a/frontend/src/core/hooks/tools/redact/useRedactParameters.ts +++ b/frontend/src/core/hooks/tools/redact/useRedactParameters.ts @@ -34,16 +34,15 @@ export const useRedactParameters = (): RedactParametersHook => { if (params.mode === 'automatic') { return '/api/v1/security/auto-redact'; } - // Manual redaction is handled client-side via EmbedPDF - // Return null or a placeholder since we don't call an endpoint + // Manual redaction is handled in the viewer; no endpoint return null; }, validateFn: (params) => { if (params.mode === 'automatic') { return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0); } - // Manual mode is always valid since redaction is done in the viewer - return true; + // Manual mode is not yet supported via this flow + return false; } }); }; diff --git a/frontend/src/core/tools/Redact.tsx b/frontend/src/core/tools/Redact.tsx index 2172b008a..92cf8fc69 100644 --- a/frontend/src/core/tools/Redact.tsx +++ b/frontend/src/core/tools/Redact.tsx @@ -6,11 +6,11 @@ import { useRedactParameters, RedactMode } from "@app/hooks/tools/redact/useReda import { useRedactOperation } from "@app/hooks/tools/redact/useRedactOperation"; import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; -import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "@app/components/tooltips/useRedactTips"; +import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips, useRedactManualTips } from "@app/components/tooltips/useRedactTips"; import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings"; import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput"; import ManualRedactionControls from "@app/components/tools/redact/ManualRedactionControls"; -import { useNavigationActions } from "@app/contexts/NavigationContext"; +import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext"; import { useRedaction } from "@app/contexts/RedactionContext"; const Redact = (props: BaseToolProps) => { @@ -23,7 +23,8 @@ const Redact = (props: BaseToolProps) => { // Navigation and redaction context const { actions: navActions } = useNavigationActions(); - const { setRedactionConfig, setRedactionMode } = useRedaction(); + const { setRedactionConfig, setRedactionMode, redactionConfig } = useRedaction(); + const { workbench } = useNavigationState(); const hasOpenedViewer = useRef(false); const base = useBaseTool( @@ -37,6 +38,16 @@ const Redact = (props: BaseToolProps) => { const modeTips = useRedactModeTips(); const wordsTips = useRedactWordsTips(); const advancedTips = useRedactAdvancedTips(); + const manualTips = useRedactManualTips(); + + // Auto-set manual mode if we're in the viewer and redaction config is set to manual + // This ensures when opening redact from viewer, it automatically selects manual mode + useEffect(() => { + if (workbench === 'viewer' && redactionConfig?.mode === 'manual' && base.params.parameters.mode !== 'manual') { + // Set immediately when conditions are met + base.params.updateParameter('mode', 'manual'); + } + }, [workbench, redactionConfig, base.params.parameters.mode, base.params.updateParameter]); // Handle mode change - navigate to viewer when manual mode is selected const handleModeChange = (mode: RedactMode) => { @@ -133,7 +144,7 @@ const Redact = (props: BaseToolProps) => { title: t("redact.manual.controlsTitle", "Manual Redaction Controls"), isCollapsed: false, onCollapsedClick: () => {}, - tooltip: [], + tooltip: manualTips, content: , }); }