Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Improved collaboration cursor UX #1374

Merged
merged 4 commits into from
Jan 17, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
@@ -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";
};

/**
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";

@@ -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<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({
27 changes: 21 additions & 6 deletions packages/core/src/editor/editor.css
Original file line number Diff line number Diff line change
@@ -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 {

Unchanged files with check annotations Beta

await page.keyboard.press("ArrowLeft");
await page.waitForTimeout(500);
expect(await page.screenshot()).toMatchSnapshot("ariakit-link-toolbar.png");

Check failure on line 41 in tests/src/end-to-end/ariakit/ariakit.test.ts

GitHub Actions / Build

[firefox] › ariakit/ariakit.test.ts:28:7 › Check Ariakit UI › Check link toolbar

1) [firefox] › ariakit/ariakit.test.ts:28:7 › Check Ariakit UI › Check link toolbar ────────────── Error: expect(Buffer).toMatchSnapshot(expected) 228 pixels (ratio 0.01 of all image pixels) are different. Expected: /home/runner/work/BlockNote/BlockNote/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-link-toolbar-firefox-linux.png Received: /home/runner/work/BlockNote/BlockNote/tests/test-results/ariakit-ariakit-Check-Ariakit-UI-Check-link-toolbar-firefox/ariakit-link-toolbar-actual.png Diff: /home/runner/work/BlockNote/BlockNote/tests/test-results/ariakit-ariakit-Check-Ariakit-UI-Check-link-toolbar-firefox/ariakit-link-toolbar-diff.png 39 | 40 | await page.waitForTimeout(500); > 41 | expect(await page.screenshot()).toMatchSnapshot("ariakit-link-toolbar.png"); | ^ 42 | }); 43 | test("Check slash menu", async ({ page }) => { 44 | await focusOnEditor(page); at /home/runner/work/BlockNote/BlockNote/tests/src/end-to-end/ariakit/ariakit.test.ts:41:37
});
test("Check slash menu", async ({ page }) => {
await focusOnEditor(page);