From 55074656a1f341727ae8f825e81d3df93ddafe19 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 2 Oct 2025 17:40:17 +0100 Subject: [PATCH] Save viewer annotaitons on nav --- .../src/components/viewer/EmbedPdfViewer.tsx | 114 ++++++++++++++++-- frontend/src/contexts/NavigationContext.tsx | 47 +++++++- 2 files changed, 145 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index f8a7102fa..95251315b 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -1,16 +1,18 @@ -import React from 'react'; +import React, { useCallback, useState, useEffect, useRef } from 'react'; import { Box, Center, Text, ActionIcon } from '@mantine/core'; import { useMantineTheme, useMantineColorScheme } from '@mantine/core'; import CloseIcon from '@mui/icons-material/Close'; -import { useFileState } from "../../contexts/FileContext"; +import { useFileState, useFileActions } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { useViewer } from "../../contexts/ViewerContext"; import { LocalEmbedPDF } from './LocalEmbedPDF'; import { PdfViewerToolbar } from './PdfViewerToolbar'; import { ThumbnailSidebar } from './ThumbnailSidebar'; -import { useNavigationState } from '../../contexts/NavigationContext'; +import { useNavigationGuard, useNavigationState } from '../../contexts/NavigationContext'; import { useSignature } from '../../contexts/SignatureContext'; +import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers'; +import NavigationWarningModal from '../shared/NavigationWarningModal'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -29,11 +31,34 @@ const EmbedPdfViewerContent = ({ const { colorScheme: _colorScheme } = useMantineColorScheme(); const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer(); + const [saveLoading, setSaveLoading] = useState(false); + + const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); const scrollState = getScrollState(); const zoomState = getZoomState(); const spreadState = getSpreadState(); + const rotationState = getRotationState(); + + // Track initial rotation to detect changes + const initialRotationRef = useRef(null); + useEffect(() => { + if (initialRotationRef.current === null && rotationState.rotation !== undefined) { + initialRotationRef.current = rotationState.rotation; + } + }, [rotationState.rotation]); + + // Get signature context + const { signatureApiRef, historyApiRef } = useSignature(); + + // Get current file from FileContext + const { selectors } = useFileState(); + const { actions } = useFileActions(); + const activeFiles = selectors.getFiles(); + const activeFileIds = activeFiles.map(f => f.fileId); + + // Navigation guard for unsaved changes + const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); // Check if we're in signature mode OR viewer annotation mode const { selectedTool } = useNavigationState(); @@ -42,13 +67,6 @@ const EmbedPdfViewerContent = ({ // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; - // Get signature context - const { signatureApiRef, historyApiRef } = useSignature(); - - // Get current file from FileContext - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); - // Determine which file to display const currentFile = React.useMemo(() => { if (previewFile) { @@ -134,6 +152,71 @@ const EmbedPdfViewerContent = ({ }; }, [isViewerHovered]); + // Register checker for unsaved changes (annotations only for now) + useEffect(() => { + if (previewFile) { + return; + } + + const checkForChanges = () => { + // Check for annotation changes via history + const hasAnnotationChanges = historyApiRef.current?.canUndo() || false; + + console.log('[Viewer] Checking for unsaved changes:', { + hasAnnotationChanges + }); + return hasAnnotationChanges; + }; + + console.log('[Viewer] Registering unsaved changes checker'); + registerUnsavedChangesChecker(checkForChanges); + + return () => { + console.log('[Viewer] Unregistering unsaved changes checker'); + unregisterUnsavedChangesChecker(); + }; + }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); + + // Apply changes - save annotations/rotations to new file version + const applyChanges = useCallback(async () => { + if (!currentFile || activeFileIds.length === 0) return; + + setSaveLoading(true); + try { + console.log('[Viewer] Applying changes - exporting PDF with rotations:', rotationState.rotation); + + // Step 1: Export PDF with annotations/rotations 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'; + const file = new File([blob], filename, { type: 'application/pdf' }); + + // Step 3: Create StirlingFiles and stubs for version history + const parentStub = selectors.getStirlingFileStub(activeFileIds[0]); + if (!parentStub) throw new Error('Parent stub not found'); + + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool'); + + // Step 4: Consume files (replace in context) + await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); + + // Step 5: Reset initial rotation after successful save + initialRotationRef.current = rotationState.rotation; + + setHasUnsavedChanges(false); + setSaveLoading(false); + } catch (error) { + console.error('Apply changes failed:', error); + setSaveLoading(false); + } + }, [currentFile, activeFileIds, rotationState.rotation, exportActions, actions, selectors, setHasUnsavedChanges]); return ( + + {/* Navigation Warning Modal */} + {!previewFile && ( + { + await applyChanges(); + }} + /> + )} ); }; diff --git a/frontend/src/contexts/NavigationContext.tsx b/frontend/src/contexts/NavigationContext.tsx index b71664b6b..3d9b4849c 100644 --- a/frontend/src/contexts/NavigationContext.tsx +++ b/frontend/src/contexts/NavigationContext.tsx @@ -74,6 +74,8 @@ export interface NavigationContextActions { setSelectedTool: (toolId: ToolId | null) => void; setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; + registerUnsavedChangesChecker: (checker: () => boolean) => void; + unregisterUnsavedChangesChecker: () => void; showNavigationWarning: (show: boolean) => void; requestNavigation: (navigationFn: () => void) => void; confirmNavigation: () => void; @@ -106,11 +108,29 @@ export const NavigationProvider: React.FC<{ }> = ({ children }) => { const [state, dispatch] = useReducer(navigationReducer, initialState); const toolRegistry = useFlatToolRegistry(); + const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null); const actions: NavigationContextActions = { setWorkbench: useCallback((workbench: WorkbenchType) => { - // If we're leaving pageEditor workbench and have unsaved changes, request navigation - if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) { + // Check for unsaved changes using registered checker or state + const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; + console.log('[NavigationContext] setWorkbench:', { + from: state.workbench, + to: workbench, + hasChecker: !!unsavedChangesCheckerRef.current, + hasUnsavedChanges + }); + + // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + const leavingWorkbenchWithChanges = + (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + + if (leavingWorkbenchWithChanges) { + // Update state to reflect unsaved changes so modal knows + if (!state.hasUnsavedChanges) { + dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } }); + } const performWorkbenchChange = () => { dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); }; @@ -126,8 +146,15 @@ export const NavigationProvider: React.FC<{ }, []), setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => { - // If we're leaving pageEditor workbench and have unsaved changes, request navigation - if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) { + // Check for unsaved changes using registered checker or state + const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; + + // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + const leavingWorkbenchWithChanges = + (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + + if (leavingWorkbenchWithChanges) { const performWorkbenchChange = () => { dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } }); }; @@ -142,6 +169,14 @@ export const NavigationProvider: React.FC<{ dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []), + registerUnsavedChangesChecker: useCallback((checker: () => boolean) => { + unsavedChangesCheckerRef.current = checker; + }, []), + + unregisterUnsavedChangesChecker: useCallback(() => { + unsavedChangesCheckerRef.current = null; + }, []), + showNavigationWarning: useCallback((show: boolean) => { dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } }); }, []), @@ -254,6 +289,8 @@ export const useNavigationGuard = () => { confirmNavigation: actions.confirmNavigation, cancelNavigation: actions.cancelNavigation, setHasUnsavedChanges: actions.setHasUnsavedChanges, - setShowNavigationWarning: actions.showNavigationWarning + setShowNavigationWarning: actions.showNavigationWarning, + registerUnsavedChangesChecker: actions.registerUnsavedChangesChecker, + unregisterUnsavedChangesChecker: actions.unregisterUnsavedChangesChecker }; };