From 7c42d8018aa51fd4766346c400eef34af006302f Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Thu, 23 Apr 2026 17:22:47 +0100
Subject: [PATCH] make clicking on comments open the comments sidebar and more
(#6174)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
make clicking on comments open the comments sidebar and add a button to
create comments into the empty state of the comments sidebar
---
.../viewer/AnnotationSelectionMenu.tsx | 50 ++++++-
.../viewer/AnnotationTypeButtons.tsx | 12 +-
.../components/viewer/CommentsSidebar.tsx | 125 +++++++++++++++---
.../viewer/useAnnotationMenuHandlers.ts | 81 ++++++++++--
.../src/core/contexts/AnnotationContext.tsx | 13 +-
frontend/src/core/tools/Annotate.tsx | 5 +
6 files changed, 237 insertions(+), 49 deletions(-)
diff --git a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx
index 0b1e5f03e..97d697a46 100644
--- a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx
+++ b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx
@@ -1,6 +1,6 @@
import { Group } from "@mantine/core";
import { createPortal } from "react-dom";
-import { useEffect, useState, useRef, useCallback } from "react";
+import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
import {
@@ -47,7 +47,7 @@ function AnnotationSelectionMenuInner({
}: AnnotationSelectionMenuProps & { documentId: string }) {
const annotation = context?.annotation;
const pageIndex = context?.pageIndex;
- const { provides } = useAnnotation(documentId);
+ const { state, provides } = useAnnotation(documentId);
const { scrollActions, requestCommentFocus } = useViewer();
const wrapperRef = useRef(null);
const [menuPosition, setMenuPosition] = useState<{
@@ -65,17 +65,52 @@ function AnnotationSelectionMenuInner({
wrapperRef,
});
+ // Read isInSidebar from live EmbedPDF state rather than annotation.object, which can be
+ // stale after updateAnnotation() is called while the annotation is selected.
+ // Also checks non-empty contents: customData.isComment is not persisted to PDF, but
+ // contents is a standard PDF field and survives save/reload.
+ const isInSidebar = useMemo(() => {
+ const annId = (annotation?.object as AnnotationObject | undefined)?.id;
+ if (!annId) return false;
+ for (const tracked of Object.values(state.byUid)) {
+ const obj = tracked.object;
+ if (obj.id !== annId) continue;
+ const { type } = obj;
+ // TEXT and CARET are standalone comment annotations — they use CommentButton,
+ // not AttachCommentButton, so isInSidebar is irrelevant for them.
+ if (
+ type === PdfAnnotationSubtype.TEXT ||
+ type === PdfAnnotationSubtype.CARET
+ )
+ return false;
+ // customData is a runtime field EmbedPDF adds but doesn't declare in its TS types
+ const customData = (
+ obj as unknown as { customData?: Record }
+ ).customData;
+ const isExplicit = customData?.isComment === true;
+ // customData (incl. toolId/isComment) is not persisted to PDF; contents is.
+ // Any non-TEXT/FreeText/CARET annotation with non-empty contents has a comment.
+ const hasContents =
+ type !== PdfAnnotationSubtype.FREETEXT &&
+ !obj.inReplyToId &&
+ (obj.contents ?? "").trim().length > 0;
+ return isExplicit || hasContents;
+ }
+ return false;
+ }, [state, annotation?.object]);
+
// Auto-open the comments sidebar when a comment annotation is selected
useEffect(() => {
const annObj = annotation?.object as AnnotationObject | undefined;
const annId = annObj?.id;
if (!selected || !annId || pageIndex === undefined) return;
- const toolId = annObj?.customData?.toolId;
const annType = annObj?.type;
+ // TEXT (type 1) = textComment; CARET (type 14) = insertText/replaceText.
+ // These are always comment annotations regardless of toolId (lost after reload).
const isComment =
- (annType === PdfAnnotationSubtype.TEXT && toolId === "textComment") ||
- (annType === PdfAnnotationSubtype.CARET &&
- (toolId === "insertText" || toolId === "replaceText"));
+ annType === PdfAnnotationSubtype.TEXT ||
+ annType === PdfAnnotationSubtype.CARET ||
+ isInSidebar;
if (!isComment) return;
requestCommentFocus(
documentId,
@@ -83,7 +118,7 @@ function AnnotationSelectionMenuInner({
annId,
(annObj?.contents ?? "").trim().length > 0,
);
- }, [selected, annotation?.object]);
+ }, [selected, annotation?.object, isInSidebar]);
// Click outside to deselect
useEffect(() => {
@@ -170,6 +205,7 @@ function AnnotationSelectionMenuInner({
+ ) : (
@@ -221,10 +225,6 @@ export function AnnotationTypeButtons(props: AnnotationTypeButtonsProps) {
return (
<>
{attachCommentButton}
-
0
+ )
+ return true;
return false;
}
@@ -213,6 +249,8 @@ export function CommentsSidebar({
} = useViewer() ?? {};
const scrollViewportRef = useRef(null);
const { state, provides } = useAnnotation(documentId);
+ const { handleToolSelectForced } = useToolWorkflow();
+ const { activateAnnotationToolRef } = useAnnotationContext();
const [draftContents, setDraftContents] = useState>(
{},
);
@@ -324,8 +362,8 @@ export function CommentsSidebar({
const all = getSidebarAnnotationsWithRepliesGroupedByPage(state) ?? {};
const filtered: typeof all = {};
for (const [page, entries] of Object.entries(all)) {
- const commentEntries = (entries as typeof entries).filter((e) =>
- isCommentAnnotation((e as any).annotation?.object),
+ const commentEntries = entries.filter((e) =>
+ isCommentAnnotation(e.annotation.object),
);
if (commentEntries.length > 0) {
filtered[Number(page)] = commentEntries;
@@ -341,12 +379,11 @@ export function CommentsSidebar({
// state is AnnotationDocumentState — selectedUids are keys in byUid, and may equal id.
const selectedAnnotationIds = useMemo(() => {
const selectedUids: string[] = state?.selectedUids ?? [];
- const byUid: Record = (state as any)?.byUid ?? {};
const ids = new Set();
for (const uid of selectedUids) {
// uid itself may be the annotation id
ids.add(uid);
- const annId = byUid[uid]?.object?.id;
+ const annId = state.byUid[uid]?.object.id;
if (annId) ids.add(annId);
}
return ids;
@@ -383,12 +420,12 @@ export function CommentsSidebar({
} | null>(null);
const isLinkedAnnotation = (ann: any) => {
- const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId;
+ const type = ann?.type;
+ if (isStandaloneCommentType(type)) return false;
+ if (ann?.inReplyToId) return false;
return (
- ann?.customData?.isComment === true &&
- toolId !== "textComment" &&
- toolId !== "insertText" &&
- toolId !== "replaceText"
+ ann?.customData?.isComment === true ||
+ (type !== undefined && (ann?.contents ?? "").trim().length > 0)
);
};
@@ -408,7 +445,13 @@ export function CommentsSidebar({
const { pageIndex, id, ann } = deleteModal;
const existing = (ann?.customData ?? {}) as Record;
const { isComment: _removed, ...rest } = existing;
- provides.updateAnnotation(pageIndex, id, { customData: rest } as any);
+ // 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);
setDeleteModal(null);
}, [deleteModal, provides]);
@@ -443,7 +486,7 @@ export function CommentsSidebar({
origin: { x: 0, y: 0 },
size: { width: 1, height: 1 },
};
- provides.createAnnotation(pageIndex, {
+ const reply: PdfTextAnnoObject = {
type: PdfAnnotationSubtype.TEXT,
id: `reply-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
pageIndex,
@@ -452,7 +495,8 @@ export function CommentsSidebar({
inReplyToId: parentId,
replyType: PdfAnnotationReplyType.Reply,
author: displayName,
- } as any);
+ };
+ provides.createAnnotation(pageIndex, reply);
setReplyDrafts((prev) => ({ ...prev, [key]: "" }));
},
[provides, replyDrafts, displayName],
@@ -476,6 +520,13 @@ export function CommentsSidebar({
[provides, displayName],
);
+ const handleAddComment = useCallback(() => {
+ handleToolSelectForced(ANNOTATE_PANEL_ID);
+ requestAnimationFrame(() => {
+ activateAnnotationToolRef.current?.(TEXT_COMMENT_TOOL_ID);
+ });
+ }, [handleToolSelectForced, activateAnnotationToolRef]);
+
if (!visible) return null;
return (
@@ -510,19 +561,49 @@ export function CommentsSidebar({
height="1.25rem"
style={{ color: "var(--mantine-color-dimmed)", flexShrink: 0 }}
/>
-
+
{t("viewer.comments.title", "Comments")}
+ {totalCount > 0 && (
+
+
+
+
+
+ )}
{totalCount === 0 ? (
-
- {t(
- "viewer.comments.hint",
- "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
- )}
-
+
+
+
+ {t(
+ "viewer.comments.hint",
+ "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
+ )}
+
+
+ }
+ >
+ {t("viewer.comments.addComment", "Add comment")}
+
+
) : (
pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
diff --git a/frontend/src/core/components/viewer/useAnnotationMenuHandlers.ts b/frontend/src/core/components/viewer/useAnnotationMenuHandlers.ts
index ae50f5f94..bf14b559f 100644
--- a/frontend/src/core/components/viewer/useAnnotationMenuHandlers.ts
+++ b/frontend/src/core/components/viewer/useAnnotationMenuHandlers.ts
@@ -27,6 +27,32 @@ export type AnnotationType =
| "stamp"
| "unknown";
+const TEXT_MARKUP_SUBTYPES = [
+ PdfAnnotationSubtype.HIGHLIGHT,
+ PdfAnnotationSubtype.UNDERLINE,
+ PdfAnnotationSubtype.SQUIGGLY,
+ PdfAnnotationSubtype.STRIKEOUT,
+];
+
+const SHAPE_SUBTYPES = [
+ PdfAnnotationSubtype.SQUARE,
+ PdfAnnotationSubtype.CIRCLE,
+ PdfAnnotationSubtype.POLYGON,
+];
+
+const LINE_SUBTYPES = [
+ PdfAnnotationSubtype.LINE,
+ PdfAnnotationSubtype.POLYLINE,
+];
+
+const STROKE_COLOR_SUBTYPES = [
+ PdfAnnotationSubtype.LINE,
+ PdfAnnotationSubtype.SQUARE,
+ PdfAnnotationSubtype.CIRCLE,
+ PdfAnnotationSubtype.POLYGON,
+ PdfAnnotationSubtype.POLYLINE,
+];
+
export type FirstLinkTarget =
| { type: "uri"; uri: string }
| { type: "goto"; pageIndex: number };
@@ -134,17 +160,36 @@ export function useAnnotationMenuHandlers({
const toolId = (annotation?.object as AnnotationObject | undefined)
?.customData?.toolId;
- if (type !== undefined && [9, 10, 11, 12].includes(type))
+ if (
+ type !== undefined &&
+ TEXT_MARKUP_SUBTYPES.includes(type as PdfAnnotationSubtype)
+ )
return "textMarkup";
- if (type === 15)
+ if (type === PdfAnnotationSubtype.INK)
return toolId === "inkHighlighter" ? "inkHighlighter" : "ink";
- if (type === 1 && toolId === "textComment") return "comment";
- if (type === 14 && (toolId === "insertText" || toolId === "replaceText"))
+ // TEXT and CARET fall back to type alone after save/reload (customData.toolId is absent).
+ if (
+ type === PdfAnnotationSubtype.TEXT &&
+ (!toolId || toolId === "textComment")
+ )
return "comment";
- if (type === 3) return "note";
- if (type !== undefined && [5, 6, 7].includes(type)) return "shape";
- if (type !== undefined && [4, 8].includes(type)) return "line";
- if (type === 13) return "stamp";
+ if (
+ type === PdfAnnotationSubtype.CARET &&
+ (!toolId || toolId === "insertText" || toolId === "replaceText")
+ )
+ return "comment";
+ if (type === PdfAnnotationSubtype.FREETEXT) return "note";
+ if (
+ type !== undefined &&
+ SHAPE_SUBTYPES.includes(type as PdfAnnotationSubtype)
+ )
+ return "shape";
+ if (
+ type !== undefined &&
+ LINE_SUBTYPES.includes(type as PdfAnnotationSubtype)
+ )
+ return "line";
+ if (type === PdfAnnotationSubtype.STAMP) return "stamp";
return "unknown";
}, [annotation]);
@@ -174,8 +219,12 @@ export function useAnnotationMenuHandlers({
const currentColor = (() => {
if (!obj) return "#000000";
const type = obj.type;
- if (type === 3) return obj.textColor || obj.color || "#000000";
- if (type !== undefined && [4, 5, 6, 7, 8].includes(type))
+ if (type === PdfAnnotationSubtype.FREETEXT)
+ return obj.textColor || obj.color || "#000000";
+ if (
+ type !== undefined &&
+ STROKE_COLOR_SUBTYPES.includes(type as PdfAnnotationSubtype)
+ )
return obj.strokeColor || obj.color || "#000000";
return obj.color || obj.strokeColor || "#000000";
})();
@@ -247,17 +296,23 @@ export function useAnnotationMenuHandlers({
patch.contents = obj?.contents ?? "";
} else {
patch.color = color;
- if (type !== undefined && [9, 10, 11, 12].includes(type)) {
+ if (
+ type !== undefined &&
+ TEXT_MARKUP_SUBTYPES.includes(type as PdfAnnotationSubtype)
+ ) {
patch.strokeColor = color;
patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1;
}
- if (type !== undefined && [4, 8].includes(type)) {
+ if (
+ type !== undefined &&
+ LINE_SUBTYPES.includes(type as PdfAnnotationSubtype)
+ ) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
}
- if (type === 15) {
+ if (type === PdfAnnotationSubtype.INK) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
patch.opacity = obj?.opacity ?? 1;
diff --git a/frontend/src/core/contexts/AnnotationContext.tsx b/frontend/src/core/contexts/AnnotationContext.tsx
index cfddc0e14..09a4927e3 100644
--- a/frontend/src/core/contexts/AnnotationContext.tsx
+++ b/frontend/src/core/contexts/AnnotationContext.tsx
@@ -1,8 +1,15 @@
import React, { createContext, useContext, ReactNode, useRef } from "react";
-import type { AnnotationAPI } from "@app/components/viewer/viewerTypes";
+import type {
+ AnnotationAPI,
+ AnnotationToolId,
+} from "@app/components/viewer/viewerTypes";
interface AnnotationContextValue {
annotationApiRef: React.RefObject;
+ /** Ref to the panel-level activateAnnotationTool function — updates React state so buttons highlight correctly. Populated by Annotate.tsx. */
+ activateAnnotationToolRef: React.RefObject<
+ ((toolId: AnnotationToolId) => void) | null
+ >;
}
const AnnotationContext = createContext(
@@ -13,9 +20,13 @@ export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const annotationApiRef = useRef(null);
+ const activateAnnotationToolRef = useRef<
+ ((toolId: AnnotationToolId) => void) | null
+ >(null);
const value: AnnotationContextValue = {
annotationApiRef,
+ activateAnnotationToolRef,
};
return (
diff --git a/frontend/src/core/tools/Annotate.tsx b/frontend/src/core/tools/Annotate.tsx
index d44a20207..df95f88f8 100644
--- a/frontend/src/core/tools/Annotate.tsx
+++ b/frontend/src/core/tools/Annotate.tsx
@@ -6,6 +6,7 @@ import { useNavigation } from "@app/contexts/NavigationContext";
import { useFileSelection } from "@app/contexts/FileContext";
import { BaseToolProps } from "@app/types/tool";
import { useSignature } from "@app/contexts/SignatureContext";
+import { useAnnotation as useAnnotationContext } from "@app/contexts/AnnotationContext";
import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
import type {
AnnotationToolId,
@@ -85,6 +86,7 @@ const Annotate = (_props: BaseToolProps) => {
placementPreviewSize,
setPlacementPreviewSize,
} = useSignature();
+ const { activateAnnotationToolRef } = useAnnotationContext();
const viewerContext = useContext(ViewerContext);
const viewerContextRef = useRef(viewerContext);
useEffect(() => {
@@ -375,6 +377,9 @@ const Annotate = (_props: BaseToolProps) => {
}, 300);
};
+ // Keep ref in sync so external callers (e.g. CommentsSidebar) get the latest closure
+ activateAnnotationToolRef.current = activateAnnotationTool;
+
useEffect(() => {
// push style updates to EmbedPDF when sliders/colors change
if (activeTool === "stamp") {