diff --git a/examples/07-collaboration/01-partykit/App.tsx b/examples/07-collaboration/01-partykit/App.tsx
index cecfb6767..5485fd519 100644
--- a/examples/07-collaboration/01-partykit/App.tsx
+++ b/examples/07-collaboration/01-partykit/App.tsx
@@ -4,6 +4,8 @@ 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();
@@ -28,7 +30,36 @@ export default function App() {
},
},
});
+ const [forked, setForked] = useState(false);
+ useEffect(() => {
+ editor.on("forked", setForked);
+ }, [editor]);
// Renders the editor instance.
- return ;
+ return (
+ <>
+
+
+
+
+
Forked: {forked ? "Yes" : "No"}
+
+
+ >
+ );
}
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index 715ef60b5..cd8f46505 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -93,6 +93,7 @@ import {
import { Dictionary } from "../i18n/dictionary.js";
import { en } from "../i18n/locales/index.js";
+import { redo, undo } from "@tiptap/pm/history";
import {
TextSelection,
type Command,
@@ -101,8 +102,13 @@ import {
} from "@tiptap/pm/state";
import { dropCursor } from "prosemirror-dropcursor";
import { EditorView } from "prosemirror-view";
-import { undoCommand, redoCommand, ySyncPluginKey } from "y-prosemirror";
-import { undo, redo } from "@tiptap/pm/history";
+import {
+ redoCommand,
+ undoCommand,
+ yCursorPluginKey,
+ ySyncPluginKey,
+ yUndoPluginKey,
+} from "y-prosemirror";
import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js";
import { inlineContentToNodes } from "../api/nodeConversions/blockToNode.js";
import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js";
@@ -113,9 +119,11 @@ 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 { EventEmitter } from "../util/EventEmitter.js";
-import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
+import { SyncPlugin } from "../extensions/Collaboration/SyncPlugin.js";
+import { UndoPlugin } from "../extensions/Collaboration/UndoPlugin.js";
export type BlockNoteExtensionFactory = (
editor: BlockNoteEditor
@@ -395,6 +403,7 @@ export class BlockNoteEditor<
SSchema extends StyleSchema = DefaultStyleSchema
> extends EventEmitter<{
create: void;
+ forked: boolean;
}> {
/**
* The underlying prosemirror schema
@@ -474,7 +483,7 @@ export class BlockNoteEditor<
private readonly showSelectionPlugin: ShowSelectionPlugin;
- private readonly cursorPlugin: CursorPlugin;
+ private cursorPlugin: CursorPlugin;
/**
* The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload).
@@ -934,6 +943,128 @@ export class BlockNoteEditor<
};
}
+ /**
+ * To find a fragment in another ydoc, we need to search for it.
+ */
+ private findTypeInOtherYdoc>(
+ ytype: T,
+ otherYdoc: Y.Doc
+ ): T {
+ const ydoc = ytype.doc!;
+ if (ytype._item === null) {
+ /**
+ * If is a root type, we need to find the root key in the original ydoc
+ * and use it to get the type in the other ydoc.
+ */
+ const rootKey = Array.from(ydoc.share.keys()).find(
+ (key) => ydoc.share.get(key) === ytype
+ );
+ if (rootKey == null) {
+ throw new Error("type does not exist in other ydoc");
+ }
+ return otherYdoc.get(rootKey, ytype.constructor as new () => T) as T;
+ } else {
+ /**
+ * If it is a sub type, we use the item id to find the history type.
+ */
+ const ytypeItem = ytype._item;
+ const otherStructs =
+ otherYdoc.store.clients.get(ytypeItem.id.client) ?? [];
+ const itemIndex = Y.findIndexSS(otherStructs, ytypeItem.id.clock);
+ const otherItem = otherStructs[itemIndex] as Y.Item;
+ const otherContent = otherItem.content as Y.ContentType;
+ return otherContent.type as T;
+ }
+ }
+
+ /**
+ * Whether the editor is editing a forked document,
+ * preserving a reference to the original document and the forked document.
+ */
+ public get isForkedFromRemote() {
+ return this.forkedState !== undefined;
+ }
+
+ /**
+ * Stores whether the editor is editing a forked document,
+ * preserving a reference to the original document and the forked document.
+ */
+ private forkedState:
+ | {
+ originalFragment: Y.XmlFragment;
+ forkedFragment: Y.XmlFragment;
+ }
+ | undefined;
+
+ /**
+ * Fork the Y.js document from syncing to the remote,
+ * allowing modifications to the document without affecting the remote.
+ * These changes can later be rolled back or applied to the remote.
+ */
+ public forkYjsSync() {
+ if (this.forkedState) {
+ return;
+ }
+
+ const originalFragment = this.options.collaboration?.fragment;
+
+ if (!originalFragment) {
+ // No original fragment found, so no need to fork
+ return;
+ }
+
+ const doc = new Y.Doc();
+ // Copy the original document to a new Yjs document
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(originalFragment.doc!));
+
+ // Find the forked fragment in the new Yjs document
+ const forkedFragment = this.findTypeInOtherYdoc(originalFragment, doc);
+
+ this.forkedState = {
+ originalFragment,
+ forkedFragment,
+ };
+
+ // Need to reset all the yjs plugins
+ [yCursorPluginKey, yUndoPluginKey, ySyncPluginKey].forEach((key) => {
+ this._tiptapEditor.unregisterPlugin(key);
+ });
+ // Register them again, based on the new forked fragment
+ this._tiptapEditor.registerPlugin(new SyncPlugin(forkedFragment).plugin);
+ this._tiptapEditor.registerPlugin(new UndoPlugin().plugin);
+ // No need to register the cursor plugin again, it's a local fork
+ this.emit("forked", true);
+ }
+
+ /**
+ * Resume syncing the Y.js document to the remote
+ * If `keepChanges` is true, any changes that have been made to the forked document will be applied to the original document.
+ * Otherwise, the original document will be restored and the changes will be discarded.
+ */
+ public resumeYjsSync(keepChanges = false) {
+ if (!this.forkedState) {
+ return;
+ }
+ // Remove the forked fragment's plugins
+ this._tiptapEditor.unregisterPlugin(ySyncPluginKey);
+ this._tiptapEditor.unregisterPlugin(yUndoPluginKey);
+
+ const { originalFragment, forkedFragment } = this.forkedState!;
+ if (keepChanges) {
+ // Apply any changes that have been made to the fork, onto the original doc
+ const update = Y.encodeStateAsUpdate(forkedFragment.doc!);
+ Y.applyUpdate(originalFragment.doc!, update);
+ }
+ // Register the plugins again, based on the original fragment
+ this._tiptapEditor.registerPlugin(new SyncPlugin(originalFragment).plugin);
+ this.cursorPlugin = new CursorPlugin(this.options.collaboration!);
+ this._tiptapEditor.registerPlugin(this.cursorPlugin.plugin);
+ this._tiptapEditor.registerPlugin(new UndoPlugin().plugin);
+ // Reset the forked state
+ this.forkedState = undefined;
+ this.emit("forked", false);
+ }
+
/**
* @deprecated, use `editor.document` instead
*/