Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions packages/core/src/editor/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
115 changes: 115 additions & 0 deletions packages/core/src/extensions/Collaboration/cursorLabel.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(
".bn-collaboration-cursor__caret",
);
const label = cursorElement.querySelector<HTMLElement>(
".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<number, number>;
afterState: Map<number, number>;
}): number[] {
if (transaction.local) {
return [];
}
return [...transaction.afterState]
.filter(
([client, clock]) => clock > (transaction.beforeState.get(client) ?? 0),
)
.map(([client]) => client);
}
Loading
Loading