make clicking on comments open the comments sidebar and more (#6174)

make clicking on comments open the comments sidebar and add a button to
create comments into the empty state of the comments sidebar
<img width="2056" height="1081" alt="Screenshot 2026-04-23 at 12 39
50 PM"
src="https://github.com/user-attachments/assets/6f15484d-d04f-4900-92c6-b2cc397d6d08"
/>

<img width="627" height="396" alt="Screenshot 2026-04-23 at 12 40 06 PM"
src="https://github.com/user-attachments/assets/509e5526-0082-4fc6-a98f-829bb4c1baf2"
/>
This commit is contained in:
EthanHealy01
2026-04-23 17:22:47 +01:00
committed by GitHub
parent 177c776658
commit 7c42d8018a
6 changed files with 237 additions and 49 deletions
@@ -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<HTMLDivElement>(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<string, unknown> }
).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({
<Group gap="sm" wrap="nowrap" justify="center">
<AnnotationTypeButtons
{...handlers}
isInSidebar={isInSidebar}
annotation={annotation}
documentId={documentId}
pageIndex={pageIndex}
@@ -58,9 +58,13 @@ export function AnnotationTypeButtons(props: AnnotationTypeButtonsProps) {
onCommentColorChange,
} = props;
const attachCommentButton = (
// When a comment is attached, show the same chat-bubble "View comment" button used by
// standalone comment annotations. When no comment, show the "Add comment" attach button.
const attachCommentButton = isInSidebar ? (
<CommentButton hasContent={hasCommentContent} onClick={onViewComment} />
) : (
<AttachCommentButton
isInSidebar={isInSidebar}
isInSidebar={false}
onView={onViewComment}
onAdd={onAddToSidebar}
/>
@@ -221,10 +225,6 @@ export function AnnotationTypeButtons(props: AnnotationTypeButtonsProps) {
return (
<>
{attachCommentButton}
<CommentButton
hasContent={hasCommentContent}
onClick={onViewComment}
/>
<LinkButton
firstLinkTarget={firstLinkTarget}
onGoToLink={onGoToLink}
@@ -22,13 +22,37 @@ 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 { PdfAnnotationSubtype, PdfAnnotationReplyType } from "@embedpdf/models";
import {
PdfAnnotationSubtype,
PdfAnnotationReplyType,
type PdfAnnotationObject,
type PdfTextAnnoObject,
} from "@embedpdf/models";
import { useCommentAuthor } from "@app/contexts/CommentAuthorContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useAnnotation as useAnnotationContext } from "@app/contexts/AnnotationContext";
import LocalIcon from "@app/components/shared/LocalIcon";
const SIDEBAR_WIDTH = "18rem";
/** PDF subtypes that are inherently standalone comment annotations (not linked to other annotations). */
const STANDALONE_COMMENT_SUBTYPES = new Set([
PdfAnnotationSubtype.TEXT,
PdfAnnotationSubtype.FREETEXT,
PdfAnnotationSubtype.CARET,
]);
function isStandaloneCommentType(type: number | undefined): boolean {
return (
type !== undefined &&
STANDALONE_COMMENT_SUBTYPES.has(type as PdfAnnotationSubtype)
);
}
const ANNOTATE_PANEL_ID = "annotate" as const;
const TEXT_COMMENT_TOOL_ID = "textComment" as const;
/** Format annotation date for display (e.g. "Mar 11, 6:05 PM"). */
function formatCommentDate(obj: any): string {
const raw =
@@ -143,8 +167,20 @@ 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;
// CARET (type 14) = insertText/replaceText; TEXT (type 1) = textComment
if (!toolId && (ann?.type === 14 || ann?.type === 1)) return true;
const type = ann?.type;
// Standalone comment types (TEXT, FREETEXT, CARET) without a toolId are always comments
if (!toolId && isStandaloneCommentType(type)) return true;
// Non-standalone annotations with non-empty contents: customData (including isComment and
// toolId) is NOT persisted to PDF, but `contents` is a standard PDF field and survives
// save/reload. Exclude standalone comment types (TEXT, FREETEXT, CARET) which use
// `contents` for their own annotation text. Exclude replies.
if (
type !== undefined &&
!isStandaloneCommentType(type) &&
!ann?.inReplyToId &&
(ann?.contents ?? "").trim().length > 0
)
return true;
return false;
}
@@ -213,6 +249,8 @@ export function CommentsSidebar({
} = useViewer() ?? {};
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const { state, provides } = useAnnotation(documentId);
const { handleToolSelectForced } = useToolWorkflow();
const { activateAnnotationToolRef } = useAnnotationContext();
const [draftContents, setDraftContents] = useState<Record<string, string>>(
{},
);
@@ -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<string, any> = (state as any)?.byUid ?? {};
const ids = new Set<string>();
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<string, unknown>;
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<PdfAnnotationObject>);
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 }}
/>
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
<Text fw={600} size="sm" tt="uppercase" lts={0.5} style={{ flex: 1 }}>
{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>
)}
</div>
<ScrollArea style={{ flex: 1 }}>
<Stack p="sm" gap="md">
{totalCount === 0 ? (
<Text size="sm" c="dimmed">
<Stack align="center" gap="sm" py="lg">
<LocalIcon
icon="comment"
width="2rem"
height="2rem"
style={{ color: "var(--mantine-color-dimmed)" }}
/>
<Text size="sm" c="dimmed" ta="center">
{t(
"viewer.comments.hint",
"Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
)}
</Text>
<Button
variant="light"
size="xs"
onClick={handleAddComment}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
{t("viewer.comments.addComment", "Add comment")}
</Button>
</Stack>
) : (
pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
@@ -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;
@@ -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<AnnotationAPI | null>;
/** 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<AnnotationContextValue | undefined>(
@@ -13,9 +20,13 @@ export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const annotationApiRef = useRef<AnnotationAPI>(null);
const activateAnnotationToolRef = useRef<
((toolId: AnnotationToolId) => void) | null
>(null);
const value: AnnotationContextValue = {
annotationApiRef,
activateAnnotationToolRef,
};
return (
+5
View File
@@ -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") {