mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54:06 +02:00
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]>
705 lines
33 KiB
TypeScript
705 lines
33 KiB
TypeScript
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>
|
|
);
|
|
}
|