merge main
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