diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 861e6a365..c662d2b35 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -163,7 +163,7 @@ export default function Workbench() { > {/* Top Controls */} {activeFiles.length > 0 && ( - { + // Guard against unsaved changes when switching PDFs inside viewer + navActions.requestNavigation(() => setActiveFileIndex(index)); + }} /> )} diff --git a/frontend/src/core/components/shared/ButtonSelector.tsx b/frontend/src/core/components/shared/ButtonSelector.tsx index 3354f06d7..ef493bd3a 100644 --- a/frontend/src/core/components/shared/ButtonSelector.tsx +++ b/frontend/src/core/components/shared/ButtonSelector.tsx @@ -43,7 +43,7 @@ const ButtonSelector = ({ - - - - - - -
- - - - ( - - -
{ e.preventDefault(); e.stopPropagation(); }} - onDrop={(e) => { e.preventDefault(); e.stopPropagation(); }} - > - - - {hasRedactionSupport && RedactionLayerComponent && ( - } - /> - )} -
-
-
- )} - /> -
-
-
-
- - ); -}; - -export default ManualRedactionWorkbenchView; - -// Inline redaction menu displayed beneath selection/rectangle -function InlineRedactionMenu( - { item, selected, menuWrapperProps, rect }: SelectionMenuProps & { rect?: any } -) { - const { provides } = useRedaction(); - const isVisible = Boolean(selected); - - // Measure wrapper to portal the menu to the document body so clicks aren't intercepted - const wrapperRef = useRef(null); - const [screenRect, setScreenRect] = useState<{ left: number; top: number; height: number } | null>(null); - const mergeRef = useCallback((node: any) => { - wrapperRef.current = node; - try { - const r = (menuWrapperProps as any)?.ref; - if (typeof r === 'function') r(node); - else if (r && typeof r === 'object') (r as any).current = node; - } catch {} - }, [menuWrapperProps]); - - useEffect(() => { - const el = wrapperRef.current; - if (!el) return; - const rectEl = el.getBoundingClientRect(); - setScreenRect({ left: rectEl.left, top: rectEl.top, height: rectEl.height || ((rect as any)?.size?.height ?? 0) }); - }, [item?.id, item?.page, isVisible]); - - const panel = ( -
{ e.preventDefault(); e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); }} - onMouseDownCapture={(e) => { e.preventDefault(); e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); }} - style={{ - position: 'fixed', - left: (screenRect?.left ?? 0), - top: (screenRect?.top ?? 0) + (screenRect?.height ?? 0) + 8, - pointerEvents: 'auto', - zIndex: 2147483647, - }} - > - - - - -
- ); - - const { ref: _ignoredRef, ...restWrapper } = (menuWrapperProps as any) || {}; - - return ( - <> -
- {isVisible && screenRect ? createPortal(panel, document.body) : null} - - ); -} diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index f85058a73..72b2c9f2f 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Center, Text, ActionIcon } from '@mantine/core'; import CloseIcon from '@mui/icons-material/Close'; - import { useFileState, useFileActions } from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -35,7 +34,7 @@ const EmbedPdfViewerContent = ({ const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions, getRedactionState, redactionActions } = useViewer(); // Register viewer right-rail buttons useViewerRightRailButtons(); @@ -69,6 +68,7 @@ const EmbedPdfViewerContent = ({ // Check if we're in signature mode OR viewer annotation mode const { selectedTool } = useNavigationState(); const isSignatureMode = selectedTool === 'sign'; + const isRedactionTool = selectedTool === 'redact'; // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; @@ -192,11 +192,14 @@ const EmbedPdfViewerContent = ({ const checkForChanges = () => { // Check for annotation changes via history const hasAnnotationChanges = historyApiRef.current?.canUndo() || false; + // Check for pending redactions via redaction bridge state + const redactionPending = getRedactionState()?.hasPending || false; console.log('[Viewer] Checking for unsaved changes:', { - hasAnnotationChanges + hasAnnotationChanges, + redactionPending }); - return hasAnnotationChanges; + return hasAnnotationChanges || redactionPending; }; console.log('[Viewer] Registering unsaved changes checker'); @@ -213,21 +216,26 @@ const EmbedPdfViewerContent = ({ if (!currentFile || activeFileIds.length === 0) return; try { - console.log('[Viewer] Applying changes - exporting PDF with annotations'); + console.log('[Viewer] Applying changes - exporting PDF'); - // Step 1: Export PDF with annotations using EmbedPDF - const arrayBuffer = await exportActions.saveAsCopy(); - if (!arrayBuffer) { - throw new Error('Failed to export PDF'); + let file: File | null = null; + // If redaction pending, export redacted copy + const redactionPending = getRedactionState()?.hasPending || false; + if (redactionPending) { + await redactionActions.applyRedactions(); + const redactedBlob = await redactionActions.exportRedactedBlob(); + if (!redactedBlob) throw new Error('Failed to export redacted PDF'); + const filename = currentFile.name || 'document.pdf'; + file = new File([redactedBlob], filename, { type: 'application/pdf' }); + } else { + // Otherwise export annotations copy + const arrayBuffer = await exportActions.saveAsCopy(); + if (!arrayBuffer) throw new Error('Failed to export PDF'); + const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); + const filename = currentFile.name || 'document.pdf'; + file = new File([blob], filename, { type: 'application/pdf' }); } - console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength); - - // Step 2: 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' }); - // Step 3: Create StirlingFiles and stubs for version history const parentStub = selectors.getStirlingFileStub(activeFileIds[0]); if (!parentStub) throw new Error('Parent stub not found'); @@ -290,6 +298,7 @@ const EmbedPdfViewerContent = ({ file={effectiveFile.file} url={effectiveFile.url} enableAnnotations={shouldEnableAnnotations} + enableRedaction={isRedactionTool} signatureApiRef={signatureApiRef as React.RefObject} historyApiRef={historyApiRef as React.RefObject} onSignatureAdded={() => { @@ -345,6 +354,7 @@ const EmbedPdfViewerContent = ({ {/* Navigation Warning Modal */} {!previewFile && ( { await applyChanges(); }} diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index bfe75df2f..75c731282 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { createPluginRegistration } from '@embedpdf/core'; import { EmbedPDF } from '@embedpdf/core/react'; import { usePdfiumEngine } from '@embedpdf/engines/react'; @@ -22,6 +23,8 @@ import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; // Import annotation plugins import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react'; +import { RedactionLayer, RedactionPluginPackage, useRedaction } from '@embedpdf/plugin-redaction/react'; +import type { SelectionMenuProps } from '@embedpdf/plugin-redaction/react'; import { PdfAnnotationSubtype } from '@embedpdf/models'; import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer'; import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge'; @@ -38,17 +41,19 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge'; import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge'; import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes'; import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; +import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge'; interface LocalEmbedPDFProps { file?: File | Blob; url?: string | null; enableAnnotations?: boolean; + enableRedaction?: boolean; onSignatureAdded?: (annotation: any) => void; signatureApiRef?: React.RefObject; historyApiRef?: React.RefObject; } -export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) { +export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableRedaction = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) { const [pdfUrl, setPdfUrl] = useState(null); const [, setAnnotations] = useState>([]); @@ -71,7 +76,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); const viewportGap = rootFontSize * 3.5; - return [ + const base = [ createPluginRegistration(LoaderPluginPackage, { loadingOptions: { type: 'url', @@ -95,17 +100,15 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register selection plugin (depends on InteractionManager) createPluginRegistration(SelectionPluginPackage), - - // Register history plugin for undo/redo (recommended for annotations) ...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []), - - // Register annotation plugin (depends on InteractionManager, Selection, History) ...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, { annotationAuthor: 'Digital Signature', autoCommit: true, deactivateToolAfterCreate: false, selectAfterCreate: true, })] : []), + // Always register redaction plugin so hooks are available immediately + createPluginRegistration(RedactionPluginPackage, { autoPreview: true }), // Register pan plugin (depends on Viewport, InteractionManager) createPluginRegistration(PanPluginPackage, { @@ -145,7 +148,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur defaultFileName: 'document.pdf', }), ]; - }, [pdfUrl]); + + return base; + }, [pdfUrl, enableAnnotations, enableRedaction]); // Initialize the engine with the React hook const { engine, isLoading, error } = usePdfiumEngine(); @@ -273,6 +278,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur {enableAnnotations && } {enableAnnotations && } + {enableRedaction && } - {/* Selection layer for text interaction */} + {/* Selection layer for text interaction */ + } {/* Annotation layer for signatures (only when enabled) */} {enableAnnotations && ( @@ -329,6 +336,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur selectionOutlineColor="#007ACC" /> )} + {enableRedaction && ( + } + /> + )}
@@ -341,3 +356,138 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur ); } + +// Inline redaction menu displayed beneath selection/rectangle +function InlineRedactionMenu( + { item, selected, menuWrapperProps, rect }: SelectionMenuProps & { rect?: any } +) { + const { provides, state } = useRedaction() as any; + const [lastAdded, setLastAdded] = useState<{ page: number; id: string } | null>(null); + const isVisible = Boolean(selected || (lastAdded && lastAdded.page === item?.page && lastAdded.id === item?.id)); + + // Try to auto-select or at least show the menu for the most recently created pending item + useEffect(() => { + if (!provides) return; + let off: any; + try { + off = provides.onRedactionEvent?.((evt: any) => { + const type = evt?.type || evt?.event || evt?.name; + if (type && String(type).toLowerCase().includes('add')) { + const page = evt?.page ?? evt?.item?.page; + const id = evt?.id ?? evt?.item?.id; + if (page != null && id != null) { + setLastAdded({ page, id }); + // Clear after a short period so the menu doesn't linger forever + setTimeout(() => setLastAdded(null), 2000); + } + } + }); + } catch {} + return () => { try { off?.(); } catch {} }; + }, [provides]); + + // Measure wrapper to portal the menu to the document body so clicks aren't intercepted + const wrapperRef = useRef(null); + const [screenRect, setScreenRect] = useState<{ left: number; top: number; height: number } | null>(null); + const mergeRef = useCallback((node: any) => { + wrapperRef.current = node; + try { + const r = (menuWrapperProps as any)?.ref; + if (typeof r === 'function') r(node); + else if (r && typeof r === 'object') (r as any).current = node; + } catch {} + }, [menuWrapperProps]); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const rectEl = el.getBoundingClientRect(); + setScreenRect({ left: rectEl.left, top: rectEl.top, height: rectEl.height || ((rect as any)?.size?.height ?? 0) }); + }, [item?.id, item?.page, isVisible]); + + // Keep the inline menu positioned with the selection while scrolling/resizing + useEffect(() => { + if (!isVisible) return; + const el = wrapperRef.current; + if (!el) return; + + const update = () => { + try { + const r = el.getBoundingClientRect(); + setScreenRect({ left: r.left, top: r.top, height: r.height || ((rect as any)?.size?.height ?? 0) }); + } catch {} + }; + + const getScrollableAncestors = (node: HTMLElement | null) => { + const list: (HTMLElement | Window)[] = []; + let current: HTMLElement | null = node; + while (current && current !== document.body && current !== document.documentElement) { + const style = getComputedStyle(current); + const overflowY = style.overflowY; + const overflow = style.overflow; + const isScrollable = /(auto|scroll|overlay)/.test(overflowY) || /(auto|scroll|overlay)/.test(overflow); + if (isScrollable) list.push(current); + current = current.parentElement as any; + } + list.push(window); + return list; + }; + + const owners = getScrollableAncestors(el); + owners.forEach(owner => { + (owner as any).addEventListener?.('scroll', update, { passive: true }); + }); + window.addEventListener('resize', update, { passive: true }); + + // Observe size/position changes of the wrapper itself + let resizeObserver: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => update()); + resizeObserver.observe(el); + } + + // Initial sync + update(); + + return () => { + owners.forEach(owner => { + (owner as any).removeEventListener?.('scroll', update); + }); + window.removeEventListener('resize', update); + try { resizeObserver?.disconnect(); } catch {} + }; + }, [isVisible, item?.id, item?.page, rect]); + + const panel = ( +
{ e.preventDefault(); e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); }} + onMouseDownCapture={(e) => { e.preventDefault(); e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); }} + style={{ + position: 'fixed', + left: (screenRect?.left ?? 0), + top: (screenRect?.top ?? 0) + (screenRect?.height ?? 0) + 8, + pointerEvents: 'auto', + zIndex: 2147483647, + }} + > +
+ + +
+
+ ); + + const { ref: _ignoredRef, ...restWrapper } = (menuWrapperProps as any) || {}; + + return ( + <> +
+ {isVisible && screenRect ? createPortal(panel, document.body) : null} + + ); +} + diff --git a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx index 93d05b377..2089aee03 100644 --- a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx +++ b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx @@ -8,6 +8,8 @@ import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import LastPageIcon from '@mui/icons-material/LastPage'; import DescriptionIcon from '@mui/icons-material/Description'; import ViewWeekIcon from '@mui/icons-material/ViewWeek'; +import RotateLeftIcon from '@mui/icons-material/RotateLeft'; +import RotateRightIcon from '@mui/icons-material/RotateRight'; interface PdfViewerToolbarProps { // Page navigation props (placeholders for now) @@ -32,7 +34,7 @@ export function PdfViewerToolbar({ currentZoom: _currentZoom = 100, }: PdfViewerToolbarProps) { const { t } = useTranslation(); - const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer(); + const { getScrollState, getZoomState, scrollActions, zoomActions, rotationActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer(); const scrollState = getScrollState(); const zoomState = getZoomState(); @@ -199,6 +201,32 @@ export function PdfViewerToolbar({ {dualPage ? : } + {/* Rotate Controls */} + + + + + {/* Zoom Controls */} + )} - {!manualHasFile && ( - - {t("redact.manual.noFileSelectedInfo", "Select a PDF from the file sidebar to begin manual redaction.")} - - )} - + + {/* Allow multiple files; viewer dropdown lets users switch between them */} + + {/* Label and mode toggles */} + {t('redact.manual.applyBy', 'Apply redaction by')} + { + // Prevent deselection - always keep one mode active + // If clicking the same button, do nothing (don't clear) + if (activeMode === mode) return; + + // Immediately set and persist requested mode + setActiveMode(mode); + navActions.setSelectedTool('redact'); + if (mode === 'text') { + redactionActions.activateText(); + } else if (mode === 'area') { + redactionActions.activateArea(); + } + }} + disabled={navigationState.workbench !== 'viewer'} + options={[ + { value: 'text', label: t('redact.manual.buttons.text', 'Text Selection') }, + { value: 'area', label: t('redact.manual.buttons.area', 'Area Selection') }, + ]} + /> + + {/* Save button is now consolidated in right rail. */} - ), + ) }); } @@ -233,7 +235,8 @@ const Redact = (props: BaseToolProps) => { files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, - minFiles: isManualMode ? 1 : undefined, + // Allow multiple files for manual redaction (viewer dropdown handles switching) + minFiles: undefined, }, steps: buildSteps(), executeButton: isManualMode ? undefined : { diff --git a/frontend/src/core/types/redact.ts b/frontend/src/core/types/redact.ts deleted file mode 100644 index e34f77737..000000000 --- a/frontend/src/core/types/redact.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { FileId } from '@app/types/file'; -import type { StirlingFile } from '@app/types/fileContext'; - -export interface ManualRedactionWorkbenchData { - fileId: FileId; - file: StirlingFile | null; - fileName: string; - onExport?: (file: File) => Promise; - onExit?: () => void; - contextId?: string; -}