Add bulk comment and annotation clearing to editor (#6792)

# 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.

<!--
Please provide a summary of the changes, including:

- What was changed:
- Why the change was made
- Any challenges encountered

-->

---


## 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 : 
<img width="310" height="397" alt="image"
src="https://github.com/user-attachments/assets/d1682611-13f8-4f40-aa77-44b37450e56e"
/>

Clear all annotations: 
<img width="284" height="549" alt="image"
src="https://github.com/user-attachments/assets/e4049bc1-f07b-4b36-b08e-ad6d6b86fe62"
/>



### 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.
This commit is contained in:
Matheus Saito
2026-06-25 08:55:11 +00:00
committed by GitHub
parent bc6f1a1ff5
commit 501a7199e0
10 changed files with 566 additions and 61 deletions
@@ -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"
@@ -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?"
@@ -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 () => {
@@ -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;
}
@@ -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<void>;
createAnnotation?: (
pageIndex: number,
annotation: Record<string, unknown>,
@@ -494,6 +500,30 @@ export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(
api?.deleteAnnotations?.(annotations);
},
clearDocumentAnnotations:
async (): Promise<ClearDocumentAnnotationsResult> => {
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<string, unknown>,
@@ -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<string, unknown> & {
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<PdfAnnotationObject> & {
customData?: Record<string, unknown>;
};
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<PdfAnnotationObject, "contents"> };
replies: Array<{ object: Pick<PdfAnnotationObject, "contents"> }>;
}): 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<PdfAnnotationObject, "author">,
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<PdfAnnotationObject, "author">,
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<HTMLElement>(
@@ -358,10 +422,10 @@ export function CommentsSidebar({
[scrollActions, getZoomState],
);
const byPage = useMemo(() => {
const byPage = useMemo<Record<number, SidebarAnnotationEntry[]>>(() => {
try {
const all = getSidebarAnnotationsWithRepliesGroupedByPage(state) ?? {};
const filtered: typeof all = {};
const filtered: Record<number, SidebarAnnotationEntry[]> = {};
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<string, unknown>;
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<PdfAnnotationObject>);
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")}
</Text>
{totalCount > 0 && (
<Tooltip label={t("viewer.comments.addComment", "Add comment")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={handleAddComment}
>
<LocalIcon icon="add" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Tooltip>
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip label={t("viewer.comments.addComment", "Add comment")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={handleAddComment}
>
<LocalIcon icon="add" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip
label={t("viewer.comments.moreActions", "More actions")}
>
<ActionIcon variant="subtle" size="sm" color="gray">
<MoreHorizIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 18 }} />}
color="red"
onClick={() => setClearAllModalOpen(true)}
>
{t("viewer.comments.clearAll", "Clear all comments")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
)}
</div>
<ScrollArea style={{ flex: 1 }}>
@@ -1054,6 +1193,29 @@ export function CommentsSidebar({
</Button>
</Group>
</Modal>
<Modal
opened={clearAllModalOpen}
onClose={() => setClearAllModalOpen(false)}
title={t("viewer.comments.clearAllTitle", "Clear all comments?")}
centered
size="sm"
>
<Text size="sm" c="dimmed" mb="lg">
{t(
"viewer.comments.clearAllDescription",
"This removes comments and replies from the sidebar while keeping any attached annotations in the document.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setClearAllModalOpen(false)}>
{t("viewer.comments.cancelClearAll", "Cancel")}
</Button>
<Button color="red" onClick={handleClearAllComments}>
{t("viewer.comments.clearAll", "Clear all comments")}
</Button>
</Group>
</Modal>
</Box>
);
}
@@ -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<typeof historyApiRef.current>(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<number>(0);
const formApplyInProgressRef = useRef(false);
const applyChangesInFlightRef = useRef<Promise<void> | 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
@@ -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<ClearDocumentAnnotationsResult>;
createAnnotation?: (
pageIndex: number,
annotation: Record<string, unknown>,
+115 -1
View File
@@ -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}
/>
@@ -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<boolean>;
onApplyChanges: () => void | Promise<void>;
applyDisabled: boolean;
}
@@ -142,6 +145,11 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
const { t } = useTranslation();
const [colorPickerTarget, setColorPickerTarget] = useState<ColorTarget>(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={
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip label={t("annotation.moreActions", "More actions")}>
<ActionIcon
variant="subtle"
size="lg"
disabled={isApplyingChanges}
aria-label={t("annotation.moreActions", "More actions")}
>
<LocalIcon icon="more-horiz" width={20} height={20} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
disabled={isClearingDocumentAnnotations || isApplyingChanges}
leftSection={
<LocalIcon icon="delete-rounded" width={18} height={18} />
}
onClick={() => setIsClearDocumentModalOpen(true)}
>
{t(
"annotation.clearDocumentAnnotations",
"Clear all annotations",
)}
</Menu.Item>
</Menu.Dropdown>
</Menu>
}
/>
</Group>
@@ -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")}
</Button>
<SuggestedToolsSection />
<Modal
opened={isClearDocumentModalOpen}
onClose={() => {
if (!isClearingDocumentAnnotations) {
setIsClearDocumentModalOpen(false);
}
}}
title={t(
"annotation.clearDocumentAnnotationsTitle",
"Clear all annotations?",
)}
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"annotation.clearDocumentAnnotationsDescription",
"This removes all annotations currently loaded in the editor. Please save changes to persist this in the PDF.",
)}
</Text>
<Group justify="flex-end">
<Button
variant="default"
disabled={isClearingDocumentAnnotations}
onClick={() => setIsClearDocumentModalOpen(false)}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
color="red"
loading={isClearingDocumentAnnotations}
disabled={isApplyingChanges}
leftSection={
<LocalIcon icon="delete-rounded" width={18} height={18} />
}
onClick={() => void handleConfirmClearDocumentAnnotations()}
>
{t("annotation.clearDocumentAnnotationsConfirm", "Clear all")}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}