From 8461552ca540c31ae8077f691ce3b5098ca3d8d0 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:18:33 +0100 Subject: [PATCH] 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 --- packages/core/src/editor/BlockNoteEditor.ts | 7 + .../core/src/editor/BlockNoteExtensions.ts | 121 +++++++++++++++--- packages/core/src/editor/editor.css | 27 +++- 3 files changed, 134 insertions(+), 21 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index bbfc6388a..a7b78cdab 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -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"; }; /** diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index ac826dce2..e87ba832c 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -1,4 +1,5 @@ import { AnyExtension, Extension, extensions } from "@tiptap/core"; +import { Awareness } from "y-protocols/awareness"; import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js"; @@ -64,6 +65,7 @@ type ExtensionOptions< }; provider: any; renderCursor?: (user: any) => HTMLElement; + showCursorLabels?: "always" | "activity"; }; disableExtensions: string[] | undefined; setIdAttribute?: boolean; @@ -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; + 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 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({ diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index ba9dffb39..0cc476e9b 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -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; @@ -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 {