mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
3 Commits
mrl
...
feat/comment-yjs
| Author | SHA1 | Date | |
|---|---|---|---|
| db404815b0 | |||
| d9ebeb2b85 | |||
| c3a9a52b7f |
@@ -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")}
|
||||
|
||||
@@ -67,4 +67,8 @@ export class CollaborationGateway {
|
||||
async destroy(): Promise<void> {
|
||||
await this.hocuspocus.destroy();
|
||||
}
|
||||
|
||||
async openDirectConnection(documentName: string) {
|
||||
return this.hocuspocus.openDirectConnection(documentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import {
|
||||
initProseMirrorDoc,
|
||||
relativePositionToAbsolutePosition,
|
||||
} from 'y-prosemirror';
|
||||
import * as Y from 'yjs';
|
||||
import { Document } from '@hocuspocus/server';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
@@ -116,3 +122,169 @@ export function jsonToNode(tiptapJson: JSONContent) {
|
||||
export function getPageId(documentName: string) {
|
||||
return documentName.split('.')[1];
|
||||
}
|
||||
|
||||
export type YjsSelection = {
|
||||
anchor: any;
|
||||
head: any;
|
||||
};
|
||||
|
||||
export function setYjsMark(
|
||||
doc: Document,
|
||||
fragment: Y.XmlFragment,
|
||||
yjsSelection: YjsSelection,
|
||||
markName: string,
|
||||
markAttributes: Record<string, any>,
|
||||
) {
|
||||
const schema = getSchema(tiptapExtensions);
|
||||
const { mapping } = initProseMirrorDoc(fragment, schema);
|
||||
|
||||
// Convert JSON positions to Y.js RelativePosition objects
|
||||
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
|
||||
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
|
||||
|
||||
const anchor = relativePositionToAbsolutePosition(
|
||||
doc,
|
||||
fragment,
|
||||
anchorRelPos,
|
||||
mapping,
|
||||
);
|
||||
const head = relativePositionToAbsolutePosition(
|
||||
doc,
|
||||
fragment,
|
||||
headRelPos,
|
||||
mapping,
|
||||
);
|
||||
|
||||
if (anchor === null || head === null) {
|
||||
throw new Error(
|
||||
'Could not resolve Y.js relative positions to absolute positions',
|
||||
);
|
||||
}
|
||||
|
||||
const from = Math.min(anchor, head);
|
||||
const to = Math.max(anchor, head);
|
||||
|
||||
// Apply mark directly to Y.js XmlText nodes
|
||||
// This bypasses updateYFragment which has compatibility issues
|
||||
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
|
||||
}
|
||||
|
||||
function applyMarkToYFragment(
|
||||
fragment: Y.XmlFragment,
|
||||
from: number,
|
||||
to: number,
|
||||
markName: string,
|
||||
markAttributes: Record<string, any>,
|
||||
) {
|
||||
let pos = 0;
|
||||
|
||||
const processItem = (item: any): boolean => {
|
||||
if (pos >= to) return false;
|
||||
|
||||
if (item instanceof Y.XmlText) {
|
||||
const textLength = item.length;
|
||||
const itemEnd = pos + textLength;
|
||||
|
||||
if (itemEnd > from && pos < to) {
|
||||
const formatFrom = Math.max(0, from - pos);
|
||||
const formatTo = Math.min(textLength, to - pos);
|
||||
const formatLength = formatTo - formatFrom;
|
||||
|
||||
if (formatLength > 0) {
|
||||
item.format(formatFrom, formatLength, { [markName]: markAttributes });
|
||||
}
|
||||
}
|
||||
pos = itemEnd;
|
||||
} else if (item instanceof Y.XmlElement) {
|
||||
pos++; // Opening tag
|
||||
for (let i = 0; i < item.length; i++) {
|
||||
if (!processItem(item.get(i))) return false;
|
||||
}
|
||||
pos++; // Closing tag
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
if (!processItem(fragment.get(i))) break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a mark from all text in the fragment that has the specified attribute value.
|
||||
* Useful for deleting comments by commentId.
|
||||
*/
|
||||
export function removeYjsMarkByAttribute(
|
||||
fragment: Y.XmlFragment,
|
||||
markName: string,
|
||||
attributeName: string,
|
||||
attributeValue: string,
|
||||
) {
|
||||
const processItem = (item: any) => {
|
||||
if (item instanceof Y.XmlText) {
|
||||
// Get all formatting deltas to find ranges with this mark
|
||||
const deltas = item.toDelta();
|
||||
let offset = 0;
|
||||
|
||||
for (const delta of deltas) {
|
||||
const length = delta.insert?.length ?? 0;
|
||||
const attributes = delta.attributes ?? {};
|
||||
const markAttr = attributes[markName];
|
||||
|
||||
if (markAttr && markAttr[attributeName] === attributeValue) {
|
||||
// Remove the mark by setting it to null
|
||||
item.format(offset, length, { [markName]: null });
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
} else if (item instanceof Y.XmlElement) {
|
||||
for (let i = 0; i < item.length; i++) {
|
||||
processItem(item.get(i));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
processItem(fragment.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a mark's attributes for all text that has the specified attribute value.
|
||||
* Useful for resolving/unresolving comments by commentId.
|
||||
*/
|
||||
export function updateYjsMarkAttribute(
|
||||
fragment: Y.XmlFragment,
|
||||
markName: string,
|
||||
findByAttribute: { name: string; value: string },
|
||||
newAttributes: Record<string, any>,
|
||||
) {
|
||||
const processItem = (item: any) => {
|
||||
if (item instanceof Y.XmlText) {
|
||||
const deltas = item.toDelta();
|
||||
let offset = 0;
|
||||
|
||||
for (const delta of deltas) {
|
||||
const length = delta.insert?.length ?? 0;
|
||||
const attributes = delta.attributes ?? {};
|
||||
const markAttr = attributes[markName];
|
||||
|
||||
if (markAttr && markAttr[findByAttribute.name] === findByAttribute.value) {
|
||||
// Update the mark with new attributes (merge with existing)
|
||||
item.format(offset, length, {
|
||||
[markName]: { ...markAttr, ...newAttributes },
|
||||
});
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
} else if (item instanceof Y.XmlElement) {
|
||||
for (let i = 0; i < item.length; i++) {
|
||||
processItem(item.get(i));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
processItem(fragment.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export class PersistenceExtension implements Extension {
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
lastUpdatedById: context?.user?.id,
|
||||
contributorIds: contributorIds,
|
||||
},
|
||||
pageId,
|
||||
@@ -157,7 +157,7 @@ export class PersistenceExtension implements Extension {
|
||||
page: {
|
||||
...page,
|
||||
content: tiptapJson,
|
||||
lastUpdatedById: context.user.id,
|
||||
lastUpdatedById: context?.user?.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@ export class PersistenceExtension implements Extension {
|
||||
|
||||
async onChange(data: onChangePayload) {
|
||||
const documentName = data.documentName;
|
||||
const userId = data.context?.user.id;
|
||||
const userId = data.context?.user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
if (!this.contributors.has(documentName)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
@@ -62,6 +63,28 @@ export class CommentController {
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create-readonly')
|
||||
async createReadOnly(
|
||||
@Body() createCommentDto: CreateReadOnlyCommentDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
return this.commentService.createReadOnlyComment(
|
||||
{
|
||||
userId: user.id,
|
||||
page,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
createCommentDto,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
async findPageComments(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CommentController } from './comment.controller';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [CollaborationModule],
|
||||
controllers: [CommentController],
|
||||
providers: [CommentService],
|
||||
exports: [CommentService],
|
||||
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||
@@ -12,13 +14,19 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
|
||||
import { setYjsMark } from '../../collaboration/collaboration.util';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
private readonly logger = new Logger(CommentService.name);
|
||||
|
||||
constructor(
|
||||
private commentRepo: CommentRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
) {}
|
||||
|
||||
async findById(commentId: string) {
|
||||
@@ -105,4 +113,49 @@ export class CommentService {
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async createReadOnlyComment(
|
||||
opts: { userId: string; page: Page; workspaceId: string },
|
||||
createCommentDto: CreateReadOnlyCommentDto,
|
||||
): Promise<Comment> {
|
||||
const { userId, page, workspaceId } = opts;
|
||||
const commentContent = JSON.parse(createCommentDto.content);
|
||||
|
||||
const comment = await this.commentRepo.insertComment({
|
||||
pageId: page.id,
|
||||
content: commentContent,
|
||||
selection: createCommentDto?.selection?.substring(0, 250),
|
||||
type: 'inline',
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
spaceId: page.spaceId,
|
||||
});
|
||||
|
||||
const documentName = `page.${page.id}`;
|
||||
const directConnection =
|
||||
await this.collaborationGateway.openDirectConnection(documentName);
|
||||
|
||||
try {
|
||||
await directConnection.transact((doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
setYjsMark(doc, fragment, createCommentDto.yjsSelection, 'comment', {
|
||||
commentId: comment.id,
|
||||
resolved: false,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to apply comment mark for comment ${comment.id}`,
|
||||
error,
|
||||
);
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
throw new BadRequestException(
|
||||
'Failed to apply comment mark. Selection may have changed.',
|
||||
);
|
||||
} finally {
|
||||
await directConnection.disconnect();
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsJSON, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateReadOnlyCommentDto {
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsJSON()
|
||||
content: any;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selection: string;
|
||||
|
||||
@IsObject()
|
||||
yjsSelection: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
};
|
||||
}
|
||||
+1
-1
Submodule apps/server/src/ee updated: fce3e9e945...1d1ab6cf81
Reference in New Issue
Block a user