diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index a1a3dda7b0..37b80eb51a 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -99,39 +99,118 @@ left: -1px; } -.bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label { - pointer-events: none; - border-radius: 0 1.5px 1.5px 0; +@property --bn-cursor-label-open { + syntax: ""; + inherits: false; + initial-value: 0; +} + +.bn-collaboration-cursor__label-collision-rect, +.bn-collaboration-cursor__label { font-size: 12px; font-style: normal; font-weight: 600; line-height: normal; - left: 0; - overflow: hidden; - position: absolute; white-space: nowrap; user-select: none; + 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; - top: -1px; + 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 { - color: #0d0d0d; + top: -17px; + color: var(--bn-cursor-label-color, #0d0d0d); 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; +/* 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; + + > .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; + + @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 new file mode 100644 index 0000000000..9de0fb40cf --- /dev/null +++ b/packages/core/src/extensions/Collaboration/cursor.browser.test.ts @@ -0,0 +1,667 @@ +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 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); + 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", + ]); + }, + 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", [ + { 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", + ]); + }, + 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", [ + { 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, labelName?: string) { + const element = mount.querySelector(selector); + if (!element) { + throw new Error("Missing cursor target: " + selector); + } + const position = session.editor.prosemirrorView.posAtDOM(element, 0); + session.move(position, labelName); + } + async function label() { + await expect + .poll(() => + session.editor.portalElement.querySelector( + ".bn-collaboration-cursor__label", + ), + ) + .not.toBeNull(); + const element = session.editor.portalElement.querySelector( + ".bn-collaboration-cursor__label", + )!; + await expect + .poll(() => + getComputedStyle(element) + .getPropertyValue("--bn-cursor-label-open") + .trim(), + ) + .toBe("1"); + return element; + } + 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); + 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) + .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().bottom).toBeCloseTo( + caret.getBoundingClientRect().top, + 0, + ); + } + }); + + 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().bottom).toBeCloseTo( + caret.getBoundingClientRect().top, + 0, + ); + expect(label.getBoundingClientRect().left).toBeCloseTo( + caret.getBoundingClientRect().left, + 0, + ); + expect(label.getBoundingClientRect().top).toBeLessThan( + session.mount.getBoundingClientRect().top, + ); + }); + + 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"); + 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(() => + Math.abs( + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, + ), + ) + .toBeLessThan(1); + session.container.scrollTop += 16; + 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(() => + Math.abs( + label.getBoundingClientRect().bottom - + caret.getBoundingClientRect().top, + ), + ) + .toBeLessThan(1); + session.editor.unmount(); + expect(label.isConnected).toBe(false); + }); + + 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" }, + }); + 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", + )!; + expect(label.getBoundingClientRect().left).toBeCloseTo( + caret.getBoundingClientRect().left, + 0, + ); + expect(label.getBoundingClientRect().right).toBeGreaterThan( + session.mount.getBoundingClientRect().right, + ); + }); + + 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"; + 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")!; + const caret = session.mount.querySelector( + "td .bn-collaboration-cursor__caret", + )!; + wrapper.scrollLeft = wrapper.scrollWidth; + 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(() => + Math.abs( + label.getBoundingClientRect().left - + caret.getBoundingClientRect().left, + ), + ) + .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"; + 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("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) + .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) + .getPropertyValue("--bn-cursor-label-open") + .trim(), + ) + .toBe("1"); + cursor.dispatchEvent(new MouseEvent("mouseleave")); + await expect + .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 new file mode 100644 index 0000000000..bbbbf4ba66 --- /dev/null +++ b/packages/core/src/extensions/Collaboration/cursor.ts @@ -0,0 +1,183 @@ +import { uuidv4 } from "lib0/random"; + +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; +} + +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"); + + const caretElement = document.createElement("span"); + caretElement.contentEditable = "false"; + caretElement.classList.add("bn-collaboration-cursor__caret"); + + 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 = + existingLabel?.collisionRect ?? document.createElement("span"); + collisionRect.classList.add("bn-collaboration-cursor__label-collision-rect"); + 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 = existingLabel?.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"; + 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. + cursorElement.append("\u2060", caretElement, "\u2060"); + + return { + element: cursorElement, + label: { + element: labelElement, + collisionRect, + anchorName, + }, + }; +} + +type Cursor = { + element: HTMLElement; + label?: CursorLabel; + 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; + hasCursor: (clientID: number) => boolean; +}) { + const cursors = new Map(); + function hideCursor(cursor: Cursor) { + clearTimeout(cursor.hideTimeout); + cursor.element.removeAttribute("data-active"); + cursor.label?.element.removeAttribute("data-active"); + } + + function showCursor(cursor: Cursor) { + clearTimeout(cursor.hideTimeout); + cursor.element.setAttribute("data-active", ""); + cursor.label?.element.setAttribute("data-active", ""); + } + + function scheduleHide(cursor: Cursor) { + clearTimeout(cursor.hideTimeout); + cursor.hideTimeout = setTimeout(() => hideCursor(cursor), 2000); + } + + 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); + clearTimeout(existing?.hideTimeout); + + const cursor: Cursor = options.renderCursor + ? { element: options.renderCursor(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); + } + return cursor.element; + } + + 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 && options.showCursorLabels !== "always") { + showCursor(cursor); + scheduleHide(cursor); + } + } + } + + 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 c847df083f..bf34edbf10 100644 --- a/packages/core/src/y/extensions/YCursorPlugin.ts +++ b/packages/core/src/y/extensions/YCursorPlugin.ts @@ -3,186 +3,57 @@ 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, + hasCursor: (clientID) => + awareness?.getStates().get(clientID)?.cursor != null, + }); return { key: "yCursor", - prosemirrorPlugins: [ - awareness - ? yCursorPlugin(awareness, { + mount() { + awareness?.on("change", cursors.onAwarenessChange); + return () => { + awareness?.off("change", cursors.onAwarenessChange); + cursors.destroy(); + }; + }, + 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), + }), + ] + : [], 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..663a5f5f87 100644 --- a/packages/core/src/yjs/extensions/YCursorPlugin.ts +++ b/packages/core/src/yjs/extensions/YCursorPlugin.ts @@ -3,198 +3,48 @@ 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, + hasCursor: (clientID) => + awareness?.getStates().get(clientID)?.cursor != null, + }); 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); + cursors.destroy(); + }; }, - 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, + }), + ] + : [], 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/packages/core/vite.config.ts b/packages/core/vite.config.ts index 4c449800c9..785967ce4c 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ }, plugins: [webpackStats()], build: { + cssMinify: "esbuild", sourcemap: true, lib: { entry: {