mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
stashing for now
This commit is contained in:
parent
1e3eea199a
commit
1d26bd12c4
@ -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));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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?")}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
}}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
117
frontend/src/core/components/viewer/RedactionAPIBridge.tsx
Normal file
117
frontend/src/core/components/viewer/RedactionAPIBridge.tsx
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 : {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user