From c3a9a52b7f669d9e9fb12b6a64231bc235395440 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:23:47 +0000 Subject: [PATCH] WIP --- .../features/comment/atoms/comment-atom.ts | 12 + .../features/comment/queries/comment-query.ts | 21 ++ .../comment/services/comment-service.ts | 17 + .../bubble-menu/read-only-bubble-menu.tsx | 110 ++++++ .../bubble-menu/read-only-comment-dialog.tsx | 126 +++++++ .../src/features/editor/page-editor.tsx | 16 +- .../collaboration/collaboration.gateway.ts | 4 + .../src/collaboration/collaboration.util.ts | 101 +++++ .../extensions/persistence.extension.ts | 4 +- apps/server/src/collaboration/openconn.txt | 349 ++++++++++++++++++ .../src/core/comment/comment.controller.ts | 23 ++ .../server/src/core/comment/comment.module.ts | 3 +- .../src/core/comment/comment.service.ts | 53 +++ .../dto/create-readonly-comment.dto.ts | 19 + apps/server/src/yjs-mark.txt | 181 +++++++++ 15 files changed, 1033 insertions(+), 6 deletions(-) create mode 100644 apps/client/src/features/editor/components/bubble-menu/read-only-bubble-menu.tsx create mode 100644 apps/client/src/features/editor/components/bubble-menu/read-only-comment-dialog.tsx create mode 100644 apps/server/src/collaboration/openconn.txt create mode 100644 apps/server/src/core/comment/dto/create-readonly-comment.dto.ts create mode 100644 apps/server/src/yjs-mark.txt diff --git a/apps/client/src/features/comment/atoms/comment-atom.ts b/apps/client/src/features/comment/atoms/comment-atom.ts index 384a2f3d..374e7f7f 100644 --- a/apps/client/src/features/comment/atoms/comment-atom.ts +++ b/apps/client/src/features/comment/atoms/comment-atom.ts @@ -3,3 +3,15 @@ import { atom } from 'jotai'; export const showCommentPopupAtom = atom(false); export const activeCommentIdAtom = atom(''); export const draftCommentIdAtom = atom(''); + +// Read-only comment state +export const showReadOnlyCommentPopupAtom = atom(false); +export type YjsSelection = { + anchor: any; + head: any; +}; +export type ReadOnlyCommentData = { + yjsSelection: YjsSelection; + selectedText: string; +}; +export const readOnlyCommentDataAtom = atom(null); diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index c10ca418..03d53069 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -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({ + 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 diff --git a/apps/client/src/features/comment/services/comment-service.ts b/apps/client/src/features/comment/services/comment-service.ts index f1512469..f5568097 100644 --- a/apps/client/src/features/comment/services/comment-service.ts +++ b/apps/client/src/features/comment/services/comment-service.ts @@ -40,3 +40,20 @@ export async function getPageComments( export async function deleteComment(commentId: string): Promise { 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 { + const req = await api.post("/comments/create-readonly", data); + return req.data; +} diff --git a/apps/client/src/features/editor/components/bubble-menu/read-only-bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/read-only-bubble-menu.tsx new file mode 100644 index 00000000..f2ce1d90 --- /dev/null +++ b/apps/client/src/features/editor/components/bubble-menu/read-only-bubble-menu.tsx @@ -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; +}; + +export const ReadOnlyBubbleMenu: FC = ({ 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 ( + { + // 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", + }} + > +
+ + + + + +
+
+ ); +}; diff --git a/apps/client/src/features/editor/components/bubble-menu/read-only-comment-dialog.tsx b/apps/client/src/features/editor/components/bubble-menu/read-only-comment-dialog.tsx new file mode 100644 index 00000000..c919a757 --- /dev/null +++ b/apps/client/src/features/editor/components/bubble-menu/read-only-comment-dialog.tsx @@ -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; + 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 ( + + + + +
+ + + {currentUser.user.name} + + +
+
+ + + +
+
+ ); +} + +export default ReadOnlyCommentDialog; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index b4478920..d7570afc 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -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(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({ )} + {editor && !editorIsEditable && ( + + )} {showCommentPopup && } + {showReadOnlyCommentPopup && ( + + )}
editor.commands.focus("end")} diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 3f894572..8c9ea2d4 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -67,4 +67,8 @@ export class CollaborationGateway { async destroy(): Promise { await this.hocuspocus.destroy(); } + + async openDirectConnection(documentName: string) { + return this.hocuspocus.openDirectConnection(documentName); + } } diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 06133c3f..264b6821 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -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, +) { + 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, + 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; +} diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 54c4a89e..96e4fb2b 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -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)) { diff --git a/apps/server/src/collaboration/openconn.txt b/apps/server/src/collaboration/openconn.txt new file mode 100644 index 00000000..f6e3e139 --- /dev/null +++ b/apps/server/src/collaboration/openconn.txt @@ -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( + "Example 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"); + }); +}); diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 5ced1656..731abfb9 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -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( diff --git a/apps/server/src/core/comment/comment.module.ts b/apps/server/src/core/comment/comment.module.ts index 60a577e8..e08f3610 100644 --- a/apps/server/src/core/comment/comment.module.ts +++ b/apps/server/src/core/comment/comment.module.ts @@ -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], diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 61fb15c9..00f84266 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -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 { + 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; + } } diff --git a/apps/server/src/core/comment/dto/create-readonly-comment.dto.ts b/apps/server/src/core/comment/dto/create-readonly-comment.dto.ts new file mode 100644 index 00000000..33c2b89b --- /dev/null +++ b/apps/server/src/core/comment/dto/create-readonly-comment.dto.ts @@ -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; + }; +} diff --git a/apps/server/src/yjs-mark.txt b/apps/server/src/yjs-mark.txt new file mode 100644 index 00000000..cdbcff08 --- /dev/null +++ b/apps/server/src/yjs-mark.txt @@ -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; + }; + + + + + +