mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Bump/embed pdfv2.8.0 (#5921)
please merge #5919, alternatively, just push this and delete that PR because this is a continuation of that. This PR bumps the embed PDF version to 2.8.0 and also adds comments functionaliy --------- Co-authored-by: ConnorYoh <[email protected]> Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
ConnorYoh
Anthony Stirling
parent
41945543e0
commit
c46156f37f
@@ -0,0 +1,704 @@
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<LocalIcon
|
||||
icon={iconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ flexShrink: 0, color: 'var(--mantine-color-blue-5)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentsSidebar({ documentId, visible, rightOffset }: CommentsSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { displayName } = useCommentAuthor();
|
||||
const { highlightCommentRequest, clearHighlightCommentRequest, scrollActions, getZoomState } = useViewer() ?? {};
|
||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const { state, provides } = useAnnotation(documentId);
|
||||
const [draftContents, setDraftContents] = useState<Record<string, string>>({});
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
/** Draft text while editing an existing reply (`${pageIndex}_${parentId}_${replyId}`). */
|
||||
const [replyEditDrafts, setReplyEditDrafts] = useState<Record<string, string>>({});
|
||||
/** When set, this card's main comment is in edit mode (show textarea for main comment). */
|
||||
const [editingMainKey, setEditingMainKey] = useState<string | null>(null);
|
||||
/** Which reply is in edit mode (same key shape as replyEditDrafts). */
|
||||
const [editingReplyKey, setEditingReplyKey] = useState<string | null>(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<HTMLElement>(`[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<HTMLTextAreaElement | HTMLInputElement>('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<HTMLElement>(`[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<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;
|
||||
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<string, unknown>;
|
||||
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 (
|
||||
<Box
|
||||
ref={scrollViewportRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: rightOffset,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
backgroundColor: 'var(--right-rail-bg)',
|
||||
borderLeft: '1px solid var(--border-subtle)',
|
||||
zIndex: 998,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '-2px 0 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="comment" width="1.25rem" height="1.25rem" style={{ color: 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
|
||||
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
|
||||
{t('viewer.comments.title', 'Comments')}
|
||||
</Text>
|
||||
</div>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack p="sm" gap="md">
|
||||
{totalCount === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('viewer.comments.hint', 'Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.')}
|
||||
</Text>
|
||||
) : (
|
||||
pageNumbers.map((pageIndex) => {
|
||||
const entries = byPage[pageIndex] ?? [];
|
||||
const pageNum = pageIndex + 1;
|
||||
return (
|
||||
<Box key={pageIndex} mb="md">
|
||||
<Text size="sm" fw={700} mb={2}>
|
||||
{t('viewer.comments.pageLabel', 'Page {{page}}', { page: pageNum })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{entries.length === 1
|
||||
? t('viewer.comments.oneComment', '1 comment')
|
||||
: t('viewer.comments.nComments', '{{count}} comments', { count: entries.length })}
|
||||
</Text>
|
||||
<Box
|
||||
mb="xs"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
{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 (
|
||||
<Box
|
||||
key={key}
|
||||
data-comment-card={key}
|
||||
p="sm"
|
||||
style={{
|
||||
border: selectedAnnotationIds.has(id)
|
||||
? '1px solid var(--mantine-color-blue-3)'
|
||||
: '1px solid var(--border-subtle)',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--bg-raised)',
|
||||
}}
|
||||
>
|
||||
<Group wrap="nowrap" gap="xs" justify="space-between" align="flex-start" mb="xs">
|
||||
<Group wrap="nowrap" gap="xs" style={{ minWidth: 0, flex: 1 }}>
|
||||
<AnnotationTypeIcon ann={ann} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{authorName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{typeLabel}{mainTimestamp ? ` · ${mainTimestamp}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Tooltip label={t('viewer.comments.locateAnnotation', 'Locate in document')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={() => handleLocateAnnotation(pageIndex, ann)}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 16 }} />
|
||||
</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={<EditIcon style={{ fontSize: 18 }} />}
|
||||
onClick={() => setEditingMainKey(key)}
|
||||
>
|
||||
{t('annotation.editText', 'Edit')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 18 }} />}
|
||||
color="red"
|
||||
onClick={() => handleDeleteClick(pageIndex, id, ann)}
|
||||
>
|
||||
{t('annotation.delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasMainContent || isEditingMain ? (
|
||||
<>
|
||||
<Textarea
|
||||
placeholder={t('viewer.comments.addCommentPlaceholder', 'Add comment...')}
|
||||
minRows={2}
|
||||
autosize
|
||||
value={draft ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
|
||||
setDraftContents((prev) => ({ ...prev, [key]: v }));
|
||||
if (isEditingMain) {
|
||||
handleContentsChange(pageIndex, id, v);
|
||||
}
|
||||
}}
|
||||
styles={{ root: { width: '100%' } }}
|
||||
mb="xs"
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
<Tooltip label={t('viewer.comments.addComment', 'Add comment')}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
handleSendMainComment(pageIndex, id, draft ?? '');
|
||||
setEditingMainKey(null);
|
||||
}}
|
||||
disabled={!(draft ?? '').trim()}
|
||||
>
|
||||
<CheckIcon style={{ fontSize: 18, color: 'white' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" mb="sm" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{displayContent}
|
||||
</Text>
|
||||
|
||||
{entry.replies?.length ? (
|
||||
<Stack gap="sm" mb="sm">
|
||||
{entry.replies.map((r) => {
|
||||
const rObj = r?.object;
|
||||
const rId = rObj?.id;
|
||||
if (!rId) return null;
|
||||
const rAuthor = getAuthorName(rObj, displayName);
|
||||
const rTimestamp = formatCommentDate(rObj);
|
||||
const replyEditKey = `${pageIndex}_${id}_${rId}`;
|
||||
const isEditingReply = editingReplyKey === replyEditKey;
|
||||
const canEditReply = isReplyAuthoredByCurrentUser(rObj, displayName);
|
||||
const replyBody =
|
||||
replyEditDrafts[replyEditKey] !== undefined
|
||||
? replyEditDrafts[replyEditKey]
|
||||
: (rObj?.contents ?? '');
|
||||
return (
|
||||
<Box key={rId} pl="xs" style={{ borderLeft: '2px solid var(--mantine-color-blue-3)' }}>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group wrap="nowrap" justify="space-between" align="flex-start" gap={4} mb={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{rAuthor}
|
||||
</Text>
|
||||
<Group wrap="nowrap" gap="xs" align="center">
|
||||
{canEditReply && !isEditingReply ? (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingReplyKey(replyEditKey);
|
||||
setReplyEditDrafts(() => ({
|
||||
[replyEditKey]: String(rObj?.contents ?? ''),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
{t('annotation.editText', 'Edit')}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
) : null}
|
||||
{rTimestamp ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{rTimestamp}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
{isEditingReply ? (
|
||||
<>
|
||||
<Textarea
|
||||
minRows={2}
|
||||
autosize
|
||||
value={replyBody}
|
||||
onChange={(e) => {
|
||||
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
|
||||
setReplyEditDrafts((p) => ({ ...p, [replyEditKey]: v }));
|
||||
}}
|
||||
styles={{ root: { width: '100%' } }}
|
||||
mb="xs"
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
<Tooltip label={t('viewer.comments.saveReply', 'Save reply')}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
handleSaveReplyEdit(replyEditKey, pageIndex, rId, replyBody)
|
||||
}
|
||||
disabled={!replyBody.trim()}
|
||||
>
|
||||
<CheckIcon style={{ fontSize: 18, color: 'white' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{rObj?.contents ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
placeholder={t('viewer.comments.addReplyPlaceholder', 'Add reply...')}
|
||||
size="xs"
|
||||
value={replyDraft}
|
||||
onChange={(e) => {
|
||||
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
|
||||
setReplyDrafts((p) => ({ ...p, [replyKey]: v }));
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: 'var(--mantine-color-blue-3)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip label={t('viewer.comments.addComment', 'Add comment')}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="md"
|
||||
color="blue"
|
||||
style={{ backgroundColor: 'var(--mantine-color-blue-6)' }}
|
||||
onClick={() => handleSendReply(pageIndex, id, ann?.rect)}
|
||||
disabled={!replyDraft.trim()}
|
||||
>
|
||||
<CheckIcon style={{ fontSize: 20, color: 'white' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Modal
|
||||
opened={!!deleteModal}
|
||||
onClose={() => setDeleteModal(null)}
|
||||
title={t('viewer.comments.deleteTitle', 'Remove annotation from comments?')}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
{t(
|
||||
'viewer.comments.deleteDescription',
|
||||
'This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything.'
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={handleRemoveFromSidebar}>
|
||||
{t('viewer.comments.removeCommentOnly', 'Remove comment only')}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDeleteAnnotation}>
|
||||
{t('viewer.comments.deleteAnnotationAndComment', 'Delete annotation & comment')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user