From 4e8fe60edbbebf92e7c6e4e48382f7e6c6ae87f1 Mon Sep 17 00:00:00 2001 From: Alex Good Date: Fri, 4 Oct 2024 10:59:07 +0100 Subject: [PATCH] Refactor initialization to make it easier to get started 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. --- README.md | 36 +++++++++--------- playground/src/Editor.tsx | 36 +++++++++--------- src/index.ts | 78 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 465d66f..bf18c78 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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) @@ -56,6 +50,8 @@ const view = new EditorView(, { }) ``` +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. @@ -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 diff --git a/playground/src/Editor.tsx b/playground/src/Editor.tsx index 71da64c..3be2f42 100644 --- a/playground/src/Editor.tsx +++ b/playground/src/Editor.tsx @@ -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, @@ -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 @@ -115,32 +114,31 @@ 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, { @@ -148,7 +146,7 @@ export function Editor({ handle, path, schemaAdapter }: EditorProps) { dispatchTransaction(this: EditorView, tr: Transaction) { const newState = this.state.apply(tr) this.updateState(newState) - setMarkState(activeMarks(newState, adapter.schema)) + setMarkState(activeMarks(newState, schema)) }, }) diff --git a/src/index.ts b/src/index.ts index d42f503..b6282b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -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, + 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 } +}