From 17c53c34afa7c3781133ee1e0cece5fe55b2727a Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 30 Jan 2025 21:19:03 +0100 Subject: [PATCH 1/2] improve collab cursor logic --- .../core/src/editor/BlockNoteExtensions.ts | 94 ++++++++++--------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 56cbd5067..1ea09ac43 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -253,7 +253,9 @@ const getTipTapExtensions = < }) ); - const awareness = opts.collaboration?.provider.awareness as Awareness; + const awareness = opts.collaboration?.provider?.awareness as + | Awareness + | undefined; if (awareness) { const cursors = new Map< @@ -293,11 +295,11 @@ const getTipTapExtensions = < ); } - const createCursor = (clientID: number, name: string, color: string) => { + const renderCursor = (user: { name: string; color: string }) => { const cursorElement = document.createElement("span"); cursorElement.classList.add("collaboration-cursor__caret"); - cursorElement.setAttribute("style", `border-color: ${color}`); + cursorElement.setAttribute("style", `border-color: ${user.color}`); if (opts.collaboration?.showCursorLabels === "always") { cursorElement.setAttribute("data-active", ""); } @@ -305,48 +307,17 @@ const getTipTapExtensions = < const labelElement = document.createElement("span"); labelElement.classList.add("collaboration-cursor__label"); - labelElement.setAttribute("style", `background-color: ${color}`); - labelElement.insertBefore(document.createTextNode(name), null); + labelElement.setAttribute("style", `background-color: ${user.color}`); + labelElement.insertBefore(document.createTextNode(user.name), null); cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space cursorElement.insertBefore(labelElement, null); cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - cursors.set(clientID, { - element: cursorElement, - hideTimeout: undefined, - }); - - if (opts.collaboration?.showCursorLabels !== "always") { - cursorElement.addEventListener("mouseenter", () => { - const cursor = cursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - cursors.set(clientID, { - element: cursor.element, - hideTimeout: undefined, - }); - } - }); - - cursorElement.addEventListener("mouseleave", () => { - const cursor = cursors.get(clientID)!; - - cursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - }); - } - - return cursors.get(clientID)!; + return cursorElement; }; - const defaultRender = (user: { color: string; name: string }) => { + const render = (user: { color: string; name: string }) => { const clientState = [...awareness.getStates().entries()].find( (state) => state[1].user === user ); @@ -357,14 +328,53 @@ const getTipTapExtensions = < const clientID = clientState[0]; - return ( - cursors.get(clientID) || createCursor(clientID, user.name, user.color) - ).element; + let cursorData = cursors.get(clientID); + + if (!cursorData) { + const cursorElement = + opts.collaboration?.renderCursor?.(user) || renderCursor(user); + + if (opts.collaboration?.showCursorLabels !== "always") { + cursorElement.addEventListener("mouseenter", () => { + const cursor = cursors.get(clientID)!; + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + cursors.set(clientID, { + element: cursor.element, + hideTimeout: undefined, + }); + } + }); + + cursorElement.addEventListener("mouseleave", () => { + const cursor = cursors.get(clientID)!; + + cursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + }); + } + + cursorData = { + element: cursorElement, + hideTimeout: undefined, + }; + + cursors.set(clientID, cursorData); + } + + return cursorData.element; }; + tiptapExtensions.push( CollaborationCursor.configure({ user: opts.collaboration.user, - render: opts.collaboration.renderCursor || defaultRender, + render, provider: opts.collaboration.provider, }) ); From 07b21b6a21c7bdb91d468f641333a245b6a178a3 Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Fri, 7 Feb 2025 13:56:43 +0100 Subject: [PATCH 2/2] Extracted collaboration extension code to new file --- .../core/src/editor/BlockNoteExtensions.ts | 142 +--------------- .../createCollaborationExtensions.ts | 151 ++++++++++++++++++ 2 files changed, 155 insertions(+), 138 deletions(-) create mode 100644 packages/core/src/extensions/Collaboration/createCollaborationExtensions.ts diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 1ea09ac43..28459bb45 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -1,10 +1,4 @@ import { AnyExtension, Extension, extensions } from "@tiptap/core"; -import { Awareness } from "y-protocols/awareness"; - -import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js"; - -import Collaboration from "@tiptap/extension-collaboration"; -import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { Gapcursor } from "@tiptap/extension-gapcursor"; import { HardBreak } from "@tiptap/extension-hard-break"; import { History } from "@tiptap/extension-history"; @@ -12,6 +6,8 @@ import { Link } from "@tiptap/extension-link"; import { Text } from "@tiptap/extension-text"; import { Plugin } from "prosemirror-state"; import * as Y from "yjs"; + +import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js"; import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension.js"; import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js"; @@ -44,6 +40,7 @@ import { StyleSchema, StyleSpecs, } from "../schema/index.js"; +import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js"; type ExtensionOptions< BSchema extends BlockSchema, @@ -247,138 +244,7 @@ const getTipTapExtensions = < ]; if (opts.collaboration) { - tiptapExtensions.push( - Collaboration.configure({ - fragment: opts.collaboration.fragment, - }) - ); - - const awareness = opts.collaboration?.provider?.awareness as - | Awareness - | undefined; - - if (awareness) { - const cursors = new Map< - number, - { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } - >(); - - if (opts.collaboration.showCursorLabels !== "always") { - awareness.on( - "change", - ({ - updated, - }: { - added: Array; - updated: Array; - removed: Array; - }) => { - for (const clientID of updated) { - const cursor = cursors.get(clientID); - - if (cursor) { - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - } - - cursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - } - } - } - ); - } - - const renderCursor = (user: { name: string; color: string }) => { - const cursorElement = document.createElement("span"); - - cursorElement.classList.add("collaboration-cursor__caret"); - cursorElement.setAttribute("style", `border-color: ${user.color}`); - if (opts.collaboration?.showCursorLabels === "always") { - cursorElement.setAttribute("data-active", ""); - } - - const labelElement = document.createElement("span"); - - labelElement.classList.add("collaboration-cursor__label"); - labelElement.setAttribute("style", `background-color: ${user.color}`); - labelElement.insertBefore(document.createTextNode(user.name), null); - - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - cursorElement.insertBefore(labelElement, null); - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - - return cursorElement; - }; - - const render = (user: { color: string; name: string }) => { - const clientState = [...awareness.getStates().entries()].find( - (state) => state[1].user === user - ); - - if (!clientState) { - throw new Error("Could not find client state for user"); - } - - const clientID = clientState[0]; - - let cursorData = cursors.get(clientID); - - if (!cursorData) { - const cursorElement = - opts.collaboration?.renderCursor?.(user) || renderCursor(user); - - if (opts.collaboration?.showCursorLabels !== "always") { - cursorElement.addEventListener("mouseenter", () => { - const cursor = cursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - cursors.set(clientID, { - element: cursor.element, - hideTimeout: undefined, - }); - } - }); - - cursorElement.addEventListener("mouseleave", () => { - const cursor = cursors.get(clientID)!; - - cursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - }); - } - - cursorData = { - element: cursorElement, - hideTimeout: undefined, - }; - - cursors.set(clientID, cursorData); - } - - return cursorData.element; - }; - - tiptapExtensions.push( - CollaborationCursor.configure({ - user: opts.collaboration.user, - render, - provider: opts.collaboration.provider, - }) - ); - } + tiptapExtensions.push(...createCollaborationExtensions(opts.collaboration)); } else { // disable history extension when collaboration is enabled as Yjs takes care of undo / redo tiptapExtensions.push(History); diff --git a/packages/core/src/extensions/Collaboration/createCollaborationExtensions.ts b/packages/core/src/extensions/Collaboration/createCollaborationExtensions.ts new file mode 100644 index 000000000..0ebea152f --- /dev/null +++ b/packages/core/src/extensions/Collaboration/createCollaborationExtensions.ts @@ -0,0 +1,151 @@ +import Collaboration from "@tiptap/extension-collaboration"; +import { Awareness } from "y-protocols/awareness"; +import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; +import * as Y from "yjs"; + +export const createCollaborationExtensions = (collaboration: { + fragment: Y.XmlFragment; + user: { + name: string; + color: string; + [key: string]: string; + }; + provider: any; + renderCursor?: (user: any) => HTMLElement; + showCursorLabels?: "always" | "activity"; +}) => { + const tiptapExtensions = []; + + tiptapExtensions.push( + Collaboration.configure({ + fragment: collaboration.fragment, + }) + ); + + const awareness = collaboration.provider?.awareness as Awareness | undefined; + + if (awareness) { + const cursors = new Map< + number, + { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } + >(); + + if (collaboration.showCursorLabels !== "always") { + awareness.on( + "change", + ({ + updated, + }: { + added: Array; + updated: Array; + removed: Array; + }) => { + for (const clientID of updated) { + const cursor = cursors.get(clientID); + + if (cursor) { + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + } + + cursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + } + } + } + ); + } + + const renderCursor = (user: { name: string; color: string }) => { + const cursorElement = document.createElement("span"); + + cursorElement.classList.add("collaboration-cursor__caret"); + cursorElement.setAttribute("style", `border-color: ${user.color}`); + if (collaboration?.showCursorLabels === "always") { + cursorElement.setAttribute("data-active", ""); + } + + const labelElement = document.createElement("span"); + + labelElement.classList.add("collaboration-cursor__label"); + labelElement.setAttribute("style", `background-color: ${user.color}`); + labelElement.insertBefore(document.createTextNode(user.name), null); + + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + cursorElement.insertBefore(labelElement, null); + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + + return cursorElement; + }; + + const render = (user: { color: string; name: string }) => { + const clientState = [...awareness.getStates().entries()].find( + (state) => state[1].user === user + ); + + if (!clientState) { + throw new Error("Could not find client state for user"); + } + + const clientID = clientState[0]; + + let cursorData = cursors.get(clientID); + + if (!cursorData) { + const cursorElement = + collaboration?.renderCursor?.(user) || renderCursor(user); + + if (collaboration?.showCursorLabels !== "always") { + cursorElement.addEventListener("mouseenter", () => { + const cursor = cursors.get(clientID)!; + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + cursors.set(clientID, { + element: cursor.element, + hideTimeout: undefined, + }); + } + }); + + cursorElement.addEventListener("mouseleave", () => { + const cursor = cursors.get(clientID)!; + + cursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + }); + } + + cursorData = { + element: cursorElement, + hideTimeout: undefined, + }; + + cursors.set(clientID, cursorData); + } + + return cursorData.element; + }; + + tiptapExtensions.push( + CollaborationCursor.configure({ + user: collaboration.user, + render, + provider: collaboration.provider, + }) + ); + } + + return tiptapExtensions; +};