diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 53aa9b044..3195bb89a 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -258,7 +258,9 @@ public ResponseEntity runJobGeneric( // GlobalExceptionHandler (either directly or wrapped) Throwable cause = e.getCause(); if (e instanceof IllegalArgumentException - || cause instanceof stirling.software.common.util.ExceptionUtils.BaseAppException + || cause + instanceof + stirling.software.common.util.ExceptionUtils.BaseAppException || cause instanceof stirling.software.common.util.ExceptionUtils diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 02f168117..969de4154 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5000,18 +5000,39 @@ zoomOut = "Zoom out" placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" title = "Pages" +[redact.modeSelector] +automatic = "Automatic" +automaticDesc = "Automatically detect and redact sensitive information like PII." +automaticDisabledTooltip = "Automatic redaction is currently disabled" +manual = "Manual Redaction" +manualComingSoon = "Coming Soon" +manualDesc = "Manually select areas to redact on each page." +mode = "Redaction Mode" +searchAndRedact = "Search & Redact" +searchAndRedactDisabledTooltip = "Search & Redact is only available for PDF files" +title = "Choose Redaction Method" + +[redact.searchAndRedact] +caseSensitive = "Case sensitive" +colorLabel = "Redaction Colour" +controlsTitle = "Search & Redact Controls" +instructions = "Enter text to find in the PDF. You can then redact all matches at once." +matchesFound = "{{count}} matches found on {{pages}} page(s)" +noMatches = "No matches found" +noMatchesRedacted = "No matches found to redact" +onPages = "Pages: {{pages}}" +redactButton = "Redact All Matches" +redactFailed = "Redaction failed" +searchButton = "Search" +searchFailed = "Search failed" +searchLabel = "Search Text" +searchPlaceholder = "Enter text to search for..." +title = "Search & Redact" +wholeWord = "Whole word" + [redact.manual.redactionColor] title = "Redaction Colour" -[redact.modeSelector] -automatic = "Automatic" -automaticDesc = "Redact text based on search terms" -automaticDisabledTooltip = "Select files in the file manager to redact multiple files at once" -manual = "Manual" -manualComingSoon = "Manual redaction coming soon" -manualDesc = "Click and drag to redact specific areas" -mode = "Mode" -title = "Redaction Method" [redact.tooltip.advanced.color] text = "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text." diff --git a/frontend/src/core/components/tools/redact/RedactModeSelector.tsx b/frontend/src/core/components/tools/redact/RedactModeSelector.tsx index 7f669ce1e..517ffdb76 100644 --- a/frontend/src/core/components/tools/redact/RedactModeSelector.tsx +++ b/frontend/src/core/components/tools/redact/RedactModeSelector.tsx @@ -6,7 +6,6 @@ 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) } @@ -14,7 +13,6 @@ export default function RedactModeSelector({ mode, onModeChange, disabled, - hasFilesSelected = false, hasAnyFiles = false }: RedactModeSelectorProps) { const { t } = useTranslation(); @@ -27,10 +25,10 @@ export default function RedactModeSelector({ options={[ { 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') + label: t('redact.modeSelector.searchAndRedact', 'Search & Redact'), + disabled: !hasAnyFiles, + tooltip: !hasAnyFiles + ? t('redact.modeSelector.searchAndRedactDisabledTooltip', 'Add files to the workbench to use Search & Redact') : undefined, }, { diff --git a/frontend/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx b/frontend/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx index 4b516aa3b..bd81821a4 100644 --- a/frontend/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx +++ b/frontend/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx @@ -32,7 +32,7 @@ describe('RedactSingleStepSettings', () => { ); expect(screen.getByText('Mode')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Search & Redact' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument(); }); @@ -124,7 +124,7 @@ describe('RedactSingleStepSettings', () => { ); // Mode selector buttons should be disabled - expect(screen.getByRole('button', { name: 'Automatic' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Search & Redact' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Manual' })).toBeDisabled(); // Automatic settings controls should be disabled diff --git a/frontend/src/core/components/tools/redact/SearchAndRedactControls.tsx b/frontend/src/core/components/tools/redact/SearchAndRedactControls.tsx new file mode 100644 index 000000000..763b02734 --- /dev/null +++ b/frontend/src/core/components/tools/redact/SearchAndRedactControls.tsx @@ -0,0 +1,265 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Stack, Text, Divider, ColorInput, TextInput, Checkbox, Group, Loader } from '@mantine/core'; +import SearchIcon from '@mui/icons-material/Search'; +import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext'; +import { useViewer } from '@app/contexts/ViewerContext'; +import type { SearchTextResult } from '@app/contexts/RedactionContext'; + +interface SearchAndRedactControlsProps { + disabled?: boolean; +} + +/** + * SearchAndRedactControls provides UI for the Search & Redact workflow. + * Searches for text across the PDF and redacts all matches at once. + */ +export default function SearchAndRedactControls({ disabled = false }: SearchAndRedactControlsProps) { + const { t } = useTranslation(); + const { searchText, redactText, setManualRedactColor, clearSearch } = useRedaction(); + const { isBridgeReady, manualRedactColor } = useRedactionMode(); + const { applyChanges } = useViewer(); + const inputRef = useRef(null); + + // Internal state + const [query, setQuery] = useState(''); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [isRedacting, setIsRedacting] = useState(false); + const [searchResults, setSearchResults] = useState(null); + const [error, setError] = useState(null); + + const isApiReady = isBridgeReady; + + useEffect(() => { + if (isApiReady && inputRef.current) { + inputRef.current.focus(); + } + }, [isApiReady]); + + // Clear search results and highlights on unmount + useEffect(() => { + return () => { + try { + clearSearch(); + } catch (_) { + // Ignore if bridge is already gone + } + }; + }, [clearSearch]); + + const handleSearch = useCallback(async () => { + if (!query.trim()) return; + + setIsSearching(true); + setError(null); + setSearchResults(null); + + try { + const result = await searchText(query, { caseSensitive, wholeWord }); + setSearchResults(result); + } catch (err) { + setError(err instanceof Error ? err.message : t('redact.searchAndRedact.searchFailed', 'Search failed')); + } finally { + setIsSearching(false); + } + }, [query, caseSensitive, wholeWord, searchText, t]); + + const handleRedact = useCallback(async () => { + if (!query.trim()) return; + + setIsRedacting(true); + setError(null); + + try { + // If the user hasn't searched yet (or options changed), run the search + // automatically so they don't need to hit Search before Redact All Matches. + let currentResults = searchResults; + if (!currentResults) { + const found = await searchText(query, { caseSensitive, wholeWord }); + setSearchResults(found); + currentResults = found; + } + + if (currentResults.totalCount === 0) { + setError(t('redact.searchAndRedact.noMatchesRedacted', 'No matches found to redact')); + return; + } + + const result = await redactText(query, { caseSensitive, wholeWord }); + if (result) { + // Redaction annotations created successfully — clear search results and highlights + setSearchResults(null); + setQuery(''); + try { + clearSearch(); + } catch (_) { + // Ignore + } + } else { + setError(t('redact.searchAndRedact.noMatchesRedacted', 'No matches found to redact')); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('redact.searchAndRedact.redactFailed', 'Redaction failed')); + } finally { + setIsRedacting(false); + } + }, [query, caseSensitive, wholeWord, searchText, redactText, searchResults, t]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } + }; + + // Handle saving changes + const handleSaveChanges = useCallback(async () => { + if (applyChanges) { + await applyChanges(); + } + }, [applyChanges]); + + const hasResults = searchResults !== null; + const matchCount = searchResults?.totalCount ?? 0; + const pageCount = searchResults?.foundOnPages.length ?? 0; + + return ( + <> + + + + {t('redact.searchAndRedact.title', 'Search & Redact')} + + + + {t('redact.searchAndRedact.instructions', 'Enter text to find in the PDF. You can then redact all matches at once.')} + + + {/* Search input */} + { + setQuery(e.currentTarget.value); + setSearchResults(null); + setError(null); + }} + onKeyDown={handleKeyDown} + disabled={disabled || !isApiReady || isSearching || isRedacting} + size="sm" + rightSection={isSearching ? : undefined} + /> + + {/* Search options */} + + { + setCaseSensitive(e.currentTarget.checked); + setSearchResults(null); + }} + disabled={disabled || !isApiReady || isSearching || isRedacting} + size="sm" + /> + { + setWholeWord(e.currentTarget.checked); + setSearchResults(null); + }} + disabled={disabled || !isApiReady || isSearching || isRedacting} + size="sm" + /> + + + {/* Color picker */} + + + {/* Search button */} + + + {/* Results display */} + {hasResults && matchCount > 0 && ( + + {t('redact.searchAndRedact.matchesFound', '{{count}} matches found on {{pages}} page(s)', { + count: matchCount, + pages: pageCount, + })} + + )} + + {hasResults && matchCount === 0 && ( + + {t('redact.searchAndRedact.noMatches', 'No matches found')} + + )} + + {/* Page list */} + {hasResults && matchCount > 0 && searchResults.foundOnPages.length <= 20 && ( + + {t('redact.searchAndRedact.onPages', 'Pages: {{pages}}', { + pages: searchResults.foundOnPages.join(', '), + })} + + )} + + {/* Error display */} + {error && ( + + {error} + + )} + + {/* Redact button */} + + + {/* Save Changes Button */} + + + + ); +} diff --git a/frontend/src/core/components/viewer/CustomSearchLayer.tsx b/frontend/src/core/components/viewer/CustomSearchLayer.tsx index 7320e1bdb..d8f4c4b34 100644 --- a/frontend/src/core/components/viewer/CustomSearchLayer.tsx +++ b/frontend/src/core/components/viewer/CustomSearchLayer.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useDocumentState } from '@embedpdf/core/react'; import { useSearch } from '@embedpdf/plugin-search/react'; import { SEARCH_CONSTANTS } from '@app/components/viewer/constants/search'; +import { useRedactionMode } from '@app/contexts/RedactionContext'; interface SearchLayerProps { documentId?: string; @@ -37,6 +38,9 @@ export function CustomSearchLayer({ }: SearchLayerProps) { const { provides: searchProvides } = useSearch(documentId); const documentState = useDocumentState(documentId); + // In redaction mode all highlights should be uniform — no active (orange) result, + // since there is no next/previous navigation in the Search & Redact workflow. + const { isRedactionModeActive } = useRedactionMode(); const [searchResultState, setSearchResultState] = useState(null); // Use document scale from EmbedPDF state, fallback to prop, then to 1 @@ -97,7 +101,7 @@ export function CustomSearchLayer({ left: `${rect.origin.x * scale - padding}px`, width: `${rect.size.width * scale + (padding * 2)}px`, height: `${rect.size.height * scale + (padding * 2)}px`, - backgroundColor: originalIndex === searchResultState?.activeResultIndex + backgroundColor: (!isRedactionModeActive && originalIndex === searchResultState?.activeResultIndex) ? activeHighlightColor : highlightColor, opacity: opacity, @@ -106,7 +110,7 @@ export function CustomSearchLayer({ transformOrigin: 'center', transition: 'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out', pointerEvents: 'none', - boxShadow: originalIndex === searchResultState?.activeResultIndex + boxShadow: (!isRedactionModeActive && originalIndex === searchResultState?.activeResultIndex) ? `0 0 0 1px ${SEARCH_CONSTANTS.HIGHLIGHT_COLORS.ACTIVE_BACKGROUND}80` : 'none' }} diff --git a/frontend/src/core/components/viewer/RedactionAPIBridge.tsx b/frontend/src/core/components/viewer/RedactionAPIBridge.tsx index 4d78abce0..72bae6988 100644 --- a/frontend/src/core/components/viewer/RedactionAPIBridge.tsx +++ b/frontend/src/core/components/viewer/RedactionAPIBridge.tsx @@ -1,10 +1,14 @@ -import { useEffect, useImperativeHandle } from 'react'; +import { useEffect, useImperativeHandle, useCallback, useRef } from 'react'; import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react'; -import { PdfAnnotationSubtype } from '@embedpdf/models'; +import { useSearch } from '@embedpdf/plugin-search/react'; +import { useAnnotation } from '@embedpdf/plugin-annotation/react'; +import { PdfAnnotationSubtype, boundingRect, MatchFlag } from '@embedpdf/models'; +import type { PdfRedactAnnoObject, SearchResult } from '@embedpdf/models'; import { useRedaction } from '@app/contexts/RedactionContext'; import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId'; import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady'; +import type { SearchRedactOptions, SearchTextResult } from '@app/contexts/RedactionContext'; /** * Bridges between the EmbedPDF redaction plugin and the Stirling-PDF RedactionContext. @@ -24,7 +28,9 @@ export function RedactionAPIBridge() { function RedactionAPIBridgeInner({ documentId }: { documentId: string }) { const { state, provides: redactionProvides } = useEmbedPdfRedaction(documentId); - const { provides: annotationProvides } = useAnnotationCapability(); + const { provides: searchProvides } = useSearch(documentId); + const { provides: annotationCapability } = useAnnotationCapability(); + const { provides: annotationScope } = useAnnotation(documentId); const { redactionApiRef, setPendingCount, @@ -34,11 +40,24 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) { manualRedactColor } = useRedaction(); - // Mark bridge as ready on mount, not ready on unmount + // Cache search results from the last searchText call. + const cachedSearchResults = useRef([]); + + // Keep a ref to searchProvides so the unmount cleanup always has the latest value. + const searchProvidesRef = useRef(searchProvides); + useEffect(() => { + searchProvidesRef.current = searchProvides; + }, [searchProvides]); + + // Mark bridge as ready on mount, clear search highlights and cache on unmount. useEffect(() => { setBridgeReady(true); return () => { setBridgeReady(false); + // Clear any lingering search highlights when the bridge tears down + // (e.g. user navigates away from the Redact tool). + searchProvidesRef.current?.stopSearch?.(); + cachedSearchResults.current = []; }; }, [setBridgeReady]); @@ -52,9 +71,8 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) { }, [state, setPendingCount, setActiveType, setIsRedacting]); // Synchronize manual redaction color with EmbedPDF - // Manual redaction uses the 'redact' annotation tool internally useEffect(() => { - const annotationApi = annotationProvides as any; + const annotationApi = annotationCapability as any; if (annotationApi?.setToolDefaults) { annotationApi.setToolDefaults('redact', { type: PdfAnnotationSubtype.REDACT, @@ -67,7 +85,117 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) { opacity: 1 }); } - }, [annotationProvides, manualRedactColor]); + }, [annotationCapability, manualRedactColor]); + + /** + * Search and Redact: searchText implementation + * Caches raw SearchResult[] for use by redactText + */ + const handleSearchText = useCallback(async ( + text: string, + options?: SearchRedactOptions, + ): Promise => { + if (!searchProvides) { + throw new Error('Search plugin not available'); + } + + // Build flags + const flags: MatchFlag[] = []; + if (options?.caseSensitive) { + flags.push(MatchFlag.MatchCase); + } + if (options?.wholeWord) { + flags.push(MatchFlag.MatchWholeWord); + } + + // Set flags on the search scope + searchProvides.setFlags(flags); + + // End any previous search session before starting a new one. + // We call stopSearch only if we have a change or to be safe, + // but starting a new search should be clean. + searchProvides.stopSearch?.(); + + // Start a fresh search session. + searchProvides.startSearch(); + + // Search all pages. + // We add a tiny delay to ensure the search plugin has processed the stop/start cycle. + // This addresses the issue where calling search twice results in 0 findings. + await new Promise(resolve => setTimeout(resolve, 50)); + const searchResult = await searchProvides.searchAllPages(text).toPromise(); + + const results = searchResult.results; + cachedSearchResults.current = results; + + // Aggregate results for the UI + const foundOnPages = [...new Set(results.map((r: { pageIndex: number }) => r.pageIndex + 1))].sort( + (a: number, b: number) => a - b, + ); + + return { + totalCount: results.length, + foundOnPages, + }; + }, [searchProvides]); + + /** + * Clears search highlights and cached results + */ + const handleClearSearch = useCallback(() => { + if (searchProvides) { + searchProvides.stopSearch?.(); + } + cachedSearchResults.current = []; + }, [searchProvides]); + + /** + * Search and Redact: redactText implementation + * Uses cached search results — does NOT re-search. + */ + const handleRedactText = useCallback(async ( + _text: string, + _options?: SearchRedactOptions, + ): Promise => { + if (!annotationScope) { + throw new Error('Annotation plugin not available'); + } + + const results = cachedSearchResults.current; + if (results.length === 0) { + return false; + } + + // Create real REDACT annotations for each cached search result. + let createdCount = 0; + for (const result of results) { + const bounding = boundingRect(result.rects); + if (bounding) { + const redactAnnotation: PdfRedactAnnoObject = { + id: `search-redact-${result.pageIndex}-${result.charIndex}-${Date.now()}-${createdCount}`, + type: PdfAnnotationSubtype.REDACT, + pageIndex: result.pageIndex, + rect: bounding, + segmentRects: result.rects, + color: manualRedactColor, + strokeColor: manualRedactColor, + opacity: 1, + }; + + annotationScope.createAnnotation(result.pageIndex, redactAnnotation); + createdCount++; + } + } + + if (createdCount === 0) { + return false; + } + + // Clear cached results after creating annotations + cachedSearchResults.current = []; + + return true; + }, [annotationScope, manualRedactColor]); // Expose the EmbedPDF API through our context's ref useImperativeHandle(redactionApiRef, () => ({ @@ -83,15 +211,15 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) { endRedact: () => { redactionProvides?.endRedact(); }, - // Common methods commitAllPending: () => { redactionProvides?.commitAllPending(); - // Don't set redactionsApplied here - it should only be set after the file is saved - // The save operation in applyChanges will handle setting/clearing this flag }, getActiveType: () => state?.activeType ?? null, getPendingCount: () => state?.pendingCount ?? 0, - }), [redactionProvides, state]); + searchText: handleSearchText, + redactText: handleRedactText, + clearSearch: handleClearSearch, + }), [redactionProvides, state, handleSearchText, handleRedactText, handleClearSearch]); return null; } diff --git a/frontend/src/core/contexts/RedactionContext.tsx b/frontend/src/core/contexts/RedactionContext.tsx index f1b98adc5..584861c3c 100644 --- a/frontend/src/core/contexts/RedactionContext.tsx +++ b/frontend/src/core/contexts/RedactionContext.tsx @@ -6,6 +6,16 @@ import { RedactionMode } from '@embedpdf/plugin-redaction'; /** * API interface that the EmbedPDF bridge will implement */ +export interface SearchRedactOptions { + caseSensitive?: boolean; + wholeWord?: boolean; +} + +export interface SearchTextResult { + totalCount: number; + foundOnPages: number[]; +} + export interface RedactionAPI { toggleRedact: () => void; enableRedact: () => void; @@ -15,6 +25,10 @@ export interface RedactionAPI { commitAllPending: () => void; getActiveType: () => RedactionMode | null; getPendingCount: () => number; + // Search and Redact methods + searchText: (text: string, options?: SearchRedactOptions) => Promise; + redactText: (text: string, options?: SearchRedactOptions) => Promise; + clearSearch: () => void; } /** @@ -62,6 +76,10 @@ interface RedactionActions { // Legacy UI actions (for backwards compatibility with UI) activateTextSelection: () => void; activateMarquee: () => void; + // Search and Redact + searchText: (text: string, options?: SearchRedactOptions) => Promise; + redactText: (text: string, options?: SearchRedactOptions) => Promise; + clearSearch: () => void; } /** @@ -211,6 +229,31 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children } }, [setActiveType]); + // Search and Redact proxy methods + const searchText = useCallback(async (text: string, options?: SearchRedactOptions): Promise => { + if (!redactionApiRef.current?.searchText) { + throw new Error('Redaction API bridge not ready'); + } + return redactionApiRef.current.searchText(text, options); + }, []); + + const redactText = useCallback(async (text: string, options?: SearchRedactOptions): Promise => { + if (!redactionApiRef.current?.redactText) { + throw new Error('Redaction API bridge not ready'); + } + const result = await redactionApiRef.current.redactText(text, options); + if (result) { + setRedactionsApplied(true); + } + return result; + }, [setRedactionsApplied]); + + const clearSearch = useCallback(() => { + if (redactionApiRef.current?.clearSearch) { + redactionApiRef.current.clearSearch(); + } + }, []); + const contextValue: RedactionContextValue = { ...state, redactionApiRef, @@ -228,6 +271,9 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children activateManualRedact, activateTextSelection, activateMarquee, + searchText, + redactText, + clearSearch, }; return ( diff --git a/frontend/src/core/tools/Redact.tsx b/frontend/src/core/tools/Redact.tsx index 86bf3f9ba..f46ea49c4 100644 --- a/frontend/src/core/tools/Redact.tsx +++ b/frontend/src/core/tools/Redact.tsx @@ -6,10 +6,9 @@ import { useRedactParameters, RedactMode } from "@app/hooks/tools/redact/useReda 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, useRedactManualTips } from "@app/components/tooltips/useRedactTips"; -import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings"; -import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput"; +import { useRedactModeTips, useRedactManualTips } from "@app/components/tooltips/useRedactTips"; import ManualRedactionControls from "@app/components/tools/redact/ManualRedactionControls"; +import SearchAndRedactControls from "@app/components/tools/redact/SearchAndRedactControls"; import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext"; import { useRedaction } from "@app/contexts/RedactionContext"; import { useFileState } from "@app/contexts/file/fileHooks"; @@ -19,14 +18,14 @@ const Redact = (props: BaseToolProps) => { // State for managing step collapse status const [methodCollapsed, setMethodCollapsed] = useState(false); - const [wordsCollapsed, setWordsCollapsed] = useState(false); - const [advancedCollapsed, setAdvancedCollapsed] = useState(true); // Navigation and redaction context const { actions: navActions } = useNavigationActions(); - const { setRedactionConfig, setRedactionMode, redactionConfig } = useRedaction(); + const { setRedactionConfig, setRedactionMode, redactionConfig, deactivateRedact, clearSearch } = useRedaction(); const { workbench } = useNavigationState(); const hasOpenedViewer = useRef(false); + const isSwitching = useRef(false); + const lastRequestedMode = useRef(null); const base = useBaseTool( 'redact', @@ -41,37 +40,60 @@ const Redact = (props: BaseToolProps) => { // Tooltips for each step const modeTips = useRedactModeTips(); - const wordsTips = useRedactWordsTips(); - const advancedTips = useRedactAdvancedTips(); const manualTips = useRedactManualTips(); // Auto-set manual mode if we're in the viewer and redaction config is set to manual // This ensures when opening redact from viewer, it automatically selects manual mode useEffect(() => { + // Skip if we are currently in the middle of a requested mode switch + if (isSwitching.current) return; + if (workbench === 'viewer' && redactionConfig?.mode === 'manual' && base.params.parameters.mode !== 'manual') { - // Set immediately when conditions are met + // Don't revert if we explicitly just requested a different mode + if (lastRequestedMode.current !== null && lastRequestedMode.current !== 'manual') return; + base.params.updateParameter('mode', 'manual'); } }, [workbench, redactionConfig, base.params.parameters.mode, base.params.updateParameter]); - // Handle mode change - navigate to viewer when manual mode is selected - // Manual mode works with any files in workbench (not just selected files) + // Handle mode change - navigate to viewer for both modes + // Both modes work with the EmbedPDF viewer const handleModeChange = (mode: RedactMode) => { + console.log(`[Redact] Mode switch requested: ${base.params.parameters.mode} -> ${mode}`); + isSwitching.current = true; + lastRequestedMode.current = mode; + + // Deactivate manual redaction tool when switching away from manual mode + if (base.params.parameters.mode === 'manual' && mode !== 'manual') { + console.log('[Redact] Deactivating manual redaction tool'); + try { deactivateRedact(); } catch { /* ignore if bridge not ready */ } + } + + // Always clear search when switching modes to ensure highlights are removed + try { clearSearch(); } catch { /* ignore if bridge not ready */ } + base.params.updateParameter('mode', mode); - if (mode === 'manual' && hasAnyFiles) { - // Set redaction config and navigate to viewer - setRedactionConfig(base.params.parameters); + if (hasAnyFiles) { + console.log('[Redact] Updating redaction config and navigating to viewer'); + const newConfig = { ...base.params.parameters, mode }; + setRedactionConfig(newConfig); setRedactionMode(true); navActions.setWorkbench('viewer'); hasOpenedViewer.current = true; } + + // Reset switching flag after state updates have had a chance to propogate + // Using a longer timeout to be safe with context propagation and viewer initialization + setTimeout(() => { + console.log('[Redact] Mode switch transition complete'); + isSwitching.current = false; + }, 1000); // 1s is long but safer for slower environments }; - // When files are added and in manual mode, navigate to viewer - // Uses hasAnyFiles since manual mode works with any files in workbench + // When files are added and in any mode, navigate to viewer useEffect(() => { - if (base.params.parameters.mode === 'manual' && hasAnyFiles && !hasOpenedViewer.current) { + if (hasAnyFiles && !hasOpenedViewer.current) { setRedactionConfig(base.params.parameters); setRedactionMode(true); navActions.setWorkbench('viewer'); @@ -79,25 +101,13 @@ const Redact = (props: BaseToolProps) => { } }, [hasAnyFiles, base.params.parameters, navActions, setRedactionConfig, setRedactionMode]); - // Reset viewer flag when mode changes back to automatic - useEffect(() => { - if (base.params.parameters.mode === 'automatic') { - hasOpenedViewer.current = false; - setRedactionMode(false); - } - }, [base.params.parameters.mode, setRedactionMode]); + // Reset viewer flag when switching modes + // Both modes use the viewer so we don't need to exit redaction mode const isExecuteDisabled = () => { - if (base.params.parameters.mode === 'manual') { - return true; // Manual mode uses viewer, not execute button - } - return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled; + return true; // Both modes use viewer-based controls, not the execute button }; - // Compute actual collapsed state based on results and user state - const getActualCollapsedState = (userCollapsed: boolean) => { - return (!base.hasFiles || base.hasResults) ? true : userCollapsed; // Force collapse when results are shown - }; // Build conditional steps based on redaction mode const buildSteps = () => { @@ -117,7 +127,6 @@ const Redact = (props: BaseToolProps) => { mode={base.params.parameters.mode} onModeChange={handleModeChange} disabled={base.endpointLoading} - hasFilesSelected={base.hasFiles} hasAnyFiles={hasAnyFiles} /> ), @@ -126,30 +135,14 @@ const Redact = (props: BaseToolProps) => { // Add mode-specific steps if (base.params.parameters.mode === 'automatic') { - steps.push( - { - title: t("redact.auto.settings.title", "Redaction Settings"), - isCollapsed: getActualCollapsedState(wordsCollapsed), - onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setWordsCollapsed(!wordsCollapsed), - tooltip: wordsTips, - content: base.params.updateParameter('wordsToRedact', words)} - disabled={base.endpointLoading} - />, - }, - { - title: t("redact.auto.settings.advancedTitle", "Advanced Settings"), - isCollapsed: getActualCollapsedState(advancedCollapsed), - onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setAdvancedCollapsed(!advancedCollapsed), - tooltip: advancedTips, - content: , - }, - ); + // Search & Redact mode - show search controls in sidebar + steps.push({ + title: t("redact.searchAndRedact.controlsTitle", "Search & Redact Controls"), + isCollapsed: false, + onCollapsedClick: () => {}, + tooltip: manualTips, // Reuse tips for now + content: , + }); } else if (base.params.parameters.mode === 'manual') { // Manual mode - show redaction controls // Uses hasAnyFiles since manual mode works with any files in workbench (viewer-powered) @@ -165,8 +158,8 @@ const Redact = (props: BaseToolProps) => { return steps; }; - // Hide execute button in manual mode (redactions applied via controls) - const isManualMode = base.params.parameters.mode === 'manual'; + // Hide execute button for both modes (redactions applied via viewer controls) + const hideExecuteButton = true; return createToolFlow({ files: { @@ -176,7 +169,7 @@ const Redact = (props: BaseToolProps) => { steps: buildSteps(), executeButton: { text: t("redact.submit", "Redact"), - isVisible: !base.hasResults && !isManualMode, + isVisible: !base.hasResults && !hideExecuteButton, loadingText: t("loading"), onClick: base.handleExecute, disabled: isExecuteDisabled(),