).current = node;
+ }
+ }, [wrapperPropsRef]);
+
+ // Get overlay - must be called unconditionally
+ const overlay = typeof document !== 'undefined' ? document.getElementById('pdf-overlay-root') : null;
+
+ // All hooks must be called before any conditional returns
+ useEffect(() => {
+ if (!selected) {
+ setPosition(null);
+ return;
+ }
+
+ const updatePosition = () => {
+ // Get the overlay root
+ const overlayEl = document.getElementById('pdf-overlay-root');
+ if (!overlayEl || !wrapperRef.current) {
+ setPosition(null);
+ return;
+ }
+
+ // Get the wrapper's bounding rect in viewport coordinates
+ const wrapperRect = wrapperRef.current.getBoundingClientRect();
+ const overlayRect = overlayEl.getBoundingClientRect();
+
+ // Calculate position relative to overlay
+ const menuHeight = item?.rect?.size?.height || 0;
+ const top = wrapperRect.top - overlayRect.top + menuHeight + 10;
+ const left = wrapperRect.left - overlayRect.left;
+
+ setPosition({ top, left });
+ };
+
+ // Initial position calculation
+ updatePosition();
+
+ // Update on scroll, resize, and zoom changes
+ const handleUpdate = () => {
+ requestAnimationFrame(updatePosition);
+ };
+
+ window.addEventListener('scroll', handleUpdate, true);
+ window.addEventListener('resize', handleUpdate);
+
+ // Use a mutation observer to catch DOM changes that might affect position
+ const observer = new MutationObserver(handleUpdate);
+ if (wrapperRef.current) {
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+
+ // Also use intersection observer as fallback
+ const intersectionObserver = new IntersectionObserver(
+ () => handleUpdate(),
+ { threshold: 0, root: null }
+ );
+ if (wrapperRef.current) {
+ intersectionObserver.observe(wrapperRef.current);
+ }
+
+ return () => {
+ window.removeEventListener('scroll', handleUpdate, true);
+ window.removeEventListener('resize', handleUpdate);
+ observer.disconnect();
+ intersectionObserver.disconnect();
+ };
+ }, [selected, item?.rect?.size?.height]);
+
+ // Now we can conditionally render - but all hooks have been called
+ if (!selected) {
+ return (
+
+ );
+ }
+
+ const menuContent = (
+
+
+
+
+ );
+
+ return (
+ <>
+
+ {overlay && position && createPortal(menuContent, overlay)}
+ >
+ );
+}
+
+
diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
index 95153f5d2..c4ae45316 100644
--- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
+++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useMemo, useState, useEffect, useRef } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext';
@@ -7,18 +7,57 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
+import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
+import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
export function useViewerRightRailButtons() {
const { t } = useTranslation();
const viewer = useViewer();
- const [isPanning, setIsPanning] = useState(() => viewer.getPanState()?.isPanning ?? false);
+ const { actions: navActions } = useNavigationActions();
+ const { handleToolSelect } = useToolWorkflow();
+ const { workbench, selectedTool } = useNavigationState();
- // Lift i18n labels out of memo for clarity
- const searchLabel = t('rightRail.search', 'Search PDF');
- const panLabel = t('rightRail.panMode', 'Pan Mode');
- const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
- const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
- const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
+ // Extract stable references to viewer methods to avoid re-renders from context changes
+ const getToolModeRef = useRef(viewer.getToolMode);
+ const registerToolModeListenerRef = useRef(viewer.registerToolModeListener);
+ const unregisterToolModeListenerRef = useRef(viewer.unregisterToolModeListener);
+ const setAnnotationModeRef = useRef(viewer.setAnnotationMode);
+ const redactionActionsRef = useRef(viewer.redactionActions);
+ const panActionsRef = useRef(viewer.panActions);
+ const rotationActionsRef = useRef(viewer.rotationActions);
+ const toggleThumbnailSidebarRef = useRef(viewer.toggleThumbnailSidebar);
+ const triggerToolModeUpdateRef = useRef(viewer.triggerToolModeUpdate);
+
+ // Update refs when viewer context changes (but don't cause re-renders)
+ useEffect(() => {
+ getToolModeRef.current = viewer.getToolMode;
+ registerToolModeListenerRef.current = viewer.registerToolModeListener;
+ unregisterToolModeListenerRef.current = viewer.unregisterToolModeListener;
+ setAnnotationModeRef.current = viewer.setAnnotationMode;
+ redactionActionsRef.current = viewer.redactionActions;
+ panActionsRef.current = viewer.panActions;
+ rotationActionsRef.current = viewer.rotationActions;
+ toggleThumbnailSidebarRef.current = viewer.toggleThumbnailSidebar;
+ triggerToolModeUpdateRef.current = viewer.triggerToolModeUpdate;
+ }, [viewer]);
+
+ // Single source of truth for active tool mode (none | pan | redact | draw)
+ const [activeMode, setActiveMode] = useState<'none' | 'pan' | 'redact' | 'draw'>(() => getToolModeRef.current());
+ useEffect(() => {
+ registerToolModeListenerRef.current((mode) => setActiveMode(mode));
+ return () => unregisterToolModeListenerRef.current();
+ }, []); // Empty deps - refs are stable
+
+ // Memoize i18n labels to prevent re-renders
+ const searchLabel = useMemo(() => t('rightRail.search', 'Search PDF'), [t]);
+ const panLabel = useMemo(() => t('rightRail.panMode', 'Pan Mode'), [t]);
+ const rotateLeftLabel = useMemo(() => t('rightRail.rotateLeft', 'Rotate Left'), [t]);
+ const rotateRightLabel = useMemo(() => t('rightRail.rotateRight', 'Rotate Right'), [t]);
+ const sidebarLabel = useMemo(() => t('rightRail.toggleSidebar', 'Toggle Sidebar'), [t]);
+ const redactLabel = useMemo(() => t('rightRail.redact', 'Redact'), [t]);
+
+ const isPanning = activeMode === 'pan';
+ const isRedacting = activeMode === 'redact';
const viewerButtons = useMemo(() => {
return [
@@ -67,8 +106,24 @@ export function useViewerRightRailButtons() {
radius="md"
className="right-rail-icon"
onClick={() => {
- viewer.panActions.togglePan();
- setIsPanning(prev => !prev);
+ // Entering pan should disable draw and redaction; leaving pan just toggles off
+ if (!isPanning) {
+ try { setAnnotationModeRef.current(false); } catch {}
+ try { redactionActionsRef.current.deactivate(); } catch {}
+ const enable = () => {
+ try { panActionsRef.current.enablePan(); } catch {}
+ try { triggerToolModeUpdateRef.current(); } catch {}
+ };
+ if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
+ requestAnimationFrame(() => setTimeout(enable, 0));
+ } else {
+ setTimeout(enable, 0);
+ }
+ } else {
+ try { panActionsRef.current.disablePan(); } catch {}
+ try { triggerToolModeUpdateRef.current(); } catch {}
+ }
+ // activeMode will update via listener
}}
disabled={disabled}
>
@@ -85,7 +140,7 @@ export function useViewerRightRailButtons() {
section: 'top' as const,
order: 30,
onClick: () => {
- viewer.rotationActions.rotateBackward();
+ rotationActionsRef.current.rotateBackward();
}
},
{
@@ -96,7 +151,7 @@ export function useViewerRightRailButtons() {
section: 'top' as const,
order: 40,
onClick: () => {
- viewer.rotationActions.rotateForward();
+ rotationActionsRef.current.rotateForward();
}
},
{
@@ -107,9 +162,56 @@ export function useViewerRightRailButtons() {
section: 'top' as const,
order: 50,
onClick: () => {
- viewer.toggleThumbnailSidebar();
+ toggleThumbnailSidebarRef.current();
}
},
+ {
+ id: 'viewer-redaction',
+ tooltip: redactLabel,
+ ariaLabel: redactLabel,
+ section: 'top' as const,
+ order: 55,
+ render: ({ disabled }) => (
+
+ {
+ // Ensure the left sidebar opens the Redact tool in viewer with manual mode
+ sessionStorage.setItem('redaction:init', 'manual');
+ // Navigate to viewer with the redact tool if we're not already there
+ if (workbench !== 'viewer' || selectedTool !== 'redact') {
+ handleToolSelect('redact' as any);
+ }
+ // Disable draw and pan when activating redaction
+ try { setAnnotationModeRef.current(false); } catch {}
+ try { panActionsRef.current.disablePan(); } catch {}
+ // Activate last used manual mode inside viewer.
+ // Defer to next frame to allow annotation plugin to fully release interaction.
+ const last = (sessionStorage.getItem('redaction:lastManualType') as 'redactSelection' | 'marqueeRedact' | null) || 'redactSelection';
+ const activate = () => {
+ if (last === 'marqueeRedact') {
+ redactionActionsRef.current.activateArea();
+ } else {
+ redactionActionsRef.current.activateText();
+ }
+ try { triggerToolModeUpdateRef.current(); } catch {}
+ };
+ if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
+ requestAnimationFrame(() => setTimeout(activate, 0));
+ } else {
+ setTimeout(activate, 0);
+ }
+ }}
+ disabled={disabled}
+ >
+
+
+
+ )
+ },
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@@ -119,7 +221,7 @@ export function useViewerRightRailButtons() {
)
}
];
- }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
+ }, [activeMode, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, redactLabel, handleToolSelect, workbench, selectedTool, isPanning, isRedacting]);
useRightRailButtons(viewerButtons);
}
diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx
index 8e0bea44a..ef7dfdde0 100644
--- a/frontend/src/core/contexts/ViewerContext.tsx
+++ b/frontend/src/core/contexts/ViewerContext.tsx
@@ -57,6 +57,19 @@ interface ExportAPIWrapper {
saveAsCopy: () => { toPromise: () => Promise };
}
+// Redaction bridge wrappers
+interface RedactionAPIWrapper {
+ toggleRedactSelection: () => void;
+ toggleMarqueeRedact: () => void;
+ clearPending: () => void;
+ commitAllPending: () => { toPromise: () => Promise } | Promise | void;
+}
+
+interface RedactionState {
+ isRedacting: boolean;
+ activeType: 'redactSelection' | 'marqueeRedact' | null;
+ pendingCount: number;
+}
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
@@ -103,6 +116,8 @@ interface ExportState {
canExport: boolean;
}
+type ToolMode = 'none' | 'pan' | 'redact' | 'draw';
+
// Bridge registration interface - bridges register with state and API
interface BridgeRef {
state: TState;
@@ -146,6 +161,8 @@ interface ViewerContextType {
getSearchState: () => SearchState;
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
getExportState: () => ExportState;
+ getToolMode: () => ToolMode;
+ getRedactionState: () => RedactionState;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
@@ -208,6 +225,20 @@ interface ViewerContextType {
saveAsCopy: () => Promise;
};
+ redactionActions: {
+ activateText: () => void;
+ activateArea: () => void;
+ deactivate: () => void;
+ commitAllPending: () => Promise;
+ clearPending: () => void;
+ isActive: () => boolean;
+ };
+
+ // Live updates for right-rail highlighting
+ registerToolModeListener: (callback: (mode: ToolMode) => void) => void;
+ unregisterToolModeListener: () => void;
+ triggerToolModeUpdate: () => void;
+
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
}
@@ -239,8 +270,11 @@ export const ViewerProvider: React.FC = ({ children }) => {
rotation: null as BridgeRef | null,
thumbnail: null as BridgeRef | null,
export: null as BridgeRef | null,
+ redaction: null as BridgeRef | null,
});
+ const toolModeListenerRef = useRef<((mode: ToolMode) => void) | null>(null);
+
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
@@ -277,6 +311,9 @@ export const ViewerProvider: React.FC = ({ children }) => {
case 'export':
bridgeRefs.current.export = ref as BridgeRef;
break;
+ case 'redaction':
+ bridgeRefs.current.redaction = ref as BridgeRef;
+ break;
}
};
@@ -290,10 +327,14 @@ export const ViewerProvider: React.FC = ({ children }) => {
const setAnnotationMode = (enabled: boolean) => {
setIsAnnotationModeState(enabled);
+ // Notify listeners when draw mode changes
+ triggerToolModeUpdate();
};
const toggleAnnotationMode = () => {
setIsAnnotationModeState(prev => !prev);
+ // Notify listeners when draw mode changes
+ setTimeout(() => triggerToolModeUpdate(), 0);
};
// State getters - read from bridge refs
@@ -333,6 +374,25 @@ export const ViewerProvider: React.FC = ({ children }) => {
return bridgeRefs.current.export?.state || { canExport: false };
};
+ const getToolMode = (): ToolMode => {
+ if (isAnnotationMode) return 'draw';
+ const redactionActive = bridgeRefs.current.redaction?.state?.isRedacting;
+ if (redactionActive) return 'redact';
+ const panActive = bridgeRefs.current.pan?.state?.isPanning;
+ if (panActive) return 'pan';
+ return 'none';
+ };
+
+ const getRedactionState = (): RedactionState => {
+ return (
+ bridgeRefs.current.redaction?.state || {
+ isRedacting: false,
+ activeType: null,
+ pendingCount: 0,
+ }
+ );
+ };
+
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
@@ -550,6 +610,66 @@ export const ViewerProvider: React.FC = ({ children }) => {
}
};
+ // Track redaction dirty state (any commit or pending marks)
+ const redactionActions = {
+ activateText: () => {
+ const api = bridgeRefs.current.redaction?.api;
+ const state = bridgeRefs.current.redaction?.state;
+ if (!api) return;
+ if (state?.activeType === 'redactSelection') return; // already active
+ // If other mode active, turn it off first
+ if (state?.activeType === 'marqueeRedact' && api.toggleMarqueeRedact) api.toggleMarqueeRedact();
+ if (api.toggleRedactSelection) api.toggleRedactSelection();
+ },
+ activateArea: () => {
+ const api = bridgeRefs.current.redaction?.api;
+ const state = bridgeRefs.current.redaction?.state;
+ if (!api) return;
+ if (state?.activeType === 'marqueeRedact') return; // already active
+ if (state?.activeType === 'redactSelection' && api.toggleRedactSelection) api.toggleRedactSelection();
+ if (api.toggleMarqueeRedact) api.toggleMarqueeRedact();
+ },
+ deactivate: () => {
+ const state = bridgeRefs.current.redaction?.state;
+ const api = bridgeRefs.current.redaction?.api;
+ if (!state || !api) return;
+ // If text is active, toggling text will deactivate; same for area
+ if (state.activeType === 'redactSelection' && api.toggleRedactSelection) {
+ api.toggleRedactSelection();
+ } else if (state.activeType === 'marqueeRedact' && api.toggleMarqueeRedact) {
+ api.toggleMarqueeRedact();
+ }
+ },
+ commitAllPending: async () => {
+ const api = bridgeRefs.current.redaction?.api;
+ if (!api?.commitAllPending) return;
+ const result = api.commitAllPending();
+ if (result && typeof (result as any).toPromise === 'function') {
+ await (result as any).toPromise();
+ }
+ },
+ clearPending: () => {
+ const api = bridgeRefs.current.redaction?.api;
+ if (api?.clearPending) api.clearPending();
+ },
+ isActive: () => {
+ return Boolean(bridgeRefs.current.redaction?.state?.isRedacting);
+ },
+ };
+
+ const registerToolModeListener = (callback: (mode: ToolMode) => void) => {
+ toolModeListenerRef.current = callback;
+ };
+
+ const unregisterToolModeListener = () => {
+ toolModeListenerRef.current = null;
+ };
+
+ const triggerToolModeUpdate = () => {
+ const cb = toolModeListenerRef.current;
+ if (cb) cb(getToolMode());
+ };
+
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
@@ -596,6 +716,8 @@ export const ViewerProvider: React.FC = ({ children }) => {
getSearchState,
getThumbnailAPI,
getExportState,
+ getToolMode,
+ getRedactionState,
// Immediate updates
registerImmediateZoomUpdate,
@@ -612,6 +734,10 @@ export const ViewerProvider: React.FC = ({ children }) => {
rotationActions,
searchActions,
exportActions,
+ redactionActions,
+ registerToolModeListener,
+ unregisterToolModeListener,
+ triggerToolModeUpdate,
// Bridge registration
registerBridge,
diff --git a/frontend/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/src/core/hooks/tools/redact/useRedactOperation.ts
index bf2a05121..63a9cb20f 100644
--- a/frontend/src/core/hooks/tools/redact/useRedactOperation.ts
+++ b/frontend/src/core/hooks/tools/redact/useRedactOperation.ts
@@ -17,8 +17,9 @@ export const buildRedactFormData = (parameters: RedactParameters, file: File): F
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');
+ // Manual mode is handled interactively in the viewer via EmbedPDF redaction plugin.
+ // The sidebar tool never posts a request in manual mode, so return minimal form data.
+ formData.append('mode', 'manual');
}
return formData;
@@ -33,8 +34,8 @@ export const redactOperationConfig = {
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 mode does not call backend; return a placeholder that will not be invoked.
+ return '/noop/manual-redaction';
}
},
defaultParameters,
diff --git a/frontend/src/core/hooks/tools/redact/useRedactParameters.ts b/frontend/src/core/hooks/tools/redact/useRedactParameters.ts
index f29f56f96..37ecaa312 100644
--- a/frontend/src/core/hooks/tools/redact/useRedactParameters.ts
+++ b/frontend/src/core/hooks/tools/redact/useRedactParameters.ts
@@ -34,15 +34,15 @@ 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 mode is handled in the viewer; this endpoint will not be called
+ return '/noop/manual-redaction';
},
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;
+ // For manual, validation is not needed for network calls; allow switching
+ return true;
}
});
};
diff --git a/frontend/src/core/tools/Redact.tsx b/frontend/src/core/tools/Redact.tsx
index 27604da0b..d4d6774bc 100644
--- a/frontend/src/core/tools/Redact.tsx
+++ b/frontend/src/core/tools/Redact.tsx
@@ -1,14 +1,19 @@
import { useTranslation } from "react-i18next";
-import { useState } from "react";
+import { useState, useEffect, useRef, useMemo, useCallback } 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 { 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 { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips, useRedactManualTips } from "@app/components/tooltips/useRedactTips";
import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings";
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
+import RedactManualControls, { ManualRedactionType } from "@app/components/tools/redact/RedactManualControls";
+import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext";
+import { useViewer } from "@app/contexts/ViewerContext";
+import { Stack, Alert, Button, Text } from "@mantine/core";
+import WarningIcon from '@mui/icons-material/Warning';
const Redact = (props: BaseToolProps) => {
const { t } = useTranslation();
@@ -29,6 +34,110 @@ const Redact = (props: BaseToolProps) => {
const modeTips = useRedactModeTips();
const wordsTips = useRedactWordsTips();
const advancedTips = useRedactAdvancedTips();
+ const manualTips = useRedactManualTips();
+
+ // Navigation for switching to viewer on Manual
+ const { actions: navActions } = useNavigationActions();
+ const { workbench } = useNavigationState();
+ const viewer = useViewer();
+
+ // Extract stable references to viewer methods to avoid re-renders from context changes
+ const getRedactionStateRef = useRef(viewer.getRedactionState);
+ const registerToolModeListenerRef = useRef(viewer.registerToolModeListener);
+ const unregisterToolModeListenerRef = useRef(viewer.unregisterToolModeListener);
+ const redactionActionsRef = useRef(viewer.redactionActions);
+ const setAnnotationModeRef = useRef(viewer.setAnnotationMode);
+ const panActionsRef = useRef(viewer.panActions);
+ const triggerToolModeUpdateRef = useRef(viewer.triggerToolModeUpdate);
+
+ // Update refs when viewer context changes (but don't cause re-renders)
+ useEffect(() => {
+ getRedactionStateRef.current = viewer.getRedactionState;
+ registerToolModeListenerRef.current = viewer.registerToolModeListener;
+ unregisterToolModeListenerRef.current = viewer.unregisterToolModeListener;
+ redactionActionsRef.current = viewer.redactionActions;
+ setAnnotationModeRef.current = viewer.setAnnotationMode;
+ panActionsRef.current = viewer.panActions;
+ triggerToolModeUpdateRef.current = viewer.triggerToolModeUpdate;
+ }, [viewer]);
+
+ // Force re-render when tool mode changes to ensure buttons reflect current state
+ const [updateCounter, setUpdateCounter] = useState(0);
+ useEffect(() => {
+ const handleToolModeUpdate = () => {
+ setUpdateCounter(prev => prev + 1);
+ };
+ registerToolModeListenerRef.current(handleToolModeUpdate);
+ return () => {
+ unregisterToolModeListenerRef.current();
+ };
+ }, []); // Empty deps - refs are stable
+
+ // Track redaction state to ensure UI stays in sync with plugin state
+ // Use a ref to track the last known state and only update when it changes
+ const [redactionStateCheck, setRedactionStateCheck] = useState(0);
+ const lastRedactionStateRef = useRef<{ activeType: string | null; pendingCount: number } | null>(null);
+
+ useEffect(() => {
+ if (base.params.parameters.mode !== 'manual') return;
+
+ // Check redaction state periodically, but only update if it actually changed
+ const interval = setInterval(() => {
+ const currentState = getRedactionStateRef.current();
+ const lastState = lastRedactionStateRef.current;
+
+ // Only trigger update if activeType changed (not just pendingCount)
+ if (!lastState || lastState.activeType !== currentState.activeType) {
+ lastRedactionStateRef.current = {
+ activeType: currentState.activeType,
+ pendingCount: currentState.pendingCount,
+ };
+ setRedactionStateCheck(prev => prev + 1);
+ } else {
+ // Update ref even if we don't trigger re-render
+ lastRedactionStateRef.current = {
+ activeType: currentState.activeType,
+ pendingCount: currentState.pendingCount,
+ };
+ }
+ }, 500); // Check less frequently - only when mode actually changes
+
+ return () => clearInterval(interval);
+ }, [base.params.parameters.mode]); // Removed viewer dependency
+
+ // Check if we need to show the viewer warning
+ const isManualModeOutsideViewer = base.params.parameters.mode === 'manual' && workbench !== 'viewer';
+
+ const handleEnterManual = useCallback(() => {
+ // Mark that manual redaction should be initialized and activate last mode
+ sessionStorage.setItem('redaction:init', 'manual');
+ // Persist current choice if any
+ const last = (sessionStorage.getItem('redaction:lastManualType') as ManualRedactionType | null) || 'redactSelection';
+ sessionStorage.setItem('redaction:lastManualType', last);
+ // Switch to viewer and show the Redact tool in sidebar
+ navActions.setToolAndWorkbench('redact' as any, 'viewer');
+ // Defer activation to ensure viewer is ready and other tools are disabled
+ const activate = () => {
+ try { setAnnotationModeRef.current(false); } catch {}
+ try { panActionsRef.current.disablePan(); } catch {}
+ const activateRedaction = () => {
+ if (last === 'marqueeRedact') {
+ redactionActionsRef.current.activateArea();
+ } else {
+ redactionActionsRef.current.activateText();
+ }
+ try { triggerToolModeUpdateRef.current(); } catch {}
+ };
+ // Use double deferral to ensure state is settled
+ if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
+ requestAnimationFrame(() => setTimeout(activateRedaction, 0));
+ } else {
+ setTimeout(activateRedaction, 0);
+ }
+ };
+ // Defer activation slightly to allow navigation to complete
+ setTimeout(activate, 50);
+ }, [navActions]);
const isExecuteDisabled = () => {
if (base.params.parameters.mode === 'manual') {
@@ -38,12 +147,20 @@ const Redact = (props: BaseToolProps) => {
};
// Compute actual collapsed state based on results and user state
- const getActualCollapsedState = (userCollapsed: boolean) => {
+ const getActualCollapsedState = useCallback((userCollapsed: boolean) => {
return (!base.hasFiles || base.hasResults) ? true : userCollapsed; // Force collapse when results are shown
- };
+ }, [base.hasFiles, base.hasResults]);
+
+ // Memoize redaction state to avoid calling getRedactionState on every render
+ const redactionState = useMemo(() => {
+ if (base.params.parameters.mode !== 'manual') return null;
+ // Reference check counter to ensure re-evaluation when it changes
+ void redactionStateCheck;
+ return getRedactionStateRef.current();
+ }, [base.params.parameters.mode, redactionStateCheck]);
// Build conditional steps based on redaction mode
- const buildSteps = () => {
+ const buildSteps = useCallback(() => {
const steps = [
// Method selection step (always present)
{
@@ -52,11 +169,41 @@ const Redact = (props: BaseToolProps) => {
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed),
tooltip: modeTips,
content: (
- base.params.updateParameter('mode', mode)}
- disabled={base.endpointLoading}
- />
+
+ {
+ base.params.updateParameter('mode', mode);
+ if (mode === 'manual') {
+ handleEnterManual();
+ }
+ }}
+ disabled={base.endpointLoading}
+ />
+ {isManualModeOutsideViewer && (
+ }
+ >
+
+
+ {t("redact.manual.viewerWarning.message", "Manual redaction can only be used in the viewer view. Please switch to the viewer to use this feature.")}
+
+
+
+
+ )}
+
),
}
];
@@ -88,11 +235,60 @@ const Redact = (props: BaseToolProps) => {
},
);
} else if (base.params.parameters.mode === 'manual') {
- // Manual mode steps would go here when implemented
+ steps.push({
+ title: t("redact.manual.settings.title", "Manual Redaction"),
+ isCollapsed: getActualCollapsedState(false),
+ onCollapsedClick: () => {},
+ tooltip: manualTips,
+ content: (
+ {
+ sessionStorage.setItem('redaction:lastManualType', val);
+ // Ensure we're in viewer and activate chosen tool
+ handleEnterManual();
+ if (val === 'marqueeRedact') redactionActionsRef.current.activateArea();
+ else redactionActionsRef.current.activateText();
+ // Trigger a state check to update UI
+ setRedactionStateCheck(prev => prev + 1);
+ }}
+ disabled={base.endpointLoading}
+ />
+ )
+ });
}
return steps;
- };
+ }, [
+ base.params.parameters, // Required for RedactAdvancedSettings prop
+ base.hasFiles,
+ base.hasResults,
+ base.endpointLoading,
+ base.params.updateParameter,
+ methodCollapsed,
+ wordsCollapsed,
+ advancedCollapsed,
+ modeTips,
+ wordsTips,
+ advancedTips,
+ manualTips,
+ isManualModeOutsideViewer,
+ handleEnterManual,
+ redactionState,
+ t,
+ getActualCollapsedState,
+ base.settingsCollapsed,
+ base.handleSettingsReset,
+ setMethodCollapsed,
+ setWordsCollapsed,
+ setAdvancedCollapsed,
+ ]);
return createToolFlow({
files: {