Skip to content

Commit

Permalink
Richtext docs (#74)
Browse files Browse the repository at this point in the history
* Add cookbook entry for richtext

* Add a cookbook entry for prosemirror+vanilla JS
  • Loading branch information
alexjg authored May 6, 2024
1 parent 6a2ed16 commit 7919377
Show file tree
Hide file tree
Showing 4 changed files with 343 additions and 8 deletions.
241 changes: 241 additions & 0 deletions docs/cookbook/rich-text-prosemirror-react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
---
sidebar_position: 2
---

# Prosemirror + React + Automerge

Automerge supports rich text editing on top of [ProseMirror](https://prosemirror.net/). 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.

```bash
yarn create @automerge/vite-app
```

Then install our prosemirror dependencies


```bash
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:

```jsx title="src/main.tsx"
...
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:

```jsx title="src/App.tsx"
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.

```jsx title="src/App.tsx"
import { AutomergeUrl } from "@automerge/automerge-repo"
import { useHandle } from "@automerge/automerge-repo-react-hooks"
// highlight-start
import { useEffect, useRef, useState } from "react"
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {exampleSetup} from "prosemirror-example-setup"
import { AutoMirror } 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"
// highlight-end

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])

// highlight-start
const [view, setView] = useState<EditorView | null>(null)
useEffect(() => {
// We're not using this for anything yet, but this `AutoMirror` object is
// where we will integrate prosemirror with automerge
const mirror = new AutoMirror(["text"])
if (editorRoot.current != null && loaded) {
const view = new EditorView(editorRoot.current, {
state: EditorState.create({
schema: mirror.schema, // It's important that we use the schema from the mirror
plugins: exampleSetup({schema: mirror.schema}),
doc: mirror.initialize(handle!, ["text"])
}),
})
setView(view)
}
return () => {
if (view) {
view.destroy()
setView(null)
}
}
}, [editorRoot, loaded])
// highlight-end

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:

```css title="src/App.css"
#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.

Update `src/App.tsx` with the following changes:

```jsx title="src/App.tsx"
import { AutomergeUrl, DocHandleChangePayload } from "@automerge/automerge-repo"
import { useHandle } from "@automerge/automerge-repo-react-hooks"
import { useEffect, useRef, useState } from "react"
import {EditorState, Transaction} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {exampleSetup} from "prosemirror-example-setup"
import { AutoMirror } 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(() => {
// We're not using this for anything yet, but this `AutoMirror` object is
// where we will integrate prosemirror with automerge
const mirror = new AutoMirror(["text"])
// highlight-start
let view: EditorView // We need a forward reference to use next
// This is a callback which will update the prosemirror view whenever the document changes
const onPatch: (args: DocHandleChangePayload<unknown>) => void = ({
doc,
patches,
patchInfo,
}) => {
const newState = mirror.reconcilePatch(
patchInfo.before,
doc,
patches,
view!.state,
)
view!.updateState(newState)
}
// highlight-end
if (editorRoot.current != null && loaded) {
view = new EditorView(editorRoot.current, {
state: EditorState.create({
schema: mirror.schema, // It's important that we use the schema from the mirror
plugins: exampleSetup({schema: mirror.schema}),
doc: mirror.initialize(handle!, ["text"]),
}),
// highlight-start
// Here we're intercepting the prosemirror transaction and feeding it through the AutoMirror
dispatchTransaction: (tx: Transaction) => {
const newState = mirror.intercept(handle!, tx, view!.state)
view!.updateState(newState)
},
// highlight-end
})
setView(view)
// highlight-next-line
handle!.on("change", onPatch)
}
return () => {
// highlight-start
// we have to remove the listener when tearing down
if (handle != null) {
handle.off("change", onPatch)
}
// highlight-end
setView(null)
if (view != null) {
view.destroy()
}
}
}, [editorRoot, loaded])

return <div id="editor" ref={editorRoot}></div>
}

export default App
```

Now, 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.
86 changes: 86 additions & 0 deletions docs/cookbook/rich-text-prosemirror-vanilla.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
sidebar_position: 3
---

# Prosemirror + VanillaJS + Automerge

Automerge supports rich text using [ProseMirror](https://prosemirror.net/). This guide will show you how to set up a simple collaborative rich text editor in a vanilla JS app; where "vanilla" means plain JavaScript without any frameworks or libraries.

We _do_ need a bundler in order to use Automerge, so we'll assume you have set up something like Vite and that you have two files, `index.html` and `main.js`.

First, put the following in `index.html`

```html title="index.html"
<!doctype html>
<html lang="en">
<head>
<title>Prosemirror + Automerge</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>
```

First, we need to get `automerge-repo` set up:

```js title="main.js"
import { DocHandle, Repo, isValidAutomergeUrl } from "@automerge/automerge-repo"
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"
import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"

const repo = new Repo({
storage: new IndexedDBStorageAdapter("automerge"),
network: [new BrowserWebSocketClientAdapter("wss://sync.automerge.org")],
})
```

Now, we'll store the automerge URL for the document we are editing in the browsers URL hash. This way, we can share the URL with others to collaborate on the document.

```js title="main.js"
// Get the document ID from the URL fragment if it's there. Otherwise, create
// a new document and update the URL fragment to match.
const docUrl = window.location.hash.slice(1)
if (docUrl && isValidAutomergeUrl(docUrl)) {
handle = repo.find(docUrl)
} else {
handle = repo.create({ text: "" })
window.location.hash = handle.url
}
// Wait for the handle to be available
await handle.whenReady()
```

At this point we have a document handle with a fully loaded automerge document, now we need to wire up a prosemirror editor.

```js title="main.js"
// This is the integration with automerge.
const mirror = new AutoMirror(["text"])

// This is the prosemirror editor.
const view = new EditorView(document.querySelector("#editor"), {
state: EditorState.create({
doc: mirror.initialize(handle), // Note that we initialize using the mirror
plugins: exampleSetup({ schema: mirror.schema }), // We _must_ use the schema from the mirror
}),
// Here we intercept the transaction and apply it to the automerge document
dispatchTransaction: (tx) => {
const newState = mirror.intercept(handle, tx, view.state)
view.updateState(newState)
},
})

// If changes arrive from elsewhere, update the prosemirror state and view
handle.on("change", d => {
const newState = mirror.reconcilePatch(
d.patchInfo.before,
d.doc,
d.patches,
view.state,
)
view.updateState(newState)
})
```

Now, you can open `index.html` in your browser and start editing the document. If you open the same URL in another browser window, you should see the changes you make in one window reflected in the other.
12 changes: 4 additions & 8 deletions src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,18 @@
--ifm-navbar-link-hover-color: inherit;
--ifm-hero-background-color: #efefef;
--ifm-hero-text-color: inherit;
--docusaurus-highlighted-code-line-bg: rgb(226, 231, 244);
}

:root[data-theme='dark'] {
--ifm-color-content: #e3e3e3;
--ifm-hero-background-color: #2e2e2e;
}

.docusaurus-highlight-code-line {
background-color: rgba(0, 0, 0, 0.1);
display: block;
margin: 0 calc(-1 * var(--ifm-pre-padding));
padding: 0 var(--ifm-pre-padding);
--docusaurus-highlighted-code-line-bg: rgb(100, 100, 100);
}

html[data-theme='dark'] .docusaurus-highlight-code-line {
background-color: rgba(0, 0, 0, 0.3);
/* Color which works with dark mode syntax highlighting theme */
--docusaurus-highlighted-code-line-bg: rgb(100, 100, 100);
}

.footer {
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,18 @@
lru-cache "^5.1.1"
semver "^6.3.1"

"@babel/helper-create-class-features-plugin@^7.16.0":
version "7.16.0"
resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.16.0.tgz"
integrity sha512-XLwWvqEaq19zFlF5PTgOod4bUA+XbkR4WLQBct1bkzmxJGB0ZEJaoKF4c8cgH9oBtCDuYJ8BP5NB9uFiEgO5QA==
dependencies:
"@babel/helper-annotate-as-pure" "^7.16.0"
"@babel/helper-function-name" "^7.16.0"
"@babel/helper-member-expression-to-functions" "^7.16.0"
"@babel/helper-optimise-call-expression" "^7.16.0"
"@babel/helper-replace-supers" "^7.16.0"
"@babel/helper-split-export-declaration" "^7.16.0"

"@babel/helper-create-class-features-plugin@^7.22.11", "@babel/helper-create-class-features-plugin@^7.22.5":
version "7.22.11"
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.11.tgz#4078686740459eeb4af3494a273ac09148dfb213"
Expand Down

0 comments on commit 7919377

Please sign in to comment.