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 */}
+ }
+ onClick={handleSelectionClick}
+ disabled={disabled || !isApiReady}
+ size="sm"
+ styles={{
+ root: { minWidth: 0 },
+ label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
+ }}
+ >
+ {t('redact.manual.markText', 'Mark Text')}
+
+
+ {/* Mark Area (Marquee) Tool */}
+ }
+ onClick={handleMarqueeClick}
+ disabled={disabled || !isApiReady}
+ size="sm"
+ styles={{
+ root: { minWidth: 0 },
+ label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
+ }}
+ >
+ {t('redact.manual.markArea', 'Mark Area')}
+
+
+
+
+
+ {/* Pending Count and Apply Button */}
+
+
+
+ {t('redact.manual.pendingLabel', 'Pending:')}
+
+ 0 ? 'red' : 'gray'}
+ variant="filled"
+ size="lg"
+ >
+ {pendingCount}
+
+
+
+ }
+ onClick={handleApplyAll}
+ disabled={disabled || pendingCount === 0 || !isApiReady}
+ size="sm"
+ styles={{
+ root: { flexShrink: 0 },
+ label: { whiteSpace: 'nowrap' },
+ }}
+ >
+ {t('redact.manual.apply', 'Apply')}
+
+
+
+ {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 (
+
+
+
+
+
+
+
+
+
+
+ }
+ styles={{
+ root: { flexShrink: 0, whiteSpace: 'nowrap' },
+ }}
+ >
+ Apply (permanent)
+
+
+
+
+
+ );
+}
+
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(),