frontend/src/js/components/viewer/CommentPanel/CommentPanel.tsx (184 lines of code) (raw):
import React, {FC, useState} from "react";
import { Comment } from 'semantic-ui-react';
import hdate from 'human-date';
import { postComment, deleteComment } from "../../../services/CommentsApi";
import { PartialUser } from "../../../types/User";
import { ResourceRange, CommentData, CommentAnchor } from "../../../types/Resource";
import { AddComment } from "./AddComment";
import { filterCommentsInView } from "../../../util/commentUtils";
import { HighlightRenderedPositions } from "../TextPreview";
import { sortBy, sumBy, takeWhile } from 'lodash';
type CommentPanelProps = {
uri: string;
view: string;
currentUser: PartialUser;
comments: CommentData[],
selection?: ResourceRange,
highlightRenderedPositions: HighlightRenderedPositions,
focusedCommentId?: string,
previousFocusedCommentId?: string,
getComments: (uri: string) => void,
clearSelection: () => void,
focusComment: (id: string) => void
}
function selectionToAnchor(view: string, selection: ResourceRange): CommentAnchor {
if(view.startsWith("ocr.")) {
const language = view.split(".")[1];
return {
type: 'ocr',
language,
startCharacter: selection.startCharacter,
endCharacter: selection.endCharacter
}
}
return {
type: 'text',
startCharacter: selection.startCharacter,
endCharacter: selection.endCharacter
}
}
type CommentLayout = { id: string, height: number, postedAt: number };
export type CommentGroup = { top: number, height: number, comments: CommentLayout[] };
// Google Docs maintains comments against the same line of text in order of insertion. If you select a comment below, all the
// comments against the line above are pushed up. This stops the UI feeling like it "jumps about".
export function groupCommentsByTop(comments: CommentData[], highlightRenderedPositions: HighlightRenderedPositions, commentHeights: { [commentId: string]: number }, margin: number): CommentGroup[] {
const grouped: Map<number, CommentLayout[]> = new Map();
for(const { id, postedAt } of comments) {
// Has the comment been mounted in the DOM yet?
const top = highlightRenderedPositions[id]?.top;
const height = commentHeights[id];
if(top !== undefined && height !== undefined) {
const before = grouped.get(top) || [];
const after = [...before, { id, height, postedAt }];
grouped.set(top, after);
}
}
// Return comments in the order of the lines they are attached to
return sortBy([...grouped.entries()].map(([top, comments]) => {
return {
top,
height: sumBy(comments, 'height') + (margin * comments.length),
// Within each line, order by insertion (with the timestamp being the best proxy we have for that at the moment)
comments: sortBy(comments, 'postedAt')
};
}), 'top');
}
export function layoutComments(groups: CommentGroup[], margin: number, rootCommentId?: string): { [commentId: string]: number } {
if(groups.length === 0) {
return {};
}
// If a comment has been focused (ie selected by the user or the last one they have previously selected) then we want
// it to be next to the line it is attached to. Find the group that contains the focused comment.
const root = groups.find(({ comments }) => comments.some(({ id }) => id === rootCommentId));
const modifiedGroups = ([] as CommentGroup[]);
let ixRoot: number | undefined = undefined;
let offset = groups[0].top;
// Lay out the comment groups in order
groups.forEach((group, ix) => {
const modifiedGroup = { ...group };
if(root !== undefined && group.top === root.top) {
ixRoot = ix;
// Pull up the root group (if needed) so the focused comment is in the center
const beforeRootInGroup = takeWhile(root.comments, ({ id }) => id !== rootCommentId);
const offsetWithinGroup = sumBy(beforeRootInGroup, 'height') + (margin * beforeRootInGroup.length);
modifiedGroup.top -= offsetWithinGroup;
} else if(group.top < offset) {
// Push down the group so that it doesn't overlap with the previous group
modifiedGroup.top = offset;
}
modifiedGroups.push(modifiedGroup);
offset = (modifiedGroup.top + modifiedGroup.height) + margin;
});
if(ixRoot !== undefined) {
// Pull up any groups above the now relocated root group
let offset = modifiedGroups[ixRoot].top;
for(let i = (ixRoot - 1); i >= 0; i--) {
const group = modifiedGroups[i];
const overlap = (group.top + group.height) - offset;
if(overlap > 0) {
group.top -= overlap;
}
offset = group.top;
}
}
// Collapse each group into the individual comments and their positions
const ret = ({} as { [commentId: string]: number });
for(const group of modifiedGroups) {
let offset = 0;
for(const comment of group.comments) {
ret[comment.id] = group.top + offset;
offset += comment.height + margin;
}
}
return ret;
}
export const CommentPanel: FC<CommentPanelProps> = ({uri, view, currentUser, comments, selection, highlightRenderedPositions, focusedCommentId, previousFocusedCommentId, getComments, clearSelection, focusComment }) => {
const [sendingComment, setSendingComment] = useState(false);
const [commentHeights, setCommentHeights] = useState<{ [commentId: string]: number }>({});
const postCommentRefresh = (newComment: string) => {
const anchor = selection ? selectionToAnchor(view, selection) : undefined;
setSendingComment(true);
postComment(uri, newComment, anchor)
.then(() => {
setSendingComment(false)
clearSelection();
getComments(uri)
})
};
const deleteCommentRefresh = (commentId: string) => {
deleteComment(commentId)
.then(() => {
getComments(uri)
})
};
const onCommentMount = (id: string) => {
return (element: HTMLDivElement | null) => {
if(element && commentHeights[id] !== element.offsetHeight) {
setCommentHeights({ ...commentHeights, [id]: element.offsetHeight });
}
};
};
const onCommentClick = (id: string) => {
return (e: React.MouseEvent) => {
e.stopPropagation();
focusComment(id);
};
};
const visibleComments = filterCommentsInView(comments, view);
if(comments.length === 0 && selection === undefined) {
return <React.Fragment></React.Fragment>;
}
const margin = 10; // px
const groups = groupCommentsByTop(comments, highlightRenderedPositions, commentHeights, margin);
const positions = layoutComments(groups, margin, focusedCommentId || previousFocusedCommentId);
return <div className="comments-sidebar">
{visibleComments
.sort((a, b) => a.postedAt - b.postedAt)
.map((c, ix) => {
const style = {
// Mount the comment in the DOM to get the height but only show it once we've been able to lay it out
display: c.id in positions ? 'block' : 'none',
top: positions[c.id] !== undefined ? `${positions[c.id]}px` : `0px`,
left: c.id === focusedCommentId ? `-15px` : `0px`,
zIndex: c.id === focusedCommentId ? visibleComments.length : ix,
cursor: 'pointer'
}
return (
<div key={c.id} className="comment comment--animatable" style={style} onClick={onCommentClick(c.id)} ref={onCommentMount(c.id)}>
<Comment.Group>
<Comment>
<Comment.Content>
<Comment.Author as='div'>{c.author.displayName}</Comment.Author>
<Comment.Metadata>
<div>{hdate.prettyPrint(new Date(c.postedAt), {showTime: true})}</div>
</Comment.Metadata>
<Comment.Text>
{c.text}
</Comment.Text>
{currentUser.username === c.author.username && <Comment.Actions>
<Comment.Action onClick={() => deleteCommentRefresh(c.id)}>Delete</Comment.Action>
</Comment.Actions>}
</Comment.Content>
</Comment>
</Comment.Group>
</div>
);
})}
{(selection || sendingComment) ?
<AddComment
sendingComment={sendingComment}
numberOfComments={visibleComments.length}
top={highlightRenderedPositions['new-comment']?.top}
onSubmit={postCommentRefresh}
onCancel={clearSelection}
/>
: false}
</div>
};