diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index f3c7a77ae5..dba8fa41cc 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -1613,6 +1613,17 @@ changeColor = "Change Colour" chooseColor = "Choose colour" circle = "Circle" clearBackground = "Remove background" +clearDocumentAnnotations = "Clear all annotations" +clearDocumentAnnotationsConfirm = "Clear all" +clearDocumentAnnotationsDescription = "This removes all annotations currently loaded in the editor. Please save changes to persist this in the PDF." +clearDocumentAnnotationsEmptyBody = "There are no annotations currently loaded in the editor." +clearDocumentAnnotationsEmptyTitle = "No annotations to clear" +clearDocumentAnnotationsErrorTitle = "Could not clear annotations" +clearDocumentAnnotationsSuccessBody = "Please save changes to persist the annotation removal in the PDF." +clearDocumentAnnotationsSuccessTitle = "All annotations cleared" +clearDocumentAnnotationsTitle = "Clear all annotations?" +clearDocumentAnnotationsUnavailableBody = "The annotation layer is still loading. Try again in a moment." +clearDocumentAnnotationsUnavailableTitle = "Annotations are not ready" color = "Colour" comment = "Comment" comments = "Comments" @@ -1630,6 +1641,7 @@ imagePreview = "Preview" insertText = "Insert Text" line = "Line" lineArrow = "Arrow" +moreActions = "More actions" noBackground = "No background" note = "Note" notesStamps = "Notes & Stamps" @@ -7981,6 +7993,8 @@ onlyPdfSupported = "This file format is not supported for preview." pageNavigation = "Page navigation" previousPage = "Previous Page" resetZoom = "Reset zoom" +saveChangesErrorBody = "The document could not be saved. Try again." +saveChangesErrorTitle = "Could not save changes" singlePageView = "Single Page View" unknownFile = "Unknown file" view = "View" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 14768a3208..5440077934 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -1637,6 +1637,17 @@ changeColor = "Change Color" chooseColor = "Choose color" circle = "Circle" clearBackground = "Remove background" +clearDocumentAnnotations = "Clear all annotations" +clearDocumentAnnotationsConfirm = "Clear all" +clearDocumentAnnotationsDescription = "This removes all annotations currently loaded in the editor. Please save changes to persist this in the PDF." +clearDocumentAnnotationsEmptyBody = "There are no annotations currently loaded in the editor." +clearDocumentAnnotationsEmptyTitle = "No annotations to clear" +clearDocumentAnnotationsErrorTitle = "Could not clear annotations" +clearDocumentAnnotationsSuccessBody = "Please save changes to persist the annotation removal in the PDF." +clearDocumentAnnotationsSuccessTitle = "All annotations cleared" +clearDocumentAnnotationsTitle = "Clear all annotations?" +clearDocumentAnnotationsUnavailableBody = "The annotation layer is still loading. Try again in a moment." +clearDocumentAnnotationsUnavailableTitle = "Annotations are not ready" color = "Color" comment = "Comment" comments = "Comments" @@ -1654,6 +1665,7 @@ imagePreview = "Preview" insertText = "Insert Text" line = "Line" lineArrow = "Arrow" +moreActions = "More actions" noBackground = "No background" note = "Note" notesStamps = "Notes & Stamps" @@ -8005,6 +8017,8 @@ onlyPdfSupported = "This file format is not supported for preview." pageNavigation = "Page navigation" previousPage = "Previous Page" resetZoom = "Reset zoom" +saveChangesErrorBody = "The document could not be saved. Try again." +saveChangesErrorTitle = "Could not save changes" singlePageView = "Single Page View" unknownFile = "Unknown file" view = "View" @@ -8026,6 +8040,10 @@ addComment = "Add comment" addCommentPlaceholder = "Add comment..." addLink = "Add link" addReplyPlaceholder = "Add reply..." +cancelClearAll = "Cancel" +clearAll = "Clear all comments" +clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document." +clearAllTitle = "Clear all comments?" deleteAnnotationAndComment = "Delete annotation & comment" deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything." deleteTitle = "Remove annotation from comments?" diff --git a/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx b/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx index 4176d4862a..9917f37ac5 100644 --- a/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx +++ b/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx @@ -48,10 +48,14 @@ const NavigationWarningModal = () => { const handleApplyAndContinue = async () => { const handlers = navigationWarningHandlersRef.current; - if (handlers?.onApplyAndContinue) { - await handlers.onApplyAndContinue(); + try { + if (handlers?.onApplyAndContinue) { + await handlers.onApplyAndContinue(); + } + finishAndNavigate(); + } catch (error) { + console.error("Failed to apply changes before navigating:", error); } - finishAndNavigate(); }; const handleExportAndContinue = async () => { diff --git a/frontend/editor/src/core/components/tools/redact/ManualRedactionControls.tsx b/frontend/editor/src/core/components/tools/redact/ManualRedactionControls.tsx index f58a6a864a..867fac3399 100644 --- a/frontend/editor/src/core/components/tools/redact/ManualRedactionControls.tsx +++ b/frontend/editor/src/core/components/tools/redact/ManualRedactionControls.tsx @@ -112,6 +112,8 @@ export default function ManualRedactionControls({ isSavingRef.current = true; try { await applyChanges(); + } catch { + // The viewer-level save handler reports the failure to the user. } finally { isSavingRef.current = false; } diff --git a/frontend/editor/src/core/components/viewer/AnnotationAPIBridge.tsx b/frontend/editor/src/core/components/viewer/AnnotationAPIBridge.tsx index bc2fe4b777..762d7584e0 100644 --- a/frontend/editor/src/core/components/viewer/AnnotationAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/AnnotationAPIBridge.tsx @@ -1,4 +1,5 @@ import { useImperativeHandle, forwardRef, useCallback } from "react"; +import type { TrackedAnnotation } from "@embedpdf/plugin-annotation"; import { useAnnotationCapability } from "@embedpdf/plugin-annotation/react"; import { PdfAnnotationSubtype, PdfAnnotationIcon } from "@embedpdf/models"; import type { @@ -9,6 +10,7 @@ import type { AnnotationPatch, AnnotationRect, AnnotationSelection, + ClearDocumentAnnotationsResult, } from "@app/components/viewer/viewerTypes"; import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady"; @@ -98,6 +100,8 @@ type AnnotationDefaults = } | null; +const EMBEDPDF_CLEAR_DOCUMENT_ANNOTATIONS = "deleteAllAnnotations"; + type AnnotationApiSurface = { setActiveTool: (toolId: AnnotationToolId | null) => void; getActiveTool?: () => { id: AnnotationToolId } | null; @@ -116,6 +120,8 @@ type AnnotationApiSurface = { deleteAnnotations?: ( annotations: Array<{ pageIndex: number; id: string }>, ) => void; + getAnnotations?: () => unknown[]; + [EMBEDPDF_CLEAR_DOCUMENT_ANNOTATIONS]?: () => void | Promise; createAnnotation?: ( pageIndex: number, annotation: Record, @@ -494,6 +500,30 @@ export const AnnotationAPIBridge = forwardRef( api?.deleteAnnotations?.(annotations); }, + clearDocumentAnnotations: + async (): Promise => { + const api = annotationApi; + const clearAnnotations = api?.[EMBEDPDF_CLEAR_DOCUMENT_ANNOTATIONS]; + + if (!api || !clearAnnotations || !api.getAnnotations) { + return { available: false, cleared: false }; + } + + let annotations: TrackedAnnotation[]; + try { + annotations = api.getAnnotations(); + } catch { + return { available: false, cleared: false }; + } + + if (annotations.length === 0) { + return { available: true, cleared: false }; + } + + await clearAnnotations.call(api); + return { available: true, cleared: true }; + }, + createAnnotation: ( pageIndex: number, annotation: Record, diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index 9d36620ada..f74ad806c6 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -21,7 +21,10 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import EditIcon from "@mui/icons-material/Edit"; import VisibilityIcon from "@mui/icons-material/Visibility"; import { useAnnotation } from "@embedpdf/plugin-annotation/react"; -import { getSidebarAnnotationsWithRepliesGroupedByPage } from "@embedpdf/plugin-annotation"; +import { + getSidebarAnnotationsWithRepliesGroupedByPage, + type SidebarAnnotationEntry, +} from "@embedpdf/plugin-annotation"; import { PdfAnnotationSubtype, PdfAnnotationReplyType, @@ -54,13 +57,38 @@ function isStandaloneCommentType(type: number | undefined): boolean { const ANNOTATE_PANEL_ID = "annotate" as const; const TEXT_COMMENT_TOOL_ID = "textComment" as const; +type StirlingAnnotationCustomData = Record & { + annotationToolId?: string; + isComment?: boolean; + modifiedDate?: Date | number | string; + toolId?: string; +}; + +type StirlingAnnotationMetadata = { + creationDate?: Date | number | string; + customData?: StirlingAnnotationCustomData; + M?: Date | number | string; + modifiedDate?: Date | number | string; +}; + +type StirlingAnnotationPatch = Partial & { + customData?: Record; +}; + +function getStirlingAnnotationMetadata( + ann: PdfAnnotationObject, +): StirlingAnnotationMetadata { + return ann as StirlingAnnotationMetadata; +} + /** Format annotation date for display (e.g. "Mar 11, 6:05 PM"). */ -function formatCommentDate(obj: any): string { +function formatCommentDate(obj: PdfAnnotationObject): string { + const metadata = getStirlingAnnotationMetadata(obj); const raw = - obj?.modifiedDate ?? - obj?.creationDate ?? - obj?.customData?.modifiedDate ?? - obj?.M; + metadata.modifiedDate ?? + metadata.creationDate ?? + metadata.customData?.modifiedDate ?? + metadata.M; if (raw == null) return ""; const d = raw instanceof Date ? raw : new Date(raw); if (Number.isNaN(d.getTime())) return ""; @@ -79,8 +107,8 @@ interface CommentsSidebarProps { } function getCommentDisplayContent(entry: { - annotation: { object: any }; - replies: Array<{ object: any }>; + annotation: { object: Pick }; + replies: Array<{ object: Pick }>; }): string { const main = entry.annotation?.object?.contents; if (main != null && String(main).trim()) return String(main).trim(); @@ -93,7 +121,10 @@ function getCommentDisplayContent(entry: { /** Placeholder authors we never show; use current user's name from context instead. */ const PLACEHOLDER_AUTHORS = new Set(["Guest", "Digital Signature", ""]); -function getAuthorName(obj: any, currentDisplayName: string): string { +function getAuthorName( + obj: Pick, + currentDisplayName: string, +): string { const stored = (obj?.author ?? "Guest").trim() || "Guest"; if (PLACEHOLDER_AUTHORS.has(stored)) return currentDisplayName || "Guest"; return stored; @@ -101,7 +132,7 @@ function getAuthorName(obj: any, currentDisplayName: string): string { /** Replies store an explicit author; only allow edit when it matches the current comment author name. */ function isReplyAuthoredByCurrentUser( - obj: any, + obj: Pick, currentDisplayName: string, ): boolean { const stored = (obj?.author ?? "").trim() || "Guest"; @@ -158,8 +189,9 @@ function getIconByType(type: number | undefined): string { return "comment"; } -function isCommentAnnotation(ann: any): boolean { - const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId; +function isCommentAnnotation(ann: PdfAnnotationObject): boolean { + const customData = getStirlingAnnotationMetadata(ann).customData; + const toolId = customData?.toolId ?? customData?.annotationToolId; if ( toolId === "textComment" || toolId === "insertText" || @@ -167,7 +199,7 @@ function isCommentAnnotation(ann: any): boolean { ) return true; // Any annotation explicitly added to comments via the "Add comment" button - if (ann?.customData?.isComment === true) return true; + if (customData?.isComment === true) return true; const type = ann?.type; // Standalone comment types (TEXT, FREETEXT, CARET) without a toolId are always comments if (!toolId && isStandaloneCommentType(type)) return true; @@ -185,12 +217,44 @@ function isCommentAnnotation(ann: any): boolean { return false; } -function getAnnotationToolId(ann: any): string { - return ann?.customData?.toolId ?? ann?.customData?.annotationToolId ?? ""; +function isLinkedCommentAnnotation(ann: PdfAnnotationObject): boolean { + const customData = getStirlingAnnotationMetadata(ann).customData; + const type = ann?.type; + if (isStandaloneCommentType(type)) return false; + if (ann?.inReplyToId) return false; + return ( + customData?.isComment === true || + (type !== undefined && (ann?.contents ?? "").trim().length > 0) + ); +} + +function getAnnotationPageIndex( + fallbackPageIndex: number, + ann: PdfAnnotationObject, +): number { + return typeof ann?.pageIndex === "number" ? ann.pageIndex : fallbackPageIndex; +} + +function getRemoveCommentPatch( + ann: PdfAnnotationObject, +): StirlingAnnotationPatch { + const customData = { + ...(getStirlingAnnotationMetadata(ann).customData ?? {}), + }; + delete customData.isComment; + return { + customData, + contents: "", + }; +} + +function getAnnotationToolId(ann: PdfAnnotationObject): string { + const customData = getStirlingAnnotationMetadata(ann).customData; + return customData?.toolId ?? customData?.annotationToolId ?? ""; } function getAnnotationTypeLabel( - ann: any, + ann: PdfAnnotationObject, t: (key: string, fallback: string) => string, ): string { const toolId = getAnnotationToolId(ann); @@ -222,7 +286,7 @@ function getAnnotationTypeLabel( return t("viewer.comments.typeComment", "Comment"); } -function AnnotationTypeIcon({ ann }: { ann: any }) { +function AnnotationTypeIcon({ ann }: { ann: PdfAnnotationObject }) { const toolId = getAnnotationToolId(ann); const iconName = TOOL_ICON_MAP[toolId] ?? getIconByType(ann?.type); return ( @@ -313,7 +377,7 @@ export function CommentsSidebar({ ]); const handleLocateAnnotation = useCallback( - (pageIndex: number, ann: any) => { + (pageIndex: number, ann: PdfAnnotationObject) => { scrollActions?.scrollToPage(pageIndex + 1, "smooth"); setTimeout(() => { const pageEl = document.querySelector( @@ -358,10 +422,10 @@ export function CommentsSidebar({ [scrollActions, getZoomState], ); - const byPage = useMemo(() => { + const byPage = useMemo>(() => { try { const all = getSidebarAnnotationsWithRepliesGroupedByPage(state) ?? {}; - const filtered: typeof all = {}; + const filtered: Record = {}; for (const [page, entries] of Object.entries(all)) { const commentEntries = entries .filter((e) => isCommentAnnotation(e.annotation.object)) @@ -417,22 +481,13 @@ export function CommentsSidebar({ const [deleteModal, setDeleteModal] = useState<{ pageIndex: number; id: string; - ann: any; + ann: PdfAnnotationObject; } | null>(null); - - const isLinkedAnnotation = (ann: any) => { - const type = ann?.type; - if (isStandaloneCommentType(type)) return false; - if (ann?.inReplyToId) return false; - return ( - ann?.customData?.isComment === true || - (type !== undefined && (ann?.contents ?? "").trim().length > 0) - ); - }; + const [clearAllModalOpen, setClearAllModalOpen] = useState(false); const handleDeleteClick = useCallback( - (pageIndex: number, annotationId: string, ann: any) => { - if (isLinkedAnnotation(ann)) { + (pageIndex: number, annotationId: string, ann: PdfAnnotationObject) => { + if (isLinkedCommentAnnotation(ann)) { setDeleteModal({ pageIndex, id: annotationId, ann }); } else { provides?.deleteAnnotation?.(pageIndex, annotationId); @@ -444,15 +499,10 @@ export function CommentsSidebar({ const handleRemoveFromSidebar = useCallback(() => { if (!deleteModal || !provides?.updateAnnotation) return; const { pageIndex, id, ann } = deleteModal; - const existing = (ann?.customData ?? {}) as Record; - const { isComment: _removed, ...rest } = existing; // Also clear contents: the contents field is the persisted signal for // post-reload linked annotations, so clearing it removes the annotation // from the sidebar (contents is not visually rendered on ink/shape/markup types). - provides.updateAnnotation(pageIndex, id, { - customData: rest, - contents: "", - } as unknown as Partial); + provides.updateAnnotation(pageIndex, id, getRemoveCommentPatch(ann)); setDeleteModal(null); }, [deleteModal, provides]); @@ -462,6 +512,73 @@ export function CommentsSidebar({ setDeleteModal(null); }, [deleteModal, provides]); + const handleClearAllComments = useCallback(() => { + const annotationsToDelete: Array<{ pageIndex: number; id: string }> = []; + const commentPatches: Array<{ + pageIndex: number; + id: string; + patch: StirlingAnnotationPatch; + }> = []; + + for (const [page, entries] of Object.entries(byPage)) { + const fallbackPageIndex = Number(page); + for (const entry of entries) { + const ann = entry.annotation?.object; + const id = ann?.id; + if (!id) continue; + + const pageIndex = getAnnotationPageIndex(fallbackPageIndex, ann); + if (isLinkedCommentAnnotation(ann)) { + commentPatches.push({ + pageIndex, + id, + patch: getRemoveCommentPatch(ann), + }); + } else { + annotationsToDelete.push({ pageIndex, id }); + } + + for (const reply of entry.replies ?? []) { + const replyObj = reply?.object; + const replyId = replyObj?.id; + if (!replyId) continue; + annotationsToDelete.push({ + pageIndex: getAnnotationPageIndex(pageIndex, replyObj), + id: replyId, + }); + } + } + } + + if (commentPatches.length > 0) { + if (provides?.updateAnnotations) { + provides.updateAnnotations(commentPatches); + } else { + for (const { pageIndex, id, patch } of commentPatches) { + provides?.updateAnnotation?.(pageIndex, id, patch); + } + } + } + + if (annotationsToDelete.length > 0) { + if (provides?.deleteAnnotations) { + provides.deleteAnnotations(annotationsToDelete); + } else { + for (const { pageIndex, id } of annotationsToDelete) { + provides?.deleteAnnotation?.(pageIndex, id); + } + } + } + + setDraftContents({}); + setReplyDrafts({}); + setReplyEditDrafts({}); + setEditingMainKey(null); + setEditingReplyKey(null); + setDeleteModal(null); + setClearAllModalOpen(false); + }, [byPage, provides]); + const handleSendMainComment = useCallback( (pageIndex: number, annotationId: string, value: string) => { const trimmed = value.trim(); @@ -566,16 +683,38 @@ export function CommentsSidebar({ {t("viewer.comments.title", "Comments")} {totalCount > 0 && ( - - - - - + + + + + + + + + + + + + + + + } + color="red" + onClick={() => setClearAllModalOpen(true)} + > + {t("viewer.comments.clearAll", "Clear all comments")} + + + + )} @@ -1054,6 +1193,29 @@ export function CommentsSidebar({ + + setClearAllModalOpen(false)} + title={t("viewer.comments.clearAllTitle", "Clear all comments?")} + centered + size="sm" + > + + {t( + "viewer.comments.clearAllDescription", + "This removes comments and replies from the sidebar while keeping any attached annotations in the document.", + )} + + + + + + ); } diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 0d2a78dee8..669b967091 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -41,6 +41,7 @@ import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; +import { alert } from "@app/components/toast"; // ─── Measure dictionary extraction ──────────────────────────────────────────── @@ -210,6 +211,10 @@ const EmbedPdfViewerContent = ({ // 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); + // EmbedPDF can emit once from the saved undo stack before the saved file remounts. + // Ignore that stale update without suppressing future edits on the same instance. + const savedAnnotationHistoryApiRef = + useRef(null); // Scroll position preservation system // We continuously track the last known good scroll position, so we always have it available @@ -223,6 +228,7 @@ const EmbedPdfViewerContent = ({ const rotationRestoreAttemptsRef = useRef(0); const formApplyInProgressRef = useRef(false); + const applyChangesInFlightRef = useRef | null>(null); // Get redaction context const { redactionsApplied, setRedactionsApplied } = useRedaction(); @@ -579,6 +585,11 @@ const EmbedPdfViewerContent = ({ } const updateHasChanges = () => { + if (savedAnnotationHistoryApiRef.current === historyApi) { + savedAnnotationHistoryApiRef.current = null; + return; + } + const canUndo = historyApi.canUndo?.() ?? false; if (!hasAnnotationChangesRef.current && canUndo) { hasAnnotationChangesRef.current = true; @@ -632,9 +643,13 @@ const EmbedPdfViewerContent = ({ // Save changes - save annotations and redactions to file (overwrites active file) const applyChanges = useCallback(async () => { + if (applyChangesInFlightRef.current) { + return applyChangesInFlightRef.current; + } + if (!currentFile || activeFileIds.length === 0) return; - try { + const saveChanges = async () => { console.log( "[Viewer] Applying changes - exporting PDF with annotations/redactions", ); @@ -702,21 +717,45 @@ const EmbedPdfViewerContent = ({ await actions.consumeFiles([currentFileId], stirlingFiles, stubs); // Mark annotations as saved so navigation away from the viewer is allowed. + savedAnnotationHistoryApiRef.current = historyApiRef.current; hasAnnotationChangesRef.current = false; setHasUnsavedChanges(false); setRedactionsApplied(false); - } catch (error) { - console.error("Apply changes failed:", error); - } + }; + + const savePromise = saveChanges() + .catch((error) => { + console.error("Apply changes failed:", error); + alert({ + title: t("viewer.saveChangesErrorTitle", "Could not save changes"), + body: + error instanceof Error && error.message + ? error.message + : t( + "viewer.saveChangesErrorBody", + "The document could not be saved. Try again.", + ), + alertType: "error", + }); + throw error; + }) + .finally(() => { + applyChangesInFlightRef.current = null; + }); + + applyChangesInFlightRef.current = savePromise; + return savePromise; }, [ currentFile, activeFiles, exportActions, actions, selectors, + historyApiRef, setHasUnsavedChanges, setRedactionsApplied, rotationState.rotation, + t, ]); // Apply form fill changes - reload the filled PDF into the viewer diff --git a/frontend/editor/src/core/components/viewer/viewerTypes.ts b/frontend/editor/src/core/components/viewer/viewerTypes.ts index 22342337d5..9233e74ca8 100644 --- a/frontend/editor/src/core/components/viewer/viewerTypes.ts +++ b/frontend/editor/src/core/components/viewer/viewerTypes.ts @@ -3,6 +3,11 @@ export interface AnnotationRect { size: { width: number; height: number }; } +export interface ClearDocumentAnnotationsResult { + available: boolean; + cleared: boolean; +} + export interface SignatureAPI { addImageSignature: ( signatureData: string, @@ -46,6 +51,7 @@ export interface AnnotationAPI { deleteAnnotations?: ( annotations: Array<{ pageIndex: number; id: string }>, ) => void; + clearDocumentAnnotations?: () => Promise; createAnnotation?: ( pageIndex: number, annotation: Record, diff --git a/frontend/editor/src/core/tools/Annotate.tsx b/frontend/editor/src/core/tools/Annotate.tsx index 473b739c6e..48369c03e8 100644 --- a/frontend/editor/src/core/tools/Annotate.tsx +++ b/frontend/editor/src/core/tools/Annotate.tsx @@ -18,6 +18,7 @@ import type { import { useAnnotationStyleState } from "@app/tools/annotate/useAnnotationStyleState"; import { useAnnotationSelection } from "@app/tools/annotate/useAnnotationSelection"; import { AnnotationPanel } from "@app/tools/annotate/AnnotationPanel"; +import { alert } from "@app/components/toast"; // Tools that require drawing/interacting with the PDF and should disable pan mode const DRAWING_TOOLS: AnnotationToolId[] = [ @@ -74,7 +75,8 @@ const isKnownAnnotationTool = ( const Annotate = (_props: BaseToolProps) => { const { t } = useTranslation(); - const { selectedTool, workbench, hasUnsavedChanges } = useNavigation(); + const { selectedTool, workbench, hasUnsavedChanges, setHasUnsavedChanges } = + useNavigation(); const { files: allFiles } = useAllFiles(); const { signatureApiRef, @@ -679,6 +681,117 @@ const Annotate = (_props: BaseToolProps) => { setTextAlignment, }); + const resetAnnotationInteractionState = useCallback(() => { + const annotationApi = annotationApiRef.current; + + signatureApiRef.current?.deactivateTools?.(); + annotationApi?.deselectAnnotation?.(); + annotationApi?.activateAnnotationTool?.("select"); + + setPlacementMode(false); + setSignatureConfig(null); + setPlacementPreviewSize(null); + setSelectedAnn(null); + setSelectedAnnId(null); + setActiveTool("select"); + activeToolRef.current = "select"; + viewerContextRef.current?.setAnnotationMode(true); + }, [ + annotationApiRef, + signatureApiRef, + setPlacementMode, + setSignatureConfig, + setPlacementPreviewSize, + setSelectedAnn, + setSelectedAnnId, + ]); + + const handleClearDocumentAnnotations = useCallback(async () => { + const annotationApi = annotationApiRef.current; + + if (!annotationApi?.clearDocumentAnnotations) { + alert({ + title: t( + "annotation.clearDocumentAnnotationsUnavailableTitle", + "Annotations are not ready", + ), + body: t( + "annotation.clearDocumentAnnotationsUnavailableBody", + "The annotation layer is still loading. Try again in a moment.", + ), + alertType: "error", + }); + return false; + } + + try { + const result = await annotationApi.clearDocumentAnnotations(); + if (!result.available) { + alert({ + title: t( + "annotation.clearDocumentAnnotationsUnavailableTitle", + "Annotations are not ready", + ), + body: t( + "annotation.clearDocumentAnnotationsUnavailableBody", + "The annotation layer is still loading. Try again in a moment.", + ), + alertType: "error", + }); + return false; + } + + if (!result.cleared) { + alert({ + title: t( + "annotation.clearDocumentAnnotationsEmptyTitle", + "No annotations to clear", + ), + body: t( + "annotation.clearDocumentAnnotationsEmptyBody", + "There are no annotations currently loaded in the editor.", + ), + alertType: "neutral", + }); + return true; + } + + resetAnnotationInteractionState(); + setHasUnsavedChanges(true); + + alert({ + title: t( + "annotation.clearDocumentAnnotationsSuccessTitle", + "All annotations cleared", + ), + body: t( + "annotation.clearDocumentAnnotationsSuccessBody", + "Please save changes to persist the annotation removal in the PDF.", + ), + alertType: "success", + }); + return true; + } catch (error) { + alert({ + title: t( + "annotation.clearDocumentAnnotationsErrorTitle", + "Could not clear annotations", + ), + body: + error instanceof Error + ? error.message + : String(error ?? "Unknown error"), + alertType: "error", + }); + return false; + } + }, [ + annotationApiRef, + resetAnnotationInteractionState, + setHasUnsavedChanges, + t, + ]); + const steps = allFiles.length === 0 ? [] @@ -715,6 +828,7 @@ const Annotate = (_props: BaseToolProps) => { undo={undo} redo={redo} historyAvailability={historyAvailability} + onClearDocumentAnnotations={handleClearDocumentAnnotations} onApplyChanges={handleApplyChanges} applyDisabled={!hasUnsavedChanges} /> diff --git a/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx b/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx index f65b031d13..17f2e028c0 100644 --- a/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx +++ b/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx @@ -12,6 +12,8 @@ import { Button, Tooltip, Paper, + Menu, + Modal, } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { @@ -133,7 +135,8 @@ interface AnnotationPanelProps { undo: () => void; redo: () => void; historyAvailability: { canUndo: boolean; canRedo: boolean }; - onApplyChanges: () => void; + onClearDocumentAnnotations: () => boolean | Promise; + onApplyChanges: () => void | Promise; applyDisabled: boolean; } @@ -142,6 +145,11 @@ export function AnnotationPanel(props: AnnotationPanelProps) { const { t } = useTranslation(); const [colorPickerTarget, setColorPickerTarget] = useState(null); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [isClearDocumentModalOpen, setIsClearDocumentModalOpen] = + useState(false); + const [isClearingDocumentAnnotations, setIsClearingDocumentAnnotations] = + useState(false); + const [isApplyingChanges, setIsApplyingChanges] = useState(false); const { activeTool, @@ -164,6 +172,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) { undo, redo, historyAvailability, + onClearDocumentAnnotations, onApplyChanges, applyDisabled, } = props; @@ -317,6 +326,37 @@ export function AnnotationPanel(props: AnnotationPanelProps) { const annotationsVisible = viewerContext?.isAnnotationsVisible ?? true; + const handleConfirmClearDocumentAnnotations = async () => { + if (isClearingDocumentAnnotations || isApplyingChanges) { + return; + } + + setIsClearingDocumentAnnotations(true); + try { + const didClear = await onClearDocumentAnnotations(); + if (didClear) { + setIsClearDocumentModalOpen(false); + } + } finally { + setIsClearingDocumentAnnotations(false); + } + }; + + const handleApplyChangesClick = async () => { + if (isApplyingChanges || isClearingDocumentAnnotations || applyDisabled) { + return; + } + + setIsApplyingChanges(true); + try { + await onApplyChanges(); + } catch { + // The viewer-level save handler reports the failure to the user. + } finally { + setIsApplyingChanges(false); + } + }; + const renderToolButtons = ( tools: { id: AnnotationToolId; label: string; icon: string }[], ) => ( @@ -1171,7 +1211,37 @@ export function AnnotationPanel(props: AnnotationPanelProps) { canUndo={historyAvailability.canUndo} canRedo={historyAvailability.canRedo} showPlaceButton={false} - additionalControls={null} + additionalControls={ + + + + + + + + + + + } + onClick={() => setIsClearDocumentModalOpen(true)} + > + {t( + "annotation.clearDocumentAnnotations", + "Clear all annotations", + )} + + + + } /> @@ -1221,13 +1291,59 @@ export function AnnotationPanel(props: AnnotationPanelProps) { mt="sm" variant="filled" color="blue" - disabled={applyDisabled} - onClick={onApplyChanges} + disabled={ + applyDisabled || isApplyingChanges || isClearingDocumentAnnotations + } + loading={isApplyingChanges} + onClick={() => void handleApplyChangesClick()} > {t("annotation.saveChanges", "Save Changes")} + + { + if (!isClearingDocumentAnnotations) { + setIsClearDocumentModalOpen(false); + } + }} + title={t( + "annotation.clearDocumentAnnotationsTitle", + "Clear all annotations?", + )} + centered + > + + + {t( + "annotation.clearDocumentAnnotationsDescription", + "This removes all annotations currently loaded in the editor. Please save changes to persist this in the PDF.", + )} + + + + + + + ); }