mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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:
@@ -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";
|
||||
|
||||
export interface ButtonOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled)
|
||||
}
|
||||
|
||||
interface ButtonSelectorProps<T> {
|
||||
@@ -39,33 +40,46 @@ const ButtonSelector = <T extends string | number>({
|
||||
|
||||
{/* Buttons */}
|
||||
<Group gap='4px'>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={value === option.value ? 'filled' : 'outline'}
|
||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled || option.disabled}
|
||||
className={buttonClassName}
|
||||
style={{
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
height: 'auto',
|
||||
minHeight: '2.5rem',
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
lineHeight: '1.4',
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<FitText
|
||||
text={option.label}
|
||||
lines={1}
|
||||
minimumFontScale={0.5}
|
||||
fontSize={10}
|
||||
className={textClassName}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
{options.map((option) => {
|
||||
const isDisabled = disabled || option.disabled;
|
||||
const button = (
|
||||
<Button
|
||||
variant={value === option.value ? 'filled' : 'outline'}
|
||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={isDisabled}
|
||||
className={buttonClassName}
|
||||
style={{
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
height: 'auto',
|
||||
minHeight: '2.5rem',
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
lineHeight: '1.4',
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<FitText
|
||||
text={option.label}
|
||||
lines={1}
|
||||
minimumFontScale={0.5}
|
||||
fontSize={10}
|
||||
className={textClassName}
|
||||
/>
|
||||
</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>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,16 @@ import { useTranslation } from "react-i18next";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => 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 { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
@@ -19,7 +22,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
||||
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);
|
||||
confirmNavigation();
|
||||
};
|
||||
@@ -32,14 +39,15 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const _handleExportAndContinue = async () => {
|
||||
const handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
const BUTTON_WIDTH = "10rem";
|
||||
|
||||
const BUTTON_WIDTH = "12rem";
|
||||
|
||||
// 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
|
||||
@@ -56,6 +64,7 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
zIndex={Z_INDEX_TOAST}
|
||||
>
|
||||
<Stack>
|
||||
<Stack ta="center" p="md">
|
||||
@@ -83,6 +92,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
{onExportAndContinue && (
|
||||
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("exportAndContinue", "Export & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -99,6 +113,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
{onExportAndContinue && (
|
||||
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("exportAndContinue", "Export & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function RightRail() {
|
||||
(btn: RightRailButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
|
||||
const isActive = Boolean(btn.active);
|
||||
|
||||
const triggerAction = () => {
|
||||
if (!disabled) action?.();
|
||||
@@ -103,6 +104,7 @@ export default function RightRail() {
|
||||
allButtonsDisabled,
|
||||
action,
|
||||
triggerAction,
|
||||
active: isActive,
|
||||
};
|
||||
return btn.render(context) ?? null;
|
||||
}
|
||||
@@ -114,12 +116,15 @@ export default function RightRail() {
|
||||
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
|
||||
const buttonNode = (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant={isActive ? 'filled' : 'subtle'}
|
||||
color={isActive ? 'blue' : undefined}
|
||||
radius="md"
|
||||
className={className}
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={isActive ? true : undefined}
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -86,6 +86,7 @@ interface RightRailButtonWithAction {
|
||||
id: string; // Unique identifier
|
||||
icon?: React.ReactNode; // Icon component (omit when using render)
|
||||
tooltip?: React.ReactNode; // Hover tooltip / description
|
||||
active?: boolean; // Optional active state for highlight
|
||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||
order?: number; // Sort order (default: 0)
|
||||
disabled?: boolean; // Disabled state (default: false)
|
||||
@@ -100,6 +101,7 @@ interface RightRailRenderContext {
|
||||
allButtonsDisabled: boolean;
|
||||
action?: () => void;
|
||||
triggerAction: () => void;
|
||||
active: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
color: var(--right-rail-icon);
|
||||
}
|
||||
|
||||
.right-rail-icon[data-active="true"] {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.right-rail-icon[aria-disabled="true"],
|
||||
.right-rail-icon[disabled] {
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
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 { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
import { useRedactionMode, useRedaction } from '@app/contexts/RedactionContext';
|
||||
import { defaultParameters, RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
|
||||
|
||||
interface ViewerAnnotationControlsProps {
|
||||
currentView: string;
|
||||
@@ -16,47 +22,162 @@ interface ViewerAnnotationControlsProps {
|
||||
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
|
||||
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
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 { actions: navActions } = useNavigationActions();
|
||||
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
|
||||
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
|
||||
|
||||
// Check if we're on annotate tool to highlight the button
|
||||
const isAnnotateActive = selectedTool === 'annotate';
|
||||
const annotationsHidden = viewerContext ? !viewerContext.isAnnotationsVisible : false;
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
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 (
|
||||
<>
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
{/* Redaction Mode Toggle */}
|
||||
<Tooltip content={isRedactMode ? t('rightRail.exitRedaction', 'Exit Redaction Mode') : t('rightRail.redact', 'Redact')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isAnnotateActive ? "filled" : "subtle"}
|
||||
color="blue"
|
||||
variant={isRedactMode ? 'filled' : 'subtle'}
|
||||
color={isRedactMode ? 'blue' : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
|
||||
onClick={handleRedactionToggle}
|
||||
disabled={disabled || currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
icon="scan-delete-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user