From d036444af178d28578a5684d8eddbd63ffcecedc Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 18 Dec 2025 15:14:27 +0000 Subject: [PATCH] add save button and allow user to change view while annotations tool is open --- .../core/components/viewer/EmbedPdfViewer.tsx | 55 ++++++++++----- .../viewer/useViewerRightRailButtons.tsx | 68 +++++++++++++++++-- frontend/src/core/tools/Annotate.tsx | 6 +- 3 files changed, 103 insertions(+), 26 deletions(-) diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index db530fb45..875cecc27 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -56,9 +56,6 @@ const EmbedPdfViewerContent = ({ exportActions, } = useViewer(); - // Register viewer right-rail buttons - useViewerRightRailButtons(); - const scrollState = getScrollState(); const rotationState = getRotationState(); @@ -73,6 +70,11 @@ const EmbedPdfViewerContent = ({ // Get signature and annotation contexts const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature(); + // Track whether there are unsaved annotation changes in this viewer session. + // This is our source of truth for navigation guards; it is set when the + // annotation history changes, and cleared after we successfully apply changes. + const hasAnnotationChangesRef = useRef(false); + // Get current file from FileContext const { selectors, state } = useFileState(); const { actions } = useFileActions(); @@ -225,6 +227,31 @@ const EmbedPdfViewerContent = ({ }; }, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]); + // Watch the annotation history API to detect when the document becomes "dirty". + // We treat any change that makes the history undoable as unsaved changes until + // the user explicitly applies them via applyChanges. + useEffect(() => { + const historyApi = historyApiRef.current; + if (!historyApi || !historyApi.subscribe) { + return; + } + + const updateHasChanges = () => { + const canUndo = historyApi.canUndo?.() ?? false; + if (!hasAnnotationChangesRef.current && canUndo) { + hasAnnotationChangesRef.current = true; + setHasUnsavedChanges(true); + } + }; + + const unsubscribe = historyApi.subscribe(updateHasChanges); + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [historyApiRef, setHasUnsavedChanges]); + // Register checker for unsaved changes (annotations only for now) useEffect(() => { if (previewFile) { @@ -232,39 +259,28 @@ const EmbedPdfViewerContent = ({ } const checkForChanges = () => { - // Check for annotation changes via history - const hasAnnotationChanges = historyApiRef.current?.canUndo() || false; - - console.log('[Viewer] Checking for unsaved changes:', { - hasAnnotationChanges - }); + const hasAnnotationChanges = hasAnnotationChangesRef.current; return hasAnnotationChanges; }; - console.log('[Viewer] Registering unsaved changes checker'); registerUnsavedChangesChecker(checkForChanges); return () => { - console.log('[Viewer] Unregistering unsaved changes checker'); unregisterUnsavedChangesChecker(); }; - }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); + }, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); // Apply changes - save annotations to new file version const applyChanges = useCallback(async () => { if (!currentFile || activeFileIds.length === 0) return; try { - console.log('[Viewer] Applying changes - exporting PDF with annotations'); - // Step 1: Export PDF with annotations using EmbedPDF const arrayBuffer = await exportActions.saveAsCopy(); if (!arrayBuffer) { throw new Error('Failed to export PDF'); } - console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength); - // Step 2: Convert ArrayBuffer to File const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); const filename = currentFile.name || 'document.pdf'; @@ -279,12 +295,19 @@ const EmbedPdfViewerContent = ({ // Step 4: Consume files (replace in context) await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); + // Mark annotations as saved so navigation away from the viewer is allowed. + hasAnnotationChangesRef.current = false; setHasUnsavedChanges(false); } catch (error) { console.error('Apply changes failed:', error); } }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); + // Register viewer right-rail buttons (including optional Save for annotations) + useViewerRightRailButtons({ + onSaveAnnotations: applyChanges, + }); + const sidebarWidthRem = 15; const totalRightMargin = (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0); diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 5c5adb4e9..1ac341ea4 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { ActionIcon, Popover } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useViewer } from '@app/contexts/ViewerContext'; @@ -13,7 +13,15 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState } from '@app/contexts/NavigationContext'; import { BASE_PATH, withBasePath } from '@app/constants/app'; -export function useViewerRightRailButtons() { +interface ViewerRightRailButtonsOptions { + /** + * Optional handler to save annotation changes to a new PDF version. + * When provided, a Save button will be shown in the viewer right rail. + */ + onSaveAnnotations?: () => void | Promise; +} + +export function useViewerRightRailButtons(options?: ViewerRightRailButtonsOptions) { const { t, i18n } = useTranslation(); const viewer = useViewer(); const [isPanning, setIsPanning] = useState(() => viewer.getPanState()?.isPanning ?? false); @@ -22,6 +30,14 @@ export function useViewerRightRailButtons() { const { handleToolSelect } = useToolWorkflow(); const { selectedTool } = useNavigationState(); + // Keep the latest save handler in a ref to avoid re-registering right-rail + // buttons on every render when the callback identity changes. + const saveAnnotationsRef = useRef<(() => void | Promise) | undefined>(undefined); + + useEffect(() => { + saveAnnotationsRef.current = options?.onSaveAnnotations; + }, [options?.onSaveAnnotations]); + const stripBasePath = useCallback((path: string) => { if (BASE_PATH && path.startsWith(BASE_PATH)) { return path.slice(BASE_PATH.length) || '/'; @@ -55,9 +71,13 @@ export function useViewerRightRailButtons() { 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(() => { - return [ + const exportState = viewer.getExportState(); + const canExport = Boolean(exportState?.canExport); + + const buttons: RightRailButtonWithAction[] = [ { id: 'viewer-search', tooltip: searchLabel, @@ -214,9 +234,47 @@ export function useViewerRightRailButtons() { render: ({ disabled }) => ( ) - } + }, ]; - }, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition, annotationsLabel, isAnnotationsActive, handleToolSelect]); + + // Optional: Save button for annotations (always registered when this hook is used + // with a save handler; uses a ref to avoid infinite re-registration loops). + buttons.push({ + id: 'viewer-save-annotations', + icon: , + tooltip: saveChangesLabel, + ariaLabel: saveChangesLabel, + section: 'top' as const, + order: 59, + disabled: !canExport, + visible: true, + onClick: () => { + const handler = saveAnnotationsRef.current; + if (handler) { + void handler(); + } + }, + }); + + return buttons; + }, [ + t, + i18n.language, + viewer, + isPanning, + searchLabel, + panLabel, + rotateLeftLabel, + rotateRightLabel, + sidebarLabel, + bookmarkLabel, + printLabel, + tooltipPosition, + annotationsLabel, + saveChangesLabel, + isAnnotationsActive, + handleToolSelect, + ]); useRightRailButtons(viewerButtons); } diff --git a/frontend/src/core/tools/Annotate.tsx b/frontend/src/core/tools/Annotate.tsx index 329a06988..de42ee0a1 100644 --- a/frontend/src/core/tools/Annotate.tsx +++ b/frontend/src/core/tools/Annotate.tsx @@ -38,7 +38,7 @@ const isKnownAnnotationTool = (toolId: string | undefined | null): toolId is Ann const Annotate = (_props: BaseToolProps) => { const { t } = useTranslation(); - const { setToolAndWorkbench, selectedTool, workbench } = useNavigation(); + const { selectedTool, workbench } = useNavigation(); const { selectedFiles } = useFileSelection(); const { signatureApiRef, @@ -143,10 +143,6 @@ const Annotate = (_props: BaseToolProps) => { setTextAlignment, } = styleActions; - useEffect(() => { - setToolAndWorkbench('annotate', 'viewer'); - }, [setToolAndWorkbench]); - useEffect(() => { const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate'; if (wasAnnotateActiveRef.current && !isAnnotateActive) {