npm install @convex-dev/prosemirror-sync
Add a collaborative editor that syncs to the cloud. With this component, users can edit the same document in multiple tabs or devices, and the changes will be synced to the cloud. The data lives in your Convex database, and can be stored alongside the rest of your app's data.
Just configure your editor features, add this component to your Convex backend, and use the provided sync React hook. Read this Stack post for more details.
Example usage, see below for more details:
function CollaborativeEditor() {
const sync = useBlockNoteSync(api.prosemirrorSync, "some-id");
return sync.isLoading ? (
<p>Loading...</p>
) : sync.editor ? (
<BlockNoteView editor={sync.editor} />
) : (
<button onClick={() => sync.create(EMPTY_DOC)}>Create document</button>
);
}
For the editor, you can choose to use Tiptap or BlockNote.
Features: Features:
See below for future feature ideas and CONTRIBUTING.md for how to contribute. Found a bug? Feature request? File it here.
You'll need an existing Convex project to use the component. Convex is a hosted backend platform, including a database, serverless functions, and a ton more you can learn about here.
Run npm create convex
or follow any of the quickstarts to set one up.
Install the component package:
npm install @convex-dev/prosemirror-sync
Create a convex.config.ts
file in your app's convex/
folder and install the component by calling use
:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import prosemirrorSync from "@convex-dev/prosemirror-sync/convex.config";
const app = defineApp();
app.use(prosemirrorSync);
export default app;
To use the component, you expose the API in a file in your convex/
folder, and
use the editor-specific sync React hook, passing in a reference to
the API you defined. For this example, we'll create the API in
convex/example.ts
.
// convex/example.ts
import { components } from "./_generated/api";
import { ProsemirrorSync } from "@convex-dev/prosemirror-sync";
const prosemirrorSync = new ProsemirrorSync(components.prosemirrorSync);
export const {
getSnapshot,
submitSnapshot,
latestVersion,
getSteps,
submitSteps,
} = prosemirrorSync.syncApi({
// ...
});
In your React components, you can then use the editor-specific hook to fetch the
document and keep it in sync via a Tiptap extension. Note: This requires a
ConvexProvider
to be in the component tree.
// src/MyComponent.tsx
import { useBlockNoteSync } from "@convex-dev/prosemirror-sync/blocknote";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { api } from "../convex/_generated/api";
export function MyComponent() {
const sync = useBlockNoteSync(api.example, "some-id");
return sync.isLoading ? (
<p>Loading...</p>
) : sync.editor ? (
<BlockNoteView editor={sync.editor} />
) : (
<button onClick={() => sync.create({ type: "doc", content: [] })}>
Create document
</button>
);
}
In your React components, you can use the useTiptapSync
hook to fetch the
initial document and keep it in sync via a Tiptap extension. Note: This
requires a
ConvexProvider
to be in the component tree.
// src/MyComponent.tsx
import { useTiptapSync } from "@convex-dev/prosemirror-sync/tiptap";
import { EditorContent, EditorProvider } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { api } from "../convex/_generated/api";
export function MyComponent() {
const sync = useTiptapSync(api.example, "some-id");
return sync.isLoading ? (
<p>Loading...</p>
) : sync.initialContent !== null ? (
<EditorProvider
content={sync.initialContent}
extensions={[StarterKit, sync.extension]}
>
<EditorContent editor={null} />
</EditorProvider>
) : (
<button onClick={() => sync.create({ type: "doc", content: [] })}>
Create document
</button>
);
}
See a working example in example.ts and App.tsx.
The snapshot debounce interval is set to one second by default.
You can specify a different interval with the snapshotDebounceMs
option when
calling useTiptapSync
or useBlockNoteSync
.
A snapshot won't be sent until both of these are true:
There can be races, but since each client will submit the snapshot for their own change, they won't conflict with each other and are safe to apply.
You can create a new document from the client by calling sync.create(content)
, or on the server by calling prosemirrorSync.create(ctx, id, content)
.
The content should be a JSON object matching the
Schema. If you're using
BlockNote, it needs to be the ProseMirror JSON representation of the BlockNote
blocks. Look at the value stored in the snapshots
table in your database for
an example. Both can use this value: { type: "doc", content: [] }
For client-side document creation:
!sync.isLoading
), you can choose to call it while offline with a newly
created ID to start editing a new document before you reconnect.You can transform the document server-side. It will give you the latest version of the document, and you return a ProseMirror Transform.
You can make this transoform via new Transform(doc)
or, if you are hydrating a
full EditorState
, you can use Editor.create({doc}).tr
to create a new
Transaction
(which is a subclass of Transform
).
For example:
import { getSchema } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
export const transformExample = action({
args: {
id: v.string(),
},
handler: async (ctx, { id }) => {
const schema = getSchema(extensions);
await prosemirrorSync.transform(ctx, id, schema, (doc) => {
const tr = EditorState.create({ doc }).tr;
tr.insertText("Hello, world!", 0);
return tr;
});
},
});
extensions
should be the same as the ones used by your client editor,
for any extensions that affect the schema (not the sync extension).transform
function can be called multiple times if the document is
being modified concurrently. Ideally this callback doesn't do any slow
operations internally. Instead, do them beforehand.doc
may differ from the one returned from getDoc
. You can compare
the version
returned from getDoc
to the second argument to the transform
function to see if the document has changed.transform
function can return a null value to abort making changes.(component).lib.getSteps
.import { Transform } from "@tiptap/pm/transform";
// An example of doing slower AI operations beforehand:
const schema = getSchema(extensions);
const { doc, version } = await prosemirrorSync.getDoc(ctx, id, schema);
const content = await generateAIContent(doc);
await prosemirrorSync.transform(ctx, id, schema, (doc, v) => {
if (v !== version) {
// Decide what to do if the document changes.
}
// An example of using Transform directly.
const tr = new Transform(doc);
tr.insert(0, schema.text(content));
return tr;
});
Features that could be added later:
sessionStorage
and sync when back online (only for active browser tab).
localStorage
so new tabs
can see and edit documents offline (but won't see edits from other tabs
until they're back online).Missing features that aren't currently planned: