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,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 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 { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
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 NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
@@ -46,14 +48,15 @@ const EmbedPdfViewerContent = ({
|
||||
isSearchInterfaceVisible,
|
||||
searchInterfaceActions,
|
||||
zoomActions,
|
||||
scrollActions,
|
||||
panActions: _panActions,
|
||||
rotationActions: _rotationActions,
|
||||
getScrollState,
|
||||
getRotationState,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
isAnnotationsVisible,
|
||||
exportActions,
|
||||
setApplyChanges,
|
||||
} = useViewer();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
@@ -75,6 +78,21 @@ const EmbedPdfViewerContent = ({
|
||||
// annotation history changes, and cleared after we successfully apply changes.
|
||||
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
|
||||
const { selectors, state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
@@ -85,16 +103,61 @@ const EmbedPdfViewerContent = ({
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
|
||||
// Check if we're in an annotation tool
|
||||
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 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(() => {
|
||||
setAnnotationMode(isInAnnotationTool);
|
||||
}, [isInAnnotationTool, setAnnotationMode]);
|
||||
if (scrollState.currentPage > 0) {
|
||||
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(
|
||||
isInAnnotationTool && isPlacementMode && signatureConfig
|
||||
);
|
||||
@@ -124,6 +187,20 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [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
|
||||
const currentFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
@@ -259,8 +336,31 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
|
||||
const checkForChanges = () => {
|
||||
// Check for annotation history changes (using ref that's updated by useEffect)
|
||||
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);
|
||||
@@ -268,13 +368,34 @@ const EmbedPdfViewerContent = ({
|
||||
return () => {
|
||||
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 () => {
|
||||
if (!currentFile || activeFileIds.length === 0) return;
|
||||
|
||||
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
|
||||
const arrayBuffer = await exportActions.saveAsCopy();
|
||||
if (!arrayBuffer) {
|
||||
@@ -287,33 +408,142 @@ const EmbedPdfViewerContent = ({
|
||||
const file = new File([blob], filename, { type: 'application/pdf' });
|
||||
|
||||
// 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');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
// Store the page to restore after file replacement triggers re-render
|
||||
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.
|
||||
hasAnnotationChangesRef.current = false;
|
||||
setHasUnsavedChanges(false);
|
||||
setRedactionsApplied(false);
|
||||
} catch (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
|
||||
// trigger saves from the left sidebar without tight coupling.
|
||||
// Discard pending redactions but save already-applied ones
|
||||
// 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(() => {
|
||||
const handler = () => {
|
||||
void applyChanges();
|
||||
if (pendingScrollRestoreRef.current === null) return;
|
||||
|
||||
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 () => {
|
||||
window.removeEventListener('stirling-annotations-apply', handler);
|
||||
setApplyChanges(null);
|
||||
};
|
||||
}, [applyChanges]);
|
||||
}, [applyChanges, setApplyChanges]);
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
@@ -370,11 +600,14 @@ const EmbedPdfViewerContent = ({
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
url={effectiveFile.url}
|
||||
enableAnnotations={isAnnotationMode}
|
||||
enableAnnotations={shouldEnableAnnotations}
|
||||
showBakedAnnotations={isAnnotationsVisible}
|
||||
enableRedaction={shouldEnableRedaction}
|
||||
isManualRedactionMode={isManualRedactMode}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
|
||||
onSignatureAdded={() => {
|
||||
// Handle signature added - for debugging, enable console logs as needed
|
||||
// Future: Handle signature completion
|
||||
@@ -433,6 +666,10 @@ const EmbedPdfViewerContent = ({
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
onDiscardAndContinue={async () => {
|
||||
// Save applied redactions (if any) while discarding pending ones
|
||||
await discardAndSaveApplied();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -21,11 +21,10 @@ import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
|
||||
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { RedactionPluginPackage, RedactionLayer } from '@embedpdf/plugin-redaction/react';
|
||||
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
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';
|
||||
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
enableAnnotations?: boolean;
|
||||
enableRedaction?: boolean;
|
||||
isManualRedactionMode?: boolean;
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
||||
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 [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
@@ -125,6 +130,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
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
|
||||
createPluginRegistration(PanPluginPackage, {}),
|
||||
|
||||
@@ -617,9 +630,14 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
<SearchAPIBridge />
|
||||
<ThumbnailAPIBridge />
|
||||
<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 && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
|
||||
<ExportAPIBridge />
|
||||
<BookmarkAPIBridge />
|
||||
<PrintAPIBridge />
|
||||
@@ -686,6 +704,16 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
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>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { usePan } from '@embedpdf/plugin-pan/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
@@ -7,12 +7,15 @@ import { useViewer } from '@app/contexts/ViewerContext';
|
||||
*/
|
||||
export function PanAPIBridge() {
|
||||
const { provides: pan, isPanning } = usePan();
|
||||
const { registerBridge } = useViewer();
|
||||
const { registerBridge, triggerImmediatePanUpdate } = useViewer();
|
||||
|
||||
// Store state locally
|
||||
const [_localState, setLocalState] = useState({
|
||||
isPanning: false
|
||||
});
|
||||
|
||||
// Track previous isPanning value to detect changes
|
||||
const prevIsPanningRef = useRef<boolean>(isPanning);
|
||||
|
||||
useEffect(() => {
|
||||
if (pan) {
|
||||
@@ -38,8 +41,14 @@ export function PanAPIBridge() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
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 { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const viewer = useViewer();
|
||||
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
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) => {
|
||||
if (BASE_PATH && path.startsWith(BASE_PATH)) {
|
||||
@@ -36,8 +49,15 @@ export function useViewerRightRailButtons() {
|
||||
|
||||
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
|
||||
|
||||
// Update isAnnotationsActive based on selected tool
|
||||
useEffect(() => {
|
||||
setIsAnnotationsActive(isAnnotationsPath());
|
||||
if (selectedTool === 'annotate') {
|
||||
setIsAnnotationsActive(true);
|
||||
} else if (selectedTool === 'redact') {
|
||||
setIsAnnotationsActive(false);
|
||||
} else {
|
||||
setIsAnnotationsActive(isAnnotationsPath());
|
||||
}
|
||||
}, [selectedTool, isAnnotationsPath]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,13 +69,13 @@ export function useViewerRightRailButtons() {
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||
const panLabel = t('rightRail.panMode', 'Pan Mode');
|
||||
const applyRedactionsLabel = t('rightRail.applyRedactionsFirst', 'Apply redactions first');
|
||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||
const printLabel = t('rightRail.print', 'Print PDF');
|
||||
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
||||
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
const buttons: RightRailButtonWithAction[] = [
|
||||
@@ -72,7 +92,7 @@ export function useViewerRightRailButtons() {
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
opened={viewer.isSearchInterfaceVisible}
|
||||
opened={isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
>
|
||||
<Popover.Target>
|
||||
@@ -91,7 +111,7 @@ export function useViewerRightRailButtons() {
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
|
||||
<SearchInterface visible={isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
@@ -100,28 +120,17 @@ export function useViewerRightRailButtons() {
|
||||
},
|
||||
{
|
||||
id: 'viewer-pan-mode',
|
||||
tooltip: panLabel,
|
||||
ariaLabel: panLabel,
|
||||
icon: <LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: (!isPanning && pendingCount > 0 && redactionActiveType !== null) ? applyRedactionsLabel : panLabel,
|
||||
ariaLabel: (!isPanning && pendingCount > 0 && redactionActiveType !== null) ? applyRedactionsLabel : panLabel,
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={panLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanning ? 'default' : 'subtle'}
|
||||
color={undefined}
|
||||
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>
|
||||
)
|
||||
active: isPanning,
|
||||
disabled: !isPanning && pendingCount > 0 && redactionActiveType !== null,
|
||||
onClick: () => {
|
||||
viewer.panActions.togglePan();
|
||||
setIsPanning(prev => !prev);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-left',
|
||||
@@ -152,6 +161,7 @@ export function useViewerRightRailButtons() {
|
||||
ariaLabel: sidebarLabel,
|
||||
section: 'top' as const,
|
||||
order: 50,
|
||||
active: isThumbnailSidebarVisible,
|
||||
onClick: () => {
|
||||
viewer.toggleThumbnailSidebar();
|
||||
}
|
||||
@@ -163,6 +173,7 @@ export function useViewerRightRailButtons() {
|
||||
ariaLabel: bookmarkLabel,
|
||||
section: 'top' as const,
|
||||
order: 55,
|
||||
active: isBookmarkSidebarVisible,
|
||||
onClick: () => {
|
||||
viewer.toggleBookmarkSidebar();
|
||||
}
|
||||
@@ -184,24 +195,37 @@ export function useViewerRightRailButtons() {
|
||||
ariaLabel: annotationsLabel,
|
||||
section: 'top' as const,
|
||||
order: 58,
|
||||
active: isAnnotationsActive,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isAnnotationsActive ? 'default' : 'subtle'}
|
||||
variant={isAnnotationsActive ? 'filled' : 'subtle'}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
if (disabled || isAnnotationsActive) return;
|
||||
const targetPath = withBasePath('/annotations');
|
||||
if (window.location.pathname !== targetPath) {
|
||||
window.history.pushState(null, '', targetPath);
|
||||
|
||||
// Check for unsaved redaction changes (pending or applied)
|
||||
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}
|
||||
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
||||
color={isAnnotationsActive ? 'blue' : undefined}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
@@ -225,9 +249,13 @@ export function useViewerRightRailButtons() {
|
||||
t,
|
||||
i18n.language,
|
||||
viewer,
|
||||
isThumbnailSidebarVisible,
|
||||
isBookmarkSidebarVisible,
|
||||
isSearchInterfaceVisible,
|
||||
isPanning,
|
||||
searchLabel,
|
||||
panLabel,
|
||||
applyRedactionsLabel,
|
||||
rotateLeftLabel,
|
||||
rotateRightLabel,
|
||||
sidebarLabel,
|
||||
@@ -235,9 +263,10 @@ export function useViewerRightRailButtons() {
|
||||
printLabel,
|
||||
tooltipPosition,
|
||||
annotationsLabel,
|
||||
saveChangesLabel,
|
||||
isAnnotationsActive,
|
||||
handleToolSelect,
|
||||
pendingCount,
|
||||
redactionActiveType,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
|
||||
Reference in New Issue
Block a user