@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
159 lines (130 loc) • 4.79 kB
text/typescript
import { defaultSelectionBuilder, yCursorPlugin } from "y-prosemirror";
import { Awareness } from "y-protocols/awareness.js";
import * as Y from "yjs";
import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
export type CollaborationUser = {
name: string;
color: string;
[key: string]: string;
};
export class CursorPlugin extends BlockNoteExtension {
public static key() {
return "yCursorPlugin";
}
private provider: { awareness: Awareness };
private recentlyUpdatedCursors: Map<
number,
{ element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined }
>;
constructor(
private collaboration: {
fragment: Y.XmlFragment;
user: CollaborationUser;
provider: { awareness: Awareness };
renderCursor?: (user: CollaborationUser) => HTMLElement;
showCursorLabels?: "always" | "activity";
},
) {
super();
this.provider = collaboration.provider;
this.recentlyUpdatedCursors = new Map();
this.provider.awareness.setLocalStateField("user", collaboration.user);
if (collaboration.showCursorLabels !== "always") {
this.provider.awareness.on(
"change",
({
updated,
}: {
added: Array<number>;
updated: Array<number>;
removed: Array<number>;
}) => {
for (const clientID of updated) {
const cursor = this.recentlyUpdatedCursors.get(clientID);
if (cursor) {
cursor.element.setAttribute("data-active", "");
if (cursor.hideTimeout) {
clearTimeout(cursor.hideTimeout);
}
this.recentlyUpdatedCursors.set(clientID, {
element: cursor.element,
hideTimeout: setTimeout(() => {
cursor.element.removeAttribute("data-active");
}, 2000),
});
}
}
},
);
}
this.addProsemirrorPlugin(
yCursorPlugin(this.provider.awareness, {
selectionBuilder: defaultSelectionBuilder,
cursorBuilder: this.renderCursor,
}),
);
}
public get priority() {
return 999;
}
private renderCursor = (user: CollaborationUser, clientID: number) => {
let cursorData = this.recentlyUpdatedCursors.get(clientID);
if (!cursorData) {
const cursorElement = (
this.collaboration.renderCursor ?? CursorPlugin.defaultCursorRender
)(user);
if (this.collaboration.showCursorLabels !== "always") {
cursorElement.addEventListener("mouseenter", () => {
const cursor = this.recentlyUpdatedCursors.get(clientID)!;
cursor.element.setAttribute("data-active", "");
if (cursor.hideTimeout) {
clearTimeout(cursor.hideTimeout);
this.recentlyUpdatedCursors.set(clientID, {
element: cursor.element,
hideTimeout: undefined,
});
}
});
cursorElement.addEventListener("mouseleave", () => {
const cursor = this.recentlyUpdatedCursors.get(clientID)!;
this.recentlyUpdatedCursors.set(clientID, {
element: cursor.element,
hideTimeout: setTimeout(() => {
cursor.element.removeAttribute("data-active");
}, 2000),
});
});
}
cursorData = {
element: cursorElement,
hideTimeout: undefined,
};
this.recentlyUpdatedCursors.set(clientID, cursorData);
}
return cursorData.element;
};
public updateUser = (user: {
name: string;
color: string;
[key: string]: string;
}) => {
this.provider.awareness.setLocalStateField("user", user);
};
public static 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}`);
const labelElement = document.createElement("span");
labelElement.classList.add("bn-collaboration-cursor__label");
labelElement.setAttribute("style", `background-color: ${user.color}`);
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;
};
}