diff --git a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx index f138dba71..676a583a2 100644 --- a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx +++ b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx @@ -307,7 +307,19 @@ export const AnnotationAPIBridge = forwardRef(function Annotation }, getSelectedAnnotation: () => { const api = annotationApi as AnnotationApiSurface | undefined; - return api?.getSelectedAnnotation?.() ?? null; + if (!api?.getSelectedAnnotation) { + return null; + } + try { + return api.getSelectedAnnotation(); + } catch (error) { + // Some EmbedPDF builds expose getSelectedAnnotation with an internal + // `this`/state dependency (e.g. reading `selectedUid` from undefined). + // If that happens, fail gracefully and treat it as "no selection" + // instead of crashing the entire annotations tool. + console.error('[AnnotationAPIBridge] getSelectedAnnotation failed:', error); + return null; + } }, deselectAnnotation: () => { const api = annotationApi as AnnotationApiSurface | undefined; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 875cecc27..19c463893 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -250,7 +250,7 @@ const EmbedPdfViewerContent = ({ unsubscribe(); } }; - }, [historyApiRef, setHasUnsavedChanges]); + }, [historyApiRef.current, setHasUnsavedChanges]); // Register checker for unsaved changes (annotations only for now) useEffect(() => { @@ -303,10 +303,20 @@ const EmbedPdfViewerContent = ({ } }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); - // Register viewer right-rail buttons (including optional Save for annotations) - useViewerRightRailButtons({ - onSaveAnnotations: applyChanges, - }); + // Expose annotation apply via a global event so tools (like Annotate) can + // trigger saves from the left sidebar without tight coupling. + useEffect(() => { + const handler = () => { + void applyChanges(); + }; + window.addEventListener('stirling-annotations-apply', handler); + return () => { + window.removeEventListener('stirling-annotations-apply', handler); + }; + }, [applyChanges]); + + // Register viewer right-rail buttons + useViewerRightRailButtons(); const sidebarWidthRem = 15; const totalRightMargin = diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 1ac341ea4..a3c749e3c 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, useRef } from 'react'; +import { useMemo, useState, useEffect, useCallback } from 'react'; import { ActionIcon, Popover } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useViewer } from '@app/contexts/ViewerContext'; @@ -13,15 +13,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState } from '@app/contexts/NavigationContext'; import { BASE_PATH, withBasePath } from '@app/constants/app'; -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) { +export function useViewerRightRailButtons() { const { t, i18n } = useTranslation(); const viewer = useViewer(); const [isPanning, setIsPanning] = useState(() => viewer.getPanState()?.isPanning ?? false); @@ -30,14 +22,6 @@ export function useViewerRightRailButtons(options?: ViewerRightRailButtonsOption 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) || '/'; @@ -239,23 +223,6 @@ export function useViewerRightRailButtons(options?: ViewerRightRailButtonsOption // 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, diff --git a/frontend/src/core/tools/Annotate.tsx b/frontend/src/core/tools/Annotate.tsx index de42ee0a1..77e392861 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 { selectedTool, workbench } = useNavigation(); + const { selectedTool, workbench, hasUnsavedChanges } = useNavigation(); const { selectedFiles } = useFileSelection(); const { signatureApiRef, @@ -143,6 +143,10 @@ const Annotate = (_props: BaseToolProps) => { setTextAlignment, } = styleActions; + const handleApplyChanges = useCallback(() => { + window.dispatchEvent(new CustomEvent('stirling-annotations-apply')); + }, []); + useEffect(() => { const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate'; if (wasAnnotateActiveRef.current && !isAnnotateActive) { @@ -371,6 +375,8 @@ const Annotate = (_props: BaseToolProps) => { undo={undo} redo={redo} historyAvailability={historyAvailability} + onApplyChanges={handleApplyChanges} + applyDisabled={!hasUnsavedChanges} /> ), }, diff --git a/frontend/src/core/tools/annotate/AnnotationPanel.tsx b/frontend/src/core/tools/annotate/AnnotationPanel.tsx index 216fe2e6d..6d6542b35 100644 --- a/frontend/src/core/tools/annotate/AnnotationPanel.tsx +++ b/frontend/src/core/tools/annotate/AnnotationPanel.tsx @@ -102,6 +102,8 @@ interface AnnotationPanelProps { undo: () => void; redo: () => void; historyAvailability: { canUndo: boolean; canRedo: boolean }; + onApplyChanges: () => void; + applyDisabled: boolean; } // AnnotationPanel component extracted from Annotate.tsx to keep the main file smaller. @@ -136,6 +138,8 @@ export function AnnotationPanel(props: AnnotationPanelProps) { undo, redo, historyAvailability, + onApplyChanges, + applyDisabled, } = props; const { @@ -1291,6 +1295,19 @@ export function AnnotationPanel(props: AnnotationPanelProps) { {colorPickerComponent} + + ); diff --git a/frontend/src/core/tools/annotate/useAnnotationSelection.ts b/frontend/src/core/tools/annotate/useAnnotationSelection.ts index 8c84cda99..fd4027daf 100644 --- a/frontend/src/core/tools/annotate/useAnnotationSelection.ts +++ b/frontend/src/core/tools/annotate/useAnnotationSelection.ts @@ -261,7 +261,19 @@ export function useAnnotationSelection({ if (!api) return; const checkSelection = () => { - const ann = api.getSelectedAnnotation?.(); + let ann: any = null; + if (typeof api.getSelectedAnnotation === 'function') { + try { + ann = api.getSelectedAnnotation(); + } catch (error) { + // Some builds of the annotation plugin can throw when reading + // internal selection state (e.g., accessing `selectedUid` on + // an undefined object). Treat this as "no current selection" + // instead of crashing the annotations tool. + console.error('[useAnnotationSelection] getSelectedAnnotation failed:', error); + ann = null; + } + } const currentId = ann?.object?.id ?? ann?.id ?? null; if (currentId !== selectedAnnIdRef.current) { applySelectionFromAnnotation(ann ?? null);