Prosemirror + React + Automerge
Automerge supports rich text editing on top of ProseMirror. This guide will show you how to set up a simple collaborative rich text editor in React using Automerge and ProseMirror.
All the code here can be found at https://github.com/automerge/automerge-prosemirror/examples/react
First, create a an example vite app using the @automerge/vite-app
template. This will give you a basic React app with the Automerge dependencies already installed.
yarn create @automerge/vite-app
Then install our prosemirror dependencies
yarn add @automerge/prosemirror prosemirror-example-setup prosemirror-model prosemirror-state prosemirror-view
Now, the app created by @automerge/vite-app
creates a document which contains a Counter
, but we want a string
which will contain the text. Modify main.tsx
so that the handle initialization logic looks like this:
...
let handle
if (isValidAutomergeUrl(rootDocUrl)) {
handle = repo.find(rootDocUrl)
} else {
handle = repo.create({text: ""})
}
...
First, let's create a basic skeleton component which just loads the document handle. The prosemirror bindings require that the document handle be loaded before we begin, so we'll add a bit of boilerplate to achieve this:
import { AutomergeUrl } from "@automerge/automerge-repo"
import { useHandle } from "@automerge/automerge-repo-react-hooks"
import { useEffect, useState } from "react"
function App({ docUrl }: { docUrl: AutomergeUrl }) {
const handle = useHandle<{text: string}>(docUrl)
const [loaded, setLoaded] = useState(handle && handle.docSync() != null)
useEffect(() => {
if (handle != null) {
handle.whenReady().then(() => {
if (handle.docSync() != null) {
setLoaded(true)
}
})
}
}, [handle])
return <div id="editor"></div>
}
export default App
Now, we're going to create a ProseMirror editor. Prosemirror manages its own UI and state, it just needs to be attached to the DOM somehow. To achieve this we'll use the useRef
hook to get hold of a reference to a dom element inside a React component which we can pass to prosemirror.
import { AutomergeUrl } from "@automerge/automerge-repo"
import { useHandle } from "@automerge/automerge-repo-react-hooks"
import { useEffect, useRef, useState } from "react"
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {exampleSetup} from "prosemirror-example-setup"
import { init } from "@automerge/prosemirror"
import "prosemirror-example-setup/style/style.css"
import "prosemirror-menu/style/menu.css"
import "prosemirror-view/style/prosemirror.css"
import "./App.css"
function App({ docUrl }: { docUrl: AutomergeUrl }) {
const editorRoot = useRef<HTMLDivElement>(null)
const handle = useHandle<{text: string}>(docUrl)
const [loaded, setLoaded] = useState(handle && handle.docSync() != null)
useEffect(() => {
if (handle != null) {
handle.whenReady().then(() => {
if (handle.docSync() != null) {
setLoaded(true)
}
})
}
}, [handle])
const [view, setView] = useState<EditorView | null>(null)
useEffect(() => {
if (editorRoot.current != null && loaded) {
// This is the integration with automerge
const { pmDoc: doc, schema, plugin } = init(handle!, ["text"])
const plugins = exampleSetup({schema})
plugins.push(plugin)
const view = new EditorView(editorRoot.current, {
state: EditorState.create({
schema,
plugins,
doc,
}),
})
setView(view)
}
return () => {
if (view) {
view.destroy()
setView(null)
}
}
}, [editorRoot, loaded])
return <div id="editor" ref={editorRoot}></div>
}
export default App
At this point if you run the application you'll find that there's a working prosemirror editor but it looks rubbish. Add the following to src/App.css
and things will look a lot better:
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
display:flex;
flex-direction: column;
width: 100%;
height: 100vh;
}
/* center the editor inside the #root */
#editor {
margin: 0 auto;
width: 100%;
max-width: 800px;
flex: 1;
background-color: #f8f9fa;
color: #333;
}
Alright, now we're ready to collaborate, you can load up the app in a different tab, or a different browser (the URL will contain a document URL after the #
), and you can see changes being merged from one side to the other.