mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 06:52:07 +10:00
feat: realtime comments (#1144)
* init * fix: close bubblemenu after comment and wait before scroll * scroll to comment when click * highlight comment animation
This commit is contained in:
@ -15,6 +15,7 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@ -35,6 +36,8 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const { isPending } = createCommentMutation;
|
||||
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
@ -63,11 +66,23 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
.run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
//unselect text to close bubble menu
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.scrollIntoView()
|
||||
);
|
||||
}, 400);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
|
||||
@ -53,6 +53,10 @@ const CommentEditor = forwardRef(
|
||||
autofocus: (autofocus && "end") || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}, [defaultContent]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
@ -15,12 +15,14 @@ import {
|
||||
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";
|
||||
|
||||
interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
function CommentListItem({ comment }: CommentListItemProps) {
|
||||
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -29,6 +31,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content)
|
||||
}, [comment]);
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
@ -39,6 +46,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
setIsEditing(false);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
} finally {
|
||||
@ -50,11 +62,27 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@ -99,7 +127,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection && (
|
||||
<Box className={classes.textSelection}>
|
||||
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@ -14,6 +14,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
function CommentList() {
|
||||
const { t } = useTranslation();
|
||||
@ -26,6 +27,7 @@ function CommentList() {
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
@ -38,6 +40,11 @@ function CommentList() {
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: page?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
@ -59,8 +66,8 @@ function CommentList() {
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} />
|
||||
<CommentListItem comment={comment} pageId={page?.id} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
@ -99,8 +106,9 @@ function CommentList() {
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
}
|
||||
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
@ -113,10 +121,11 @@ const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
<CommentListItem comment={childComment} pageId={pageId} />
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
@ -219,9 +219,12 @@ export default function PageEditor({
|
||||
setActiveCommentId(commentId);
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
//wait if aside is closed
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 400);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -144,6 +144,19 @@
|
||||
border-bottom: 2px solid rgb(166, 158, 12);
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
animation: flash-highlight 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-highlight {
|
||||
0% {
|
||||
background-color: #ff4d4d;
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(255, 215, 0, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
|
||||
@ -7,6 +7,11 @@ export type InvalidateEvent = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type InvalidateCommentsEvent = {
|
||||
operation: "invalidateComment";
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export type UpdateEvent = {
|
||||
operation: "updateOne";
|
||||
spaceId: string;
|
||||
@ -52,4 +57,4 @@ export type DeleteTreeNodeEvent = {
|
||||
}
|
||||
};
|
||||
|
||||
export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;
|
||||
export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;
|
||||
|
||||
@ -3,6 +3,7 @@ import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||
|
||||
export const useQuerySubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@ -21,6 +22,11 @@ export const useQuerySubscription = () => {
|
||||
queryKey: [...data.entity, data.id].filter(Boolean),
|
||||
});
|
||||
break;
|
||||
case "invalidateComment":
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: RQ_KEY(data.pageId),
|
||||
});
|
||||
break;
|
||||
case "updateOne":
|
||||
entity = data.entity[0];
|
||||
if (entity === "pages") {
|
||||
|
||||
Reference in New Issue
Block a user