feat(redaction): improve manual redaction with color selection and updated UI elements (#5679)

# Description of Changes

<img width="1920" height="977" alt="image"
src="https://github.com/user-attachments/assets/17e451b7-df2b-4097-b8aa-66954d89b935"
/>


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs 2026-02-12 20:26:26 +01:00 committed by GitHub
parent 4b14ddfb37
commit f3a4dbc903
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 113 additions and 105 deletions

View File

@ -4879,6 +4879,9 @@ applyRedactions = "Apply Redactions"
applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted"
boxRedaction = "Box draw redaction"
colourPicker = "Colour Picker"
colorLabel = "Redaction Colour"
active = "Redaction Mode Active"
activate = "Activate Redaction Tool"
controlsTitle = "Manual Redaction Controls"
convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)"
export = "Export"

View File

@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next';
import { useEffect, useRef, useCallback } from 'react';
import { Button, Stack, Text, Group, Divider } from '@mantine/core';
import HighlightAltIcon from '@mui/icons-material/HighlightAlt';
import CropFreeIcon from '@mui/icons-material/CropFree';
import { Button, Stack, Text, Divider, ColorInput } from '@mantine/core';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
@ -19,8 +18,8 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
const { t } = useTranslation();
// Use our RedactionContext which bridges to EmbedPDF
const { activateTextSelection, activateMarquee, redactionsApplied, setActiveType } = useRedaction();
const { pendingCount, activeType, isBridgeReady } = useRedactionMode();
const { activateManualRedact, redactionsApplied, setActiveType, setManualRedactColor } = useRedaction();
const { pendingCount, activeType, isBridgeReady, isRedacting, manualRedactColor } = useRedactionMode();
// Get viewer context to manage annotation mode and save changes
const { isAnnotationMode, setAnnotationMode, applyChanges, activeFileIndex } = useViewer();
@ -28,9 +27,8 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
// 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';
// Check if redaction mode is active
const isRedactActive = isRedacting;
// Track if we've auto-activated for the current bridge session
const hasAutoActivated = useRef(false);
@ -47,12 +45,12 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
const timer = setTimeout(() => {
// Deactivate annotation mode to show redaction layer
setAnnotationMode(false);
// Pre-select the Mark Text tool
activateTextSelection();
// Pre-select the Redaction tool
activateManualRedact();
}, 150);
return () => clearTimeout(timer);
}
}, [isBridgeReady, disabled, activateTextSelection, setAnnotationMode]);
}, [isBridgeReady, disabled, activateManualRedact, setAnnotationMode]);
// Reset auto-activation flag when disabled changes or bridge becomes not ready
useEffect(() => {
@ -68,18 +66,16 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
prevFileIndexRef.current = activeFileIndex;
// Reset active type to null when switching files
// This makes both buttons appear unselected, requiring the user to re-click
// which ensures proper activation on the new PDF
if (isSelectionActive || isMarqueeActive) {
if (activeType) {
setActiveType(null);
}
// Reset auto-activation flag so new file can auto-activate
hasAutoActivated.current = false;
}
}, [activeFileIndex, isSelectionActive, isMarqueeActive, setActiveType]);
}, [activeFileIndex, activeType, setActiveType]);
const handleSelectionClick = () => {
const handleRedactClick = () => {
// Deactivate annotation mode and tools to switch to redaction layer
if (isAnnotationMode) {
setAnnotationMode(false);
@ -93,34 +89,7 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
}
}
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();
}
activateManualRedact();
};
// Handle saving changes - this will apply pending redactions and save to file
@ -149,43 +118,27 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
{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' : 'outline'}
color={isSelectionActive && !isAnnotationMode ? 'blue' : '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>
<ColorInput
label={t('redact.manual.colorLabel', 'Redaction Colour')}
value={manualRedactColor}
onChange={setManualRedactColor}
disabled={disabled || !isApiReady}
size="sm"
format="hex"
popoverProps={{ withinPortal: true }}
/>
{/* Mark Area (Marquee) Tool */}
<Button
variant={isMarqueeActive && !isAnnotationMode ? 'filled' : 'outline'}
color={isMarqueeActive && !isAnnotationMode ? 'blue' : '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>
<Button
variant={isRedactActive && !isAnnotationMode ? 'filled' : 'outline'}
color={isRedactActive && !isAnnotationMode ? 'blue' : 'gray'}
leftSection={<AutoFixHighIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleRedactClick}
disabled={disabled || !isApiReady}
fullWidth
size="sm"
>
{isRedactActive && !isAnnotationMode ? t('redact.manual.active', 'Redaction Mode Active') : t('redact.manual.activate', 'Activate Redaction Tool')}
</Button>
{/* Save Changes Button - applies pending redactions and saves to file */}
<Button

View File

@ -131,7 +131,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda
// Register redaction plugin (depends on InteractionManager, Selection, History)
// Always register for redaction functionality
createPluginRegistration(RedactionPluginPackage),
createPluginRegistration(RedactionPluginPackage, {
useAnnotationMode: true,
drawBlackBoxes: false,
}),
// Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {
@ -645,7 +648,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <SignatureAPIBridge ref={signatureApiRef} />}
{(enableRedaction || isManualRedactionMode) && <RedactionPendingTracker ref={redactionTrackerRef} />}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<PrintAPIBridge />
@ -706,12 +709,13 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda
<SelectionLayer documentId={documentId} pageIndex={pageIndex} />
{/* AnnotationLayer for annotation editing (only when enabled) */}
{enableAnnotations && (
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutlineColor="#007ACC"
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
/>
)}

View File

@ -1,7 +1,9 @@
import { useEffect, useImperativeHandle } from 'react';
import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react';
import { PdfAnnotationSubtype } from '@embedpdf/models';
import { useRedaction } from '@app/contexts/RedactionContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
/**
* RedactionAPIBridge - Uses embedPDF v2.5.0
@ -10,24 +12,25 @@ import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId'
*/
export function RedactionAPIBridge() {
const activeDocumentId = useActiveDocumentId();
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return <RedactionAPIBridgeInner documentId={activeDocumentId} />;
}
function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
const { state, provides } = useEmbedPdfRedaction(documentId);
const {
redactionApiRef,
setPendingCount,
setActiveType,
const { state, provides: redactionProvides } = useEmbedPdfRedaction(documentId);
const { provides: annotationProvides } = useAnnotationCapability();
const {
redactionApiRef,
setPendingCount,
setActiveType,
setIsRedacting,
setRedactionsApplied,
setBridgeReady
setBridgeReady,
manualRedactColor
} = useRedaction();
// Mark bridge as ready on mount, not ready on unmount
@ -45,34 +48,51 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
setActiveType(state.activeType ?? null);
setIsRedacting(state.isRedacting ?? false);
}
}, [state?.pendingCount, state?.activeType, state?.isRedacting, setPendingCount, setActiveType, setIsRedacting]);
}, [state, setPendingCount, setActiveType, setIsRedacting]);
// Synchronize manual redaction color with EmbedPDF
// Manual redaction uses the 'redact' annotation tool internally
useEffect(() => {
const annotationApi = annotationProvides as any;
if (annotationApi?.setToolDefaults) {
annotationApi.setToolDefaults('redact', {
type: PdfAnnotationSubtype.REDACT,
strokeColor: manualRedactColor,
color: manualRedactColor,
overlayColor: manualRedactColor,
fillColor: manualRedactColor,
interiorColor: manualRedactColor,
backgroundColor: manualRedactColor,
opacity: 1
});
}
}, [annotationProvides, manualRedactColor]);
// Expose the EmbedPDF API through our context's ref
// Uses v2.5.0 unified redaction mode
useImperativeHandle(redactionApiRef, () => ({
// Unified redaction methods (v2.5.0)
toggleRedact: () => {
provides?.toggleRedact();
redactionProvides?.toggleRedact();
},
enableRedact: () => {
provides?.enableRedact();
redactionProvides?.enableRedact();
},
isRedactActive: () => {
return provides?.isRedactActive() ?? false;
return redactionProvides?.isRedactActive() ?? false;
},
endRedact: () => {
provides?.endRedact();
redactionProvides?.endRedact();
},
// Common methods
commitAllPending: () => {
provides?.commitAllPending();
redactionProvides?.commitAllPending();
// Don't set redactionsApplied here - it should only be set after the file is saved
// The save operation in applyChanges will handle setting/clearing this flag
},
getActiveType: () => state?.activeType ?? null,
getPendingCount: () => state?.pendingCount ?? 0,
}), [provides, state, setRedactionsApplied]);
}), [redactionProvides, state]);
return null;
}

View File

@ -1,4 +1,5 @@
import { useRedaction as useEmbedPdfRedaction, RedactionSelectionMenuProps } from '@embedpdf/plugin-redaction/react';
import { PdfAnnotationSubtype } from '@embedpdf/models';
import { ActionIcon, Tooltip, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
@ -10,7 +11,7 @@ import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId'
export type { RedactionSelectionMenuProps };
export function RedactionSelectionMenu(props: RedactionSelectionMenuProps) {
export function RedactionSelectionMenu(props: any) {
const activeDocumentId = useActiveDocumentId();
// Don't render until we have a valid document ID
@ -32,7 +33,12 @@ function RedactionSelectionMenuInner({
selected,
menuWrapperProps,
}: RedactionSelectionMenuProps & { documentId: string }) {
const item = context?.item;
const item = context?.type === 'redaction'
? context.item
: (context?.type === 'annotation' ? (context as any).annotation?.object : null);
const isRedaction = context?.type === 'redaction' || (context?.type === 'annotation' && item?.type === PdfAnnotationSubtype.REDACT);
const pageIndex = context?.pageIndex;
const { t } = useTranslation();
const { provides } = useEmbedPdfRedaction(documentId);
@ -64,7 +70,7 @@ function RedactionSelectionMenuInner({
// Calculate position for portal based on wrapper element
useEffect(() => {
if (!selected || !item || !wrapperRef.current) {
if (!selected || !isRedaction || !item || !wrapperRef.current) {
setMenuPosition(null);
return;
}
@ -99,7 +105,7 @@ function RedactionSelectionMenuInner({
}, [selected, item]);
// Early return AFTER all hooks have been called
if (!selected || !item) return null;
if (!selected || !isRedaction || !item) return null;
const menuContent = menuPosition ? (
<div

View File

@ -35,6 +35,8 @@ interface RedactionState {
isRedacting: boolean;
// Whether the redaction API bridge is ready (API ref is populated)
isBridgeReady: boolean;
// Color for manual redaction
manualRedactColor: string;
}
/**
@ -50,10 +52,13 @@ interface RedactionActions {
setActiveType: (type: RedactionMode | null) => void;
setIsRedacting: (isRedacting: boolean) => void;
setBridgeReady: (ready: boolean) => void;
setManualRedactColor: (color: string) => void;
// Unified redaction actions (v2.5.0)
activateRedact: () => void;
deactivateRedact: () => void;
commitAllPending: () => void;
// Unified manual redaction action
activateManualRedact: () => void;
// Legacy UI actions (for backwards compatibility with UI)
activateTextSelection: () => void;
activateMarquee: () => void;
@ -79,6 +84,7 @@ const initialState: RedactionState = {
activeType: null,
isRedacting: false,
isBridgeReady: false,
manualRedactColor: '#000000',
};
/**
@ -141,6 +147,13 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children
}));
}, []);
const setManualRedactColor = useCallback((color: string) => {
setState(prev => ({
...prev,
manualRedactColor: color,
}));
}, []);
// Keep navigation guard aware of pending or applied redactions so we block navigation
// Also clear the flag when all redactions have been saved
useEffect(() => {
@ -175,6 +188,12 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children
}
}, [setRedactionsApplied]);
const activateManualRedact = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.enableRedact();
}
}, []);
// Legacy UI actions for backwards compatibility
// In v2.5.0, both text selection and marquee use the same unified mode
// These just activate the unified redact mode and set the active type for UI state
@ -202,9 +221,11 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children
setActiveType,
setIsRedacting,
setBridgeReady,
setManualRedactColor,
activateRedact,
deactivateRedact,
commitAllPending,
activateManualRedact,
activateTextSelection,
activateMarquee,
};
@ -239,6 +260,7 @@ export const useRedactionMode = () => {
activeType: context?.activeType || null,
isRedacting: context?.isRedacting || false,
isBridgeReady: context?.isBridgeReady || false,
manualRedactColor: context?.manualRedactColor || '#000000',
};
};