Files
outline/app/scenes/Document/components/Comments/CommentThread.tsx
Tom Moor 7426ed785f fix: New comment auto-focus (#10911)
* refactor

* fix: autoFocus on comment editor
2025-12-14 17:10:18 -05:00

360 lines
11 KiB
TypeScript

import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, hover } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useCurrentUser from "~/hooks/useCurrentUser";
import { sidebarAppearDuration } from "~/styles/animations";
import CommentForm from "./CommentForm";
import CommentThreadItem from "./CommentThreadItem";
type Props = {
/** The document that this comment thread belongs to */
document: Document;
/** The root comment to render */
comment: Comment;
/** Whether the thread is focused */
focused: boolean;
/** Whether the thread is displayed in a recessed/backgrounded state */
recessed: boolean;
/** Number of replies before collapsing */
collapseThreshold?: number;
/** Number of replies to display when collapsed */
collapseNumDisplayed?: number;
};
function CommentThread({
comment: thread,
document,
recessed,
focused,
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor, setFocusedCommentId } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const user = useCurrentUser();
const can = usePolicy(document);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document.id}-${thread.id}`,
undefined
);
// Track edit states for all comments in the thread
const [editingCommentIds, setEditingCommentIds] = React.useState<Set<string>>(
new Set()
);
const canReply = can.comment && !thread.isResolved;
const highlightedText = ProsemirrorHelper.getAnchorTextForComment(
editor?.getComments() ?? [],
thread.id
);
const commentsInThread = comments
.inThread(thread.id)
.filter((comment) => !comment.isNew);
const [collapse, setCollapse] = React.useState(() => {
const numReplies = commentsInThread.length - 1;
if (numReplies >= collapseThreshold) {
return {
begin: 1,
final: commentsInThread.length - collapseNumDisplayed - 1,
};
}
return null;
});
useOnClickOutside(topRef, (event) => {
if (
focused &&
!(event.target as HTMLElement).classList.contains("comment") &&
event.defaultPrevented === false
) {
setFocusedCommentId(null);
}
});
const handleSubmit = React.useCallback(() => {
editor?.updateComment(thread.id, { draft: false });
}, [editor, thread.id]);
const handleClickThread = () => {
setFocusedCommentId(thread.id);
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
ev.stopPropagation();
setCollapse(null);
};
const handleUpArrowAtStart = React.useCallback(() => {
// Find the previous comment by the current user in reverse order
const userComments = commentsInThread
.filter((comment) => comment.createdById === user.id)
.reverse(); // Start from most recent
if (userComments.length > 0) {
const previousComment = userComments[0];
setEditingCommentIds((prev) => new Set(prev).add(previousComment.id));
}
}, [commentsInThread, user.id]);
const handleCommentEditStart = React.useCallback((commentId: string) => {
setEditingCommentIds((prev) => new Set(prev).add(commentId));
}, []);
const handleCommentEditEnd = React.useCallback((commentId: string) => {
setEditingCommentIds((prev) => {
const newSet = new Set(prev);
newSet.delete(commentId);
return newSet;
});
}, []);
const renderShowMore = (collapse: { begin: number; final: number }) => {
const count = collapse.final - collapse.begin + 1;
const createdBy = commentsInThread
.slice(collapse.begin, collapse.final + 1)
.map((c) => c.createdBy);
const users = Array.from(new Set(createdBy));
const limit = 3;
const overflow = users.length - limit;
return (
<ShowMore onClick={handleClickExpand} key="show-more">
{t("Show {{ count }} reply", { count })}
<Facepile
users={users}
limit={limit}
overflow={overflow}
size={AvatarSize.Medium}
/>
</ShowMore>
);
};
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocusOff();
}
}, [focused, autoFocus, setAutoFocusOff]);
React.useEffect(() => {
if (focused) {
if (scrollOnMount) {
setTimeout(() => {
if (!topRef.current) {
return;
}
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
boundary: (parent) =>
// Prevents body and other parent elements from being scrolled
parent.id !== "comments",
});
}, sidebarAppearDuration);
} else {
setTimeout(() => {
if (!replyRef.current) {
return;
}
scrollIntoView(replyRef.current, {
scrollMode: "if-needed",
behavior: "smooth",
block: "center",
boundary: (parent) =>
// Prevents body and other parent elements from being scrolled
parent.id !== "comments",
});
}, 0);
}
const getCommentMarkElement = () =>
window.document?.getElementById(`comment-${thread.id}`);
const isMarkVisible = !!getCommentMarkElement();
setTimeout(
() => {
getCommentMarkElement()?.scrollIntoView({
behavior: "smooth",
block: "center",
});
},
isMarkVisible ? 0 : sidebarAppearDuration
);
}
}, [focused, scrollOnMount, thread.id]);
return (
<Thread
ref={topRef}
$focused={focused}
$recessed={recessed}
$dir={document.dir}
onClick={handleClickThread}
>
{commentsInThread.map((comment, index) => {
if (collapse !== null) {
if (index === collapse.begin) {
return renderShowMore(collapse);
} else if (index > collapse.begin && index <= collapse.final) {
return null;
}
}
const firstOfAuthor =
index === 0 ||
(collapse && index === collapse.final + 1) ||
comment.createdById !== commentsInThread[index - 1].createdById;
const lastOfAuthor =
index === commentsInThread.length - 1 ||
comment.createdById !== commentsInThread[index + 1].createdById;
return (
<CommentThreadItem
highlightedText={index === 0 ? highlightedText : undefined}
comment={comment}
onDelete={editor?.removeComment}
onUpdate={editor?.updateComment}
key={comment.id}
firstOfThread={index === 0}
lastOfThread={index === commentsInThread.length - 1 && !draft}
canReply={focused && can.comment}
firstOfAuthor={firstOfAuthor}
lastOfAuthor={lastOfAuthor}
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
dir={document.dir}
forceEdit={editingCommentIds.has(comment.id)}
onEditStart={() => handleCommentEditStart(comment.id)}
onEditEnd={() => handleCommentEditEnd(comment.id)}
/>
);
})}
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
{(focused || draft || commentsInThread.length === 0) && canReply && (
<Fade timing={100}>
<CommentForm
onSubmit={handleSubmit}
onSaveDraft={onSaveDraft}
draft={draft}
documentId={document.id}
thread={thread}
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
highlightedText={
commentsInThread.length === 0 ? highlightedText : undefined
}
onUpArrowAtStart={handleUpArrowAtStart}
/>
</Fade>
)}
</ResizingHeightContainer>
{!focused && !recessed && !draft && canReply && (
<Reply onClick={setAutoFocusOn}>{t("Reply")}</Reply>
)}
</Thread>
);
}
const Reply = styled.button`
border: 0;
padding: 8px;
margin: 0;
background: none;
color: ${s("textTertiary")};
font-size: 14px;
-webkit-appearance: none;
cursor: var(--pointer);
transition: opacity 100ms ease-out;
position: absolute;
text-align: left;
width: 100%;
bottom: -30px;
left: 32px;
${breakpoint("tablet")`
opacity: 0;
`}
`;
const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1px;
margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px;
margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px;
padding: 8px 12px;
color: ${s("textTertiary")};
background: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
cursor: var(--pointer);
font-size: 13px;
&: ${hover} {
color: ${s("textSecondary")};
background: ${s("backgroundTertiary")};
}
* {
border-color: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
}
`;
const Thread = styled.div<{
$focused: boolean;
$recessed: boolean;
$dir?: "rtl" | "ltr";
}>`
margin: 12px 12px 32px;
margin-right: ${(props) => (props.$dir !== "rtl" ? "18px" : "12px")};
margin-left: ${(props) => (props.$dir === "rtl" ? "18px" : "12px")};
position: relative;
transition: opacity 100ms ease-out;
&: ${hover} {
${Reply} {
opacity: 1;
}
}
${(props) =>
props.$recessed &&
css`
opacity: 0.35;
cursor: default;
`}
`;
export default observer(CommentThread);