Skip to content

Commit

Permalink
fix: improve collab cursor code (#1405)
Browse files Browse the repository at this point in the history
* improve collab cursor logic

* Extracted collaboration extension code to new file

---------

Co-authored-by: matthewlipski <[email protected]>
  • Loading branch information
YousefED and matthewlipski authored Feb 7, 2025
1 parent d386300 commit 7ca714c
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 128 deletions.
132 changes: 4 additions & 128 deletions packages/core/src/editor/BlockNoteExtensions.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
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";
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";
Expand Down Expand Up @@ -44,6 +40,7 @@ import {
StyleSchema,
StyleSpecs,
} from "../schema/index.js";
import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js";

type ExtensionOptions<
BSchema extends BlockSchema,
Expand Down Expand Up @@ -247,128 +244,7 @@ const getTipTapExtensions = <
];

if (opts.collaboration) {
tiptapExtensions.push(
Collaboration.configure({
fragment: opts.collaboration.fragment,
})
);

const awareness = opts.collaboration?.provider.awareness as Awareness;

if (awareness) {
const cursors = new Map<
number,
{ element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined }
>();

if (opts.collaboration.showCursorLabels !== "always") {
awareness.on(
"change",
({
updated,
}: {
added: Array<number>;
updated: Array<number>;
removed: Array<number>;
}) => {
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 createCursor = (clientID: number, name: string, color: string) => {
const cursorElement = document.createElement("span");

cursorElement.classList.add("collaboration-cursor__caret");
cursorElement.setAttribute("style", `border-color: ${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: ${color}`);
labelElement.insertBefore(document.createTextNode(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)!;
};

const defaultRender = (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];

return (
cursors.get(clientID) || createCursor(clientID, user.name, user.color)
).element;
};
tiptapExtensions.push(
CollaborationCursor.configure({
user: opts.collaboration.user,
render: opts.collaboration.renderCursor || defaultRender,
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<number>;
updated: Array<number>;
removed: Array<number>;
}) => {
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;
};

0 comments on commit 7ca714c

Please sign in to comment.