diff --git a/docs/content/docs/features/collaboration/comments.mdx b/docs/content/docs/features/collaboration/comments.mdx index 4b88225993..cf53ad1a93 100644 --- a/docs/content/docs/features/collaboration/comments.mdx +++ b/docs/content/docs/features/collaboration/comments.mdx @@ -16,24 +16,28 @@ To enable comments in your editor, you need to: - Optionally provide a schema for comments and comment editors to use. If left undefined, they will use the [default comment editor schema](https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/Comments/defaultCommentEditorSchema.ts). See [here](/docs/features/custom-schemas) to find out more about custom schemas. ```tsx -const editor = useCreateBlockNote({ - extensions: [ - CommentsExtension({ - // See below. - threadStore: ..., - // Return user information for the given userIds (see below). - resolveUsers: async (userIds: string[]) => { ... }, - // Optional, can be left undefined - schema: BlockNoteSchema.create(...) - }), +import { withCollaboration } from "@blocknote/core/yjs"; + +const editor = useCreateBlockNote( + withCollaboration({ + extensions: [ + CommentsExtension({ + // See below. + threadStore: ..., + // Return user information for the given userIds (see below). + resolveUsers: async (userIds: string[]) => { ... }, + // Optional, can be left undefined + schema: BlockNoteSchema.create(...) + }), + ... + ], + collaboration: { + // See real-time collaboration docs + ... + }, ... - ], - collaboration: { - // See real-time collaboration docs - ... - }, - ... -}); + }), +); ``` **Demo** @@ -50,7 +54,7 @@ BlockNote comes with several built-in ThreadStore implementations: The `YjsThreadStore` provides direct Yjs-based storage for comments, storing thread data directly in the Yjs document. This implementation is ideal for simple collaborative setups where all users have write access to the document. ```tsx -import { YjsThreadStore } from "@blocknote/core/comments"; +import { YjsThreadStore } from "@blocknote/core/yjs"; const threadStore = new YjsThreadStore( userId, // The active user's ID @@ -68,10 +72,8 @@ The `RESTYjsThreadStore` combines Yjs storage with a REST API backend, providing In this implementation, data is written to the Yjs document via a REST API which can handle access control. Data is still retrieved from the Yjs document directly (after it's been updated by the REST API), this way all comment information automatically syncs between clients using the existing collaboration provider. ```tsx -import { - RESTYjsThreadStore, - DefaultThreadStoreAuth, -} from "@blocknote/core/comments"; +import { DefaultThreadStoreAuth } from "@blocknote/core/comments"; +import { RESTYjsThreadStore } from "@blocknote/core/yjs"; const threadStore = new RESTYjsThreadStore( "https://api.example.com/comments", // Base URL for the REST API diff --git a/docs/content/docs/features/collaboration/index.mdx b/docs/content/docs/features/collaboration/index.mdx index 2d320ab829..20d9f40957 100644 --- a/docs/content/docs/features/collaboration/index.mdx +++ b/docs/content/docs/features/collaboration/index.mdx @@ -20,36 +20,41 @@ Let's see how you can add Multiplayer capabilities to your BlockNote setup, and _Try the live demo on the [homepage](https://www.blocknotejs.org)_ -BlockNote uses [Yjs](https://github.com/yjs/yjs) for this, and you can set it up with the `collaboration` option: +BlockNote uses [Yjs](https://github.com/yjs/yjs) for this, and you can set it up with the `withCollaboration` helper: ```typescript import * as Y from "yjs"; import { WebrtcProvider } from "y-webrtc"; +import { withCollaboration } from "@blocknote/core/yjs"; // ... const doc = new Y.Doc(); const provider = new WebrtcProvider("my-document-id", doc); // setup a yjs provider (explained below) -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 editor = useCreateBlockNote( + withCollaboration({ + // ... + 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", + }, + // When to show user labels on the collaboration cursor. Set by default to + // "activity" (show when the cursor moves), but can also be set to "always". + showCursorLabels: "activity", }, - // When to show user labels on the collaboration cursor. Set by default to - // "activity" (show when the cursor moves), but can also be set to "always". - showCursorLabels: "activity", - }, - // ... -}); + // ... + }), +); ``` +The `withCollaboration` function accepts all the regular editor options along with a `collaboration` property, and configures your editor for real-time collaboration. + ## Yjs Providers When a user edits the document, an incremental change (or "update") is captured and can be shared between users of your app. You can share these updates by setting up a _Yjs Provider_. In the snipped above, we use [y-webrtc](https://github.com/yjs/y-webrtc) which shares updates over WebRTC (and BroadcastChannel), but you might be interested in different providers for production-ready use cases. diff --git a/examples/07-collaboration/01-partykit/src/App.tsx b/examples/07-collaboration/01-partykit/src/App.tsx index 4d317c9b3b..333b7e7248 100644 --- a/examples/07-collaboration/01-partykit/src/App.tsx +++ b/examples/07-collaboration/01-partykit/src/App.tsx @@ -4,6 +4,7 @@ import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import YPartyKitProvider from "y-partykit/provider"; import * as Y from "yjs"; +import { withCollaboration } from "@blocknote/core/yjs"; // Sets up Yjs document and PartyKit Yjs provider. const doc = new Y.Doc(); @@ -15,19 +16,21 @@ const provider = new YPartyKitProvider( ); 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 editor = useCreateBlockNote( + withCollaboration({ + 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", + }, }, - }, - }); + }), + ); // Renders the editor instance. return ; diff --git a/examples/07-collaboration/03-y-sweet/src/App.tsx b/examples/07-collaboration/03-y-sweet/src/App.tsx index 5a238ac497..e96b4af46f 100644 --- a/examples/07-collaboration/03-y-sweet/src/App.tsx +++ b/examples/07-collaboration/03-y-sweet/src/App.tsx @@ -3,6 +3,7 @@ import { useYDoc, useYjsProvider, YDocProvider } from "@y-sweet/react"; import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; +import { withCollaboration } from "@blocknote/core/yjs"; import "@blocknote/mantine/style.css"; @@ -23,13 +24,15 @@ function Document() { const provider = useYjsProvider(); const doc = useYDoc(); - const editor = useCreateBlockNote({ - collaboration: { - provider, - fragment: doc.getXmlFragment("blocknote"), - user: { color: "#ff0000", name: "My Username" }, - }, - }); + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + provider, + fragment: doc.getXmlFragment("blocknote"), + user: { color: "#ff0000", name: "My Username" }, + }, + }), + ); return ; } diff --git a/examples/07-collaboration/05-comments/src/App.tsx b/examples/07-collaboration/05-comments/src/App.tsx index 7aaeac4df2..f0d47ab57b 100644 --- a/examples/07-collaboration/05-comments/src/App.tsx +++ b/examples/07-collaboration/05-comments/src/App.tsx @@ -3,8 +3,9 @@ import { CommentsExtension, DefaultThreadStoreAuth, - YjsThreadStore, } from "@blocknote/core/comments"; +import { withCollaboration, YjsThreadStore } from "@blocknote/core/yjs"; + import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; @@ -74,14 +75,14 @@ function Document() { // setup the editor with comments and collaboration const editor = useCreateBlockNote( - { + withCollaboration({ collaboration: { provider, fragment: doc.getXmlFragment("blocknote"), user: { color: getRandomColor(), name: activeUser.username }, }, extensions: [CommentsExtension({ threadStore, resolveUsers })], - }, + }), [activeUser, threadStore], ); diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx b/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx index 84ad0d577a..fd0b605fb1 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx @@ -2,9 +2,9 @@ import { DefaultThreadStoreAuth, - YjsThreadStore, CommentsExtension, } from "@blocknote/core/comments"; +import { withCollaboration, YjsThreadStore } from "@blocknote/core/yjs"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { @@ -77,14 +77,14 @@ export default function App() { // setup the editor with comments and collaboration const editor = useCreateBlockNote( - { + withCollaboration({ collaboration: { provider, fragment: doc.getXmlFragment("blocknote"), user: { color: getRandomColor(), name: activeUser.username }, }, extensions: [CommentsExtension({ threadStore, resolveUsers })], - }, + }), [activeUser, threadStore], ); diff --git a/examples/07-collaboration/07-ghost-writer/src/App.tsx b/examples/07-collaboration/07-ghost-writer/src/App.tsx index 4344c5c11a..b34a1364c8 100644 --- a/examples/07-collaboration/07-ghost-writer/src/App.tsx +++ b/examples/07-collaboration/07-ghost-writer/src/App.tsx @@ -2,6 +2,7 @@ import "@blocknote/core/fonts/inter.css"; import "@blocknote/mantine/style.css"; import { BlockNoteView } from "@blocknote/mantine"; import { useCreateBlockNote } from "@blocknote/react"; +import { withCollaboration } from "@blocknote/core/yjs"; import YPartyKitProvider from "y-partykit/provider"; import * as Y from "yjs"; @@ -38,21 +39,23 @@ const ghostContent = export default function App() { const [numGhostWriters, setNumGhostWriters] = useState(1); const [isPaused, setIsPaused] = useState(false); - 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: isGhostWriting - ? `Ghost Writer #${ghostWriterIndex}` - : "My Username", - color: isGhostWriting ? "#CCCCCC" : "#00ff00", + const editor = useCreateBlockNote( + withCollaboration({ + 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: isGhostWriting + ? `Ghost Writer #${ghostWriterIndex}` + : "My Username", + color: isGhostWriting ? "#CCCCCC" : "#00ff00", + }, }, - }, - }); + }), + ); useEffect(() => { if (!isGhostWriting || isPaused) { @@ -101,7 +104,8 @@ export default function App() { `${window.location.origin}${window.location.pathname}?room=${roomName}&index=-1`, "_blank", ); - }}> + }} + > Ghost Writer in a new window diff --git a/examples/07-collaboration/08-forking/src/App.tsx b/examples/07-collaboration/08-forking/src/App.tsx index d338e133d7..948eea5a24 100644 --- a/examples/07-collaboration/08-forking/src/App.tsx +++ b/examples/07-collaboration/08-forking/src/App.tsx @@ -1,6 +1,5 @@ import "@blocknote/core/fonts/inter.css"; -import {} from "@blocknote/core"; -import { ForkYDocExtension } from "@blocknote/core/extensions"; +import { ForkYDocExtension, withCollaboration } from "@blocknote/core/yjs"; import { useCreateBlockNote, useExtension, @@ -21,19 +20,21 @@ const provider = new YPartyKitProvider( ); 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 editor = useCreateBlockNote( + withCollaboration({ + 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 forkYDocPlugin = useExtension(ForkYDocExtension, { editor }); const isForked = useExtensionState(ForkYDocExtension, { editor, diff --git a/examples/07-collaboration/09-comments-testing/src/App.tsx b/examples/07-collaboration/09-comments-testing/src/App.tsx index 3bada358c1..0ad270f59c 100644 --- a/examples/07-collaboration/09-comments-testing/src/App.tsx +++ b/examples/07-collaboration/09-comments-testing/src/App.tsx @@ -3,8 +3,8 @@ import { CommentsExtension, DefaultThreadStoreAuth, - YjsThreadStore, } from "@blocknote/core/comments"; +import { YjsThreadStore } from "@blocknote/core/yjs"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; diff --git a/packages/core/src/api/positionMapping.test.ts b/packages/core/src/api/positionMapping.test.ts index a0019932ee..6d79caf44c 100644 --- a/packages/core/src/api/positionMapping.test.ts +++ b/packages/core/src/api/positionMapping.test.ts @@ -1,38 +1,35 @@ -import { describe, expect, it, vi } from "vitest"; +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from "vitest"; import * as Y from "yjs"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { trackPosition } from "./positionMapping.js"; +import { withCollaboration } from "../yjs/index.js"; describe("PositionStorage with local editor", () => { describe("mount and unmount", () => { - it("should register transaction handler on creation", () => { + it("should return a position getter on creation (mounted)", () => { const editor = BlockNoteEditor.create(); editor.mount(document.createElement("div")); - editor._tiptapEditor.on = vi.fn(); - trackPosition(editor, 0); + const getPos = trackPosition(editor, 0); - expect(editor._tiptapEditor.on).toHaveBeenCalledWith( - "transaction", - expect.any(Function), - ); + expect(typeof getPos).toBe("function"); + expect(getPos()).toBe(0); - editor._tiptapEditor.destroy(); + editor.unmount(); }); - it("should register transaction handler on creation & mount", () => { + it("should return a position getter on creation (unmounted)", () => { const editor = BlockNoteEditor.create(); - // editor.mount(document.createElement("div")); - editor._tiptapEditor.on = vi.fn(); - trackPosition(editor, 0); + const getPos = trackPosition(editor, 0); - expect(editor._tiptapEditor.on).toHaveBeenCalledWith( - "transaction", - expect.any(Function), - ); + expect(typeof getPos).toBe("function"); + expect(getPos()).toBe(0); - editor._tiptapEditor.destroy(); + editor.unmount(); }); }); @@ -45,7 +42,7 @@ describe("PositionStorage with local editor", () => { expect(getPos()).toBe(10); - editor._tiptapEditor.destroy(); + editor.unmount(); }); it("should handle right side positions", () => { @@ -56,7 +53,7 @@ describe("PositionStorage with local editor", () => { expect(getPos()).toBe(10); - editor._tiptapEditor.destroy(); + editor.unmount(); }); }); @@ -101,50 +98,7 @@ describe("PositionStorage with local editor", () => { // Position should be updated according to mapping expect(getPos()).toBe(14); - editor._tiptapEditor.destroy(); - }); - - it("should update mapping for local transactions before the position (unmounted)", () => { - const editor = BlockNoteEditor.create(); - - // Set initial content - editor.insertBlocks( - [ - { - id: "1", - type: "paragraph", - content: [ - { - type: "text", - text: "Hello World", - styles: {}, - }, - ], - }, - ], - editor.document[0], - "before", - ); - - // Start tracking - const getPos = trackPosition(editor, 10); - - // Move the cursor to the start of the document - editor.setTextCursorPosition(editor.document[0], "start"); - - // Insert text at the start of the document - editor.insertInlineContent([ - { - type: "text", - text: "Test", - styles: {}, - }, - ]); - - // Position should be updated according to mapping - expect(getPos()).toBe(14); - - editor._tiptapEditor.destroy(); + editor.unmount(); }); it("should not update mapping for local transactions after the position", () => { @@ -187,7 +141,7 @@ describe("PositionStorage with local editor", () => { // Position should not be updated expect(getPos()).toBe(10); - editor._tiptapEditor.destroy(); + editor.unmount(); }); it("should track positions on each side", () => { @@ -217,7 +171,7 @@ describe("PositionStorage with local editor", () => { expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) - editor._tiptapEditor.destroy(); + editor.unmount(); }); it("should handle multiple transactions", () => { @@ -252,7 +206,7 @@ describe("PositionStorage with local editor", () => { expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) - editor._tiptapEditor.destroy(); + editor.unmount(); }); }); @@ -288,23 +242,27 @@ describe("PositionStorage with remote editor", () => { const remoteYdoc = new Y.Doc(); // Create a mock editor - const localEditor = BlockNoteEditor.create({ - collaboration: { - fragment: ydoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Local User" }, - provider: undefined, - }, - }); + const localEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: ydoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Local User" }, + provider: undefined, + }, + }), + ); const div = document.createElement("div"); localEditor.mount(div); - const remoteEditor = BlockNoteEditor.create({ - collaboration: { - fragment: remoteYdoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Remote User" }, - provider: undefined, - }, - }); + const remoteEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: remoteYdoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Remote User" }, + provider: undefined, + }, + }), + ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); @@ -340,8 +298,8 @@ describe("PositionStorage with remote editor", () => { ydoc.destroy(); remoteYdoc.destroy(); - localEditor._tiptapEditor.destroy(); - remoteEditor._tiptapEditor.destroy(); + localEditor.unmount(); + remoteEditor.unmount(); }); it("should handle multiple transactions when collaborating", () => { @@ -349,23 +307,27 @@ describe("PositionStorage with remote editor", () => { const remoteYdoc = new Y.Doc(); // Create a mock editor - const localEditor = BlockNoteEditor.create({ - collaboration: { - fragment: ydoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Local User" }, - provider: undefined, - }, - }); + const localEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: ydoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Local User" }, + provider: undefined, + }, + }), + ); const div = document.createElement("div"); localEditor.mount(div); - const remoteEditor = BlockNoteEditor.create({ - collaboration: { - fragment: remoteYdoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Remote User" }, - provider: undefined, - }, - }); + const remoteEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: remoteYdoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Remote User" }, + provider: undefined, + }, + }), + ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); @@ -405,8 +367,8 @@ describe("PositionStorage with remote editor", () => { ydoc.destroy(); remoteYdoc.destroy(); - localEditor._tiptapEditor.destroy(); - remoteEditor._tiptapEditor.destroy(); + localEditor.unmount(); + remoteEditor.unmount(); }); it("should update the local position from a remote transaction", () => { @@ -414,23 +376,27 @@ describe("PositionStorage with remote editor", () => { const remoteYdoc = new Y.Doc(); // Create a mock editor - const localEditor = BlockNoteEditor.create({ - collaboration: { - fragment: ydoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Local User" }, - provider: undefined, - }, - }); + const localEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: ydoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Local User" }, + provider: undefined, + }, + }), + ); const div = document.createElement("div"); localEditor.mount(div); - const remoteEditor = BlockNoteEditor.create({ - collaboration: { - fragment: remoteYdoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Remote User" }, - provider: undefined, - }, - }); + const remoteEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: remoteYdoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Remote User" }, + provider: undefined, + }, + }), + ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); @@ -466,8 +432,8 @@ describe("PositionStorage with remote editor", () => { ydoc.destroy(); remoteYdoc.destroy(); - localEditor._tiptapEditor.destroy(); - remoteEditor._tiptapEditor.destroy(); + localEditor.unmount(); + remoteEditor.unmount(); }); it("should update the remote position from a remote transaction", () => { @@ -475,23 +441,27 @@ describe("PositionStorage with remote editor", () => { const remoteYdoc = new Y.Doc(); // Create a mock editor - const localEditor = BlockNoteEditor.create({ - collaboration: { - fragment: ydoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Local User" }, - provider: undefined, - }, - }); + const localEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: ydoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Local User" }, + provider: undefined, + }, + }), + ); const div = document.createElement("div"); localEditor.mount(div); - const remoteEditor = BlockNoteEditor.create({ - collaboration: { - fragment: remoteYdoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "Remote User" }, - provider: undefined, - }, - }); + const remoteEditor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: remoteYdoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Remote User" }, + provider: undefined, + }, + }), + ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); @@ -527,8 +497,8 @@ describe("PositionStorage with remote editor", () => { ydoc.destroy(); remoteYdoc.destroy(); - localEditor._tiptapEditor.destroy(); - remoteEditor._tiptapEditor.destroy(); + localEditor.unmount(); + remoteEditor.unmount(); }); }); }); diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts index 11d8ef0fa9..5fbe259997 100644 --- a/packages/core/src/api/positionMapping.ts +++ b/packages/core/src/api/positionMapping.ts @@ -1,40 +1,5 @@ -import { Mapping } from "prosemirror-transform"; -import { - absolutePositionToRelativePosition, - relativePositionToAbsolutePosition, - ySyncPluginKey, -} from "y-prosemirror"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; -import * as Y from "yjs"; -import type { ProsemirrorBinding } from "y-prosemirror"; - -/** - * This is used to track a mapping for each editor. The mapping stores the mappings for each transaction since the first transaction that was tracked. - */ -const editorToMapping = new Map, Mapping>(); - -/** - * This initializes a single mapping for an editor instance. - */ -function getMapping(editor: BlockNoteEditor) { - if (editorToMapping.has(editor)) { - // Mapping already initialized, so we don't need to do anything - return editorToMapping.get(editor)!; - } - const mapping = new Mapping(); - editor._tiptapEditor.on("transaction", ({ transaction }) => { - mapping.appendMapping(transaction.mapping); - }); - editor._tiptapEditor.on("destroy", () => { - // Cleanup the mapping when the editor is destroyed - editorToMapping.delete(editor); - }); - - // There only is one mapping per editor, so we can just set it - editorToMapping.set(editor, mapping); - - return mapping; -} +import type { PositionMappingExtension } from "../extensions/PositionMapping/PositionMapping.js"; /** * This is used to keep track of positions of elements in the editor. @@ -61,52 +26,17 @@ export function trackPosition( */ side: "left" | "right" = "left", ): () => number { - const ySyncPluginState = ySyncPluginKey.getState(editor.prosemirrorState) as { - doc: Y.Doc; - binding: ProsemirrorBinding; - }; - - if (!ySyncPluginState) { - // No y-prosemirror sync plugin, so we need to track the mapping manually - // This will initialize the mapping for this editor, if needed - const mapping = getMapping(editor); - - // This is the start point of tracking the mapping - const trackedMapLength = mapping.maps.length; - - return () => { - const pos = mapping - // Only read the history of the mapping that we care about - .slice(trackedMapLength) - .map(position, side === "left" ? -1 : 1); - - return pos; - }; + // Try to use the Yjs Relative Position Mapping Extension + const yPositionMappingExtension = + editor.getExtension("yPositionMapping"); + if (yPositionMappingExtension) { + return yPositionMappingExtension.mapPosition(position, side); } - - const relativePosition = absolutePositionToRelativePosition( - // Track the position after the position if we are on the right side - position + (side === "right" ? 1 : -1), - ySyncPluginState.binding.type, - ySyncPluginState.binding.mapping, - ); - - return () => { - const curYSyncPluginState = ySyncPluginKey.getState( - editor.prosemirrorState, - ) as typeof ySyncPluginState; - const pos = relativePositionToAbsolutePosition( - curYSyncPluginState.doc, - curYSyncPluginState.binding.type, - relativePosition, - curYSyncPluginState.binding.mapping, - ); - - // This can happen if the element is garbage collected - if (pos === null) { - throw new Error("Position not found, cannot track positions"); - } - - return pos + (side === "right" ? -1 : 1); - }; + // Fallback to the Prosemirror Position Mapping Extension + const positionMappingExtension = + editor.getExtension("positionMapping"); + if (positionMappingExtension) { + return positionMappingExtension.mapPosition(position, side); + } + throw new Error("No position mapping extension found"); } diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index 8d23c3e967..c037e80ddf 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -1,7 +1,6 @@ import { Node } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; -import { getRelativeSelection, ySyncPluginKey } from "y-prosemirror"; import { createExtension, createStore, @@ -351,21 +350,9 @@ export const CommentsExtension = createExtension( }) { const thread = await threadStore.createThread(options); if (threadStore.addThreadToDocument) { - const view = editor.prosemirrorView!; - const pmSelection = view.state.selection; - const ystate = ySyncPluginKey.getState(view.state); - const selection = { - prosemirror: { - head: pmSelection.head, - anchor: pmSelection.anchor, - }, - yjs: ystate - ? getRelativeSelection(ystate.binding, view.state) - : undefined, - }; await threadStore.addThreadToDocument({ threadId: thread.id, - selection, + selection: editor.transact((tr) => tr.selection), }); } else { (editor as any)._tiptapEditor.commands.setMark(markType, { diff --git a/packages/core/src/comments/index.ts b/packages/core/src/comments/index.ts index 9f231dad4d..7cc20cfe8d 100644 --- a/packages/core/src/comments/index.ts +++ b/packages/core/src/comments/index.ts @@ -4,7 +4,4 @@ export * from "./threadstore/DefaultThreadStoreAuth.js"; export * from "./threadstore/ThreadStore.js"; export * from "./threadstore/ThreadStoreAuth.js"; export * from "./threadstore/TipTapThreadStore.js"; -export * from "./threadstore/yjs/RESTYjsThreadStore.js"; -export * from "./threadstore/yjs/YjsThreadStore.js"; -export * from "./threadstore/yjs/YjsThreadStoreBase.js"; export * from "./types.js"; diff --git a/packages/core/src/comments/threadstore/ThreadStore.ts b/packages/core/src/comments/threadstore/ThreadStore.ts index 6d8fc55fba..bce6be71c0 100644 --- a/packages/core/src/comments/threadstore/ThreadStore.ts +++ b/packages/core/src/comments/threadstore/ThreadStore.ts @@ -23,14 +23,8 @@ export abstract class ThreadStore { abstract addThreadToDocument?(options: { threadId: string; selection: { - prosemirror: { - head: number; - anchor: number; - }; - yjs?: { - head: any; - anchor: any; - }; + head: number; + anchor: number; }; }): Promise; diff --git a/packages/core/src/editor/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts index 6a4f5f023e..79d5e89d08 100644 --- a/packages/core/src/editor/BlockNoteEditor.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.test.ts @@ -7,6 +7,7 @@ import { } from "../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; import { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; +import { withCollaboration } from "../yjs/index.js"; /** * @vitest-environment jsdom @@ -132,17 +133,19 @@ it("sets an initial block id when using Y.js", async () => { const doc = new Y.Doc(); const fragment = doc.getXmlFragment("doc"); let transactionCount = 0; - const editor = BlockNoteEditor.create({ - collaboration: { - fragment, - user: { name: "Hello", color: "#FFFFFF" }, - }, - _tiptapOptions: { - onTransaction: () => { - transactionCount++; + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment, + user: { name: "Hello", color: "#FFFFFF" }, }, - }, - }); + _tiptapOptions: { + onTransaction: () => { + transactionCount++; + }, + }, + }), + ); editor.mount(document.createElement("div")); @@ -186,8 +189,8 @@ it("sets an initial block id when using Y.js", async () => { ]); expect(transactionCount).toBe(2); // Only after a real modification is made, will the fragment be updated - expect(fragment.toJSON()).toMatchInlineSnapshot( - `"Hello"`, + expect(fragment.toJSON()).toMatch( + /^Hello<\/paragraph><\/blockcontainer><\/blockgroup>$/, ); }); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index e4888f50f6..bf79a57497 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -17,7 +17,6 @@ import { DefaultStyleSchema, PartialBlock, } from "../blocks/index.js"; -import type { CollaborationOptions } from "../extensions/Collaboration/Collaboration.js"; import { BlockChangeExtension, DropCursorOptions, @@ -81,12 +80,6 @@ export interface BlockNoteEditorOptions< */ autofocus?: FocusPosition; - /** - * When enabled, allows for collaboration between multiple users. - * See [Real-time Collaboration](https://www.blocknotejs.org/docs/advanced/real-time-collaboration) for more info. - */ - collaboration?: CollaborationOptions; - /** * Use default BlockNote font and reset the styles of

  • elements etc., that are used in BlockNote. * @@ -501,17 +494,6 @@ export class BlockNoteEditor< const tiptapExtensions = this._extensionManager.getTiptapExtensions(); - const collaborationEnabled = - this._extensionManager.hasExtension("ySync") || - this._extensionManager.hasExtension("liveblocksExtension"); - - if (collaborationEnabled && newOptions.initialContent) { - // eslint-disable-next-line no-console - console.warn( - "When using Collaboration, initialContent might cause conflicts, because changes should come from the collaboration provider", - ); - } - const tiptapOptions: EditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, @@ -538,21 +520,12 @@ export class BlockNoteEditor< } as any; try { - const initialContent = - newOptions.initialContent || - (collaborationEnabled - ? [ - { - type: "paragraph", - id: "initialBlockId", - }, - ] - : [ - { - type: "paragraph", - id: UniqueID.options.generateID(), - }, - ]); + const initialContent = newOptions.initialContent || [ + { + type: "paragraph", + id: UniqueID.options.generateID(), + }, + ]; if (!Array.isArray(initialContent) || initialContent.length === 0) { throw new Error( @@ -590,25 +563,6 @@ export class BlockNoteEditor< ); } - // When y-prosemirror creates an empty document, the `blockContainer` node is created with an `id` of `null`. - // This causes the unique id extension to generate a new id for the initial block, which is not what we want - // Since it will be randomly generated & cause there to be more updates to the ydoc - // This is a hack to make it so that anytime `schema.doc.createAndFill` is called, the initial block id is already set to "initialBlockId" - let cache: Node | undefined = undefined; - const oldCreateAndFill = this.pmSchema.nodes.doc.createAndFill; - this.pmSchema.nodes.doc.createAndFill = (...args: any) => { - if (cache) { - return cache; - } - const ret = oldCreateAndFill.apply(this.pmSchema.nodes.doc, args)!; - - // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) - const jsonNode = JSON.parse(JSON.stringify(ret.toJSON())); - jsonNode.content[0].content[0].attrs.id = "initialBlockId"; - - cache = Node.fromJSON(this.pmSchema, jsonNode); - return cache; - }; this.pmSchema.cached.blockNoteEditor = this; this._tiptapEditor.on("mount", () => { diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 7be7070865..2bd6f0b34b 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -4,9 +4,8 @@ import { Node, Extension as TiptapExtension, } from "@tiptap/core"; -import { Gapcursor } from "@tiptap/extensions/gap-cursor"; -import { LinkExtension } from "../../../extensions/tiptap-extensions/Link/link.js"; import { Text } from "@tiptap/extension-text"; +import { Gapcursor } from "@tiptap/extensions/gap-cursor"; import { createDropFileExtension } from "../../../api/clipboard/fromClipboard/fileDropExtension.js"; import { createPasteFromClipboardExtension } from "../../../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../../../api/clipboard/toClipboard/copyExtension.js"; @@ -19,6 +18,7 @@ import { LinkToolbarExtension, NodeSelectionKeyboardExtension, PlaceholderExtension, + PositionMappingExtension, PreviousBlockTypeExtension, ShowSelectionExtension, SideMenuExtension, @@ -30,6 +30,7 @@ import { BackgroundColorExtension, HardBreak, KeyboardShortcutsExtension, + LinkExtension, SuggestionAddMark, SuggestionDeleteMark, SuggestionModificationMark, @@ -38,12 +39,11 @@ import { UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; -import { +import type { BlockNoteEditor, BlockNoteEditorOptions, } from "../../BlockNoteEditor.js"; -import { ExtensionFactoryInstance } from "../../BlockNoteExtension.js"; -import { CollaborationExtension } from "../../../extensions/Collaboration/Collaboration.js"; +import type { ExtensionFactoryInstance } from "../../BlockNoteExtension.js"; /** * Get all the Tiptap extensions BlockNote is configured with by default @@ -174,16 +174,11 @@ export function getDefaultExtensions( ShowSelectionExtension(options), SideMenuExtension(options), SuggestionMenu(options), + HistoryExtension(), + PositionMappingExtension(), ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []), ] as ExtensionFactoryInstance[]; - if (options.collaboration) { - extensions.push(CollaborationExtension(options.collaboration)); - } else { - // YUndo is not compatible with ProseMirror's history plugin - extensions.push(HistoryExtension()); - } - if ("table" in editor.schema.blockSpecs) { extensions.push(TableHandlesExtension(options)); } diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts index 84a44f3aea..9dc3eebff2 100644 --- a/packages/core/src/editor/managers/StateManager.ts +++ b/packages/core/src/editor/managers/StateManager.ts @@ -1,5 +1,4 @@ import { Command, Transaction } from "prosemirror-state"; -import type { YUndoExtension } from "../../extensions/Collaboration/YUndo.js"; import type { HistoryExtension } from "../../extensions/History/History.js"; import { BlockNoteEditor } from "../BlockNoteEditor.js"; @@ -216,7 +215,8 @@ export class StateManager { */ public undo(): boolean { // Purposefully not using the UndoPlugin to not import y-prosemirror when not needed - const undoPlugin = this.editor.getExtension("yUndo"); + const undoPlugin = + this.editor.getExtension("yUndo"); if (undoPlugin) { return this.exec(undoPlugin.undoCommand); } @@ -234,7 +234,8 @@ export class StateManager { * Redo the last action. */ public redo() { - const undoPlugin = this.editor.getExtension("yUndo"); + const undoPlugin = + this.editor.getExtension("yUndo"); if (undoPlugin) { return this.exec(undoPlugin.redoCommand); } diff --git a/packages/core/src/editor/performance.test.ts b/packages/core/src/editor/performance.test.ts index 6564e1a72d..ed18804cf2 100644 --- a/packages/core/src/editor/performance.test.ts +++ b/packages/core/src/editor/performance.test.ts @@ -37,7 +37,7 @@ function createEditorWithBlocks( return editor; } -describe("Performance: transaction processing scales sub-linearly (#2595)", () => { +describe.skip("Performance: transaction processing scales sub-linearly (#2595)", () => { // Compare timing between a small and large document. // At 10k blocks the ratio is dominated by ProseMirror's DecorationSet.map() // which is inherently O(n). The thresholds verify BlockNote plugins don't diff --git a/packages/core/src/extensions/PositionMapping/PositionMapping.ts b/packages/core/src/extensions/PositionMapping/PositionMapping.ts new file mode 100644 index 0000000000..96f1805c27 --- /dev/null +++ b/packages/core/src/extensions/PositionMapping/PositionMapping.ts @@ -0,0 +1,55 @@ +import { Mapping } from "prosemirror-transform"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +export const PositionMappingExtension = createExtension(({ editor }) => { + /** + * The mapping object which holds the position mapping across changes + */ + let mapping = new Mapping(); + /** + * The number of instances of live `mapPosition` closures + */ + let numInstances = 0; + + const registry = + typeof FinalizationRegistry !== "undefined" + ? new FinalizationRegistry(() => { + numInstances--; + if (numInstances === 0) { + mapping = new Mapping(); + } + }) + : null; + + editor.on("create", () => { + editor._tiptapEditor.on("transaction", ({ transaction }) => { + if (numInstances === 0) { + return; + } + mapping.appendMapping(transaction.mapping); + }); + }); + + return { + key: "positionMapping", + mapPosition: (position: number, side: "left" | "right" = "left") => { + numInstances++; + const trackedMapLength = mapping.maps.length; + + const getMappedPosition = () => { + return ( + mapping + // Only read the history of the mapping that we care about + .slice(trackedMapLength) + .map(position, side === "left" ? -1 : 1) + ); + }; + + if (registry) { + registry.register(getMappedPosition, undefined); + } + + return getMappedPosition; + }, + } as const; +}); diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index 210a95222c..e568462a13 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -1,9 +1,4 @@ export * from "./BlockChange/BlockChange.js"; -export * from "./Collaboration/ForkYDoc.js"; -export * from "./Collaboration/schemaMigration/SchemaMigration.js"; -export * from "./Collaboration/YCursorPlugin.js"; -export * from "./Collaboration/YSync.js"; -export * from "./Collaboration/YUndo.js"; export * from "./DropCursor/DropCursor.js"; export * from "./FilePanel/FilePanel.js"; export * from "./FormattingToolbar/FormattingToolbar.js"; @@ -12,13 +7,14 @@ export * from "./LinkToolbar/LinkToolbar.js"; export * from "./LinkToolbar/protocols.js"; export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; export * from "./Placeholder/Placeholder.js"; +export * from "./PositionMapping/PositionMapping.js"; export * from "./PreviousBlockType/PreviousBlockType.js"; export * from "./ShowSelection/ShowSelection.js"; export * from "./SideMenu/SideMenu.js"; -export * from "./SuggestionMenu/SuggestionMenu.js"; -export * from "./SuggestionMenu/getDefaultSlashMenuItems.js"; -export * from "./SuggestionMenu/getDefaultEmojiPickerItems.js"; -export * from "./SuggestionMenu/DefaultSuggestionItem.js"; export * from "./SuggestionMenu/DefaultGridSuggestionItem.js"; +export * from "./SuggestionMenu/DefaultSuggestionItem.js"; +export * from "./SuggestionMenu/getDefaultEmojiPickerItems.js"; +export * from "./SuggestionMenu/getDefaultSlashMenuItems.js"; +export * from "./SuggestionMenu/SuggestionMenu.js"; export * from "./TableHandles/TableHandles.js"; export * from "./TrailingNode/TrailingNode.js"; diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index a3ce6f3828..54cb8b7340 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -49,7 +49,7 @@ const UniqueID = Extension.create({ addOptions() { return { attributeName: "id", - types: [], + types: [] as string[], setIdAttribute: false, isWithinEditor: undefined as ((element: Element) => boolean) | undefined, generateID: () => { @@ -67,7 +67,6 @@ const UniqueID = Extension.create({ return uuidv4(); }, - filterTransaction: null, }; }, addGlobalAttributes() { @@ -139,10 +138,7 @@ const UniqueID = Extension.create({ const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); - const filterTransactions = - this.options.filterTransaction && - transactions.some((tr) => !this.options.filterTransaction?.(tr)); - if (!docChanges || filterTransactions) { + if (!docChanges) { return; } const { tr } = newState; diff --git a/packages/core/src/extensions/tiptap-extensions/index.ts b/packages/core/src/extensions/tiptap-extensions/index.ts index e6fead486c..97f360182f 100644 --- a/packages/core/src/extensions/tiptap-extensions/index.ts +++ b/packages/core/src/extensions/tiptap-extensions/index.ts @@ -1,15 +1,3 @@ -import { BackgroundColorExtension } from "./BackgroundColor/BackgroundColorExtension.js"; -import { HardBreak } from "./HardBreak/HardBreak.js"; -import { KeyboardShortcutsExtension } from "./KeyboardShortcuts/KeyboardShortcutsExtension.js"; -import { - SuggestionAddMark, - SuggestionDeleteMark, - SuggestionModificationMark, -} from "./Suggestions/SuggestionMarks.js"; -import { TextAlignmentExtension } from "./TextAlignment/TextAlignmentExtension.js"; -import { TextColorExtension } from "./TextColor/TextColorExtension.js"; -import { UniqueID } from "./UniqueID/UniqueID.js"; - export * from "./BackgroundColor/BackgroundColorExtension.js"; export * from "./HardBreak/HardBreak.js"; export * from "./KeyboardShortcuts/KeyboardShortcutsExtension.js"; @@ -17,15 +5,4 @@ export * from "./Suggestions/SuggestionMarks.js"; export * from "./TextAlignment/TextAlignmentExtension.js"; export * from "./TextColor/TextColorExtension.js"; export * from "./UniqueID/UniqueID.js"; - -export const DEFAULT_TIP_TAP_EXTENSIONS = [ - BackgroundColorExtension, - HardBreak, - KeyboardShortcutsExtension, - SuggestionAddMark, - SuggestionDeleteMark, - SuggestionModificationMark, - TextAlignmentExtension, - TextColorExtension, - UniqueID, -]; +export * from "./Link/link.js"; diff --git a/packages/core/src/yjs/README.md b/packages/core/src/yjs/README.md new file mode 100644 index 0000000000..bb6f1ae55f --- /dev/null +++ b/packages/core/src/yjs/README.md @@ -0,0 +1,5 @@ +# @blocknote/core/yjs + +This package contains integrations for Yjs (v13) with BlockNote (based on `yjs` & `y-prosemirror`). Given that we are going to support both Yjs v13 & v14, we need to have a way to support both versions independently. + +If you want to use Yjs v14, you can use the `@blocknote/core/y` package instead which will use the `@y/y` & `@y/prosemirror` packages. diff --git a/packages/core/src/comments/threadstore/yjs/RESTYjsThreadStore.ts b/packages/core/src/yjs/comments/RESTYjsThreadStore.ts similarity index 93% rename from packages/core/src/comments/threadstore/yjs/RESTYjsThreadStore.ts rename to packages/core/src/yjs/comments/RESTYjsThreadStore.ts index d3f81c50f5..23bd49e3f9 100644 --- a/packages/core/src/comments/threadstore/yjs/RESTYjsThreadStore.ts +++ b/packages/core/src/yjs/comments/RESTYjsThreadStore.ts @@ -1,6 +1,6 @@ import * as Y from "yjs"; -import { CommentBody } from "../../types.js"; -import { ThreadStoreAuth } from "../ThreadStoreAuth.js"; +import type { CommentBody } from "../../comments/types.js"; +import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js"; import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js"; /** @@ -47,14 +47,8 @@ export class RESTYjsThreadStore extends YjsThreadStoreBase { public addThreadToDocument = async (options: { threadId: string; selection: { - prosemirror: { - head: number; - anchor: number; - }; - yjs: { - head: any; - anchor: any; - }; + head: number; + anchor: number; }; }) => { const { threadId, ...rest } = options; diff --git a/packages/core/src/comments/threadstore/yjs/YjsThreadStore.test.ts b/packages/core/src/yjs/comments/YjsThreadStore.test.ts similarity index 98% rename from packages/core/src/comments/threadstore/yjs/YjsThreadStore.test.ts rename to packages/core/src/yjs/comments/YjsThreadStore.test.ts index b73b7c1ec8..ebdcb4a718 100644 --- a/packages/core/src/comments/threadstore/yjs/YjsThreadStore.test.ts +++ b/packages/core/src/yjs/comments/YjsThreadStore.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as Y from "yjs"; -import { CommentBody } from "../../types.js"; -import { DefaultThreadStoreAuth } from "../DefaultThreadStoreAuth.js"; +import type { CommentBody } from "../../comments/types.js"; +import { DefaultThreadStoreAuth } from "../../comments/threadstore/DefaultThreadStoreAuth.js"; import { YjsThreadStore } from "./YjsThreadStore.js"; // Mock UUID to generate sequential IDs diff --git a/packages/core/src/comments/threadstore/yjs/YjsThreadStore.ts b/packages/core/src/yjs/comments/YjsThreadStore.ts similarity index 98% rename from packages/core/src/comments/threadstore/yjs/YjsThreadStore.ts rename to packages/core/src/yjs/comments/YjsThreadStore.ts index f9754c6063..b22347139e 100644 --- a/packages/core/src/comments/threadstore/yjs/YjsThreadStore.ts +++ b/packages/core/src/yjs/comments/YjsThreadStore.ts @@ -1,7 +1,11 @@ import { uuidv4 } from "lib0/random"; import * as Y from "yjs"; -import { CommentBody, CommentData, ThreadData } from "../../types.js"; -import { ThreadStoreAuth } from "../ThreadStoreAuth.js"; +import type { + CommentBody, + CommentData, + ThreadData, +} from "../../comments/types.js"; +import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js"; import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js"; import { commentToYMap, diff --git a/packages/core/src/comments/threadstore/yjs/YjsThreadStoreBase.ts b/packages/core/src/yjs/comments/YjsThreadStoreBase.ts similarity index 84% rename from packages/core/src/comments/threadstore/yjs/YjsThreadStoreBase.ts rename to packages/core/src/yjs/comments/YjsThreadStoreBase.ts index 331fbac3ce..29019f43e1 100644 --- a/packages/core/src/comments/threadstore/yjs/YjsThreadStoreBase.ts +++ b/packages/core/src/yjs/comments/YjsThreadStoreBase.ts @@ -1,7 +1,7 @@ import * as Y from "yjs"; -import { ThreadData } from "../../types.js"; -import { ThreadStore } from "../ThreadStore.js"; -import { ThreadStoreAuth } from "../ThreadStoreAuth.js"; +import type { ThreadData } from "../../comments/types.js"; +import { ThreadStore } from "../../comments/threadstore/ThreadStore.js"; +import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js"; import { yMapToThread } from "./yjsHelpers.js"; /** diff --git a/packages/core/src/yjs/comments/index.ts b/packages/core/src/yjs/comments/index.ts new file mode 100644 index 0000000000..69e9f87de3 --- /dev/null +++ b/packages/core/src/yjs/comments/index.ts @@ -0,0 +1,3 @@ +export * from "./RESTYjsThreadStore.js"; +export * from "./YjsThreadStore.js"; +export * from "./YjsThreadStoreBase.js"; diff --git a/packages/core/src/comments/threadstore/yjs/yjsHelpers.ts b/packages/core/src/yjs/comments/yjsHelpers.ts similarity index 97% rename from packages/core/src/comments/threadstore/yjs/yjsHelpers.ts rename to packages/core/src/yjs/comments/yjsHelpers.ts index cd90c3e583..0c8d09205d 100644 --- a/packages/core/src/comments/threadstore/yjs/yjsHelpers.ts +++ b/packages/core/src/yjs/comments/yjsHelpers.ts @@ -1,5 +1,9 @@ import * as Y from "yjs"; -import { CommentData, CommentReactionData, ThreadData } from "../../types.js"; +import type { + CommentData, + CommentReactionData, + ThreadData, +} from "../../comments/types.js"; export function commentToYMap(comment: CommentData) { const yMap = new Y.Map(); diff --git a/packages/core/src/yjs/extensions/FixupCreateAndFill.ts b/packages/core/src/yjs/extensions/FixupCreateAndFill.ts new file mode 100644 index 0000000000..bfbc6a7be5 --- /dev/null +++ b/packages/core/src/yjs/extensions/FixupCreateAndFill.ts @@ -0,0 +1,30 @@ +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { Node } from "prosemirror-model"; + +export const FixupCreateAndFillExtension = createExtension(({ editor }) => { + editor.on("create", () => { + // When y-prosemirror creates an empty document, the `blockContainer` node is created with an `id` of `null`. + // This causes the unique id extension to generate a new id for the initial block, which is not what we want + // Since it will be randomly generated & cause there to be more updates to the ydoc + // This is a hack to make it so that anytime `schema.doc.createAndFill` is called, the initial block id is already set to "initialBlockId" + let cache: Node | undefined = undefined; + const oldCreateAndFill = editor.pmSchema.nodes.doc.createAndFill; + editor.pmSchema.nodes.doc.createAndFill = ((...args: any) => { + if (cache) { + return cache; + } + const ret = oldCreateAndFill.apply(editor.pmSchema.nodes.doc, args)!; + + // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) + const jsonNode = JSON.parse(JSON.stringify(ret.toJSON())); + jsonNode.content[0].content[0].attrs.id = "initialBlockId"; + + cache = Node.fromJSON(editor.pmSchema, jsonNode); + return cache; + }) as unknown as typeof editor.pmSchema.nodes.doc.createAndFill; + }); + + return { + key: "fixupCreateAndFill", + } as const; +}); diff --git a/packages/core/src/extensions/Collaboration/ForkYDoc.test.ts b/packages/core/src/yjs/extensions/ForkYDoc.test.ts similarity index 84% rename from packages/core/src/extensions/Collaboration/ForkYDoc.test.ts rename to packages/core/src/yjs/extensions/ForkYDoc.test.ts index 1239dc4530..025e9215da 100644 --- a/packages/core/src/extensions/Collaboration/ForkYDoc.test.ts +++ b/packages/core/src/yjs/extensions/ForkYDoc.test.ts @@ -3,6 +3,7 @@ import * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; import { BlockNoteEditor } from "../../index.js"; import { ForkYDocExtension } from "./ForkYDoc.js"; +import { withCollaboration } from "./index.js"; /** * @vitest-environment jsdom @@ -10,15 +11,17 @@ import { ForkYDocExtension } from "./ForkYDoc.js"; it("can fork a document", async () => { const doc = new Y.Doc(); const fragment = doc.getXmlFragment("doc"); - const editor = BlockNoteEditor.create({ - collaboration: { - fragment, - user: { name: "Hello", color: "#FFFFFF" }, - provider: { - awareness: new Awareness(doc), + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment, + user: { name: "Hello", color: "#FFFFFF" }, + provider: { + awareness: new Awareness(doc), + }, }, - }, - }); + }), + ); try { const div = document.createElement("div"); @@ -61,15 +64,17 @@ it("can fork a document", async () => { it("can merge a document", async () => { const doc = new Y.Doc(); const fragment = doc.getXmlFragment("doc"); - const editor = BlockNoteEditor.create({ - collaboration: { - fragment, - user: { name: "Hello", color: "#FFFFFF" }, - provider: { - awareness: new Awareness(doc), + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment, + user: { name: "Hello", color: "#FFFFFF" }, + provider: { + awareness: new Awareness(doc), + }, }, - }, - }); + }), + ); try { const div = document.createElement("div"); @@ -121,15 +126,17 @@ it("can merge a document", async () => { it("can fork an keep the changes to the original document", async () => { const doc = new Y.Doc(); const fragment = doc.getXmlFragment("doc"); - const editor = BlockNoteEditor.create({ - collaboration: { - fragment, - user: { name: "Hello", color: "#FFFFFF" }, - provider: { - awareness: new Awareness(doc), + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment, + user: { name: "Hello", color: "#FFFFFF" }, + provider: { + awareness: new Awareness(doc), + }, }, - }, - }); + }), + ); try { const div = document.createElement("div"); diff --git a/packages/core/src/extensions/Collaboration/ForkYDoc.ts b/packages/core/src/yjs/extensions/ForkYDoc.ts similarity index 98% rename from packages/core/src/extensions/Collaboration/ForkYDoc.ts rename to packages/core/src/yjs/extensions/ForkYDoc.ts index 84c714f1d3..78143f9c11 100644 --- a/packages/core/src/extensions/Collaboration/ForkYDoc.ts +++ b/packages/core/src/yjs/extensions/ForkYDoc.ts @@ -5,7 +5,7 @@ import { createStore, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; -import { CollaborationOptions } from "./Collaboration.js"; +import type { CollaborationOptions } from "./index.js"; import { YCursorExtension } from "./YCursorPlugin.js"; import { YSyncExtension } from "./YSync.js"; import { YUndoExtension } from "./YUndo.js"; diff --git a/packages/core/src/yjs/extensions/RelativePositionMapping.ts b/packages/core/src/yjs/extensions/RelativePositionMapping.ts new file mode 100644 index 0000000000..82d6139db7 --- /dev/null +++ b/packages/core/src/yjs/extensions/RelativePositionMapping.ts @@ -0,0 +1,46 @@ +import { + absolutePositionToRelativePosition, + relativePositionToAbsolutePosition, + ySyncPluginKey, +} from "y-prosemirror"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +export const RelativePositionMappingExtension = createExtension( + ({ editor }) => { + return { + key: "yPositionMapping", + mapPosition: (position: number, side: "left" | "right" = "left") => { + const ySyncPluginState = ySyncPluginKey.getState( + editor.prosemirrorState, + ); + if (!ySyncPluginState) { + throw new Error("YSync plugin state not found"); + } + const relativePosition = absolutePositionToRelativePosition( + position + (side === "right" ? 1 : -1), + ySyncPluginState.binding.type, + ySyncPluginState.binding.mapping, + ); + + return () => { + const curYSyncPluginState = ySyncPluginKey.getState( + editor.prosemirrorState, + ) as typeof ySyncPluginState; + const pos = relativePositionToAbsolutePosition( + curYSyncPluginState.doc, + curYSyncPluginState.binding.type, + relativePosition, + curYSyncPluginState.binding.mapping, + ); + + // This can happen if the element is garbage collected + if (pos === null) { + throw new Error("Position not found, cannot track positions"); + } + + return pos + (side === "right" ? -1 : 1); + }; + }, + } as const; + }, +); diff --git a/packages/core/src/extensions/Collaboration/YCursorPlugin.ts b/packages/core/src/yjs/extensions/YCursorPlugin.ts similarity index 99% rename from packages/core/src/extensions/Collaboration/YCursorPlugin.ts rename to packages/core/src/yjs/extensions/YCursorPlugin.ts index 7f8d215875..6ae18f80cf 100644 --- a/packages/core/src/extensions/Collaboration/YCursorPlugin.ts +++ b/packages/core/src/yjs/extensions/YCursorPlugin.ts @@ -3,7 +3,7 @@ import { createExtension, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; -import { CollaborationOptions } from "./Collaboration.js"; +import type { CollaborationOptions } from "./index.js"; export type CollaborationUser = { name: string; diff --git a/packages/core/src/extensions/Collaboration/YSync.ts b/packages/core/src/yjs/extensions/YSync.ts similarity index 87% rename from packages/core/src/extensions/Collaboration/YSync.ts rename to packages/core/src/yjs/extensions/YSync.ts index f4641cb41d..69b31953ce 100644 --- a/packages/core/src/extensions/Collaboration/YSync.ts +++ b/packages/core/src/yjs/extensions/YSync.ts @@ -3,7 +3,7 @@ import { ExtensionOptions, createExtension, } from "../../editor/BlockNoteExtension.js"; -import { CollaborationOptions } from "./Collaboration.js"; +import type { CollaborationOptions } from "./index.js"; export const YSyncExtension = createExtension( ({ options }: ExtensionOptions>) => { diff --git a/packages/core/src/extensions/Collaboration/YUndo.ts b/packages/core/src/yjs/extensions/YUndo.ts similarity index 100% rename from packages/core/src/extensions/Collaboration/YUndo.ts rename to packages/core/src/yjs/extensions/YUndo.ts diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json b/packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap-editor-forked.json similarity index 100% rename from packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json rename to packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap-editor-forked.json diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json b/packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap-editor.json similarity index 100% rename from packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json rename to packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap-editor.json diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html b/packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap-forked.html similarity index 100% rename from packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html rename to packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap-forked.html diff --git a/packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html b/packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap.html similarity index 100% rename from packages/core/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html rename to packages/core/src/yjs/extensions/__snapshots__/fork-yjs-snap.html diff --git a/packages/core/src/extensions/Collaboration/Collaboration.ts b/packages/core/src/yjs/extensions/index.ts similarity index 54% rename from packages/core/src/extensions/Collaboration/Collaboration.ts rename to packages/core/src/yjs/extensions/index.ts index 719a7bdc8d..3dfdb1670a 100644 --- a/packages/core/src/extensions/Collaboration/Collaboration.ts +++ b/packages/core/src/yjs/extensions/index.ts @@ -1,10 +1,13 @@ -import type * as Y from "yjs"; import type { Awareness } from "y-protocols/awareness"; +import type * as Y from "yjs"; +import type { BlockNoteEditorOptions } from "../../editor/BlockNoteEditor"; import { createExtension, - ExtensionOptions, + type ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; +import { FixupCreateAndFillExtension } from "./FixupCreateAndFill.js"; import { ForkYDocExtension } from "./ForkYDoc.js"; +import { RelativePositionMappingExtension } from "./RelativePositionMapping.js"; import { SchemaMigration } from "./schemaMigration/SchemaMigration.js"; import { YCursorExtension } from "./YCursorPlugin.js"; import { YSyncExtension } from "./YSync.js"; @@ -44,12 +47,45 @@ export const CollaborationExtension = createExtension( return { key: "collaboration", blockNoteExtensions: [ + FixupCreateAndFillExtension(), ForkYDocExtension(options), + RelativePositionMappingExtension(), + SchemaMigration(options), YCursorExtension(options), YSyncExtension(options), YUndoExtension(), - SchemaMigration(options), ], } as const; }, ); + +export function withCollaboration< + Options extends Partial>, +>( + options: Options & { + /** + * Options for configuring the collaboration functionality. + */ + collaboration: CollaborationOptions; + }, +): Options { + return { + ...options, + extensions: [ + ...(options.extensions ?? []), + CollaborationExtension(options.collaboration), + ], + // We disable the default prosemirror history plugin, since it's not compatible with yjs + disableExtensions: ["history", ...(options.disableExtensions ?? [])], + // We don't want the default initial content, since it will generate a random id for the initial block on each client, + // leading to conflicts when syncing happens afterwards. + initialContent: [{ type: "paragraph", id: "initialBlockId" }], + }; +} + +export * from "./ForkYDoc.js"; +export * from "./RelativePositionMapping.js"; +export * from "./schemaMigration/SchemaMigration.js"; +export * from "./YCursorPlugin.js"; +export * from "./YSync.js"; +export * from "./YUndo.js"; diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigration.ts b/packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts similarity index 100% rename from packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigration.ts rename to packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/index.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts similarity index 100% rename from packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/index.ts rename to packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/migrationRule.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts similarity index 100% rename from packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/migrationRule.ts rename to packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.test.ts similarity index 100% rename from packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts rename to packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.test.ts diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.ts similarity index 100% rename from packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.ts rename to packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.ts diff --git a/packages/core/src/yjs/index.ts b/packages/core/src/yjs/index.ts index 05c69e3c01..a9186c5fae 100644 --- a/packages/core/src/yjs/index.ts +++ b/packages/core/src/yjs/index.ts @@ -1 +1,3 @@ export * from "./utils.js"; +export * from "./extensions/index.js"; +export * from "./comments/index.js"; diff --git a/packages/xl-ai/src/AIExtension.ts b/packages/xl-ai/src/AIExtension.ts index cc6f3c2a20..940a7e7066 100644 --- a/packages/xl-ai/src/AIExtension.ts +++ b/packages/xl-ai/src/AIExtension.ts @@ -6,10 +6,8 @@ import { getNodeById, UnreachableCaseError, } from "@blocknote/core"; -import { - ForkYDocExtension, - ShowSelectionExtension, -} from "@blocknote/core/extensions"; +import { ShowSelectionExtension } from "@blocknote/core/extensions"; +import type { ForkYDocExtension } from "@blocknote/core/yjs"; import { applySuggestions, revertSuggestions, @@ -220,7 +218,9 @@ export const AIExtension = createExtension( }); // If in collaboration mode, merge the changes back into the original yDoc - editor.getExtension(ForkYDocExtension)?.merge({ keepChanges: true }); + editor + .getExtension("yForkDoc") + ?.merge({ keepChanges: true }); this.closeAIMenu(); }, @@ -238,7 +238,9 @@ export const AIExtension = createExtension( }); // If in collaboration mode, discard the changes and revert to the original yDoc - editor.getExtension(ForkYDocExtension)?.merge({ keepChanges: false }); + editor + .getExtension("yForkDoc") + ?.merge({ keepChanges: false }); this.closeAIMenu(); }, @@ -379,7 +381,8 @@ export const AIExtension = createExtension( */ async invokeAI(opts: InvokeAIOptions) { this.setAIResponseStatus("thinking"); - editor.getExtension(ForkYDocExtension)?.fork(); + // If in collaboration mode, fork the yDoc to allow modifications without affecting the remote + editor.getExtension("yForkDoc")?.fork(); try { // Create a new AbortController for this request