merge main

This commit is contained in:
Frooodle
2026-06-25 13:57:27 +01:00
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>
);
}