Skip to content

feat: pausing & resuming of a ydoc #1639

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 19, 2025
Merged
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
10 changes: 10 additions & 0 deletions examples/07-collaboration/07-forking/.bnexample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"playground": true,
"docs": false,
"author": "nperez0111",
"tags": ["Advanced", "Development", "Collaboration"],
"dependencies": {
"y-partykit": "^0.0.25",
"yjs": "^13.6.15"
}
}
72 changes: 72 additions & 0 deletions examples/07-collaboration/07-forking/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import "@blocknote/core/fonts/inter.css";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import YPartyKitProvider from "y-partykit/provider";
import * as Y from "yjs";
import { useEffect } from "react";
import { useState } from "react";

// Sets up Yjs document and PartyKit Yjs provider.
const doc = new Y.Doc();
const provider = new YPartyKitProvider(
"blocknote-dev.yousefed.partykit.dev",
// Use a unique name as a "room" for your application.
"your-project-name-room",
doc,
);

export default function App() {
const editor = useCreateBlockNote({
collaboration: {
// The Yjs Provider responsible for transporting updates:
provider,
// Where to store BlockNote data in the Y.Doc:
fragment: doc.getXmlFragment("document-store"),
// Information (name and color) for this user:
user: {
name: "My Username",
color: "#ff0000",
},
},
});
const [isForked, setIsForked] = useState(false);

useEffect(() => {
editor.forkYDocPlugin.on("forked", setIsForked);
}, [editor]);

// Renders the editor instance.
return (
<>
<button
onClick={() => {
editor.forkYDocPlugin.fork();
}}
disabled={isForked}
>
Pause syncing
</button>
<button
onClick={() => {
editor.forkYDocPlugin.merge({ keepChanges: true });
}}
disabled={!isForked}
>
Play (accept changes)
</button>
<button
onClick={() => {
editor.forkYDocPlugin.merge({ keepChanges: false });
}}
disabled={!isForked}
>
Play (reject changes)
</button>
<div>
<p>Forked: {isForked ? "Yes" : "No"}</p>
</div>
<BlockNoteView editor={editor} />
</>
);
}
9 changes: 9 additions & 0 deletions examples/07-collaboration/07-forking/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Collaborative Editing with Forking

In this example, we can fork a document and edit it independently of other collaborators. Then, we can choose to merge the changes back into the original document, or discard the changes.

**Try it out:** Open this page in a new browser tab or window to see it in action!

**Relevant Docs:**

- [Editor Setup](/docs/editor-basics/setup)
14 changes: 14 additions & 0 deletions examples/07-collaboration/07-forking/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html lang="en">
<head>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Collaborative Editing with Forking</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/07-collaboration/07-forking/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
29 changes: 29 additions & 0 deletions examples/07-collaboration/07-forking/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@blocknote/example-collaboration-forking",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"private": true,
"version": "0.12.4",
"scripts": {
"start": "vite",
"dev": "vite",
"build:prod": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@blocknote/core": "latest",
"@blocknote/react": "latest",
"@blocknote/ariakit": "latest",
"@blocknote/mantine": "latest",
"@blocknote/shadcn": "latest",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"y-partykit": "^0.0.25",
"yjs": "^13.6.15"
},
"devDependencies": {
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.4"
}
}
36 changes: 36 additions & 0 deletions examples/07-collaboration/07-forking/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"composite": true
},
"include": [
"."
],
"__ADD_FOR_LOCAL_DEV_references": [
{
"path": "../../../packages/core/"
},
{
"path": "../../../packages/react/"
}
]
}
32 changes: 32 additions & 0 deletions examples/07-collaboration/07-forking/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
import react from "@vitejs/plugin-react";
import * as fs from "fs";
import * as path from "path";
import { defineConfig } from "vite";
// import eslintPlugin from "vite-plugin-eslint";
// https://vitejs.dev/config/
export default defineConfig((conf) => ({
plugins: [react()],
optimizeDeps: {},
build: {
sourcemap: true,
},
resolve: {
alias:
conf.command === "build" ||
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
? {}
: ({
// Comment out the lines below to load a built version of blocknote
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
"../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
"../../packages/react/src/"
),
} as any),
},
}));
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@shikijs/types": "3.2.1",
"@tiptap/core": "^2.11.5",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-bold": "^2.11.5",
"@tiptap/extension-code": "^2.11.5",
"@tiptap/extension-gapcursor": "^2.11.5",
Expand All @@ -90,7 +90,7 @@
"@tiptap/extension-table-header": "^2.11.5",
"@tiptap/extension-text": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/pm": "^2.12.0",
"emoji-mart": "^5.6.0",
"hast-util-from-dom": "^5.0.1",
"prosemirror-dropcursor": "^1.8.1",
Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,13 @@ import {
import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js";
import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js";
import type { ThreadStore, User } from "../comments/index.js";
import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
import "../style.css";
import type { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
import type { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js";
import { EventEmitter } from "../util/EventEmitter.js";
import { BlockNoteExtension } from "./BlockNoteExtension.js";

import "../style.css";

/**
* A factory function that returns a BlockNoteExtension
* This is useful so we can create extensions that require an editor instance
Expand Down Expand Up @@ -416,7 +418,7 @@ export class BlockNoteEditor<
/**
* extensions that are added to the editor, can be tiptap extensions or prosemirror plugins
*/
public readonly extensions: Record<string, SupportedExtension> = {};
public extensions: Record<string, SupportedExtension> = {};

/**
* Boolean indicating whether the editor is in headless mode.
Expand Down Expand Up @@ -485,8 +487,10 @@ export class BlockNoteEditor<

private readonly showSelectionPlugin: ShowSelectionPlugin;

private readonly cursorPlugin: CursorPlugin;

/**
* The plugin for forking a document, only defined if in collaboration mode
*/
public readonly forkYDocPlugin?: ForkYDocPlugin;
/**
* The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload).
* This method should set when creating the editor as this is application-specific.
Expand Down Expand Up @@ -647,7 +651,7 @@ export class BlockNoteEditor<
this.tableHandles = this.extensions["tableHandles"] as any;
this.comments = this.extensions["comments"] as any;
this.showSelectionPlugin = this.extensions["showSelection"] as any;
this.cursorPlugin = this.extensions["yCursorPlugin"] as any;
this.forkYDocPlugin = this.extensions["forkYDocPlugin"] as any;

if (newOptions.uploadFile) {
const uploadFile = newOptions.uploadFile;
Expand Down Expand Up @@ -1547,7 +1551,7 @@ export class BlockNoteEditor<
);
}

this.cursorPlugin.updateUser(user);
(this.extensions["yCursorPlugin"] as CursorPlugin).updateUser(user);
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/editor/BlockNoteExtension.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Plugin } from "prosemirror-state";
import { EventEmitter } from "../util/EventEmitter.js";

export abstract class BlockNoteExtension extends EventEmitter<any> {
export abstract class BlockNoteExtension<
TEvent extends Record<string, any> = any,
> extends EventEmitter<TEvent> {
public static name(): string {
throw new Error("You must implement the name method in your extension");
}
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/editor/BlockNoteExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
BlockNoteEditorOptions,
SupportedExtension,
} from "./BlockNoteEditor.js";
import { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js";

type ExtensionOptions<
BSchema extends BlockSchema,
Expand Down Expand Up @@ -120,6 +121,10 @@ export const getBlockNoteExtensions = <
if (opts.collaboration.provider?.awareness) {
ret["yCursorPlugin"] = new CursorPlugin(opts.collaboration);
}
ret["forkYDocPlugin"] = new ForkYDocPlugin({
editor: opts.editor,
collaboration: opts.collaboration,
});
}

// Note: this is pretty hardcoded and will break when user provides plugins with same keys.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/extensions/Collaboration/CursorPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export type CollaborationUser = {
};

export class CursorPlugin extends BlockNoteExtension {
public static name() {
return "yCursorPlugin";
}

private provider: { awareness: Awareness };
private recentlyUpdatedCursors: Map<
number,
Expand Down
Loading
Loading