mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Feature/v2/redact (#5249)
# Description of Changes - Add manual redaction and added it to the right rail in the viewer --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
Generated
+2559
-1075
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"@embedpdf/plugin-loader": "^1.5.0",
|
"@embedpdf/plugin-loader": "^1.5.0",
|
||||||
"@embedpdf/plugin-pan": "^1.5.0",
|
"@embedpdf/plugin-pan": "^1.5.0",
|
||||||
"@embedpdf/plugin-print": "^1.5.0",
|
"@embedpdf/plugin-print": "^1.5.0",
|
||||||
|
"@embedpdf/plugin-redaction": "^1.5.0",
|
||||||
"@embedpdf/plugin-render": "^1.5.0",
|
"@embedpdf/plugin-render": "^1.5.0",
|
||||||
"@embedpdf/plugin-rotate": "^1.5.0",
|
"@embedpdf/plugin-rotate": "^1.5.0",
|
||||||
"@embedpdf/plugin-scroll": "^1.5.0",
|
"@embedpdf/plugin-scroll": "^1.5.0",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
unsavedChanges = "You have unsaved changes to your PDF."
|
unsavedChanges = "You have unsaved changes to your PDF."
|
||||||
|
pendingRedactionsTitle = "Unapplied Redactions"
|
||||||
|
pendingRedactions = "You have unapplied redactions that will be lost."
|
||||||
areYouSure = "Are you sure you want to leave?"
|
areYouSure = "Are you sure you want to leave?"
|
||||||
unsavedChangesTitle = "Unsaved Changes"
|
unsavedChangesTitle = "Unsaved Changes"
|
||||||
keepWorking = "Keep Working"
|
keepWorking = "Keep Working"
|
||||||
discardChanges = "Discard & Leave"
|
discardChanges = "Discard & Leave"
|
||||||
|
discardRedactions = "Discard & Leave"
|
||||||
applyAndContinue = "Save & Leave"
|
applyAndContinue = "Save & Leave"
|
||||||
exportAndContinue = "Export & Continue"
|
exportAndContinue = "Export & Continue"
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
@@ -3219,6 +3222,7 @@ title = "Redaction Method"
|
|||||||
mode = "Mode"
|
mode = "Mode"
|
||||||
automatic = "Automatic"
|
automatic = "Automatic"
|
||||||
automaticDesc = "Redact text based on search terms"
|
automaticDesc = "Redact text based on search terms"
|
||||||
|
automaticDisabledTooltip = "Select files in the file manager to redact multiple files at once"
|
||||||
manual = "Manual"
|
manual = "Manual"
|
||||||
manualDesc = "Click and drag to redact specific areas"
|
manualDesc = "Click and drag to redact specific areas"
|
||||||
manualComingSoon = "Manual redaction coming soon"
|
manualComingSoon = "Manual redaction coming soon"
|
||||||
@@ -3289,8 +3293,35 @@ text = "Only match complete words, not partial matches. 'John' won't match 'John
|
|||||||
title = "Convert to PDF-Image"
|
title = "Convert to PDF-Image"
|
||||||
text = "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable."
|
text = "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable."
|
||||||
|
|
||||||
|
[redact.tooltip.manual.header]
|
||||||
|
title = "Manual Redaction Controls"
|
||||||
|
|
||||||
|
[redact.tooltip.manual.markText]
|
||||||
|
title = "Mark Text Tool"
|
||||||
|
text = "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact."
|
||||||
|
|
||||||
|
[redact.tooltip.manual.markArea]
|
||||||
|
title = "Mark Area Tool"
|
||||||
|
text = "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes."
|
||||||
|
|
||||||
|
[redact.tooltip.manual.apply]
|
||||||
|
title = "Apply Redactions"
|
||||||
|
text = "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied."
|
||||||
|
bullet1 = "Mark as many areas as needed before applying"
|
||||||
|
bullet2 = "All pending redactions are applied at once"
|
||||||
|
bullet3 = "Redactions cannot be undone after applying"
|
||||||
|
|
||||||
[redact.manual]
|
[redact.manual]
|
||||||
|
title = "Redaction Tools"
|
||||||
|
instructions = "Select text or draw areas on the PDF to mark content for redaction."
|
||||||
|
markText = "Mark Text"
|
||||||
|
markArea = "Mark Area"
|
||||||
|
pendingLabel = "Pending:"
|
||||||
|
applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted"
|
||||||
|
apply = "Apply"
|
||||||
|
noMarks = "No redaction marks. Use the tools above to mark content for redaction."
|
||||||
header = "Manual Redaction"
|
header = "Manual Redaction"
|
||||||
|
controlsTitle = "Manual Redaction Controls"
|
||||||
textBasedRedaction = "Text-based Redaction"
|
textBasedRedaction = "Text-based Redaction"
|
||||||
pageBasedRedaction = "Page-based Redaction"
|
pageBasedRedaction = "Page-based Redaction"
|
||||||
convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)"
|
convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)"
|
||||||
@@ -4078,11 +4109,16 @@ language = "Language"
|
|||||||
toggleAnnotations = "Toggle Annotations Visibility"
|
toggleAnnotations = "Toggle Annotations Visibility"
|
||||||
search = "Search PDF"
|
search = "Search PDF"
|
||||||
panMode = "Pan Mode"
|
panMode = "Pan Mode"
|
||||||
|
applyRedactionsFirst = "Apply redactions first"
|
||||||
rotateLeft = "Rotate Left"
|
rotateLeft = "Rotate Left"
|
||||||
rotateRight = "Rotate Right"
|
rotateRight = "Rotate Right"
|
||||||
toggleSidebar = "Toggle Sidebar"
|
toggleSidebar = "Toggle Sidebar"
|
||||||
toggleBookmarks = "Toggle Bookmarks"
|
toggleBookmarks = "Toggle Bookmarks"
|
||||||
print = "Print PDF"
|
print = "Print PDF"
|
||||||
|
draw = "Draw"
|
||||||
|
redact = "Redact"
|
||||||
|
exitRedaction = "Exit Redaction Mode"
|
||||||
|
save = "Save"
|
||||||
downloadAll = "Download All"
|
downloadAll = "Download All"
|
||||||
saveAll = "Save All"
|
saveAll = "Save All"
|
||||||
|
|
||||||
@@ -4156,6 +4192,7 @@ editSwitchToSelect = "Switch to Select & Edit to edit this annotation."
|
|||||||
undo = "Undo"
|
undo = "Undo"
|
||||||
redo = "Redo"
|
redo = "Redo"
|
||||||
applyChanges = "Apply Changes"
|
applyChanges = "Apply Changes"
|
||||||
|
saveChanges = "Save Changes"
|
||||||
|
|
||||||
[search]
|
[search]
|
||||||
title = "Search PDF"
|
title = "Search PDF"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
|||||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
||||||
|
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||||
|
|
||||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||||
function ScarfTrackingInitializer() {
|
function ScarfTrackingInitializer() {
|
||||||
@@ -96,6 +97,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<ViewerProvider>
|
<ViewerProvider>
|
||||||
<PageEditorProvider>
|
<PageEditorProvider>
|
||||||
<SignatureProvider>
|
<SignatureProvider>
|
||||||
|
<RedactionProvider>
|
||||||
<AnnotationProvider>
|
<AnnotationProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<TourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
@@ -105,6 +107,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
</TourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</AnnotationProvider>
|
</AnnotationProvider>
|
||||||
|
</RedactionProvider>
|
||||||
</SignatureProvider>
|
</SignatureProvider>
|
||||||
</PageEditorProvider>
|
</PageEditorProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||||
import { useFileState } from '@app/contexts/FileContext';
|
import { useFileState } from '@app/contexts/FileContext';
|
||||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
import { useNavigationState, useNavigationActions, useNavigationGuard } from '@app/contexts/NavigationContext';
|
||||||
import { isBaseWorkbench } from '@app/types/workbench';
|
import { isBaseWorkbench } from '@app/types/workbench';
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
@@ -51,6 +52,21 @@ export default function Workbench() {
|
|||||||
|
|
||||||
// Get active file index from ViewerContext
|
// Get active file index from ViewerContext
|
||||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||||
|
|
||||||
|
// Get navigation guard for unsaved changes check when switching files
|
||||||
|
const { requestNavigation } = useNavigationGuard();
|
||||||
|
|
||||||
|
// Wrap file selection to check for unsaved changes before switching
|
||||||
|
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
|
||||||
|
const handleFileSelect = useCallback((index: number) => {
|
||||||
|
// Don't do anything if selecting the same file
|
||||||
|
if (index === activeFileIndex) return;
|
||||||
|
|
||||||
|
// requestNavigation handles the unsaved changes check internally
|
||||||
|
requestNavigation(() => {
|
||||||
|
setActiveFileIndex(index);
|
||||||
|
});
|
||||||
|
}, [activeFileIndex, requestNavigation, setActiveFileIndex]);
|
||||||
|
|
||||||
const handlePreviewClose = () => {
|
const handlePreviewClose = () => {
|
||||||
setPreviewFile(null);
|
setPreviewFile(null);
|
||||||
@@ -182,7 +198,7 @@ export default function Workbench() {
|
|||||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||||
})}
|
})}
|
||||||
currentFileIndex={activeFileIndex}
|
currentFileIndex={activeFileIndex}
|
||||||
onFileSelect={setActiveFileIndex}
|
onFileSelect={handleFileSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
import { Button, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import FitText from "@app/components/shared/FitText";
|
import FitText from "@app/components/shared/FitText";
|
||||||
|
|
||||||
export interface ButtonOption<T> {
|
export interface ButtonOption<T> {
|
||||||
value: T;
|
value: T;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ButtonSelectorProps<T> {
|
interface ButtonSelectorProps<T> {
|
||||||
@@ -39,33 +40,46 @@ const ButtonSelector = <T extends string | number>({
|
|||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<Group gap='4px'>
|
<Group gap='4px'>
|
||||||
{options.map((option) => (
|
{options.map((option) => {
|
||||||
<Button
|
const isDisabled = disabled || option.disabled;
|
||||||
key={option.value}
|
const button = (
|
||||||
variant={value === option.value ? 'filled' : 'outline'}
|
<Button
|
||||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
variant={value === option.value ? 'filled' : 'outline'}
|
||||||
onClick={() => onChange(option.value)}
|
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||||
disabled={disabled || option.disabled}
|
onClick={() => onChange(option.value)}
|
||||||
className={buttonClassName}
|
disabled={isDisabled}
|
||||||
style={{
|
className={buttonClassName}
|
||||||
flex: fullWidth ? 1 : undefined,
|
style={{
|
||||||
height: 'auto',
|
flex: fullWidth ? 1 : undefined,
|
||||||
minHeight: '2.5rem',
|
height: 'auto',
|
||||||
fontSize: 'var(--mantine-font-size-sm)',
|
minHeight: '2.5rem',
|
||||||
lineHeight: '1.4',
|
fontSize: 'var(--mantine-font-size-sm)',
|
||||||
paddingTop: '0.5rem',
|
lineHeight: '1.4',
|
||||||
paddingBottom: '0.5rem'
|
paddingTop: '0.5rem',
|
||||||
}}
|
paddingBottom: '0.5rem'
|
||||||
>
|
}}
|
||||||
<FitText
|
>
|
||||||
text={option.label}
|
<FitText
|
||||||
lines={1}
|
text={option.label}
|
||||||
minimumFontScale={0.5}
|
lines={1}
|
||||||
fontSize={10}
|
minimumFontScale={0.5}
|
||||||
className={textClassName}
|
fontSize={10}
|
||||||
/>
|
className={textClassName}
|
||||||
</Button>
|
/>
|
||||||
))}
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap with tooltip if provided (useful for disabled state explanations)
|
||||||
|
if (option.tooltip && isDisabled) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={option.value} label={option.tooltip} position="top" withArrow>
|
||||||
|
<span style={{ flex: fullWidth ? 1 : undefined, display: 'flex' }}>{button}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={option.value} style={{ flex: fullWidth ? 1 : undefined, display: 'flex' }}>{button}</span>;
|
||||||
|
})}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||||
|
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
|
||||||
|
|
||||||
interface NavigationWarningModalProps {
|
interface NavigationWarningModalProps {
|
||||||
onApplyAndContinue?: () => Promise<void>;
|
onApplyAndContinue?: () => Promise<void>;
|
||||||
onExportAndContinue?: () => Promise<void>;
|
onExportAndContinue?: () => Promise<void>;
|
||||||
|
/** Called when discarding - allows saving applied changes while discarding pending ones */
|
||||||
|
onDiscardAndContinue?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue, onDiscardAndContinue }: NavigationWarningModalProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||||
useNavigationGuard();
|
useNavigationGuard();
|
||||||
@@ -19,7 +22,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
|||||||
cancelNavigation();
|
cancelNavigation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscardChanges = () => {
|
const handleDiscardChanges = async () => {
|
||||||
|
// If a discard handler is provided, call it to save any already-applied changes, then discard the unsaved changes
|
||||||
|
if (onDiscardAndContinue) {
|
||||||
|
await onDiscardAndContinue();
|
||||||
|
}
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
confirmNavigation();
|
confirmNavigation();
|
||||||
};
|
};
|
||||||
@@ -32,14 +39,15 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
|||||||
confirmNavigation();
|
confirmNavigation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleExportAndContinue = async () => {
|
const handleExportAndContinue = async () => {
|
||||||
if (onExportAndContinue) {
|
if (onExportAndContinue) {
|
||||||
await onExportAndContinue();
|
await onExportAndContinue();
|
||||||
}
|
}
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
confirmNavigation();
|
confirmNavigation();
|
||||||
};
|
};
|
||||||
const BUTTON_WIDTH = "10rem";
|
|
||||||
|
const BUTTON_WIDTH = "12rem";
|
||||||
|
|
||||||
// Only show modal if there are unsaved changes AND there's an actual pending navigation
|
// Only show modal if there are unsaved changes AND there's an actual pending navigation
|
||||||
// This prevents the modal from showing due to spurious state updates
|
// This prevents the modal from showing due to spurious state updates
|
||||||
@@ -56,6 +64,7 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
|||||||
size="auto"
|
size="auto"
|
||||||
closeOnClickOutside={true}
|
closeOnClickOutside={true}
|
||||||
closeOnEscape={true}
|
closeOnEscape={true}
|
||||||
|
zIndex={Z_INDEX_TOAST}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack ta="center" p="md">
|
<Stack ta="center" p="md">
|
||||||
@@ -83,6 +92,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
|||||||
{t("applyAndContinue", "Apply & Leave")}
|
{t("applyAndContinue", "Apply & Leave")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onExportAndContinue && (
|
||||||
|
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||||
|
{t("exportAndContinue", "Export & Leave")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -99,6 +113,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
|||||||
{t("applyAndContinue", "Apply & Leave")}
|
{t("applyAndContinue", "Apply & Leave")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onExportAndContinue && (
|
||||||
|
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||||
|
{t("exportAndContinue", "Export & Leave")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default function RightRail() {
|
|||||||
(btn: RightRailButtonConfig) => {
|
(btn: RightRailButtonConfig) => {
|
||||||
const action = actions[btn.id];
|
const action = actions[btn.id];
|
||||||
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
|
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
|
||||||
|
const isActive = Boolean(btn.active);
|
||||||
|
|
||||||
const triggerAction = () => {
|
const triggerAction = () => {
|
||||||
if (!disabled) action?.();
|
if (!disabled) action?.();
|
||||||
@@ -103,6 +104,7 @@ export default function RightRail() {
|
|||||||
allButtonsDisabled,
|
allButtonsDisabled,
|
||||||
action,
|
action,
|
||||||
triggerAction,
|
triggerAction,
|
||||||
|
active: isActive,
|
||||||
};
|
};
|
||||||
return btn.render(context) ?? null;
|
return btn.render(context) ?? null;
|
||||||
}
|
}
|
||||||
@@ -114,12 +116,15 @@ export default function RightRail() {
|
|||||||
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
|
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
|
||||||
const buttonNode = (
|
const buttonNode = (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant={isActive ? 'filled' : 'subtle'}
|
||||||
|
color={isActive ? 'blue' : undefined}
|
||||||
radius="md"
|
radius="md"
|
||||||
className={className}
|
className={className}
|
||||||
onClick={triggerAction}
|
onClick={triggerAction}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-pressed={isActive ? true : undefined}
|
||||||
|
data-active={isActive ? 'true' : 'false'}
|
||||||
>
|
>
|
||||||
{btn.icon}
|
{btn.icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ interface RightRailButtonWithAction {
|
|||||||
id: string; // Unique identifier
|
id: string; // Unique identifier
|
||||||
icon?: React.ReactNode; // Icon component (omit when using render)
|
icon?: React.ReactNode; // Icon component (omit when using render)
|
||||||
tooltip?: React.ReactNode; // Hover tooltip / description
|
tooltip?: React.ReactNode; // Hover tooltip / description
|
||||||
|
active?: boolean; // Optional active state for highlight
|
||||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||||
order?: number; // Sort order (default: 0)
|
order?: number; // Sort order (default: 0)
|
||||||
disabled?: boolean; // Disabled state (default: false)
|
disabled?: boolean; // Disabled state (default: false)
|
||||||
@@ -100,6 +101,7 @@ interface RightRailRenderContext {
|
|||||||
allButtonsDisabled: boolean;
|
allButtonsDisabled: boolean;
|
||||||
action?: () => void;
|
action?: () => void;
|
||||||
triggerAction: () => void;
|
triggerAction: () => void;
|
||||||
|
active: boolean;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,10 @@
|
|||||||
color: var(--right-rail-icon);
|
color: var(--right-rail-icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-rail-icon[data-active="true"] {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.right-rail-icon[aria-disabled="true"],
|
.right-rail-icon[aria-disabled="true"],
|
||||||
.right-rail-icon[disabled] {
|
.right-rail-icon[disabled] {
|
||||||
color: var(--right-rail-icon-disabled) !important;
|
color: var(--right-rail-icon-disabled) !important;
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { ActionIcon } from '@mantine/core';
|
import { ActionIcon } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
|
import { useFileState, useFileContext } from '@app/contexts/FileContext';
|
||||||
|
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||||
|
import { useNavigationState, useNavigationGuard, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||||
|
import { useRedactionMode, useRedaction } from '@app/contexts/RedactionContext';
|
||||||
|
import { defaultParameters, RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
|
||||||
|
|
||||||
interface ViewerAnnotationControlsProps {
|
interface ViewerAnnotationControlsProps {
|
||||||
currentView: string;
|
currentView: string;
|
||||||
@@ -16,47 +22,162 @@ interface ViewerAnnotationControlsProps {
|
|||||||
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
|
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
|
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
|
||||||
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||||
|
|
||||||
// Viewer context for PDF controls - safely handle when not available
|
// Viewer context for PDF controls - safely handle when not available
|
||||||
const viewerContext = React.useContext(ViewerContext);
|
const viewerContext = React.useContext(ViewerContext);
|
||||||
|
|
||||||
// Check if we're in sign mode
|
// Signature context for accessing drawing API
|
||||||
|
const { historyApiRef, isPlacementMode } = useSignature();
|
||||||
|
|
||||||
|
// File state for save functionality
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
const { actions: fileActions } = useFileContext();
|
||||||
|
const activeFiles = selectors.getFiles();
|
||||||
|
|
||||||
|
// Check if we're in sign mode or redaction mode
|
||||||
const { selectedTool } = useNavigationState();
|
const { selectedTool } = useNavigationState();
|
||||||
|
const { actions: navActions } = useNavigationActions();
|
||||||
const isSignMode = selectedTool === 'sign';
|
const isSignMode = selectedTool === 'sign';
|
||||||
|
const isRedactMode = selectedTool === 'redact';
|
||||||
|
|
||||||
|
// Get redaction pending state and navigation guard
|
||||||
|
const { isRedacting: _isRedacting } = useRedactionMode();
|
||||||
|
const { requestNavigation, setHasUnsavedChanges } = useNavigationGuard();
|
||||||
|
const { setRedactionMode, activateTextSelection, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction();
|
||||||
|
|
||||||
|
|
||||||
// Check if we're in any annotation tool that should disable the toggle
|
// Check if we're in any annotation tool that should disable the toggle
|
||||||
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
|
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
|
||||||
|
|
||||||
// Check if we're on annotate tool to highlight the button
|
// Check if we're on annotate tool to highlight the button
|
||||||
const isAnnotateActive = selectedTool === 'annotate';
|
const isAnnotateActive = selectedTool === 'annotate';
|
||||||
|
const annotationsHidden = viewerContext ? !viewerContext.isAnnotationsVisible : false;
|
||||||
|
|
||||||
// Don't show any annotation controls in sign mode
|
// Don't show any annotation controls in sign mode
|
||||||
if (isSignMode) {
|
if (isSignMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist annotations to file if there are unsaved changes
|
||||||
|
const saveAnnotationsIfNeeded = async () => {
|
||||||
|
if (!viewerContext?.exportActions?.saveAsCopy || currentView !== 'viewer' || !historyApiRef?.current?.canUndo()) return;
|
||||||
|
if (activeFiles.length === 0 || state.files.ids.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await viewerContext.exportActions.saveAsCopy();
|
||||||
|
if (!arrayBuffer) return;
|
||||||
|
|
||||||
|
const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: 'application/pdf' });
|
||||||
|
const parentStub = selectors.getStirlingFileStub(state.files.ids[0]);
|
||||||
|
if (!parentStub) return;
|
||||||
|
|
||||||
|
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'redact');
|
||||||
|
await fileActions.consumeFiles([state.files.ids[0]], stirlingFiles, stubs);
|
||||||
|
|
||||||
|
// Clear unsaved changes flags after successful save
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setRedactionsApplied(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error auto-saving annotations before redaction:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitRedactionMode = useCallback(() => {
|
||||||
|
navActions.setToolAndWorkbench(null, 'viewer');
|
||||||
|
setLeftPanelView('toolPicker');
|
||||||
|
setRedactionMode(false);
|
||||||
|
setActiveType(null);
|
||||||
|
}, [navActions, setLeftPanelView, setRedactionMode, setActiveType]);
|
||||||
|
|
||||||
|
// Handle redaction mode toggle
|
||||||
|
const handleRedactionToggle = async () => {
|
||||||
|
if (isRedactMode) {
|
||||||
|
// Exit redaction mode
|
||||||
|
exitRedactionMode();
|
||||||
|
} else {
|
||||||
|
// Check for unsaved annotation changes
|
||||||
|
const hasAnnotationChanges = historyApiRef?.current?.canUndo() ?? false;
|
||||||
|
|
||||||
|
const enterRedactionMode = async () => {
|
||||||
|
await saveAnnotationsIfNeeded();
|
||||||
|
|
||||||
|
// Set redaction config to manual mode when opening from viewer
|
||||||
|
const manualConfig: RedactParameters = {
|
||||||
|
...defaultParameters,
|
||||||
|
mode: 'manual',
|
||||||
|
};
|
||||||
|
setRedactionConfig(manualConfig);
|
||||||
|
|
||||||
|
// Set tool and keep viewer workbench
|
||||||
|
navActions.setToolAndWorkbench('redact', 'viewer');
|
||||||
|
|
||||||
|
// Ensure sidebars are visible and open tool content
|
||||||
|
setSidebarsVisible(true);
|
||||||
|
setLeftPanelView('toolContent');
|
||||||
|
|
||||||
|
setRedactionMode(true);
|
||||||
|
// Activate text selection mode after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentType = redactionApiRef.current?.getActiveType?.();
|
||||||
|
if (currentType !== 'redactSelection') {
|
||||||
|
activateTextSelection();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasAnnotationChanges) {
|
||||||
|
requestNavigation(enterRedactionMode);
|
||||||
|
} else {
|
||||||
|
await enterRedactionMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Annotation Visibility Toggle */}
|
{/* Redaction Mode Toggle */}
|
||||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
<Tooltip content={isRedactMode ? t('rightRail.exitRedaction', 'Exit Redaction Mode') : t('rightRail.redact', 'Redact')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isAnnotateActive ? "filled" : "subtle"}
|
variant={isRedactMode ? 'filled' : 'subtle'}
|
||||||
color="blue"
|
color={isRedactMode ? 'blue' : undefined}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="right-rail-icon"
|
||||||
onClick={() => {
|
onClick={handleRedactionToggle}
|
||||||
viewerContext?.toggleAnnotationsVisibility();
|
disabled={disabled || currentView !== 'viewer'}
|
||||||
}}
|
|
||||||
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
|
|
||||||
>
|
>
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
icon="scan-delete-rounded"
|
||||||
width="1.5rem"
|
width="1.5rem"
|
||||||
height="1.5rem"
|
height="1.5rem"
|
||||||
/>
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Annotation Visibility Toggle */}
|
||||||
|
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||||
|
<ActionIcon
|
||||||
|
variant={annotationsHidden ? "filled" : "subtle"}
|
||||||
|
color={annotationsHidden ? "blue" : undefined}
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={() => {
|
||||||
|
viewerContext?.toggleAnnotationsVisibility();
|
||||||
|
}}
|
||||||
|
disabled={disabled || currentView !== 'viewer' || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode}
|
||||||
|
data-active={annotationsHidden ? 'true' : undefined}
|
||||||
|
aria-pressed={annotationsHidden}
|
||||||
|
>
|
||||||
|
<LocalIcon
|
||||||
|
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "preview-off-rounded"}
|
||||||
|
width="1.5rem"
|
||||||
|
height="1.5rem"
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Button, Stack, Text, Group, Divider } from '@mantine/core';
|
||||||
|
import HighlightAltIcon from '@mui/icons-material/HighlightAlt';
|
||||||
|
import CropFreeIcon from '@mui/icons-material/CropFree';
|
||||||
|
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||||
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
|
|
||||||
|
interface ManualRedactionControlsProps {
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ManualRedactionControls provides UI for manual PDF redaction in the tool panel.
|
||||||
|
* Displays controls for marking text/areas for redaction and applying them.
|
||||||
|
* Uses our RedactionContext which bridges to the EmbedPDF API.
|
||||||
|
*/
|
||||||
|
export default function ManualRedactionControls({ disabled = false }: ManualRedactionControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Use our RedactionContext which bridges to EmbedPDF
|
||||||
|
const { activateTextSelection, activateMarquee, redactionsApplied, setActiveType } = useRedaction();
|
||||||
|
const { pendingCount, activeType, isRedacting, isBridgeReady } = useRedactionMode();
|
||||||
|
|
||||||
|
// Get viewer context to manage annotation mode and save changes
|
||||||
|
const { isAnnotationMode, setAnnotationMode, applyChanges, activeFileIndex } = useViewer();
|
||||||
|
|
||||||
|
// Get signature context to deactivate annotation tools when switching to redaction
|
||||||
|
const { signatureApiRef } = useSignature();
|
||||||
|
|
||||||
|
// Check which tool is active based on activeType
|
||||||
|
const isSelectionActive = activeType === 'redactSelection';
|
||||||
|
const isMarqueeActive = activeType === 'marqueeRedact';
|
||||||
|
|
||||||
|
// Track if we've auto-activated
|
||||||
|
const hasAutoActivated = useRef(false);
|
||||||
|
|
||||||
|
// Track the previous file index to detect file switches
|
||||||
|
const prevFileIndexRef = useRef<number>(activeFileIndex);
|
||||||
|
|
||||||
|
// Track previous pending count to detect when all redactions are applied
|
||||||
|
const prevPendingCountRef = useRef<number>(pendingCount);
|
||||||
|
|
||||||
|
// Track if we're currently auto-saving to prevent re-entry
|
||||||
|
const isAutoSavingRef = useRef(false);
|
||||||
|
|
||||||
|
// Auto-activate selection mode when the API bridge becomes ready
|
||||||
|
// This ensures at least one tool is selected when entering manual redaction mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBridgeReady && !disabled && !isRedacting && !hasAutoActivated.current) {
|
||||||
|
hasAutoActivated.current = true;
|
||||||
|
// Small delay to ensure EmbedPDF is fully ready
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Deactivate annotation mode to show redaction layer
|
||||||
|
setAnnotationMode(false);
|
||||||
|
activateTextSelection();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isBridgeReady, disabled, isRedacting, activateTextSelection, setAnnotationMode]);
|
||||||
|
|
||||||
|
// Reset auto-activation flag when disabled changes or bridge becomes not ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled || !isBridgeReady) {
|
||||||
|
hasAutoActivated.current = false;
|
||||||
|
}
|
||||||
|
}, [disabled, isBridgeReady]);
|
||||||
|
|
||||||
|
// Reset redaction tool when switching between files
|
||||||
|
// The new PDF gets a fresh EmbedPDF instance - forcing user to re-select tool ensures it works properly
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFileIndexRef.current !== activeFileIndex) {
|
||||||
|
prevFileIndexRef.current = activeFileIndex;
|
||||||
|
|
||||||
|
// Reset active type to null when switching files
|
||||||
|
// This makes both buttons appear unselected, requiring the user to re-click
|
||||||
|
// which ensures proper activation on the new PDF
|
||||||
|
if (isSelectionActive || isMarqueeActive) {
|
||||||
|
setActiveType(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeFileIndex, isSelectionActive, isMarqueeActive, setActiveType]);
|
||||||
|
|
||||||
|
// Auto-save when all pending redactions have been applied
|
||||||
|
// This triggers when the user clicks "Apply (permanent)" on the last pending redaction
|
||||||
|
useEffect(() => {
|
||||||
|
const hadPendingBefore = prevPendingCountRef.current > 0;
|
||||||
|
const hasNoPendingNow = pendingCount === 0;
|
||||||
|
const wasJustCleared = hadPendingBefore && hasNoPendingNow;
|
||||||
|
|
||||||
|
// Update the ref for next comparison
|
||||||
|
prevPendingCountRef.current = pendingCount;
|
||||||
|
|
||||||
|
// Auto-save when:
|
||||||
|
// - pendingCount just went from > 0 to 0 (user applied the last pending redaction)
|
||||||
|
// - redactionsApplied is true (at least one redaction was committed)
|
||||||
|
// - not already auto-saving
|
||||||
|
// - applyChanges is available
|
||||||
|
if (wasJustCleared && redactionsApplied && !isAutoSavingRef.current && applyChanges) {
|
||||||
|
isAutoSavingRef.current = true;
|
||||||
|
|
||||||
|
// Small delay to ensure UI updates before save
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await applyChanges();
|
||||||
|
} finally {
|
||||||
|
isAutoSavingRef.current = false;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
isAutoSavingRef.current = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [pendingCount, redactionsApplied, applyChanges]);
|
||||||
|
|
||||||
|
const handleSelectionClick = () => {
|
||||||
|
// Deactivate annotation mode and tools to switch to redaction layer
|
||||||
|
if (isAnnotationMode) {
|
||||||
|
setAnnotationMode(false);
|
||||||
|
// Deactivate any active annotation tools (like draw)
|
||||||
|
if (signatureApiRef?.current) {
|
||||||
|
try {
|
||||||
|
signatureApiRef.current.deactivateTools();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Unable to deactivate annotation tools:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelectionActive && !isAnnotationMode) {
|
||||||
|
// If already active and not coming from annotation mode, switch to marquee
|
||||||
|
activateMarquee();
|
||||||
|
} else {
|
||||||
|
activateTextSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarqueeClick = () => {
|
||||||
|
// Deactivate annotation mode and tools to switch to redaction layer
|
||||||
|
if (isAnnotationMode) {
|
||||||
|
setAnnotationMode(false);
|
||||||
|
// Deactivate any active annotation tools (like draw)
|
||||||
|
if (signatureApiRef?.current) {
|
||||||
|
try {
|
||||||
|
signatureApiRef.current.deactivateTools();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Unable to deactivate annotation tools:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMarqueeActive && !isAnnotationMode) {
|
||||||
|
// If already active and not coming from annotation mode, switch to selection
|
||||||
|
activateTextSelection();
|
||||||
|
} else {
|
||||||
|
activateMarquee();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle saving changes - this will apply pending redactions and save to file
|
||||||
|
const handleSaveChanges = useCallback(async () => {
|
||||||
|
if (applyChanges) {
|
||||||
|
await applyChanges();
|
||||||
|
}
|
||||||
|
}, [applyChanges]);
|
||||||
|
|
||||||
|
// Check if there are unsaved changes to save (pending redactions OR applied redactions)
|
||||||
|
// Save Changes button will apply pending redactions and then save everything
|
||||||
|
const hasUnsavedChanges = pendingCount > 0 || redactionsApplied;
|
||||||
|
|
||||||
|
// Check if API is available - use isBridgeReady state instead of ref (refs don't trigger re-renders)
|
||||||
|
const isApiReady = isBridgeReady;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t('redact.manual.title', 'Redaction Tools')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('redact.manual.instructions', 'Select text or draw areas on the PDF to mark content for redaction.')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap="sm" grow wrap="nowrap">
|
||||||
|
{/* Mark Text Selection Tool */}
|
||||||
|
<Button
|
||||||
|
variant={isSelectionActive && !isAnnotationMode ? 'filled' : 'outline'}
|
||||||
|
color={isSelectionActive && !isAnnotationMode ? 'blue' : 'gray'}
|
||||||
|
leftSection={<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />}
|
||||||
|
onClick={handleSelectionClick}
|
||||||
|
disabled={disabled || !isApiReady}
|
||||||
|
size="sm"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('redact.manual.markText', 'Mark Text')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Mark Area (Marquee) Tool */}
|
||||||
|
<Button
|
||||||
|
variant={isMarqueeActive && !isAnnotationMode ? 'filled' : 'outline'}
|
||||||
|
color={isMarqueeActive && !isAnnotationMode ? 'blue' : 'gray'}
|
||||||
|
leftSection={<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />}
|
||||||
|
onClick={handleMarqueeClick}
|
||||||
|
disabled={disabled || !isApiReady}
|
||||||
|
size="sm"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('redact.manual.markArea', 'Mark Area')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Save Changes Button - applies pending redactions and saves to file */}
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
mt="sm"
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
disabled={!hasUnsavedChanges}
|
||||||
|
onClick={handleSaveChanges}
|
||||||
|
>
|
||||||
|
{t('annotation.saveChanges', 'Save Changes')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,9 +6,17 @@ interface RedactModeSelectorProps {
|
|||||||
mode: RedactMode;
|
mode: RedactMode;
|
||||||
onModeChange: (mode: RedactMode) => void;
|
onModeChange: (mode: RedactMode) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
hasFilesSelected?: boolean; // Files are selected in workbench
|
||||||
|
hasAnyFiles?: boolean; // Any files exist in workbench (for manual mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
|
export default function RedactModeSelector({
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
disabled,
|
||||||
|
hasFilesSelected = false,
|
||||||
|
hasAnyFiles = false
|
||||||
|
}: RedactModeSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,11 +28,15 @@ export default function RedactModeSelector({ mode, onModeChange, disabled }: Red
|
|||||||
{
|
{
|
||||||
value: 'automatic' as const,
|
value: 'automatic' as const,
|
||||||
label: t('redact.modeSelector.automatic', 'Automatic'),
|
label: t('redact.modeSelector.automatic', 'Automatic'),
|
||||||
|
disabled: !hasFilesSelected, // Automatic requires files to be selected
|
||||||
|
tooltip: !hasFilesSelected
|
||||||
|
? t('redact.modeSelector.automaticDisabledTooltip', 'Select files in the file manager to redact multiple files at once')
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'manual' as const,
|
value: 'manual' as const,
|
||||||
label: t('redact.modeSelector.manual', 'Manual'),
|
label: t('redact.modeSelector.manual', 'Manual'),
|
||||||
disabled: true, // Keep manual mode disabled until implemented
|
disabled: !hasAnyFiles, // Manual mode works if any files exist in workbench
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -77,3 +77,32 @@ export const useRedactAdvancedTips = (): TooltipContent => {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useRedactManualTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("redact.tooltip.manual.header.title", "Manual Redaction Controls")
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.manual.markText.title", "Mark Text Tool"),
|
||||||
|
description: t("redact.tooltip.manual.markText.text", "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.manual.markArea.title", "Mark Area Tool"),
|
||||||
|
description: t("redact.tooltip.manual.markArea.text", "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.manual.apply.title", "Apply Redactions"),
|
||||||
|
description: t("redact.tooltip.manual.apply.text", "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied."),
|
||||||
|
bullets: [
|
||||||
|
t("redact.tooltip.manual.apply.bullet1", "Mark as many areas as needed before applying"),
|
||||||
|
t("redact.tooltip.manual.apply.bullet2", "All pending redactions are applied at once"),
|
||||||
|
t("redact.tooltip.manual.apply.bullet3", "Redactions cannot be undone after applying")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Box, Center, Text, ActionIcon } from '@mantine/core';
|
import { Box, Center, Text, ActionIcon } from '@mantine/core';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
|
|||||||
import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
|
import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
|
||||||
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
|
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
|
||||||
import { useSignature } from '@app/contexts/SignatureContext';
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
|
import { useRedaction } from '@app/contexts/RedactionContext';
|
||||||
|
import type { RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
|
||||||
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||||
import { isStirlingFile } from '@app/types/fileContext';
|
import { isStirlingFile } from '@app/types/fileContext';
|
||||||
@@ -46,14 +48,15 @@ const EmbedPdfViewerContent = ({
|
|||||||
isSearchInterfaceVisible,
|
isSearchInterfaceVisible,
|
||||||
searchInterfaceActions,
|
searchInterfaceActions,
|
||||||
zoomActions,
|
zoomActions,
|
||||||
|
scrollActions,
|
||||||
panActions: _panActions,
|
panActions: _panActions,
|
||||||
rotationActions: _rotationActions,
|
rotationActions: _rotationActions,
|
||||||
getScrollState,
|
getScrollState,
|
||||||
getRotationState,
|
getRotationState,
|
||||||
isAnnotationMode,
|
|
||||||
setAnnotationMode,
|
setAnnotationMode,
|
||||||
isAnnotationsVisible,
|
isAnnotationsVisible,
|
||||||
exportActions,
|
exportActions,
|
||||||
|
setApplyChanges,
|
||||||
} = useViewer();
|
} = useViewer();
|
||||||
|
|
||||||
const scrollState = getScrollState();
|
const scrollState = getScrollState();
|
||||||
@@ -75,6 +78,21 @@ const EmbedPdfViewerContent = ({
|
|||||||
// annotation history changes, and cleared after we successfully apply changes.
|
// annotation history changes, and cleared after we successfully apply changes.
|
||||||
const hasAnnotationChangesRef = useRef(false);
|
const hasAnnotationChangesRef = useRef(false);
|
||||||
|
|
||||||
|
// Scroll position preservation system
|
||||||
|
// We continuously track the last known good scroll position, so we always have it available
|
||||||
|
const lastKnownScrollPageRef = useRef<number>(1);
|
||||||
|
const pendingScrollRestoreRef = useRef<number | null>(null);
|
||||||
|
const scrollRestoreAttemptsRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Track the file ID we should be viewing after a save (to handle list reordering)
|
||||||
|
const pendingFileIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Get redaction context
|
||||||
|
const { redactionsApplied, setRedactionsApplied } = useRedaction();
|
||||||
|
|
||||||
|
// Ref for redaction pending tracker API
|
||||||
|
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
|
||||||
|
|
||||||
// Get current file from FileContext
|
// Get current file from FileContext
|
||||||
const { selectors, state } = useFileState();
|
const { selectors, state } = useFileState();
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
@@ -85,16 +103,61 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Navigation guard for unsaved changes
|
// Navigation guard for unsaved changes
|
||||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||||
|
|
||||||
// Check if we're in an annotation tool
|
|
||||||
const { selectedTool } = useNavigationState();
|
const { selectedTool } = useNavigationState();
|
||||||
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
|
|
||||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
||||||
|
const isSignatureMode = isInAnnotationTool;
|
||||||
|
const isManualRedactMode = selectedTool === 'redact';
|
||||||
|
|
||||||
// Sync isAnnotationMode in ViewerContext with current tool
|
// Enable annotations only when annotation tool is selected
|
||||||
|
const shouldEnableAnnotations = selectedTool === 'annotate' || isSignatureMode;
|
||||||
|
|
||||||
|
// Enable redaction only when redaction tool is selected
|
||||||
|
const shouldEnableRedaction = selectedTool === 'redact';
|
||||||
|
|
||||||
|
// Track previous annotation/redaction state to detect tool switches
|
||||||
|
const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations);
|
||||||
|
const prevEnableRedactionRef = useRef(shouldEnableRedaction);
|
||||||
|
|
||||||
|
// Track scroll position whenever scrollState changes from the context
|
||||||
|
// This ensures we always have the most up-to-date position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAnnotationMode(isInAnnotationTool);
|
if (scrollState.currentPage > 0) {
|
||||||
}, [isInAnnotationTool, setAnnotationMode]);
|
lastKnownScrollPageRef.current = scrollState.currentPage;
|
||||||
|
}
|
||||||
|
}, [scrollState.currentPage]);
|
||||||
|
|
||||||
|
// Preserve scroll position when switching between annotation and redaction tools
|
||||||
|
// Using useLayoutEffect to capture synchronously before DOM updates
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const annotationsChanged = prevEnableAnnotationsRef.current !== shouldEnableAnnotations;
|
||||||
|
const redactionChanged = prevEnableRedactionRef.current !== shouldEnableRedaction;
|
||||||
|
|
||||||
|
if (annotationsChanged || redactionChanged) {
|
||||||
|
// Read scroll state directly AND use the tracked value - take whichever is valid
|
||||||
|
const currentScrollState = getScrollState();
|
||||||
|
const pageFromState = currentScrollState.currentPage;
|
||||||
|
const pageFromRef = lastKnownScrollPageRef.current;
|
||||||
|
|
||||||
|
// Use the current state if valid, otherwise fall back to tracked ref
|
||||||
|
const pageToRestore = pageFromState > 0 ? pageFromState : pageFromRef;
|
||||||
|
|
||||||
|
if (pageToRestore > 0) {
|
||||||
|
pendingScrollRestoreRef.current = pageToRestore;
|
||||||
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevEnableAnnotationsRef.current = shouldEnableAnnotations;
|
||||||
|
prevEnableRedactionRef.current = shouldEnableRedaction;
|
||||||
|
}
|
||||||
|
}, [shouldEnableAnnotations, shouldEnableRedaction, getScrollState]);
|
||||||
|
|
||||||
|
// Keep annotation mode enabled when entering placement tools without overriding manual toggles
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInAnnotationTool) {
|
||||||
|
setAnnotationMode(true);
|
||||||
|
}
|
||||||
|
}, [isInAnnotationTool, setAnnotationMode]);
|
||||||
const isPlacementOverlayActive = Boolean(
|
const isPlacementOverlayActive = Boolean(
|
||||||
isInAnnotationTool && isPlacementMode && signatureConfig
|
isInAnnotationTool && isPlacementMode && signatureConfig
|
||||||
);
|
);
|
||||||
@@ -124,6 +187,20 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
}, [activeFiles.length, activeFileIndex]);
|
}, [activeFiles.length, activeFileIndex]);
|
||||||
|
|
||||||
|
// After saving a file, the list may reorder (sorted by version).
|
||||||
|
// Track the saved file's ID and update activeFileIndex to follow it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingFileIdRef.current && activeFiles.length > 0) {
|
||||||
|
const targetFileId = pendingFileIdRef.current;
|
||||||
|
const newIndex = activeFiles.findIndex(f => f.fileId === targetFileId);
|
||||||
|
if (newIndex !== -1 && newIndex !== activeFileIndex) {
|
||||||
|
setActiveFileIndex(newIndex);
|
||||||
|
}
|
||||||
|
// Clear the pending file ID once we've found and switched to it
|
||||||
|
pendingFileIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, [activeFiles, activeFileIndex, setActiveFileIndex]);
|
||||||
|
|
||||||
// Determine which file to display
|
// Determine which file to display
|
||||||
const currentFile = React.useMemo(() => {
|
const currentFile = React.useMemo(() => {
|
||||||
if (previewFile) {
|
if (previewFile) {
|
||||||
@@ -259,8 +336,31 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkForChanges = () => {
|
const checkForChanges = () => {
|
||||||
|
// Check for annotation history changes (using ref that's updated by useEffect)
|
||||||
const hasAnnotationChanges = hasAnnotationChangesRef.current;
|
const hasAnnotationChanges = hasAnnotationChangesRef.current;
|
||||||
return hasAnnotationChanges;
|
|
||||||
|
// Check for pending redactions
|
||||||
|
const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
||||||
|
|
||||||
|
// Always consider applied redactions as unsaved until export
|
||||||
|
const hasAppliedRedactions = redactionsApplied;
|
||||||
|
|
||||||
|
// When in redact mode, still include annotation changes (users may draw)
|
||||||
|
if (isManualRedactMode) {
|
||||||
|
console.log('[Viewer] Checking for unsaved changes (redact mode):', {
|
||||||
|
hasPendingRedactions,
|
||||||
|
hasAppliedRedactions,
|
||||||
|
hasAnnotationChanges,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('[Viewer] Checking for unsaved changes:', {
|
||||||
|
hasAnnotationChanges,
|
||||||
|
hasPendingRedactions,
|
||||||
|
hasAppliedRedactions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAnnotationChanges || hasPendingRedactions || hasAppliedRedactions;
|
||||||
};
|
};
|
||||||
|
|
||||||
registerUnsavedChangesChecker(checkForChanges);
|
registerUnsavedChangesChecker(checkForChanges);
|
||||||
@@ -268,13 +368,34 @@ const EmbedPdfViewerContent = ({
|
|||||||
return () => {
|
return () => {
|
||||||
unregisterUnsavedChangesChecker();
|
unregisterUnsavedChangesChecker();
|
||||||
};
|
};
|
||||||
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, isManualRedactMode, redactionsApplied]);
|
||||||
|
|
||||||
// Apply changes - save annotations to new file version
|
// Save changes - save annotations and redactions to file (overwrites active file)
|
||||||
const applyChanges = useCallback(async () => {
|
const applyChanges = useCallback(async () => {
|
||||||
if (!currentFile || activeFileIds.length === 0) return;
|
if (!currentFile || activeFileIds.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Viewer] Applying changes - exporting PDF with annotations/redactions');
|
||||||
|
|
||||||
|
// Use the continuously tracked scroll position - more reliable than reading at this moment
|
||||||
|
const pageToRestore = lastKnownScrollPageRef.current;
|
||||||
|
|
||||||
|
// Step 0: Commit any pending redactions before export
|
||||||
|
const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
||||||
|
|
||||||
|
// Mark redactions as applied BEFORE committing, so the button stays enabled during the save process
|
||||||
|
// This ensures the button doesn't become disabled when pendingCount becomes 0
|
||||||
|
if (hadPendingRedactions || redactionsApplied) {
|
||||||
|
setRedactionsApplied(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadPendingRedactions) {
|
||||||
|
console.log('[Viewer] Committing pending redactions before export');
|
||||||
|
redactionTrackerRef.current?.commitAllPending();
|
||||||
|
// Give a small delay for the commit to process
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Export PDF with annotations using EmbedPDF
|
// Step 1: Export PDF with annotations using EmbedPDF
|
||||||
const arrayBuffer = await exportActions.saveAsCopy();
|
const arrayBuffer = await exportActions.saveAsCopy();
|
||||||
if (!arrayBuffer) {
|
if (!arrayBuffer) {
|
||||||
@@ -287,33 +408,142 @@ const EmbedPdfViewerContent = ({
|
|||||||
const file = new File([blob], filename, { type: 'application/pdf' });
|
const file = new File([blob], filename, { type: 'application/pdf' });
|
||||||
|
|
||||||
// Step 3: Create StirlingFiles and stubs for version history
|
// Step 3: Create StirlingFiles and stubs for version history
|
||||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
// Only consume the current file, not all active files
|
||||||
|
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||||
|
if (!currentFileId) throw new Error('Current file ID not found');
|
||||||
|
|
||||||
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
if (!parentStub) throw new Error('Parent stub not found');
|
if (!parentStub) throw new Error('Parent stub not found');
|
||||||
|
|
||||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||||
|
|
||||||
// Step 4: Consume files (replace in context)
|
// Store the page to restore after file replacement triggers re-render
|
||||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
pendingScrollRestoreRef.current = pageToRestore;
|
||||||
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
|
||||||
|
// Store the new file ID so we can track it after the list reorders
|
||||||
|
const newFileId = stubs[0]?.id;
|
||||||
|
if (newFileId) {
|
||||||
|
pendingFileIdRef.current = newFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Consume only the current file (replace in context)
|
||||||
|
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||||
|
|
||||||
// Mark annotations as saved so navigation away from the viewer is allowed.
|
// Mark annotations as saved so navigation away from the viewer is allowed.
|
||||||
hasAnnotationChangesRef.current = false;
|
hasAnnotationChangesRef.current = false;
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
|
setRedactionsApplied(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Apply changes failed:', error);
|
console.error('Apply changes failed:', error);
|
||||||
}
|
}
|
||||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
}, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied]);
|
||||||
|
|
||||||
// Expose annotation apply via a global event so tools (like Annotate) can
|
// Discard pending redactions but save already-applied ones
|
||||||
// trigger saves from the left sidebar without tight coupling.
|
// This is called when user clicks "Discard & Leave" - we want to:
|
||||||
|
// 1. NOT commit pending redaction marks (they get discarded)
|
||||||
|
// 2. Save the PDF with already-applied redactions (if any)
|
||||||
|
const discardAndSaveApplied = useCallback(async () => {
|
||||||
|
// Only save if there are applied redactions to preserve
|
||||||
|
if (!redactionsApplied || !currentFile || activeFileIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[Viewer] Discarding pending marks but saving applied redactions');
|
||||||
|
|
||||||
|
// Export PDF WITHOUT committing pending marks - this saves only applied redactions
|
||||||
|
const arrayBuffer = await exportActions.saveAsCopy();
|
||||||
|
if (!arrayBuffer) {
|
||||||
|
throw new Error('Failed to export PDF');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' });
|
||||||
|
|
||||||
|
// Create StirlingFiles and stubs for version history
|
||||||
|
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||||
|
if (!currentFileId) throw new Error('Current file ID not found');
|
||||||
|
|
||||||
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
|
if (!parentStub) throw new Error('Parent stub not found');
|
||||||
|
|
||||||
|
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||||
|
|
||||||
|
// Consume only the current file (replace in context)
|
||||||
|
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
hasAnnotationChangesRef.current = false;
|
||||||
|
setRedactionsApplied(false);
|
||||||
|
|
||||||
|
console.log('[Viewer] Applied redactions saved, pending marks discarded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save applied redactions:', error);
|
||||||
|
}
|
||||||
|
}, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied]);
|
||||||
|
|
||||||
|
// Restore scroll position after file replacement or tool switch
|
||||||
|
// Uses polling with retries to ensure the scroll succeeds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => {
|
if (pendingScrollRestoreRef.current === null) return;
|
||||||
void applyChanges();
|
|
||||||
|
const pageToRestore = pendingScrollRestoreRef.current;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
const attemptInterval = 100; // ms between attempts
|
||||||
|
|
||||||
|
const attemptScroll = () => {
|
||||||
|
const currentState = getScrollState();
|
||||||
|
const targetPage = Math.min(pageToRestore, currentState.totalPages);
|
||||||
|
|
||||||
|
// Only attempt if we have valid state (totalPages > 0 means PDF is loaded)
|
||||||
|
if (currentState.totalPages > 0 && targetPage > 0) {
|
||||||
|
scrollActions.scrollToPage(targetPage, 'instant');
|
||||||
|
|
||||||
|
// Check if scroll succeeded after a brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const afterState = getScrollState();
|
||||||
|
if (afterState.currentPage === targetPage || scrollRestoreAttemptsRef.current >= maxAttempts) {
|
||||||
|
// Success or max attempts reached - clear pending
|
||||||
|
pendingScrollRestoreRef.current = null;
|
||||||
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
} else {
|
||||||
|
// Scroll might not have worked, retry
|
||||||
|
scrollRestoreAttemptsRef.current++;
|
||||||
|
if (scrollRestoreAttemptsRef.current < maxAttempts) {
|
||||||
|
setTimeout(attemptScroll, attemptInterval);
|
||||||
|
} else {
|
||||||
|
// Give up after max attempts
|
||||||
|
pendingScrollRestoreRef.current = null;
|
||||||
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
} else if (scrollRestoreAttemptsRef.current < maxAttempts) {
|
||||||
|
// PDF not ready yet, retry
|
||||||
|
scrollRestoreAttemptsRef.current++;
|
||||||
|
setTimeout(attemptScroll, attemptInterval);
|
||||||
|
} else {
|
||||||
|
// Give up after max attempts
|
||||||
|
pendingScrollRestoreRef.current = null;
|
||||||
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('stirling-annotations-apply', handler);
|
|
||||||
|
// Start attempting after initial delay
|
||||||
|
const timer = setTimeout(attemptScroll, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [scrollState.totalPages, scrollActions, getScrollState]);
|
||||||
|
|
||||||
|
// Register applyChanges with ViewerContext so tools can access it directly
|
||||||
|
useEffect(() => {
|
||||||
|
setApplyChanges(applyChanges);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('stirling-annotations-apply', handler);
|
setApplyChanges(null);
|
||||||
};
|
};
|
||||||
}, [applyChanges]);
|
}, [applyChanges, setApplyChanges]);
|
||||||
|
|
||||||
// Register viewer right-rail buttons
|
// Register viewer right-rail buttons
|
||||||
useViewerRightRailButtons();
|
useViewerRightRailButtons();
|
||||||
@@ -370,11 +600,14 @@ const EmbedPdfViewerContent = ({
|
|||||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||||
file={effectiveFile.file}
|
file={effectiveFile.file}
|
||||||
url={effectiveFile.url}
|
url={effectiveFile.url}
|
||||||
enableAnnotations={isAnnotationMode}
|
enableAnnotations={shouldEnableAnnotations}
|
||||||
showBakedAnnotations={isAnnotationsVisible}
|
showBakedAnnotations={isAnnotationsVisible}
|
||||||
|
enableRedaction={shouldEnableRedaction}
|
||||||
|
isManualRedactionMode={isManualRedactMode}
|
||||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||||
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
||||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||||
|
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
|
||||||
onSignatureAdded={() => {
|
onSignatureAdded={() => {
|
||||||
// Handle signature added - for debugging, enable console logs as needed
|
// Handle signature added - for debugging, enable console logs as needed
|
||||||
// Future: Handle signature completion
|
// Future: Handle signature completion
|
||||||
@@ -433,6 +666,10 @@ const EmbedPdfViewerContent = ({
|
|||||||
onApplyAndContinue={async () => {
|
onApplyAndContinue={async () => {
|
||||||
await applyChanges();
|
await applyChanges();
|
||||||
}}
|
}}
|
||||||
|
onDiscardAndContinue={async () => {
|
||||||
|
// Save applied redactions (if any) while discarding pending ones
|
||||||
|
await discardAndSaveApplied();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
|||||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||||
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
|
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
|
||||||
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
|
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
|
||||||
|
|
||||||
// Import annotation plugins
|
|
||||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||||
|
import { RedactionPluginPackage, RedactionLayer } from '@embedpdf/plugin-redaction/react';
|
||||||
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
|
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
|
||||||
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
|
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
|
||||||
import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback';
|
import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback';
|
||||||
@@ -47,20 +46,26 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
|||||||
import { isPdfFile } from '@app/utils/fileUtils';
|
import { isPdfFile } from '@app/utils/fileUtils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
||||||
|
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
|
||||||
|
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
|
||||||
|
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
|
||||||
import { absoluteWithBasePath } from '@app/constants/app';
|
import { absoluteWithBasePath } from '@app/constants/app';
|
||||||
|
|
||||||
interface LocalEmbedPDFProps {
|
interface LocalEmbedPDFProps {
|
||||||
file?: File | Blob;
|
file?: File | Blob;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
enableAnnotations?: boolean;
|
enableAnnotations?: boolean;
|
||||||
|
enableRedaction?: boolean;
|
||||||
|
isManualRedactionMode?: boolean;
|
||||||
showBakedAnnotations?: boolean;
|
showBakedAnnotations?: boolean;
|
||||||
onSignatureAdded?: (annotation: any) => void;
|
onSignatureAdded?: (annotation: any) => void;
|
||||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
||||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||||
|
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||||
@@ -125,6 +130,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
selectAfterCreate: true,
|
selectAfterCreate: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Register redaction plugin (depends on InteractionManager, Selection, History)
|
||||||
|
// Always register for redaction functionality
|
||||||
|
createPluginRegistration(RedactionPluginPackage),
|
||||||
|
|
||||||
|
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||||
|
createPluginRegistration(PanPluginPackage, {
|
||||||
|
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||||
|
}),
|
||||||
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
|
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
|
||||||
createPluginRegistration(PanPluginPackage, {}),
|
createPluginRegistration(PanPluginPackage, {}),
|
||||||
|
|
||||||
@@ -617,9 +630,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
<SearchAPIBridge />
|
<SearchAPIBridge />
|
||||||
<ThumbnailAPIBridge />
|
<ThumbnailAPIBridge />
|
||||||
<RotateAPIBridge />
|
<RotateAPIBridge />
|
||||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <HistoryAPIBridge ref={historyApiRef} />}
|
||||||
|
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
|
||||||
|
{(enableRedaction || isManualRedactionMode) && <RedactionAPIBridge />}
|
||||||
|
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
|
||||||
|
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||||
|
{(enableRedaction || isManualRedactionMode) && <RedactionPendingTracker ref={redactionTrackerRef} />}
|
||||||
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
|
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
|
||||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
|
||||||
<ExportAPIBridge />
|
<ExportAPIBridge />
|
||||||
<BookmarkAPIBridge />
|
<BookmarkAPIBridge />
|
||||||
<PrintAPIBridge />
|
<PrintAPIBridge />
|
||||||
@@ -686,6 +704,16 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
selectionOutlineColor="#007ACC"
|
selectionOutlineColor="#007ACC"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Redaction layer for marking areas to redact (only when enabled) */}
|
||||||
|
{enableRedaction && (
|
||||||
|
<RedactionLayer
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
scale={scale}
|
||||||
|
rotation={rotation}
|
||||||
|
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PagePointerProvider>
|
</PagePointerProvider>
|
||||||
</Rotate>
|
</Rotate>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { usePan } from '@embedpdf/plugin-pan/react';
|
import { usePan } from '@embedpdf/plugin-pan/react';
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
|
|
||||||
@@ -7,12 +7,15 @@ import { useViewer } from '@app/contexts/ViewerContext';
|
|||||||
*/
|
*/
|
||||||
export function PanAPIBridge() {
|
export function PanAPIBridge() {
|
||||||
const { provides: pan, isPanning } = usePan();
|
const { provides: pan, isPanning } = usePan();
|
||||||
const { registerBridge } = useViewer();
|
const { registerBridge, triggerImmediatePanUpdate } = useViewer();
|
||||||
|
|
||||||
// Store state locally
|
// Store state locally
|
||||||
const [_localState, setLocalState] = useState({
|
const [_localState, setLocalState] = useState({
|
||||||
isPanning: false
|
isPanning: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track previous isPanning value to detect changes
|
||||||
|
const prevIsPanningRef = useRef<boolean>(isPanning);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pan) {
|
if (pan) {
|
||||||
@@ -38,8 +41,14 @@ export function PanAPIBridge() {
|
|||||||
makePanDefault: () => pan.makePanDefault(),
|
makePanDefault: () => pan.makePanDefault(),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trigger immediate pan update if the value changed
|
||||||
|
if (prevIsPanningRef.current !== isPanning) {
|
||||||
|
prevIsPanningRef.current = isPanning;
|
||||||
|
triggerImmediatePanUpdate(isPanning);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [pan, isPanning]);
|
}, [pan, isPanning, triggerImmediatePanUpdate]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useImperativeHandle } from 'react';
|
||||||
|
import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react';
|
||||||
|
import { useRedaction } from '@app/contexts/RedactionContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RedactionAPIBridge connects the EmbedPDF redaction plugin to our RedactionContext.
|
||||||
|
* It must be rendered inside the EmbedPDF context to access the plugin API.
|
||||||
|
*
|
||||||
|
* It does two things:
|
||||||
|
* 1. Syncs EmbedPDF state (pendingCount, activeType, isRedacting) to our context
|
||||||
|
* 2. Exposes the EmbedPDF API through our context's ref so outside components can call it
|
||||||
|
*/
|
||||||
|
export function RedactionAPIBridge() {
|
||||||
|
const { state, provides } = useEmbedPdfRedaction();
|
||||||
|
const {
|
||||||
|
redactionApiRef,
|
||||||
|
setPendingCount,
|
||||||
|
setActiveType,
|
||||||
|
setIsRedacting,
|
||||||
|
setRedactionsApplied,
|
||||||
|
setBridgeReady
|
||||||
|
} = useRedaction();
|
||||||
|
|
||||||
|
// Mark bridge as ready on mount, not ready on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
setBridgeReady(true);
|
||||||
|
return () => {
|
||||||
|
setBridgeReady(false);
|
||||||
|
};
|
||||||
|
}, [setBridgeReady]);
|
||||||
|
|
||||||
|
// Sync EmbedPDF state to our context
|
||||||
|
useEffect(() => {
|
||||||
|
if (state) {
|
||||||
|
setPendingCount(state.pendingCount ?? 0);
|
||||||
|
setActiveType(state.activeType ?? null);
|
||||||
|
setIsRedacting(state.isRedacting ?? false);
|
||||||
|
}
|
||||||
|
}, [state?.pendingCount, state?.activeType, state?.isRedacting, setPendingCount, setActiveType, setIsRedacting]);
|
||||||
|
|
||||||
|
// Expose the EmbedPDF API through our context's ref
|
||||||
|
useImperativeHandle(redactionApiRef, () => ({
|
||||||
|
toggleRedactSelection: () => {
|
||||||
|
provides?.toggleRedactSelection();
|
||||||
|
},
|
||||||
|
toggleMarqueeRedact: () => {
|
||||||
|
provides?.toggleMarqueeRedact();
|
||||||
|
},
|
||||||
|
commitAllPending: () => {
|
||||||
|
provides?.commitAllPending();
|
||||||
|
// Don't set redactionsApplied here - it should only be set after the file is saved
|
||||||
|
// The save operation in applyChanges will handle setting/clearing this flag
|
||||||
|
},
|
||||||
|
getActiveType: () => state?.activeType ?? null,
|
||||||
|
getPendingCount: () => state?.pendingCount ?? 0,
|
||||||
|
}), [provides, state, setRedactionsApplied]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||||
|
import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react';
|
||||||
|
|
||||||
|
export interface RedactionPendingTrackerAPI {
|
||||||
|
commitAllPending: () => void;
|
||||||
|
getPendingCount: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RedactionPendingTracker monitors pending redactions and exposes an API
|
||||||
|
* for committing and checking pending redactions.
|
||||||
|
* Must be rendered inside the EmbedPDF context.
|
||||||
|
*
|
||||||
|
* Note: The unsaved changes checker is registered by EmbedPdfViewer, not here,
|
||||||
|
* to avoid conflicts and allow the viewer to check both annotations and redactions.
|
||||||
|
*/
|
||||||
|
export const RedactionPendingTracker = forwardRef<RedactionPendingTrackerAPI>(
|
||||||
|
function RedactionPendingTracker(_, ref) {
|
||||||
|
const { state, provides } = useEmbedPdfRedaction();
|
||||||
|
|
||||||
|
const pendingCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Expose API through ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
commitAllPending: () => {
|
||||||
|
if (provides?.commitAllPending) {
|
||||||
|
provides.commitAllPending();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getPendingCount: () => pendingCountRef.current,
|
||||||
|
}), [provides]);
|
||||||
|
|
||||||
|
// Update ref when pending count changes
|
||||||
|
useEffect(() => {
|
||||||
|
pendingCountRef.current = state?.pendingCount ?? 0;
|
||||||
|
}, [state?.pendingCount]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useRedaction as useEmbedPdfRedaction, SelectionMenuProps } from '@embedpdf/plugin-redaction/react';
|
||||||
|
import { ActionIcon, Tooltip, Button, Group } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import { useRedaction } from '@app/contexts/RedactionContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom menu component that appears when a pending redaction mark is selected.
|
||||||
|
* Allows users to remove or apply individual pending marks.
|
||||||
|
* Uses a portal to ensure it appears above all content, including next pages.
|
||||||
|
*/
|
||||||
|
export function RedactionSelectionMenu({ item, selected, menuWrapperProps }: SelectionMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { provides } = useEmbedPdfRedaction();
|
||||||
|
const { setRedactionsApplied } = useRedaction();
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Merge refs if menuWrapperProps has a ref
|
||||||
|
const setRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
wrapperRef.current = node;
|
||||||
|
if (menuWrapperProps?.ref) {
|
||||||
|
const ref = menuWrapperProps.ref;
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref && 'current' in ref) {
|
||||||
|
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [menuWrapperProps]);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
if (provides?.removePending && item) {
|
||||||
|
provides.removePending(item.page, item.id);
|
||||||
|
}
|
||||||
|
}, [provides, item]);
|
||||||
|
|
||||||
|
const handleApply = useCallback(() => {
|
||||||
|
if (provides?.commitPending && item) {
|
||||||
|
provides.commitPending(item.page, item.id);
|
||||||
|
// Mark redactions as applied (but not yet saved) so the Save Changes button stays enabled
|
||||||
|
// This ensures the button doesn't become disabled when pendingCount decreases
|
||||||
|
setRedactionsApplied(true);
|
||||||
|
}
|
||||||
|
}, [provides, item, setRedactionsApplied]);
|
||||||
|
|
||||||
|
// Calculate position for portal based on wrapper element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected || !item || !wrapperRef.current) {
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!wrapper) {
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
// Position menu below the wrapper, centered
|
||||||
|
// Use getBoundingClientRect which gives viewport-relative coordinates
|
||||||
|
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
|
||||||
|
setMenuPosition({
|
||||||
|
top: rect.bottom + 8,
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
// Update position on scroll/resize
|
||||||
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
|
window.addEventListener('resize', updatePosition);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
};
|
||||||
|
}, [selected, item]);
|
||||||
|
|
||||||
|
// Early return AFTER all hooks have been called
|
||||||
|
if (!selected || !item) return null;
|
||||||
|
|
||||||
|
const menuContent = menuPosition ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${menuPosition.top}px`,
|
||||||
|
left: `${menuPosition.left}px`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 10000, // Very high z-index to appear above everything
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
|
||||||
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
|
// Fixed size to prevent browser zoom affecting layout
|
||||||
|
fontSize: '14px',
|
||||||
|
minWidth: '180px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" justify="center">
|
||||||
|
<Tooltip label="Remove this mark">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
onClick={handleRemove}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: 'var(--bg-raised)',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--hover-bg)',
|
||||||
|
borderColor: 'var(--border-strong)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon style={{ fontSize: 18 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={t('redact.manual.applyWarning', '⚠️ Permanent application, cannot be undone and the data underneath will be deleted')}
|
||||||
|
withArrow
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleApply}
|
||||||
|
leftSection={<CheckCircleIcon style={{ fontSize: 16 }} />}
|
||||||
|
styles={{
|
||||||
|
root: { flexShrink: 0, whiteSpace: 'nowrap' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply (permanent)
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Extract ref from menuWrapperProps to avoid conflicts
|
||||||
|
const { ref: _, ...wrapperPropsWithoutRef } = menuWrapperProps || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={setRef}
|
||||||
|
{...wrapperPropsWithoutRef}
|
||||||
|
style={{
|
||||||
|
// Preserve the original positioning from menuWrapperProps
|
||||||
|
...(wrapperPropsWithoutRef?.style || {}),
|
||||||
|
// Override visibility to hide the wrapper (we only need its position)
|
||||||
|
visibility: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{typeof document !== 'undefined' && menuContent
|
||||||
|
? createPortal(menuContent, document.body)
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,17 +10,30 @@ import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnn
|
|||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
import { useNavigationState, useNavigationGuard } from '@app/contexts/NavigationContext';
|
||||||
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
||||||
|
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||||
|
|
||||||
export function useViewerRightRailButtons() {
|
export function useViewerRightRailButtons() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const viewer = useViewer();
|
const viewer = useViewer();
|
||||||
|
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
||||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||||
const { handleToolSelect } = useToolWorkflow();
|
const { handleToolSelect } = useToolWorkflow();
|
||||||
const { selectedTool } = useNavigationState();
|
const { selectedTool } = useNavigationState();
|
||||||
|
const { requestNavigation } = useNavigationGuard();
|
||||||
|
const { redactionsApplied, activeType: redactionActiveType } = useRedaction();
|
||||||
|
const { pendingCount } = useRedactionMode();
|
||||||
|
|
||||||
|
// Subscribe to immediate pan updates so button state stays in sync with actual pan state
|
||||||
|
// This handles cases where annotation/redaction tools disable pan mode
|
||||||
|
useEffect(() => {
|
||||||
|
return registerImmediatePanUpdate((newIsPanning) => {
|
||||||
|
setIsPanning(newIsPanning);
|
||||||
|
});
|
||||||
|
}, [registerImmediatePanUpdate]);
|
||||||
|
|
||||||
const stripBasePath = useCallback((path: string) => {
|
const stripBasePath = useCallback((path: string) => {
|
||||||
if (BASE_PATH && path.startsWith(BASE_PATH)) {
|
if (BASE_PATH && path.startsWith(BASE_PATH)) {
|
||||||
@@ -36,8 +49,15 @@ export function useViewerRightRailButtons() {
|
|||||||
|
|
||||||
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
|
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
|
||||||
|
|
||||||
|
// Update isAnnotationsActive based on selected tool
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsAnnotationsActive(isAnnotationsPath());
|
if (selectedTool === 'annotate') {
|
||||||
|
setIsAnnotationsActive(true);
|
||||||
|
} else if (selectedTool === 'redact') {
|
||||||
|
setIsAnnotationsActive(false);
|
||||||
|
} else {
|
||||||
|
setIsAnnotationsActive(isAnnotationsPath());
|
||||||
|
}
|
||||||
}, [selectedTool, isAnnotationsPath]);
|
}, [selectedTool, isAnnotationsPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,13 +69,13 @@ export function useViewerRightRailButtons() {
|
|||||||
// Lift i18n labels out of memo for clarity
|
// Lift i18n labels out of memo for clarity
|
||||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||||
const panLabel = t('rightRail.panMode', 'Pan Mode');
|
const panLabel = t('rightRail.panMode', 'Pan Mode');
|
||||||
|
const applyRedactionsLabel = t('rightRail.applyRedactionsFirst', 'Apply redactions first');
|
||||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||||
const printLabel = t('rightRail.print', 'Print PDF');
|
const printLabel = t('rightRail.print', 'Print PDF');
|
||||||
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
||||||
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
|
||||||
|
|
||||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||||
const buttons: RightRailButtonWithAction[] = [
|
const buttons: RightRailButtonWithAction[] = [
|
||||||
@@ -72,7 +92,7 @@ export function useViewerRightRailButtons() {
|
|||||||
withArrow
|
withArrow
|
||||||
shadow="md"
|
shadow="md"
|
||||||
offset={8}
|
offset={8}
|
||||||
opened={viewer.isSearchInterfaceVisible}
|
opened={isSearchInterfaceVisible}
|
||||||
onClose={viewer.searchInterfaceActions.close}
|
onClose={viewer.searchInterfaceActions.close}
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
@@ -91,7 +111,7 @@ export function useViewerRightRailButtons() {
|
|||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<div style={{ minWidth: '20rem' }}>
|
<div style={{ minWidth: '20rem' }}>
|
||||||
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
|
<SearchInterface visible={isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
|
||||||
</div>
|
</div>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -100,28 +120,17 @@ export function useViewerRightRailButtons() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'viewer-pan-mode',
|
id: 'viewer-pan-mode',
|
||||||
tooltip: panLabel,
|
icon: <LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
ariaLabel: panLabel,
|
tooltip: (!isPanning && pendingCount > 0 && redactionActiveType !== null) ? applyRedactionsLabel : panLabel,
|
||||||
|
ariaLabel: (!isPanning && pendingCount > 0 && redactionActiveType !== null) ? applyRedactionsLabel : panLabel,
|
||||||
section: 'top' as const,
|
section: 'top' as const,
|
||||||
order: 20,
|
order: 20,
|
||||||
render: ({ disabled }) => (
|
active: isPanning,
|
||||||
<Tooltip content={panLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
disabled: !isPanning && pendingCount > 0 && redactionActiveType !== null,
|
||||||
<ActionIcon
|
onClick: () => {
|
||||||
variant={isPanning ? 'default' : 'subtle'}
|
viewer.panActions.togglePan();
|
||||||
color={undefined}
|
setIsPanning(prev => !prev);
|
||||||
radius="md"
|
},
|
||||||
className="right-rail-icon"
|
|
||||||
onClick={() => {
|
|
||||||
viewer.panActions.togglePan();
|
|
||||||
setIsPanning(prev => !prev);
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
|
||||||
>
|
|
||||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'viewer-rotate-left',
|
id: 'viewer-rotate-left',
|
||||||
@@ -152,6 +161,7 @@ export function useViewerRightRailButtons() {
|
|||||||
ariaLabel: sidebarLabel,
|
ariaLabel: sidebarLabel,
|
||||||
section: 'top' as const,
|
section: 'top' as const,
|
||||||
order: 50,
|
order: 50,
|
||||||
|
active: isThumbnailSidebarVisible,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
viewer.toggleThumbnailSidebar();
|
viewer.toggleThumbnailSidebar();
|
||||||
}
|
}
|
||||||
@@ -163,6 +173,7 @@ export function useViewerRightRailButtons() {
|
|||||||
ariaLabel: bookmarkLabel,
|
ariaLabel: bookmarkLabel,
|
||||||
section: 'top' as const,
|
section: 'top' as const,
|
||||||
order: 55,
|
order: 55,
|
||||||
|
active: isBookmarkSidebarVisible,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
viewer.toggleBookmarkSidebar();
|
viewer.toggleBookmarkSidebar();
|
||||||
}
|
}
|
||||||
@@ -184,24 +195,37 @@ export function useViewerRightRailButtons() {
|
|||||||
ariaLabel: annotationsLabel,
|
ariaLabel: annotationsLabel,
|
||||||
section: 'top' as const,
|
section: 'top' as const,
|
||||||
order: 58,
|
order: 58,
|
||||||
|
active: isAnnotationsActive,
|
||||||
render: ({ disabled }) => (
|
render: ({ disabled }) => (
|
||||||
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isAnnotationsActive ? 'default' : 'subtle'}
|
variant={isAnnotationsActive ? 'filled' : 'subtle'}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="right-rail-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (disabled || isAnnotationsActive) return;
|
if (disabled || isAnnotationsActive) return;
|
||||||
const targetPath = withBasePath('/annotations');
|
|
||||||
if (window.location.pathname !== targetPath) {
|
// Check for unsaved redaction changes (pending or applied)
|
||||||
window.history.pushState(null, '', targetPath);
|
const hasRedactionChanges = pendingCount > 0 || redactionsApplied;
|
||||||
|
|
||||||
|
const switchToAnnotations = () => {
|
||||||
|
const targetPath = withBasePath('/annotations');
|
||||||
|
if (window.location.pathname !== targetPath) {
|
||||||
|
window.history.pushState(null, '', targetPath);
|
||||||
|
}
|
||||||
|
setIsAnnotationsActive(true);
|
||||||
|
handleToolSelect('annotate');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasRedactionChanges) {
|
||||||
|
requestNavigation(switchToAnnotations);
|
||||||
|
} else {
|
||||||
|
switchToAnnotations();
|
||||||
}
|
}
|
||||||
setIsAnnotationsActive(true);
|
|
||||||
handleToolSelect('annotate');
|
|
||||||
}}
|
}}
|
||||||
disabled={disabled || isAnnotationsActive}
|
disabled={disabled}
|
||||||
aria-pressed={isAnnotationsActive}
|
aria-pressed={isAnnotationsActive}
|
||||||
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
color={isAnnotationsActive ? 'blue' : undefined}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -225,9 +249,13 @@ export function useViewerRightRailButtons() {
|
|||||||
t,
|
t,
|
||||||
i18n.language,
|
i18n.language,
|
||||||
viewer,
|
viewer,
|
||||||
|
isThumbnailSidebarVisible,
|
||||||
|
isBookmarkSidebarVisible,
|
||||||
|
isSearchInterfaceVisible,
|
||||||
isPanning,
|
isPanning,
|
||||||
searchLabel,
|
searchLabel,
|
||||||
panLabel,
|
panLabel,
|
||||||
|
applyRedactionsLabel,
|
||||||
rotateLeftLabel,
|
rotateLeftLabel,
|
||||||
rotateRightLabel,
|
rotateRightLabel,
|
||||||
sidebarLabel,
|
sidebarLabel,
|
||||||
@@ -235,9 +263,10 @@ export function useViewerRightRailButtons() {
|
|||||||
printLabel,
|
printLabel,
|
||||||
tooltipPosition,
|
tooltipPosition,
|
||||||
annotationsLabel,
|
annotationsLabel,
|
||||||
saveChangesLabel,
|
|
||||||
isAnnotationsActive,
|
isAnnotationsActive,
|
||||||
handleToolSelect,
|
handleToolSelect,
|
||||||
|
pendingCount,
|
||||||
|
redactionActiveType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useRightRailButtons(viewerButtons);
|
useRightRailButtons(viewerButtons);
|
||||||
|
|||||||
@@ -230,25 +230,37 @@ export const NavigationProvider: React.FC<{
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToolSelect = useCallback((toolId: string) => {
|
const handleToolSelect = useCallback((toolId: string) => {
|
||||||
if (toolId === 'allTools') {
|
const performToolSelect = () => {
|
||||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
if (toolId === 'allTools') {
|
||||||
return;
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolId === 'read' || toolId === 'view-pdf') {
|
||||||
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the tool in the registry to get its proper workbench
|
||||||
|
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
|
||||||
|
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
|
||||||
|
|
||||||
|
// Validate toolId and convert to ToolId type
|
||||||
|
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||||
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for unsaved changes using registered checker or state
|
||||||
|
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||||
|
|
||||||
|
// If switching away from current tool and have unsaved changes, show warning
|
||||||
|
if (hasUnsavedChanges && state.selectedTool && state.selectedTool !== toolId) {
|
||||||
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performToolSelect } });
|
||||||
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||||
|
} else {
|
||||||
|
performToolSelect();
|
||||||
}
|
}
|
||||||
|
}, [toolRegistry, state.hasUnsavedChanges, state.selectedTool]);
|
||||||
if (toolId === 'read' || toolId === 'view-pdf') {
|
|
||||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the tool in the registry to get its proper workbench
|
|
||||||
|
|
||||||
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
|
|
||||||
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
|
|
||||||
|
|
||||||
// Validate toolId and convert to ToolId type
|
|
||||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
|
||||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
|
|
||||||
}, [toolRegistry]);
|
|
||||||
|
|
||||||
// Memoize the actions object to prevent unnecessary context updates
|
// Memoize the actions object to prevent unnecessary context updates
|
||||||
// This is critical to avoid infinite loops when effects depend on actions
|
// This is critical to avoid infinite loops when effects depend on actions
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
|
||||||
|
import { useNavigationGuard } from '@app/contexts/NavigationContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API interface that the EmbedPDF bridge will implement
|
||||||
|
*/
|
||||||
|
export interface RedactionAPI {
|
||||||
|
toggleRedactSelection: () => void;
|
||||||
|
toggleMarqueeRedact: () => void;
|
||||||
|
commitAllPending: () => void;
|
||||||
|
getActiveType: () => 'redactSelection' | 'marqueeRedact' | null;
|
||||||
|
getPendingCount: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State interface for redaction operations
|
||||||
|
*/
|
||||||
|
interface RedactionState {
|
||||||
|
// Current redaction configuration from the tool
|
||||||
|
redactionConfig: RedactParameters | null;
|
||||||
|
// Whether we're in redaction mode (viewer should show redaction layer)
|
||||||
|
isRedactionMode: boolean;
|
||||||
|
// Whether redactions have been applied
|
||||||
|
redactionsApplied: boolean;
|
||||||
|
// Synced state from EmbedPDF
|
||||||
|
pendingCount: number;
|
||||||
|
activeType: 'redactSelection' | 'marqueeRedact' | null;
|
||||||
|
isRedacting: boolean;
|
||||||
|
// Whether the redaction API bridge is ready (API ref is populated)
|
||||||
|
isBridgeReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions interface for redaction operations
|
||||||
|
*/
|
||||||
|
interface RedactionActions {
|
||||||
|
setRedactionConfig: (config: RedactParameters | null) => void;
|
||||||
|
setRedactionMode: (enabled: boolean) => void;
|
||||||
|
setRedactionsApplied: (applied: boolean) => void;
|
||||||
|
// Synced state setters (called from inside EmbedPDF)
|
||||||
|
setPendingCount: (count: number) => void;
|
||||||
|
setActiveType: (type: 'redactSelection' | 'marqueeRedact' | null) => void;
|
||||||
|
setIsRedacting: (isRedacting: boolean) => void;
|
||||||
|
setBridgeReady: (ready: boolean) => void;
|
||||||
|
// Actions that call through to EmbedPDF API
|
||||||
|
activateTextSelection: () => void;
|
||||||
|
activateMarquee: () => void;
|
||||||
|
commitAllPending: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined context interface
|
||||||
|
*/
|
||||||
|
interface RedactionContextValue extends RedactionState, RedactionActions {
|
||||||
|
// Ref that the bridge component will populate
|
||||||
|
redactionApiRef: React.MutableRefObject<RedactionAPI | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
const RedactionContext = createContext<RedactionContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: RedactionState = {
|
||||||
|
redactionConfig: null,
|
||||||
|
isRedactionMode: false,
|
||||||
|
redactionsApplied: false,
|
||||||
|
pendingCount: 0,
|
||||||
|
activeType: null,
|
||||||
|
isRedacting: false,
|
||||||
|
isBridgeReady: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider component for redaction functionality
|
||||||
|
* Bridges between the tool panel (outside EmbedPDF) and the viewer (inside EmbedPDF)
|
||||||
|
*/
|
||||||
|
export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<RedactionState>(initialState);
|
||||||
|
const redactionApiRef = useRef<RedactionAPI | null>(null);
|
||||||
|
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||||
|
|
||||||
|
// Actions for tool configuration
|
||||||
|
const setRedactionConfig = useCallback((config: RedactParameters | null) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
redactionConfig: config,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setRedactionMode = useCallback((enabled: boolean) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isRedactionMode: enabled,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setRedactionsApplied = useCallback((applied: boolean) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
redactionsApplied: applied,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Synced state setters (called from bridge inside EmbedPDF)
|
||||||
|
const setPendingCount = useCallback((count: number) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
pendingCount: count,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActiveType = useCallback((type: 'redactSelection' | 'marqueeRedact' | null) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
activeType: type,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setIsRedacting = useCallback((isRedacting: boolean) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isRedacting,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setBridgeReady = useCallback((ready: boolean) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isBridgeReady: ready,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep navigation guard aware of pending or applied redactions so we block navigation
|
||||||
|
// Also clear the flag when all redactions have been saved
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.pendingCount > 0 || state.redactionsApplied) {
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
} else if (state.isRedactionMode) {
|
||||||
|
// Only clear if we're in redaction mode - this avoids interfering with annotation changes
|
||||||
|
// When there are no pending redactions and nothing has been applied, we're "clean"
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}
|
||||||
|
}, [state.pendingCount, state.redactionsApplied, state.isRedactionMode, setHasUnsavedChanges]);
|
||||||
|
|
||||||
|
// Actions that call through to EmbedPDF API
|
||||||
|
const activateTextSelection = useCallback(() => {
|
||||||
|
if (redactionApiRef.current) {
|
||||||
|
redactionApiRef.current.toggleRedactSelection();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activateMarquee = useCallback(() => {
|
||||||
|
if (redactionApiRef.current) {
|
||||||
|
redactionApiRef.current.toggleMarqueeRedact();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commitAllPending = useCallback(() => {
|
||||||
|
if (redactionApiRef.current) {
|
||||||
|
redactionApiRef.current.commitAllPending();
|
||||||
|
// Mark redactions as applied (but not yet saved) so the Save Changes button stays enabled
|
||||||
|
// The button will only be disabled after the file is successfully saved
|
||||||
|
setRedactionsApplied(true);
|
||||||
|
}
|
||||||
|
}, [setRedactionsApplied]);
|
||||||
|
|
||||||
|
const contextValue: RedactionContextValue = {
|
||||||
|
...state,
|
||||||
|
redactionApiRef,
|
||||||
|
setRedactionConfig,
|
||||||
|
setRedactionMode,
|
||||||
|
setRedactionsApplied,
|
||||||
|
setPendingCount,
|
||||||
|
setActiveType,
|
||||||
|
setIsRedacting,
|
||||||
|
setBridgeReady,
|
||||||
|
activateTextSelection,
|
||||||
|
activateMarquee,
|
||||||
|
commitAllPending,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RedactionContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</RedactionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to use redaction context
|
||||||
|
*/
|
||||||
|
export const useRedaction = (): RedactionContextValue => {
|
||||||
|
const context = useContext(RedactionContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useRedaction must be used within a RedactionProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for components that need to check if redaction mode is active
|
||||||
|
*/
|
||||||
|
export const useRedactionMode = () => {
|
||||||
|
const context = useContext(RedactionContext);
|
||||||
|
return {
|
||||||
|
isRedactionModeActive: context?.isRedactionMode || false,
|
||||||
|
hasRedactionConfig: context?.redactionConfig !== null,
|
||||||
|
pendingCount: context?.pendingCount || 0,
|
||||||
|
activeType: context?.activeType || null,
|
||||||
|
isRedacting: context?.isRedacting || false,
|
||||||
|
isBridgeReady: context?.isBridgeReady || false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -117,11 +117,13 @@ interface ViewerContextType {
|
|||||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
||||||
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void;
|
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void;
|
||||||
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void;
|
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void;
|
||||||
|
registerImmediatePanUpdate: (callback: (isPanning: boolean) => void) => () => void;
|
||||||
|
|
||||||
// Internal - for bridges to trigger immediate updates
|
// Internal - for bridges to trigger immediate updates
|
||||||
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
|
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
|
||||||
triggerImmediateZoomUpdate: (zoomPercent: number) => void;
|
triggerImmediateZoomUpdate: (zoomPercent: number) => void;
|
||||||
triggerImmediateSpreadUpdate: (mode: SpreadMode, isDualPage?: boolean) => void;
|
triggerImmediateSpreadUpdate: (mode: SpreadMode, isDualPage?: boolean) => void;
|
||||||
|
triggerImmediatePanUpdate: (isPanning: boolean) => void;
|
||||||
|
|
||||||
// Action handlers - call EmbedPDF APIs directly
|
// Action handlers - call EmbedPDF APIs directly
|
||||||
scrollActions: ScrollActions;
|
scrollActions: ScrollActions;
|
||||||
@@ -140,6 +142,10 @@ interface ViewerContextType {
|
|||||||
type: K,
|
type: K,
|
||||||
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
|
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
// Save changes function - registered by EmbedPdfViewer
|
||||||
|
applyChanges: (() => Promise<void>) | null;
|
||||||
|
setApplyChanges: (fn: (() => Promise<void>) | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ViewerContext = createContext<ViewerContextType | null>(null);
|
export const ViewerContext = createContext<ViewerContextType | null>(null);
|
||||||
@@ -163,6 +169,19 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
// Bridge registry - bridges register their state and APIs here
|
// Bridge registry - bridges register their state and APIs here
|
||||||
const bridgeRefs = useRef<ViewerBridgeRegistry>(createBridgeRegistry());
|
const bridgeRefs = useRef<ViewerBridgeRegistry>(createBridgeRegistry());
|
||||||
|
|
||||||
|
// Apply changes function - registered by EmbedPdfViewer
|
||||||
|
const applyChangesRef = useRef<(() => Promise<void>) | null>(null);
|
||||||
|
|
||||||
|
const setApplyChanges = useCallback((fn: (() => Promise<void>) | null) => {
|
||||||
|
applyChangesRef.current = fn;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyChanges = useCallback(async () => {
|
||||||
|
if (applyChangesRef.current) {
|
||||||
|
await applyChangesRef.current();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register: registerImmediateZoomUpdate,
|
register: registerImmediateZoomUpdate,
|
||||||
trigger: triggerImmediateZoomInternal,
|
trigger: triggerImmediateZoomInternal,
|
||||||
@@ -175,6 +194,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
register: registerImmediateSpreadUpdate,
|
register: registerImmediateSpreadUpdate,
|
||||||
trigger: triggerImmediateSpreadInternal,
|
trigger: triggerImmediateSpreadInternal,
|
||||||
} = useImmediateNotifier<[SpreadMode, boolean]>();
|
} = useImmediateNotifier<[SpreadMode, boolean]>();
|
||||||
|
const {
|
||||||
|
register: registerImmediatePanUpdate,
|
||||||
|
trigger: triggerImmediatePanInternal,
|
||||||
|
} = useImmediateNotifier<[boolean]>();
|
||||||
|
|
||||||
const triggerImmediateZoomUpdate = useCallback(
|
const triggerImmediateZoomUpdate = useCallback(
|
||||||
(percent: number) => {
|
(percent: number) => {
|
||||||
@@ -197,6 +220,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
[triggerImmediateSpreadInternal]
|
[triggerImmediateSpreadInternal]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const triggerImmediatePanUpdate = useCallback(
|
||||||
|
(isPanning: boolean) => {
|
||||||
|
triggerImmediatePanInternal(isPanning);
|
||||||
|
},
|
||||||
|
[triggerImmediatePanInternal]
|
||||||
|
);
|
||||||
|
|
||||||
const registerBridge = useCallback(
|
const registerBridge = useCallback(
|
||||||
<K extends BridgeKey>(
|
<K extends BridgeKey>(
|
||||||
type: K,
|
type: K,
|
||||||
@@ -335,9 +365,11 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
registerImmediateZoomUpdate,
|
registerImmediateZoomUpdate,
|
||||||
registerImmediateScrollUpdate,
|
registerImmediateScrollUpdate,
|
||||||
registerImmediateSpreadUpdate,
|
registerImmediateSpreadUpdate,
|
||||||
|
registerImmediatePanUpdate,
|
||||||
triggerImmediateScrollUpdate,
|
triggerImmediateScrollUpdate,
|
||||||
triggerImmediateZoomUpdate,
|
triggerImmediateZoomUpdate,
|
||||||
triggerImmediateSpreadUpdate,
|
triggerImmediateSpreadUpdate,
|
||||||
|
triggerImmediatePanUpdate,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
scrollActions,
|
scrollActions,
|
||||||
@@ -353,6 +385,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
// Bridge registration
|
// Bridge registration
|
||||||
registerBridge,
|
registerBridge,
|
||||||
|
|
||||||
|
// Apply changes
|
||||||
|
applyChanges,
|
||||||
|
setApplyChanges,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { PdfBookmarkObject } from '@embedpdf/models';
|
import { PdfBookmarkObject } from '@embedpdf/models';
|
||||||
|
|
||||||
export interface ScrollActions {
|
export interface ScrollActions {
|
||||||
scrollToPage: (page: number) => void;
|
scrollToPage: (page: number, behavior?: 'smooth' | 'instant') => void;
|
||||||
scrollToFirstPage: () => void;
|
scrollToFirstPage: () => void;
|
||||||
scrollToPreviousPage: () => void;
|
scrollToPreviousPage: () => void;
|
||||||
scrollToNextPage: () => void;
|
scrollToNextPage: () => void;
|
||||||
@@ -97,10 +97,10 @@ export function createViewerActions({
|
|||||||
triggerImmediateZoomUpdate,
|
triggerImmediateZoomUpdate,
|
||||||
}: ViewerActionDependencies): ViewerActionsBundle {
|
}: ViewerActionDependencies): ViewerActionsBundle {
|
||||||
const scrollActions: ScrollActions = {
|
const scrollActions: ScrollActions = {
|
||||||
scrollToPage: (page: number) => {
|
scrollToPage: (page: number, behavior?: 'smooth' | 'instant') => {
|
||||||
const api = registry.current.scroll?.api;
|
const api = registry.current.scroll?.api;
|
||||||
if (api?.scrollToPage) {
|
if (api?.scrollToPage) {
|
||||||
api.scrollToPage({ pageNumber: page });
|
api.scrollToPage({ pageNumber: page, behavior: behavior || 'smooth' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollToFirstPage: () => {
|
scrollToFirstPage: () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
|||||||
import { PdfBookmarkObject } from '@embedpdf/models';
|
import { PdfBookmarkObject } from '@embedpdf/models';
|
||||||
|
|
||||||
export interface ScrollAPIWrapper {
|
export interface ScrollAPIWrapper {
|
||||||
scrollToPage: (params: { pageNumber: number }) => void;
|
scrollToPage: (params: { pageNumber: number; behavior?: ScrollBehavior }) => void;
|
||||||
scrollToPreviousPage: () => void;
|
scrollToPreviousPage: () => void;
|
||||||
scrollToNextPage: () => void;
|
scrollToNextPage: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,13 +103,14 @@ describe('buildRedactFormData', () => {
|
|||||||
expect(formData.get('convertPDFToImage')).toBe('false');
|
expect(formData.get('convertPDFToImage')).toBe('false');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw error for manual mode (not implemented)', () => {
|
test('should return empty form data for manual mode (handled client-side)', () => {
|
||||||
const parameters: RedactParameters = {
|
const parameters: RedactParameters = {
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
|
const formData = buildRedactFormData(parameters, mockFile);
|
||||||
|
expect(formData.get('fileInput')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { RedactParameters, defaultParameters } from '@app/hooks/tools/redact/use
|
|||||||
// Static configuration that can be used by both the hook and automation executor
|
// Static configuration that can be used by both the hook and automation executor
|
||||||
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
|
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("fileInput", file);
|
|
||||||
|
|
||||||
|
// For automatic mode we hit the backend and need full payload
|
||||||
if (parameters.mode === 'automatic') {
|
if (parameters.mode === 'automatic') {
|
||||||
|
formData.append("fileInput", file);
|
||||||
// Convert array to newline-separated string as expected by backend
|
// Convert array to newline-separated string as expected by backend
|
||||||
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
|
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
|
||||||
formData.append("useRegex", parameters.useRegex.toString());
|
formData.append("useRegex", parameters.useRegex.toString());
|
||||||
@@ -17,8 +18,8 @@ export const buildRedactFormData = (parameters: RedactParameters, file: File): F
|
|||||||
formData.append("customPadding", parameters.customPadding.toString());
|
formData.append("customPadding", parameters.customPadding.toString());
|
||||||
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
|
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
|
||||||
} else {
|
} else {
|
||||||
// Manual mode parameters would go here when implemented
|
// Manual redaction uses EmbedPDF in-viewer; we don't call the API.
|
||||||
throw new Error('Manual redaction not yet implemented');
|
// Return an empty formData to satisfy shared interfaces without throwing.
|
||||||
}
|
}
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
@@ -32,10 +33,9 @@ export const redactOperationConfig = {
|
|||||||
endpoint: (parameters: RedactParameters) => {
|
endpoint: (parameters: RedactParameters) => {
|
||||||
if (parameters.mode === 'automatic') {
|
if (parameters.mode === 'automatic') {
|
||||||
return '/api/v1/security/auto-redact';
|
return '/api/v1/security/auto-redact';
|
||||||
} else {
|
|
||||||
// Manual redaction endpoint would go here when implemented
|
|
||||||
throw new Error('Manual redaction not yet implemented');
|
|
||||||
}
|
}
|
||||||
|
// Manual redaction is handled by EmbedPDF in the viewer; no endpoint call.
|
||||||
|
return "";
|
||||||
},
|
},
|
||||||
defaultParameters,
|
defaultParameters,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -84,14 +84,14 @@ describe('useRedactParameters', () => {
|
|||||||
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
|
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw error for manual mode (not implemented)', () => {
|
test('should return empty endpoint for manual mode (handled client-side)', () => {
|
||||||
const { result } = renderHook(() => useRedactParameters());
|
const { result } = renderHook(() => useRedactParameters());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.updateParameter('mode', 'manual');
|
result.current.updateParameter('mode', 'manual');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
|
expect(result.current.getEndpointName()).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ export const useRedactParameters = (): RedactParametersHook => {
|
|||||||
if (params.mode === 'automatic') {
|
if (params.mode === 'automatic') {
|
||||||
return '/api/v1/security/auto-redact';
|
return '/api/v1/security/auto-redact';
|
||||||
}
|
}
|
||||||
// Manual redaction endpoint would go here when implemented
|
// Manual redaction handled client-side (validation prevents this path)
|
||||||
throw new Error('Manual redaction not yet implemented');
|
return '';
|
||||||
},
|
},
|
||||||
validateFn: (params) => {
|
validateFn: (params) => {
|
||||||
if (params.mode === 'automatic') {
|
if (params.mode === 'automatic') {
|
||||||
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
|
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
|
||||||
}
|
}
|
||||||
// Manual mode validation would go here when implemented
|
// Manual mode is not yet supported via this flow
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,27 @@ import { useAnnotationStyleState } from '@app/tools/annotate/useAnnotationStyleS
|
|||||||
import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection';
|
import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection';
|
||||||
import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel';
|
import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel';
|
||||||
|
|
||||||
|
// Tools that require drawing/interacting with the PDF and should disable pan mode
|
||||||
|
const DRAWING_TOOLS: AnnotationToolId[] = [
|
||||||
|
'highlight',
|
||||||
|
'underline',
|
||||||
|
'strikeout',
|
||||||
|
'squiggly',
|
||||||
|
'ink',
|
||||||
|
'inkHighlighter',
|
||||||
|
'text',
|
||||||
|
'note',
|
||||||
|
'square',
|
||||||
|
'circle',
|
||||||
|
'line',
|
||||||
|
'lineArrow',
|
||||||
|
'polyline',
|
||||||
|
'polygon',
|
||||||
|
'stamp',
|
||||||
|
'signatureStamp',
|
||||||
|
'signatureInk',
|
||||||
|
];
|
||||||
|
|
||||||
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
|
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
|
||||||
'select',
|
'select',
|
||||||
'highlight',
|
'highlight',
|
||||||
@@ -52,9 +73,12 @@ const Annotate = (_props: BaseToolProps) => {
|
|||||||
setPlacementPreviewSize,
|
setPlacementPreviewSize,
|
||||||
} = useSignature();
|
} = useSignature();
|
||||||
const viewerContext = useContext(ViewerContext);
|
const viewerContext = useContext(ViewerContext);
|
||||||
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
const { getZoomState, registerImmediateZoomUpdate, applyChanges, activeFileIndex, panActions } = useViewer();
|
||||||
|
|
||||||
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
|
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
|
||||||
|
|
||||||
|
// Track the previous file index to detect file switches
|
||||||
|
const prevFileIndexRef = useRef<number>(activeFileIndex);
|
||||||
const activeToolRef = useRef<AnnotationToolId>('select');
|
const activeToolRef = useRef<AnnotationToolId>('select');
|
||||||
const wasAnnotateActiveRef = useRef<boolean>(false);
|
const wasAnnotateActiveRef = useRef<boolean>(false);
|
||||||
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
|
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
|
||||||
@@ -143,9 +167,11 @@ const Annotate = (_props: BaseToolProps) => {
|
|||||||
setTextAlignment,
|
setTextAlignment,
|
||||||
} = styleActions;
|
} = styleActions;
|
||||||
|
|
||||||
const handleApplyChanges = useCallback(() => {
|
const handleApplyChanges = useCallback(async () => {
|
||||||
window.dispatchEvent(new CustomEvent('stirling-annotations-apply'));
|
if (applyChanges) {
|
||||||
}, []);
|
await applyChanges();
|
||||||
|
}
|
||||||
|
}, [applyChanges]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
|
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
|
||||||
@@ -205,6 +231,35 @@ const Annotate = (_props: BaseToolProps) => {
|
|||||||
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, toolOptions);
|
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, toolOptions);
|
||||||
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions, stampImageData, stampImageSize]);
|
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions, stampImageData, stampImageSize]);
|
||||||
|
|
||||||
|
// Reset to 'select' mode when switching between files
|
||||||
|
// The new PDF gets a fresh EmbedPDF instance - forcing user to re-select tool ensures it works properly
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFileIndexRef.current !== activeFileIndex) {
|
||||||
|
prevFileIndexRef.current = activeFileIndex;
|
||||||
|
|
||||||
|
// Reset to select mode when switching files
|
||||||
|
// This forces the user to re-select their tool, which ensures proper activation on the new PDF
|
||||||
|
if (activeTool !== 'select') {
|
||||||
|
setActiveTool('select');
|
||||||
|
activeToolRef.current = 'select';
|
||||||
|
|
||||||
|
// Clean up placement mode if we were in stamp tool
|
||||||
|
setPlacementMode(false);
|
||||||
|
setSignatureConfig(null);
|
||||||
|
|
||||||
|
// Small delay to ensure the new EmbedPDF instance is ready, then activate select mode
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (annotationApiRef?.current) {
|
||||||
|
annotationApiRef.current.deselectAnnotation?.();
|
||||||
|
annotationApiRef.current.activateAnnotationTool?.('select');
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeFileIndex, activeTool, setPlacementMode, setSignatureConfig]);
|
||||||
|
|
||||||
const activateAnnotationTool = (toolId: AnnotationToolId) => {
|
const activateAnnotationTool = (toolId: AnnotationToolId) => {
|
||||||
// If leaving stamp tool, clean up placement mode
|
// If leaving stamp tool, clean up placement mode
|
||||||
if (activeTool === 'stamp' && toolId !== 'stamp') {
|
if (activeTool === 'stamp' && toolId !== 'stamp') {
|
||||||
@@ -224,6 +279,11 @@ const Annotate = (_props: BaseToolProps) => {
|
|||||||
setSelectedAnn(null);
|
setSelectedAnn(null);
|
||||||
setSelectedAnnId(null);
|
setSelectedAnnId(null);
|
||||||
|
|
||||||
|
// Disable pan mode when activating drawing tools to avoid conflict
|
||||||
|
if (DRAWING_TOOLS.includes(toolId)) {
|
||||||
|
panActions.disablePan();
|
||||||
|
}
|
||||||
|
|
||||||
// Change the tool
|
// Change the tool
|
||||||
setActiveTool(toolId);
|
setActiveTool(toolId);
|
||||||
const options =
|
const options =
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector";
|
import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector";
|
||||||
import { useRedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
import { useRedactParameters, RedactMode } from "@app/hooks/tools/redact/useRedactParameters";
|
||||||
import { useRedactOperation } from "@app/hooks/tools/redact/useRedactOperation";
|
import { useRedactOperation } from "@app/hooks/tools/redact/useRedactOperation";
|
||||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "@app/components/tooltips/useRedactTips";
|
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips, useRedactManualTips } from "@app/components/tooltips/useRedactTips";
|
||||||
import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings";
|
import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings";
|
||||||
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
|
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
|
||||||
|
import ManualRedactionControls from "@app/components/tools/redact/ManualRedactionControls";
|
||||||
|
import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext";
|
||||||
|
import { useRedaction } from "@app/contexts/RedactionContext";
|
||||||
|
import { useFileState } from "@app/contexts/file/fileHooks";
|
||||||
|
|
||||||
const Redact = (props: BaseToolProps) => {
|
const Redact = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -18,6 +22,12 @@ const Redact = (props: BaseToolProps) => {
|
|||||||
const [wordsCollapsed, setWordsCollapsed] = useState(false);
|
const [wordsCollapsed, setWordsCollapsed] = useState(false);
|
||||||
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
||||||
|
|
||||||
|
// Navigation and redaction context
|
||||||
|
const { actions: navActions } = useNavigationActions();
|
||||||
|
const { setRedactionConfig, setRedactionMode, redactionConfig } = useRedaction();
|
||||||
|
const { workbench } = useNavigationState();
|
||||||
|
const hasOpenedViewer = useRef(false);
|
||||||
|
|
||||||
const base = useBaseTool(
|
const base = useBaseTool(
|
||||||
'redact',
|
'redact',
|
||||||
useRedactParameters,
|
useRedactParameters,
|
||||||
@@ -25,14 +35,61 @@ const Redact = (props: BaseToolProps) => {
|
|||||||
props
|
props
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get total file count from context (any files in workbench, not just selected)
|
||||||
|
const { state: fileState } = useFileState();
|
||||||
|
const hasAnyFiles = fileState.files.ids.length > 0;
|
||||||
|
|
||||||
// Tooltips for each step
|
// Tooltips for each step
|
||||||
const modeTips = useRedactModeTips();
|
const modeTips = useRedactModeTips();
|
||||||
const wordsTips = useRedactWordsTips();
|
const wordsTips = useRedactWordsTips();
|
||||||
const advancedTips = useRedactAdvancedTips();
|
const advancedTips = useRedactAdvancedTips();
|
||||||
|
const manualTips = useRedactManualTips();
|
||||||
|
|
||||||
|
// Auto-set manual mode if we're in the viewer and redaction config is set to manual
|
||||||
|
// This ensures when opening redact from viewer, it automatically selects manual mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (workbench === 'viewer' && redactionConfig?.mode === 'manual' && base.params.parameters.mode !== 'manual') {
|
||||||
|
// Set immediately when conditions are met
|
||||||
|
base.params.updateParameter('mode', 'manual');
|
||||||
|
}
|
||||||
|
}, [workbench, redactionConfig, base.params.parameters.mode, base.params.updateParameter]);
|
||||||
|
|
||||||
|
// Handle mode change - navigate to viewer when manual mode is selected
|
||||||
|
// Manual mode works with any files in workbench (not just selected files)
|
||||||
|
const handleModeChange = (mode: RedactMode) => {
|
||||||
|
base.params.updateParameter('mode', mode);
|
||||||
|
|
||||||
|
if (mode === 'manual' && hasAnyFiles) {
|
||||||
|
// Set redaction config and navigate to viewer
|
||||||
|
setRedactionConfig(base.params.parameters);
|
||||||
|
setRedactionMode(true);
|
||||||
|
navActions.setWorkbench('viewer');
|
||||||
|
hasOpenedViewer.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When files are added and in manual mode, navigate to viewer
|
||||||
|
// Uses hasAnyFiles since manual mode works with any files in workbench
|
||||||
|
useEffect(() => {
|
||||||
|
if (base.params.parameters.mode === 'manual' && hasAnyFiles && !hasOpenedViewer.current) {
|
||||||
|
setRedactionConfig(base.params.parameters);
|
||||||
|
setRedactionMode(true);
|
||||||
|
navActions.setWorkbench('viewer');
|
||||||
|
hasOpenedViewer.current = true;
|
||||||
|
}
|
||||||
|
}, [hasAnyFiles, base.params.parameters, navActions, setRedactionConfig, setRedactionMode]);
|
||||||
|
|
||||||
|
// Reset viewer flag when mode changes back to automatic
|
||||||
|
useEffect(() => {
|
||||||
|
if (base.params.parameters.mode === 'automatic') {
|
||||||
|
hasOpenedViewer.current = false;
|
||||||
|
setRedactionMode(false);
|
||||||
|
}
|
||||||
|
}, [base.params.parameters.mode, setRedactionMode]);
|
||||||
|
|
||||||
const isExecuteDisabled = () => {
|
const isExecuteDisabled = () => {
|
||||||
if (base.params.parameters.mode === 'manual') {
|
if (base.params.parameters.mode === 'manual') {
|
||||||
return true; // Manual mode not implemented yet
|
return true; // Manual mode uses viewer, not execute button
|
||||||
}
|
}
|
||||||
return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled;
|
return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled;
|
||||||
};
|
};
|
||||||
@@ -44,18 +101,24 @@ const Redact = (props: BaseToolProps) => {
|
|||||||
|
|
||||||
// Build conditional steps based on redaction mode
|
// Build conditional steps based on redaction mode
|
||||||
const buildSteps = () => {
|
const buildSteps = () => {
|
||||||
|
// Method step is always expandable (even without files selected)
|
||||||
|
// Only collapse on results or user preference
|
||||||
|
const methodStepCollapsed = base.hasResults ? true : methodCollapsed;
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
// Method selection step (always present)
|
// Method selection step (always present and always expandable)
|
||||||
{
|
{
|
||||||
title: t("redact.modeSelector.title", "Redaction Method"),
|
title: t("redact.modeSelector.title", "Redaction Method"),
|
||||||
isCollapsed: getActualCollapsedState(methodCollapsed),
|
isCollapsed: methodStepCollapsed,
|
||||||
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed),
|
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed),
|
||||||
tooltip: modeTips,
|
tooltip: modeTips,
|
||||||
content: (
|
content: (
|
||||||
<RedactModeSelector
|
<RedactModeSelector
|
||||||
mode={base.params.parameters.mode}
|
mode={base.params.parameters.mode}
|
||||||
onModeChange={(mode) => base.params.updateParameter('mode', mode)}
|
onModeChange={handleModeChange}
|
||||||
disabled={base.endpointLoading}
|
disabled={base.endpointLoading}
|
||||||
|
hasFilesSelected={base.hasFiles}
|
||||||
|
hasAnyFiles={hasAnyFiles}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -88,12 +151,23 @@ const Redact = (props: BaseToolProps) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (base.params.parameters.mode === 'manual') {
|
} else if (base.params.parameters.mode === 'manual') {
|
||||||
// Manual mode steps would go here when implemented
|
// Manual mode - show redaction controls
|
||||||
|
// Uses hasAnyFiles since manual mode works with any files in workbench (viewer-powered)
|
||||||
|
steps.push({
|
||||||
|
title: t("redact.manual.controlsTitle", "Manual Redaction Controls"),
|
||||||
|
isCollapsed: false,
|
||||||
|
onCollapsedClick: () => {},
|
||||||
|
tooltip: manualTips,
|
||||||
|
content: <ManualRedactionControls disabled={!hasAnyFiles} />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return steps;
|
return steps;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hide execute button in manual mode (redactions applied via controls)
|
||||||
|
const isManualMode = base.params.parameters.mode === 'manual';
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles: base.selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
@@ -102,7 +176,7 @@ const Redact = (props: BaseToolProps) => {
|
|||||||
steps: buildSteps(),
|
steps: buildSteps(),
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("redact.submit", "Redact"),
|
text: t("redact.submit", "Redact"),
|
||||||
isVisible: !base.hasResults,
|
isVisible: !base.hasResults && !isManualMode,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
disabled: isExecuteDisabled(),
|
disabled: isExecuteDisabled(),
|
||||||
|
|||||||
@@ -1305,7 +1305,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
|||||||
disabled={applyDisabled}
|
disabled={applyDisabled}
|
||||||
onClick={onApplyChanges}
|
onClick={onApplyChanges}
|
||||||
>
|
>
|
||||||
{t('annotation.applyChanges', 'Apply Changes')}
|
{t('annotation.saveChanges', 'Save Changes')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<SuggestedToolsSection />
|
<SuggestedToolsSection />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface RightRailRenderContext {
|
|||||||
allButtonsDisabled: boolean;
|
allButtonsDisabled: boolean;
|
||||||
action?: RightRailAction;
|
action?: RightRailAction;
|
||||||
triggerAction: () => void;
|
triggerAction: () => void;
|
||||||
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RightRailButtonConfig {
|
export interface RightRailButtonConfig {
|
||||||
@@ -35,4 +36,6 @@ export interface RightRailButtonConfig {
|
|||||||
render?: (ctx: RightRailRenderContext) => React.ReactNode;
|
render?: (ctx: RightRailRenderContext) => React.ReactNode;
|
||||||
/** Optional className applied to wrapper when using default renderer */
|
/** Optional className applied to wrapper when using default renderer */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Optional active state to highlight the control */
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user