mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user