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")}
@@ -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,12 @@
import { StarterKit } from '@tiptap/starter-kit';
import { EditorState, TextSelection } from '@tiptap/pm/state';
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
updateYFragment,
} 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 +124,96 @@ 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 { doc: pNode, mapping } = initProseMirrorDoc(fragment, schema);
// Convert JSON positions to Y.js RelativePosition objects
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
console.log(anchorRelPos, headRelPos);
const anchor = relativePositionToAbsolutePosition(
doc,
fragment,
anchorRelPos,
mapping,
);
const head = relativePositionToAbsolutePosition(
doc,
fragment,
headRelPos,
mapping,
);
console.log('second')
console.log(anchor, head);
if (anchor === null || head === null) {
throw new Error('Could not resolve Y.js relative positions to absolute positions');
}
const state = EditorState.create({
doc: pNode,
schema: schema,
selection: TextSelection.create(pNode, anchor, head),
});
const tr = setMarkInProsemirror(schema.marks[markName], markAttributes, state);
// Update the Y.js fragment with the modified ProseMirror document
// @ts-ignore
updateYFragment(doc, fragment, tr.doc, mapping);
}
function setMarkInProsemirror(
type: any,
attributes: Record<string, any>,
state: EditorState,
) {
let tr = state.tr;
const { selection } = state;
const { ranges } = selection;
ranges.forEach((range) => {
const from = range.$from.pos;
const to = range.$to.pos;
state.doc.nodesBetween(from, to, (node, pos) => {
const trimmedFrom = Math.max(pos, from);
const trimmedTo = Math.min(pos + node.nodeSize, to);
const someHasMark = node.marks.find((mark) => mark.type === type);
if (someHasMark) {
node.marks.forEach((mark) => {
if (type === mark.type) {
tr = tr.addMark(
trimmedFrom,
trimmedTo,
type.create({
...mark.attrs,
...attributes,
}),
);
}
});
} else {
tr = tr.addMark(trimmedFrom, trimmedTo, type.create(attributes));
}
});
});
return tr;
}
@@ -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)) {
+349
View File
@@ -0,0 +1,349 @@
import { TiptapTransformer } from "@hocuspocus/transformer";
import test from "ava";
import * as Y from "yjs";
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
test("direct connection prevents document from being removed from memory", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus();
await server.openDirectConnection("hocuspocus-test");
const provider = newHocuspocusProvider(server, {
onSynced() {
provider.configuration.websocketProvider.destroy();
provider.destroy();
sleep(server.configuration.debounce + 50).then(() => {
t.is(server.getDocumentsCount(), 1);
resolve("done");
});
},
});
});
});
test("direct connection works even if provider is connected", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus();
const provider = newHocuspocusProvider(server, {
onSynced() {
provider.document.getMap("config").set("a", "valueFromProvider");
},
});
await sleep(150);
const directConnection =
await server.openDirectConnection("hocuspocus-test");
await directConnection.transact((doc) => {
t.is("valueFromProvider", String(doc.getMap("config").get("a")));
doc.getMap("config").set("b", "valueFromServerDirectConnection");
});
await sleep(100);
t.is(
"valueFromServerDirectConnection",
String(provider.document.getMap("config").get("b")),
);
resolve(1);
t.pass();
});
});
test("direct connection can apply yjsUpdate", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus();
const provider = newHocuspocusProvider(server);
t.is("", provider.document.getXmlFragment("default").toJSON());
const directConnection =
await server.openDirectConnection("hocuspocus-test");
await directConnection.transact((doc) => {
Y.applyUpdate(
doc,
Y.encodeStateAsUpdate(
TiptapTransformer.toYdoc({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Example Paragraph",
},
],
},
],
}),
),
);
});
await sleep(100);
t.is(
"<paragraph>Example Paragraph</paragraph>",
provider.document.getXmlFragment("default").toJSON(),
);
resolve(1);
t.pass();
});
});
test("direct connection can transact", async (t) => {
const server = await newHocuspocus();
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.is(direct.document?.getArray("test").toJSON()[0], "value");
});
test("direct connection cannot transact once closed", async (t) => {
const server = await newHocuspocus();
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.disconnect();
try {
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.fail(
"DirectConnection should throw an error when transacting on closed connection",
);
} catch (err) {
if (err instanceof Error && err.message === "direct connection closed") {
t.pass();
} else {
t.fail("unknown error");
}
}
});
test("if a direct connection closes, the document should be unloaded if there is no other connection left", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus();
const direct = await server.openDirectConnection("hocuspocus-test1");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
resolve("done");
});
});
test("direct connection transact awaits until onStoreDocument has finished", async (t) => {
let onStoreDocumentFinished = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus({
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
},
});
const direct = await server.openDirectConnection("hocuspocus-test2");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
t.is(onStoreDocumentFinished, true);
resolve("done");
});
});
test("direct connection transact awaits until onStoreDocument has finished, even if unloadImmediately=false", async (t) => {
let onStoreDocumentFinished = false;
let directConnDisconnecting = false;
let storedAfterDisconnect = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus({
unloadImmediately: false,
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
if (directConnDisconnecting) {
storedAfterDisconnect = true;
}
},
afterUnloadDocument: async (data) => {
if (!storedAfterDisconnect) {
t.fail("this shouldnt be called");
}
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
const provider = newHocuspocusProvider(server);
provider.document.getMap("aaa").set("bb", "b");
provider.disconnect();
provider.configuration.websocketProvider.disconnect();
await sleep(100);
directConnDisconnecting = true;
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
t.is(server.getConnectionsCount(), 0);
t.is(storedAfterDisconnect, true);
resolve("done");
});
});
test("does not unload document if an earlierly started onStoreDocument is still running", async (t) => {
let onStoreDocumentStarted = 0;
let onStoreDocumentFinished = 0;
const server = await newHocuspocus({
unloadImmediately: false,
debounce: 100,
onStoreDocument: async () => {
onStoreDocumentStarted++;
if (onStoreDocumentStarted === 1) {
// Simulate a long running onStoreDocument for the first debounced save
await sleep(500);
}
onStoreDocumentFinished++;
},
afterUnloadDocument: async (data) => {},
});
// Trigger a change, which will start a debounced onStoreDocument after 100ms
const provider = newHocuspocusProvider(server);
provider.document.getMap("aaa").set("bb", "b");
await new Promise(async (resolve) => {
provider.on("synced", resolve);
if (!provider.unsyncedChanges) resolve("");
});
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
// Wait for the debounced onStoreDocument to start
await sleep(110);
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
// Open direct connection to prevent document from being unloaded
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 2);
// Close the websocket client
provider.disconnect();
provider.configuration.websocketProvider.disconnect();
await sleep(50);
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
direct.disconnect();
await sleep(50);
// Another save must not start before the first one has finished
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
// Document must not be unloaded yet, because the first onStoreDocument is still running
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 0);
// Wait enough time to be sure the onStoreDocument has finished and ensure that the document was eventually unloaded
await sleep(500);
// The second onStoreDocument triggered by direct.disconnect must have started and finished now
t.is(onStoreDocumentStarted, 2);
t.is(onStoreDocumentFinished, 2);
// The document must have been unloaded now as well
t.is(server.getDocumentsCount(), 0);
});
test("creating a websocket connection after transact but before debounce interval doesnt create different docs", async (t) => {
let onStoreDocumentFinished = false;
let disconnected = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus({
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
},
async afterUnloadDocument(data) {
console.log("called");
if (disconnected) {
t.fail("must not be called");
}
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.transact(() => {
document.getArray("test").insert(0, ["value"]);
}, "testOrigin");
});
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
disconnected = true;
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
t.is(onStoreDocumentFinished, true);
const provider = newHocuspocusProvider(server);
await sleep(server.configuration.debounce * 2);
resolve("done");
});
});
@@ -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;
};
}
+181
View File
@@ -0,0 +1,181 @@
async createThread(options: {
initialComment: { body: CommentBody; metadata?: any };
metadata?: any;
}) {
const thread = await threadStore.createThread(options);
if (threadStore.addThreadToDocument) {
const view = editor.prosemirrorView!;
const pmSelection = view.state.selection;
const ystate = ySyncPluginKey.getState(view.state);
const selection = {
prosemirror: {
head: pmSelection.head,
anchor: pmSelection.anchor,
},
yjs: ystate
? getRelativeSelection(ystate.binding, view.state)
: undefined,
};
await threadStore.addThreadToDocument({
threadId: thread.id,
selection,
});
} else {
(editor as any)._tiptapEditor.commands.setMark(markType, {
orphan: false,
threadId: thread.id,
});
}
},
userStore,
commentEditorSchema,
---
public addThreadToDocument = async (options: {
threadId: string;
selection: {
prosemirror: {
head: number;
anchor: number;
};
yjs: {
head: any;
anchor: any;
};
};
}) => {
const { threadId, ...rest } = options;
return this.doRequest(`/${threadId}/addToDocument`, "POST", rest);
};
-----
// addToDocument
router.post("/:threadId/addToDocument", async (c) => {
const json = await c.req.json();
// TODO: you'd probably validate the request json here
const doc = c.get("document");
const fragment = doc.getXmlFragment("doc");
setMark(doc, fragment, json.selection.yjs, "comment", {
orphan: false,
threadId: c.req.param("threadId"),
});
return c.json({ message: "Thread added to document" });
});
----
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { Document } from "@hocuspocus/server";
import { EditorState, TextSelection } from "prosemirror-state";
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
updateYFragment,
} from "y-prosemirror";
import * as Y from "yjs";
/**
* Sets a mark in the yjs document based on a yjs selection
*/
export function setMark(
doc: Document,
fragment: Y.XmlFragment,
yjsSelection: {
anchor: any;
head: any;
},
markName: string,
markAttributes: any
) {
// needed to get the pmSchema
// if you use a BlockNote custom schema, make sure to pass it to the create options
const editor = ServerBlockNoteEditor.create();
// get the prosemirror document
const { doc: pNode, mapping } = initProseMirrorDoc(
fragment,
editor.editor.pmSchema as any
);
// get the prosemirror positions based on the yjs positions
// we need to get this from yjs because other users might have made changes in between
const anchor = relativePositionToAbsolutePosition(
doc,
fragment,
yjsSelection.anchor,
mapping
);
const head = relativePositionToAbsolutePosition(
doc,
fragment,
yjsSelection.head,
mapping
);
// now, let's create the mark in the prosemirror document
const state = EditorState.create({
doc: pNode,
schema: editor.editor.pmSchema as any,
selection: TextSelection.create(pNode, anchor!, head!),
});
const tr = setMarkInProsemirror(
editor.editor.pmSchema.marks[markName],
markAttributes,
state
);
// finally, update the yjs document with the new prosemirror document
updateYFragment(doc, fragment, tr.doc, mapping);
}
// based on https://github.com/ueberdosis/tiptap/blob/f3258d9ee5fb7979102fe63434f6ea4120507311/packages/core/src/commands/setMark.ts#L66
export const setMarkInProsemirror = (
type: any,
attributes = {},
state: EditorState
) => {
let tr = state.tr;
const { selection } = state;
const { ranges } = selection;
ranges.forEach((range) => {
const from = range.$from.pos;
const to = range.$to.pos;
state.doc.nodesBetween(from, to, (node, pos) => {
const trimmedFrom = Math.max(pos, from);
const trimmedTo = Math.min(pos + node.nodeSize, to);
const someHasMark = node.marks.find((mark) => mark.type === type);
// if there is already a mark of this type
// we know that we have to merge its attributes
// otherwise we add a fresh new mark
if (someHasMark) {
node.marks.forEach((mark) => {
if (type === mark.type) {
tr = tr.addMark(
trimmedFrom,
trimmedTo,
type.create({
...mark.attrs,
...attributes,
})
);
}
});
} else {
tr = tr.addMark(trimmedFrom, trimmedTo, type.create(attributes));
}
});
});
return tr;
};