Skip to content

Commit 7ca714c

Browse files
fix: improve collab cursor code (#1405)
* improve collab cursor logic * Extracted collaboration extension code to new file --------- Co-authored-by: matthewlipski <[email protected]>
1 parent d386300 commit 7ca714c

File tree

2 files changed

+155
-128
lines changed

2 files changed

+155
-128
lines changed

packages/core/src/editor/BlockNoteExtensions.ts

Lines changed: 4 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { AnyExtension, Extension, extensions } from "@tiptap/core";
2-
import { Awareness } from "y-protocols/awareness";
3-
4-
import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js";
5-
6-
import Collaboration from "@tiptap/extension-collaboration";
7-
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
82
import { Gapcursor } from "@tiptap/extension-gapcursor";
93
import { HardBreak } from "@tiptap/extension-hard-break";
104
import { History } from "@tiptap/extension-history";
115
import { Link } from "@tiptap/extension-link";
126
import { Text } from "@tiptap/extension-text";
137
import { Plugin } from "prosemirror-state";
148
import * as Y from "yjs";
9+
10+
import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js";
1511
import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension.js";
1612
import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js";
1713
import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js";
@@ -44,6 +40,7 @@ import {
4440
StyleSchema,
4541
StyleSpecs,
4642
} from "../schema/index.js";
43+
import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js";
4744

4845
type ExtensionOptions<
4946
BSchema extends BlockSchema,
@@ -247,128 +244,7 @@ const getTipTapExtensions = <
247244
];
248245

249246
if (opts.collaboration) {
250-
tiptapExtensions.push(
251-
Collaboration.configure({
252-
fragment: opts.collaboration.fragment,
253-
})
254-
);
255-
256-
const awareness = opts.collaboration?.provider.awareness as Awareness;
257-
258-
if (awareness) {
259-
const cursors = new Map<
260-
number,
261-
{ element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined }
262-
>();
263-
264-
if (opts.collaboration.showCursorLabels !== "always") {
265-
awareness.on(
266-
"change",
267-
({
268-
updated,
269-
}: {
270-
added: Array<number>;
271-
updated: Array<number>;
272-
removed: Array<number>;
273-
}) => {
274-
for (const clientID of updated) {
275-
const cursor = cursors.get(clientID);
276-
277-
if (cursor) {
278-
cursor.element.setAttribute("data-active", "");
279-
280-
if (cursor.hideTimeout) {
281-
clearTimeout(cursor.hideTimeout);
282-
}
283-
284-
cursors.set(clientID, {
285-
element: cursor.element,
286-
hideTimeout: setTimeout(() => {
287-
cursor.element.removeAttribute("data-active");
288-
}, 2000),
289-
});
290-
}
291-
}
292-
}
293-
);
294-
}
295-
296-
const createCursor = (clientID: number, name: string, color: string) => {
297-
const cursorElement = document.createElement("span");
298-
299-
cursorElement.classList.add("collaboration-cursor__caret");
300-
cursorElement.setAttribute("style", `border-color: ${color}`);
301-
if (opts.collaboration?.showCursorLabels === "always") {
302-
cursorElement.setAttribute("data-active", "");
303-
}
304-
305-
const labelElement = document.createElement("span");
306-
307-
labelElement.classList.add("collaboration-cursor__label");
308-
labelElement.setAttribute("style", `background-color: ${color}`);
309-
labelElement.insertBefore(document.createTextNode(name), null);
310-
311-
cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
312-
cursorElement.insertBefore(labelElement, null);
313-
cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
314-
315-
cursors.set(clientID, {
316-
element: cursorElement,
317-
hideTimeout: undefined,
318-
});
319-
320-
if (opts.collaboration?.showCursorLabels !== "always") {
321-
cursorElement.addEventListener("mouseenter", () => {
322-
const cursor = cursors.get(clientID)!;
323-
cursor.element.setAttribute("data-active", "");
324-
325-
if (cursor.hideTimeout) {
326-
clearTimeout(cursor.hideTimeout);
327-
cursors.set(clientID, {
328-
element: cursor.element,
329-
hideTimeout: undefined,
330-
});
331-
}
332-
});
333-
334-
cursorElement.addEventListener("mouseleave", () => {
335-
const cursor = cursors.get(clientID)!;
336-
337-
cursors.set(clientID, {
338-
element: cursor.element,
339-
hideTimeout: setTimeout(() => {
340-
cursor.element.removeAttribute("data-active");
341-
}, 2000),
342-
});
343-
});
344-
}
345-
346-
return cursors.get(clientID)!;
347-
};
348-
349-
const defaultRender = (user: { color: string; name: string }) => {
350-
const clientState = [...awareness.getStates().entries()].find(
351-
(state) => state[1].user === user
352-
);
353-
354-
if (!clientState) {
355-
throw new Error("Could not find client state for user");
356-
}
357-
358-
const clientID = clientState[0];
359-
360-
return (
361-
cursors.get(clientID) || createCursor(clientID, user.name, user.color)
362-
).element;
363-
};
364-
tiptapExtensions.push(
365-
CollaborationCursor.configure({
366-
user: opts.collaboration.user,
367-
render: opts.collaboration.renderCursor || defaultRender,
368-
provider: opts.collaboration.provider,
369-
})
370-
);
371-
}
247+
tiptapExtensions.push(...createCollaborationExtensions(opts.collaboration));
372248
} else {
373249
// disable history extension when collaboration is enabled as Yjs takes care of undo / redo
374250
tiptapExtensions.push(History);
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import Collaboration from "@tiptap/extension-collaboration";
2+
import { Awareness } from "y-protocols/awareness";
3+
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
4+
import * as Y from "yjs";
5+
6+
export const createCollaborationExtensions = (collaboration: {
7+
fragment: Y.XmlFragment;
8+
user: {
9+
name: string;
10+
color: string;
11+
[key: string]: string;
12+
};
13+
provider: any;
14+
renderCursor?: (user: any) => HTMLElement;
15+
showCursorLabels?: "always" | "activity";
16+
}) => {
17+
const tiptapExtensions = [];
18+
19+
tiptapExtensions.push(
20+
Collaboration.configure({
21+
fragment: collaboration.fragment,
22+
})
23+
);
24+
25+
const awareness = collaboration.provider?.awareness as Awareness | undefined;
26+
27+
if (awareness) {
28+
const cursors = new Map<
29+
number,
30+
{ element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined }
31+
>();
32+
33+
if (collaboration.showCursorLabels !== "always") {
34+
awareness.on(
35+
"change",
36+
({
37+
updated,
38+
}: {
39+
added: Array<number>;
40+
updated: Array<number>;
41+
removed: Array<number>;
42+
}) => {
43+
for (const clientID of updated) {
44+
const cursor = cursors.get(clientID);
45+
46+
if (cursor) {
47+
cursor.element.setAttribute("data-active", "");
48+
49+
if (cursor.hideTimeout) {
50+
clearTimeout(cursor.hideTimeout);
51+
}
52+
53+
cursors.set(clientID, {
54+
element: cursor.element,
55+
hideTimeout: setTimeout(() => {
56+
cursor.element.removeAttribute("data-active");
57+
}, 2000),
58+
});
59+
}
60+
}
61+
}
62+
);
63+
}
64+
65+
const renderCursor = (user: { name: string; color: string }) => {
66+
const cursorElement = document.createElement("span");
67+
68+
cursorElement.classList.add("collaboration-cursor__caret");
69+
cursorElement.setAttribute("style", `border-color: ${user.color}`);
70+
if (collaboration?.showCursorLabels === "always") {
71+
cursorElement.setAttribute("data-active", "");
72+
}
73+
74+
const labelElement = document.createElement("span");
75+
76+
labelElement.classList.add("collaboration-cursor__label");
77+
labelElement.setAttribute("style", `background-color: ${user.color}`);
78+
labelElement.insertBefore(document.createTextNode(user.name), null);
79+
80+
cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
81+
cursorElement.insertBefore(labelElement, null);
82+
cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
83+
84+
return cursorElement;
85+
};
86+
87+
const render = (user: { color: string; name: string }) => {
88+
const clientState = [...awareness.getStates().entries()].find(
89+
(state) => state[1].user === user
90+
);
91+
92+
if (!clientState) {
93+
throw new Error("Could not find client state for user");
94+
}
95+
96+
const clientID = clientState[0];
97+
98+
let cursorData = cursors.get(clientID);
99+
100+
if (!cursorData) {
101+
const cursorElement =
102+
collaboration?.renderCursor?.(user) || renderCursor(user);
103+
104+
if (collaboration?.showCursorLabels !== "always") {
105+
cursorElement.addEventListener("mouseenter", () => {
106+
const cursor = cursors.get(clientID)!;
107+
cursor.element.setAttribute("data-active", "");
108+
109+
if (cursor.hideTimeout) {
110+
clearTimeout(cursor.hideTimeout);
111+
cursors.set(clientID, {
112+
element: cursor.element,
113+
hideTimeout: undefined,
114+
});
115+
}
116+
});
117+
118+
cursorElement.addEventListener("mouseleave", () => {
119+
const cursor = cursors.get(clientID)!;
120+
121+
cursors.set(clientID, {
122+
element: cursor.element,
123+
hideTimeout: setTimeout(() => {
124+
cursor.element.removeAttribute("data-active");
125+
}, 2000),
126+
});
127+
});
128+
}
129+
130+
cursorData = {
131+
element: cursorElement,
132+
hideTimeout: undefined,
133+
};
134+
135+
cursors.set(clientID, cursorData);
136+
}
137+
138+
return cursorData.element;
139+
};
140+
141+
tiptapExtensions.push(
142+
CollaborationCursor.configure({
143+
user: collaboration.user,
144+
render,
145+
provider: collaboration.provider,
146+
})
147+
);
148+
}
149+
150+
return tiptapExtensions;
151+
};

0 commit comments

Comments
 (0)