From a1dbddf40138b76f5210498931c9bf651119cb5b Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Thu, 16 Jan 2025 16:16:08 +0100 Subject: [PATCH] - 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 ed3c9333d..10b105e98 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 a9a1f9a44..f3f04e6b2 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; }