From 501a7199e0c0326e2f592c62752423e1f6010681 Mon Sep 17 00:00:00 2001
From: Matheus Saito <106726276+MattSaito@users.noreply.github.com>
Date: Thu, 25 Jun 2026 05:55:11 -0300
Subject: [PATCH] Add bulk comment and annotation clearing to editor (#6792)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description of Changes
Closes #6695
This PR adds bulk cleanup actions for comments and annotations in the
PDF editor, while tightening the save and navigation behavior around
annotation edits.
### Comments sidebar
Adds a “Clear all comments” action to the comments sidebar overflow
menu. The action opens a confirmation modal before clearing sidebar
comments and replies.
The implementation distinguishes between standalone comment annotations
and comments attached to existing visual annotations. Standalone
comments and replies are removed from the document, while comments
attached to markup, shapes, ink, or other visual annotations are cleared
from the sidebar without deleting the underlying annotation itself. This
preserves the visible document markup while removing the comment
metadata and persisted comment contents.
The comments sidebar state is also reset after clearing, including draft
comments, reply drafts, edit state, and open confirmation/delete modal
state.
### Annotate tool
Adds a document-level “Clear all annotations” action to the Annotate
tool. The action is exposed through the annotation panel’s overflow menu
and uses a confirmation modal before removing annotations.
The clear operation is routed through the existing annotation API bridge
and delegates to EmbedPDF’s document-level annotation clearing API. The
UI handles unavailable annotation state, successful clears, and
failures.
After annotations are cleared, the editor resets annotation interaction
state, exits placement/selection-specific state, returns to select mode,
and marks the document as having unsaved changes only when annotations
were actually removed. The user can then persist the removal through the
normal Save Changes flow.
### Save and navigation hardening
Improves the viewer save/apply flow used by annotations and manual
redactions.
Save operations are now deduplicated while an apply operation is already
in flight, preventing duplicate exports or duplicate file consumption
when users trigger save/navigation repeatedly.
The global unsaved-changes navigation modal now waits for “Apply &
Leave” to complete successfully before navigating. If saving fails, the
modal keeps the user in place instead of leaving with unsaved edits
still present.
The Annotate panel also prevents “Save Changes” and “Clear all
annotations” from running concurrently.
---
## Checklist
### General
- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/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)
- [X] I have performed a self-review of my own code
- [X] 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)
- [X] 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)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
Clear all comments :
Clear all annotations:
### Testing (if applicable)
- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.../public/locales/en-GB/translation.toml | 14 +
.../public/locales/en-US/translation.toml | 18 ++
.../shared/NavigationWarningModal.tsx | 10 +-
.../tools/redact/ManualRedactionControls.tsx | 2 +
.../components/viewer/AnnotationAPIBridge.tsx | 30 ++
.../components/viewer/CommentsSidebar.tsx | 260 ++++++++++++++----
.../core/components/viewer/EmbedPdfViewer.tsx | 47 +++-
.../src/core/components/viewer/viewerTypes.ts | 6 +
frontend/editor/src/core/tools/Annotate.tsx | 116 +++++++-
.../core/tools/annotate/AnnotationPanel.tsx | 124 ++++++++-
10 files changed, 566 insertions(+), 61 deletions(-)
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 && (
-
-
-
-
-
+
+
+
+
+
+
+
+
)}
@@ -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={
+
+ }
/>
@@ -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.",
+ )}
+
+
+
+
+ }
+ onClick={() => void handleConfirmClearDocumentAnnotations()}
+ >
+ {t("annotation.clearDocumentAnnotationsConfirm", "Clear all")}
+
+
+
+
);
}