From d7c4271916e76ed571c1fbf434f4a927d540ce39 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 16 Sep 2026 22:32:51 +0200 Subject: [PATCH 1/6] Fixed collaboration cursor getting clipped --- packages/core/package.json | 1 + packages/core/src/editor/editor.css | 46 +++ .../Collaboration/cursor.browser.test.ts | 320 ++++++++++++++++++ .../src/extensions/Collaboration/cursor.ts | 279 +++++++++++++++ .../core/src/y/extensions/YCursorPlugin.ts | 201 ++--------- .../core/src/yjs/extensions/YCursorPlugin.ts | 204 ++--------- pnpm-lock.yaml | 4 +- 7 files changed, 708 insertions(+), 347 deletions(-) create mode 100644 packages/core/src/extensions/Collaboration/cursor.browser.test.ts create mode 100644 packages/core/src/extensions/Collaboration/cursor.ts diff --git a/packages/core/package.json b/packages/core/package.json index a9a159025a..c2dcfbe254 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,7 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", + "@floating-ui/dom": "^1.8.0", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4.4.3", "@tiptap/core": "^3.29.2", diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index a1a3dda7b0..5fe8e591b9 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -134,6 +134,52 @@ transition: all 0.2s; } +/* Default collaboration labels live in editor.portalElement, outside the + contenteditable and its table/column clipping ancestors. */ +.bn-collaboration-cursor__label[data-portal] { + position: fixed; + width: max-content; + max-width: 20rem; + box-sizing: border-box; + padding: 0.1rem 0.3rem; + font-family: inherit; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + user-select: none; + z-index: 20; + border-radius: 3px 3px 3px 0; +} + +.bn-collaboration-cursor__label[data-portal][data-placement="top-end"] { + border-radius: 3px 3px 0 3px; +} + +.bn-collaboration-cursor__label[data-portal][data-placement="bottom-start"] { + border-radius: 0 3px 3px 3px; +} + +.bn-collaboration-cursor__label[data-portal][data-placement="bottom-end"] { + border-radius: 3px 0 3px 3px; +} + +.bn-editor + .bn-collaboration-cursor__base[data-default]:not([data-active]) + .bn-collaboration-cursor__caret::before { + content: ""; + position: absolute; + top: -1px; + width: 4px; + height: 5px; + background-color: inherit; + border-radius: 0 1.5px 1.5px 0; +} + .bn-editor [data-content-type="table"] .tableWrapper { /* Size of + buttons to add rows/columns. */ --bn-table-widget-size: 22px; diff --git a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts new file mode 100644 index 0000000000..b97276cce5 --- /dev/null +++ b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts @@ -0,0 +1,320 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import * as Y13 from "yjs"; +import * as Y14 from "@y/y"; +import { Awareness as Awareness13 } from "y-protocols/awareness"; +import { Awareness as Awareness14 } from "@y/protocols/awareness"; +import { + absolutePositionToRelativePosition as relative13, + ySyncPluginKey as sync13, +} from "y-prosemirror"; +import { + absolutePositionToRelativePosition as relative14, + ySyncPluginKey as sync14, +} from "@y/prosemirror"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { withCollaboration as collaboration13 } from "../../yjs/extensions/index.js"; +import { withCollaboration as collaboration14 } from "../../y/extensions/index.js"; +import type { CollaborationUser } from "./cursor.js"; +import "../../style.css"; + +type Options = { + showCursorLabels?: "always" | "activity"; + renderCursor?: (user: CollaborationUser) => HTMLElement; +}; +const remoteUser = { name: "Remote User", color: "#aaccff" }; +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup(); + } +}); + +function create13(options: Options) { + const doc = new Y13.Doc(); + const awareness = new Awareness13(doc); + const editor = BlockNoteEditor.create( + collaboration13({ + collaboration: { + ...options, + fragment: doc.getXmlFragment("document"), + provider: { awareness }, + user: { name: "Local", color: "#ff0000" }, + }, + }), + ); + return { + editor, + move(position: number, name = remoteUser.name) { + const state = sync13.getState(editor.prosemirrorState); + if (!state) { + throw new Error("Missing sync state"); + } + const relative = relative13(position, state.type, state.binding.mapping); + awareness.getStates().set(123, { + user: { ...remoteUser, name }, + cursor: { anchor: relative, head: relative }, + }); + awareness.emit("change", [ + { added: [], updated: [123], removed: [] }, + "test", + ]); + }, + remove() { + awareness.getStates().delete(123); + awareness.emit("change", [ + { added: [], updated: [], removed: [123] }, + "test", + ]); + }, + destroy() { + editor.unmount(); + awareness.destroy(); + doc.destroy(); + }, + }; +} +function create14(options: Options) { + const doc = new Y14.Doc(); + const awareness = new Awareness14(doc); + const editor = BlockNoteEditor.create( + collaboration14({ + collaboration: { + ...options, + fragment: doc.get("document"), + provider: { awareness }, + user: { name: "Local", color: "#ff0000" }, + }, + }), + ); + return { + editor, + move(position: number, name = remoteUser.name) { + const state = sync14.getState(editor.prosemirrorState); + if (!state) { + throw new Error("Missing sync state"); + } + const relative = relative14( + editor.prosemirrorState.doc.resolve(position), + state.ytype, + state.renderer, + ); + awareness.getStates().set(123, { + user: { ...remoteUser, name }, + cursor: { anchor: relative, head: relative }, + }); + awareness.emit("change", [ + { added: [], updated: [123], removed: [] }, + "test", + ]); + }, + remove() { + awareness.getStates().delete(123); + awareness.emit("change", [ + { added: [], updated: [], removed: [123] }, + "test", + ]); + }, + destroy() { + editor.unmount(); + awareness.destroy(); + doc.destroy(); + }, + }; +} + +for (const [name, create] of [ + ["Yjs 13", create13], + ["Yjs 14", create14], +] as const) { + describe(name + " collaboration labels", () => { + function setup( + options: Options = { showCursorLabels: "always" }, + portalTarget?: HTMLElement, + ) { + const container = document.createElement("div"); + container.style.cssText = + "position:relative;width:500px;height:180px;overflow:auto;margin:40px;"; + const mount = document.createElement("div"); + container.append(mount); + document.body.append(container); + const session = create(options); + session.editor.mount(mount, { portalTarget }); + session.editor.prosemirrorView.dom.style.cssText = + "padding:0;min-height:400px;"; + session.editor.replaceBlocks(session.editor.document, [ + { type: "paragraph", content: "First line" }, + { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Table cell", "Other cell"] }], + }, + }, + { type: "paragraph", content: "Last line" }, + ]); + cleanups.push(() => { + session.destroy(); + container.remove(); + }); + function moveTo(selector: string, atEnd = false, labelName?: string) { + const element = mount.querySelector(selector); + if (!element) { + throw new Error("Missing cursor target: " + selector); + } + const position = session.editor.prosemirrorView.posAtDOM( + element, + atEnd ? element.childNodes.length : 0, + ); + session.move(position, labelName); + } + async function label() { + await expect + .poll( + () => + session.editor.portalElement.querySelector( + ".bn-collaboration-cursor__label", + )?.style.visibility, + ) + .toBe("visible"); + return session.editor.portalElement.querySelector( + ".bn-collaboration-cursor__label", + )!; + } + return { ...session, container, mount, moveTo, label }; + } + + it("portals only the label and escapes the table boundary", async () => { + const session = setup(); + const wrapper = + session.mount.querySelector(".tableWrapper")!; + wrapper.style.paddingTop = "0"; + session.moveTo("td p"); + const label = await session.label(); + const caret = session.mount.querySelector( + "td .bn-collaboration-cursor__caret", + ); + expect(caret).not.toBeNull(); + expect(session.mount.contains(label)).toBe(false); + const table = session.mount.querySelector(".tableWrapper")!; + expect(label.dataset.placement).toBe("top-start"); + expect(label.getBoundingClientRect().top).toBeLessThan( + table.getBoundingClientRect().top, + ); + session.remove(); + await expect + .poll(() => session.editor.portalElement.childElementCount) + .toBe(0); + }); + + it("flips below the editor top and keeps capped long labels inside the editor", async () => { + const session = setup(); + session.moveTo(".bn-inline-content", false, "Remote User ".repeat(20)); + const label = await session.label(); + expect(label.dataset.placement).toBe("bottom-start"); + const editorRect = session.mount.getBoundingClientRect(); + expect(label.getBoundingClientRect().left).toBeGreaterThanOrEqual( + editorRect.left - 1, + ); + expect(label.getBoundingClientRect().right).toBeLessThanOrEqual( + editorRect.right + 1, + ); + }); + + it("tracks scrolling and hides labels whose caret is clipped", async () => { + const session = setup({ showCursorLabels: "always" }, document.body); + session.moveTo("td p"); + const label = await session.label(); + const caret = session.mount.querySelector( + ".bn-collaboration-cursor__caret", + )!; + session.container.scrollTop = + caret.getBoundingClientRect().top - + session.container.getBoundingClientRect().top - + 8; + await expect.poll(() => label.dataset.placement).toBe("bottom-start"); + session.container.scrollTop = 140; + await expect.poll(() => label.style.visibility).toBe("hidden"); + session.container.scrollTop = 0; + await expect.poll(() => label.style.visibility).toBe("visible"); + session.editor.unmount(); + expect(label.isConnected).toBe(false); + }); + + it("flips left at the right edge and resizes labels with the editor", async () => { + const session = setup(); + session.editor.updateBlock(session.editor.document.at(-1)!, { + props: { textAlignment: "right" }, + }); + const paragraphs = + session.mount.querySelectorAll(".bn-inline-content"); + const last = paragraphs[paragraphs.length - 1]; + session.move( + session.editor.prosemirrorView.posAtDOM(last, last.childNodes.length), + ); + const label = await session.label(); + expect(label.dataset.placement).toBe("top-end"); + session.container.style.width = "65px"; + await expect + .poll(() => label.getBoundingClientRect().width) + .toBeLessThanOrEqual(65); + session.container.style.width = "500px"; + await expect + .poll(() => label.getBoundingClientRect().width) + .toBeGreaterThan(65); + }); + + it("tracks a table's scroll after a visible cursor moves into it", async () => { + const session = setup(); + session.container.style.width = "180px"; + session.moveTo(".bn-inline-content"); + await session.label(); + session.moveTo("td p"); + await expect + .poll(() => + session.mount.querySelector("td .bn-collaboration-cursor__caret"), + ) + .not.toBeNull(); + const label = await session.label(); + const wrapper = + session.mount.querySelector(".tableWrapper")!; + wrapper.scrollLeft = wrapper.scrollWidth; + await expect.poll(() => label.style.visibility).toBe("hidden"); + wrapper.scrollLeft = 0; + await expect.poll(() => label.style.visibility).toBe("visible"); + }); + + it("preserves custom cursor DOM and does not portal it", async () => { + const custom = document.createElement("span"); + custom.textContent = "Custom cursor"; + const session = setup({ + showCursorLabels: "always", + renderCursor: () => custom, + }); + session.moveTo("td p"); + await expect.poll(() => session.mount.contains(custom)).toBe(true); + expect(session.editor.portalElement.childElementCount).toBe(0); + }); + + it("shows activity labels on hover and removes them after inactivity", async () => { + const session = setup({ showCursorLabels: "activity" }); + session.moveTo("td p"); + await session.label(); + await expect + .poll(() => session.editor.portalElement.childElementCount, { + timeout: 4000, + }) + .toBe(0); + const cursor = session.mount.querySelector( + ".bn-collaboration-cursor__base", + )!; + cursor.dispatchEvent(new MouseEvent("mouseenter")); + await session.label(); + cursor.dispatchEvent(new MouseEvent("mouseleave")); + await expect + .poll(() => session.editor.portalElement.childElementCount, { + timeout: 4000, + }) + .toBe(0); + }); + }); +} diff --git a/packages/core/src/extensions/Collaboration/cursor.ts b/packages/core/src/extensions/Collaboration/cursor.ts new file mode 100644 index 0000000000..92dc5573e5 --- /dev/null +++ b/packages/core/src/extensions/Collaboration/cursor.ts @@ -0,0 +1,279 @@ +import { + autoUpdate, + computePosition, + flip, + getOverflowAncestors, + hide, + shift, + size, +} from "@floating-ui/dom"; +import { Plugin } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; + +export type CollaborationUser = { + id?: string; + name: string; + color: string; + [key: string]: unknown; +}; + +/** + * Determine whether the foreground color should be white or black based on a provided background color + * Inspired by: https://stackoverflow.com/a/3943023 + */ +function isDarkColor(bgColor: string): boolean { + const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor; + const r = parseInt(color.substring(0, 2), 16); // hexToR + const g = parseInt(color.substring(2, 4), 16); // hexToG + const b = parseInt(color.substring(4, 6), 16); // hexToB + const uicolors = [r / 255, g / 255, b / 255]; + const c = uicolors.map((col) => { + if (col <= 0.03928) { + return col / 12.92; + } + return Math.pow((col + 0.055) / 1.055, 2.4); + }); + const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + return L <= 0.179; +} + +function defaultCursorRender(user: CollaborationUser) { + const cursorElement = document.createElement("span"); + + cursorElement.classList.add("bn-collaboration-cursor__base"); + + const caretElement = document.createElement("span"); + caretElement.setAttribute("contenteditable", "false"); + caretElement.classList.add("bn-collaboration-cursor__caret"); + caretElement.setAttribute( + "style", + `background-color: ${user.color}; color: ${ + isDarkColor(user.color) ? "white" : "black" + }`, + ); + + const labelElement = document.createElement("span"); + + labelElement.classList.add("bn-collaboration-cursor__label"); + labelElement.setAttribute( + "style", + `background-color: ${user.color}; color: ${ + isDarkColor(user.color) ? "white" : "black" + }`, + ); + labelElement.insertBefore(document.createTextNode(user.name), null); + + cursorElement.setAttribute("data-default", ""); + + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + cursorElement.insertBefore(caretElement, null); + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + + return { + element: cursorElement, + label: { element: labelElement, caret: caretElement }, + }; +} + +type FloatingLabel = { + element: HTMLElement; + caret: HTMLElement; + positioning?: { + ancestors: ReturnType; + update: () => void; + destroy: () => void; + }; +}; + +type Cursor = { + element: HTMLElement; + label?: FloatingLabel; + hideTimeout?: ReturnType; +}; + +/** Shared DOM renderer for the Yjs 13 and Yjs 14 cursor plugins. */ +export function createCollaborationCursorManager(options: { + renderCursor?: (user: CollaborationUser) => HTMLElement; + showCursorLabels?: "always" | "activity"; + getPortalElement: () => HTMLElement; +}) { + const cursors = new Map(); + let view: EditorView | undefined; + + function stopPositioning(label: FloatingLabel) { + label.positioning?.destroy(); + label.positioning = undefined; + label.element.remove(); + } + + function positionLabel(cursor: Cursor) { + const label = cursor.label; + if (!label || !view || !view.dom.contains(cursor.element)) { + return; + } + if (!cursor.element.hasAttribute("data-active")) { + stopPositioning(label); + return; + } + // A reused widget can move into a different table/scroll container. Refresh + // autoUpdate's listeners when its clipping ancestors change. + const ancestors = getOverflowAncestors(label.caret); + const positioning = label.positioning; + if (positioning) { + if ( + ancestors.length === positioning.ancestors.length && + ancestors.every( + (ancestor, index) => ancestor === positioning.ancestors[index], + ) + ) { + positioning.update(); + return; + } + stopPositioning(label); + } + + const portal = options.getPortalElement(); + label.element.setAttribute("data-portal", ""); + label.element.style.visibility = "hidden"; + portal.append(label.element); + // Table ancestors constrain the caret's visibility, but not its label's + // placement. Use the editor and its outer scroll containers as the boundary. + const boundary = [ + view.dom, + ...getOverflowAncestors(view.dom).filter( + (ancestor): ancestor is Element => ancestor instanceof Element, + ), + ]; + const { element, caret } = label; + let revision = 0; + async function update() { + const currentRevision = ++revision; + const result = await computePosition(caret, element, { + placement: "top-start", + strategy: "fixed", + middleware: [ + flip({ + boundary, + fallbackPlacements: ["top-end", "bottom-start", "bottom-end"], + }), + shift({ boundary }), + size({ + boundary, + apply({ availableWidth, elements }) { + elements.floating.style.maxWidth = `min(20rem, ${Math.max(0, availableWidth)}px)`; + }, + }), + hide({ strategy: "referenceHidden" }), + ], + }); + if (currentRevision !== revision || !element.isConnected) { + return; + } + Object.assign(element.style, { + left: `${result.x}px`, + top: `${result.y}px`, + visibility: result.middlewareData.hide?.referenceHidden + ? "hidden" + : "visible", + }); + element.dataset.placement = result.placement; + } + const cleanup = autoUpdate(label.caret, label.element, update); + label.positioning = { + ancestors, + update, + destroy() { + revision++; + cleanup(); + }, + }; + } + + function hideCursor(cursor: Cursor) { + cursor.element.removeAttribute("data-active"); + if (cursor.label) { + stopPositioning(cursor.label); + } + } + + function showCursor(cursor: Cursor) { + clearTimeout(cursor.hideTimeout); + cursor.element.setAttribute("data-active", ""); + positionLabel(cursor); + } + + function scheduleHide(cursor: Cursor) { + clearTimeout(cursor.hideTimeout); + cursor.hideTimeout = setTimeout(() => hideCursor(cursor), 2000); + } + + function destroyCursor(cursor: Cursor) { + clearTimeout(cursor.hideTimeout); + if (cursor.label) { + stopPositioning(cursor.label); + } + } + + function sync(nextView: EditorView) { + view = nextView; + for (const [clientID, cursor] of cursors) { + if (!view.dom.contains(cursor.element)) { + destroyCursor(cursor); + cursors.delete(clientID); + } else { + positionLabel(cursor); + } + } + } + + return { + cursorBuilder(this: void, user: CollaborationUser, clientID: number) { + let cursor = cursors.get(clientID); + if (!cursor) { + const created: Cursor = options.renderCursor + ? { element: options.renderCursor(user) } + : defaultCursorRender(user); + cursor = created; + cursors.set(clientID, created); + if (options.showCursorLabels !== "always") { + created.element.addEventListener("mouseenter", () => + showCursor(created), + ); + created.element.addEventListener("mouseleave", () => + scheduleHide(created), + ); + scheduleHide(created); + } + created.element.setAttribute("data-active", ""); + } + return cursor.element; + }, + onAwarenessChange(this: void, { updated }: { updated: number[] }) { + if (options.showCursorLabels === "always") { + return; + } + for (const clientID of updated) { + const cursor = cursors.get(clientID); + if (cursor) { + showCursor(cursor); + scheduleHide(cursor); + } + } + }, + plugin: new Plugin({ + view(initialView) { + sync(initialView); + return { + update: sync, + destroy() { + for (const cursor of cursors.values()) { + destroyCursor(cursor); + } + cursors.clear(); + view = undefined; + }, + }; + }, + }), + }; +} diff --git a/packages/core/src/y/extensions/YCursorPlugin.ts b/packages/core/src/y/extensions/YCursorPlugin.ts index c847df083f..6108f2d5bf 100644 --- a/packages/core/src/y/extensions/YCursorPlugin.ts +++ b/packages/core/src/y/extensions/YCursorPlugin.ts @@ -3,186 +3,53 @@ import { createExtension, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; -import { CollaborationOptions } from "./index.js"; - -export type CollaborationUser = { - id?: string; - name: string; - color: string; - [key: string]: unknown; -}; - -/** - * Determine whether the foreground color should be white or black based on a provided background color - * Inspired by: https://stackoverflow.com/a/3943023 - */ -function isDarkColor(bgColor: string): boolean { - const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor; - const r = parseInt(color.substring(0, 2), 16); // hexToR - const g = parseInt(color.substring(2, 4), 16); // hexToG - const b = parseInt(color.substring(4, 6), 16); // hexToB - const uicolors = [r / 255, g / 255, b / 255]; - const c = uicolors.map((col) => { - if (col <= 0.03928) { - return col / 12.92; - } - return Math.pow((col + 0.055) / 1.055, 2.4); - }); - const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; - return L <= 0.179; -} - -function defaultCursorRender(user: CollaborationUser) { - const cursorElement = document.createElement("span"); - - cursorElement.classList.add("bn-collaboration-cursor__base"); - - const caretElement = document.createElement("span"); - caretElement.setAttribute("contentedEditable", "false"); - caretElement.classList.add("bn-collaboration-cursor__caret"); - caretElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - isDarkColor(user.color) ? "white" : "black" - }`, - ); - - const labelElement = document.createElement("span"); - - labelElement.classList.add("bn-collaboration-cursor__label"); - labelElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - isDarkColor(user.color) ? "white" : "black" - }`, - ); - labelElement.insertBefore(document.createTextNode(user.name), null); - - caretElement.insertBefore(labelElement, null); - - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - cursorElement.insertBefore(caretElement, null); - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space +import { + createCollaborationCursorManager, + type CollaborationUser, +} from "../../extensions/Collaboration/cursor.js"; +import type { CollaborationOptions } from "./index.js"; - return cursorElement; -} +export type { CollaborationUser } from "../../extensions/Collaboration/cursor.js"; export const YCursorExtension = createExtension( - ({ options }: ExtensionOptions) => { - const recentlyUpdatedCursors = new Map(); - const awareness = - options.provider && - "awareness" in options.provider && - typeof options.provider.awareness === "object" - ? options.provider.awareness - : undefined; - if (awareness) { - if ( - "setLocalStateField" in awareness && - typeof awareness.setLocalStateField === "function" - ) { - awareness.setLocalStateField("user", options.user); - } - if ("on" in awareness && typeof awareness.on === "function") { - if (options.showCursorLabels !== "always") { - awareness.on( - "change", - ({ - updated, - }: { - added: Array; - updated: Array; - removed: Array; - }) => { - for (const clientID of updated) { - const cursor = recentlyUpdatedCursors.get(clientID); - - if (cursor) { - setTimeout(() => { - cursor.element.setAttribute("data-active", ""); - }, 10); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - } - - recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - } - } - }, - ); - } - } - } - + ({ options, editor }: ExtensionOptions) => { + const awareness = options.provider?.awareness; + awareness?.setLocalStateField("user", options.user); + const cursors = createCollaborationCursorManager({ + renderCursor: options.renderCursor, + showCursorLabels: options.showCursorLabels, + getPortalElement: () => editor.portalElement, + }); return { key: "yCursor", - prosemirrorPlugins: [ - awareness - ? yCursorPlugin(awareness, { + mount() { + awareness?.on("change", cursors.onAwarenessChange); + return () => awareness?.off("change", cursors.onAwarenessChange); + }, + prosemirrorPlugins: awareness + ? [ + yCursorPlugin(awareness, { selectionBuilder: defaultSelectionBuilder, cursorBuilder(user, clientID) { - let cursorData = recentlyUpdatedCursors.get(clientID); - - if (!cursorData) { - const cursorElement = ( - options.renderCursor ?? defaultCursorRender - )(user as CollaborationUser); - - if (options.showCursorLabels !== "always") { - cursorElement.addEventListener("mouseenter", () => { - const cursor = recentlyUpdatedCursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: undefined, - }); - } - }); - - cursorElement.addEventListener("mouseleave", () => { - const cursor = recentlyUpdatedCursors.get(clientID)!; - - recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - }); - } - - cursorData = { - element: cursorElement, - hideTimeout: undefined, - }; - - recentlyUpdatedCursors.set(clientID, cursorData); - } - - return cursorData.element; + return cursors.cursorBuilder( + { + ...user, + name: user.name ?? "Anonymous", + color: user.color ?? "#ffa500", + }, + clientID, + ); }, - }) - : undefined, - ].filter((a) => a !== undefined), + }), + cursors.plugin, + ] + : [], dependsOn: ["ySync"], updateUser(user: CollaborationUser) { awareness?.setLocalStateField("user", user); }, getUser(): CollaborationUser | undefined { - const state = awareness?.getLocalState(); - if (!state) { - return undefined; - } - return state["user"]; + return awareness?.getLocalState()?.["user"]; }, } as const; }, diff --git a/packages/core/src/yjs/extensions/YCursorPlugin.ts b/packages/core/src/yjs/extensions/YCursorPlugin.ts index f31c1b2da1..873c75ab72 100644 --- a/packages/core/src/yjs/extensions/YCursorPlugin.ts +++ b/packages/core/src/yjs/extensions/YCursorPlugin.ts @@ -3,198 +3,44 @@ import { createExtension, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; +import { + createCollaborationCursorManager, + type CollaborationUser, +} from "../../extensions/Collaboration/cursor.js"; import type { CollaborationOptions } from "./index.js"; -export type CollaborationUser = { - id?: string; - name: string; - color: string; - [key: string]: unknown; -}; - -/** - * Determine whether the foreground color should be white or black based on a provided background color - * Inspired by: https://stackoverflow.com/a/3943023 - */ -function isDarkColor(bgColor: string): boolean { - const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor; - const r = parseInt(color.substring(0, 2), 16); // hexToR - const g = parseInt(color.substring(2, 4), 16); // hexToG - const b = parseInt(color.substring(4, 6), 16); // hexToB - const uicolors = [r / 255, g / 255, b / 255]; - const c = uicolors.map((col) => { - if (col <= 0.03928) { - return col / 12.92; - } - return Math.pow((col + 0.055) / 1.055, 2.4); - }); - const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; - return L <= 0.179; -} - -function defaultCursorRender(user: CollaborationUser) { - const cursorElement = document.createElement("span"); - - cursorElement.classList.add("bn-collaboration-cursor__base"); - - const caretElement = document.createElement("span"); - caretElement.setAttribute("contentedEditable", "false"); - caretElement.classList.add("bn-collaboration-cursor__caret"); - caretElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - isDarkColor(user.color) ? "white" : "black" - }`, - ); - - const labelElement = document.createElement("span"); - - labelElement.classList.add("bn-collaboration-cursor__label"); - labelElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - isDarkColor(user.color) ? "white" : "black" - }`, - ); - labelElement.insertBefore(document.createTextNode(user.name), null); - - caretElement.insertBefore(labelElement, null); - - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - cursorElement.insertBefore(caretElement, null); - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - - return cursorElement; -} +export type { CollaborationUser } from "../../extensions/Collaboration/cursor.js"; export const YCursorExtension = createExtension( - ({ options }: ExtensionOptions) => { - const recentlyUpdatedCursors = new Map(); - const awareness = - options.provider && - "awareness" in options.provider && - typeof options.provider.awareness === "object" - ? options.provider.awareness - : undefined; - if (awareness) { - if ( - "setLocalStateField" in awareness && - typeof awareness.setLocalStateField === "function" - ) { - awareness.setLocalStateField("user", options.user); - } - } - - const handleAwarenessChange = ({ - updated, - }: { - added: Array; - updated: Array; - removed: Array; - }) => { - for (const clientID of updated) { - const cursor = recentlyUpdatedCursors.get(clientID); - - if (cursor) { - setTimeout(() => { - cursor.element.setAttribute("data-active", ""); - }, 10); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - } - - recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - } - } - }; - + ({ options, editor }: ExtensionOptions) => { + const awareness = options.provider?.awareness; + awareness?.setLocalStateField("user", options.user); + const cursors = createCollaborationCursorManager({ + renderCursor: options.renderCursor, + showCursorLabels: options.showCursorLabels, + getPortalElement: () => editor.portalElement, + }); return { key: "yCursor", mount() { - if ( - awareness && - options.showCursorLabels !== "always" && - "on" in awareness && - typeof awareness.on === "function" - ) { - awareness.on("change", handleAwarenessChange); - - return () => { - if ("off" in awareness && typeof awareness.off === "function") { - awareness.off("change", handleAwarenessChange); - } - }; - } - - return undefined; + awareness?.on("change", cursors.onAwarenessChange); + return () => awareness?.off("change", cursors.onAwarenessChange); }, - prosemirrorPlugins: [ - awareness - ? yCursorPlugin(awareness, { + prosemirrorPlugins: awareness + ? [ + yCursorPlugin(awareness, { selectionBuilder: defaultSelectionBuilder, - cursorBuilder(user: CollaborationUser, clientID: number) { - let cursorData = recentlyUpdatedCursors.get(clientID); - - if (!cursorData) { - const cursorElement = ( - options.renderCursor ?? defaultCursorRender - )(user); - - if (options.showCursorLabels !== "always") { - cursorElement.addEventListener("mouseenter", () => { - const cursor = recentlyUpdatedCursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: undefined, - }); - } - }); - - cursorElement.addEventListener("mouseleave", () => { - const cursor = recentlyUpdatedCursors.get(clientID)!; - - recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - }); - } - - cursorData = { - element: cursorElement, - hideTimeout: undefined, - }; - - recentlyUpdatedCursors.set(clientID, cursorData); - } - - return cursorData.element; - }, - }) - : undefined, - ].filter(Boolean), + cursorBuilder: cursors.cursorBuilder, + }), + cursors.plugin, + ] + : [], dependsOn: ["ySync"], - updateUser(user: { name: string; color: string; [key: string]: string }) { + updateUser(user: CollaborationUser) { awareness?.setLocalStateField("user", user); }, getUser(): CollaborationUser | undefined { - const state = awareness?.getLocalState(); - if (!state) { - return undefined; - } - return state["user"]; + return awareness?.getLocalState()?.["user"]; }, } as const; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c704abb1be..98862e63f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5255,6 +5255,9 @@ importers: '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 + '@floating-ui/dom': + specifier: ^1.8.0 + version: 1.8.0 '@handlewithcare/prosemirror-inputrules': specifier: ^0.1.4 version: 0.1.4(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) @@ -17951,7 +17954,6 @@ snapshots: dependencies: '@floating-ui/core': 1.8.0 '@floating-ui/utils': 0.2.12 - optional: true '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: From f2a191a3d3cb78ccd9014d0452dc8262c8a18e30 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 17 Sep 2026 11:21:33 +0200 Subject: [PATCH 2/6] Cleanup --- packages/core/src/editor/editor.css | 77 ++++++------ .../Collaboration/cursor.browser.test.ts | 12 +- .../src/extensions/Collaboration/cursor.ts | 111 ++++++++---------- 3 files changed, 86 insertions(+), 114 deletions(-) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 5fe8e591b9..dfd0864ba8 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -99,24 +99,52 @@ left: -1px; } -.bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label { +/* Labels default to floating outside the contenteditable's clipping ancestors. */ +.bn-collaboration-cursor__label { + position: fixed; + width: max-content; + max-width: 20rem; + box-sizing: border-box; + padding: 0.1rem 0.3rem; + border-radius: 3px 3px 3px 0; pointer-events: none; - border-radius: 0 1.5px 1.5px 0; font-size: 12px; font-style: normal; font-weight: 600; line-height: normal; - left: 0; overflow: hidden; - position: absolute; + text-overflow: ellipsis; white-space: nowrap; user-select: none; + z-index: 20; +} + +.bn-collaboration-cursor__label[data-placement="top-end"] { + border-radius: 3px 3px 0 3px; +} + +.bn-collaboration-cursor__label[data-placement="bottom-start"] { + border-radius: 0 3px 3px 3px; +} +.bn-collaboration-cursor__label[data-placement="bottom-end"] { + border-radius: 3px 0 3px 3px; +} + +/* Inline labels are also used by custom renderers and the AI agent cursor. */ +.bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label { + position: absolute; + width: auto; + box-sizing: content-box; + text-overflow: clip; + z-index: auto; + left: 0; + top: -1px; + border-radius: 0 1.5px 1.5px 0; color: transparent; max-height: 5px; max-width: 4px; padding: 0; - top: -1px; transition: all 0.2s; } @@ -127,45 +155,8 @@ max-height: 1.1rem; max-width: 20rem; padding: 0.1rem 0.3rem; - top: -17px; - left: 0; - border-radius: 3px 3px 3px 0; - - transition: all 0.2s; -} - -/* Default collaboration labels live in editor.portalElement, outside the - contenteditable and its table/column clipping ancestors. */ -.bn-collaboration-cursor__label[data-portal] { - position: fixed; - width: max-content; - max-width: 20rem; - box-sizing: border-box; - padding: 0.1rem 0.3rem; - font-family: inherit; - font-size: 12px; - font-style: normal; - font-weight: 600; - line-height: normal; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - pointer-events: none; - user-select: none; - z-index: 20; border-radius: 3px 3px 3px 0; -} - -.bn-collaboration-cursor__label[data-portal][data-placement="top-end"] { - border-radius: 3px 3px 0 3px; -} - -.bn-collaboration-cursor__label[data-portal][data-placement="bottom-start"] { - border-radius: 0 3px 3px 3px; -} - -.bn-collaboration-cursor__label[data-portal][data-placement="bottom-end"] { - border-radius: 3px 0 3px 3px; + top: -17px; } .bn-editor diff --git a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts index b97276cce5..33b71d8a67 100644 --- a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts +++ b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts @@ -156,15 +156,12 @@ for (const [name, create] of [ session.destroy(); container.remove(); }); - function moveTo(selector: string, atEnd = false, labelName?: string) { + function moveTo(selector: string, labelName?: string) { const element = mount.querySelector(selector); if (!element) { throw new Error("Missing cursor target: " + selector); } - const position = session.editor.prosemirrorView.posAtDOM( - element, - atEnd ? element.childNodes.length : 0, - ); + const position = session.editor.prosemirrorView.posAtDOM(element, 0); session.move(position, labelName); } async function label() { @@ -195,10 +192,9 @@ for (const [name, create] of [ ); expect(caret).not.toBeNull(); expect(session.mount.contains(label)).toBe(false); - const table = session.mount.querySelector(".tableWrapper")!; expect(label.dataset.placement).toBe("top-start"); expect(label.getBoundingClientRect().top).toBeLessThan( - table.getBoundingClientRect().top, + wrapper.getBoundingClientRect().top, ); session.remove(); await expect @@ -208,7 +204,7 @@ for (const [name, create] of [ it("flips below the editor top and keeps capped long labels inside the editor", async () => { const session = setup(); - session.moveTo(".bn-inline-content", false, "Remote User ".repeat(20)); + session.moveTo(".bn-inline-content", "Remote User ".repeat(20)); const label = await session.label(); expect(label.dataset.placement).toBe("bottom-start"); const editorRect = session.mount.getBoundingClientRect(); diff --git a/packages/core/src/extensions/Collaboration/cursor.ts b/packages/core/src/extensions/Collaboration/cursor.ts index 92dc5573e5..29da3ef8ba 100644 --- a/packages/core/src/extensions/Collaboration/cursor.ts +++ b/packages/core/src/extensions/Collaboration/cursor.ts @@ -43,31 +43,22 @@ function defaultCursorRender(user: CollaborationUser) { cursorElement.classList.add("bn-collaboration-cursor__base"); const caretElement = document.createElement("span"); - caretElement.setAttribute("contenteditable", "false"); + caretElement.contentEditable = "false"; caretElement.classList.add("bn-collaboration-cursor__caret"); - caretElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - isDarkColor(user.color) ? "white" : "black" - }`, - ); const labelElement = document.createElement("span"); - labelElement.classList.add("bn-collaboration-cursor__label"); - labelElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - isDarkColor(user.color) ? "white" : "black" - }`, - ); - labelElement.insertBefore(document.createTextNode(user.name), null); + labelElement.textContent = user.name; - cursorElement.setAttribute("data-default", ""); + const textColor = isDarkColor(user.color) ? "white" : "black"; + for (const element of [caretElement, labelElement]) { + element.style.backgroundColor = user.color; + element.style.color = textColor; + } - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - cursorElement.insertBefore(caretElement, null); - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + cursorElement.setAttribute("data-default", ""); + // Word joiners anchor the widget in the text without adding visible spacing. + cursorElement.append("\u2060", caretElement, "\u2060"); return { element: cursorElement, @@ -133,7 +124,6 @@ export function createCollaborationCursorManager(options: { } const portal = options.getPortalElement(); - label.element.setAttribute("data-portal", ""); label.element.style.visibility = "hidden"; portal.append(label.element); // Table ancestors constrain the caret's visibility, but not its label's @@ -178,7 +168,7 @@ export function createCollaborationCursorManager(options: { }); element.dataset.placement = result.placement; } - const cleanup = autoUpdate(label.caret, label.element, update); + const cleanup = autoUpdate(caret, element, update); label.positioning = { ancestors, update, @@ -190,6 +180,7 @@ export function createCollaborationCursorManager(options: { } function hideCursor(cursor: Cursor) { + clearTimeout(cursor.hideTimeout); cursor.element.removeAttribute("data-active"); if (cursor.label) { stopPositioning(cursor.label); @@ -207,18 +198,11 @@ export function createCollaborationCursorManager(options: { cursor.hideTimeout = setTimeout(() => hideCursor(cursor), 2000); } - function destroyCursor(cursor: Cursor) { - clearTimeout(cursor.hideTimeout); - if (cursor.label) { - stopPositioning(cursor.label); - } - } - function sync(nextView: EditorView) { view = nextView; for (const [clientID, cursor] of cursors) { if (!view.dom.contains(cursor.element)) { - destroyCursor(cursor); + hideCursor(cursor); cursors.delete(clientID); } else { positionLabel(cursor); @@ -226,40 +210,41 @@ export function createCollaborationCursorManager(options: { } } - return { - cursorBuilder(this: void, user: CollaborationUser, clientID: number) { - let cursor = cursors.get(clientID); - if (!cursor) { - const created: Cursor = options.renderCursor - ? { element: options.renderCursor(user) } - : defaultCursorRender(user); - cursor = created; - cursors.set(clientID, created); - if (options.showCursorLabels !== "always") { - created.element.addEventListener("mouseenter", () => - showCursor(created), - ); - created.element.addEventListener("mouseleave", () => - scheduleHide(created), - ); - scheduleHide(created); - } - created.element.setAttribute("data-active", ""); - } - return cursor.element; - }, - onAwarenessChange(this: void, { updated }: { updated: number[] }) { - if (options.showCursorLabels === "always") { - return; - } - for (const clientID of updated) { - const cursor = cursors.get(clientID); - if (cursor) { - showCursor(cursor); - scheduleHide(cursor); - } + function cursorBuilder(user: CollaborationUser, clientID: number) { + const existing = cursors.get(clientID); + if (existing) { + return existing.element; + } + + const cursor: Cursor = options.renderCursor + ? { element: options.renderCursor(user) } + : defaultCursorRender(user); + cursors.set(clientID, cursor); + if (options.showCursorLabels !== "always") { + cursor.element.addEventListener("mouseenter", () => showCursor(cursor)); + cursor.element.addEventListener("mouseleave", () => scheduleHide(cursor)); + scheduleHide(cursor); + } + cursor.element.setAttribute("data-active", ""); + return cursor.element; + } + + function onAwarenessChange({ updated }: { updated: number[] }) { + if (options.showCursorLabels === "always") { + return; + } + for (const clientID of updated) { + const cursor = cursors.get(clientID); + if (cursor) { + showCursor(cursor); + scheduleHide(cursor); } - }, + } + } + + return { + cursorBuilder, + onAwarenessChange, plugin: new Plugin({ view(initialView) { sync(initialView); @@ -267,7 +252,7 @@ export function createCollaborationCursorManager(options: { update: sync, destroy() { for (const cursor of cursors.values()) { - destroyCursor(cursor); + hideCursor(cursor); } cursors.clear(); view = undefined; From 665c2108c719770a3fa0a242bf2e89520635df1e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 17 Sep 2026 15:30:03 +0200 Subject: [PATCH 3/6] Removed FloatingUI dep and related overflow handling --- packages/core/package.json | 1 - packages/core/src/editor/editor.css | 14 +- .../Collaboration/cursor.browser.test.ts | 109 ++++++++++----- .../src/extensions/Collaboration/cursor.ts | 126 +++++------------- pnpm-lock.yaml | 4 +- 5 files changed, 114 insertions(+), 140 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index c2dcfbe254..a9a159025a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,7 +95,6 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", - "@floating-ui/dom": "^1.8.0", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4.4.3", "@tiptap/core": "^3.29.2", diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index dfd0864ba8..354ce0730b 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -102,6 +102,7 @@ /* Labels default to floating outside the contenteditable's clipping ancestors. */ .bn-collaboration-cursor__label { position: fixed; + transform: translateY(-100%); width: max-content; max-width: 20rem; box-sizing: border-box; @@ -119,21 +120,10 @@ z-index: 20; } -.bn-collaboration-cursor__label[data-placement="top-end"] { - border-radius: 3px 3px 0 3px; -} - -.bn-collaboration-cursor__label[data-placement="bottom-start"] { - border-radius: 0 3px 3px 3px; -} - -.bn-collaboration-cursor__label[data-placement="bottom-end"] { - border-radius: 3px 0 3px 3px; -} - /* Inline labels are also used by custom renderers and the AI agent cursor. */ .bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label { position: absolute; + transform: none; width: auto; box-sizing: content-box; text-overflow: clip; diff --git a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts index 33b71d8a67..bb2b2de5e2 100644 --- a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts +++ b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts @@ -166,13 +166,12 @@ for (const [name, create] of [ } async function label() { await expect - .poll( - () => - session.editor.portalElement.querySelector( - ".bn-collaboration-cursor__label", - )?.style.visibility, + .poll(() => + session.editor.portalElement.querySelector( + ".bn-collaboration-cursor__label", + ), ) - .toBe("visible"); + .not.toBeNull(); return session.editor.portalElement.querySelector( ".bn-collaboration-cursor__label", )!; @@ -192,7 +191,10 @@ for (const [name, create] of [ ); expect(caret).not.toBeNull(); expect(session.mount.contains(label)).toBe(false); - expect(label.dataset.placement).toBe("top-start"); + expect(label.getBoundingClientRect().bottom).toBeCloseTo( + caret!.getBoundingClientRect().top, + 0, + ); expect(label.getBoundingClientRect().top).toBeLessThan( wrapper.getBoundingClientRect().top, ); @@ -202,21 +204,27 @@ for (const [name, create] of [ .toBe(0); }); - it("flips below the editor top and keeps capped long labels inside the editor", async () => { + it("keeps labels above the caret at the top edge", async () => { const session = setup(); session.moveTo(".bn-inline-content", "Remote User ".repeat(20)); const label = await session.label(); - expect(label.dataset.placement).toBe("bottom-start"); - const editorRect = session.mount.getBoundingClientRect(); - expect(label.getBoundingClientRect().left).toBeGreaterThanOrEqual( - editorRect.left - 1, + const caret = session.mount.querySelector( + ".bn-collaboration-cursor__caret", + )!; + expect(label.getBoundingClientRect().bottom).toBeCloseTo( + caret.getBoundingClientRect().top, + 0, ); - expect(label.getBoundingClientRect().right).toBeLessThanOrEqual( - editorRect.right + 1, + expect(label.getBoundingClientRect().left).toBeCloseTo( + caret.getBoundingClientRect().left, + 0, + ); + expect(label.getBoundingClientRect().top).toBeLessThan( + session.mount.getBoundingClientRect().top, ); }); - it("tracks scrolling and hides labels whose caret is clipped", async () => { + it("tracks scrolling without hiding labels whose caret is clipped", async () => { const session = setup({ showCursorLabels: "always" }, document.body); session.moveTo("td p"); const label = await session.label(); @@ -227,16 +235,38 @@ for (const [name, create] of [ caret.getBoundingClientRect().top - session.container.getBoundingClientRect().top - 8; - await expect.poll(() => label.dataset.placement).toBe("bottom-start"); + await expect + .poll(() => + Math.abs( + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, + ), + ) + .toBeLessThan(1); session.container.scrollTop = 140; - await expect.poll(() => label.style.visibility).toBe("hidden"); + await expect + .poll(() => + Math.abs( + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, + ), + ) + .toBeLessThan(1); + expect(getComputedStyle(label).visibility).toBe("visible"); session.container.scrollTop = 0; - await expect.poll(() => label.style.visibility).toBe("visible"); + await expect + .poll(() => + Math.abs( + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, + ), + ) + .toBeLessThan(1); session.editor.unmount(); expect(label.isConnected).toBe(false); }); - it("flips left at the right edge and resizes labels with the editor", async () => { + it("keeps labels to the right of the caret at the right edge", async () => { const session = setup(); session.editor.updateBlock(session.editor.document.at(-1)!, { props: { textAlignment: "right" }, @@ -248,15 +278,16 @@ for (const [name, create] of [ session.editor.prosemirrorView.posAtDOM(last, last.childNodes.length), ); const label = await session.label(); - expect(label.dataset.placement).toBe("top-end"); - session.container.style.width = "65px"; - await expect - .poll(() => label.getBoundingClientRect().width) - .toBeLessThanOrEqual(65); - session.container.style.width = "500px"; - await expect - .poll(() => label.getBoundingClientRect().width) - .toBeGreaterThan(65); + const caret = session.mount.querySelector( + ".bn-collaboration-cursor__caret", + )!; + expect(label.getBoundingClientRect().left).toBeCloseTo( + caret.getBoundingClientRect().left, + 0, + ); + expect(label.getBoundingClientRect().right).toBeGreaterThan( + session.mount.getBoundingClientRect().right, + ); }); it("tracks a table's scroll after a visible cursor moves into it", async () => { @@ -273,10 +304,28 @@ for (const [name, create] of [ const label = await session.label(); const wrapper = session.mount.querySelector(".tableWrapper")!; + const caret = session.mount.querySelector( + "td .bn-collaboration-cursor__caret", + )!; wrapper.scrollLeft = wrapper.scrollWidth; - await expect.poll(() => label.style.visibility).toBe("hidden"); + await expect + .poll(() => + Math.abs( + label.getBoundingClientRect().left - + caret.getBoundingClientRect().left, + ), + ) + .toBeLessThan(1); + expect(getComputedStyle(label).visibility).toBe("visible"); wrapper.scrollLeft = 0; - await expect.poll(() => label.style.visibility).toBe("visible"); + await expect + .poll(() => + Math.abs( + label.getBoundingClientRect().left - + caret.getBoundingClientRect().left, + ), + ) + .toBeLessThan(1); }); it("preserves custom cursor DOM and does not portal it", async () => { diff --git a/packages/core/src/extensions/Collaboration/cursor.ts b/packages/core/src/extensions/Collaboration/cursor.ts index 29da3ef8ba..dc56eb7b3e 100644 --- a/packages/core/src/extensions/Collaboration/cursor.ts +++ b/packages/core/src/extensions/Collaboration/cursor.ts @@ -1,12 +1,3 @@ -import { - autoUpdate, - computePosition, - flip, - getOverflowAncestors, - hide, - shift, - size, -} from "@floating-ui/dom"; import { Plugin } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; @@ -66,19 +57,14 @@ function defaultCursorRender(user: CollaborationUser) { }; } -type FloatingLabel = { +type CursorLabel = { element: HTMLElement; caret: HTMLElement; - positioning?: { - ancestors: ReturnType; - update: () => void; - destroy: () => void; - }; }; type Cursor = { element: HTMLElement; - label?: FloatingLabel; + label?: CursorLabel; hideTimeout?: ReturnType; }; @@ -91,100 +77,35 @@ export function createCollaborationCursorManager(options: { const cursors = new Map(); let view: EditorView | undefined; - function stopPositioning(label: FloatingLabel) { - label.positioning?.destroy(); - label.positioning = undefined; - label.element.remove(); - } - function positionLabel(cursor: Cursor) { const label = cursor.label; if (!label || !view || !view.dom.contains(cursor.element)) { return; } if (!cursor.element.hasAttribute("data-active")) { - stopPositioning(label); + label.element.remove(); return; } - // A reused widget can move into a different table/scroll container. Refresh - // autoUpdate's listeners when its clipping ancestors change. - const ancestors = getOverflowAncestors(label.caret); - const positioning = label.positioning; - if (positioning) { - if ( - ancestors.length === positioning.ancestors.length && - ancestors.every( - (ancestor, index) => ancestor === positioning.ancestors[index], - ) - ) { - positioning.update(); - return; - } - stopPositioning(label); + const portal = options.getPortalElement(); + if (label.element.parentElement !== portal) { + portal.append(label.element); } - const portal = options.getPortalElement(); - label.element.style.visibility = "hidden"; - portal.append(label.element); - // Table ancestors constrain the caret's visibility, but not its label's - // placement. Use the editor and its outer scroll containers as the boundary. - const boundary = [ - view.dom, - ...getOverflowAncestors(view.dom).filter( - (ancestor): ancestor is Element => ancestor instanceof Element, - ), - ]; - const { element, caret } = label; - let revision = 0; - async function update() { - const currentRevision = ++revision; - const result = await computePosition(caret, element, { - placement: "top-start", - strategy: "fixed", - middleware: [ - flip({ - boundary, - fallbackPlacements: ["top-end", "bottom-start", "bottom-end"], - }), - shift({ boundary }), - size({ - boundary, - apply({ availableWidth, elements }) { - elements.floating.style.maxWidth = `min(20rem, ${Math.max(0, availableWidth)}px)`; - }, - }), - hide({ strategy: "referenceHidden" }), - ], - }); - if (currentRevision !== revision || !element.isConnected) { - return; - } - Object.assign(element.style, { - left: `${result.x}px`, - top: `${result.y}px`, - visibility: result.middlewareData.hide?.referenceHidden - ? "hidden" - : "visible", - }); - element.dataset.placement = result.placement; + const rect = label.caret.getBoundingClientRect(); + label.element.style.left = `${rect.left}px`; + label.element.style.top = `${rect.top}px`; + } + + function updatePositions() { + for (const cursor of cursors.values()) { + positionLabel(cursor); } - const cleanup = autoUpdate(caret, element, update); - label.positioning = { - ancestors, - update, - destroy() { - revision++; - cleanup(); - }, - }; } function hideCursor(cursor: Cursor) { clearTimeout(cursor.hideTimeout); cursor.element.removeAttribute("data-active"); - if (cursor.label) { - stopPositioning(cursor.label); - } + cursor.label?.element.remove(); } function showCursor(cursor: Cursor) { @@ -247,10 +168,27 @@ export function createCollaborationCursorManager(options: { onAwarenessChange, plugin: new Plugin({ view(initialView) { + const root = initialView.dom.getRootNode(); + const document = initialView.dom.ownerDocument; + const window = document.defaultView!; + // Capture scroll events from nested tables as well as outer scrollers. + root.addEventListener("scroll", updatePositions, true); + if (root !== document) { + document.addEventListener("scroll", updatePositions, true); + } + window.addEventListener("resize", updatePositions); + root.addEventListener("load", updatePositions, true); + const resizeObserver = new ResizeObserver(updatePositions); + resizeObserver.observe(initialView.dom); sync(initialView); return { update: sync, destroy() { + root.removeEventListener("scroll", updatePositions, true); + document.removeEventListener("scroll", updatePositions, true); + window.removeEventListener("resize", updatePositions); + root.removeEventListener("load", updatePositions, true); + resizeObserver.disconnect(); for (const cursor of cursors.values()) { hideCursor(cursor); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98862e63f0..c704abb1be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5255,9 +5255,6 @@ importers: '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 - '@floating-ui/dom': - specifier: ^1.8.0 - version: 1.8.0 '@handlewithcare/prosemirror-inputrules': specifier: ^0.1.4 version: 0.1.4(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) @@ -17954,6 +17951,7 @@ snapshots: dependencies: '@floating-ui/core': 1.8.0 '@floating-ui/utils': 0.2.12 + optional: true '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: From 32fa60aff470c1ca441e6a05cce06fc201cf1dfd Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 17 Sep 2026 17:58:07 +0200 Subject: [PATCH 4/6] Replaced listeners with CSS anchors --- packages/core/src/editor/editor.css | 59 +++++----- .../Collaboration/cursor.browser.test.ts | 101 ++++++++++++++---- .../src/extensions/Collaboration/cursor.ts | 67 ++++-------- 3 files changed, 131 insertions(+), 96 deletions(-) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 354ce0730b..6ce99423e4 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -102,12 +102,23 @@ /* Labels default to floating outside the contenteditable's clipping ancestors. */ .bn-collaboration-cursor__label { position: fixed; - transform: translateY(-100%); + top: anchor(top); + left: anchor(left); + /* Match the original vertical offsets while aligning with the caret. */ + transform: translateY(-2px); width: max-content; - max-width: 20rem; - box-sizing: border-box; - padding: 0.1rem 0.3rem; - border-radius: 3px 3px 3px 0; + color: transparent; + max-height: 5px; + max-width: 4px; + padding: 0; + border-radius: 0 1.5px 1.5px 0; + transition: + color 0.2s, + max-height 0.2s, + max-width 0.2s, + padding 0.2s, + border-radius 0.2s, + transform 0.2s; pointer-events: none; font-size: 12px; font-style: normal; @@ -120,47 +131,39 @@ z-index: 20; } +.bn-collaboration-cursor__label[data-active], +.bn-editor + .bn-collaboration-cursor__base[data-active] + .bn-collaboration-cursor__label { + color: var(--bn-cursor-label-color, #0d0d0d); + max-height: 1.1rem; + max-width: 20rem; + padding: 0.1rem 0.3rem; + border-radius: 3px 3px 3px 0; +} + +.bn-collaboration-cursor__label[data-active] { + transform: translateY(-18px); +} + /* Inline labels are also used by custom renderers and the AI agent cursor. */ .bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label { position: absolute; transform: none; width: auto; - box-sizing: content-box; text-overflow: clip; z-index: auto; left: 0; top: -1px; - border-radius: 0 1.5px 1.5px 0; - color: transparent; - max-height: 5px; - max-width: 4px; - padding: 0; transition: all 0.2s; } .bn-editor .bn-collaboration-cursor__base[data-active] .bn-collaboration-cursor__label { - color: #0d0d0d; - max-height: 1.1rem; - max-width: 20rem; - padding: 0.1rem 0.3rem; - border-radius: 3px 3px 3px 0; top: -17px; } -.bn-editor - .bn-collaboration-cursor__base[data-default]:not([data-active]) - .bn-collaboration-cursor__caret::before { - content: ""; - position: absolute; - top: -1px; - width: 4px; - height: 5px; - background-color: inherit; - border-radius: 0 1.5px 1.5px 0; -} - .bn-editor [data-content-type="table"] .tableWrapper { /* Size of + buttons to add rows/columns. */ --bn-table-widget-size: 22px; diff --git a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts index bb2b2de5e2..ac1cfb67f5 100644 --- a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts +++ b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts @@ -172,9 +172,13 @@ for (const [name, create] of [ ), ) .not.toBeNull(); - return session.editor.portalElement.querySelector( + const element = session.editor.portalElement.querySelector( ".bn-collaboration-cursor__label", )!; + await expect + .poll(() => getComputedStyle(element).maxWidth) + .toBe("320px"); + return element; } return { ...session, container, mount, moveTo, label }; } @@ -191,8 +195,8 @@ for (const [name, create] of [ ); expect(caret).not.toBeNull(); expect(session.mount.contains(label)).toBe(false); - expect(label.getBoundingClientRect().bottom).toBeCloseTo( - caret!.getBoundingClientRect().top, + expect(label.getBoundingClientRect().top).toBeCloseTo( + caret!.getBoundingClientRect().top - 18, 0, ); expect(label.getBoundingClientRect().top).toBeLessThan( @@ -204,6 +208,33 @@ for (const [name, create] of [ .toBe(0); }); + it("uses distinct anchors for the same collaborator in multiple editors", async () => { + const first = setup({ showCursorLabels: "always" }, document.body); + const second = setup({ showCursorLabels: "always" }, document.body); + first.moveTo(".bn-inline-content"); + second.moveTo("td p"); + const firstLabel = await first.label(); + const secondLabel = await second.label(); + expect(firstLabel.style.getPropertyValue("position-anchor")).not.toBe( + secondLabel.style.getPropertyValue("position-anchor"), + ); + for (const [session, label] of [ + [first, firstLabel], + [second, secondLabel], + ] as const) { + const caret = session.mount.querySelector( + ".bn-collaboration-cursor__caret", + )!; + expect(label.style.getPropertyValue("position-anchor")).toBe( + caret.style.getPropertyValue("anchor-name"), + ); + expect(label.getBoundingClientRect().top).toBeCloseTo( + caret.getBoundingClientRect().top - 18, + 0, + ); + } + }); + it("keeps labels above the caret at the top edge", async () => { const session = setup(); session.moveTo(".bn-inline-content", "Remote User ".repeat(20)); @@ -211,8 +242,8 @@ for (const [name, create] of [ const caret = session.mount.querySelector( ".bn-collaboration-cursor__caret", )!; - expect(label.getBoundingClientRect().bottom).toBeCloseTo( - caret.getBoundingClientRect().top, + expect(label.getBoundingClientRect().top).toBeCloseTo( + caret.getBoundingClientRect().top - 18, 0, ); expect(label.getBoundingClientRect().left).toBeCloseTo( @@ -238,8 +269,9 @@ for (const [name, create] of [ await expect .poll(() => Math.abs( - label.getBoundingClientRect().bottom - - caret.getBoundingClientRect().top, + label.getBoundingClientRect().top - + caret.getBoundingClientRect().top + + 18, ), ) .toBeLessThan(1); @@ -247,8 +279,9 @@ for (const [name, create] of [ await expect .poll(() => Math.abs( - label.getBoundingClientRect().bottom - - caret.getBoundingClientRect().top, + label.getBoundingClientRect().top - + caret.getBoundingClientRect().top + + 18, ), ) .toBeLessThan(1); @@ -257,8 +290,9 @@ for (const [name, create] of [ await expect .poll(() => Math.abs( - label.getBoundingClientRect().bottom - - caret.getBoundingClientRect().top, + label.getBoundingClientRect().top - + caret.getBoundingClientRect().top + + 18, ), ) .toBeLessThan(1); @@ -290,6 +324,32 @@ for (const [name, create] of [ ); }); + it("follows layout changes without an editor transaction", async () => { + const session = setup(); + session.editor.updateBlock(session.editor.document.at(-1)!, { + props: { textAlignment: "right" }, + }); + const paragraphs = + session.mount.querySelectorAll(".bn-inline-content"); + const last = paragraphs[paragraphs.length - 1]; + session.move( + session.editor.prosemirrorView.posAtDOM(last, last.childNodes.length), + ); + const label = await session.label(); + const caret = session.mount.querySelector( + ".bn-collaboration-cursor__caret", + )!; + const originalLeft = label.getBoundingClientRect().left; + session.container.style.width = "300px"; + await expect + .poll(() => label.getBoundingClientRect().left) + .toBeLessThan(originalLeft); + expect(label.getBoundingClientRect().left).toBeCloseTo( + caret.getBoundingClientRect().left, + 0, + ); + }); + it("tracks a table's scroll after a visible cursor moves into it", async () => { const session = setup(); session.container.style.width = "180px"; @@ -340,26 +400,23 @@ for (const [name, create] of [ expect(session.editor.portalElement.childElementCount).toBe(0); }); - it("shows activity labels on hover and removes them after inactivity", async () => { + it("shows activity labels on hover and hides them after inactivity", async () => { const session = setup({ showCursorLabels: "activity" }); session.moveTo("td p"); - await session.label(); + const label = await session.label(); await expect - .poll(() => session.editor.portalElement.childElementCount, { - timeout: 4000, - }) - .toBe(0); + .poll(() => getComputedStyle(label).maxWidth, { timeout: 4000 }) + .toBe("4px"); + expect(label.isConnected).toBe(true); const cursor = session.mount.querySelector( ".bn-collaboration-cursor__base", )!; cursor.dispatchEvent(new MouseEvent("mouseenter")); - await session.label(); + await expect.poll(() => getComputedStyle(label).maxWidth).toBe("320px"); cursor.dispatchEvent(new MouseEvent("mouseleave")); await expect - .poll(() => session.editor.portalElement.childElementCount, { - timeout: 4000, - }) - .toBe(0); + .poll(() => getComputedStyle(label).maxWidth, { timeout: 4000 }) + .toBe("4px"); }); }); } diff --git a/packages/core/src/extensions/Collaboration/cursor.ts b/packages/core/src/extensions/Collaboration/cursor.ts index dc56eb7b3e..f6113d677a 100644 --- a/packages/core/src/extensions/Collaboration/cursor.ts +++ b/packages/core/src/extensions/Collaboration/cursor.ts @@ -1,3 +1,4 @@ +import { uuidv4 } from "lib0/random"; import { Plugin } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; @@ -41,30 +42,29 @@ function defaultCursorRender(user: CollaborationUser) { labelElement.classList.add("bn-collaboration-cursor__label"); labelElement.textContent = user.name; + // Names must be unique across editors sharing the same document/portal root. + const anchorName = `--bn-cursor-${uuidv4()}`; + caretElement.style.setProperty("anchor-name", anchorName); + labelElement.style.setProperty("position-anchor", anchorName); + const textColor = isDarkColor(user.color) ? "white" : "black"; for (const element of [caretElement, labelElement]) { element.style.backgroundColor = user.color; - element.style.color = textColor; } - cursorElement.setAttribute("data-default", ""); + labelElement.style.setProperty("--bn-cursor-label-color", textColor); // Word joiners anchor the widget in the text without adding visible spacing. cursorElement.append("\u2060", caretElement, "\u2060"); return { element: cursorElement, - label: { element: labelElement, caret: caretElement }, + label: labelElement, }; } -type CursorLabel = { - element: HTMLElement; - caret: HTMLElement; -}; - type Cursor = { element: HTMLElement; - label?: CursorLabel; + label?: HTMLElement; hideTimeout?: ReturnType; }; @@ -77,41 +77,31 @@ export function createCollaborationCursorManager(options: { const cursors = new Map(); let view: EditorView | undefined; - function positionLabel(cursor: Cursor) { + function syncLabel(cursor: Cursor) { const label = cursor.label; if (!label || !view || !view.dom.contains(cursor.element)) { return; } - if (!cursor.element.hasAttribute("data-active")) { - label.element.remove(); - return; - } + label.toggleAttribute( + "data-active", + cursor.element.hasAttribute("data-active"), + ); const portal = options.getPortalElement(); - if (label.element.parentElement !== portal) { - portal.append(label.element); - } - - const rect = label.caret.getBoundingClientRect(); - label.element.style.left = `${rect.left}px`; - label.element.style.top = `${rect.top}px`; - } - - function updatePositions() { - for (const cursor of cursors.values()) { - positionLabel(cursor); + if (label.parentElement !== portal) { + portal.append(label); } } function hideCursor(cursor: Cursor) { clearTimeout(cursor.hideTimeout); cursor.element.removeAttribute("data-active"); - cursor.label?.element.remove(); + cursor.label?.removeAttribute("data-active"); } function showCursor(cursor: Cursor) { clearTimeout(cursor.hideTimeout); cursor.element.setAttribute("data-active", ""); - positionLabel(cursor); + syncLabel(cursor); } function scheduleHide(cursor: Cursor) { @@ -124,9 +114,10 @@ export function createCollaborationCursorManager(options: { for (const [clientID, cursor] of cursors) { if (!view.dom.contains(cursor.element)) { hideCursor(cursor); + cursor.label?.remove(); cursors.delete(clientID); } else { - positionLabel(cursor); + syncLabel(cursor); } } } @@ -168,29 +159,13 @@ export function createCollaborationCursorManager(options: { onAwarenessChange, plugin: new Plugin({ view(initialView) { - const root = initialView.dom.getRootNode(); - const document = initialView.dom.ownerDocument; - const window = document.defaultView!; - // Capture scroll events from nested tables as well as outer scrollers. - root.addEventListener("scroll", updatePositions, true); - if (root !== document) { - document.addEventListener("scroll", updatePositions, true); - } - window.addEventListener("resize", updatePositions); - root.addEventListener("load", updatePositions, true); - const resizeObserver = new ResizeObserver(updatePositions); - resizeObserver.observe(initialView.dom); sync(initialView); return { update: sync, destroy() { - root.removeEventListener("scroll", updatePositions, true); - document.removeEventListener("scroll", updatePositions, true); - window.removeEventListener("resize", updatePositions); - root.removeEventListener("load", updatePositions, true); - resizeObserver.disconnect(); for (const cursor of cursors.values()) { hideCursor(cursor); + cursor.label?.remove(); } cursors.clear(); view = undefined; From 0cb7a0da1f89559c1723fae4ad162d2b3bf073c8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 21 Sep 2026 14:52:49 +0200 Subject: [PATCH 5/6] Added viewport overflow handling --- packages/core/src/editor/editor.css | 131 ++++++--- .../Collaboration/cursor.browser.test.ts | 251 ++++++++++++++++-- .../src/extensions/Collaboration/cursor.ts | 35 ++- 3 files changed, 340 insertions(+), 77 deletions(-) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 6ce99423e4..37b80eb51a 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -99,42 +99,42 @@ left: -1px; } -/* Labels default to floating outside the contenteditable's clipping ancestors. */ +@property --bn-cursor-label-open { + syntax: ""; + inherits: false; + initial-value: 0; +} + +.bn-collaboration-cursor__label-collision-rect, .bn-collaboration-cursor__label { - position: fixed; - top: anchor(top); - left: anchor(left); - /* Match the original vertical offsets while aligning with the caret. */ - transform: translateY(-2px); - width: max-content; - color: transparent; - max-height: 5px; - max-width: 4px; - padding: 0; - border-radius: 0 1.5px 1.5px 0; - transition: - color 0.2s, - max-height 0.2s, - max-width 0.2s, - padding 0.2s, - border-radius 0.2s, - transform 0.2s; - pointer-events: none; font-size: 12px; font-style: normal; font-weight: 600; line-height: normal; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; user-select: none; - z-index: 20; + pointer-events: none; +} + +/* Inline labels are also used by custom renderers and the AI agent cursor. */ +.bn-collaboration-cursor__label { + position: absolute; + left: 0; + top: -1px; + color: transparent; + max-height: 5px; + max-width: 4px; + padding: 0; + border-radius: 0 1.5px 1.5px 0; + overflow: hidden; + transition: all 0.2s; } .bn-collaboration-cursor__label[data-active], .bn-editor .bn-collaboration-cursor__base[data-active] .bn-collaboration-cursor__label { + top: -17px; color: var(--bn-cursor-label-color, #0d0d0d); max-height: 1.1rem; max-width: 20rem; @@ -142,26 +142,75 @@ border-radius: 3px 3px 3px 0; } -.bn-collaboration-cursor__label[data-active] { - transform: translateY(-18px); -} +/* Reserve the full open size for collision detection. The visible label uses + one progress value to expand from the caret to this resolved rectangle. */ +.bn-collaboration-cursor__label-collision-rect { + position: fixed; + bottom: anchor(top); + left: anchor(left); + width: max-content; + max-width: 20rem; + max-height: 1.1rem; + padding: 0.1rem 0.3rem; + visibility: hidden; + container-type: anchored; + position-try-fallbacks: + flip-block, + flip-inline, + flip-block flip-inline; -/* Inline labels are also used by custom renderers and the AI agent cursor. */ -.bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label { - position: absolute; - transform: none; - width: auto; - text-overflow: clip; - z-index: auto; - left: 0; - top: -1px; - transition: all 0.2s; -} + > .bn-collaboration-cursor__label { + position: fixed; + visibility: visible; + box-sizing: border-box; + max-width: none; + max-height: none; + text-overflow: ellipsis; + z-index: 20; + /* Interpolate relative to live anchors, so caret movement never trails + behind an opening or closing animation. */ + top: calc( + anchor(top) - 2px + + (anchor(var(--bn-cursor-label-anchor) top) - anchor(top) + 2px) * + var(--bn-cursor-label-open) + ); + left: calc( + anchor(left) + + (anchor(var(--bn-cursor-label-anchor) left) - anchor(left)) * + var(--bn-cursor-label-open) + ); + /* The collapsed label is a 4px × 5px marker at the top of the caret, hence + the need to add these values to the overall width/height. */ + width: calc( + 4px + (anchor-size(var(--bn-cursor-label-anchor) width) - 4px) * + var(--bn-cursor-label-open) + ); + height: calc( + 5px + (anchor-size(var(--bn-cursor-label-anchor) height) - 5px) * + var(--bn-cursor-label-open) + ); + padding: calc(0.1rem * var(--bn-cursor-label-open)) + calc(0.3rem * var(--bn-cursor-label-open)); + transition: + --bn-cursor-label-open 0.2s, + color 0.2s, + border-radius 0.2s; + /* One corner of the label is left un-rounded so it connects flush with the + caret. When the label flips, this corner changes change. */ + &[data-active] { + --bn-cursor-label-open: 1; -.bn-editor - .bn-collaboration-cursor__base[data-active] - .bn-collaboration-cursor__label { - top: -17px; + @container anchored(fallback: flip-block) { + border-radius: 0 3px 3px 3px; + } + @container anchored(fallback: flip-inline) { + border-radius: 3px 3px 0 3px; + } + @container anchored(fallback: flip-block flip-inline) { + border-radius: 3px 0 3px 3px; + } + } + } } .bn-editor [data-content-type="table"] .tableWrapper { diff --git a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts index ac1cfb67f5..da78de3d92 100644 --- a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts +++ b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts @@ -29,6 +29,82 @@ afterEach(() => { } }); +function expectLabelTransition( + label: HTMLElement, + caret: Element, + from: DOMRect, + to: DOMRect, +) { + const animations = label.getAnimations(); + expect(animations.length).toBeGreaterThan(0); + for (const animation of animations) { + animation.pause(); + animation.currentTime = 0; + } + const start = label.getBoundingClientRect(); + for (const property of ["top", "left", "width", "height"] as const) { + expect(start[property]).toBeCloseTo(from[property], 1); + } + for (const animation of animations) { + animation.currentTime = 100; + } + const middle = label.getBoundingClientRect(); + for (const property of ["top", "left", "width", "height"] as const) { + if (Math.abs(from[property] - to[property]) > 1) { + expect(middle[property]).toBeGreaterThan( + Math.min(from[property], to[property]), + ); + expect(middle[property]).toBeLessThan( + Math.max(from[property], to[property]), + ); + } + } + // Moving the anchor must move the whole in-progress animation immediately, + // rather than starting another transition from its old viewport coordinates. + const caretStyle = caret.getAttribute("style"); + const dx = + caret.getBoundingClientRect().right > + document.documentElement.clientWidth - 20 + ? -10 + : 10; + caret.setAttribute("style", `${caretStyle ?? ""};translate:${dx}px 1px;`); + const moved = label.getBoundingClientRect(); + expect(moved.left - middle.left).toBeCloseTo(dx, 1); + expect(moved.top - middle.top).toBeCloseTo(1, 1); + if (caretStyle === null) { + caret.removeAttribute("style"); + } else { + caret.setAttribute("style", caretStyle); + } + + // Sampling just before completion catches a delayed placement reset that + // would otherwise snap to the endpoint when the animation finishes. + for (const animation of animations) { + animation.currentTime = 199; + } + const almostFinished = label.getBoundingClientRect(); + for (const property of ["top", "left", "width", "height"] as const) { + expect(Math.abs(almostFinished[property] - to[property])).toBeLessThan(1); + } + for (const animation of animations) { + animation.finish(); + } + const finished = label.getBoundingClientRect(); + for (const property of ["top", "left", "width", "height"] as const) { + expect(finished[property]).toBeCloseTo(to[property], 1); + } +} + +function expectOpeningAndClosing(label: HTMLElement, caret: Element) { + const open = label.getBoundingClientRect(); + const anchor = caret.getBoundingClientRect(); + const closed = new DOMRect(anchor.left, anchor.top - 2, 4, 5); + label.removeAttribute("data-active"); + expectLabelTransition(label, caret, open, closed); + label.setAttribute("data-active", ""); + expectLabelTransition(label, caret, closed, open); +} + function create13(options: Options) { const doc = new Y13.Doc(); const awareness = new Awareness13(doc); @@ -176,8 +252,12 @@ for (const [name, create] of [ ".bn-collaboration-cursor__label", )!; await expect - .poll(() => getComputedStyle(element).maxWidth) - .toBe("320px"); + .poll(() => + getComputedStyle(element) + .getPropertyValue("--bn-cursor-label-open") + .trim(), + ) + .toBe("1"); return element; } return { ...session, container, mount, moveTo, label }; @@ -195,13 +275,20 @@ for (const [name, create] of [ ); expect(caret).not.toBeNull(); expect(session.mount.contains(label)).toBe(false); - expect(label.getBoundingClientRect().top).toBeCloseTo( - caret!.getBoundingClientRect().top - 18, + await expect + .poll(() => getComputedStyle(label).borderBottomLeftRadius) + .toBe("0px"); + expect(getComputedStyle(label).borderTopLeftRadius).toBe("3px"); + expect(getComputedStyle(label).borderTopRightRadius).toBe("3px"); + expect(getComputedStyle(label).borderBottomRightRadius).toBe("3px"); + expect(label.getBoundingClientRect().bottom).toBeCloseTo( + caret!.getBoundingClientRect().top, 0, ); expect(label.getBoundingClientRect().top).toBeLessThan( wrapper.getBoundingClientRect().top, ); + expectOpeningAndClosing(label, caret!); session.remove(); await expect .poll(() => session.editor.portalElement.childElementCount) @@ -228,22 +315,22 @@ for (const [name, create] of [ expect(label.style.getPropertyValue("position-anchor")).toBe( caret.style.getPropertyValue("anchor-name"), ); - expect(label.getBoundingClientRect().top).toBeCloseTo( - caret.getBoundingClientRect().top - 18, + expect(label.getBoundingClientRect().bottom).toBeCloseTo( + caret.getBoundingClientRect().top, 0, ); } }); - it("keeps labels above the caret at the top edge", async () => { + it("keeps labels above the caret at the editor top edge", async () => { const session = setup(); session.moveTo(".bn-inline-content", "Remote User ".repeat(20)); const label = await session.label(); const caret = session.mount.querySelector( ".bn-collaboration-cursor__caret", )!; - expect(label.getBoundingClientRect().top).toBeCloseTo( - caret.getBoundingClientRect().top - 18, + expect(label.getBoundingClientRect().bottom).toBeCloseTo( + caret.getBoundingClientRect().top, 0, ); expect(label.getBoundingClientRect().left).toBeCloseTo( @@ -255,6 +342,78 @@ for (const [name, create] of [ ); }); + for (const edge of ["top", "right", "top-right"] as const) { + it(`keeps labels inside the viewport at the ${edge} edge`, async () => { + const session = setup(); + const atTop = edge !== "right"; + const atRight = edge !== "top"; + session.container.style.cssText = `position:fixed;top:${atTop ? 0 : 80}px;left:${atRight ? "auto" : "40px"};right:${atRight ? "4px" : "auto"};width:500px;height:180px;overflow:auto;margin:0;`; + if (atRight) { + session.editor.updateBlock(session.editor.document[0], { + props: { textAlignment: "right" }, + }); + } + const paragraph = + session.mount.querySelector(".bn-inline-content")!; + session.move( + session.editor.prosemirrorView.posAtDOM( + paragraph, + atRight ? paragraph.childNodes.length : 0, + ), + ); + const label = await session.label(); + const caret = session.mount.querySelector( + ".bn-collaboration-cursor__caret", + )!; + await expect + .poll(() => { + const rect = label.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.right <= document.documentElement.clientWidth && + rect.bottom <= document.documentElement.clientHeight + ); + }) + .toBe(true); + await expect + .poll(() => { + const style = getComputedStyle(label); + return [ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomRightRadius, + style.borderBottomLeftRadius, + ]; + }) + .toEqual( + edge === "top" + ? ["0px", "3px", "3px", "3px"] + : edge === "right" + ? ["3px", "3px", "0px", "3px"] + : ["3px", "0px", "3px", "3px"], + ); + if (atTop) { + expect(label.getBoundingClientRect().top).toBeCloseTo( + caret.getBoundingClientRect().bottom, + 0, + ); + } else { + expect(label.getBoundingClientRect().bottom).toBeCloseTo( + caret.getBoundingClientRect().top, + 0, + ); + } + if (atRight) { + expect(label.getBoundingClientRect().right).toBeCloseTo( + caret.getBoundingClientRect().right, + 0, + ); + } + expectOpeningAndClosing(label, caret!); + }); + } + it("tracks scrolling without hiding labels whose caret is clipped", async () => { const session = setup({ showCursorLabels: "always" }, document.body); session.moveTo("td p"); @@ -269,19 +428,17 @@ for (const [name, create] of [ await expect .poll(() => Math.abs( - label.getBoundingClientRect().top - - caret.getBoundingClientRect().top + - 18, + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, ), ) .toBeLessThan(1); - session.container.scrollTop = 140; + session.container.scrollTop += 16; await expect .poll(() => Math.abs( - label.getBoundingClientRect().top - - caret.getBoundingClientRect().top + - 18, + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, ), ) .toBeLessThan(1); @@ -290,9 +447,8 @@ for (const [name, create] of [ await expect .poll(() => Math.abs( - label.getBoundingClientRect().top - - caret.getBoundingClientRect().top + - 18, + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, ), ) .toBeLessThan(1); @@ -300,7 +456,7 @@ for (const [name, create] of [ expect(label.isConnected).toBe(false); }); - it("keeps labels to the right of the caret at the right edge", async () => { + it("keeps labels to the right of the caret at the editor right edge", async () => { const session = setup(); session.editor.updateBlock(session.editor.document.at(-1)!, { props: { textAlignment: "right" }, @@ -400,23 +556,68 @@ for (const [name, create] of [ expect(session.editor.portalElement.childElementCount).toBe(0); }); + it("opens at the new caret location when a hidden cursor moves", async () => { + const session = setup({ showCursorLabels: "activity" }); + session.moveTo(".bn-inline-content"); + const label = await session.label(); + label.removeAttribute("data-active"); + for (const animation of label.getAnimations()) { + animation.finish(); + } + const oldPosition = label.getBoundingClientRect(); + session.moveTo("td p"); + const animations = label.getAnimations(); + for (const animation of animations) { + animation.pause(); + animation.currentTime = 0; + } + const caret = session.mount.querySelector( + "td .bn-collaboration-cursor__caret", + )!; + const start = label.getBoundingClientRect(); + expect(start.top).not.toBeCloseTo(oldPosition.top, 1); + expect(start.top).toBeCloseTo(caret.getBoundingClientRect().top - 2, 1); + expect(start.left).toBeCloseTo(caret.getBoundingClientRect().left, 1); + for (const animation of animations) { + animation.finish(); + } + }); + it("shows activity labels on hover and hides them after inactivity", async () => { const session = setup({ showCursorLabels: "activity" }); session.moveTo("td p"); const label = await session.label(); await expect - .poll(() => getComputedStyle(label).maxWidth, { timeout: 4000 }) - .toBe("4px"); + .poll( + () => + getComputedStyle(label) + .getPropertyValue("--bn-cursor-label-open") + .trim(), + { timeout: 4000 }, + ) + .toBe("0"); expect(label.isConnected).toBe(true); const cursor = session.mount.querySelector( ".bn-collaboration-cursor__base", )!; cursor.dispatchEvent(new MouseEvent("mouseenter")); - await expect.poll(() => getComputedStyle(label).maxWidth).toBe("320px"); + await expect + .poll(() => + getComputedStyle(label) + .getPropertyValue("--bn-cursor-label-open") + .trim(), + ) + .toBe("1"); cursor.dispatchEvent(new MouseEvent("mouseleave")); await expect - .poll(() => getComputedStyle(label).maxWidth, { timeout: 4000 }) - .toBe("4px"); + .poll( + () => + getComputedStyle(label) + .getPropertyValue("--bn-cursor-label-open") + .trim(), + { timeout: 4000 }, + ) + .toBe("0"); }); }); } diff --git a/packages/core/src/extensions/Collaboration/cursor.ts b/packages/core/src/extensions/Collaboration/cursor.ts index f6113d677a..582d5286b3 100644 --- a/packages/core/src/extensions/Collaboration/cursor.ts +++ b/packages/core/src/extensions/Collaboration/cursor.ts @@ -42,15 +42,28 @@ function defaultCursorRender(user: CollaborationUser) { labelElement.classList.add("bn-collaboration-cursor__label"); labelElement.textContent = user.name; + // Reserves the open label's size to flip the orientation at the viewport edges before opening. + const collisionRect = document.createElement("span"); + collisionRect.classList.add("bn-collaboration-cursor__label-collision-rect"); + const measurement = document.createElement("span"); + measurement.textContent = user.name; + measurement.setAttribute("aria-hidden", "true"); + collisionRect.append(measurement, labelElement); + // Names must be unique across editors sharing the same document/portal root. const anchorName = `--bn-cursor-${uuidv4()}`; caretElement.style.setProperty("anchor-name", anchorName); labelElement.style.setProperty("position-anchor", anchorName); + collisionRect.style.setProperty("position-anchor", anchorName); + collisionRect.style.setProperty("anchor-name", `${anchorName}-label`); + labelElement.style.setProperty( + "--bn-cursor-label-anchor", + `${anchorName}-label`, + ); const textColor = isDarkColor(user.color) ? "white" : "black"; - for (const element of [caretElement, labelElement]) { - element.style.backgroundColor = user.color; - } + caretElement.style.backgroundColor = user.color; + labelElement.style.backgroundColor = user.color; labelElement.style.setProperty("--bn-cursor-label-color", textColor); // Word joiners anchor the widget in the text without adding visible spacing. @@ -58,13 +71,13 @@ function defaultCursorRender(user: CollaborationUser) { return { element: cursorElement, - label: labelElement, + label: { element: labelElement, collisionRect }, }; } type Cursor = { element: HTMLElement; - label?: HTMLElement; + label?: { element: HTMLElement; collisionRect: HTMLElement }; hideTimeout?: ReturnType; }; @@ -82,20 +95,20 @@ export function createCollaborationCursorManager(options: { if (!label || !view || !view.dom.contains(cursor.element)) { return; } - label.toggleAttribute( + label.element.toggleAttribute( "data-active", cursor.element.hasAttribute("data-active"), ); const portal = options.getPortalElement(); - if (label.parentElement !== portal) { - portal.append(label); + if (label.collisionRect.parentElement !== portal) { + portal.append(label.collisionRect); } } function hideCursor(cursor: Cursor) { clearTimeout(cursor.hideTimeout); cursor.element.removeAttribute("data-active"); - cursor.label?.removeAttribute("data-active"); + cursor.label?.element.removeAttribute("data-active"); } function showCursor(cursor: Cursor) { @@ -114,7 +127,7 @@ export function createCollaborationCursorManager(options: { for (const [clientID, cursor] of cursors) { if (!view.dom.contains(cursor.element)) { hideCursor(cursor); - cursor.label?.remove(); + cursor.label?.collisionRect.remove(); cursors.delete(clientID); } else { syncLabel(cursor); @@ -165,7 +178,7 @@ export function createCollaborationCursorManager(options: { destroy() { for (const cursor of cursors.values()) { hideCursor(cursor); - cursor.label?.remove(); + cursor.label?.collisionRect.remove(); } cursors.clear(); view = undefined; From c8b3ca558c59aa6e121f83e8f0677ce706051a92 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 21 Sep 2026 16:33:21 +0200 Subject: [PATCH 6/6] Replaced ProseMirror plugin with awareness change handler --- .../Collaboration/cursor.browser.test.ts | 44 ++++++ .../src/extensions/Collaboration/cursor.ts | 131 +++++++++--------- .../core/src/y/extensions/YCursorPlugin.ts | 8 +- .../core/src/yjs/extensions/YCursorPlugin.ts | 8 +- 4 files changed, 118 insertions(+), 73 deletions(-) diff --git a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts index da78de3d92..9de0fb40cf 100644 --- a/packages/core/src/extensions/Collaboration/cursor.browser.test.ts +++ b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts @@ -135,6 +135,13 @@ function create13(options: Options) { "test", ]); }, + clearCursor() { + awareness.getStates().set(123, { user: remoteUser, cursor: null }); + awareness.emit("change", [ + { added: [], updated: [123], removed: [] }, + "test", + ]); + }, remove() { awareness.getStates().delete(123); awareness.emit("change", [ @@ -183,6 +190,13 @@ function create14(options: Options) { "test", ]); }, + clearCursor() { + awareness.getStates().set(123, { user: remoteUser, cursor: null }); + awareness.emit("change", [ + { added: [], updated: [123], removed: [] }, + "test", + ]); + }, remove() { awareness.getStates().delete(123); awareness.emit("change", [ @@ -544,6 +558,36 @@ for (const [name, create] of [ .toBeLessThan(1); }); + for (const showCursorLabels of ["always", "activity"] as const) { + it(`cleans up a cleared cursor and renders its return (${showCursorLabels})`, async () => { + const session = setup({ showCursorLabels }); + session.moveTo("td p"); + const original = await session.label(); + session.clearCursor(); + expect(original.isConnected).toBe(false); + expect(session.editor.portalElement.childElementCount).toBe(0); + session.moveTo(".bn-inline-content"); + const returned = await session.label(); + expect(returned).not.toBe(original); + expect(session.editor.portalElement.childElementCount).toBe(1); + session.remove(); + expect(session.editor.portalElement.childElementCount).toBe(0); + }); + } + + it("reuses the label when rebuilding a cursor and updates its user", async () => { + const session = setup(); + session.moveTo(".bn-inline-content"); + const original = await session.label(); + session.moveTo("td p", "Updated User"); + const updated = await session.label(); + expect(updated).toBe(original); + expect(updated.textContent).toBe("Updated User"); + expect(session.editor.portalElement.childElementCount).toBe(1); + session.editor.unmount(); + expect(session.editor.portalElement.childElementCount).toBe(0); + }); + it("preserves custom cursor DOM and does not portal it", async () => { const custom = document.createElement("span"); custom.textContent = "Custom cursor"; diff --git a/packages/core/src/extensions/Collaboration/cursor.ts b/packages/core/src/extensions/Collaboration/cursor.ts index 582d5286b3..bbbbf4ba66 100644 --- a/packages/core/src/extensions/Collaboration/cursor.ts +++ b/packages/core/src/extensions/Collaboration/cursor.ts @@ -1,6 +1,4 @@ import { uuidv4 } from "lib0/random"; -import { Plugin } from "prosemirror-state"; -import type { EditorView } from "prosemirror-view"; export type CollaborationUser = { id?: string; @@ -29,7 +27,16 @@ function isDarkColor(bgColor: string): boolean { return L <= 0.179; } -function defaultCursorRender(user: CollaborationUser) { +type CursorLabel = { + element: HTMLElement; + collisionRect: HTMLElement; + anchorName: string; +}; + +function defaultCursorRender( + user: CollaborationUser, + existingLabel?: CursorLabel, +) { const cursorElement = document.createElement("span"); cursorElement.classList.add("bn-collaboration-cursor__base"); @@ -38,20 +45,22 @@ function defaultCursorRender(user: CollaborationUser) { caretElement.contentEditable = "false"; caretElement.classList.add("bn-collaboration-cursor__caret"); - const labelElement = document.createElement("span"); + const labelElement = existingLabel?.element ?? document.createElement("span"); labelElement.classList.add("bn-collaboration-cursor__label"); labelElement.textContent = user.name; // Reserves the open label's size to flip the orientation at the viewport edges before opening. - const collisionRect = document.createElement("span"); + const collisionRect = + existingLabel?.collisionRect ?? document.createElement("span"); collisionRect.classList.add("bn-collaboration-cursor__label-collision-rect"); - const measurement = document.createElement("span"); - measurement.textContent = user.name; - measurement.setAttribute("aria-hidden", "true"); - collisionRect.append(measurement, labelElement); + if (collisionRect.firstChild) { + collisionRect.firstChild.nodeValue = user.name; + } else { + collisionRect.append(user.name, labelElement); + } // Names must be unique across editors sharing the same document/portal root. - const anchorName = `--bn-cursor-${uuidv4()}`; + const anchorName = existingLabel?.anchorName ?? `--bn-cursor-${uuidv4()}`; caretElement.style.setProperty("anchor-name", anchorName); labelElement.style.setProperty("position-anchor", anchorName); collisionRect.style.setProperty("position-anchor", anchorName); @@ -71,13 +80,17 @@ function defaultCursorRender(user: CollaborationUser) { return { element: cursorElement, - label: { element: labelElement, collisionRect }, + label: { + element: labelElement, + collisionRect, + anchorName, + }, }; } type Cursor = { element: HTMLElement; - label?: { element: HTMLElement; collisionRect: HTMLElement }; + label?: CursorLabel; hideTimeout?: ReturnType; }; @@ -86,25 +99,9 @@ export function createCollaborationCursorManager(options: { renderCursor?: (user: CollaborationUser) => HTMLElement; showCursorLabels?: "always" | "activity"; getPortalElement: () => HTMLElement; + hasCursor: (clientID: number) => boolean; }) { const cursors = new Map(); - let view: EditorView | undefined; - - function syncLabel(cursor: Cursor) { - const label = cursor.label; - if (!label || !view || !view.dom.contains(cursor.element)) { - return; - } - label.element.toggleAttribute( - "data-active", - cursor.element.hasAttribute("data-active"), - ); - const portal = options.getPortalElement(); - if (label.collisionRect.parentElement !== portal) { - portal.append(label.collisionRect); - } - } - function hideCursor(cursor: Cursor) { clearTimeout(cursor.hideTimeout); cursor.element.removeAttribute("data-active"); @@ -114,7 +111,7 @@ export function createCollaborationCursorManager(options: { function showCursor(cursor: Cursor) { clearTimeout(cursor.hideTimeout); cursor.element.setAttribute("data-active", ""); - syncLabel(cursor); + cursor.label?.element.setAttribute("data-active", ""); } function scheduleHide(cursor: Cursor) { @@ -122,69 +119,65 @@ export function createCollaborationCursorManager(options: { cursor.hideTimeout = setTimeout(() => hideCursor(cursor), 2000); } - function sync(nextView: EditorView) { - view = nextView; - for (const [clientID, cursor] of cursors) { - if (!view.dom.contains(cursor.element)) { - hideCursor(cursor); - cursor.label?.collisionRect.remove(); - cursors.delete(clientID); - } else { - syncLabel(cursor); - } + function removeCursor(clientID: number) { + const cursor = cursors.get(clientID); + if (cursor) { + hideCursor(cursor); + cursor.label?.collisionRect.remove(); + cursors.delete(clientID); } } function cursorBuilder(user: CollaborationUser, clientID: number) { const existing = cursors.get(clientID); - if (existing) { - return existing.element; - } + clearTimeout(existing?.hideTimeout); const cursor: Cursor = options.renderCursor ? { element: options.renderCursor(user) } - : defaultCursorRender(user); + : defaultCursorRender(user, existing?.label); cursors.set(clientID, cursor); + if (cursor.label && !existing?.label) { + options.getPortalElement().append(cursor.label.collisionRect); + } if (options.showCursorLabels !== "always") { cursor.element.addEventListener("mouseenter", () => showCursor(cursor)); cursor.element.addEventListener("mouseleave", () => scheduleHide(cursor)); + } + showCursor(cursor); + if (options.showCursorLabels !== "always") { scheduleHide(cursor); } - cursor.element.setAttribute("data-active", ""); return cursor.element; } - function onAwarenessChange({ updated }: { updated: number[] }) { - if (options.showCursorLabels === "always") { - return; + function onAwarenessChange({ + updated, + removed, + }: { + updated: number[]; + removed: number[]; + }) { + for (const clientID of removed) { + removeCursor(clientID); } for (const clientID of updated) { + if (!options.hasCursor(clientID)) { + removeCursor(clientID); + continue; + } const cursor = cursors.get(clientID); - if (cursor) { + if (cursor && options.showCursorLabels !== "always") { showCursor(cursor); scheduleHide(cursor); } } } - return { - cursorBuilder, - onAwarenessChange, - plugin: new Plugin({ - view(initialView) { - sync(initialView); - return { - update: sync, - destroy() { - for (const cursor of cursors.values()) { - hideCursor(cursor); - cursor.label?.collisionRect.remove(); - } - cursors.clear(); - view = undefined; - }, - }; - }, - }), - }; + function destroy() { + for (const clientID of cursors.keys()) { + removeCursor(clientID); + } + } + + return { cursorBuilder, onAwarenessChange, destroy }; } diff --git a/packages/core/src/y/extensions/YCursorPlugin.ts b/packages/core/src/y/extensions/YCursorPlugin.ts index 6108f2d5bf..bf34edbf10 100644 --- a/packages/core/src/y/extensions/YCursorPlugin.ts +++ b/packages/core/src/y/extensions/YCursorPlugin.ts @@ -19,12 +19,17 @@ export const YCursorExtension = createExtension( renderCursor: options.renderCursor, showCursorLabels: options.showCursorLabels, getPortalElement: () => editor.portalElement, + hasCursor: (clientID) => + awareness?.getStates().get(clientID)?.cursor != null, }); return { key: "yCursor", mount() { awareness?.on("change", cursors.onAwarenessChange); - return () => awareness?.off("change", cursors.onAwarenessChange); + return () => { + awareness?.off("change", cursors.onAwarenessChange); + cursors.destroy(); + }; }, prosemirrorPlugins: awareness ? [ @@ -41,7 +46,6 @@ export const YCursorExtension = createExtension( ); }, }), - cursors.plugin, ] : [], dependsOn: ["ySync"], diff --git a/packages/core/src/yjs/extensions/YCursorPlugin.ts b/packages/core/src/yjs/extensions/YCursorPlugin.ts index 873c75ab72..663a5f5f87 100644 --- a/packages/core/src/yjs/extensions/YCursorPlugin.ts +++ b/packages/core/src/yjs/extensions/YCursorPlugin.ts @@ -19,12 +19,17 @@ export const YCursorExtension = createExtension( renderCursor: options.renderCursor, showCursorLabels: options.showCursorLabels, getPortalElement: () => editor.portalElement, + hasCursor: (clientID) => + awareness?.getStates().get(clientID)?.cursor != null, }); return { key: "yCursor", mount() { awareness?.on("change", cursors.onAwarenessChange); - return () => awareness?.off("change", cursors.onAwarenessChange); + return () => { + awareness?.off("change", cursors.onAwarenessChange); + cursors.destroy(); + }; }, prosemirrorPlugins: awareness ? [ @@ -32,7 +37,6 @@ export const YCursorExtension = createExtension( selectionBuilder: defaultSelectionBuilder, cursorBuilder: cursors.cursorBuilder, }), - cursors.plugin, ] : [], dependsOn: ["ySync"],