Skip to content

Commit 8461552

Browse files
feat: Improved collaboration cursor UX (#1374)
* Improved collaboration cursor UX * - Made label show on selection changes too - Cleaned up code - Added visibility dot to cursors - Added animations * Implemented PR feedback & revised animations/hide delays * Added editor option flag and sorted CSS
1 parent 9a8f957 commit 8461552

File tree

3 files changed

+134
-21
lines changed

3 files changed

+134
-21
lines changed

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ export type BlockNoteEditorOptions<
196196
* Optional function to customize how cursors of users are rendered
197197
*/
198198
renderCursor?: (user: any) => HTMLElement;
199+
/**
200+
* Optional flag to set when the user label should be shown with the default
201+
* collaboration cursor. Setting to "always" will always show the label,
202+
* while "activity" will only show the label when the user moves the cursor
203+
* or types. Defaults to "activity".
204+
*/
205+
showCursorLabels?: "always" | "activity";
199206
};
200207

201208
/**

packages/core/src/editor/BlockNoteExtensions.ts

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AnyExtension, Extension, extensions } from "@tiptap/core";
2+
import { Awareness } from "y-protocols/awareness";
23

34
import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js";
45

@@ -64,6 +65,7 @@ type ExtensionOptions<
6465
};
6566
provider: any;
6667
renderCursor?: (user: any) => HTMLElement;
68+
showCursorLabels?: "always" | "activity";
6769
};
6870
disableExtensions: string[] | undefined;
6971
setIdAttribute?: boolean;
@@ -250,25 +252,114 @@ const getTipTapExtensions = <
250252
fragment: opts.collaboration.fragment,
251253
})
252254
);
253-
if (opts.collaboration.provider?.awareness) {
254-
const defaultRender = (user: { color: string; name: string }) => {
255-
const cursor = document.createElement("span");
256255

257-
cursor.classList.add("collaboration-cursor__caret");
258-
cursor.setAttribute("style", `border-color: ${user.color}`);
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+
);
259353

260-
const label = document.createElement("span");
354+
if (!clientState) {
355+
throw new Error("Could not find client state for user");
356+
}
261357

262-
label.classList.add("collaboration-cursor__label");
263-
label.setAttribute("style", `background-color: ${user.color}`);
264-
label.insertBefore(document.createTextNode(user.name), null);
358+
const clientID = clientState[0];
265359

266-
const nonbreakingSpace1 = document.createTextNode("\u2060");
267-
const nonbreakingSpace2 = document.createTextNode("\u2060");
268-
cursor.insertBefore(nonbreakingSpace1, null);
269-
cursor.insertBefore(label, null);
270-
cursor.insertBefore(nonbreakingSpace2, null);
271-
return cursor;
360+
return (
361+
cursors.get(clientID) || createCursor(clientID, user.name, user.color)
362+
).element;
272363
};
273364
tiptapExtensions.push(
274365
CollaborationCursor.configure({

packages/core/src/editor/editor.css

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ Tippy popups that are appended to document.body directly
8383
border-right: 1px solid #0d0d0d;
8484
margin-left: -1px;
8585
margin-right: -1px;
86-
pointer-events: none;
8786
position: relative;
8887
word-break: normal;
8988
white-space: nowrap !important;
@@ -92,17 +91,33 @@ Tippy popups that are appended to document.body directly
9291
/* Render the username above the caret */
9392
.collaboration-cursor__label {
9493
border-radius: 3px 3px 3px 0;
95-
color: #0d0d0d;
9694
font-size: 12px;
9795
font-style: normal;
9896
font-weight: 600;
99-
left: -1px;
10097
line-height: normal;
101-
padding: 0.1rem 0.3rem;
98+
left: -1px;
99+
overflow: hidden;
102100
position: absolute;
103-
top: -1.4em;
104-
user-select: none;
105101
white-space: nowrap;
102+
103+
color: transparent;
104+
max-height: 4px;
105+
max-width: 4px;
106+
padding: 0;
107+
transform: translateY(3px);
108+
109+
transition: all 0.2s;
110+
111+
}
112+
113+
.collaboration-cursor__caret[data-active] > .collaboration-cursor__label {
114+
color: #0d0d0d;
115+
max-height: 1.1rem;
116+
max-width: 20rem;
117+
padding: 0.1rem 0.3rem;
118+
transform: translateY(-14px);
119+
120+
transition: all 0.2s;
106121
}
107122

108123
/* .tableWrapper {

0 commit comments

Comments
 (0)