Skip to content

Commit

Permalink
feat: Improved collaboration cursor UX (#1374)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
matthewlipski authored Jan 17, 2025
1 parent 9a8f957 commit 8461552
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 21 deletions.
7 changes: 7 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ export type BlockNoteEditorOptions<
* Optional function to customize how cursors of users are rendered
*/
renderCursor?: (user: any) => HTMLElement;
/**
* Optional flag to set when the user label should be shown with the default
* collaboration cursor. Setting to "always" will always show the label,
* while "activity" will only show the label when the user moves the cursor
* or types. Defaults to "activity".
*/
showCursorLabels?: "always" | "activity";
};

/**
Expand Down
121 changes: 106 additions & 15 deletions packages/core/src/editor/BlockNoteExtensions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AnyExtension, Extension, extensions } from "@tiptap/core";
import { Awareness } from "y-protocols/awareness";

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

Expand Down Expand Up @@ -64,6 +65,7 @@ type ExtensionOptions<
};
provider: any;
renderCursor?: (user: any) => HTMLElement;
showCursorLabels?: "always" | "activity";
};
disableExtensions: string[] | undefined;
setIdAttribute?: boolean;
Expand Down Expand Up @@ -250,25 +252,114 @@ const getTipTapExtensions = <
fragment: opts.collaboration.fragment,
})
);
if (opts.collaboration.provider?.awareness) {
const defaultRender = (user: { color: string; name: string }) => {
const cursor = document.createElement("span");

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

const label = document.createElement("span");
if (!clientState) {
throw new Error("Could not find client state for user");
}

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

const nonbreakingSpace1 = document.createTextNode("\u2060");
const nonbreakingSpace2 = document.createTextNode("\u2060");
cursor.insertBefore(nonbreakingSpace1, null);
cursor.insertBefore(label, null);
cursor.insertBefore(nonbreakingSpace2, null);
return cursor;
return (
cursors.get(clientID) || createCursor(clientID, user.name, user.color)
).element;
};
tiptapExtensions.push(
CollaborationCursor.configure({
Expand Down
27 changes: 21 additions & 6 deletions packages/core/src/editor/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ Tippy popups that are appended to document.body directly
border-right: 1px solid #0d0d0d;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
white-space: nowrap !important;
Expand All @@ -92,17 +91,33 @@ Tippy popups that are appended to document.body directly
/* Render the username above the caret */
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
left: -1px;
overflow: hidden;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;

color: transparent;
max-height: 4px;
max-width: 4px;
padding: 0;
transform: translateY(3px);

transition: all 0.2s;

}

.collaboration-cursor__caret[data-active] > .collaboration-cursor__label {
color: #0d0d0d;
max-height: 1.1rem;
max-width: 20rem;
padding: 0.1rem 0.3rem;
transform: translateY(-14px);

transition: all 0.2s;
}

/* .tableWrapper {
Expand Down

0 comments on commit 8461552

Please sign in to comment.