Feature/v2/redact (#5249)

# Description of Changes

- Add manual redaction and added it to the right rail in the viewer

---

## 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)

### 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.
This commit is contained in:
EthanHealy01
2026-01-05 15:12:14 +00:00
committed by GitHub
parent 991d158cb8
commit 0c96133544
34 changed files with 4210 additions and 1237 deletions
@@ -22,6 +22,7 @@ import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
import { RedactionProvider } from "@app/contexts/RedactionContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -96,6 +97,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
@@ -105,6 +107,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
@@ -1,9 +1,10 @@
import { useCallback } from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFileState } from '@app/contexts/FileContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useNavigationState, useNavigationActions, useNavigationGuard } from '@app/contexts/NavigationContext';
import { isBaseWorkbench } from '@app/types/workbench';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
@@ -51,6 +52,21 @@ export default function Workbench() {
// Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer();
// Get navigation guard for unsaved changes check when switching files
const { requestNavigation } = useNavigationGuard();
// Wrap file selection to check for unsaved changes before switching
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
const handleFileSelect = useCallback((index: number) => {
// Don't do anything if selecting the same file
if (index === activeFileIndex) return;
// requestNavigation handles the unsaved changes check internally
requestNavigation(() => {
setActiveFileIndex(index);
});
}, [activeFileIndex, requestNavigation, setActiveFileIndex]);
const handlePreviewClose = () => {
setPreviewFile(null);
@@ -182,7 +198,7 @@ export default function Workbench() {
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
})}
currentFileIndex={activeFileIndex}
onFileSelect={setActiveFileIndex}
onFileSelect={handleFileSelect}
/>
)}
@@ -1,10 +1,11 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import { Button, Group, Stack, Text, Tooltip } from "@mantine/core";
import FitText from "@app/components/shared/FitText";
export interface ButtonOption<T> {
value: T;
label: string;
disabled?: boolean;
tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled)
}
interface ButtonSelectorProps<T> {
@@ -39,33 +40,46 @@ const ButtonSelector = <T extends string | number>({
{/* Buttons */}
<Group gap='4px'>
{options.map((option) => (
<Button
key={option.value}
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
className={buttonClassName}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
minHeight: '2.5rem',
fontSize: 'var(--mantine-font-size-sm)',
lineHeight: '1.4',
paddingTop: '0.5rem',
paddingBottom: '0.5rem'
}}
>
<FitText
text={option.label}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={textClassName}
/>
</Button>
))}
{options.map((option) => {
const isDisabled = disabled || option.disabled;
const button = (
<Button
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={isDisabled}
className={buttonClassName}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
minHeight: '2.5rem',
fontSize: 'var(--mantine-font-size-sm)',
lineHeight: '1.4',
paddingTop: '0.5rem',
paddingBottom: '0.5rem'
}}
>
<FitText
text={option.label}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={textClassName}
/>
</Button>
);
// Wrap with tooltip if provided (useful for disabled state explanations)
if (option.tooltip && isDisabled) {
return (
<Tooltip key={option.value} label={option.tooltip} position="top" withArrow>
<span style={{ flex: fullWidth ? 1 : undefined, display: 'flex' }}>{button}</span>
</Tooltip>
);
}
return <span key={option.value} style={{ flex: fullWidth ? 1 : undefined, display: 'flex' }}>{button}</span>;
})}
</Group>
</Stack>
);
@@ -4,13 +4,16 @@ import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
/** Called when discarding - allows saving applied changes while discarding pending ones */
onDiscardAndContinue?: () => Promise<void>;
}
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue, onDiscardAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
@@ -19,7 +22,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
cancelNavigation();
};
const handleDiscardChanges = () => {
const handleDiscardChanges = async () => {
// If a discard handler is provided, call it to save any already-applied changes, then discard the unsaved changes
if (onDiscardAndContinue) {
await onDiscardAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
@@ -32,14 +39,15 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
confirmNavigation();
};
const _handleExportAndContinue = async () => {
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const BUTTON_WIDTH = "10rem";
const BUTTON_WIDTH = "12rem";
// Only show modal if there are unsaved changes AND there's an actual pending navigation
// This prevents the modal from showing due to spurious state updates
@@ -56,6 +64,7 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
zIndex={Z_INDEX_TOAST}
>
<Stack>
<Stack ta="center" p="md">
@@ -83,6 +92,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
{onExportAndContinue && (
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
</Group>
</Group>
@@ -99,6 +113,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
{onExportAndContinue && (
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
</Stack>
</Stack>
</Modal>
@@ -91,6 +91,7 @@ export default function RightRail() {
(btn: RightRailButtonConfig) => {
const action = actions[btn.id];
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
const isActive = Boolean(btn.active);
const triggerAction = () => {
if (!disabled) action?.();
@@ -103,6 +104,7 @@ export default function RightRail() {
allButtonsDisabled,
action,
triggerAction,
active: isActive,
};
return btn.render(context) ?? null;
}
@@ -114,12 +116,15 @@ export default function RightRail() {
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
const buttonNode = (
<ActionIcon
variant="subtle"
variant={isActive ? 'filled' : 'subtle'}
color={isActive ? 'blue' : undefined}
radius="md"
className={className}
onClick={triggerAction}
disabled={disabled}
aria-label={ariaLabel}
aria-pressed={isActive ? true : undefined}
data-active={isActive ? 'true' : 'false'}
>
{btn.icon}
</ActionIcon>
@@ -86,6 +86,7 @@ interface RightRailButtonWithAction {
id: string; // Unique identifier
icon?: React.ReactNode; // Icon component (omit when using render)
tooltip?: React.ReactNode; // Hover tooltip / description
active?: boolean; // Optional active state for highlight
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
order?: number; // Sort order (default: 0)
disabled?: boolean; // Disabled state (default: false)
@@ -100,6 +101,7 @@ interface RightRailRenderContext {
allButtonsDisabled: boolean;
action?: () => void;
triggerAction: () => void;
active: boolean;
}
```
@@ -71,6 +71,10 @@
color: var(--right-rail-icon);
}
.right-rail-icon[data-active="true"] {
color: #fff;
}
.right-rail-icon[aria-disabled="true"],
.right-rail-icon[disabled] {
color: var(--right-rail-icon-disabled) !important;
@@ -1,12 +1,18 @@
import React from 'react';
import React, { useCallback } from 'react';
import { ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { useFileState, useFileContext } from '@app/contexts/FileContext';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import { useNavigationState, useNavigationGuard, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useRedactionMode, useRedaction } from '@app/contexts/RedactionContext';
import { defaultParameters, RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
interface ViewerAnnotationControlsProps {
currentView: string;
@@ -16,47 +22,162 @@ interface ViewerAnnotationControlsProps {
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Check if we're in sign mode
// Signature context for accessing drawing API
const { historyApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// 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 { isRedacting: _isRedacting } = useRedactionMode();
const { requestNavigation, setHasUnsavedChanges } = useNavigationGuard();
const { setRedactionMode, activateTextSelection, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction();
// Check if we're in any annotation tool that should disable the toggle
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
// Check if we're on annotate tool to highlight the button
const isAnnotateActive = selectedTool === 'annotate';
const annotationsHidden = viewerContext ? !viewerContext.isAnnotationsVisible : false;
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
}
// Persist annotations to file if there are unsaved changes
const saveAnnotationsIfNeeded = async () => {
if (!viewerContext?.exportActions?.saveAsCopy || currentView !== 'viewer' || !historyApiRef?.current?.canUndo()) return;
if (activeFiles.length === 0 || state.files.ids.length === 0) return;
try {
const arrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (!arrayBuffer) return;
const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: 'application/pdf' });
const parentStub = selectors.getStirlingFileStub(state.files.ids[0]);
if (!parentStub) return;
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'redact');
await fileActions.consumeFiles([state.files.ids[0]], stirlingFiles, stubs);
// Clear unsaved changes flags after successful save
setHasUnsavedChanges(false);
setRedactionsApplied(false);
} catch (error) {
console.error('Error auto-saving annotations before redaction:', error);
}
};
const exitRedactionMode = useCallback(() => {
navActions.setToolAndWorkbench(null, 'viewer');
setLeftPanelView('toolPicker');
setRedactionMode(false);
setActiveType(null);
}, [navActions, setLeftPanelView, setRedactionMode, setActiveType]);
// Handle redaction mode toggle
const handleRedactionToggle = async () => {
if (isRedactMode) {
// Exit redaction mode
exitRedactionMode();
} else {
// Check for unsaved annotation changes
const hasAnnotationChanges = historyApiRef?.current?.canUndo() ?? false;
const enterRedactionMode = async () => {
await saveAnnotationsIfNeeded();
// Set redaction config to manual mode when opening from viewer
const manualConfig: RedactParameters = {
...defaultParameters,
mode: 'manual',
};
setRedactionConfig(manualConfig);
// Set tool and keep viewer workbench
navActions.setToolAndWorkbench('redact', 'viewer');
// Ensure sidebars are visible and open tool content
setSidebarsVisible(true);
setLeftPanelView('toolContent');
setRedactionMode(true);
// Activate text selection mode after a short delay
setTimeout(() => {
const currentType = redactionApiRef.current?.getActiveType?.();
if (currentType !== 'redactSelection') {
activateTextSelection();
}
}, 200);
};
if (hasAnnotationChanges) {
requestNavigation(enterRedactionMode);
} else {
await enterRedactionMode();
}
}
};
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
{/* 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={isAnnotateActive ? "filled" : "subtle"}
color="blue"
variant={isRedactMode ? 'filled' : 'subtle'}
color={isRedactMode ? 'blue' : undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
onClick={handleRedactionToggle}
disabled={disabled || currentView !== 'viewer'}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
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
variant={annotationsHidden ? "filled" : "subtle"}
color={annotationsHidden ? "blue" : undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode}
data-active={annotationsHidden ? 'true' : undefined}
aria-pressed={annotationsHidden}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "preview-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
</>
);
}
@@ -0,0 +1,244 @@
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 { 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, redactionsApplied, setActiveType } = useRedaction();
const { pendingCount, activeType, isRedacting, isBridgeReady } = useRedactionMode();
// Get viewer context to manage annotation mode and save changes
const { isAnnotationMode, setAnnotationMode, applyChanges, activeFileIndex } = 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);
// Track the previous file index to detect file switches
const prevFileIndexRef = useRef<number>(activeFileIndex);
// Track previous pending count to detect when all redactions are applied
const prevPendingCountRef = useRef<number>(pendingCount);
// Track if we're currently auto-saving to prevent re-entry
const isAutoSavingRef = useRef(false);
// Auto-activate selection mode when the API bridge becomes ready
// This ensures at least one tool is selected when entering manual redaction mode
useEffect(() => {
if (isBridgeReady && !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);
}
}, [isBridgeReady, disabled, isRedacting, activateTextSelection, setAnnotationMode]);
// Reset auto-activation flag when disabled changes or bridge becomes not ready
useEffect(() => {
if (disabled || !isBridgeReady) {
hasAutoActivated.current = false;
}
}, [disabled, isBridgeReady]);
// Reset redaction tool when switching between files
// The new PDF gets a fresh EmbedPDF instance - forcing user to re-select tool ensures it works properly
useEffect(() => {
if (prevFileIndexRef.current !== activeFileIndex) {
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) {
setActiveType(null);
}
}
}, [activeFileIndex, isSelectionActive, isMarqueeActive, setActiveType]);
// Auto-save when all pending redactions have been applied
// This triggers when the user clicks "Apply (permanent)" on the last pending redaction
useEffect(() => {
const hadPendingBefore = prevPendingCountRef.current > 0;
const hasNoPendingNow = pendingCount === 0;
const wasJustCleared = hadPendingBefore && hasNoPendingNow;
// Update the ref for next comparison
prevPendingCountRef.current = pendingCount;
// Auto-save when:
// - pendingCount just went from > 0 to 0 (user applied the last pending redaction)
// - redactionsApplied is true (at least one redaction was committed)
// - not already auto-saving
// - applyChanges is available
if (wasJustCleared && redactionsApplied && !isAutoSavingRef.current && applyChanges) {
isAutoSavingRef.current = true;
// Small delay to ensure UI updates before save
const timer = setTimeout(async () => {
try {
await applyChanges();
} finally {
isAutoSavingRef.current = false;
}
}, 100);
return () => {
clearTimeout(timer);
isAutoSavingRef.current = false;
};
}
}, [pendingCount, redactionsApplied, applyChanges]);
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();
}
};
// Handle saving changes - this will apply pending redactions and save to file
const handleSaveChanges = useCallback(async () => {
if (applyChanges) {
await applyChanges();
}
}, [applyChanges]);
// Check if there are unsaved changes to save (pending redactions OR applied redactions)
// Save Changes button will apply pending redactions and then save everything
const hasUnsavedChanges = pendingCount > 0 || redactionsApplied;
// Check if API is available - use isBridgeReady state instead of ref (refs don't trigger re-renders)
const isApiReady = isBridgeReady;
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' : '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>
{/* 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>
{/* Save Changes Button - applies pending redactions and saves to file */}
<Button
fullWidth
size="md"
radius="md"
mt="sm"
variant="filled"
color="blue"
disabled={!hasUnsavedChanges}
onClick={handleSaveChanges}
>
{t('annotation.saveChanges', 'Save Changes')}
</Button>
</Stack>
</>
);
}
@@ -6,9 +6,17 @@ interface RedactModeSelectorProps {
mode: RedactMode;
onModeChange: (mode: RedactMode) => void;
disabled?: boolean;
hasFilesSelected?: boolean; // Files are selected in workbench
hasAnyFiles?: boolean; // Any files exist in workbench (for manual mode)
}
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
export default function RedactModeSelector({
mode,
onModeChange,
disabled,
hasFilesSelected = false,
hasAnyFiles = false
}: RedactModeSelectorProps) {
const { t } = useTranslation();
return (
@@ -20,11 +28,15 @@ export default function RedactModeSelector({ mode, onModeChange, disabled }: Red
{
value: 'automatic' as const,
label: t('redact.modeSelector.automatic', 'Automatic'),
disabled: !hasFilesSelected, // Automatic requires files to be selected
tooltip: !hasFilesSelected
? t('redact.modeSelector.automaticDisabledTooltip', 'Select files in the file manager to redact multiple files at once')
: undefined,
},
{
value: 'manual' as const,
label: t('redact.modeSelector.manual', 'Manual'),
disabled: true, // Keep manual mode disabled until implemented
disabled: !hasAnyFiles, // Manual mode works if any files exist in workbench
},
]}
disabled={disabled}
@@ -77,3 +77,32 @@ export const useRedactAdvancedTips = (): TooltipContent => {
]
};
};
export const useRedactManualTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.manual.header.title", "Manual Redaction Controls")
},
tips: [
{
title: t("redact.tooltip.manual.markText.title", "Mark Text Tool"),
description: t("redact.tooltip.manual.markText.text", "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact."),
},
{
title: t("redact.tooltip.manual.markArea.title", "Mark Area Tool"),
description: t("redact.tooltip.manual.markArea.text", "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes."),
},
{
title: t("redact.tooltip.manual.apply.title", "Apply Redactions"),
description: t("redact.tooltip.manual.apply.text", "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied."),
bullets: [
t("redact.tooltip.manual.apply.bullet1", "Mark as many areas as needed before applying"),
t("redact.tooltip.manual.apply.bullet2", "All pending redactions are applied at once"),
t("redact.tooltip.manual.apply.bullet3", "Redactions cannot be undone after applying")
]
}
]
};
};
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Box, Center, Text, ActionIcon } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
@@ -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';
@@ -46,14 +48,15 @@ const EmbedPdfViewerContent = ({
isSearchInterfaceVisible,
searchInterfaceActions,
zoomActions,
scrollActions,
panActions: _panActions,
rotationActions: _rotationActions,
getScrollState,
getRotationState,
isAnnotationMode,
setAnnotationMode,
isAnnotationsVisible,
exportActions,
setApplyChanges,
} = useViewer();
const scrollState = getScrollState();
@@ -75,6 +78,21 @@ const EmbedPdfViewerContent = ({
// annotation history changes, and cleared after we successfully apply changes.
const hasAnnotationChangesRef = useRef(false);
// Scroll position preservation system
// We continuously track the last known good scroll position, so we always have it available
const lastKnownScrollPageRef = useRef<number>(1);
const pendingScrollRestoreRef = useRef<number | null>(null);
const scrollRestoreAttemptsRef = useRef<number>(0);
// Track the file ID we should be viewing after a save (to handle list reordering)
const pendingFileIdRef = useRef<string | null>(null);
// Get redaction context
const { redactionsApplied, setRedactionsApplied } = useRedaction();
// Ref for redaction pending tracker API
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
// Get current file from FileContext
const { selectors, state } = useFileState();
const { actions } = useFileActions();
@@ -85,16 +103,61 @@ const EmbedPdfViewerContent = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
// Check if we're in an annotation tool
const { selectedTool } = useNavigationState();
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
const isSignatureMode = isInAnnotationTool;
const isManualRedactMode = selectedTool === 'redact';
// Sync isAnnotationMode in ViewerContext with current tool
// Enable annotations only when annotation tool is selected
const shouldEnableAnnotations = selectedTool === 'annotate' || isSignatureMode;
// Enable redaction only when redaction tool is selected
const shouldEnableRedaction = selectedTool === 'redact';
// Track previous annotation/redaction state to detect tool switches
const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations);
const prevEnableRedactionRef = useRef(shouldEnableRedaction);
// Track scroll position whenever scrollState changes from the context
// This ensures we always have the most up-to-date position
useEffect(() => {
setAnnotationMode(isInAnnotationTool);
}, [isInAnnotationTool, setAnnotationMode]);
if (scrollState.currentPage > 0) {
lastKnownScrollPageRef.current = scrollState.currentPage;
}
}, [scrollState.currentPage]);
// Preserve scroll position when switching between annotation and redaction tools
// Using useLayoutEffect to capture synchronously before DOM updates
useLayoutEffect(() => {
const annotationsChanged = prevEnableAnnotationsRef.current !== shouldEnableAnnotations;
const redactionChanged = prevEnableRedactionRef.current !== shouldEnableRedaction;
if (annotationsChanged || redactionChanged) {
// Read scroll state directly AND use the tracked value - take whichever is valid
const currentScrollState = getScrollState();
const pageFromState = currentScrollState.currentPage;
const pageFromRef = lastKnownScrollPageRef.current;
// Use the current state if valid, otherwise fall back to tracked ref
const pageToRestore = pageFromState > 0 ? pageFromState : pageFromRef;
if (pageToRestore > 0) {
pendingScrollRestoreRef.current = pageToRestore;
scrollRestoreAttemptsRef.current = 0;
}
prevEnableAnnotationsRef.current = shouldEnableAnnotations;
prevEnableRedactionRef.current = shouldEnableRedaction;
}
}, [shouldEnableAnnotations, shouldEnableRedaction, getScrollState]);
// Keep annotation mode enabled when entering placement tools without overriding manual toggles
useEffect(() => {
if (isInAnnotationTool) {
setAnnotationMode(true);
}
}, [isInAnnotationTool, setAnnotationMode]);
const isPlacementOverlayActive = Boolean(
isInAnnotationTool && isPlacementMode && signatureConfig
);
@@ -124,6 +187,20 @@ const EmbedPdfViewerContent = ({
}
}, [activeFiles.length, activeFileIndex]);
// After saving a file, the list may reorder (sorted by version).
// Track the saved file's ID and update activeFileIndex to follow it.
useEffect(() => {
if (pendingFileIdRef.current && activeFiles.length > 0) {
const targetFileId = pendingFileIdRef.current;
const newIndex = activeFiles.findIndex(f => f.fileId === targetFileId);
if (newIndex !== -1 && newIndex !== activeFileIndex) {
setActiveFileIndex(newIndex);
}
// Clear the pending file ID once we've found and switched to it
pendingFileIdRef.current = null;
}
}, [activeFiles, activeFileIndex, setActiveFileIndex]);
// Determine which file to display
const currentFile = React.useMemo(() => {
if (previewFile) {
@@ -259,8 +336,31 @@ const EmbedPdfViewerContent = ({
}
const checkForChanges = () => {
// Check for annotation history changes (using ref that's updated by useEffect)
const hasAnnotationChanges = hasAnnotationChangesRef.current;
return hasAnnotationChanges;
// Check for pending redactions
const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
// Always consider applied redactions as unsaved until export
const hasAppliedRedactions = redactionsApplied;
// When in redact mode, still include annotation changes (users may draw)
if (isManualRedactMode) {
console.log('[Viewer] Checking for unsaved changes (redact mode):', {
hasPendingRedactions,
hasAppliedRedactions,
hasAnnotationChanges,
});
} else {
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges,
hasPendingRedactions,
hasAppliedRedactions,
});
}
return hasAnnotationChanges || hasPendingRedactions || hasAppliedRedactions;
};
registerUnsavedChangesChecker(checkForChanges);
@@ -268,13 +368,34 @@ const EmbedPdfViewerContent = ({
return () => {
unregisterUnsavedChangesChecker();
};
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, isManualRedactMode, redactionsApplied]);
// Apply changes - save annotations to new file version
// Save changes - save annotations and redactions to file (overwrites active file)
const applyChanges = useCallback(async () => {
if (!currentFile || activeFileIds.length === 0) return;
try {
console.log('[Viewer] Applying changes - exporting PDF with annotations/redactions');
// Use the continuously tracked scroll position - more reliable than reading at this moment
const pageToRestore = lastKnownScrollPageRef.current;
// Step 0: Commit any pending redactions before export
const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
// Mark redactions as applied BEFORE committing, so the button stays enabled during the save process
// This ensures the button doesn't become disabled when pendingCount becomes 0
if (hadPendingRedactions || redactionsApplied) {
setRedactionsApplied(true);
}
if (hadPendingRedactions) {
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();
if (!arrayBuffer) {
@@ -287,33 +408,142 @@ const EmbedPdfViewerContent = ({
const file = new File([blob], filename, { type: 'application/pdf' });
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
// Only consume the current file, not all active files
const currentFileId = activeFiles[activeFileIndex]?.fileId;
if (!currentFileId) throw new Error('Current file ID not found');
const parentStub = selectors.getStirlingFileStub(currentFileId);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
// Store the page to restore after file replacement triggers re-render
pendingScrollRestoreRef.current = pageToRestore;
scrollRestoreAttemptsRef.current = 0;
// Store the new file ID so we can track it after the list reorders
const newFileId = stubs[0]?.id;
if (newFileId) {
pendingFileIdRef.current = newFileId;
}
// Step 4: Consume only the current file (replace in context)
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
// Mark annotations as saved so navigation away from the viewer is allowed.
hasAnnotationChangesRef.current = false;
setHasUnsavedChanges(false);
setRedactionsApplied(false);
} catch (error) {
console.error('Apply changes failed:', error);
}
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
}, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied]);
// Expose annotation apply via a global event so tools (like Annotate) can
// trigger saves from the left sidebar without tight coupling.
// Discard pending redactions but save already-applied ones
// This is called when user clicks "Discard & Leave" - we want to:
// 1. NOT commit pending redaction marks (they get discarded)
// 2. Save the PDF with already-applied redactions (if any)
const discardAndSaveApplied = useCallback(async () => {
// Only save if there are applied redactions to preserve
if (!redactionsApplied || !currentFile || activeFileIds.length === 0) {
return;
}
try {
console.log('[Viewer] Discarding pending marks but saving applied redactions');
// Export PDF WITHOUT committing pending marks - this saves only applied redactions
const arrayBuffer = await exportActions.saveAsCopy();
if (!arrayBuffer) {
throw new Error('Failed to export PDF');
}
// Convert ArrayBuffer to File
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const filename = currentFile.name || 'document.pdf';
const file = new File([blob], filename, { type: 'application/pdf' });
// Create StirlingFiles and stubs for version history
const currentFileId = activeFiles[activeFileIndex]?.fileId;
if (!currentFileId) throw new Error('Current file ID not found');
const parentStub = selectors.getStirlingFileStub(currentFileId);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
// Consume only the current file (replace in context)
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
// Clear flags
hasAnnotationChangesRef.current = false;
setRedactionsApplied(false);
console.log('[Viewer] Applied redactions saved, pending marks discarded');
} catch (error) {
console.error('Failed to save applied redactions:', error);
}
}, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied]);
// Restore scroll position after file replacement or tool switch
// Uses polling with retries to ensure the scroll succeeds
useEffect(() => {
const handler = () => {
void applyChanges();
if (pendingScrollRestoreRef.current === null) return;
const pageToRestore = pendingScrollRestoreRef.current;
const maxAttempts = 10;
const attemptInterval = 100; // ms between attempts
const attemptScroll = () => {
const currentState = getScrollState();
const targetPage = Math.min(pageToRestore, currentState.totalPages);
// Only attempt if we have valid state (totalPages > 0 means PDF is loaded)
if (currentState.totalPages > 0 && targetPage > 0) {
scrollActions.scrollToPage(targetPage, 'instant');
// Check if scroll succeeded after a brief delay
setTimeout(() => {
const afterState = getScrollState();
if (afterState.currentPage === targetPage || scrollRestoreAttemptsRef.current >= maxAttempts) {
// Success or max attempts reached - clear pending
pendingScrollRestoreRef.current = null;
scrollRestoreAttemptsRef.current = 0;
} else {
// Scroll might not have worked, retry
scrollRestoreAttemptsRef.current++;
if (scrollRestoreAttemptsRef.current < maxAttempts) {
setTimeout(attemptScroll, attemptInterval);
} else {
// Give up after max attempts
pendingScrollRestoreRef.current = null;
scrollRestoreAttemptsRef.current = 0;
}
}
}, 50);
} else if (scrollRestoreAttemptsRef.current < maxAttempts) {
// PDF not ready yet, retry
scrollRestoreAttemptsRef.current++;
setTimeout(attemptScroll, attemptInterval);
} else {
// Give up after max attempts
pendingScrollRestoreRef.current = null;
scrollRestoreAttemptsRef.current = 0;
}
};
window.addEventListener('stirling-annotations-apply', handler);
// Start attempting after initial delay
const timer = setTimeout(attemptScroll, 150);
return () => clearTimeout(timer);
}, [scrollState.totalPages, scrollActions, getScrollState]);
// Register applyChanges with ViewerContext so tools can access it directly
useEffect(() => {
setApplyChanges(applyChanges);
return () => {
window.removeEventListener('stirling-annotations-apply', handler);
setApplyChanges(null);
};
}, [applyChanges]);
}, [applyChanges, setApplyChanges]);
// Register viewer right-rail buttons
useViewerRightRailButtons();
@@ -370,11 +600,14 @@ const EmbedPdfViewerContent = ({
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
url={effectiveFile.url}
enableAnnotations={isAnnotationMode}
enableAnnotations={shouldEnableAnnotations}
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
isManualRedactionMode={isManualRedactMode}
signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef 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
@@ -433,6 +666,10 @@ const EmbedPdfViewerContent = ({
onApplyAndContinue={async () => {
await applyChanges();
}}
onDiscardAndContinue={async () => {
// Save applied redactions (if any) while discarding pending ones
await discardAndSaveApplied();
}}
/>
)}
</Box>
@@ -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';
@@ -47,20 +46,26 @@ 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';
import { absoluteWithBasePath } from '@app/constants/app';
interface LocalEmbedPDFProps {
file?: File | Blob;
url?: string | null;
enableAnnotations?: boolean;
enableRedaction?: boolean;
isManualRedactionMode?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>;
annotationApiRef?: React.RefObject<AnnotationAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
}
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@@ -125,6 +130,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
selectAfterCreate: true,
}),
// Register redaction plugin (depends on InteractionManager, Selection, History)
// Always register for redaction functionality
createPluginRegistration(RedactionPluginPackage),
// Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {
defaultMode: 'mobile', // Try mobile mode which might be more permissive
}),
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
createPluginRegistration(PanPluginPackage, {}),
@@ -617,9 +630,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{enableAnnotations && <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 />}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <SignatureAPIBridge ref={signatureApiRef} />}
{(enableRedaction || isManualRedactionMode) && <RedactionPendingTracker ref={redactionTrackerRef} />}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<PrintAPIBridge />
@@ -686,6 +704,16 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
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>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { usePan } from '@embedpdf/plugin-pan/react';
import { useViewer } from '@app/contexts/ViewerContext';
@@ -7,12 +7,15 @@ import { useViewer } from '@app/contexts/ViewerContext';
*/
export function PanAPIBridge() {
const { provides: pan, isPanning } = usePan();
const { registerBridge } = useViewer();
const { registerBridge, triggerImmediatePanUpdate } = useViewer();
// Store state locally
const [_localState, setLocalState] = useState({
isPanning: false
});
// Track previous isPanning value to detect changes
const prevIsPanningRef = useRef<boolean>(isPanning);
useEffect(() => {
if (pan) {
@@ -38,8 +41,14 @@ export function PanAPIBridge() {
makePanDefault: () => pan.makePanDefault(),
}
});
// Trigger immediate pan update if the value changed
if (prevIsPanningRef.current !== isPanning) {
prevIsPanningRef.current = isPanning;
triggerImmediatePanUpdate(isPanning);
}
}
}, [pan, isPanning]);
}, [pan, isPanning, triggerImmediatePanUpdate]);
return null;
}
@@ -0,0 +1,60 @@
import { useEffect, useImperativeHandle } from 'react';
import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react';
import { useRedaction } 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,
setBridgeReady
} = useRedaction();
// Mark bridge as ready on mount, not ready on unmount
useEffect(() => {
setBridgeReady(true);
return () => {
setBridgeReady(false);
};
}, [setBridgeReady]);
// 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();
// 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]);
return null;
}
@@ -0,0 +1,41 @@
import { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react';
export interface RedactionPendingTrackerAPI {
commitAllPending: () => void;
getPendingCount: () => number;
}
/**
* RedactionPendingTracker monitors pending redactions and exposes an API
* for committing and checking pending redactions.
* Must be rendered inside the EmbedPDF context.
*
* Note: The unsaved changes checker is registered by EmbedPdfViewer, not here,
* to avoid conflicts and allow the viewer to check both annotations and redactions.
*/
export const RedactionPendingTracker = forwardRef<RedactionPendingTrackerAPI>(
function RedactionPendingTracker(_, ref) {
const { state, provides } = useEmbedPdfRedaction();
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;
}, [state?.pendingCount]);
return null;
}
);
@@ -0,0 +1,177 @@
import { useRedaction as useEmbedPdfRedaction, SelectionMenuProps } from '@embedpdf/plugin-redaction/react';
import { ActionIcon, Tooltip, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { useEffect, useState, useRef, useCallback } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useRedaction } from '@app/contexts/RedactionContext';
/**
* Custom menu component that appears when a pending redaction mark is selected.
* Allows users to remove or apply individual pending marks.
* Uses a portal to ensure it appears above all content, including next pages.
*/
export function RedactionSelectionMenu({ item, selected, menuWrapperProps }: SelectionMenuProps) {
const { t } = useTranslation();
const { provides } = useEmbedPdfRedaction();
const { setRedactionsApplied } = useRedaction();
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Merge refs if menuWrapperProps has a ref
const setRef = useCallback((node: HTMLDivElement | null) => {
wrapperRef.current = node;
if (menuWrapperProps?.ref) {
const ref = menuWrapperProps.ref;
if (typeof ref === 'function') {
ref(node);
} else if (ref && 'current' in ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
}
}, [menuWrapperProps]);
const handleRemove = useCallback(() => {
if (provides?.removePending && item) {
provides.removePending(item.page, item.id);
}
}, [provides, item]);
const handleApply = useCallback(() => {
if (provides?.commitPending && item) {
provides.commitPending(item.page, item.id);
// Mark redactions as applied (but not yet saved) so the Save Changes button stays enabled
// This ensures the button doesn't become disabled when pendingCount decreases
setRedactionsApplied(true);
}
}, [provides, item, setRedactionsApplied]);
// Calculate position for portal based on wrapper element
useEffect(() => {
if (!selected || !item || !wrapperRef.current) {
setMenuPosition(null);
return;
}
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) {
setMenuPosition(null);
return;
}
const rect = wrapper.getBoundingClientRect();
// Position menu below the wrapper, centered
// Use getBoundingClientRect which gives viewport-relative coordinates
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
setMenuPosition({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
});
};
updatePosition();
// Update position on scroll/resize
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [selected, item]);
// Early return AFTER all hooks have been called
if (!selected || !item) return null;
const menuContent = menuPosition ? (
<div
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
zIndex: 10000, // Very high z-index to appear above everything
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="subtle"
color="gray"
size="md"
onClick={handleRemove}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
<Tooltip
label={t('redact.manual.applyWarning', '⚠️ Permanent application, cannot be undone and the data underneath will be deleted')}
withArrow
position="top"
>
<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>
) : null;
// Extract ref from menuWrapperProps to avoid conflicts
const { ref: _, ...wrapperPropsWithoutRef } = menuWrapperProps || {};
return (
<>
<div
ref={setRef}
{...wrapperPropsWithoutRef}
style={{
// Preserve the original positioning from menuWrapperProps
...(wrapperPropsWithoutRef?.style || {}),
// Override visibility to hide the wrapper (we only need its position)
visibility: 'hidden',
pointerEvents: 'none',
}}
/>
{typeof document !== 'undefined' && menuContent
? createPortal(menuContent, document.body)
: null}
</>
);
}
@@ -10,17 +10,30 @@ import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnn
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useNavigationState, useNavigationGuard } from '@app/contexts/NavigationContext';
import { BASE_PATH, withBasePath } from '@app/constants/app';
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
const viewer = useViewer();
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
const { handleToolSelect } = useToolWorkflow();
const { selectedTool } = useNavigationState();
const { requestNavigation } = useNavigationGuard();
const { redactionsApplied, activeType: redactionActiveType } = useRedaction();
const { pendingCount } = useRedactionMode();
// Subscribe to immediate pan updates so button state stays in sync with actual pan state
// This handles cases where annotation/redaction tools disable pan mode
useEffect(() => {
return registerImmediatePanUpdate((newIsPanning) => {
setIsPanning(newIsPanning);
});
}, [registerImmediatePanUpdate]);
const stripBasePath = useCallback((path: string) => {
if (BASE_PATH && path.startsWith(BASE_PATH)) {
@@ -36,8 +49,15 @@ export function useViewerRightRailButtons() {
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
// Update isAnnotationsActive based on selected tool
useEffect(() => {
setIsAnnotationsActive(isAnnotationsPath());
if (selectedTool === 'annotate') {
setIsAnnotationsActive(true);
} else if (selectedTool === 'redact') {
setIsAnnotationsActive(false);
} else {
setIsAnnotationsActive(isAnnotationsPath());
}
}, [selectedTool, isAnnotationsPath]);
useEffect(() => {
@@ -49,13 +69,13 @@ export function useViewerRightRailButtons() {
// Lift i18n labels out of memo for clarity
const searchLabel = t('rightRail.search', 'Search PDF');
const panLabel = t('rightRail.panMode', 'Pan Mode');
const applyRedactionsLabel = t('rightRail.applyRedactionsFirst', 'Apply redactions first');
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
const buttons: RightRailButtonWithAction[] = [
@@ -72,7 +92,7 @@ export function useViewerRightRailButtons() {
withArrow
shadow="md"
offset={8}
opened={viewer.isSearchInterfaceVisible}
opened={isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
>
<Popover.Target>
@@ -91,7 +111,7 @@ export function useViewerRightRailButtons() {
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
<SearchInterface visible={isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
</div>
</Popover.Dropdown>
</Popover>
@@ -100,28 +120,17 @@ export function useViewerRightRailButtons() {
},
{
id: 'viewer-pan-mode',
tooltip: panLabel,
ariaLabel: panLabel,
icon: <LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />,
tooltip: (!isPanning && pendingCount > 0 && redactionActiveType !== null) ? applyRedactionsLabel : panLabel,
ariaLabel: (!isPanning && pendingCount > 0 && redactionActiveType !== null) ? applyRedactionsLabel : panLabel,
section: 'top' as const,
order: 20,
render: ({ disabled }) => (
<Tooltip content={panLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanning ? 'default' : 'subtle'}
color={undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
viewer.panActions.togglePan();
setIsPanning(prev => !prev);
}}
disabled={disabled}
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)
active: isPanning,
disabled: !isPanning && pendingCount > 0 && redactionActiveType !== null,
onClick: () => {
viewer.panActions.togglePan();
setIsPanning(prev => !prev);
},
},
{
id: 'viewer-rotate-left',
@@ -152,6 +161,7 @@ export function useViewerRightRailButtons() {
ariaLabel: sidebarLabel,
section: 'top' as const,
order: 50,
active: isThumbnailSidebarVisible,
onClick: () => {
viewer.toggleThumbnailSidebar();
}
@@ -163,6 +173,7 @@ export function useViewerRightRailButtons() {
ariaLabel: bookmarkLabel,
section: 'top' as const,
order: 55,
active: isBookmarkSidebarVisible,
onClick: () => {
viewer.toggleBookmarkSidebar();
}
@@ -184,24 +195,37 @@ export function useViewerRightRailButtons() {
ariaLabel: annotationsLabel,
section: 'top' as const,
order: 58,
active: isAnnotationsActive,
render: ({ disabled }) => (
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isAnnotationsActive ? 'default' : 'subtle'}
variant={isAnnotationsActive ? 'filled' : 'subtle'}
radius="md"
className="right-rail-icon"
onClick={() => {
if (disabled || isAnnotationsActive) return;
const targetPath = withBasePath('/annotations');
if (window.location.pathname !== targetPath) {
window.history.pushState(null, '', targetPath);
// Check for unsaved redaction changes (pending or applied)
const hasRedactionChanges = pendingCount > 0 || redactionsApplied;
const switchToAnnotations = () => {
const targetPath = withBasePath('/annotations');
if (window.location.pathname !== targetPath) {
window.history.pushState(null, '', targetPath);
}
setIsAnnotationsActive(true);
handleToolSelect('annotate');
};
if (hasRedactionChanges) {
requestNavigation(switchToAnnotations);
} else {
switchToAnnotations();
}
setIsAnnotationsActive(true);
handleToolSelect('annotate');
}}
disabled={disabled || isAnnotationsActive}
disabled={disabled}
aria-pressed={isAnnotationsActive}
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
color={isAnnotationsActive ? 'blue' : undefined}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -225,9 +249,13 @@ export function useViewerRightRailButtons() {
t,
i18n.language,
viewer,
isThumbnailSidebarVisible,
isBookmarkSidebarVisible,
isSearchInterfaceVisible,
isPanning,
searchLabel,
panLabel,
applyRedactionsLabel,
rotateLeftLabel,
rotateRightLabel,
sidebarLabel,
@@ -235,9 +263,10 @@ export function useViewerRightRailButtons() {
printLabel,
tooltipPosition,
annotationsLabel,
saveChangesLabel,
isAnnotationsActive,
handleToolSelect,
pendingCount,
redactionActiveType,
]);
useRightRailButtons(viewerButtons);