This commit is contained in:
Philipinho
2026-01-17 02:23:47 +00:00
parent bcb004af21
commit c3a9a52b7f
15 changed files with 1033 additions and 6 deletions
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>('');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
export type YjsSelection = {
anchor: any;
head: any;
};
export type ReadOnlyCommentData = {
yjsSelection: YjsSelection;
selectedText: string;
};
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
@@ -6,6 +6,8 @@ import {
} from "@tanstack/react-query";
import {
createComment,
createReadOnlyComment,
CreateReadOnlyCommentData,
deleteComment,
getPageComments,
updateComment,
@@ -106,4 +108,23 @@ export function useDeleteCommentMutation(pageId?: string) {
});
}
export function useCreateReadOnlyCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, Error, CreateReadOnlyCommentData>({
mutationFn: (data) => createReadOnlyComment(data),
onSuccess: (data) => {
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: t("Comment created successfully") });
},
onError: () => {
notifications.show({
message: t("Error creating comment"),
color: "red",
});
},
});
}
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
@@ -40,3 +40,20 @@ export async function getPageComments(
export async function deleteComment(commentId: string): Promise<void> {
await api.post("/comments/delete", { commentId });
}
export type CreateReadOnlyCommentData = {
pageId: string;
content: string;
selection?: string;
yjsSelection: {
anchor: any;
head: any;
};
};
export async function createReadOnlyComment(
data: CreateReadOnlyCommentData,
): Promise<IComment> {
const req = await api.post<IComment>("/comments/create-readonly", data);
return req.data;
}
@@ -0,0 +1,110 @@
import {
BubbleMenu,
isNodeSelection,
isTextSelection,
useEditor,
} from "@tiptap/react";
import { FC, useEffect, useRef } from "react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
readOnlyCommentDataAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai";
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import { ySyncPluginKey } from "y-prosemirror";
import { getRelativeSelection } from "y-prosemirror";
type ReadOnlyBubbleMenuProps = {
editor: ReturnType<typeof useEditor>;
};
export const ReadOnlyBubbleMenu: FC<ReadOnlyBubbleMenuProps> = ({ editor }) => {
const { t } = useTranslation();
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
showReadOnlyCommentPopupAtom,
);
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const showPopupRef = useRef(showReadOnlyCommentPopup);
useEffect(() => {
showPopupRef.current = showReadOnlyCommentPopup;
}, [showReadOnlyCommentPopup]);
const handleCommentClick = () => {
if (!editor) return;
const view = editor.view;
const ystate = ySyncPluginKey.getState(view.state);
if (ystate?.binding) {
const selection = getRelativeSelection(ystate.binding, view.state);
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to);
// @ts-ignore
setReadOnlyCommentData({
yjsSelection: {
anchor: selection.anchor,
head: selection.head,
},
selectedText,
});
setShowReadOnlyCommentPopup(true);
}
};
// Don't render if editor is not available or is editable
if (!editor || editor.isEditable) return null;
return (
<BubbleMenu
editor={editor}
pluginKey="readonly"
shouldShow={({ state, editor }) => {
// Safety check - don't show if editor became editable
if (!editor || editor.isEditable || editor.isDestroyed) {
return false;
}
const { selection } = state;
const { empty, from, to } = selection;
if (
editor.isActive("image") ||
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showPopupRef?.current
) {
return false;
}
// Check if actual text is selected (not just empty block)
const hasText = state.doc.textBetween(from, to).length > 0;
return isTextSelection(selection) && hasText;
}}
tippyOptions={{
moveTransition: "transform 0.15s ease-out",
}}
>
<div className={classes.bubbleMenu}>
<Tooltip label={t("Comment")} withArrow>
<ActionIcon
variant="default"
size="lg"
radius="0"
aria-label={t("Comment")}
style={{ border: "none" }}
onClick={handleCommentClick}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</BubbleMenu>
);
};
@@ -0,0 +1,126 @@
import React, { useState } from "react";
import { Dialog, Group, Stack, Text } from "@mantine/core";
import { useClickOutside } from "@mantine/hooks";
import { useAtom } from "jotai";
import {
activeCommentIdAtom,
readOnlyCommentDataAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useCreateReadOnlyCommentMutation } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
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";
type ReadOnlyCommentDialogProps = {
editor: ReturnType<typeof useEditor>;
pageId: string;
};
function ReadOnlyCommentDialog({ editor, pageId }: ReadOnlyCommentDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(
readOnlyCommentDataAtom,
);
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => {
handleDialogClose();
});
const createCommentMutation = useCreateReadOnlyCommentMutation();
const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => {
setShowReadOnlyCommentPopup(false);
setReadOnlyCommentData(null);
};
const handleAddComment = async () => {
if (!readOnlyCommentData) return;
try {
const commentData = {
pageId: pageId,
content: JSON.stringify(comment),
selection: readOnlyCommentData.selectedText,
yjsSelection: readOnlyCommentData.yjsSelection,
};
const createdComment =
await createCommentMutation.mutateAsync(commentData);
setActiveCommentId(createdComment.id);
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} finally {
setShowReadOnlyCommentPopup(false);
setReadOnlyCommentData(null);
}
};
const handleCommentEditorChange = (newContent: any) => {
setComment(newContent);
};
return (
<Dialog
opened={true}
onClose={handleDialogClose}
ref={useClickOutsideRef}
size="lg"
radius="md"
w={300}
position={{ bottom: 500, right: 50 }}
withCloseButton
withBorder
>
<Stack gap={2}>
<Group>
<CustomAvatar
size="sm"
avatarUrl={currentUser.user.avatarUrl}
name={currentUser.user.name}
/>
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={500} lineClamp={1}>
{currentUser.user.name}
</Text>
</Group>
</div>
</Group>
<CommentEditor
onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>
<CommentActions onSave={handleAddComment} isLoading={isPending} />
</Stack>
</Dialog>
);
}
export default ReadOnlyCommentDialog;
@@ -28,9 +28,12 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import {
activeCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentDialog from "@/features/comment/components/comment-dialog";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { ReadOnlyBubbleMenu } from "@/features/editor/components/bubble-menu/read-only-bubble-menu";
import ReadOnlyCommentDialog from "@/features/editor/components/bubble-menu/read-only-comment-dialog";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
@@ -70,7 +73,7 @@ export default function PageEditor({
content,
}: PageEditorProps) {
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
@@ -78,12 +81,13 @@ export default function PageEditor({
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
@@ -104,7 +108,7 @@ export default function PageEditor({
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
@@ -429,7 +433,13 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{editor && !editorIsEditable && (
<ReadOnlyBubbleMenu key="readonly-bubble" editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<ReadOnlyCommentDialog editor={editor} pageId={pageId} />
)}
</div>
<div
onClick={() => editor.commands.focus("end")}