diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 6aeaea0d33..5d0ecf03ff 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -100,6 +100,10 @@ left: -1px; } +/* The inline label, for backwards compatibility: browsers without anchor + positioning, cursors from `renderCursor` and the AI agent cursor. Labels + that `floatCursorLabel` sets up get the anchored label below, whose rules + override these and must stay after them. */ .bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label { pointer-events: none; border-radius: 0 1.5px 1.5px 0; @@ -135,6 +139,110 @@ transition: all 0.2s; } +/* The label of a remote cursor (see `floatCursorLabel`) is anchored to its + caret and `fixed`, so a scroll container around the caret, e.g. a table, + doesn't clip it. At the edges of the screen, it goes below the caret or to + its left (`data-placement`). Its box always has its full size; it opens by + growing from the caret (`clip-path`). The closed label is the marker at the + top of the caret. Browsers without anchor positioning show the + label inline, as before. */ +@supports (position-anchor: --a) { + .bn-editor + .bn-collaboration-cursor__base[data-floating-label] + .bn-collaboration-cursor__label { + position: fixed; + /* Above the content that comes after the caret, e.g. the next table cell. */ + z-index: 1; + inset: auto; + /* Above the caret, from its left. `floatCursorLabel` picks the side + (`data-placement`), so the label can't be flipped away from it. */ + bottom: anchor(top); + left: anchor(left); + /* Hidden when the caret is scrolled out of view. */ + position-visibility: anchors-visible; + max-height: 1.1rem; + max-width: 20rem; + padding: 0.1rem 0.3rem; + /* The corner at the caret is square (see `data-placement`). */ + border-radius: 3px 3px 3px 0; + /* Closed: only the corner at the caret, moved onto the top of the caret, + which is the marker. It grows to the whole label. */ + clip-path: inset(calc(100% - 5px) calc(100% - 4px) 0 0); + margin-bottom: -4px; + transition: + clip-path 0.2s, + margin 0.2s; + } + + /* Below the caret, the closed label can't be the marker at the top of the + caret. It is hidden, and the marker is drawn on the caret instead. */ + .bn-editor + .bn-collaboration-cursor__base[data-floating-label] + .bn-collaboration-cursor__label[data-placement^="bottom"] { + opacity: 0; + transition: + clip-path 0.2s, + margin 0.2s, + opacity 0.05s 0.15s; + } + + .bn-editor + .bn-collaboration-cursor__base[data-floating-label]:not([data-active]):has( + .bn-collaboration-cursor__label[data-placement^="bottom"] + ) + .bn-collaboration-cursor__caret::before { + content: ""; + position: absolute; + top: -1px; + left: 0; + width: 4px; + height: 5px; + background-color: inherit; + border-radius: 0 1.5px 1.5px 0; + } + + .bn-editor + .bn-collaboration-cursor__base[data-floating-label] + .bn-collaboration-cursor__label[data-placement="bottom-start"] { + top: anchor(bottom); + bottom: auto; + border-radius: 0 3px 3px 3px; + clip-path: inset(0 calc(100% - 4px) calc(100% - 5px) 0); + } + + .bn-editor + .bn-collaboration-cursor__base[data-floating-label] + .bn-collaboration-cursor__label[data-placement="top-end"] { + right: anchor(right); + left: auto; + border-radius: 3px 3px 0 3px; + clip-path: inset(calc(100% - 5px) 0 0 calc(100% - 4px)); + } + + .bn-editor + .bn-collaboration-cursor__base[data-floating-label] + .bn-collaboration-cursor__label[data-placement="bottom-end"] { + top: anchor(bottom); + bottom: auto; + right: anchor(right); + left: auto; + border-radius: 3px 0 3px 3px; + clip-path: inset(0 0 calc(100% - 5px) calc(100% - 4px)); + } + + .bn-editor + .bn-collaboration-cursor__base[data-floating-label][data-active] + .bn-collaboration-cursor__label { + clip-path: inset(0); + margin: 0; + opacity: 1; + transition: + clip-path 0.2s, + margin 0.2s, + opacity 0.05s; + } +} + .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/cursorLabel.ts b/packages/core/src/extensions/Collaboration/cursorLabel.ts new file mode 100644 index 0000000000..e5cb6f8f29 --- /dev/null +++ b/packages/core/src/extensions/Collaboration/cursorLabel.ts @@ -0,0 +1,115 @@ +let anchors = 0; + +/** The default caret and label of a cursor, if it has them. */ +function caretAndLabel(cursorElement: HTMLElement) { + const caret = cursorElement.querySelector( + ".bn-collaboration-cursor__caret", + ); + const label = cursorElement.querySelector( + ".bn-collaboration-cursor__label", + ); + return caret && label ? { caret, label } : undefined; +} + +/** + * Anchors the label of a remote cursor to its caret with CSS anchor + * positioning (see `editor.css`). The label is `fixed`, so a scroll container + * around the caret, e.g. a table, doesn't clip it. At the edges of the screen, + * it goes below the caret or to its left. + * + * A cursor without the default caret and label (`renderCursor`) is left as it + * is. + */ +export function floatCursorLabel(cursorElement: HTMLElement) { + const elements = caretAndLabel(cursorElement); + if (!elements) { + return; + } + // Unique on the page, also across editors. + const anchor = `--bn-cursor-${++anchors}`; + elements.caret.style.setProperty("anchor-name", anchor); + elements.label.style.setProperty("position-anchor", anchor); + cursorElement.setAttribute("data-floating-label", ""); + // The closed label is the marker at the caret, so it needs its side too, + // once the cursor is in the editor and has its size. + requestAnimationFrame(() => updateSide(cursorElement, elements)); +} + +/** + * Shows the label of a remote cursor, on the side it is on now. Showing it + * again, e.g. after the cursor moved, updates the side. + */ +export function showCursorLabel(cursorElement: HTMLElement) { + const elements = floatingCaretAndLabel(cursorElement); + if (elements) { + updateSide(cursorElement, elements); + } + cursorElement.setAttribute("data-active", ""); +} + +/** Hides the label of a remote cursor. */ +export function hideCursorLabel(cursorElement: HTMLElement) { + cursorElement.removeAttribute("data-active"); +} + +/** + * Picks the side of the label (`data-placement`): above the caret, from its + * left, unless it doesn't fit on the screen there. CSS positions the label on + * that side, grows it from the caret, and gives it a square corner there. + * (The browser's own flipping, `position-try-fallbacks`, keeps a side that + * still fits after the caret moved away from the edge, and CSS can't tell + * which side it picked.) + */ +function updateSide( + cursorElement: HTMLElement, + { caret, label }: { caret: HTMLElement; label: HTMLElement }, +) { + const caretRect = caret.getBoundingClientRect(); + // The size of the open label: `clip-path` and margins don't change it. + const { offsetWidth: width, offsetHeight: height } = label; + const block = caretRect.top - height >= 0 ? "top" : "bottom"; + const inline = + caretRect.left + width <= document.documentElement.clientWidth + ? "start" + : "end"; + const placement = `${block}-${inline}`; + if (label.getAttribute("data-placement") === placement) { + return; + } + label.setAttribute("data-placement", placement); + if (!cursorElement.hasAttribute("data-active")) { + // Closed, the label is the marker at the caret: moves it at once, so it + // doesn't slide, and the label opens from the new side. + label.style.transition = "none"; + getComputedStyle(label).getPropertyValue("clip-path"); + label.style.transition = ""; + } +} + +/** The caret and label of a cursor that `floatCursorLabel` set up. */ +function floatingCaretAndLabel(cursorElement: HTMLElement) { + return cursorElement.hasAttribute("data-floating-label") + ? caretAndLabel(cursorElement) + : undefined; +} + +/** + * The collaborators whose edits a remote Yjs transaction applies: their clocks + * advanced. Typing doesn't move a collaborator's cursor in Yjs (it is relative + * to the text), so the edits show that they are active. Deletions don't + * advance a clock, and Yjs doesn't record who deleted. + */ +export function remoteEditors(transaction: { + local: boolean; + beforeState: Map; + afterState: Map; +}): number[] { + if (transaction.local) { + return []; + } + return [...transaction.afterState] + .filter( + ([client, clock]) => clock > (transaction.beforeState.get(client) ?? 0), + ) + .map(([client]) => client); +} diff --git a/packages/core/src/y/extensions/YCursorPlugin.browser.test.ts b/packages/core/src/y/extensions/YCursorPlugin.browser.test.ts new file mode 100644 index 0000000000..e347708300 --- /dev/null +++ b/packages/core/src/y/extensions/YCursorPlugin.browser.test.ts @@ -0,0 +1,200 @@ +import { Awareness } from "@y/protocols/awareness"; +import { resolvedPositionToRelativePosition } from "@y/prosemirror"; +import * as Y from "@y/y"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import "../../style.css"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { withCollaboration, type CollaborationOptions } from "./index.js"; + +const REMOTE = 424242; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup(); + } +}); + +/** A collaborative editor, mounted in the page. */ +function createEditor( + doc: Y.Doc, + awareness: Awareness, + showCursorLabels?: CollaborationOptions["showCursorLabels"], +) { + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: doc.get("doc"), + provider: { awareness }, + user: { name: "Local", color: "#FF0000" }, + showCursorLabels, + }, + }), + ); + const host = document.createElement("div"); + host.style.cssText = "padding-top: 100px"; + document.body.append(host); + editor.mount(host); + cleanups.push(() => { + editor.unmount(); + host.remove(); + }); + return { editor, host }; +} + +/** + * An editor with a paragraph, and a collaborator with a cursor at the start + * of the text. The collaborator types in their own editor. + */ +async function setup( + showCursorLabels?: CollaborationOptions["showCursorLabels"], +) { + const doc = new Y.Doc(); + const awareness = new Awareness(doc); + cleanups.push(() => { + awareness.destroy(); + doc.destroy(); + }); + const { editor, host } = createEditor(doc, awareness, showCursorLabels); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "Some text" }, + ]); + + // The collaborator's copy of the document and editor. + const remoteDoc = new Y.Doc(); + remoteDoc.clientID = REMOTE; + const remoteAwareness = new Awareness(remoteDoc); + cleanups.push(() => { + remoteAwareness.destroy(); + remoteDoc.destroy(); + }); + Y.applyUpdate(remoteDoc, Y.encodeStateAsUpdate(doc)); + const { editor: remoteEditor } = createEditor(remoteDoc, remoteAwareness); + + const label = () => + host.querySelector(".bn-collaboration-cursor__label")!; + + /** Moves the collaborator's cursor to the start of the text. */ + async function moveCursor() { + const view = editor.prosemirrorView; + let pos = -1; + view.state.doc.descendants((node, nodePos) => { + if (pos === -1 && node.isText) { + pos = nodePos; + } + }); + const relative = Y.relativePositionToJSON( + resolvedPositionToRelativePosition(view, view.state.doc.resolve(pos))!, + ); + const added = !awareness.getStates().has(REMOTE); + awareness.getStates().set(REMOTE, { + user: { name: "Remote collaborator", color: "#00AA00" }, + cursor: { anchor: relative, head: relative }, + }); + awareness.emit("change", [ + { + added: added ? [REMOTE] : [], + updated: added ? [] : [REMOTE], + removed: [], + }, + "remote", + ]); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + /** The collaborator types the text at the end of their paragraph. */ + function typeRemotely(text: string) { + const block = remoteEditor.document[0]; + remoteEditor.setTextCursorPosition(block, "end"); + remoteEditor.insertInlineContent(text); + Y.applyUpdate( + doc, + Y.encodeStateAsUpdate(remoteDoc, Y.encodeStateVector(doc)), + "remote", + ); + } + + return { label, moveCursor, typeRemotely }; +} + +/** Whether the label is open: its cursor is active, and nothing clips it. */ +function isOpen(label: HTMLElement) { + if (!label.closest("[data-active]")) { + return false; + } + const rect = label.getBoundingClientRect(); + // The label ignores the pointer, which hit testing would skip. + label.style.pointerEvents = "auto"; + const element = document.elementFromPoint(rect.right - 2, rect.top + 2); + label.style.pointerEvents = ""; + return element !== null && label.contains(element); +} + +/** The number of listeners to an event of a Yjs observable. */ +function listeners( + observable: { _observers: Map> }, + event: string, +) { + return observable._observers.get(event)?.size ?? 0; +} + +describe("remote cursor labels (Yjs 14)", () => { + it("shows the label when the cursor moves, and closes it 2 seconds later", async () => { + const { label, moveCursor } = await setup(); + await moveCursor(); + expect(isOpen(label())).toBe(false); + await moveCursor(); + await expect.poll(() => isOpen(label())).toBe(true); + await expect.poll(() => isOpen(label()), { timeout: 3000 }).toBe(false); + }); + + it("shows the label while the collaborator types", async () => { + const { label, moveCursor, typeRemotely } = await setup(); + await moveCursor(); + expect(isOpen(label())).toBe(false); + typeRemotely("a"); + await expect.poll(() => isOpen(label())).toBe(true); + }); + + it('shows the label without activity with showCursorLabels: "always"', async () => { + const { label, moveCursor } = await setup("always"); + await moveCursor(); + await expect.poll(() => isOpen(label())).toBe(true); + }); + + it("removes its listeners when the editor unmounts", () => { + const doc = new Y.Doc(); + const awareness = new Awareness(doc); + cleanups.push(() => { + awareness.destroy(); + doc.destroy(); + }); + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: doc.get("doc"), + provider: { awareness }, + user: { name: "Local", color: "#FF0000" }, + }, + }), + ); + const before = { + change: listeners(awareness, "change"), + afterTransaction: listeners(doc, "afterTransaction"), + }; + const host = document.createElement("div"); + document.body.append(host); + cleanups.push(() => host.remove()); + editor.mount(host); + // The cursors follow awareness changes and edits of collaborators. + expect(listeners(awareness, "change")).toBeGreaterThan(before.change); + expect(listeners(doc, "afterTransaction")).toBeGreaterThan( + before.afterTransaction, + ); + + editor.unmount(); + expect(listeners(awareness, "change")).toBe(before.change); + expect(listeners(doc, "afterTransaction")).toBe(before.afterTransaction); + }); +}); diff --git a/packages/core/src/y/extensions/YCursorPlugin.ts b/packages/core/src/y/extensions/YCursorPlugin.ts index 4b120b5958..44e39d987b 100644 --- a/packages/core/src/y/extensions/YCursorPlugin.ts +++ b/packages/core/src/y/extensions/YCursorPlugin.ts @@ -3,6 +3,12 @@ import { createExtension, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; +import { + floatCursorLabel, + hideCursorLabel, + remoteEditors, + showCursorLabel, +} from "../../extensions/Collaboration/cursorLabel.js"; import { CollaborationOptions } from "./index.js"; export type CollaborationUser = { @@ -83,45 +89,72 @@ export const YCursorExtension = createExtension( ) { 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), - }); - } - } - }, - ); + const handleAwarenessChange = ({ + updated, + }: { + added: Array; + updated: Array; + removed: Array; + }) => { + for (const clientID of updated) { + const cursor = recentlyUpdatedCursors.get(clientID); + + if (cursor) { + setTimeout(() => { + showCursorLabel(cursor.element); + }, 10); + + if (options.showCursorLabels === "always") { + continue; + } + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + } + + recentlyUpdatedCursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + hideCursorLabel(cursor.element); + }, 2000), + }); } } - } + }; + + // A collaborator who types is active too. + const handleTransaction = ( + transaction: Parameters[0], + ) => { + const updated = remoteEditors(transaction); + if (updated.length > 0) { + handleAwarenessChange({ added: [], updated, removed: [] }); + } + }; return { key: "yCursor", + mount() { + if ( + awareness && + "on" in awareness && + typeof awareness.on === "function" + ) { + awareness.on("change", handleAwarenessChange); + options.fragment.doc?.on("afterTransaction", handleTransaction); + + return () => { + if ("off" in awareness && typeof awareness.off === "function") { + awareness.off("change", handleAwarenessChange); + } + options.fragment.doc?.off("afterTransaction", handleTransaction); + }; + } + + return undefined; + }, prosemirrorPlugins: [ awareness ? yCursorPlugin(awareness, { @@ -133,11 +166,15 @@ export const YCursorExtension = createExtension( const cursorElement = ( options.renderCursor ?? defaultCursorRender )(user as CollaborationUser); + floatCursorLabel(cursorElement); + if (options.showCursorLabels === "always") { + showCursorLabel(cursorElement); + } if (options.showCursorLabels !== "always") { cursorElement.addEventListener("mouseenter", () => { const cursor = recentlyUpdatedCursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); + showCursorLabel(cursor.element); if (cursor.hideTimeout) { clearTimeout(cursor.hideTimeout); @@ -154,7 +191,7 @@ export const YCursorExtension = createExtension( recentlyUpdatedCursors.set(clientID, { element: cursor.element, hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); + hideCursorLabel(cursor.element); }, 2000), }); }); diff --git a/packages/core/src/yjs/extensions/YCursorPlugin.ts b/packages/core/src/yjs/extensions/YCursorPlugin.ts index f31c1b2da1..5d8cbd1da0 100644 --- a/packages/core/src/yjs/extensions/YCursorPlugin.ts +++ b/packages/core/src/yjs/extensions/YCursorPlugin.ts @@ -3,6 +3,12 @@ import { createExtension, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; +import { + floatCursorLabel, + hideCursorLabel, + remoteEditors, + showCursorLabel, +} from "../../extensions/Collaboration/cursorLabel.js"; import type { CollaborationOptions } from "./index.js"; export type CollaborationUser = { @@ -97,9 +103,13 @@ export const YCursorExtension = createExtension( if (cursor) { setTimeout(() => { - cursor.element.setAttribute("data-active", ""); + showCursorLabel(cursor.element); }, 10); + if (options.showCursorLabels === "always") { + continue; + } + if (cursor.hideTimeout) { clearTimeout(cursor.hideTimeout); } @@ -107,28 +117,39 @@ export const YCursorExtension = createExtension( recentlyUpdatedCursors.set(clientID, { element: cursor.element, hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); + hideCursorLabel(cursor.element); }, 2000), }); } } }; + // A collaborator who types is active too. + const handleTransaction = ( + transaction: Parameters[0], + ) => { + const updated = remoteEditors(transaction); + if (updated.length > 0) { + handleAwarenessChange({ added: [], updated, removed: [] }); + } + }; + return { key: "yCursor", mount() { if ( awareness && - options.showCursorLabels !== "always" && "on" in awareness && typeof awareness.on === "function" ) { awareness.on("change", handleAwarenessChange); + options.fragment.doc?.on("afterTransaction", handleTransaction); return () => { if ("off" in awareness && typeof awareness.off === "function") { awareness.off("change", handleAwarenessChange); } + options.fragment.doc?.off("afterTransaction", handleTransaction); }; } @@ -145,11 +166,15 @@ export const YCursorExtension = createExtension( const cursorElement = ( options.renderCursor ?? defaultCursorRender )(user); + floatCursorLabel(cursorElement); + if (options.showCursorLabels === "always") { + showCursorLabel(cursorElement); + } if (options.showCursorLabels !== "always") { cursorElement.addEventListener("mouseenter", () => { const cursor = recentlyUpdatedCursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); + showCursorLabel(cursor.element); if (cursor.hideTimeout) { clearTimeout(cursor.hideTimeout); @@ -166,7 +191,7 @@ export const YCursorExtension = createExtension( recentlyUpdatedCursors.set(clientID, { element: cursor.element, hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); + hideCursorLabel(cursor.element); }, 2000), }); }); diff --git a/packages/core/src/yjs/extensions/cursorLabel.browser.test.ts b/packages/core/src/yjs/extensions/cursorLabel.browser.test.ts new file mode 100644 index 0000000000..c631f8a514 --- /dev/null +++ b/packages/core/src/yjs/extensions/cursorLabel.browser.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { Awareness } from "y-protocols/awareness"; +import { + absolutePositionToRelativePosition, + ySyncPluginKey, +} from "y-prosemirror"; +import * as Y from "yjs"; + +import "../../style.css"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { withCollaboration, type CollaborationOptions } from "./index.js"; + +const REMOTE = 424242; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup(); + } +}); + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * An editor with the blocks, in a host with the given style. `moveCursor` + * puts a remote cursor at the start of a text, as a collaborator's edit does. + */ +function setup( + blocks: PartialBlock[], + hostStyle: string, + showCursorLabels?: CollaborationOptions["showCursorLabels"], +) { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + const awareness = new Awareness(doc); + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment, + provider: { awareness }, + user: { name: "Local", color: "#FF0000" }, + showCursorLabels, + }, + }), + ); + const host = document.createElement("div"); + host.style.cssText = hostStyle; + document.body.append(host); + editor.mount(host); + cleanups.push(() => { + editor.unmount(); + awareness.destroy(); + doc.destroy(); + host.remove(); + }); + editor.replaceBlocks(editor.document, blocks); + + const label = () => + host.querySelector(".bn-collaboration-cursor__label")!; + const caret = () => + host.querySelector(".bn-collaboration-cursor__caret")!; + + async function moveCursor(text: string) { + let pos = -1; + editor.prosemirrorState.doc.descendants((node, nodePos) => { + if (pos === -1 && node.isText && node.text!.includes(text)) { + pos = nodePos + node.text!.indexOf(text); + } + }); + const { binding } = ySyncPluginKey.getState(editor.prosemirrorState); + const relative = Y.relativePositionToJSON( + absolutePositionToRelativePosition(pos, fragment, binding.mapping), + ); + const added = !awareness.getStates().has(REMOTE); + awareness.getStates().set(REMOTE, { + user: { name: "Remote collaborator", color: "#00AA00" }, + cursor: { anchor: relative, head: relative }, + }); + // A new cursor is rendered first; a known one moves (`updated`), which + // shows its label after 10ms. + awareness.emit("change", [ + { + added: added ? [REMOTE] : [], + updated: added ? [] : [REMOTE], + removed: [], + }, + ]); + await wait(50); + } + + /** + * Renders the remote cursor at the text, then moves it there again, so its + * label shows as after a collaborator's edit. + */ + async function showRemoteCursor(text: string) { + await moveCursor(text); + await moveCursor(text); + } + + // The collaborator's copy of the document. One for the whole test: Yjs + // gives a new document whose client ID is already in the content a new ID. + const remoteDoc = new Y.Doc(); + remoteDoc.clientID = REMOTE; + cleanups.push(() => remoteDoc.destroy()); + + /** The collaborator types the text at the end of the first paragraph. */ + function typeRemotely(text: string) { + Y.applyUpdate( + remoteDoc, + Y.encodeStateAsUpdate(doc, Y.encodeStateVector(remoteDoc)), + ); + const firstText = [ + ...remoteDoc + .getXmlFragment("doc") + .createTreeWalker((node) => node instanceof Y.XmlText), + ][0] as Y.XmlText; + firstText.insert(firstText.length, text); + Y.applyUpdate( + doc, + Y.encodeStateAsUpdate(remoteDoc, Y.encodeStateVector(doc)), + "remote", + ); + } + + return { label, caret, moveCursor, showRemoteCursor, typeRemotely }; +} + +/** Whether the label shows at (x, y), i.e. nothing clips it there. */ +function showsAt(label: HTMLElement, x: number, y: number) { + // The label ignores the pointer, which hit testing would skip. + label.style.pointerEvents = "auto"; + const element = document.elementFromPoint(x, y); + label.style.pointerEvents = ""; + return element !== null && label.contains(element); +} + +/** Whether the whole label shows: it is open and nothing clips it. */ +function showsWholeLabel(label: HTMLElement) { + const rect = label.getBoundingClientRect(); + return ( + rect.width > 40 && + showsAt(label, rect.left + 2, rect.top + 2) && + showsAt(label, rect.right - 2, rect.bottom - 2) + ); +} + +describe("remote cursor labels", () => { + it("shows the whole label of a cursor in a table", async () => { + const { label, showRemoteCursor } = setup( + [ + { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Cell one", "Cell two"] }], + }, + }, + ], + "padding-top: 100px", + ); + await showRemoteCursor("Cell one"); + await expect.poll(() => showsWholeLabel(label())).toBe(true); + }); + + it("shows the label below the caret at the top of the screen", async () => { + const { label, caret, showRemoteCursor } = setup( + [{ type: "paragraph", content: "First line" }], + "position: fixed; top: 0; left: 0; width: 600px", + ); + await showRemoteCursor("First line"); + const rect = label().getBoundingClientRect(); + expect(rect.top).toBeGreaterThanOrEqual(0); + expect(rect.top).toBeGreaterThanOrEqual( + caret().getBoundingClientRect().bottom - 1, + ); + }); + + it("shows the label left of the caret at the right of the screen", async () => { + const { label, caret, showRemoteCursor } = setup( + [{ type: "paragraph", content: "Right" }], + // The caret is about 30px from the right edge of the screen. + "position: fixed; top: 200px; left: calc(100vw - 80px); width: 400px", + ); + await showRemoteCursor("Right"); + const rect = label().getBoundingClientRect(); + expect(rect.right).toBeLessThanOrEqual(innerWidth); + expect(rect.right).toBeLessThanOrEqual( + caret().getBoundingClientRect().right + 1, + ); + }); + + it("shows the label while the collaborator types, until they stop", async () => { + const { label, moveCursor, typeRemotely } = setup( + [{ type: "paragraph", content: "Some text" }], + "padding-top: 100px", + ); + // Only renders the cursor: its label is closed. + await moveCursor("Some text"); + expect(showsWholeLabel(label())).toBe(false); + // Typing doesn't move a cursor in Yjs (it is relative to the text), so + // only the edits tell that the collaborator is active. + typeRemotely("a"); + await expect.poll(() => showsWholeLabel(label())).toBe(true); + await wait(1500); + typeRemotely("b"); + await wait(1500); + // 3 seconds after the first edit, but 1.5 after the last one. + expect(showsWholeLabel(label())).toBe(true); + await expect + .poll(() => showsWholeLabel(label()), { timeout: 3000 }) + .toBe(false); + }); + + it("closes the label into the caret after it moved away from the right edge", async () => { + const { label, moveCursor, showRemoteCursor } = setup( + [{ type: "paragraph", content: "One two three four five six" }], + // The end of the text is near the right edge of the screen. + "position: fixed; top: 200px; left: calc(100vw - 260px); width: 400px", + ); + await showRemoteCursor("six"); + await expect + .poll(() => label().getAttribute("data-placement")) + .toBe("top-end"); + // Moving left, as with the arrow keys: away from the edge. + for (const word of ["five", "four", "three", "two", "One"]) { + await moveCursor(word); + } + await expect + .poll(() => showsWholeLabel(label()), { timeout: 3000 }) + .toBe(false); + // It closed into its corner at the caret: the label is to its right. + expect(label().getAttribute("data-placement")).toBe("top-start"); + }); + + it("closes the label 2 seconds after the cursor's activity", async () => { + const { label, showRemoteCursor } = setup( + [{ type: "paragraph", content: "Some text" }], + "padding-top: 100px", + ); + await showRemoteCursor("Some text"); + await expect.poll(() => showsWholeLabel(label())).toBe(true); + await expect + .poll(() => showsWholeLabel(label()), { timeout: 3000 }) + .toBe(false); + }); +}); + +describe('remote cursor labels with showCursorLabels: "always"', () => { + it("shows the label of a cursor without activity", async () => { + const { label, moveCursor } = setup( + [{ type: "paragraph", content: "Some text" }], + "padding-top: 100px", + "always", + ); + // Only renders the cursor: no edit shows its label. + await moveCursor("Some text"); + await expect.poll(() => showsWholeLabel(label())).toBe(true); + }); + + it("keeps the label open after the cursor's activity", async () => { + const { label, showRemoteCursor } = setup( + [{ type: "paragraph", content: "Some text" }], + "padding-top: 100px", + "always", + ); + await showRemoteCursor("Some text"); + await wait(2100); + await expect.poll(() => showsWholeLabel(label())).toBe(true); + }); + + it("shows the label below the caret when the cursor moves to the top of the screen", async () => { + const { label, caret, moveCursor } = setup( + [ + { type: "paragraph", content: "First line" }, + { type: "paragraph", content: "Second line" }, + ], + "position: fixed; top: 0; left: 0; width: 600px", + "always", + ); + await moveCursor("Second line"); + await moveCursor("First line"); + await expect + .poll(() => label().getBoundingClientRect().top) + .toBeGreaterThanOrEqual(caret().getBoundingClientRect().bottom - 1); + // The label opens from the caret's side, with its square corner there + // (see `editor.css`). Firefox reports a stale computed style for an + // element in a fallback position, so this reads the side instead. + await expect + .poll(() => label().getAttribute("data-placement")) + .toBe("bottom-start"); + }); +});