From 5485b06735636b86f8128f62febfa0829df43620 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Wed, 3 Dec 2025 14:25:21 +0000 Subject: [PATCH] Added bones for manual redaction, quickly saving so I can work on the text editor --- frontend/package-lock.json | 20 ++ frontend/package.json | 1 + frontend/src/core/components/AppProviders.tsx | 17 +- .../rightRail/ViewerAnnotationControls.tsx | 103 ++++++++- .../tools/redact/ManualRedactionControls.tsx | 201 ++++++++++++++++++ .../tools/redact/RedactModeSelector.tsx | 5 +- .../core/components/viewer/EmbedPdfViewer.tsx | 41 +++- .../core/components/viewer/LocalEmbedPDF.tsx | 35 ++- .../components/viewer/RedactionAPIBridge.tsx | 50 +++++ .../viewer/RedactionPendingTracker.tsx | 59 +++++ .../viewer/RedactionSelectionMenu.tsx | 80 +++++++ .../src/core/contexts/NavigationContext.tsx | 48 +++-- .../src/core/contexts/RedactionContext.tsx | 185 ++++++++++++++++ .../hooks/tools/redact/useRedactOperation.ts | 10 +- .../hooks/tools/redact/useRedactParameters.ts | 9 +- frontend/src/core/tools/Redact.tsx | 62 +++++- 16 files changed, 861 insertions(+), 65 deletions(-) create mode 100644 frontend/src/core/components/tools/redact/ManualRedactionControls.tsx create mode 100644 frontend/src/core/components/viewer/RedactionAPIBridge.tsx create mode 100644 frontend/src/core/components/viewer/RedactionPendingTracker.tsx create mode 100644 frontend/src/core/components/viewer/RedactionSelectionMenu.tsx create mode 100644 frontend/src/core/contexts/RedactionContext.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e1f885e6..2db56bbfd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@embedpdf/plugin-loader": "^1.5.0", "@embedpdf/plugin-pan": "^1.5.0", "@embedpdf/plugin-print": "^1.5.0", + "@embedpdf/plugin-redaction": "^1.5.0", "@embedpdf/plugin-render": "^1.5.0", "@embedpdf/plugin-rotate": "^1.5.0", "@embedpdf/plugin-scroll": "^1.5.0", @@ -759,6 +760,25 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/plugin-redaction": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-1.5.0.tgz", + "integrity": "sha512-txiukr5UKAGvJzl6dVBmmIT1v3r/t4e2qYm1hqU2faGgNCa2dwk79x9mDBlvWwxlJXCDFuFE+7Ps9/nU6qmU2w==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "1.5.0", + "@embedpdf/utils": "1.5.0" + }, + "peerDependencies": { + "@embedpdf/core": "1.5.0", + "@embedpdf/plugin-interaction-manager": "1.5.0", + "@embedpdf/plugin-selection": "1.5.0", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@embedpdf/plugin-render": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5489f6b46..25ae7ac8a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@embedpdf/plugin-loader": "^1.5.0", "@embedpdf/plugin-pan": "^1.5.0", "@embedpdf/plugin-print": "^1.5.0", + "@embedpdf/plugin-redaction": "^1.5.0", "@embedpdf/plugin-render": "^1.5.0", "@embedpdf/plugin-rotate": "^1.5.0", "@embedpdf/plugin-scroll": "^1.5.0", diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 7e47d00e0..0ad287559 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -12,6 +12,7 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from import { RightRailProvider } from "@app/contexts/RightRailContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; +import { RedactionProvider } from "@app/contexts/RedactionContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; @@ -93,13 +94,15 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - {children} - - - + + + + + {children} + + + + diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index cd95b90b9..822c3c83d 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -10,9 +10,10 @@ import { useFileState, useFileContext } from '@app/contexts/FileContext'; import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils'; import { createProcessedFile } from '@app/contexts/file/fileActions'; import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext'; -import { useNavigationState } from '@app/contexts/NavigationContext'; +import { useNavigationState, useNavigationGuard, useNavigationActions } from '@app/contexts/NavigationContext'; import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; +import { useRedactionMode, useRedaction } from '@app/contexts/RedactionContext'; interface ViewerAnnotationControlsProps { currentView: string; @@ -38,9 +39,16 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const { actions: fileActions } = useFileContext(); const activeFiles = selectors.getFiles(); - // Check if we're in sign mode + // Check if we're in sign mode or redaction mode const { selectedTool } = 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 { requestNavigation } = useNavigationGuard(); + const { setRedactionMode, activateTextSelection } = useRedaction(); // Turn off annotation mode when switching away from viewer useEffect(() => { @@ -54,8 +62,60 @@ export default function ViewerAnnotationControls({ currentView, disabled = false return null; } + // Handle redaction mode toggle + const handleRedactionToggle = () => { + if (isRedactMode) { + // If already in redact mode, toggle annotation mode off and show redaction layer + if (viewerContext?.isAnnotationMode) { + viewerContext.setAnnotationMode(false); + // Deactivate any active annotation tools + if (signatureApiRef?.current) { + try { + signatureApiRef.current.deactivateTools(); + } catch (error) { + console.log('Unable to deactivate annotation tools:', error); + } + } + // Activate redaction tool + setTimeout(() => { + activateTextSelection(); + }, 100); + } else { + // Exit redaction mode - go back to default view + navActions.handleToolSelect('allTools'); + setRedactionMode(false); + } + } else { + // Enter redaction mode - select redact tool with manual mode + navActions.handleToolSelect('redact'); + setRedactionMode(true); + // Activate text selection mode after a short delay + setTimeout(() => { + activateTextSelection(); + }, 200); + } + }; + return ( <> + {/* Redaction Mode Toggle */} + + + + + + {/* Annotation Visibility Toggle */} @@ -140,14 +200,35 @@ export default function ViewerAnnotationControls({ currentView, disabled = false radius="md" className="right-rail-icon" onClick={() => { - viewerContext?.toggleAnnotationMode(); - // Activate ink drawing tool when entering annotation mode - if (signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.activateDrawMode(); - signatureApiRef.current.updateDrawSettings(selectedColor, 2); - } catch (error) { - console.log('Signature API not ready:', error); + const activateDrawMode = () => { + // Use setTimeout to ensure this runs after any state updates from applyChanges + setTimeout(() => { + viewerContext?.setAnnotationMode(true); + // Activate ink drawing tool when entering annotation mode + if (signatureApiRef?.current && currentView === 'viewer') { + try { + signatureApiRef.current.activateDrawMode(); + signatureApiRef.current.updateDrawSettings(selectedColor, 2); + } catch (error) { + console.log('Signature API not ready:', error); + } + } + }, 150); + }; + + // If in redaction mode with pending redactions, show warning modal + if (isRedactMode && redactionPendingCount > 0) { + requestNavigation(activateDrawMode); + } else { + // Direct activation - no need for delay + viewerContext?.toggleAnnotationMode(); + if (signatureApiRef?.current && currentView === 'viewer') { + try { + signatureApiRef.current.activateDrawMode(); + signatureApiRef.current.updateDrawSettings(selectedColor, 2); + } catch (error) { + console.log('Signature API not ready:', error); + } } } }} diff --git a/frontend/src/core/components/tools/redact/ManualRedactionControls.tsx b/frontend/src/core/components/tools/redact/ManualRedactionControls.tsx new file mode 100644 index 000000000..f944c34e3 --- /dev/null +++ b/frontend/src/core/components/tools/redact/ManualRedactionControls.tsx @@ -0,0 +1,201 @@ +import { useTranslation } from 'react-i18next'; +import { useEffect, useRef } from 'react'; +import { Button, Stack, Text, Badge, Group, Divider } from '@mantine/core'; +import HighlightAltIcon from '@mui/icons-material/HighlightAlt'; +import CropFreeIcon from '@mui/icons-material/CropFree'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { useSignature } from '@app/contexts/SignatureContext'; + +interface ManualRedactionControlsProps { + disabled?: boolean; +} + +/** + * ManualRedactionControls provides UI for manual PDF redaction in the tool panel. + * Displays controls for marking text/areas for redaction and applying them. + * Uses our RedactionContext which bridges to the EmbedPDF API. + */ +export default function ManualRedactionControls({ disabled = false }: ManualRedactionControlsProps) { + const { t } = useTranslation(); + + // Use our RedactionContext which bridges to EmbedPDF + const { activateTextSelection, activateMarquee, commitAllPending, redactionApiRef } = useRedaction(); + const { pendingCount, activeType, isRedacting } = useRedactionMode(); + + // Get viewer context to manage annotation mode + const { isAnnotationMode, setAnnotationMode } = useViewer(); + + // Get signature context to deactivate annotation tools when switching to redaction + const { signatureApiRef } = useSignature(); + + // Check which tool is active based on activeType + const isSelectionActive = activeType === 'redactSelection'; + const isMarqueeActive = activeType === 'marqueeRedact'; + + // Track if we've auto-activated + const hasAutoActivated = useRef(false); + + // Auto-activate selection mode when the API becomes available + // This ensures at least one tool is selected when entering manual redaction mode + useEffect(() => { + if (redactionApiRef.current && !disabled && !isRedacting && !hasAutoActivated.current) { + hasAutoActivated.current = true; + // Small delay to ensure EmbedPDF is fully ready + const timer = setTimeout(() => { + // Deactivate annotation mode to show redaction layer + setAnnotationMode(false); + activateTextSelection(); + }, 100); + return () => clearTimeout(timer); + } + }, [redactionApiRef.current, disabled, isRedacting, activateTextSelection, setAnnotationMode]); + + // Reset auto-activation flag when disabled changes + useEffect(() => { + if (disabled) { + hasAutoActivated.current = false; + } + }, [disabled]); + + const handleSelectionClick = () => { + // 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); + } + } + } + + if (isSelectionActive && !isAnnotationMode) { + // If already active and not coming from annotation mode, switch to marquee + activateMarquee(); + } else { + activateTextSelection(); + } + }; + + const handleMarqueeClick = () => { + // 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); + } + } + } + + if (isMarqueeActive && !isAnnotationMode) { + // If already active and not coming from annotation mode, switch to selection + activateTextSelection(); + } else { + activateMarquee(); + } + }; + + const handleApplyAll = () => { + commitAllPending(); + }; + + // Check if API is available + const isApiReady = redactionApiRef.current !== null; + + return ( + <> + + + + {t('redact.manual.title', 'Redaction Tools')} + + + + {t('redact.manual.instructions', 'Select text or draw areas on the PDF to mark content for redaction.')} + + + + {/* Mark Text Selection Tool */} + + + {/* Mark Area (Marquee) Tool */} + + + + + + {/* Pending Count and Apply Button */} + + + + {t('redact.manual.pendingLabel', 'Pending:')} + + 0 ? 'red' : 'gray'} + variant="filled" + size="lg" + > + {pendingCount} + + + + + + + {pendingCount === 0 && ( + + {t('redact.manual.noMarks', 'No redaction marks. Use the tools above to mark content for redaction.')} + + )} + + + ); +} + diff --git a/frontend/src/core/components/tools/redact/RedactModeSelector.tsx b/frontend/src/core/components/tools/redact/RedactModeSelector.tsx index 47f15da66..f4a071af5 100644 --- a/frontend/src/core/components/tools/redact/RedactModeSelector.tsx +++ b/frontend/src/core/components/tools/redact/RedactModeSelector.tsx @@ -6,9 +6,10 @@ interface RedactModeSelectorProps { mode: RedactMode; onModeChange: (mode: RedactMode) => void; disabled?: boolean; + hasFiles?: boolean; } -export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) { +export default function RedactModeSelector({ mode, onModeChange, disabled, hasFiles = false }: RedactModeSelectorProps) { const { t } = useTranslation(); return ( @@ -24,7 +25,7 @@ export default function RedactModeSelector({ mode, onModeChange, disabled }: Red { value: 'manual' as const, label: t('redact.modeSelector.manual', 'Manual'), - disabled: true, // Keep manual mode disabled until implemented + disabled: !hasFiles, // Enable manual mode when files are present }, ]} disabled={disabled} diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 9ca8bac0a..97838c33c 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -11,6 +11,8 @@ import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar'; import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar'; import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext'; import { useSignature } from '@app/contexts/SignatureContext'; +import { useRedaction } from '@app/contexts/RedactionContext'; +import type { RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker'; import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers'; import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; import { isStirlingFile } from '@app/types/fileContext'; @@ -70,6 +72,12 @@ const EmbedPdfViewerContent = ({ // Get signature context const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature(); + // Get redaction context + const { isRedactionMode } = useRedaction(); + + // Ref for redaction pending tracker API + const redactionTrackerRef = useRef(null); + // Get current file from FileContext const { selectors, state } = useFileState(); const { actions } = useFileActions(); @@ -80,13 +88,20 @@ const EmbedPdfViewerContent = ({ // Navigation guard for unsaved changes const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); - // Check if we're in signature mode OR viewer annotation mode + // Check if we're in signature mode OR viewer annotation mode OR redaction mode const { selectedTool } = useNavigationState(); // Tools that use the stamp/signature placement system with hover preview const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; + // Check if we're in manual redaction mode + const isManualRedactMode = selectedTool === 'redact'; // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations - const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; + // When in manual redaction mode, annotation mode takes priority if active (user clicked draw tool) + const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || (isAnnotationsVisible && !isManualRedactMode); + + // Enable redaction when the redact tool is selected and annotation mode is NOT active + // This allows switching between redaction and annotation tools while redact is the selected tool + const shouldEnableRedaction = (isManualRedactMode || isRedactionMode) && !isAnnotationMode; const isPlacementOverlayActive = Boolean( isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig ); @@ -218,11 +233,14 @@ const EmbedPdfViewerContent = ({ 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; console.log('[Viewer] Checking for unsaved changes:', { - hasAnnotationChanges + hasAnnotationChanges, + hasPendingRedactions }); - return hasAnnotationChanges; + return hasAnnotationChanges || hasPendingRedactions; }; console.log('[Viewer] Registering unsaved changes checker'); @@ -234,12 +252,20 @@ const EmbedPdfViewerContent = ({ }; }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); - // Apply changes - save annotations to new file version + // Apply changes - save annotations and redactions to new file version const applyChanges = useCallback(async () => { if (!currentFile || activeFileIds.length === 0) return; try { - console.log('[Viewer] Applying changes - exporting PDF with annotations'); + console.log('[Viewer] Applying changes - exporting PDF with annotations/redactions'); + + // Step 0: Commit any pending redactions before export + if (redactionTrackerRef.current?.getPendingCount() ?? 0 > 0) { + console.log('[Viewer] Committing pending redactions before export'); + redactionTrackerRef.current?.commitAllPending(); + // Give a small delay for the commit to process + await new Promise(resolve => setTimeout(resolve, 100)); + } // Step 1: Export PDF with annotations using EmbedPDF const arrayBuffer = await exportActions.saveAsCopy(); @@ -322,8 +348,11 @@ const EmbedPdfViewerContent = ({ file={effectiveFile.file} url={effectiveFile.url} enableAnnotations={shouldEnableAnnotations} + enableRedaction={shouldEnableRedaction} + isManualRedactionMode={isManualRedactMode} signatureApiRef={signatureApiRef as React.RefObject} historyApiRef={historyApiRef as React.RefObject} + redactionTrackerRef={redactionTrackerRef as React.RefObject} onSignatureAdded={() => { // Handle signature added - for debugging, enable console logs as needed // Future: Handle signature completion diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 3e5fa5f1c..0a982ab42 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -21,11 +21,10 @@ import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark'; import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; - -// Import annotation plugins import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react'; import { PdfAnnotationSubtype } from '@embedpdf/models'; +import { RedactionPluginPackage, RedactionLayer } from '@embedpdf/plugin-redaction/react'; import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer'; import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge'; import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback'; @@ -46,17 +45,24 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge'; import { isPdfFile } from '@app/utils/fileUtils'; import { useTranslation } from 'react-i18next'; import { LinkLayer } from '@app/components/viewer/LinkLayer'; +import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu'; +import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker'; +import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge'; interface LocalEmbedPDFProps { file?: File | Blob; url?: string | null; enableAnnotations?: boolean; + enableRedaction?: boolean; + /** When true, RedactionAPIBridge is rendered even if enableRedaction is false (for switching between annotation/redaction) */ + isManualRedactionMode?: boolean; onSignatureAdded?: (annotation: any) => void; signatureApiRef?: React.RefObject; historyApiRef?: React.RefObject; + redactionTrackerRef?: React.RefObject; } -export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) { +export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, onSignatureAdded, signatureApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) { const { t } = useTranslation(); const [pdfUrl, setPdfUrl] = useState(null); const [, setAnnotations] = useState>([]); @@ -121,6 +127,11 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur selectAfterCreate: true, }), + // Register redaction plugin (depends on InteractionManager, Selection) + createPluginRegistration(RedactionPluginPackage, { + drawBlackBoxes: true, // Draw black boxes over redacted content + }), + // Register pan plugin (depends on Viewport, InteractionManager) createPluginRegistration(PanPluginPackage, { defaultMode: 'mobile', // Try mobile mode which might be more permissive @@ -305,8 +316,12 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur - {enableAnnotations && } - {enableAnnotations && } + {/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */} + + {(enableAnnotations || enableRedaction || isManualRedactionMode) && } + {/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */} + {(enableRedaction || isManualRedactionMode) && } + {(enableRedaction || isManualRedactionMode) && } @@ -373,6 +388,16 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur selectionOutlineColor="#007ACC" /> )} + + {/* Redaction layer for marking areas to redact (only when enabled) */} + {enableRedaction && ( + } + /> + )} diff --git a/frontend/src/core/components/viewer/RedactionAPIBridge.tsx b/frontend/src/core/components/viewer/RedactionAPIBridge.tsx new file mode 100644 index 000000000..f930d1281 --- /dev/null +++ b/frontend/src/core/components/viewer/RedactionAPIBridge.tsx @@ -0,0 +1,50 @@ +import { useEffect, useImperativeHandle } from 'react'; +import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react'; +import { useRedaction, RedactionAPI } from '@app/contexts/RedactionContext'; + +/** + * RedactionAPIBridge connects the EmbedPDF redaction plugin to our RedactionContext. + * It must be rendered inside the EmbedPDF context to access the plugin API. + * + * It does two things: + * 1. Syncs EmbedPDF state (pendingCount, activeType, isRedacting) to our context + * 2. Exposes the EmbedPDF API through our context's ref so outside components can call it + */ +export function RedactionAPIBridge() { + const { state, provides } = useEmbedPdfRedaction(); + const { + redactionApiRef, + setPendingCount, + setActiveType, + setIsRedacting, + setRedactionsApplied + } = useRedaction(); + + // Sync EmbedPDF state to our context + useEffect(() => { + if (state) { + setPendingCount(state.pendingCount ?? 0); + setActiveType(state.activeType ?? null); + setIsRedacting(state.isRedacting ?? false); + } + }, [state?.pendingCount, state?.activeType, state?.isRedacting, setPendingCount, setActiveType, setIsRedacting]); + + // Expose the EmbedPDF API through our context's ref + useImperativeHandle(redactionApiRef, () => ({ + toggleRedactSelection: () => { + provides?.toggleRedactSelection(); + }, + toggleMarqueeRedact: () => { + provides?.toggleMarqueeRedact(); + }, + commitAllPending: () => { + provides?.commitAllPending(); + setRedactionsApplied(true); + }, + getActiveType: () => state?.activeType ?? null, + getPendingCount: () => state?.pendingCount ?? 0, + }), [provides, state, setRedactionsApplied]); + + return null; +} + diff --git a/frontend/src/core/components/viewer/RedactionPendingTracker.tsx b/frontend/src/core/components/viewer/RedactionPendingTracker.tsx new file mode 100644 index 000000000..8efbde6f5 --- /dev/null +++ b/frontend/src/core/components/viewer/RedactionPendingTracker.tsx @@ -0,0 +1,59 @@ +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; + getPendingCount: () => number; +} + +/** + * RedactionPendingTracker monitors pending redactions and integrates with + * the navigation guard to warn users about unsaved changes. + * Must be rendered inside the EmbedPDF context. + */ +export const RedactionPendingTracker = forwardRef( + function RedactionPendingTracker(_, ref) { + const { state, provides } = useEmbedPdfRedaction(); + const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, setHasUnsavedChanges } = useNavigationGuard(); + + const pendingCountRef = useRef(0); + + // Expose API through ref + useImperativeHandle(ref, () => ({ + commitAllPending: () => { + if (provides?.commitAllPending) { + provides.commitAllPending(); + } + }, + getPendingCount: () => pendingCountRef.current, + }), [provides]); + + // 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]); + + return null; + } +); + diff --git a/frontend/src/core/components/viewer/RedactionSelectionMenu.tsx b/frontend/src/core/components/viewer/RedactionSelectionMenu.tsx new file mode 100644 index 000000000..5ef127b09 --- /dev/null +++ b/frontend/src/core/components/viewer/RedactionSelectionMenu.tsx @@ -0,0 +1,80 @@ +import { useRedaction as useEmbedPdfRedaction, SelectionMenuProps } from '@embedpdf/plugin-redaction/react'; +import { ActionIcon, Tooltip, Button, Group } from '@mantine/core'; +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. + */ +export function RedactionSelectionMenu({ item, selected, menuWrapperProps }: SelectionMenuProps) { + const { provides } = useEmbedPdfRedaction(); + + if (!selected || !item) return null; + + const handleRemove = () => { + if (provides?.removePending) { + provides.removePending(item.page, item.id); + } + }; + + const handleApply = () => { + if (provides?.commitPending) { + provides.commitPending(item.page, item.id); + } + }; + + return ( +
+
+ + + + + + + + + + + +
+
+ ); +} + diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx index 500a6db5e..33645e3e5 100644 --- a/frontend/src/core/contexts/NavigationContext.tsx +++ b/frontend/src/core/contexts/NavigationContext.tsx @@ -230,25 +230,37 @@ export const NavigationProvider: React.FC<{ }, []), handleToolSelect: useCallback((toolId: string) => { - if (toolId === 'allTools') { - dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } }); - return; + const performToolSelect = () => { + if (toolId === 'allTools') { + dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } }); + return; + } + + if (toolId === 'read' || toolId === 'view-pdf') { + dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } }); + return; + } + + // Look up the tool in the registry to get its proper workbench + const tool = isValidToolId(toolId)? toolRegistry[toolId] : null; + const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench(); + + // Validate toolId and convert to ToolId type + const validToolId = isValidToolId(toolId) ? toolId : null; + dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } }); + }; + + // Check for unsaved changes using registered checker or state + const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; + + // If switching away from current tool and have unsaved changes, show warning + if (hasUnsavedChanges && state.selectedTool && state.selectedTool !== toolId) { + dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performToolSelect } }); + dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); + } else { + performToolSelect(); } - - if (toolId === 'read' || toolId === 'view-pdf') { - dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } }); - return; - } - - // Look up the tool in the registry to get its proper workbench - - const tool = isValidToolId(toolId)? toolRegistry[toolId] : null; - const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench(); - - // Validate toolId and convert to ToolId type - const validToolId = isValidToolId(toolId) ? toolId : null; - dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } }); - }, [toolRegistry]) + }, [toolRegistry, state.hasUnsavedChanges, state.selectedTool]) }; const stateValue: NavigationContextStateValue = { diff --git a/frontend/src/core/contexts/RedactionContext.tsx b/frontend/src/core/contexts/RedactionContext.tsx new file mode 100644 index 000000000..1d6bb6eda --- /dev/null +++ b/frontend/src/core/contexts/RedactionContext.tsx @@ -0,0 +1,185 @@ +import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react'; +import { RedactParameters } from '@app/hooks/tools/redact/useRedactParameters'; + +/** + * API interface that the EmbedPDF bridge will implement + */ +export interface RedactionAPI { + toggleRedactSelection: () => void; + toggleMarqueeRedact: () => void; + commitAllPending: () => void; + getActiveType: () => 'redactSelection' | 'marqueeRedact' | null; + getPendingCount: () => number; +} + +/** + * State interface for redaction operations + */ +interface RedactionState { + // Current redaction configuration from the tool + redactionConfig: RedactParameters | null; + // Whether we're in redaction mode (viewer should show redaction layer) + isRedactionMode: boolean; + // Whether redactions have been applied + redactionsApplied: boolean; + // Synced state from EmbedPDF + pendingCount: number; + activeType: 'redactSelection' | 'marqueeRedact' | null; + isRedacting: boolean; +} + +/** + * Actions interface for redaction operations + */ +interface RedactionActions { + setRedactionConfig: (config: RedactParameters | null) => void; + setRedactionMode: (enabled: boolean) => void; + setRedactionsApplied: (applied: boolean) => void; + // Synced state setters (called from inside EmbedPDF) + setPendingCount: (count: number) => void; + setActiveType: (type: 'redactSelection' | 'marqueeRedact' | null) => void; + setIsRedacting: (isRedacting: boolean) => void; + // Actions that call through to EmbedPDF API + activateTextSelection: () => void; + activateMarquee: () => void; + commitAllPending: () => void; +} + +/** + * Combined context interface + */ +interface RedactionContextValue extends RedactionState, RedactionActions { + // Ref that the bridge component will populate + redactionApiRef: React.MutableRefObject; +} + +// Create context +const RedactionContext = createContext(undefined); + +// Initial state +const initialState: RedactionState = { + redactionConfig: null, + isRedactionMode: false, + redactionsApplied: false, + pendingCount: 0, + activeType: null, + isRedacting: false, +}; + +/** + * Provider component for redaction functionality + * Bridges between the tool panel (outside EmbedPDF) and the viewer (inside EmbedPDF) + */ +export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [state, setState] = useState(initialState); + const redactionApiRef = useRef(null); + + // Actions for tool configuration + const setRedactionConfig = useCallback((config: RedactParameters | null) => { + setState(prev => ({ + ...prev, + redactionConfig: config, + })); + }, []); + + const setRedactionMode = useCallback((enabled: boolean) => { + setState(prev => ({ + ...prev, + isRedactionMode: enabled, + })); + }, []); + + const setRedactionsApplied = useCallback((applied: boolean) => { + setState(prev => ({ + ...prev, + redactionsApplied: applied, + })); + }, []); + + // Synced state setters (called from bridge inside EmbedPDF) + const setPendingCount = useCallback((count: number) => { + setState(prev => ({ + ...prev, + pendingCount: count, + })); + }, []); + + const setActiveType = useCallback((type: 'redactSelection' | 'marqueeRedact' | null) => { + setState(prev => ({ + ...prev, + activeType: type, + })); + }, []); + + const setIsRedacting = useCallback((isRedacting: boolean) => { + setState(prev => ({ + ...prev, + isRedacting, + })); + }, []); + + // Actions that call through to EmbedPDF API + const activateTextSelection = useCallback(() => { + if (redactionApiRef.current) { + redactionApiRef.current.toggleRedactSelection(); + } + }, []); + + const activateMarquee = useCallback(() => { + if (redactionApiRef.current) { + redactionApiRef.current.toggleMarqueeRedact(); + } + }, []); + + const commitAllPending = useCallback(() => { + if (redactionApiRef.current) { + redactionApiRef.current.commitAllPending(); + } + }, []); + + const contextValue: RedactionContextValue = { + ...state, + redactionApiRef, + setRedactionConfig, + setRedactionMode, + setRedactionsApplied, + setPendingCount, + setActiveType, + setIsRedacting, + activateTextSelection, + activateMarquee, + commitAllPending, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook to use redaction context + */ +export const useRedaction = (): RedactionContextValue => { + const context = useContext(RedactionContext); + if (context === undefined) { + throw new Error('useRedaction must be used within a RedactionProvider'); + } + return context; +}; + +/** + * Hook for components that need to check if redaction mode is active + */ +export const useRedactionMode = () => { + const context = useContext(RedactionContext); + return { + isRedactionModeActive: context?.isRedactionMode || false, + hasRedactionConfig: context?.redactionConfig !== null, + pendingCount: context?.pendingCount || 0, + activeType: context?.activeType || null, + isRedacting: context?.isRedacting || false, + }; +}; + diff --git a/frontend/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/src/core/hooks/tools/redact/useRedactOperation.ts index bf2a05121..fd3325247 100644 --- a/frontend/src/core/hooks/tools/redact/useRedactOperation.ts +++ b/frontend/src/core/hooks/tools/redact/useRedactOperation.ts @@ -16,10 +16,8 @@ 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 mode parameters would go here when implemented - throw new Error('Manual redaction not yet implemented'); } + // Note: Manual mode is handled client-side via EmbedPDF, no formData needed return formData; }; @@ -32,10 +30,10 @@ export const redactOperationConfig = { endpoint: (parameters: RedactParameters) => { if (parameters.mode === 'automatic') { return '/api/v1/security/auto-redact'; - } else { - // Manual redaction endpoint would go here when implemented - throw new Error('Manual redaction not yet implemented'); } + // Manual redaction is handled client-side via EmbedPDF + // Return null to indicate no server endpoint is needed + return null; }, defaultParameters, } as const; diff --git a/frontend/src/core/hooks/tools/redact/useRedactParameters.ts b/frontend/src/core/hooks/tools/redact/useRedactParameters.ts index f29f56f96..890f85721 100644 --- a/frontend/src/core/hooks/tools/redact/useRedactParameters.ts +++ b/frontend/src/core/hooks/tools/redact/useRedactParameters.ts @@ -34,15 +34,16 @@ export const useRedactParameters = (): RedactParametersHook => { if (params.mode === 'automatic') { return '/api/v1/security/auto-redact'; } - // Manual redaction endpoint would go here when implemented - throw new Error('Manual redaction not yet implemented'); + // Manual redaction is handled client-side via EmbedPDF + // Return null or a placeholder since we don't call an endpoint + return null; }, validateFn: (params) => { if (params.mode === 'automatic') { return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0); } - // Manual mode validation would go here when implemented - return false; + // Manual mode is always valid since redaction is done in the viewer + return true; } }); }; diff --git a/frontend/src/core/tools/Redact.tsx b/frontend/src/core/tools/Redact.tsx index 27604da0b..2172b008a 100644 --- a/frontend/src/core/tools/Redact.tsx +++ b/frontend/src/core/tools/Redact.tsx @@ -1,14 +1,17 @@ import { useTranslation } from "react-i18next"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector"; -import { useRedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; +import { useRedactParameters, RedactMode } from "@app/hooks/tools/redact/useRedactParameters"; 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 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 { useRedaction } from "@app/contexts/RedactionContext"; const Redact = (props: BaseToolProps) => { const { t } = useTranslation(); @@ -18,6 +21,11 @@ const Redact = (props: BaseToolProps) => { const [wordsCollapsed, setWordsCollapsed] = useState(false); const [advancedCollapsed, setAdvancedCollapsed] = useState(true); + // Navigation and redaction context + const { actions: navActions } = useNavigationActions(); + const { setRedactionConfig, setRedactionMode } = useRedaction(); + const hasOpenedViewer = useRef(false); + const base = useBaseTool( 'redact', useRedactParameters, @@ -30,9 +38,40 @@ const Redact = (props: BaseToolProps) => { const wordsTips = useRedactWordsTips(); const advancedTips = useRedactAdvancedTips(); + // Handle mode change - navigate to viewer when manual mode is selected + const handleModeChange = (mode: RedactMode) => { + base.params.updateParameter('mode', mode); + + if (mode === 'manual' && base.hasFiles) { + // Set redaction config and navigate to viewer + setRedactionConfig(base.params.parameters); + setRedactionMode(true); + navActions.setWorkbench('viewer'); + hasOpenedViewer.current = true; + } + }; + + // When files are added and in manual mode, navigate to viewer + useEffect(() => { + if (base.params.parameters.mode === 'manual' && base.hasFiles && !hasOpenedViewer.current) { + setRedactionConfig(base.params.parameters); + setRedactionMode(true); + navActions.setWorkbench('viewer'); + hasOpenedViewer.current = true; + } + }, [base.hasFiles, base.params.parameters, navActions, setRedactionConfig, setRedactionMode]); + + // Reset viewer flag when mode changes back to automatic + useEffect(() => { + if (base.params.parameters.mode === 'automatic') { + hasOpenedViewer.current = false; + setRedactionMode(false); + } + }, [base.params.parameters.mode, setRedactionMode]); + const isExecuteDisabled = () => { if (base.params.parameters.mode === 'manual') { - return true; // Manual mode not implemented yet + return true; // Manual mode uses viewer, not execute button } return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled; }; @@ -54,8 +93,9 @@ const Redact = (props: BaseToolProps) => { content: ( base.params.updateParameter('mode', mode)} + onModeChange={handleModeChange} disabled={base.endpointLoading} + hasFiles={base.hasFiles} /> ), } @@ -88,12 +128,22 @@ const Redact = (props: BaseToolProps) => { }, ); } else if (base.params.parameters.mode === 'manual') { - // Manual mode steps would go here when implemented + // Manual mode - show redaction controls + steps.push({ + title: t("redact.manual.controlsTitle", "Manual Redaction Controls"), + isCollapsed: false, + onCollapsedClick: () => {}, + tooltip: [], + content: , + }); } return steps; }; + // Hide execute button in manual mode (redactions applied via controls) + const isManualMode = base.params.parameters.mode === 'manual'; + return createToolFlow({ files: { selectedFiles: base.selectedFiles, @@ -102,7 +152,7 @@ const Redact = (props: BaseToolProps) => { steps: buildSteps(), executeButton: { text: t("redact.submit", "Redact"), - isVisible: !base.hasResults, + isVisible: !base.hasResults && !isManualMode, loadingText: t("loading"), onClick: base.handleExecute, disabled: isExecuteDisabled(),