mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 14:54:05 +08:00
f6a8247c48
* fix: cursor jumps to end of text when editing a comment When editing a comment mid-text, the cursor would jump to the end after every keystroke, making it impossible to insert text at any position other than the end. Root cause: on each keystroke, the comment editor's onUpdate callback updated parent state (setContent), which changed the defaultContent prop passed back to CommentEditor. A useEffect watching defaultContent then called commentEditor.commands.setContent(), which reset the entire editor content and moved the cursor to the end. Fix: - Store in-progress edits in a ref instead of state to avoid triggering React re-renders and the prop->effect->setContent cascade - Read from the ref when saving the comment - Sync the ref back into state after a successful save so the read-only view updates immediately - Guard the setContent useEffect to only run for read-only editors, so websocket-driven updates from other browsers still work Fixes #1791 Functionally tested on Firefox and Chrome: mid-text editing, saving, cross-browser live updates via websocket. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix stale content on edit cancel --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
222 lines
6.6 KiB
TypeScript
222 lines
6.6 KiB
TypeScript
import { Group, Text, Box, Badge } from "@mantine/core";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import classes from "./comment.module.css";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import { timeAgo } from "@/lib/time";
|
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
|
import CommentActions from "@/features/comment/components/comment-actions";
|
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
|
import ResolveComment from "@/ee/comment/components/resolve-comment";
|
|
import { useHover } from "@mantine/hooks";
|
|
import {
|
|
useDeleteCommentMutation,
|
|
useUpdateCommentMutation,
|
|
} from "@/features/comment/queries/comment-query";
|
|
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
|
import { IComment } from "@/features/comment/types/comment.types";
|
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
interface CommentListItemProps {
|
|
comment: IComment;
|
|
pageId: string;
|
|
canComment: boolean;
|
|
userSpaceRole?: string;
|
|
}
|
|
|
|
function CommentListItem({
|
|
comment,
|
|
pageId,
|
|
canComment,
|
|
userSpaceRole,
|
|
}: CommentListItemProps) {
|
|
const { t } = useTranslation();
|
|
const { hovered, ref } = useHover();
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const editor = useAtomValue(pageEditorAtom);
|
|
const [content, setContent] = useState<string>(comment.content);
|
|
const editContentRef = useRef<any>(null);
|
|
const updateCommentMutation = useUpdateCommentMutation();
|
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
|
const resolveCommentMutation = useResolveCommentMutation();
|
|
const [currentUser] = useAtom(currentUserAtom);
|
|
const emit = useQueryEmit();
|
|
const isCloudEE = useIsCloudEE();
|
|
|
|
useEffect(() => {
|
|
setContent(comment.content);
|
|
}, [comment]);
|
|
|
|
async function handleUpdateComment() {
|
|
try {
|
|
setIsLoading(true);
|
|
const commentToUpdate = {
|
|
commentId: comment.id,
|
|
content: JSON.stringify(editContentRef.current ?? content),
|
|
};
|
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
|
if (editContentRef.current) {
|
|
setContent(editContentRef.current);
|
|
editContentRef.current = null;
|
|
}
|
|
setIsEditing(false);
|
|
|
|
emit({
|
|
operation: "invalidateComment",
|
|
pageId: pageId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to update comment:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteComment() {
|
|
try {
|
|
await deleteCommentMutation.mutateAsync(comment.id);
|
|
editor?.commands.unsetComment(comment.id);
|
|
|
|
emit({
|
|
operation: "invalidateComment",
|
|
pageId: pageId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to delete comment:", error);
|
|
}
|
|
}
|
|
|
|
async function handleResolveComment() {
|
|
if (!isCloudEE) return;
|
|
|
|
try {
|
|
const isResolved = comment.resolvedAt != null;
|
|
|
|
await resolveCommentMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
resolved: !isResolved,
|
|
});
|
|
|
|
if (editor) {
|
|
editor.commands.setCommentResolved(comment.id, !isResolved);
|
|
}
|
|
|
|
emit({
|
|
operation: "invalidateComment",
|
|
pageId: pageId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to toggle resolved state:", error);
|
|
}
|
|
}
|
|
|
|
function handleCommentClick(comment: IComment) {
|
|
const el = document.querySelector(
|
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
|
);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
el.classList.add("comment-highlight");
|
|
setTimeout(() => {
|
|
el.classList.remove("comment-highlight");
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function handleEditToggle() {
|
|
setIsEditing(true);
|
|
}
|
|
function cancelEdit() {
|
|
editContentRef.current = null;
|
|
setIsEditing(false);
|
|
}
|
|
|
|
return (
|
|
<Box ref={ref} pb="xs">
|
|
<Group>
|
|
<CustomAvatar
|
|
size="sm"
|
|
avatarUrl={comment.creator.avatarUrl}
|
|
name={comment.creator.name}
|
|
/>
|
|
|
|
<div style={{ flex: 1 }}>
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Text size="sm" fw={500} lineClamp={1}>
|
|
{comment.creator.name}
|
|
</Text>
|
|
|
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
|
{!comment.parentCommentId && canComment && isCloudEE && (
|
|
<ResolveComment
|
|
editor={editor}
|
|
commentId={comment.id}
|
|
pageId={comment.pageId}
|
|
resolvedAt={comment.resolvedAt}
|
|
/>
|
|
)}
|
|
|
|
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
|
<CommentMenu
|
|
onEditComment={handleEditToggle}
|
|
onDeleteComment={handleDeleteComment}
|
|
onResolveComment={handleResolveComment}
|
|
canEdit={currentUser?.user?.id === comment.creatorId}
|
|
isResolved={comment.resolvedAt != null}
|
|
isParentComment={!comment.parentCommentId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Group>
|
|
|
|
<Group gap="xs">
|
|
<Text size="xs" fw={500} c="dimmed">
|
|
{timeAgo(comment.createdAt)}
|
|
</Text>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
|
|
<div>
|
|
{!comment.parentCommentId && comment?.selection && (
|
|
<Box
|
|
className={classes.textSelection}
|
|
onClick={() => handleCommentClick(comment)}
|
|
>
|
|
<Text size="sm">{comment?.selection}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{!isEditing ? (
|
|
<CommentEditor defaultContent={content} editable={false} />
|
|
) : (
|
|
<>
|
|
<CommentEditor
|
|
defaultContent={content}
|
|
editable={true}
|
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
|
onSave={handleUpdateComment}
|
|
autofocus={true}
|
|
/>
|
|
|
|
<CommentActions
|
|
onSave={handleUpdateComment}
|
|
isLoading={isLoading}
|
|
onCancel={cancelEdit}
|
|
isCommentEditor={true}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export default CommentListItem;
|