import { useMemo, useState, useCallback, useEffect, useRef } from 'react'; import { Box, ScrollArea, Text, Textarea, Stack, ActionIcon, Group, Tooltip, TextInput, Menu, Modal, Button, UnstyledButton } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import DeleteIcon from '@mui/icons-material/Delete'; import CheckIcon from '@mui/icons-material/CheckRounded'; 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 { PdfAnnotationSubtype, PdfAnnotationReplyType } from '@embedpdf/models'; import { useCommentAuthor } from '@app/contexts/CommentAuthorContext'; import { useViewer } from '@app/contexts/ViewerContext'; import LocalIcon from '@app/components/shared/LocalIcon'; const SIDEBAR_WIDTH = '18rem'; /** Format annotation date for display (e.g. "Mar 11, 6:05 PM"). */ function formatCommentDate(obj: any): string { const raw = obj?.modifiedDate ?? obj?.creationDate ?? obj?.customData?.modifiedDate ?? obj?.M; if (raw == null) return ''; const d = raw instanceof Date ? raw : new Date(raw); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } interface CommentsSidebarProps { documentId: string; visible: boolean; rightOffset: string; } function getCommentDisplayContent(entry: { annotation: { object: any }; replies: Array<{ object: any }> }): string { const main = entry.annotation?.object?.contents; if (main != null && String(main).trim()) return String(main).trim(); const firstReply = entry.replies?.[0]?.object?.contents; if (firstReply != null && String(firstReply).trim()) return String(firstReply).trim(); return ''; } /** 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 { const stored = (obj?.author ?? 'Guest').trim() || 'Guest'; if (PLACEHOLDER_AUTHORS.has(stored)) return currentDisplayName || 'Guest'; return stored; } /** Replies store an explicit author; only allow edit when it matches the current comment author name. */ function isReplyAuthoredByCurrentUser(obj: any, currentDisplayName: string): boolean { const stored = (obj?.author ?? '').trim() || 'Guest'; // Resolve current user the same way getAuthorName does const resolvedMine = PLACEHOLDER_AUTHORS.has((currentDisplayName ?? '').trim()) ? 'Guest' : (currentDisplayName ?? '').trim(); const resolvedStored = PLACEHOLDER_AUTHORS.has(stored) ? 'Guest' : stored; // Both are guest/anonymous โ†’ same unauthenticated user, allow editing if (resolvedStored === 'Guest' && resolvedMine === 'Guest') return true; if (!resolvedMine) return false; return resolvedStored === resolvedMine; } // Map toolId โ†’ LocalIcon icon name (matches AnnotationPanel icon definitions) const TOOL_ICON_MAP: Record = { highlight: 'highlight', underline: 'format-underlined', strikeout: 'strikethrough-s', squiggly: 'show-chart', ink: 'edit', inkHighlighter: 'brush', square: 'crop-square', circle: 'radio-button-unchecked', line: 'show-chart', lineArrow: 'show-chart', polyline: 'show-chart', polygon: 'change-history', text: 'text-fields', note: 'sticky-note-2', stamp: 'add-photo-alternate', textComment: 'comment', insertText: 'add-comment', replaceText: 'find-replace', }; // Type-based fallback icon when no toolId is present function getIconByType(type: number | undefined): string { if (type === 1) return 'comment'; if (type === 3) return 'sticky-note-2'; if (type === 4 || type === 8) return 'show-chart'; if (type === 5) return 'crop-square'; if (type === 6) return 'radio-button-unchecked'; if (type === 7 || type === 8) return 'change-history'; if (type === 9) return 'highlight'; if (type === 10) return 'format-underlined'; if (type === 11) return 'show-chart'; if (type === 12) return 'strikethrough-s'; if (type === 13) return 'add-photo-alternate'; if (type === 14) return 'add-comment'; if (type === 15) return 'edit'; return 'comment'; } function isCommentAnnotation(ann: any): boolean { const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId; if (toolId === 'textComment' || toolId === 'insertText' || toolId === 'replaceText') 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; return false; } function getAnnotationToolId(ann: any): string { return ann?.customData?.toolId ?? ann?.customData?.annotationToolId ?? ''; } function getAnnotationTypeLabel(ann: any, t: (key: string, fallback: string) => string): string { const toolId = getAnnotationToolId(ann); const labels: Record = { highlight: t('annotation.highlight', 'Highlight'), underline: t('annotation.underline', 'Underline'), strikeout: t('annotation.strikeout', 'Strikeout'), squiggly: t('annotation.squiggly', 'Squiggly'), ink: t('annotation.pen', 'Pen'), inkHighlighter: t('annotation.freehandHighlighter', 'Freehand Highlighter'), square: t('annotation.square', 'Square'), circle: t('annotation.circle', 'Circle'), line: t('annotation.line', 'Line'), lineArrow: t('annotation.lineArrow', 'Arrow'), polyline: t('annotation.polyline', 'Polyline'), polygon: t('annotation.polygon', 'Polygon'), text: t('annotation.text', 'Text box'), note: t('annotation.note', 'Note'), stamp: t('annotation.stamp', 'Stamp'), textComment: t('viewer.comments.typeComment', 'Comment'), insertText: t('viewer.comments.typeInsertText', 'Insert Text'), replaceText: t('viewer.comments.typeReplaceText', 'Replace Text'), }; if (labels[toolId]) return labels[toolId]; // Type-based fallback (mirrors getIconByType) for annotations without customData.toolId const type = ann?.type; if (type === 14) return t('viewer.comments.typeInsertText', 'Insert Text'); if (type === 1) return t('viewer.comments.typeComment', 'Comment'); return t('viewer.comments.typeComment', 'Comment'); } function AnnotationTypeIcon({ ann }: { ann: any }) { const toolId = getAnnotationToolId(ann); const iconName = TOOL_ICON_MAP[toolId] ?? getIconByType(ann?.type); return ( ); } export function CommentsSidebar({ documentId, visible, rightOffset }: CommentsSidebarProps) { const { t } = useTranslation(); const { displayName } = useCommentAuthor(); const { highlightCommentRequest, clearHighlightCommentRequest, scrollActions, getZoomState } = useViewer() ?? {}; const scrollViewportRef = useRef(null); const { state, provides } = useAnnotation(documentId); const [draftContents, setDraftContents] = useState>({}); const [replyDrafts, setReplyDrafts] = useState>({}); /** Draft text while editing an existing reply (`${pageIndex}_${parentId}_${replyId}`). */ const [replyEditDrafts, setReplyEditDrafts] = useState>({}); /** When set, this card's main comment is in edit mode (show textarea for main comment). */ const [editingMainKey, setEditingMainKey] = useState(null); /** Which reply is in edit mode (same key shape as replyEditDrafts). */ const [editingReplyKey, setEditingReplyKey] = useState(null); // React to request to focus or highlight a comment card (e.g. from "Add comment" / "View comment" in selection menu) useEffect(() => { if (!visible || !highlightCommentRequest || highlightCommentRequest.documentId !== documentId) return; const { pageIndex, annotationId, action } = highlightCommentRequest; const cardKey = `${pageIndex}_${annotationId}`; const root = scrollViewportRef.current; if (!root) return; const card = root.querySelector(`[data-comment-card="${cardKey}"]`); if (!card) { clearHighlightCommentRequest?.(); return; } card.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (action === 'highlight') { card.classList.remove('comment-card-flash-highlight'); void card.offsetWidth; card.classList.add('comment-card-flash-highlight'); const tId = window.setTimeout(() => { card.classList.remove('comment-card-flash-highlight'); clearHighlightCommentRequest?.(); }, 1500); return () => window.clearTimeout(tId); } // action === 'focus': focus the first textarea or reply input in the card const input = card.querySelector('textarea, input'); if (input) { requestAnimationFrame(() => { input.focus(); }); } clearHighlightCommentRequest?.(); }, [visible, highlightCommentRequest, documentId, clearHighlightCommentRequest]); const handleLocateAnnotation = useCallback((pageIndex: number, ann: any) => { scrollActions?.scrollToPage(pageIndex + 1, 'smooth'); setTimeout(() => { const pageEl = document.querySelector(`[data-page-index="${pageIndex}"]`); if (!pageEl || !ann?.rect) return; const zoom = getZoomState?.()?.currentZoom ?? 1; const { origin, size } = ann.rect as { origin: { x: number; y: number }; size: { width: number; height: number } }; const flashEl = document.createElement('div'); // Append to page element so it scrolls with the page (position: absolute relative to page) flashEl.style.cssText = ` position: absolute; left: ${origin.x * zoom}px; top: ${origin.y * zoom}px; width: ${size.width * zoom}px; height: ${size.height * zoom}px; background: rgba(255, 213, 0, 0.55); border: 2px solid rgba(255, 170, 0, 0.8); border-radius: 3px; pointer-events: none; z-index: 9998; animation: annotation-locate-flash 1.6s ease-out forwards; `; if (!document.getElementById('annotation-locate-flash-style')) { const style = document.createElement('style'); style.id = 'annotation-locate-flash-style'; style.textContent = `@keyframes annotation-locate-flash { 0% { opacity: 0; transform: scale(1.08); } 15% { opacity: 1; transform: scale(1); } 70% { opacity: 1; } 100% { opacity: 0; } }`; document.head.appendChild(style); } pageEl.appendChild(flashEl); setTimeout(() => flashEl.remove(), 1700); }, 550); }, [scrollActions, getZoomState]); const byPage = useMemo(() => { try { 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) ); if (commentEntries.length > 0) { filtered[Number(page)] = commentEntries; } } return filtered; } catch { return {}; } }, [state]); // Derive the set of selected annotation IDs from EmbedPDF's selection state. // 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; if (annId) ids.add(annId); } return ids; }, [state]); const pageNumbers = useMemo(() => Object.keys(byPage).map(Number).sort((a, b) => a - b), [byPage]); const totalCount = useMemo(() => pageNumbers.reduce((sum, p) => sum + (byPage[p]?.length ?? 0), 0), [pageNumbers, byPage]); const handleContentsChange = useCallback( (pageIndex: number, annotationId: string, value: string) => { setDraftContents((prev) => ({ ...prev, [pageIndex + '_' + annotationId]: value })); if (!provides?.updateAnnotation) return; provides.updateAnnotation(pageIndex, annotationId, { contents: value }); }, [provides] ); const [deleteModal, setDeleteModal] = useState<{ pageIndex: number; id: string; ann: any } | null>(null); const isLinkedAnnotation = (ann: any) => { const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId; return ann?.customData?.isComment === true && toolId !== 'textComment' && toolId !== 'insertText' && toolId !== 'replaceText'; }; const handleDeleteClick = useCallback( (pageIndex: number, annotationId: string, ann: any) => { if (isLinkedAnnotation(ann)) { setDeleteModal({ pageIndex, id: annotationId, ann }); } else { provides?.deleteAnnotation?.(pageIndex, annotationId); } }, [provides] ); const handleRemoveFromSidebar = useCallback(() => { if (!deleteModal || !provides?.updateAnnotation) return; const { pageIndex, id, ann } = deleteModal; const existing = (ann?.customData ?? {}) as Record; const { isComment: _removed, ...rest } = existing; provides.updateAnnotation(pageIndex, id, { customData: rest } as any); setDeleteModal(null); }, [deleteModal, provides]); const handleDeleteAnnotation = useCallback(() => { if (!deleteModal) return; provides?.deleteAnnotation?.(deleteModal.pageIndex, deleteModal.id); setDeleteModal(null); }, [deleteModal, provides]); const handleSendMainComment = useCallback( (pageIndex: number, annotationId: string, value: string) => { const trimmed = value.trim(); if (!trimmed || !provides?.updateAnnotation) return; provides.updateAnnotation(pageIndex, annotationId, { contents: trimmed, author: displayName }); setDraftContents((prev) => ({ ...prev, [pageIndex + '_' + annotationId]: trimmed })); }, [provides, displayName] ); const handleSendReply = useCallback( (pageIndex: number, parentId: string, parentRect: any) => { const key = `${pageIndex}_${parentId}_reply`; const text = replyDrafts[key]?.trim(); if (!text || !provides?.createAnnotation) return; const rect = parentRect ?? { origin: { x: 0, y: 0 }, size: { width: 1, height: 1 } }; provides.createAnnotation(pageIndex, { type: PdfAnnotationSubtype.TEXT, id: `reply-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, pageIndex, rect, contents: text, inReplyToId: parentId, replyType: PdfAnnotationReplyType.Reply, author: displayName, } as any); setReplyDrafts((prev) => ({ ...prev, [key]: '' })); }, [provides, replyDrafts, displayName] ); const handleSaveReplyEdit = useCallback( (editKey: string, pageIndex: number, replyId: string, value: string) => { const trimmed = value.trim(); if (!trimmed || !provides?.updateAnnotation) return; provides.updateAnnotation(pageIndex, replyId, { contents: trimmed, author: displayName }); setReplyEditDrafts((prev) => { const next = { ...prev }; delete next[editKey]; return next; }); setEditingReplyKey(null); }, [provides, displayName] ); if (!visible) return null; return (
{t('viewer.comments.title', 'Comments')}
{totalCount === 0 ? ( {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] ?? []; const pageNum = pageIndex + 1; return ( {t('viewer.comments.pageLabel', 'Page {{page}}', { page: pageNum })} {entries.length === 1 ? t('viewer.comments.oneComment', '1 comment') : t('viewer.comments.nComments', '{{count}} comments', { count: entries.length })} {entries.map((entry) => { const ann = entry.annotation?.object; const id = ann?.id; if (!id) return null; const key = `${pageIndex}_${id}`; const replyKey = `${pageIndex}_${id}_reply`; const displayContent = getCommentDisplayContent(entry); const draft = draftContents[key] !== undefined ? draftContents[key] : displayContent; const replyDraft = replyDrafts[replyKey] ?? ''; const authorName = getAuthorName(ann, displayName); /** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */ const hasMainContent = (displayContent ?? '').trim().length > 0; const isEditingMain = editingMainKey === key; const mainTimestamp = formatCommentDate(ann); const typeLabel = getAnnotationTypeLabel(ann, t); return ( {authorName} {typeLabel}{mainTimestamp ? ` ยท ${mainTimestamp}` : ''} handleLocateAnnotation(pageIndex, ann)} > } onClick={() => setEditingMainKey(key)} > {t('annotation.editText', 'Edit')} } color="red" onClick={() => handleDeleteClick(pageIndex, id, ann)} > {t('annotation.delete', 'Delete')} {!hasMainContent || isEditingMain ? ( <>