stashing for now

This commit is contained in:
EthanHealy01 2025-11-04 17:50:36 +00:00
parent 1e3eea199a
commit 1d26bd12c4
14 changed files with 749 additions and 894 deletions

View File

@ -163,7 +163,7 @@ export default function Workbench() {
>
{/* Top Controls */}
{activeFiles.length > 0 && (
<TopControls
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
customViews={customWorkbenchViews}
@ -172,7 +172,10 @@ export default function Workbench() {
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
})}
currentFileIndex={activeFileIndex}
onFileSelect={setActiveFileIndex}
onFileSelect={(index) => {
// Guard against unsaved changes when switching PDFs inside viewer
navActions.requestNavigation(() => setActiveFileIndex(index));
}}
/>
)}

View File

@ -43,7 +43,7 @@ const ButtonSelector = <T extends string | number>({
<Button
key={option.value}
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
color={value === option.value ? 'blue' : undefined}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
className={buttonClassName}

View File

@ -8,9 +8,10 @@ import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
message?: string;
}
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue, message }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
@ -58,7 +59,7 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
<Stack>
<Stack ta="center" p="md">
<Text size="md" fw="300">
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
{message || t("unsavedChanges", "You have unsaved changes to your PDF.")}
</Text>
<Text size="lg" fw="500" >
{t("areYouSure", "Are you sure you want to leave?")}

View File

@ -9,6 +9,7 @@ import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
import { useNavigationGuard } from '@app/contexts/NavigationContext';
const viewOptionStyle: React.CSSProperties = {
@ -142,6 +143,7 @@ const TopControls = ({
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const { requestNavigation } = useNavigationGuard();
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
@ -157,7 +159,7 @@ const TopControls = ({
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(workbench);
requestNavigation(() => setCurrentView(workbench));
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
@ -165,12 +167,16 @@ const TopControls = ({
});
}, [setCurrentView]);
const guardedFileSelect = useCallback((index: number) => {
requestNavigation(() => onFileSelect?.(index));
}, [onFileSelect, requestNavigation]);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data-tour="view-switcher"
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, guardedFileSelect, customViews)}
value={currentView}
onChange={handleViewChange}
color="blue"

View File

@ -136,6 +136,8 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
radius="md"
className="right-rail-icon"
onClick={() => {
// Clear any active redaction mode when entering draw
try { (viewerContext as any)?.redactionActions?.clearMode?.(); } catch {}
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
@ -162,41 +164,35 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
if (currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
let newFile: File | null = null;
const redactionPending = viewerContext?.getRedactionState?.().hasPending || false;
if (redactionPending) {
await (viewerContext as any)?.redactionActions?.applyRedactions?.();
const blob = await (viewerContext as any)?.redactionActions?.exportRedactedBlob?.();
if (!blob) return;
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
newFile = new File([blob], originalFileName, { type: 'application/pdf' });
} else if (viewerContext?.exportActions?.saveAsCopy) {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (!pdfArrayBuffer) return;
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
newFile = new File([blob], originalFileName, { type: 'application/pdf' });
}
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
if (newFile) {
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) return;
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
} catch (error) {

View File

@ -1,677 +0,0 @@
import { 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';
import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react';
import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
import { RedactionPluginPackage, RedactionLayer, useRedaction } from '@embedpdf/plugin-redaction/react';
import type { SelectionMenuProps } from '@embedpdf/plugin-redaction/react';
import { Stack, Group, Text, Button, Loader, Alert } from '@mantine/core';
import Warning from '@app/components/shared/Warning';
import { useTranslation } from 'react-i18next';
import CropFreeRoundedIcon from '@mui/icons-material/CropFreeRounded';
import TextFieldsRoundedIcon from '@mui/icons-material/TextFieldsRounded';
import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback';
import { alert } from '@app/components/toast';
import { useRightRailButtons, type RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import type { ManualRedactionWorkbenchData } from '@app/types/redact';
interface ManualRedactionWorkbenchViewProps {
data: ManualRedactionWorkbenchData | null;
}
const toPdfBlob = async (value: any): Promise<Blob | null> => {
if (!value) return null;
if (value instanceof Blob) return value;
if (value instanceof ArrayBuffer) return new Blob([value], { type: 'application/pdf' });
if (value instanceof Uint8Array) {
const copy = new Uint8Array(value.byteLength);
copy.set(value);
return new Blob([copy.buffer], { type: 'application/pdf' });
}
if (value.data instanceof ArrayBuffer) return new Blob([value.data], { type: 'application/pdf' });
if (value.blob instanceof Blob) return value.blob;
if (typeof value.toBlob === 'function') {
return value.toBlob();
}
if (typeof value.toPromise === 'function') {
const result = await value.toPromise();
if (result instanceof ArrayBuffer) return new Blob([result], { type: 'application/pdf' });
}
if (typeof value.arrayBuffer === 'function') {
const buffer = await value.arrayBuffer();
return new Blob([buffer], { type: 'application/pdf' });
}
return null;
};
const buildRedactedFileName = (name: string | undefined | null) => {
if (!name || name.trim() === '') {
return 'redacted.pdf';
}
const lower = name.toLowerCase();
if (lower.includes('redacted')) {
return name;
}
const dotIndex = name.lastIndexOf('.');
if (dotIndex === -1) {
return `${name}_redacted.pdf`;
}
const base = name.slice(0, dotIndex);
const ext = name.slice(dotIndex);
return `${base}_redacted${ext}`;
};
const ManualRedactionWorkbenchView = ({ data }: ManualRedactionWorkbenchViewProps) => {
const { t } = useTranslation();
const { actions: navigationActions } = useNavigationActions();
const redactionApiRef = useRef<Record<string, any> | null>(null);
const exportApiRef = useRef<Record<string, any> | null>(null);
const selectionApiRef = useRef<Record<string, any> | null>(null);
const historyApiRef = useRef<Record<string, any> | null>(null);
const [isReady, setIsReady] = useState(false);
const [isApplying, setIsApplying] = useState(false);
// Removed undo/redo controls; plugin state still manages pending internally
const [activeType, setActiveType] = useState<string | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const desiredModeRef = useRef<'area' | 'text' | null>(null);
const correctingModeRef = useRef(false);
const selectedFile = data?.file ?? null;
const redactionPluginPackage = RedactionPluginPackage;
const RedactionLayerComponent = RedactionLayer;
const hasRedactionSupport = Boolean(redactionPluginPackage && RedactionLayerComponent);
const exitWorkbench = useCallback(() => {
if (data?.onExit) {
data.onExit();
} else {
navigationActions.setWorkbench('fileEditor');
}
}, [data, navigationActions]);
useEffect(() => {
if (selectedFile) {
const url = URL.createObjectURL(selectedFile);
setPdfUrl(url);
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
setObjectUrl(null);
};
}
setPdfUrl(null);
return () => {};
}, [selectedFile]);
const { engine, isLoading, error } = usePdfiumEngine();
const plugins = useMemo(() => {
if (!pdfUrl) return [];
const rootFontSize = typeof window !== 'undefined'
? parseFloat(getComputedStyle(document.documentElement).fontSize)
: 16;
const viewportGap = rootFontSize * 3.5;
const baseRegistrations: any[] = [
createPluginRegistration(LoaderPluginPackage, {
loadingOptions: {
type: 'url',
pdfFile: {
id: 'stirling-pdf-manual-redaction',
url: pdfUrl,
},
},
}),
createPluginRegistration(ViewportPluginPackage, { viewportGap }),
createPluginRegistration(ScrollPluginPackage, {
strategy: ScrollStrategy.Vertical,
initialPage: 0,
}),
createPluginRegistration(RenderPluginPackage),
createPluginRegistration(InteractionManagerPluginPackage),
createPluginRegistration(SelectionPluginPackage),
createPluginRegistration(HistoryPluginPackage),
// Intentionally omit Pan plugin here so drag gestures are captured by redaction/selection layers
createPluginRegistration(ZoomPluginPackage, {
defaultZoomLevel: 1.2,
minZoom: 0.25,
maxZoom: 4,
}),
createPluginRegistration(TilingPluginPackage, {
tileSize: 768,
overlapPx: 5,
extraRings: 1,
}),
createPluginRegistration(SpreadPluginPackage, {
defaultSpreadMode: SpreadMode.None,
}),
createPluginRegistration(SearchPluginPackage),
createPluginRegistration(ThumbnailPluginPackage),
createPluginRegistration(RotatePluginPackage),
createPluginRegistration(ExportPluginPackage, {
defaultFileName: buildRedactedFileName(data?.fileName),
}),
];
if (hasRedactionSupport) {
baseRegistrations.splice(6, 0, createPluginRegistration(redactionPluginPackage, { autoPreview: true }));
}
return baseRegistrations;
}, [pdfUrl, data?.fileName, hasRedactionSupport, redactionPluginPackage]);
const assignPluginApi = useCallback((plugin: any, ref: React.MutableRefObject<Record<string, any> | null>, onReady?: () => void) => {
if (!plugin || typeof plugin.provides !== 'function') return;
try {
const provided = plugin.provides();
if (provided && typeof provided.then === 'function') {
provided
.then((resolved: any) => {
ref.current = resolved ?? null;
onReady?.();
})
.catch((err: any) => {
console.warn('[manual-redaction] Failed to resolve plugin capability', err);
});
} else {
ref.current = provided ?? null;
onReady?.();
}
} catch (err) {
console.warn('[manual-redaction] Plugin capability unavailable', err);
}
}, [hasRedactionSupport]);
const handleInitialized = useCallback(async (registry: any) => {
const redactionPlugin = hasRedactionSupport ? registry.getPlugin?.('redaction') : null;
const exportPlugin = registry.getPlugin?.('export');
const historyPlugin = registry.getPlugin?.('history');
if (hasRedactionSupport) {
assignPluginApi(redactionPlugin, redactionApiRef, () => {
setIsReady(true);
// default to area redaction mode
enableAreaRedaction();
// no pan plugin: drags go to redaction/selection layers
// subscribe to state changes to drive undo/redo availability
try {
const api = redactionApiRef.current;
api?.onStateChange?.((state: any) => {
setActiveType(state?.activeType ?? null);
// Prevent unexpected plugin mode flips (e.g., switching to text after area drag)
const desired = desiredModeRef.current;
const isAreaDesired = desired === 'area';
const isTextDesired = desired === 'text';
const isAreaActive = state?.activeType === 'marqueeRedact' || state?.activeType === 'area';
const isTextActive = state?.activeType === 'redactSelection' || state?.activeType === 'text';
if (!correctingModeRef.current) {
if (isAreaDesired && !isAreaActive) {
correctingModeRef.current = true;
// best-effort attempts to re-activate area mode
enableAreaRedaction();
setTimeout(() => { correctingModeRef.current = false; }, 0);
} else if (isTextDesired && !isTextActive) {
correctingModeRef.current = true;
enableTextRedaction();
setTimeout(() => { correctingModeRef.current = false; }, 0);
}
}
});
} catch {}
});
} else {
setIsReady(false);
}
assignPluginApi(exportPlugin, exportApiRef);
assignPluginApi(historyPlugin, historyApiRef);
const selectionPlugin = registry.getPlugin?.('selection');
assignPluginApi(selectionPlugin, selectionApiRef);
}, [assignPluginApi, hasRedactionSupport]);
const invokeRedactionMethod = useCallback((names: string[], args: any[] = []) => {
if (!hasRedactionSupport) return false;
const api = redactionApiRef.current;
if (!api) return false;
for (const name of names) {
const candidate = (api as Record<string, any>)[name];
if (typeof candidate === 'function') {
try {
const result = candidate.apply(api, args);
if (result && typeof result.then === 'function') {
// Fire and forget for interactive methods
result.catch((err: any) => console.warn(`[manual-redaction] ${name} failed`, err));
}
return true;
} catch (err) {
console.warn(`[manual-redaction] ${name} threw`, err);
}
}
}
return false;
}, []);
const invokeRedactionMethodAsync = useCallback(async (names: string[], args: any[] = []) => {
if (!hasRedactionSupport) return false;
const api = redactionApiRef.current;
if (!api) return false;
for (const name of names) {
const candidate = (api as Record<string, any>)[name];
if (typeof candidate === 'function') {
try {
const result = candidate.apply(api, args);
if (result && typeof result.then === 'function') {
await result;
}
return true;
} catch (err) {
console.warn(`[manual-redaction] ${name} failed`, err);
}
}
}
return false;
}, []);
const enableAreaRedaction = useCallback(() => {
if (!hasRedactionSupport) return;
const api = redactionApiRef.current;
// Ensure selection plugin is not intercepting as text selection
try { selectionApiRef.current?.setMode?.('none'); } catch {}
desiredModeRef.current = 'area';
// Prefer official capability
if (api?.toggleMarqueeRedact) {
try { api.toggleMarqueeRedact(); setActiveType('marqueeRedact'); return; } catch {}
}
// Fall back to common method names
const areaNames = ['area', 'box', 'rectangle', 'shape'];
for (const mode of areaNames) {
if (invokeRedactionMethod(['activateAreaRedaction', 'startAreaRedaction', 'enableAreaRedaction', 'activateMode'], [mode])) return;
if (invokeRedactionMethod(['setRedactionMode', 'setMode'], [mode])) return;
if (invokeRedactionMethod(['setRedactionMode', 'setMode'], [{ mode }])) return;
if (invokeRedactionMethod(['setMode'], [{ type: mode }])) return;
if (invokeRedactionMethod(['setMode'], [mode.toUpperCase?.() ?? mode])) return;
}
console.warn('[manual-redaction] No compatible area redaction activation method found');
}, [hasRedactionSupport, invokeRedactionMethod]);
const enableTextRedaction = useCallback(() => {
if (!hasRedactionSupport) return;
const api = redactionApiRef.current;
// Ensure selection plugin is in text mode when redacting text
try { selectionApiRef.current?.setMode?.('text'); } catch {}
desiredModeRef.current = 'text';
if (api?.toggleRedactSelection) {
try { api.toggleRedactSelection(); setActiveType('redactSelection'); return; } catch {}
}
const textModes = ['text', 'search', 'pattern'];
for (const mode of textModes) {
if (invokeRedactionMethod(['activateTextRedaction', 'startTextRedaction', 'enableTextRedaction', 'activateMode'], [mode])) return;
if (invokeRedactionMethod(['setRedactionMode', 'setMode'], [mode])) return;
if (invokeRedactionMethod(['setRedactionMode', 'setMode'], [{ mode }])) return;
if (invokeRedactionMethod(['setMode'], [{ type: mode }])) return;
if (invokeRedactionMethod(['setMode'], [mode.toUpperCase?.() ?? mode])) return;
}
console.warn('[manual-redaction] No compatible text redaction activation method found');
}, [hasRedactionSupport, invokeRedactionMethod]);
// Undo/Redo removed from UI; users can remove individual marks via the inline menu
const exportRedactedBlob = useCallback(async (): Promise<Blob | null> => {
if (!hasRedactionSupport) {
throw new Error('Manual redaction plugin is not available.');
}
const redactionApi = redactionApiRef.current;
const exportApi = exportApiRef.current;
const tryCall = async (api: Record<string, any> | null, method: string, args: any[] = []): Promise<any> => {
if (!api) return null;
const candidate = api[method];
if (typeof candidate !== 'function') return null;
try {
const result = candidate.apply(api, args);
if (result && typeof result.then === 'function') {
return await result;
}
return result;
} catch (err) {
console.warn(`[manual-redaction] ${method} failed`, err);
return null;
}
};
const attempts: Array<[Record<string, any> | null, string, any[]]> = [
[redactionApi, 'exportRedactedDocument', [{ type: 'blob' }]],
[redactionApi, 'exportRedactedDocument', []],
[redactionApi, 'getRedactedDocument', []],
[redactionApi, 'getBlob', []],
[redactionApi, 'download', [{ type: 'blob' }]],
[exportApi, 'exportDocument', [{ type: 'blob' }]],
[exportApi, 'exportDocument', []],
[exportApi, 'download', [{ type: 'blob' }]],
];
for (const [api, method, args] of attempts) {
const result = await tryCall(api, method, args);
const blob = await toPdfBlob(result);
if (blob) return blob;
}
// Fallback: some export APIs return handles with toPromise()
if (exportApi && typeof exportApi.saveAsCopy === 'function') {
const handle = exportApi.saveAsCopy();
const blob = await toPdfBlob(handle);
if (blob) return blob;
}
return null;
}, [hasRedactionSupport]);
const handleApplyAndSave = useCallback(async () => {
if (!selectedFile) {
alert({
alertType: 'error',
title: t('redact.manual.noFileSelected', 'No PDF selected'),
body: t('redact.manual.noFileSelectedBody', 'Select a PDF before opening the redaction editor.'),
});
return;
}
setIsApplying(true);
try {
const applied = await invokeRedactionMethodAsync([
'applyRedactions',
'applyPendingRedactions',
'apply',
'commit',
'finalizeRedactions',
'performRedactions',
]);
if (!applied) {
console.warn('[manual-redaction] No compatible apply method found');
}
const blob = await exportRedactedBlob();
if (!blob) {
throw new Error('Unable to export redacted PDF');
}
const outputName = buildRedactedFileName(selectedFile.name);
const exportedFile = new File([blob], outputName, { type: 'application/pdf' });
if (data?.onExport) {
await data.onExport(exportedFile);
}
alert({
alertType: 'success',
title: t('redact.manual.exportSuccess', 'Redacted copy saved'),
body: t('redact.manual.exportSuccessBody', 'A redacted PDF has been added to your files.'),
});
exitWorkbench();
} catch (err) {
const message = err instanceof Error ? err.message : t('redact.manual.exportUnknownError', 'Failed to export redacted PDF.');
alert({
alertType: 'error',
title: t('redact.manual.exportFailed', 'Export failed'),
body: message,
});
} finally {
setIsApplying(false);
}
}, [data, exitWorkbench, exportRedactedBlob, invokeRedactionMethodAsync, navigationActions, selectedFile, t]);
useEffect(() => {
if (!isReady || !hasRedactionSupport) return;
return () => {
setIsReady(false);
redactionApiRef.current = null;
exportApiRef.current = null;
historyApiRef.current = null;
};
}, [hasRedactionSupport, isReady, objectUrl]);
const rightRailButtons = useMemo<RightRailButtonWithAction[]>(() => ([
{
id: 'manual-redaction-area',
icon: <CropFreeRoundedIcon fontSize="small" />,
tooltip: t('redact.manual.buttons.area', 'Mark area for redaction'),
ariaLabel: t('redact.manual.buttons.area', 'Mark area for redaction'),
section: 'top',
order: 0,
disabled: !isReady || !hasRedactionSupport,
className: activeType === 'marqueeRedact' ? 'right-rail-icon--active' : undefined,
onClick: enableAreaRedaction,
},
{
id: 'manual-redaction-text',
icon: <TextFieldsRoundedIcon fontSize="small" />,
tooltip: t('redact.manual.buttons.text', 'Mark text for redaction'),
ariaLabel: t('redact.manual.buttons.text', 'Mark text for redaction'),
section: 'top',
order: 1,
disabled: !isReady || !hasRedactionSupport,
className: activeType === 'redactSelection' ? 'right-rail-icon--active' : undefined,
onClick: enableTextRedaction,
},
]), [enableAreaRedaction, enableTextRedaction, hasRedactionSupport, isReady, t, activeType]);
useRightRailButtons(rightRailButtons);
if (!selectedFile) {
return (
<Stack gap="md" p="lg" h="100%" align="center" justify="center">
<Alert color="blue" variant="light">
{t('redact.manual.selectFilePrompt', 'Select a single PDF from the sidebar to start manual redaction.')}
</Alert>
</Stack>
);
}
if (isLoading || !engine || !pdfUrl) {
return <ToolLoadingFallback toolName="Manual Redaction Viewer" />;
}
if (error) {
return (
<Stack gap="sm" align="center" justify="center" h="100%" p="xl">
<Alert color="red" variant="light" title={t('redact.manual.loadFailed', 'Unable to open PDF')}>
{error.message}
</Alert>
</Stack>
);
}
return (
<Stack gap="sm" h="100%" p="md" className="manual-redaction-workbench">
<Group justify="space-between" align="center">
<Stack gap={2}>
<Text fw={600}>{t('redact.manual.editorHeading', 'Manual redaction')}</Text>
<Text size="sm" c="dimmed">
{t('redact.manual.editorSubheading', 'Draw rectangles or search for text to mark redactions, then apply the changes.')}
</Text>
<Text size="xs" c="dimmed">
{t('redact.manual.currentFile', 'Current file: {{name}}', { name: selectedFile.name })}
</Text>
</Stack>
<Group gap="sm">
<Button
variant="default"
onClick={exitWorkbench}
>
{t('redact.manual.exit', 'Back to files')}
</Button>
<Button
variant="filled"
color="blue"
onClick={handleApplyAndSave}
disabled={!isReady || isApplying}
leftSection={isApplying ? <Loader size="xs" color="white" /> : undefined}
>
{isApplying
? t('redact.manual.applying', 'Applying…')
: t('redact.manual.applyAndSave', 'Apply & save copy')}
</Button>
</Group>
</Group>
<Warning text={t('redact.manual.irreversible', 'Redaction is an irreversible process. Once committed, the original content is removed and cannot be restored from the document.')} />
<div
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
position: 'relative',
borderRadius: 0,
overflow: 'hidden',
boxShadow: 'none',
backgroundColor: 'transparent',
}}
>
<EmbedPDF
engine={engine}
plugins={plugins}
onInitialized={handleInitialized}
>
<GlobalPointerProvider>
<Viewport
style={{
backgroundColor: 'var(--bg-background)',
height: '100%',
width: '100%',
maxHeight: '100%',
maxWidth: '100%',
overflow: 'auto',
position: 'relative',
flex: 1,
minHeight: 0,
minWidth: 0,
contain: 'strict',
}}
>
<Scroller
renderPage={({ document, width, height, pageIndex, scale, rotation }) => (
<Rotate key={document?.id} pageSize={{ width, height }}>
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
<div
style={{
width,
height,
position: 'relative',
overflow: 'visible',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
backgroundColor: 'white',
cursor: activeType === 'marqueeRedact' ? 'crosshair' : activeType === 'redactSelection' ? 'text' : 'auto',
}}
draggable={false}
onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<TilingLayer pageIndex={pageIndex} scale={scale} />
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{hasRedactionSupport && RedactionLayerComponent && (
<RedactionLayerComponent
pageIndex={pageIndex}
scale={scale}
rotation={rotation}
selectionMenu={(props: SelectionMenuProps) => <InlineRedactionMenu {...props} />}
/>
)}
</div>
</PagePointerProvider>
</Rotate>
)}
/>
</Viewport>
</GlobalPointerProvider>
</EmbedPDF>
</div>
</Stack>
);
};
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<HTMLDivElement | null>(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 = (
<div
onPointerDownCapture={(e) => { 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,
}}
>
<Group gap="xs" p={6} style={{ background: 'var(--bg-surface)', border: '1px solid var(--border-default)', borderRadius: 8, boxShadow: 'var(--shadow-sm)', cursor: 'default' }}>
<Button size="xs" color="red" onClick={(e) => { e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); (provides?.commitAllPending?.() ?? provides?.commitPending?.(item.page, item.id)); }}>
Apply
</Button>
<Button size="xs" variant="default" onClick={(e) => { e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); provides?.removePending?.(item.page, item.id); }}>
Cancel
</Button>
</Group>
</div>
);
const { ref: _ignoredRef, ...restWrapper } = (menuWrapperProps as any) || {};
return (
<>
<div ref={mergeRef} {...restWrapper} />
{isVisible && screenRect ? createPortal(panel, document.body) : null}
</>
);
}

View File

@ -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<HTMLDivElement>(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<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
onSignatureAdded={() => {
@ -345,6 +354,7 @@ const EmbedPdfViewerContent = ({
{/* Navigation Warning Modal */}
{!previewFile && (
<NavigationWarningModal
message={"You have unsaved changes (redactions and/or annotations)."}
onApplyAndContinue={async () => {
await applyChanges();
}}

View File

@ -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<SignatureAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
}
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<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@ -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 && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
{enableRedaction && <RedactionAPIBridge />}
<GlobalPointerProvider>
<Viewport
style={{
@ -316,7 +322,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{/* Search highlight layer */}
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Selection layer for text interaction */}
{/* Selection layer for text interaction */
}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
@ -329,6 +336,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
selectionOutlineColor="#007ACC"
/>
)}
{enableRedaction && (
<RedactionLayer
pageIndex={pageIndex}
scale={scale}
rotation={rotation}
selectionMenu={(props: SelectionMenuProps) => <InlineRedactionMenu {...props} />}
/>
)}
</div>
</PagePointerProvider>
</Rotate>
@ -341,3 +356,138 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
</div>
);
}
// 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<HTMLDivElement | null>(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 = (
<div
onPointerDownCapture={(e) => { 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,
}}
>
<div style={{ display: 'flex', gap: 8, padding: 6, background: 'var(--bg-surface)', border: '1px solid var(--border-default)', borderRadius: 8, boxShadow: 'var(--shadow-sm)', cursor: 'default' }}>
<button style={{ padding: '4px 8px', background: '#e03131', color: 'white', borderRadius: 6, border: 'none' }} onClick={(e) => { e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); (provides?.commitAllPending?.() ?? provides?.commitPending?.(item.page, item.id)); }}>
Apply
</button>
<button style={{ padding: '4px 8px', background: 'var(--bg-surface)', color: 'var(--text-default)', borderRadius: 6, border: '1px solid var(--border-default)' }} onClick={(e) => { e.stopPropagation(); (e as any).nativeEvent?.stopImmediatePropagation?.(); provides?.removePending?.(item.page, item.id); }}>
Cancel
</button>
</div>
</div>
);
const { ref: _ignoredRef, ...restWrapper } = (menuWrapperProps as any) || {};
return (
<>
<div ref={mergeRef} {...restWrapper} />
{isVisible && screenRect ? createPortal(panel, document.body) : null}
</>
);
}

View File

@ -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 ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
{/* Rotate Controls */}
<Group gap={4} align="center" style={{ marginLeft: 8 }}>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => rotationActions.rotateBackward()}
style={{ minWidth: '2.5rem' }}
title={t('viewer.rotateLeft', 'Rotate Left')}
>
<RotateLeftIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => rotationActions.rotateForward()}
style={{ minWidth: '2.5rem' }}
title={t('viewer.rotateRight', 'Rotate Right')}
>
<RotateRightIcon fontSize="small" />
</Button>
</Group>
{/* Zoom Controls */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button

View File

@ -0,0 +1,117 @@
import { useEffect, useRef } from 'react';
import { useRedaction } from '@embedpdf/plugin-redaction/react';
import { useViewer } from '@app/contexts/ViewerContext';
/**
* Bridge EmbedPDF redaction plugin to ViewerContext.
* Registers minimal state and exposes raw API for mode toggling and apply/export.
*/
export function RedactionAPIBridge() {
const { provides: redactionApi, state: redactionState } = useRedaction();
const { registerBridge, redactionActions, getRedactionDesiredMode, triggerImmediateRedactionModeUpdate } = useViewer();
const activeTypeRef = useRef<string | null>(null);
const hasPendingRef = useRef<boolean>(false);
const correctingRef = useRef(false);
// Subscribe to redaction state changes if supported
useEffect(() => {
if (!redactionApi) return;
let unsubscribe: (() => void) | undefined;
try {
const handler = (state: any) => {
const prevActiveType = activeTypeRef.current;
activeTypeRef.current = state?.activeType ?? null;
// Map plugin activeType to our mode format
const mappedMode: 'text' | 'area' | null =
state?.activeType === 'marqueeRedact' || state?.activeType === 'area' ? 'area' :
state?.activeType === 'redactSelection' || state?.activeType === 'text' ? 'text' :
null;
try {
const hasPending = Boolean(
state?.hasPending === true ||
(Array.isArray(state?.pending) && state.pending.length > 0) ||
(typeof state?.pendingCount === 'number' && state.pendingCount > 0) ||
(Array.isArray((state as any)?.pendingItems) && (state as any).pendingItems.length > 0)
);
hasPendingRef.current = hasPending;
} catch { hasPendingRef.current = hasPendingRef.current; }
// Update UI immediately when mode changes
const prevMappedMode = prevActiveType === 'marqueeRedact' || prevActiveType === 'area' ? 'area' : prevActiveType === 'redactSelection' || prevActiveType === 'text' ? 'text' : null;
if (mappedMode !== prevMappedMode) {
triggerImmediateRedactionModeUpdate(mappedMode);
}
// Re-register to push updated state snapshot
registerBridge('redaction', {
state: { activeType: mappedMode, hasPending: hasPendingRef.current },
api: redactionApi
});
// Keep desired mode active after actions like inline apply or drawing
const desired = getRedactionDesiredMode();
if (!desired || correctingRef.current) return;
const isAreaActive = state?.activeType === 'marqueeRedact' || state?.activeType === 'area';
const isTextActive = state?.activeType === 'redactSelection' || state?.activeType === 'text';
// If plugin cleared the mode but we have a desired mode, re-activate immediately
if ((desired === 'area' && !isAreaActive) || (desired === 'text' && !isTextActive)) {
correctingRef.current = true;
if (desired === 'area') {
redactionActions.activateArea();
} else {
redactionActions.activateText();
}
setTimeout(() => { correctingRef.current = false; }, 150);
}
};
if (typeof (redactionApi as any).onStateChange === 'function') {
(redactionApi as any).onStateChange(handler);
unsubscribe = () => {
try { (redactionApi as any).offStateChange?.(handler); } catch {}
};
} else {
// If plugin doesn't support subscriptions, register once
registerBridge('redaction', { state: { activeType: null }, api: redactionApi });
}
} catch {
// Best-effort registration
registerBridge('redaction', { state: { activeType: null }, api: redactionApi });
}
return () => {
try { unsubscribe?.(); } catch {}
};
}, [redactionApi, registerBridge, redactionActions, getRedactionDesiredMode, triggerImmediateRedactionModeUpdate]);
// Sync initial state from plugin
useEffect(() => {
if (!redactionState || !redactionApi) return;
const activeType = redactionState.activeType as any;
const mappedMode: 'text' | 'area' | null =
activeType === 'marqueeRedact' || activeType === 'area' ? 'area' :
activeType === 'redactSelection' || activeType === 'text' ? 'text' :
null;
activeTypeRef.current = activeType;
triggerImmediateRedactionModeUpdate(mappedMode);
}, [redactionState, triggerImmediateRedactionModeUpdate]);
// Initial registration when API becomes available
useEffect(() => {
if (!redactionApi) return;
const mappedMode: 'text' | 'area' | null =
activeTypeRef.current === 'marqueeRedact' || activeTypeRef.current === 'area' ? 'area' :
activeTypeRef.current === 'redactSelection' || activeTypeRef.current === 'text' ? 'text' :
null;
registerBridge('redaction', {
state: { activeType: mappedMode, hasPending: hasPendingRef.current },
api: redactionApi
});
}, [redactionApi, registerBridge]);
return null;
}

View File

@ -1,6 +1,8 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
@ -12,6 +14,19 @@ export function useViewerRightRailButtons() {
const { t } = useTranslation();
const viewer = useViewer();
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const [redactionActiveType, setRedactionActiveType] = useState<'text' | 'area' | null>(() => {
const state = viewer.getRedactionState();
return (state?.activeType as 'text' | 'area' | null) ?? null;
});
const { actions: navActions } = useNavigationActions();
const { setLeftPanelView } = useToolWorkflow();
// Subscribe to redaction state changes to update button highlight
useEffect(() => {
viewer.registerImmediateRedactionModeUpdate((mode) => {
setRedactionActiveType(mode);
});
}, [viewer]);
// Lift i18n labels out of memo for clarity
const searchLabel = t('rightRail.search', 'Search PDF');
@ -67,6 +82,7 @@ export function useViewerRightRailButtons() {
radius="md"
className="right-rail-icon"
onClick={() => {
try { (viewer as any).redactionActions?.clearMode?.(); } catch {}
viewer.panActions.togglePan();
setIsPanning(prev => !prev);
}}
@ -77,27 +93,36 @@ export function useViewerRightRailButtons() {
</Tooltip>
)
},
// Removed rotate buttons from right rail (now in bottom toolbar)
{
id: 'viewer-rotate-left',
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
tooltip: rotateLeftLabel,
ariaLabel: rotateLeftLabel,
id: 'viewer-redact',
tooltip: t('rightRail.redact', 'Manual Redaction'),
ariaLabel: t('rightRail.redact', 'Manual Redaction'),
section: 'top' as const,
order: 30,
onClick: () => {
viewer.rotationActions.rotateBackward();
}
},
{
id: 'viewer-rotate-right',
icon: <LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />,
tooltip: rotateRightLabel,
ariaLabel: rotateRightLabel,
section: 'top' as const,
order: 40,
onClick: () => {
viewer.rotationActions.rotateForward();
}
order: 35,
render: ({ disabled }) => (
<Tooltip content={t('rightRail.redact', 'Manual Redaction')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={redactionActiveType ? 'filled' : 'subtle'}
color={redactionActiveType ? 'blue' : undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
// Show redact tool on the left and keep current workbench (viewer)
navActions.setSelectedTool('redact');
setLeftPanelView('toolContent');
// Always ensure area selection is active (don't toggle off)
const currentMode = viewer.getRedactionState()?.activeType;
if (!currentMode) {
viewer.redactionActions.activateArea();
}
}}
disabled={disabled}
>
<LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)
},
{
id: 'viewer-toggle-sidebar',
@ -119,7 +144,7 @@ export function useViewerRightRailButtons() {
)
}
];
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
}, [t, viewer, isPanning, redactionActiveType, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, navActions, setLeftPanelView]);
useRightRailButtons(viewerButtons);
}

View File

@ -57,6 +57,31 @@ interface ExportAPIWrapper {
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
interface RedactionAPIWrapper {
// Common redaction API surface (union of likely method names)
toggleMarqueeRedact?: () => void;
toggleRedactSelection?: () => void;
activateAreaRedaction?: (mode?: any) => void;
activateTextRedaction?: (mode?: any) => void;
startAreaRedaction?: (mode?: any) => void;
startTextRedaction?: (mode?: any) => void;
enableAreaRedaction?: (mode?: any) => void;
enableTextRedaction?: (mode?: any) => void;
setRedactionMode?: (mode: any) => void;
setMode?: (mode: any) => void;
applyRedactions?: () => any;
applyPendingRedactions?: () => any;
apply?: () => any;
commit?: () => any;
finalizeRedactions?: () => any;
performRedactions?: () => any;
exportRedactedDocument?: (opts?: any) => any;
getRedactedDocument?: () => any;
getBlob?: () => any;
onStateChange?: (cb: (state: any) => void) => void;
offStateChange?: (cb: (state: any) => void) => void;
}
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
@ -103,6 +128,11 @@ interface ExportState {
canExport: boolean;
}
interface RedactionState {
activeType: string | null;
hasPending: boolean;
}
// Bridge registration interface - bridges register with state and API
interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
@ -208,6 +238,20 @@ interface ViewerContextType {
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
// Redaction
getRedactionState: () => RedactionState;
getRedactionDesiredMode: () => 'area' | 'text' | null;
redactionActions: {
activateArea: () => void;
activateText: () => void;
clearMode: () => void;
applyRedactions: () => Promise<boolean>;
exportRedactedBlob: () => Promise<Blob | null>;
};
// Immediate redaction mode update subscription (for left panel UI)
registerImmediateRedactionModeUpdate: (callback: (mode: 'area' | 'text' | null) => void) => void;
triggerImmediateRedactionModeUpdate: (mode: 'area' | 'text' | null) => void;
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
}
@ -239,8 +283,21 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
redaction: null as BridgeRef<RedactionState, RedactionAPIWrapper> | null,
});
// Desired redaction mode persists across state changes to keep tool active after inline apply
const desiredRedactionModeRef = useRef<'area' | 'text' | null>(null);
const immediateRedactionModeCallbacksRef = useRef<Set<(mode: 'area' | 'text' | null) => void>>(new Set());
const notifyImmediateRedactionMode = (mode: 'area' | 'text' | null) => {
try {
immediateRedactionModeCallbacksRef.current.forEach(cb => {
try { cb(mode); } catch {}
});
} catch {}
};
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
@ -277,6 +334,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
case 'export':
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
break;
case 'redaction':
bridgeRefs.current.redaction = ref as BridgeRef<RedactionState, RedactionAPIWrapper>;
break;
}
};
@ -333,6 +393,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
return bridgeRefs.current.export?.state || { canExport: false };
};
const getRedactionState = (): RedactionState => {
return bridgeRefs.current.redaction?.state || { activeType: null, hasPending: false };
};
const getRedactionDesiredMode = () => desiredRedactionModeRef.current;
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
@ -550,6 +616,137 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
}
};
// Helpers to robustly call redaction API methods across versions
const callFirst = (api: any, names: string[], args: any[] = []) => {
for (const name of names) {
const fn = api?.[name];
if (typeof fn === 'function') {
try { return fn.apply(api, args); } catch { /* noop */ }
}
}
return undefined;
};
const redactionActions = {
activateArea: () => {
const api = bridgeRefs.current.redaction?.api as any;
if (!api) return;
desiredRedactionModeRef.current = 'area';
notifyImmediateRedactionMode('area');
// Exclusivity: turn off draw and pan when redaction is active
try { setIsAnnotationModeState(false); } catch {}
try { (bridgeRefs.current.pan?.api as any)?.disable?.(); } catch {}
// Ensure selection isn't intercepting text drags
try { (bridgeRefs.current.selection?.api as any)?.setMode?.('none'); } catch {}
// Prefer non-toggle methods first to avoid accidentally clearing mode
const areaModes = ['area', 'box', 'rectangle', 'shape'];
for (const mode of areaModes) {
if (callFirst(api, ['activateAreaRedaction','startAreaRedaction','enableAreaRedaction','activateMode'], [mode])) return;
if (callFirst(api, ['setRedactionMode','setMode'], [mode])) return;
if (callFirst(api, ['setRedactionMode','setMode'], [{ mode }])) return;
if (callFirst(api, ['setMode'], [{ type: mode }])) return;
if (callFirst(api, ['setMode'], [mode.toUpperCase?.() ?? mode])) return;
}
// Fallback to toggle only if non-toggle methods don't work
if (api.toggleMarqueeRedact) { try { api.toggleMarqueeRedact(); return; } catch {} }
},
activateText: () => {
const api = bridgeRefs.current.redaction?.api as any;
if (!api) return;
desiredRedactionModeRef.current = 'text';
notifyImmediateRedactionMode('text');
// Exclusivity: turn off draw and pan when redaction is active
try { setIsAnnotationModeState(false); } catch {}
try { (bridgeRefs.current.pan?.api as any)?.disable?.(); } catch {}
// Ensure selection plugin is in text mode
try { (bridgeRefs.current.selection?.api as any)?.setMode?.('text'); } catch {}
// Prefer non-toggle methods first to avoid accidentally clearing mode
const textModes = ['text','search','pattern'];
for (const mode of textModes) {
if (callFirst(api, ['activateTextRedaction','startTextRedaction','enableTextRedaction','activateMode'], [mode])) return;
if (callFirst(api, ['setRedactionMode','setMode'], [mode])) return;
if (callFirst(api, ['setRedactionMode','setMode'], [{ mode }])) return;
if (callFirst(api, ['setMode'], [{ type: mode }])) return;
if (callFirst(api, ['setMode'], [mode.toUpperCase?.() ?? mode])) return;
}
// Fallback to toggle only if non-toggle methods don't work
if (api.toggleRedactSelection) { try { api.toggleRedactSelection(); return; } catch {} }
},
clearMode: () => {
const api = bridgeRefs.current.redaction?.api as any;
if (!api) return;
desiredRedactionModeRef.current = null;
notifyImmediateRedactionMode(null);
// Best effort: set selection mode to none or deactivate redaction
try { (bridgeRefs.current.selection?.api as any)?.setMode?.('none'); } catch {}
try { api.setMode?.('none'); } catch {}
},
applyRedactions: async () => {
const api = bridgeRefs.current.redaction?.api as any;
if (!api) return false;
const names = ['applyRedactions','applyPendingRedactions','apply','commit','finalizeRedactions','performRedactions'];
for (const name of names) {
const fn = api?.[name];
if (typeof fn === 'function') {
try { const r = fn.call(api); if (r?.then) await r; return true; } catch { /* try next */ }
}
}
return false;
},
exportRedactedBlob: async () => {
const api = bridgeRefs.current.redaction?.api as any;
const exportApi = bridgeRefs.current.export?.api as any;
const toBlob = async (value: any): Promise<Blob | null> => {
if (!value) return null;
if (value instanceof Blob) return value;
if (value instanceof ArrayBuffer) return new Blob([value], { type: 'application/pdf' });
if (value instanceof Uint8Array) {
const copy = new Uint8Array(value.byteLength);
copy.set(value);
return new Blob([copy.buffer], { type: 'application/pdf' });
}
if (value.data instanceof ArrayBuffer) return new Blob([value.data], { type: 'application/pdf' });
if (value.blob instanceof Blob) return value.blob;
if (typeof value.toBlob === 'function') return value.toBlob();
if (typeof value.toPromise === 'function') {
const res = await value.toPromise();
if (res instanceof ArrayBuffer) return new Blob([res], { type: 'application/pdf' });
}
if (typeof value.arrayBuffer === 'function') {
const buf = await value.arrayBuffer();
return new Blob([buf], { type: 'application/pdf' });
}
return null;
};
const attempts: Array<[any, string, any[]]> = [
[api, 'exportRedactedDocument', [{ type: 'blob' }]],
[api, 'exportRedactedDocument', []],
[api, 'getRedactedDocument', []],
[api, 'getBlob', []],
[exportApi, 'exportDocument', [{ type: 'blob' }]],
[exportApi, 'exportDocument', []],
];
for (const [target, method, args] of attempts) {
const fn = target?.[method];
if (typeof fn === 'function') {
try {
const r = fn.apply(target, args);
const blob = await toBlob(r);
if (blob) return blob;
} catch { /* try next */ }
}
}
// Fallback
try {
const handle = exportApi?.saveAsCopy?.();
const blob = await toBlob(handle);
if (blob) return blob;
} catch {}
return null;
}
};
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
@ -613,6 +810,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
searchActions,
exportActions,
// Redaction
getRedactionState,
getRedactionDesiredMode,
redactionActions,
registerImmediateRedactionModeUpdate: (callback) => { immediateRedactionModeCallbacksRef.current.add(callback); },
triggerImmediateRedactionModeUpdate: (mode) => { notifyImmediateRedactionMode(mode); },
// Bridge registration
registerBridge,
};

View File

@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, Stack, Text } from "@mantine/core";
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import type { MiddleStepConfig } from "@app/components/tools/shared/createToolFlow";
import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector";
@ -12,28 +11,19 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool";
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "@app/components/tooltips/useRedactTips";
import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings";
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext";
import ManualRedactionWorkbenchView from "@app/components/tools/redact/ManualRedactionWorkbenchView";
import type { ManualRedactionWorkbenchData } from "@app/types/redact";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import { useFileContext } from "@app/contexts/file/fileHooks";
import type { StirlingFile } from "@app/types/fileContext";
const MANUAL_VIEW_ID = "manualRedactionWorkbench";
const MANUAL_WORKBENCH_ID = "custom:manualRedactionWorkbench" as const;
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
const Redact = (props: BaseToolProps) => {
const { t } = useTranslation();
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const { actions: navigationActions } = useNavigationActions();
const navigationState = useNavigationState();
const { actions: fileActions } = useFileContext();
const manualWorkbenchIcon = useMemo(() => <VisibilityOffRoundedIcon fontSize="small" />, []);
const { actions: navActions } = useNavigationActions();
const { actions: fileActions, selectors } = useFileContext();
const { redactionActions, getRedactionDesiredMode, registerImmediateRedactionModeUpdate } = useViewer();
// State for managing step collapse status
const [methodCollapsed, setMethodCollapsed] = useState(false);
@ -47,81 +37,30 @@ const Redact = (props: BaseToolProps) => {
props
);
const createManualWorkbenchData = useCallback((file: StirlingFile): ManualRedactionWorkbenchData => ({
fileId: file.fileId,
file,
fileName: file.name,
onExport: async (exportedFile: File) => {
await fileActions.addFiles([exportedFile], { selectFiles: true });
},
onExit: () => {
clearCustomWorkbenchViewData(MANUAL_VIEW_ID);
navigationActions.setWorkbench('fileEditor');
},
}), [clearCustomWorkbenchViewData, fileActions, navigationActions]);
const handleOpenManualEditor = useCallback(() => {
if (base.selectedFiles.length !== 1) {
return;
}
// Manual apply/export using the viewer
const handleApplyAndSave = useCallback(async () => {
if (base.selectedFiles.length !== 1) return;
const [selected] = base.selectedFiles as [StirlingFile];
const workbenchData = createManualWorkbenchData(selected);
setCustomWorkbenchViewData(MANUAL_VIEW_ID, workbenchData);
navigationActions.setWorkbench(MANUAL_WORKBENCH_ID);
}, [base.selectedFiles, createManualWorkbenchData, navigationActions, setCustomWorkbenchViewData]);
useEffect(() => {
registerCustomWorkbenchView({
id: MANUAL_VIEW_ID,
workbenchId: MANUAL_WORKBENCH_ID,
label: t('redact.manual.workbenchLabel', 'Manual redaction'),
icon: manualWorkbenchIcon,
component: ManualRedactionWorkbenchView,
});
return () => {
clearCustomWorkbenchViewData(MANUAL_VIEW_ID);
unregisterCustomWorkbenchView(MANUAL_VIEW_ID);
};
}, [
clearCustomWorkbenchViewData,
manualWorkbenchIcon,
registerCustomWorkbenchView,
t,
unregisterCustomWorkbenchView,
]);
useEffect(() => {
if (base.params.parameters.mode !== 'manual') {
clearCustomWorkbenchViewData(MANUAL_VIEW_ID);
if (navigationState.workbench === MANUAL_WORKBENCH_ID) {
navigationActions.setWorkbench('fileEditor');
}
}
}, [
base.params.parameters.mode,
clearCustomWorkbenchViewData,
navigationActions,
navigationState.workbench,
]);
useEffect(() => {
if (
navigationState.workbench !== MANUAL_WORKBENCH_ID ||
base.params.parameters.mode !== 'manual' ||
base.selectedFiles.length !== 1
) {
return;
}
const [selected] = base.selectedFiles as [StirlingFile];
setCustomWorkbenchViewData(MANUAL_VIEW_ID, createManualWorkbenchData(selected));
}, [
base.params.parameters.mode,
base.selectedFiles,
createManualWorkbenchData,
navigationState.workbench,
setCustomWorkbenchViewData,
]);
// Apply pending redactions in viewer
await redactionActions.applyRedactions();
const blob = await redactionActions.exportRedactedBlob();
if (!blob) return;
const outputName = (() => {
const name = selected.name || 'document.pdf';
const lower = name.toLowerCase();
if (lower.includes('redacted')) return name;
const i = name.lastIndexOf('.');
if (i === -1) return `${name}_redacted.pdf`;
const baseName = name.slice(0, i);
const ext = name.slice(i);
return `${baseName}_redacted${ext}`;
})();
const exportedFile = new File([blob], outputName, { type: 'application/pdf' });
const parentStub = selectors.getStirlingFileStub(selected.fileId);
if (!parentStub) return;
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([exportedFile], parentStub, 'redact');
await fileActions.consumeFiles([selected.fileId], stirlingFiles, stubs);
}, [base.selectedFiles, redactionActions, selectors, fileActions]);
// Tooltips for each step
const modeTips = useRedactModeTips();
@ -129,6 +68,56 @@ const Redact = (props: BaseToolProps) => {
const advancedTips = useRedactAdvancedTips();
const isManualMode = base.params.parameters.mode === 'manual';
const isRedactToolActive = navigationState.selectedTool === 'redact';
// Drive button highlight from user's desired mode so it stays blue even when plugin temporarily clears it
const [activeMode, setActiveMode] = useState<'text' | 'area' | null>(getRedactionDesiredMode?.() ?? null);
useEffect(() => {
registerImmediateRedactionModeUpdate((mode) => setActiveMode(mode));
}, [registerImmediateRedactionModeUpdate]);
// Track first-time initialization for manual mode
const manualInitRef = useRef(false);
// When switching to Manual the first time, jump to viewer and default to Area selection
useEffect(() => {
if (!isManualMode) {
manualInitRef.current = false;
return;
}
if (!manualInitRef.current) {
manualInitRef.current = true;
// Navigate to viewer first
if (navigationState.workbench !== 'viewer') {
navActions.setWorkbench('viewer');
}
// Then activate area mode after a short delay to ensure plugin is ready
setTimeout(() => {
navActions.setSelectedTool('redact');
redactionActions.activateArea();
setActiveMode('area');
}, 100);
}
}, [isManualMode, navigationState.workbench, navActions, redactionActions]);
// Ensure one mode is always active when manual redaction is enabled
useEffect(() => {
if (!isManualMode || navigationState.workbench !== 'viewer') return;
// If no mode is active and manual redaction is enabled, default to area
if (!activeMode) {
setActiveMode('area');
redactionActions.activateArea();
}
}, [isManualMode, activeMode, navigationState.workbench, redactionActions]);
// If user triggers redaction mode from viewer (desiredMode present) while tool is open,
// automatically switch panel to Manual so the manual controls are visible.
useEffect(() => {
const desired = getRedactionDesiredMode?.() ?? null;
if (navigationState.selectedTool === 'redact' && desired && base.params.parameters.mode !== 'manual') {
base.params.updateParameter('mode', 'manual' as any);
}
}, [navigationState.selectedTool, getRedactionDesiredMode, base.params, base.params.parameters.mode]);
const isExecuteDisabled = () => {
if (isManualMode) {
@ -188,41 +177,54 @@ const Redact = (props: BaseToolProps) => {
},
);
} else if (isManualMode) {
const manualHasFile = base.selectedFiles.length > 0;
const manualHasSingleFile = base.selectedFiles.length === 1;
const manualTooManyFiles = base.selectedFiles.length > 1;
const selectedName = manualHasSingleFile ? base.selectedFiles[0].name : null;
steps.push({
title: t("redact.manual.stepTitle", "Manual redaction editor"),
steps.push({
title: t("redact.manual.stepTitle", "Manual redaction"),
isCollapsed: false,
content: (
<Stack gap="sm">
<Text size="sm">
{manualHasSingleFile
? t("redact.manual.stepDescriptionSelected", "Launch the editor to mark redactions on {{file}}.", { file: selectedName })
: t("redact.manual.stepDescription", "Open the embedded redaction editor to draw boxes or search for sensitive text.")}
</Text>
{manualTooManyFiles && (
<Alert color="red" variant="light">
{t("redact.manual.multipleNotSupported", "Manual redaction works on one PDF at a time. Deselect extra files to continue.")}
{/* View mode guard - only after initial manual init */}
{navigationState.workbench !== 'viewer' && manualInitRef.current && (
<Alert color="yellow" variant="light">
<Stack gap={6}>
<Text size="sm">{t('redact.manual.viewerRequired', 'You must return to the viewer to use manual redaction.')}</Text>
<Button size="xs" variant="filled" color="blue" onClick={() => navActions.setWorkbench('viewer')}>
{t('redact.manual.goToViewer', 'Go to viewer')}
</Button>
</Stack>
</Alert>
)}
{!manualHasFile && (
<Alert color="blue" variant="light">
{t("redact.manual.noFileSelectedInfo", "Select a PDF from the file sidebar to begin manual redaction.")}
</Alert>
)}
<Button
variant="filled"
color="blue"
disabled={!manualHasSingleFile || manualTooManyFiles}
onClick={handleOpenManualEditor}
>
{t("redact.manual.openEditorCta", "Open redaction editor")}
</Button>
{/* Allow multiple files; viewer dropdown lets users switch between them */}
{/* Label and mode toggles */}
<Text size="sm" fw={500}>{t('redact.manual.applyBy', 'Apply redaction by')}</Text>
<ButtonSelector
value={activeMode || undefined}
onChange={(mode: 'text' | 'area') => {
// 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. */}
</Stack>
),
)
});
}
@ -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 : {

View File

@ -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<void>;
onExit?: () => void;
contextId?: string;
}