From ebd1f12fbb1d12d8413a6f4030594beec901b126 Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Wed, 15 Jan 2025 18:41:24 +0100 Subject: [PATCH 1/4] Improved collaboration cursor UX --- .../core/src/editor/BlockNoteExtensions.ts | 73 +++++++++++++++++-- packages/core/src/editor/editor.css | 1 - 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index ac826dce26..ed3c9333d6 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"; @@ -251,7 +252,11 @@ const getTipTapExtensions = < }) ); if (opts.collaboration.provider?.awareness) { - const defaultRender = (user: { color: string; name: string }) => { + const defaultRender = (user: { + clientID: number; + color: string; + name: string; + }) => { const cursor = document.createElement("span"); cursor.classList.add("collaboration-cursor__caret"); @@ -265,14 +270,72 @@ const getTipTapExtensions = < const nonbreakingSpace1 = document.createTextNode("\u2060"); const nonbreakingSpace2 = document.createTextNode("\u2060"); - cursor.insertBefore(nonbreakingSpace1, null); - cursor.insertBefore(label, null); - cursor.insertBefore(nonbreakingSpace2, null); + + let hideTimeout: NodeJS.Timeout | undefined = undefined; + let oldDoc = opts.editor.document; + const awareness = opts.collaboration!.provider.awareness as Awareness; + + awareness.on( + "change", + (a: { + added: Array; + updated: Array; + removed: Array; + }) => { + if (!a.updated.includes(user.clientID)) { + return; + } + + if (hideTimeout) { + clearTimeout(hideTimeout); + } + + if ( + JSON.stringify(opts.editor.document) !== JSON.stringify(oldDoc) + ) { + cursor.insertBefore(nonbreakingSpace1, null); + cursor.insertBefore(label, null); + cursor.insertBefore(nonbreakingSpace2, null); + + hideTimeout = setTimeout(() => { + label.remove(); + nonbreakingSpace1.remove(); + nonbreakingSpace2.remove(); + }, 500); + } + + oldDoc = opts.editor.document; + } + ); + + cursor.addEventListener("mouseenter", () => { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = undefined; + } + + cursor.insertBefore(nonbreakingSpace1, null); + cursor.insertBefore(label, null); + cursor.insertBefore(nonbreakingSpace2, null); + }); + + cursor.addEventListener("mouseleave", () => { + hideTimeout = setTimeout(() => { + label.remove(); + nonbreakingSpace1.remove(); + nonbreakingSpace2.remove(); + }, 250); + }); + return cursor; }; tiptapExtensions.push( CollaborationCursor.configure({ - user: opts.collaboration.user, + user: { + clientID: opts.collaboration.provider.awareness.clientID, + name: opts.collaboration.user.name, + color: opts.collaboration.user.color, + }, render: opts.collaboration.renderCursor || defaultRender, provider: opts.collaboration.provider, }) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index ba9dffb39d..a9a1f9a440 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; From a1dbddf40138b76f5210498931c9bf651119cb5b Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Thu, 16 Jan 2025 16:16:08 +0100 Subject: [PATCH 2/4] - Made label show on selection changes too - Cleaned up code - Added visibility dot to cursors - Added animations --- .../core/src/editor/BlockNoteExtensions.ts | 156 ++++++++++-------- packages/core/src/editor/editor.css | 66 +++++++- 2 files changed, 150 insertions(+), 72 deletions(-) diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index ed3c9333d6..10b105e989 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -252,87 +252,109 @@ const getTipTapExtensions = < }) ); if (opts.collaboration.provider?.awareness) { - const defaultRender = (user: { - clientID: number; - color: string; - name: string; - }) => { - const cursor = document.createElement("span"); - - cursor.classList.add("collaboration-cursor__caret"); - cursor.setAttribute("style", `border-color: ${user.color}`); - - const label = document.createElement("span"); - - label.classList.add("collaboration-cursor__label"); - label.setAttribute("style", `background-color: ${user.color}`); - label.insertBefore(document.createTextNode(user.name), null); - - const nonbreakingSpace1 = document.createTextNode("\u2060"); - const nonbreakingSpace2 = document.createTextNode("\u2060"); - - let hideTimeout: NodeJS.Timeout | undefined = undefined; - let oldDoc = opts.editor.document; - const awareness = opts.collaboration!.provider.awareness as Awareness; - - awareness.on( - "change", - (a: { - added: Array; - updated: Array; - removed: Array; - }) => { - if (!a.updated.includes(user.clientID)) { - return; + const cursors = new Map< + number, + { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } + >(); + + const awareness = opts.collaboration!.provider.awareness as Awareness; + + 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"); + }, 1000), + }); } + } + } + ); - if (hideTimeout) { - clearTimeout(hideTimeout); - } + const createCursor = (clientID: number, name: string, color: string) => { + const cursorElement = document.createElement("span"); - if ( - JSON.stringify(opts.editor.document) !== JSON.stringify(oldDoc) - ) { - cursor.insertBefore(nonbreakingSpace1, null); - cursor.insertBefore(label, null); - cursor.insertBefore(nonbreakingSpace2, null); - - hideTimeout = setTimeout(() => { - label.remove(); - nonbreakingSpace1.remove(); - nonbreakingSpace2.remove(); - }, 500); - } + cursorElement.classList.add("collaboration-cursor__caret"); + cursorElement.setAttribute("style", `border-color: ${color}`); - oldDoc = opts.editor.document; - } - ); + const labelElement = document.createElement("span"); - cursor.addEventListener("mouseenter", () => { - if (hideTimeout) { - clearTimeout(hideTimeout); - hideTimeout = undefined; - } + 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 - cursor.insertBefore(nonbreakingSpace1, null); - cursor.insertBefore(label, null); - cursor.insertBefore(nonbreakingSpace2, null); + cursors.set(clientID, { + element: cursorElement, + hideTimeout: undefined, }); - cursor.addEventListener("mouseleave", () => { - hideTimeout = setTimeout(() => { - label.remove(); - nonbreakingSpace1.remove(); - nonbreakingSpace2.remove(); - }, 250); + 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, + }); + } }); - return cursor; + cursorElement.addEventListener("mouseleave", () => { + const cursor = cursors.get(clientID)!; + + cursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 1000), + }); + }); + + 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: { - clientID: opts.collaboration.provider.awareness.clientID, name: opts.collaboration.user.name, color: opts.collaboration.user.color, }, diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index a9a1f9a440..f3f04e6b2e 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -88,19 +88,75 @@ Tippy popups that are appended to document.body directly white-space: nowrap !important; } +@keyframes label-collapse { + from { + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + max-height: 1.1rem; + max-width: 10rem; + padding: 0.1rem 0.3rem; + top: -1.1rem; + } + + to { + border-radius: 0 3px 3px 0; + color: transparent; + max-height: 4px; + max-width: 4px; + padding: 0; + top: 0; + } +} + /* Render the username above the caret */ .collaboration-cursor__label { - border-radius: 3px 3px 3px 0; - color: #0d0d0d; + animation-name: label-collapse; + animation-duration: 0.2s; + border-radius: 0 3px 3px 0; + color: transparent; font-size: 12px; font-style: normal; font-weight: 600; - left: -1px; line-height: normal; - padding: 0.1rem 0.3rem; + left: -1px; + max-height: 4px; + max-width: 4px; + overflow: hidden; + padding: 0; position: absolute; - top: -1.4em; + top: 0; user-select: none; +} + +@keyframes label-expand { + from { + border-radius: 0 3px 3px 0; + color: transparent; + max-height: 4px; + max-width: 4px; + padding: 0; + top: -4px; + } + + to { + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + max-height: 1.1rem; + max-width: 20rem; + padding: 0.1rem 0.3rem; + top: -1.1rem; + } +} + +.collaboration-cursor__caret[data-active] > .collaboration-cursor__label { + animation-name: label-expand; + animation-duration: 0.2s; + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + max-height: 1.1rem; + max-width: 20rem; + padding: 0.1rem 0.3rem; + top: -1.1rem; white-space: nowrap; } From 44ecf51ccb5dfe242d34965fcfba5fc6d3492c06 Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Thu, 16 Jan 2025 17:13:48 +0100 Subject: [PATCH 3/4] Implemented PR feedback & revised animations/hide delays --- .../core/src/editor/BlockNoteExtensions.ts | 16 +++--- packages/core/src/editor/editor.css | 53 ++----------------- 2 files changed, 12 insertions(+), 57 deletions(-) diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 10b105e989..c3e227262b 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -251,14 +251,15 @@ const getTipTapExtensions = < fragment: opts.collaboration.fragment, }) ); - if (opts.collaboration.provider?.awareness) { + + const awareness = opts.collaboration?.provider.awareness as Awareness; + + if (awareness) { const cursors = new Map< number, { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } >(); - const awareness = opts.collaboration!.provider.awareness as Awareness; - awareness.on( "change", ({ @@ -282,7 +283,7 @@ const getTipTapExtensions = < element: cursor.element, hideTimeout: setTimeout(() => { cursor.element.removeAttribute("data-active"); - }, 1000), + }, 2000), }); } } @@ -330,7 +331,7 @@ const getTipTapExtensions = < element: cursor.element, hideTimeout: setTimeout(() => { cursor.element.removeAttribute("data-active"); - }, 1000), + }, 2000), }); }); @@ -354,10 +355,7 @@ const getTipTapExtensions = < }; tiptapExtensions.push( CollaborationCursor.configure({ - user: { - name: opts.collaboration.user.name, - color: opts.collaboration.user.color, - }, + user: opts.collaboration.user, render: opts.collaboration.renderCursor || defaultRender, provider: opts.collaboration.provider, }) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index f3f04e6b2e..818c1336c5 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -88,31 +88,9 @@ Tippy popups that are appended to document.body directly white-space: nowrap !important; } -@keyframes label-collapse { - from { - border-radius: 3px 3px 3px 0; - color: #0d0d0d; - max-height: 1.1rem; - max-width: 10rem; - padding: 0.1rem 0.3rem; - top: -1.1rem; - } - - to { - border-radius: 0 3px 3px 0; - color: transparent; - max-height: 4px; - max-width: 4px; - padding: 0; - top: 0; - } -} - /* Render the username above the caret */ .collaboration-cursor__label { - animation-name: label-collapse; - animation-duration: 0.2s; - border-radius: 0 3px 3px 0; + border-radius: 3px 3px 3px 0; color: transparent; font-size: 12px; font-style: normal; @@ -124,39 +102,18 @@ Tippy popups that are appended to document.body directly overflow: hidden; padding: 0; position: absolute; - top: 0; - user-select: none; -} - -@keyframes label-expand { - from { - border-radius: 0 3px 3px 0; - color: transparent; - max-height: 4px; - max-width: 4px; - padding: 0; - top: -4px; - } - - to { - border-radius: 3px 3px 3px 0; - color: #0d0d0d; - max-height: 1.1rem; - max-width: 20rem; - padding: 0.1rem 0.3rem; - top: -1.1rem; - } + transform: translateY(3px); + transition: all 0.2s; } .collaboration-cursor__caret[data-active] > .collaboration-cursor__label { - animation-name: label-expand; - animation-duration: 0.2s; border-radius: 3px 3px 3px 0; color: #0d0d0d; max-height: 1.1rem; max-width: 20rem; padding: 0.1rem 0.3rem; - top: -1.1rem; + transform: translateY(-14px); + transition: all 0.2s; white-space: nowrap; } From c569e67a06a507b1c19c8defb10aee5d6d4f92ac Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Fri, 17 Jan 2025 11:11:51 +0100 Subject: [PATCH 4/4] Added editor option flag and sorted CSS --- packages/core/src/editor/BlockNoteEditor.ts | 7 ++ .../core/src/editor/BlockNoteExtensions.ts | 96 ++++++++++--------- packages/core/src/editor/editor.css | 13 ++- 3 files changed, 67 insertions(+), 49 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index bbfc6388a1..a7b78cdaba 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 c3e227262b..e87ba832c7 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -65,6 +65,7 @@ type ExtensionOptions< }; provider: any; renderCursor?: (user: any) => HTMLElement; + showCursorLabels?: "always" | "activity"; }; disableExtensions: string[] | undefined; setIdAttribute?: boolean; @@ -260,41 +261,46 @@ const getTipTapExtensions = < { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } >(); - 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); + 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), + }); } - - 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"); @@ -311,29 +317,31 @@ const getTipTapExtensions = < hideTimeout: undefined, }); - cursorElement.addEventListener("mouseenter", () => { - const cursor = cursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); + 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)!; - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); cursors.set(clientID, { element: cursor.element, - hideTimeout: undefined, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), }); - } - }); - - 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)!; }; diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 818c1336c5..0cc476e9b1 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -91,30 +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: transparent; font-size: 12px; font-style: normal; font-weight: 600; line-height: normal; left: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + + color: transparent; max-height: 4px; max-width: 4px; - overflow: hidden; padding: 0; - position: absolute; transform: translateY(3px); + transition: all 0.2s; + } .collaboration-cursor__caret[data-active] > .collaboration-cursor__label { - border-radius: 3px 3px 3px 0; color: #0d0d0d; max-height: 1.1rem; max-width: 20rem; padding: 0.1rem 0.3rem; transform: translateY(-14px); + transition: all 0.2s; - white-space: nowrap; } /* .tableWrapper {