Skip to content

Commit a18de11

Browse files
committed
feat: forking a ydoc
1 parent 7e4844a commit a18de11

File tree

16 files changed

+651
-417
lines changed

16 files changed

+651
-417
lines changed

examples/07-collaboration/01-partykit/App.tsx

+36-2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { BlockNoteView } from "@blocknote/mantine";
44
import "@blocknote/mantine/style.css";
55
import YPartyKitProvider from "y-partykit/provider";
66
import * as Y from "yjs";
7+
import { useEffect } from "react";
8+
import { useState } from "react";
79

810
// Sets up Yjs document and PartyKit Yjs provider.
911
const doc = new Y.Doc();
1012
const provider = new YPartyKitProvider(
1113
"blocknote-dev.yousefed.partykit.dev",
1214
// Use a unique name as a "room" for your application.
13-
"your-project-name",
15+
"your-project-name-room",
1416
doc,
1517
);
1618

@@ -28,7 +30,39 @@ export default function App() {
2830
},
2931
},
3032
});
33+
const [forked, setForked] = useState(false);
34+
useEffect(() => {
35+
editor.extensions["ForkYDocPlugin"].on("forked", setForked);
36+
}, [editor]);
3137

3238
// Renders the editor instance.
33-
return <BlockNoteView editor={editor} />;
39+
return (
40+
<>
41+
<button
42+
onClick={() => {
43+
editor.extensions["ForkYDocPlugin"].forkYjsSync();
44+
}}
45+
>
46+
Pause syncing
47+
</button>
48+
<button
49+
onClick={() => {
50+
editor.extensions["ForkYDocPlugin"].resumeYjsSync(true);
51+
}}
52+
>
53+
Play (accept changes)
54+
</button>
55+
<button
56+
onClick={() => {
57+
editor.extensions["ForkYDocPlugin"].resumeYjsSync(false);
58+
}}
59+
>
60+
Play (reject changes)
61+
</button>
62+
<div>
63+
<p>Forked: {forked ? "Yes" : "No"}</p>
64+
</div>
65+
<BlockNoteView editor={editor} />
66+
</>
67+
);
3468
}

examples/08-extensions/01-tiptap-arrow-conversion/package.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
"preview": "vite preview"
1111
},
1212
"dependencies": {
13-
"@blocknote/core": "latest",
14-
"@blocknote/react": "latest",
1513
"@blocknote/ariakit": "latest",
14+
"@blocknote/core": "latest",
1615
"@blocknote/mantine": "latest",
16+
"@blocknote/react": "latest",
1717
"@blocknote/shadcn": "latest",
18+
"@tiptap/core": "^2.12.0",
1819
"react": "^18.3.1",
19-
"react-dom": "^18.3.1",
20-
"@tiptap/core": "^2"
20+
"react-dom": "^18.3.1"
2121
},
2222
"devDependencies": {
2323
"@types/react": "^18.0.25",

packages/core/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
"dependencies": {
7777
"@emoji-mart/data": "^1.2.1",
7878
"@shikijs/types": "3.2.1",
79-
"@tiptap/core": "^2.11.5",
79+
"@tiptap/core": "^2.12.0",
8080
"@tiptap/extension-bold": "^2.11.5",
8181
"@tiptap/extension-code": "^2.11.5",
8282
"@tiptap/extension-gapcursor": "^2.11.5",
@@ -90,7 +90,7 @@
9090
"@tiptap/extension-table-header": "^2.11.5",
9191
"@tiptap/extension-text": "^2.11.5",
9292
"@tiptap/extension-underline": "^2.11.5",
93-
"@tiptap/pm": "^2.11.5",
93+
"@tiptap/pm": "^2.12.0",
9494
"emoji-mart": "^5.6.0",
9595
"hast-util-from-dom": "^5.0.1",
9696
"prosemirror-dropcursor": "^1.8.1",

packages/core/src/editor/BlockNoteEditor.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,11 @@ import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nested
115115
import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js";
116116
import type { ThreadStore, User } from "../comments/index.js";
117117
import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
118-
import "../style.css";
119118
import { EventEmitter } from "../util/EventEmitter.js";
120119
import { BlockNoteExtension } from "./BlockNoteExtension.js";
121120

121+
import "../style.css";
122+
122123
/**
123124
* A factory function that returns a BlockNoteExtension
124125
* This is useful so we can create extensions that require an editor instance
@@ -416,7 +417,7 @@ export class BlockNoteEditor<
416417
/**
417418
* extensions that are added to the editor, can be tiptap extensions or prosemirror plugins
418419
*/
419-
public readonly extensions: Record<string, SupportedExtension> = {};
420+
public extensions: Record<string, SupportedExtension> = {};
420421

421422
/**
422423
* Boolean indicating whether the editor is in headless mode.
@@ -485,8 +486,6 @@ export class BlockNoteEditor<
485486

486487
private readonly showSelectionPlugin: ShowSelectionPlugin;
487488

488-
private readonly cursorPlugin: CursorPlugin;
489-
490489
/**
491490
* The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload).
492491
* This method should set when creating the editor as this is application-specific.
@@ -647,7 +646,6 @@ export class BlockNoteEditor<
647646
this.tableHandles = this.extensions["tableHandles"] as any;
648647
this.comments = this.extensions["comments"] as any;
649648
this.showSelectionPlugin = this.extensions["showSelection"] as any;
650-
this.cursorPlugin = this.extensions["yCursorPlugin"] as any;
651649

652650
if (newOptions.uploadFile) {
653651
const uploadFile = newOptions.uploadFile;
@@ -1547,7 +1545,7 @@ export class BlockNoteEditor<
15471545
);
15481546
}
15491547

1550-
this.cursorPlugin.updateUser(user);
1548+
(this.extensions["yCursorPlugin"] as CursorPlugin).updateUser(user);
15511549
}
15521550

15531551
/**

packages/core/src/editor/BlockNoteExtension.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Plugin } from "prosemirror-state";
22
import { EventEmitter } from "../util/EventEmitter.js";
33

4-
export abstract class BlockNoteExtension extends EventEmitter<any> {
4+
export abstract class BlockNoteExtension<
5+
TEvent extends Record<string, any> = any,
6+
> extends EventEmitter<TEvent> {
57
public static name(): string {
68
throw new Error("You must implement the name method in your extension");
79
}

packages/core/src/editor/BlockNoteExtensions.ts

+5
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import type {
5656
BlockNoteEditorOptions,
5757
SupportedExtension,
5858
} from "./BlockNoteEditor.js";
59+
import { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js";
5960

6061
type ExtensionOptions<
6162
BSchema extends BlockSchema,
@@ -120,6 +121,10 @@ export const getBlockNoteExtensions = <
120121
if (opts.collaboration.provider?.awareness) {
121122
ret["yCursorPlugin"] = new CursorPlugin(opts.collaboration);
122123
}
124+
ret["ForkYDocPlugin"] = new ForkYDocPlugin({
125+
editor: opts.editor,
126+
collaboration: opts.collaboration,
127+
});
123128
}
124129

125130
// Note: this is pretty hardcoded and will break when user provides plugins with same keys.

packages/core/src/extensions/Collaboration/CursorPlugin.ts

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export type CollaborationUser = {
1010
};
1111

1212
export class CursorPlugin extends BlockNoteExtension {
13+
public static name() {
14+
return "yCursorPlugin";
15+
}
16+
1317
private provider: { awareness: Awareness };
1418
private recentlyUpdatedCursors: Map<
1519
number,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import * as Y from "yjs";
2+
3+
import {
4+
yCursorPluginKey,
5+
ySyncPluginKey,
6+
yUndoPluginKey,
7+
} from "y-prosemirror";
8+
import { CursorPlugin } from "./CursorPlugin.js";
9+
import { SyncPlugin } from "./SyncPlugin.js";
10+
import { UndoPlugin } from "./UndoPlugin.js";
11+
12+
import {
13+
BlockNoteEditor,
14+
BlockNoteEditorOptions,
15+
} from "../../editor/BlockNoteEditor.js";
16+
import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
17+
18+
export class ForkYDocPlugin extends BlockNoteExtension<{
19+
forked: boolean;
20+
}> {
21+
public static name() {
22+
return "ForkYDocPlugin";
23+
}
24+
25+
private editor: BlockNoteEditor<any, any, any>;
26+
private collaboration: BlockNoteEditorOptions<any, any, any>["collaboration"];
27+
28+
constructor({
29+
editor,
30+
collaboration,
31+
}: {
32+
editor: BlockNoteEditor<any, any, any>;
33+
collaboration: BlockNoteEditorOptions<any, any, any>["collaboration"];
34+
}) {
35+
super(editor);
36+
this.editor = editor;
37+
this.collaboration = collaboration;
38+
}
39+
40+
/**
41+
* To find a fragment in another ydoc, we need to search for it.
42+
*/
43+
private findTypeInOtherYdoc<T extends Y.AbstractType<any>>(
44+
ytype: T,
45+
otherYdoc: Y.Doc,
46+
): T {
47+
const ydoc = ytype.doc!;
48+
if (ytype._item === null) {
49+
/**
50+
* If is a root type, we need to find the root key in the original ydoc
51+
* and use it to get the type in the other ydoc.
52+
*/
53+
const rootKey = Array.from(ydoc.share.keys()).find(
54+
(key) => ydoc.share.get(key) === ytype,
55+
);
56+
if (rootKey == null) {
57+
throw new Error("type does not exist in other ydoc");
58+
}
59+
return otherYdoc.get(rootKey, ytype.constructor as new () => T) as T;
60+
} else {
61+
/**
62+
* If it is a sub type, we use the item id to find the history type.
63+
*/
64+
const ytypeItem = ytype._item;
65+
const otherStructs =
66+
otherYdoc.store.clients.get(ytypeItem.id.client) ?? [];
67+
const itemIndex = Y.findIndexSS(otherStructs, ytypeItem.id.clock);
68+
const otherItem = otherStructs[itemIndex] as Y.Item;
69+
const otherContent = otherItem.content as Y.ContentType;
70+
return otherContent.type as T;
71+
}
72+
}
73+
74+
/**
75+
* Whether the editor is editing a forked document,
76+
* preserving a reference to the original document and the forked document.
77+
*/
78+
public get isForkedFromRemote() {
79+
return this.forkedState !== undefined;
80+
}
81+
82+
/**
83+
* Stores whether the editor is editing a forked document,
84+
* preserving a reference to the original document and the forked document.
85+
*/
86+
private forkedState:
87+
| {
88+
originalFragment: Y.XmlFragment;
89+
forkedFragment: Y.XmlFragment;
90+
}
91+
| undefined;
92+
93+
/**
94+
* Fork the Y.js document from syncing to the remote,
95+
* allowing modifications to the document without affecting the remote.
96+
* These changes can later be rolled back or applied to the remote.
97+
*/
98+
public forkYjsSync() {
99+
if (this.forkedState) {
100+
return;
101+
}
102+
103+
const originalFragment = this.collaboration.fragment;
104+
105+
if (!originalFragment) {
106+
throw new Error("No fragment to fork from");
107+
}
108+
109+
const doc = new Y.Doc();
110+
// Copy the original document to a new Yjs document
111+
Y.applyUpdate(doc, Y.encodeStateAsUpdate(originalFragment.doc!));
112+
113+
// Find the forked fragment in the new Yjs document
114+
const forkedFragment = this.findTypeInOtherYdoc(originalFragment, doc);
115+
116+
this.forkedState = {
117+
originalFragment,
118+
forkedFragment,
119+
};
120+
121+
// Need to reset all the yjs plugins
122+
this.editor._tiptapEditor.unregisterPlugin([
123+
yCursorPluginKey,
124+
yUndoPluginKey,
125+
ySyncPluginKey,
126+
]);
127+
// Register them again, based on the new forked fragment
128+
this.editor._tiptapEditor.registerPlugin(
129+
new SyncPlugin(forkedFragment).plugins[0],
130+
);
131+
this.editor._tiptapEditor.registerPlugin(new UndoPlugin().plugins[0]);
132+
// No need to register the cursor plugin again, it's a local fork
133+
this.emit("forked", true);
134+
}
135+
136+
/**
137+
* Resume syncing the Y.js document to the remote
138+
* If `keepChanges` is true, any changes that have been made to the forked document will be applied to the original document.
139+
* Otherwise, the original document will be restored and the changes will be discarded.
140+
*/
141+
public resumeYjsSync(keepChanges = false) {
142+
if (!this.forkedState) {
143+
return;
144+
}
145+
// Remove the forked fragment's plugins
146+
this.editor._tiptapEditor.unregisterPlugin(ySyncPluginKey);
147+
this.editor._tiptapEditor.unregisterPlugin(yUndoPluginKey);
148+
149+
const { originalFragment, forkedFragment } = this.forkedState!;
150+
if (keepChanges) {
151+
// Apply any changes that have been made to the fork, onto the original doc
152+
const update = Y.encodeStateAsUpdate(forkedFragment.doc!);
153+
Y.applyUpdate(originalFragment.doc!, update);
154+
}
155+
this.editor.extensions["ySyncPlugin"] = new SyncPlugin(originalFragment);
156+
this.editor.extensions["yCursorPlugin"] = new CursorPlugin(
157+
this.collaboration!,
158+
);
159+
this.editor.extensions["yUndoPlugin"] = new UndoPlugin();
160+
// Register the plugins again, based on the original fragment
161+
this.editor._tiptapEditor.registerPlugin(
162+
this.editor.extensions["ySyncPlugin"].plugins[0],
163+
);
164+
this.editor._tiptapEditor.registerPlugin(
165+
this.editor.extensions["yCursorPlugin"].plugins[0],
166+
);
167+
this.editor._tiptapEditor.registerPlugin(
168+
this.editor.extensions["yUndoPlugin"].plugins[0],
169+
);
170+
// Reset the forked state
171+
this.forkedState = undefined;
172+
this.emit("forked", false);
173+
}
174+
}
175+
176+
// export const forkYDocPlugin = (
177+
// editor: BlockNoteEditor<any, any, any>,
178+
// {
179+
// collaboration,
180+
// }: {
181+
// collaboration: BlockNoteEditorOptions<any, any, any>["collaboration"];
182+
// },
183+
// ): BlockNoteExtension<{ forked: boolean }> => {
184+
// return new ForkYDocPlugin({ editor, collaboration });
185+
// };

packages/core/src/extensions/Collaboration/SyncPlugin.ts

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type * as Y from "yjs";
33
import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
44

55
export class SyncPlugin extends BlockNoteExtension {
6+
public static name() {
7+
return "ySyncPlugin";
8+
}
9+
610
constructor(fragment: Y.XmlFragment) {
711
super();
812
this.addProsemirrorPlugin(ySyncPlugin(fragment));

packages/core/src/extensions/Collaboration/UndoPlugin.ts

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { yUndoPlugin } from "y-prosemirror";
22
import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
33

44
export class UndoPlugin extends BlockNoteExtension {
5+
public static name() {
6+
return "yUndoPlugin";
7+
}
8+
59
constructor() {
610
super();
711
this.addProsemirrorPlugin(yUndoPlugin());

0 commit comments

Comments
 (0)