Skip to content

Commit 86df775

Browse files
authored
feat: re-implement Y.js collaboration as BlockNote plugins (#1638)
This re-implements `@tiptap/extension-collaboration` and `@tiptap/extension-collaboration-curosr` as BlockNote plugins. This is also future-proofs us from the upcoming change to Tiptap's `@tiptap/y-tiptap` when we'd rather stick to `y-prosemirror`. Along the way, I discovered that some of the patches being done in Tiptap are no longer required. So, this also was able to simplify things considerably
1 parent 3f0974d commit 86df775

File tree

9 files changed

+207
-162
lines changed

9 files changed

+207
-162
lines changed

packages/core/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,6 @@
7777
"@tiptap/core": "^2.11.5",
7878
"@tiptap/extension-bold": "^2.11.5",
7979
"@tiptap/extension-code": "^2.11.5",
80-
"@tiptap/extension-collaboration": "^2.11.5",
81-
"@tiptap/extension-collaboration-cursor": "^2.11.5",
8280
"@tiptap/extension-gapcursor": "^2.11.5",
8381
"@tiptap/extension-history": "^2.11.5",
8482
"@tiptap/extension-horizontal-rule": "^2.11.5",

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js
115115
import type { ThreadStore, User } from "../comments/index.js";
116116
import "../style.css";
117117
import { EventEmitter } from "../util/EventEmitter.js";
118+
import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
118119

119120
export type BlockNoteExtensionFactory = (
120121
editor: BlockNoteEditor<any, any, any>
@@ -124,6 +125,7 @@ export type BlockNoteExtension =
124125
| AnyExtension
125126
| {
126127
plugin: Plugin;
128+
priority?: number;
127129
};
128130

129131
export type BlockCache<
@@ -472,6 +474,8 @@ export class BlockNoteEditor<
472474

473475
private readonly showSelectionPlugin: ShowSelectionPlugin;
474476

477+
private readonly cursorPlugin: CursorPlugin;
478+
475479
/**
476480
* The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload).
477481
* This method should set when creating the editor as this is application-specific.
@@ -622,6 +626,7 @@ export class BlockNoteEditor<
622626
this.tableHandles = this.extensions["tableHandles"] as any;
623627
this.comments = this.extensions["comments"] as any;
624628
this.showSelectionPlugin = this.extensions["showSelection"] as any;
629+
this.cursorPlugin = this.extensions["yCursorPlugin"] as any;
625630

626631
if (newOptions.uploadFile) {
627632
const uploadFile = newOptions.uploadFile;
@@ -643,7 +648,7 @@ export class BlockNoteEditor<
643648
this.headless = newOptions._headless;
644649

645650
const collaborationEnabled =
646-
"collaboration" in this.extensions ||
651+
"ySyncPlugin" in this.extensions ||
647652
"liveblocksExtension" in this.extensions;
648653

649654
if (collaborationEnabled && newOptions.initialContent) {
@@ -696,6 +701,7 @@ export class BlockNoteEditor<
696701
// "blocknote" extensions (prosemirror plugins)
697702
return Extension.create({
698703
name: key,
704+
priority: ext.priority,
699705
addProseMirrorPlugins: () => [ext.plugin],
700706
});
701707
}),
@@ -1488,7 +1494,8 @@ export class BlockNoteEditor<
14881494
"Cannot update collaboration user info when collaboration is disabled."
14891495
);
14901496
}
1491-
this._tiptapEditor.commands.updateUser(user);
1497+
1498+
this.cursorPlugin.updateUser(user);
14921499
}
14931500

14941501
/**

packages/core/src/editor/BlockNoteExtensions.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDrop
1010
import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js";
1111
import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js";
1212
import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js";
13-
import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js";
13+
import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
14+
import { UndoPlugin } from "../extensions/Collaboration/UndoPlugin.js";
15+
import { SyncPlugin } from "../extensions/Collaboration/SyncPlugin.js";
1416
import { CommentMark } from "../extensions/Comments/CommentMark.js";
1517
import { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.js";
1618
import type { ThreadStore } from "../comments/index.js";
@@ -106,6 +108,15 @@ export const getBlockNoteExtensions = <
106108
ret[ext.name] = ext;
107109
}
108110

111+
if (opts.collaboration) {
112+
ret["ySyncPlugin"] = new SyncPlugin(opts.collaboration.fragment);
113+
ret["yUndoPlugin"] = new UndoPlugin();
114+
115+
if (opts.collaboration.provider?.awareness) {
116+
ret["yCursorPlugin"] = new CursorPlugin(opts.collaboration);
117+
}
118+
}
119+
109120
// Note: this is pretty hardcoded and will break when user provides plugins with same keys.
110121
// Define name on plugins instead and not make this a map?
111122
ret["formattingToolbar"] = new FormattingToolbarProsemirrorPlugin(
@@ -285,10 +296,8 @@ const getTipTapExtensions = <
285296

286297
LINKIFY_INITIALIZED = true;
287298

288-
if (opts.collaboration) {
289-
tiptapExtensions.push(...createCollaborationExtensions(opts.collaboration));
290-
} else {
291-
// disable history extension when collaboration is enabled as Yjs takes care of undo / redo
299+
if (!opts.collaboration) {
300+
// disable history extension when collaboration is enabled as y-prosemirror takes care of undo / redo
292301
tiptapExtensions.push(History);
293302
}
294303

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Plugin } from "prosemirror-state";
2+
import { defaultSelectionBuilder, yCursorPlugin } from "y-prosemirror";
3+
import { Awareness } from "y-protocols/awareness.js";
4+
import * as Y from "yjs";
5+
6+
export type CollaborationUser = {
7+
name: string;
8+
color: string;
9+
[key: string]: string;
10+
};
11+
12+
export class CursorPlugin {
13+
public plugin: Plugin;
14+
private provider: { awareness: Awareness };
15+
private recentlyUpdatedCursors: Map<
16+
number,
17+
{ element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined }
18+
>;
19+
constructor(
20+
private collaboration: {
21+
fragment: Y.XmlFragment;
22+
user: CollaborationUser;
23+
provider: { awareness: Awareness };
24+
renderCursor?: (user: CollaborationUser) => HTMLElement;
25+
showCursorLabels?: "always" | "activity";
26+
}
27+
) {
28+
this.provider = collaboration.provider;
29+
this.recentlyUpdatedCursors = new Map();
30+
31+
this.provider.awareness.setLocalStateField("user", collaboration.user);
32+
33+
if (collaboration.showCursorLabels !== "always") {
34+
this.provider.awareness.on(
35+
"change",
36+
({
37+
updated,
38+
}: {
39+
added: Array<number>;
40+
updated: Array<number>;
41+
removed: Array<number>;
42+
}) => {
43+
for (const clientID of updated) {
44+
const cursor = this.recentlyUpdatedCursors.get(clientID);
45+
46+
if (cursor) {
47+
cursor.element.setAttribute("data-active", "");
48+
49+
if (cursor.hideTimeout) {
50+
clearTimeout(cursor.hideTimeout);
51+
}
52+
53+
this.recentlyUpdatedCursors.set(clientID, {
54+
element: cursor.element,
55+
hideTimeout: setTimeout(() => {
56+
cursor.element.removeAttribute("data-active");
57+
}, 2000),
58+
});
59+
}
60+
}
61+
}
62+
);
63+
}
64+
65+
this.plugin = yCursorPlugin(this.provider.awareness, {
66+
selectionBuilder: defaultSelectionBuilder,
67+
cursorBuilder: this.renderCursor,
68+
});
69+
}
70+
71+
public get priority() {
72+
return 999;
73+
}
74+
75+
private renderCursor = (user: CollaborationUser, clientID: number) => {
76+
let cursorData = this.recentlyUpdatedCursors.get(clientID);
77+
78+
if (!cursorData) {
79+
const cursorElement = (
80+
this.collaboration.renderCursor ?? CursorPlugin.defaultCursorRender
81+
)(user);
82+
83+
if (this.collaboration.showCursorLabels !== "always") {
84+
cursorElement.addEventListener("mouseenter", () => {
85+
const cursor = this.recentlyUpdatedCursors.get(clientID)!;
86+
cursor.element.setAttribute("data-active", "");
87+
88+
if (cursor.hideTimeout) {
89+
clearTimeout(cursor.hideTimeout);
90+
this.recentlyUpdatedCursors.set(clientID, {
91+
element: cursor.element,
92+
hideTimeout: undefined,
93+
});
94+
}
95+
});
96+
97+
cursorElement.addEventListener("mouseleave", () => {
98+
const cursor = this.recentlyUpdatedCursors.get(clientID)!;
99+
100+
this.recentlyUpdatedCursors.set(clientID, {
101+
element: cursor.element,
102+
hideTimeout: setTimeout(() => {
103+
cursor.element.removeAttribute("data-active");
104+
}, 2000),
105+
});
106+
});
107+
}
108+
109+
cursorData = {
110+
element: cursorElement,
111+
hideTimeout: undefined,
112+
};
113+
114+
this.recentlyUpdatedCursors.set(clientID, cursorData);
115+
}
116+
117+
return cursorData.element;
118+
};
119+
120+
public updateUser = (user: {
121+
name: string;
122+
color: string;
123+
[key: string]: string;
124+
}) => {
125+
this.provider.awareness.setLocalStateField("user", user);
126+
};
127+
128+
public static defaultCursorRender = (user: CollaborationUser) => {
129+
const cursorElement = document.createElement("span");
130+
131+
cursorElement.classList.add("bn-collaboration-cursor__base");
132+
133+
const caretElement = document.createElement("span");
134+
caretElement.setAttribute("contentedEditable", "false");
135+
caretElement.classList.add("bn-collaboration-cursor__caret");
136+
caretElement.setAttribute("style", `background-color: ${user.color}`);
137+
138+
const labelElement = document.createElement("span");
139+
140+
labelElement.classList.add("bn-collaboration-cursor__label");
141+
labelElement.setAttribute("style", `background-color: ${user.color}`);
142+
labelElement.insertBefore(document.createTextNode(user.name), null);
143+
144+
caretElement.insertBefore(labelElement, null);
145+
146+
cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
147+
cursorElement.insertBefore(caretElement, null);
148+
cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space
149+
150+
return cursorElement;
151+
};
152+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Plugin } from "prosemirror-state";
2+
import { ySyncPlugin } from "y-prosemirror";
3+
import type * as Y from "yjs";
4+
5+
export class SyncPlugin {
6+
public plugin: Plugin;
7+
8+
constructor(fragment: Y.XmlFragment) {
9+
this.plugin = ySyncPlugin(fragment);
10+
}
11+
12+
public get priority() {
13+
return 1001;
14+
}
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Plugin } from "prosemirror-state";
2+
import { yUndoPlugin } from "y-prosemirror";
3+
4+
export class UndoPlugin {
5+
public plugin: Plugin;
6+
7+
constructor() {
8+
this.plugin = yUndoPlugin();
9+
}
10+
11+
public get priority() {
12+
return 1000;
13+
}
14+
}

0 commit comments

Comments
 (0)