From db404815b0c4a45d2db4ec8cf1439fde44ab4e64 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:55:38 +0000 Subject: [PATCH] resolve comment, delete comment --- .../src/collaboration/collaboration.util.ts | 79 ++++ apps/server/src/collaboration/openconn.txt | 349 ------------------ apps/server/src/ee | 2 +- apps/server/src/yjs-mark.txt | 181 --------- 4 files changed, 80 insertions(+), 531 deletions(-) delete mode 100644 apps/server/src/collaboration/openconn.txt delete mode 100644 apps/server/src/yjs-mark.txt diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index acdf14f5..2d75d778 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -209,3 +209,82 @@ function applyMarkToYFragment( 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, +) { + 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)); + } +} diff --git a/apps/server/src/collaboration/openconn.txt b/apps/server/src/collaboration/openconn.txt deleted file mode 100644 index f6e3e139..00000000 --- a/apps/server/src/collaboration/openconn.txt +++ /dev/null @@ -1,349 +0,0 @@ -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/ee b/apps/server/src/ee index fce3e9e9..1d1ab6cf 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit fce3e9e945da114c4f7cdc4de86a6729b072515e +Subproject commit 1d1ab6cf81da5c55be04bcd8fdaebbd000a63b8b diff --git a/apps/server/src/yjs-mark.txt b/apps/server/src/yjs-mark.txt deleted file mode 100644 index cdbcff08..00000000 --- a/apps/server/src/yjs-mark.txt +++ /dev/null @@ -1,181 +0,0 @@ - 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; - }; - - - - - -