Added bones for manual redaction, quickly saving so I can work on the text editor

This commit is contained in:
EthanHealy01 2025-12-03 14:25:21 +00:00
parent bdb3c887f3
commit 5485b06735
16 changed files with 861 additions and 65 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
<RedactionProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>

View File

@ -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 */}
<Tooltip content={isRedactMode ? t('rightRail.exitRedaction', 'Exit Redaction Mode') : t('rightRail.redact', 'Redact')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant={isRedactMode && !viewerContext?.isAnnotationMode ? 'filled' : 'subtle'}
color={isRedactMode && !viewerContext?.isAnnotationMode ? 'red' : undefined}
radius="md"
className="right-rail-icon"
onClick={handleRedactionToggle}
disabled={disabled || currentView !== 'viewer'}
>
<LocalIcon
icon="scan-delete-rounded"
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
@ -68,7 +128,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "preview-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
@ -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);
}
}
}
}}

View File

@ -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 (
<>
<Divider my="sm" />
<Stack gap="md">
<Text size="sm" fw={500}>
{t('redact.manual.title', 'Redaction Tools')}
</Text>
<Text size="xs" c="dimmed">
{t('redact.manual.instructions', 'Select text or draw areas on the PDF to mark content for redaction.')}
</Text>
<Group gap="sm" grow wrap="nowrap">
{/* Mark Text Selection Tool */}
<Button
variant={isSelectionActive && !isAnnotationMode ? 'filled' : 'light'}
color={isSelectionActive && !isAnnotationMode ? 'red' : 'gray'}
leftSection={<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleSelectionClick}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: { minWidth: 0 },
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.markText', 'Mark Text')}
</Button>
{/* Mark Area (Marquee) Tool */}
<Button
variant={isMarqueeActive && !isAnnotationMode ? 'filled' : 'light'}
color={isMarqueeActive && !isAnnotationMode ? 'red' : 'gray'}
leftSection={<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleMarqueeClick}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: { minWidth: 0 },
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.markArea', 'Mark Area')}
</Button>
</Group>
<Divider />
{/* Pending Count and Apply Button */}
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{t('redact.manual.pendingLabel', 'Pending:')}
</Text>
<Badge
color={pendingCount > 0 ? 'red' : 'gray'}
variant="filled"
size="lg"
>
{pendingCount}
</Badge>
</Group>
<Button
variant="filled"
color="red"
leftSection={<CheckCircleIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleApplyAll}
disabled={disabled || pendingCount === 0 || !isApiReady}
size="sm"
styles={{
root: { flexShrink: 0 },
label: { whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.apply', 'Apply')}
</Button>
</Group>
{pendingCount === 0 && (
<Text size="xs" c="dimmed" ta="center">
{t('redact.manual.noMarks', 'No redaction marks. Use the tools above to mark content for redaction.')}
</Text>
)}
</Stack>
</>
);
}

View File

@ -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}

View File

@ -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<RedactionPendingTrackerAPI>(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<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed
// Future: Handle signature completion

View File

@ -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<SignatureAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
}
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<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@ -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
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
<SignatureAPIBridge ref={signatureApiRef} />
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <HistoryAPIBridge ref={historyApiRef} />}
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && <RedactionAPIBridge />}
{(enableRedaction || isManualRedactionMode) && <RedactionPendingTracker ref={redactionTrackerRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<PrintAPIBridge />
@ -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 && (
<RedactionLayer
pageIndex={pageIndex}
scale={scale}
rotation={rotation}
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
/>
)}
</div>
</PagePointerProvider>
</Rotate>

View File

@ -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;
}

View File

@ -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<RedactionPendingTrackerAPI>(
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;
}
);

View File

@ -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 (
<div {...menuWrapperProps}>
<div
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: 8,
pointerEvents: 'auto',
zIndex: 100,
backgroundColor: 'var(--mantine-color-body)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
border: '1px solid var(--mantine-color-default-border)',
// Fixed size to prevent browser zoom affecting layout
fontSize: '14px',
minWidth: '180px',
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
<Tooltip label="Remove this mark">
<ActionIcon
variant="light"
color="gray"
size="md"
onClick={handleRemove}
style={{ flexShrink: 0 }}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Apply this redaction permanently">
<Button
variant="filled"
color="red"
size="xs"
onClick={handleApply}
leftSection={<CheckCircleIcon style={{ fontSize: 16 }} />}
styles={{
root: { flexShrink: 0, whiteSpace: 'nowrap' },
}}
>
Apply (permanent)
</Button>
</Tooltip>
</Group>
</div>
</div>
);
}

View File

@ -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 = {

View File

@ -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<RedactionAPI | null>;
}
// Create context
const RedactionContext = createContext<RedactionContextValue | undefined>(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<RedactionState>(initialState);
const redactionApiRef = useRef<RedactionAPI | null>(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 (
<RedactionContext.Provider value={contextValue}>
{children}
</RedactionContext.Provider>
);
};
/**
* 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,
};
};

View File

@ -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;

View File

@ -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;
}
});
};

View File

@ -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: (
<RedactModeSelector
mode={base.params.parameters.mode}
onModeChange={(mode) => 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: <ManualRedactionControls disabled={!base.hasFiles} />,
});
}
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(),