Skip to content

Commit

Permalink
Refactor initialization to make it easier to get started
Browse files Browse the repository at this point in the history
Problem: The initialization of this library requires a lot of
familiarity with the various moving parts. This makes it difficult to
get started. 

Solution: Add an init function which gives users the three things they
need: a schema, a prosemirror document, and a plugin. These things can
be passed straight to the Editor component without needing to understand
how they all fit together.
  • Loading branch information
alexjg committed Oct 4, 2024
1 parent 324e34a commit 4e8fe60
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 37 deletions.
36 changes: 19 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,23 @@ There is a fully functional editor in this repository, you can play with that by

## 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.

Example setup
This library provides a plugin which maps between Automerge documents and ProseMirror documents. This plugin relies on two things: firstly that the schema you use be a very specific subset of the ProseMirror schema which is mapped to the Automerge rich text schema (more on this later), and secondly that you initialize the ProseMirror document from the Automerge document. Thus, we provide a simple entrypoint which does all these things for you:

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

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

const adapter = basicSchemaAdapter
// This is the important part, we initialize the plugin with the handle and the path to the text field in the document
// and we get back a schema, a ProseMirror document, and a plugin
const { schema, pmDoc, plugin } = init(handle, ["text"])

// Create your prosemirror state
// Create your prosemirror state with the schema, plugin, and document
let editorConfig = {
schema: adapter.schema,
schema,
plugins: [
keymap({
...baseKeymap,
Expand All @@ -40,13 +38,9 @@ let editorConfig = {
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
syncPlugin({
adapter,
handle,
path: ["text"]
})
plugin,
],
doc: pmDocFromSpans(adapter, am.spans(handle.docSync()!, ["text"]))
doc: pmDoc
}

let state = EditorState.create(editorConfig)
Expand All @@ -56,6 +50,8 @@ const view = new EditorView(<whatever DOM element you are rendering to>, {
})
```

See the `playground/src/Editor.tsx` file for a fully featured example.

## Schema Mapping

ProseMirror documents have a schema, which determines the kinds of nodes and marks which are allowed in the document. In order to map between the block markers and marks in the Automerge document and the ProseMirror document you must create a `SchemaAdapter`. The argument to the `SchemaAdapter` is the same as the `{nodes, marks}` object you would use to create a `ProseMirror` schema, but the `nodes` and `marks` objects have an additional optional key called `automerge`, which configures how the automerge document is mapped to the ProseMirror document.
Expand Down Expand Up @@ -88,6 +84,12 @@ const adapter = new SchemaAdapter({
})
```

This schema adapter can then be passed to `init` as an option:

```typescript
const { pmDoc, schema, plugin } = init(handle, ["text"], { schemaAdapter: adapter })
```

There are a number of keys available in the `automerge` mapping. To understand what they all mean you need to understand the goals of schema mapping:

- Converting between automerge blocks and ProseMirror nodes
Expand Down
36 changes: 17 additions & 19 deletions playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
emDash,
} from "prosemirror-inputrules"
import "prosemirror-view/style/prosemirror.css"
import { Prop, next as am } from "@automerge/automerge"
import { pmDocFromSpans, SchemaAdapter } from "../../src/index.js"
import { Prop } from "@automerge/automerge"
import { init, SchemaAdapter } from "../../src/index.js"
import { DocHandle } from "@automerge/automerge-repo"
import {
wrapInList,
Expand Down Expand Up @@ -53,7 +53,6 @@ import {
import Modal from "./Modal.js"
import ImageForm from "./ImageForm.js"
import LinkForm from "./LinkForm.js"
import { syncPlugin } from "../../src/syncPlugin.js"

export type EditorProps = {
name?: string
Expand Down Expand Up @@ -115,40 +114,39 @@ export function Editor({ handle, path, schemaAdapter }: EditorProps) {
return
}

const adapter = schemaAdapter
const doc = pmDocFromSpans(adapter, am.spans(handle.docSync()!, path))
const {
schema,
pmDoc,
plugin: syncPlugin,
} = init(handle, path, { schemaAdapter })
const state = EditorState.create({
schema: adapter.schema,
schema,
plugins: [
buildInputRules(adapter.schema),
buildInputRules(schema),
history(),
keymap({ "Mod-z": undo, "Mod-y": redo, "Shift-Mod-z": redo }),
keymap({
"Mod-b": toggleBold(adapter.schema),
"Mod-i": toggleItalic(adapter.schema),
"Mod-l": toggleMark(adapter.schema.marks.link, {
"Mod-b": toggleBold(schema),
"Mod-i": toggleItalic(schema),
"Mod-l": toggleMark(schema.marks.link, {
href: "https://example.com",
title: "example",
}),
Enter: splitListItem(adapter.schema.nodes.list_item),
Enter: splitListItem(schema.nodes.list_item),
}),
keymap(buildKeymap(adapter.schema)),
keymap(buildKeymap(schema)),
keymap(baseKeymap),
syncPlugin({
adapter: adapter,
handle,
path,
}),
syncPlugin,
],
doc,
doc: pmDoc,
})

const editorView = new EditorView(editorRoot.current, {
state,
dispatchTransaction(this: EditorView, tr: Transaction) {
const newState = this.state.apply(tr)
this.updateState(newState)
setMarkState(activeMarks(newState, adapter.schema))
setMarkState(activeMarks(newState, schema))
},
})

Expand Down
78 changes: 77 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
export { type DocHandle } from "./types.js"
import { next as A } from "@automerge/automerge/slim"
import { Node, Schema } from "prosemirror-model"
import { Plugin } from "prosemirror-state"
import { DocHandle } from "./DocHandle.js"
import { SchemaAdapter } from "./schema.js"
import { basicSchemaAdapter } from "./basicSchema.js"
import { pmDocFromSpans } from "./traversal.js"
import { syncPlugin } from "./syncPlugin.js"
export { type DocHandle }

export {
SchemaAdapter,
type MappedSchemaSpec,
Expand All @@ -9,3 +18,70 @@ export {
export { basicSchemaAdapter } from "./basicSchema.js"
export { pmDocFromSpans, pmNodeToSpans } from "./traversal.js"
export { syncPlugin, syncPluginKey } from "./syncPlugin.js"

/**
* Initialize a ProseMirror document, schema, and plugin from an Automerge document
*
* @remarks
* This function is used to create the initial ProseMirror schema, plugin, and document which you
* pass to the ProseMirror Editor. If your text uses the
* {@link https://automerge.org/docs/under-the-hood/rich_text_schema/ | default schema}
* Then you can just pass the document handle and a path to the text field in the document,
* otherwise you can pass the `schemaAdapter` option with your own adapter.
*
* @param handle - The DocHandle containing the text to edit
* @param pathToTextField - The path to the text field in the automerge document
* @param options - Additional options, this is where you can pass a custom schema adapter
*
* @returns A ProseMirror Schema, Node, and Plugin ready to pass to the ProseMirror Editor
*
* @example
* Here's an example of basic usage for editing the description of a todo item
*
* ```ts
* import { next as A } from "@automerge/automerge"
* import { init } from "automerge-prosemirror"
* import { EditorState } from "prosemirror-state"
*
* const repo = new Repo({network: []})
* const handle = repo.create({ items: [{ description: "Hello World" }] })
* const { schema, pmDoc, plugin } = init(amDoc, ["items", 0, "description"])
* const state = EditorState.create({ schema, doc: pmDoc, plugins: [plugin] })
* ```
*
* @example
* Here's an example of using a custom schema adapter
*
* ```ts
* import { Repo } from "@automerge/automerge-repo"
* import { initPmDoc, SchemaAdapter } from "automerge-prosemirror"
* import { EditorState } from "prosemirror-state"
*
* const repo = new Repo({network: []})
* const handle = repo.create({ items: [{ description: "Hello World" }] })
*
* // Create and pass the custom schema adapter
* const adapter = new SchemaAdapter( ... )
* const { schema, pmDoc, plugin } = init(amDoc, ["items", 0, "description"], { schemaAdapter: adapter })
*
* const state = EditorState.create({ schema, doc: pmDoc, plugins: [plugin] })
* ```
*/
export function init(
handle: DocHandle<unknown>,
pathToTextField: A.Prop[],
options: { schemaAdapter: SchemaAdapter } | undefined = undefined,
): { schema: Schema; pmDoc: Node; plugin: Plugin } {
const adapter = options?.schemaAdapter ?? basicSchemaAdapter
const doc = handle.docSync()
if (!doc) {
throw new Error(
"cannot initialize ProseMirror document when handle is not ready",
)
}
const spans = A.spans(doc, pathToTextField)
const pmDoc = pmDocFromSpans(adapter, spans)
const plugin = syncPlugin({ adapter, handle, path: pathToTextField })
return { schema: adapter.schema, pmDoc, plugin }
}

0 comments on commit 4e8fe60

Please sign in to comment.