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 Screenshot 2026-04-23 at 12 39
50 PM Screenshot 2026-04-23 at 12 40 06 PM --- .../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.", + )} + + + ) : ( 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") {