Skip to content

Commit

Permalink
Move to appendTransaction based architecture
Browse files Browse the repository at this point in the history
Problem: The current architecture which always replaces an incoming ProseMirror transaction with a new transaction breaks many ProseMirror plugins - including the history plugin.

Solution: Instead of replacing the incoming transaction, just apply it to the automerge document and then check that the transaction we would have produced matches the incoming transaction. If the transactions don't match then a) we append a change which fixes up the incoming transaction without breaking plugins and b) log an error because it means we've done something wron.
  • Loading branch information
BrianHung authored Oct 3, 2024
1 parent 08612db commit 1635ee8
Show file tree
Hide file tree
Showing 22 changed files with 412 additions and 441 deletions.
77 changes: 13 additions & 64 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,25 @@ There is a fully functional editor in this repository, you can play with that by

## Example

The API for this library is based around an object called an `AutoMirror`. This object is used to intercept transactions from Prosemirror and to handle changes received over the network. This is best used in tandem with `@automerge/automerge-repo`. See the `playground/src/Editor.tsx` file for a fully featured example.
The API for this library is based around `syncPlugin`. This plugin is used to apply transactions from Prosemirror and to handle changes received over the network. This is best used in tandem with `@automerge/automerge-repo`. See the `playground/src/Editor.tsx` file for a fully featured example.

In order to edit rich text we need to know how to map from the [rich text schema](https://automerge.org/docs/under-the-hood/rich_text_schema/) to the ProseMirror schema you're using. This is done with a `SchemaAdapter`. We provide a built in `basicSchemaAdapter` which adapts the basic example schema which ships with ProseMirror, but you can provide your own as well.

The workflow when using this library is to first create an `AutoMirror` object, then use `AutoMirror.initialize` to create an initial prosemirror document and `AutoMirror.schema` to get a schema which you pass to prosemirror. Then, you intercept transactions from prosemirror using `AutoMirror.intercept` and you reconcile patches from the network using `AutoMirror.reconcilePatch`.

For example
Example setup

```javascript
import {AutoMirror, basicSchemaAdapter} from "@automerge/prosemirror"

//
import {basicSchemaAdapter, syncPlugin, pmDocFromSpans} from "@automerge/prosemirror"
import { next as am } from "@automerge/automerge"

const handle = repo.find("some-doc-url")
// somehow wait for the handle to be ready before continuing
await handle.whenReady()

// Create an AutoMirror
const autoMirror = new AutoMirror(["text"], basicSchemaAdapter)
const adapter = basicSchemaAdapter

// Create your prosemirror state
let editorConfig = {
schema: autoMirror.schema, // This _must_ be the schema from the AutoMirror
..., // whatever other stuff
schema: adapter.schema,
plugins: [
keymap({
...baseKeymap,
Expand All @@ -45,36 +40,20 @@ let editorConfig = {
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
syncPlugin({
adapter,
handle,
path: ["text"]
})
],
doc: autoMirror.initialize(handle.docSync())
doc: pmDocFromSpans(adapter, am.spans(handle.docSync()!, ["text"]))
}

let state = EditorState.create(editorConfig)


const view = new EditorView(<whatever DOM element you are rendering to>, {
state,
dispatchTransaction: (tx: Transaction) => {
// Here we intercept the transaction
let newState = autoMirror.intercept(automerge.getHeads(handle.doc), tx, view.state)
view.updateState(newState)
}
state
})

// This is a callback which you wire up to be called anytime there are changes
// received from elsewhere. The type signature here assuems you're using
// automerge-repo
const onPatch = (p: DocHandlePatchPayload<any>) => {
const newState = autoMirror.reconcilePatch(
patchInfo.before,
doc,
patches,
view.state,
)
view.updateState(newState)
}
// somehow wire up the callback
handle.on("change", onPatch)
```

## Schema Mapping
Expand Down Expand Up @@ -266,36 +245,6 @@ nodes: {

## API

### `AutoMirror`

#### `new AutoMirror<T>(path: (string | number)[], schemaAdapter: SchemaAdapter<T>)`

Create a new `AutoMirror`. The `path` argument is the path in the automerge document where the text containing the rich text that is to be edited should be. For example, if your document has this type:

```typescript
type Doc = {
content: string
}
```
Then you would pass `["text"]` to the `AutoMirror` constructor.
#### `AutoMirror.initialize(doc: DocHandle<unknown>): Node`
Create a new ProseMirror document from the given automerge document. This is the document you should pass to ProseMirror when creating a new editor.
#### `AutoMirror.schema: Schema`
The ProseMirror schema corresponding that should be used to initialize the ProseMirror editor.
#### `AutoMirror.intercept(handle: DocHandle<unknown>, intercepted: Transaction, state: EditorState): EditorState`
This function should be called inside `dispatchTransaction` in your ProseMirror editor. The incoming transaction is intercepted and applied to the automerge document and a new `EditorState` is returned which should be used to update the editor view.
#### `AutoMirror.reconcilePatch(before: Doc<unknown>, docAfter: Doc<unknown>, patches: Patch[], state: EditorState): EditorState`
This function shoul be called whenever a patch is received from the network. Typically you would call this in the `DocHandle.on("change", callback)` callback.
### `SchemaAdapter`

A `SchemaAdapter` provides the mapping between a ProseMirror Schema and the block markers you are using in the automerge document. The part of this API to understand is the specification which you pass to the `SchemaAdapter` constructor.
Expand Down
45 changes: 15 additions & 30 deletions examples/react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { AutomergeUrl, DocHandleChangePayload } from "@automerge/automerge-repo"
import { AutomergeUrl } 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 {
syncPlugin,
pmDocFromSpans,
basicSchemaAdapter,
} from "@automerge/prosemirror"
import { next as am } from "@automerge/automerge"
import "prosemirror-example-setup/style/style.css"
import "prosemirror-menu/style/menu.css"
import "prosemirror-view/style/prosemirror.css"
Expand All @@ -25,46 +30,26 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) {
}, [handle])

useEffect(() => {
const mirror = new AutoMirror(["text"])
const adapter = basicSchemaAdapter
let view: EditorView
const onPatch: (args: DocHandleChangePayload<unknown>) => void = ({
doc,
patches,
patchInfo,
}) => {
//console.log(`${name}: patch received`)
const newState = mirror.reconcilePatch(
patchInfo.before,
doc,
patches,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
view!.state,
)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
view!.updateState(newState)
}
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 }),
schema: adapter.schema, // It's important that we use the schema from the mirror
plugins: [
...exampleSetup({ schema: adapter.schema }),
syncPlugin({ adapter, handle, path: ["text"] }),
],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
doc: mirror.initialize(handle!),
doc: pmDocFromSpans(adapter, am.spans(handle.docSync()!, ["text"])),
}),
dispatchTransaction: (tx: Transaction) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newState = mirror.intercept(handle!, tx, view!.state)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
view!.updateState(newState)
view!.updateState(view.state.apply(tx))
},
})
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handle!.on("change", onPatch)
}
return () => {
if (handle != null) {
handle.off("change", onPatch)
}
if (view != null) {
view.destroy()
}
Expand Down
30 changes: 14 additions & 16 deletions examples/vanilla/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { EditorState, Transaction } from "prosemirror-state"
import { EditorView } from "prosemirror-view"
import { exampleSetup } from "prosemirror-example-setup"
import { AutoMirror } from "@automerge/prosemirror"
import {
syncPlugin,
basicSchemaAdapter,
pmDocFromSpans,
} from "@automerge/prosemirror"
import { DocHandle, Repo, isValidAutomergeUrl } from "@automerge/automerge-repo"
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"
import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"
import { next as am } from "@automerge/automerge"
import "prosemirror-example-setup/style/style.css"
import "prosemirror-menu/style/menu.css"
import "prosemirror-view/style/prosemirror.css"
Expand All @@ -28,25 +33,18 @@ if (docUrl && isValidAutomergeUrl(docUrl)) {
}
await handle.whenReady()

const mirror = new AutoMirror(["text"])
const adapter = basicSchemaAdapter

const view = new EditorView(document.querySelector("#editor"), {
state: EditorState.create({
doc: mirror.initialize(handle),
plugins: exampleSetup({ schema: mirror.schema }),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
doc: pmDocFromSpans(adapter, am.spans(handle.docSync()!, ["text"])),
plugins: [
...exampleSetup({ schema: adapter.schema }),
syncPlugin({ adapter, handle, path: ["text"] }),
],
}),
dispatchTransaction: (tx: Transaction) => {
const newState = mirror.intercept(handle, tx, view.state)
view.updateState(newState)
view.updateState(view.state.apply(tx))
},
})

handle.on("change", d => {
const newState = mirror.reconcilePatch(
d.patchInfo.before,
d.doc,
d.patches,
view.state,
)
view.updateState(newState)
})
24 changes: 16 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"dependencies": {
"@automerge/automerge": "2.2.1",
"ordered-map": "^0.1.0",
"prosemirror-changeset": "^2.2.1",
"prosemirror-history": "^1.4.1",
"prosemirror-model": "^1.19.4",
"prosemirror-schema-basic": "^1.2.2",
"prosemirror-schema-list": "^1.3.0",
Expand Down
Loading

0 comments on commit 1635ee8

Please sign in to comment.