Rich text editors

Bind an existing editor to a richText() field. Text uses native character operations, formatting uses attribute registers, structure uses hidden anchors. The core has no editor dependency.

The field

note.ts
import { definePulse, object, richText } from "pulsify-core";

export const Note = definePulse("Note", object({
  title: "string",
  body: richText(),
})).materialize();

The field supplies the initial document. A plain string becomes a paragraph; later bindings restore the full structure. Each document records its editor family, so a Lexical binding on a ProseMirror document fails before changing anything.

Bind

editor.ts
// bun add pulsify-prosemirror prosemirror-model prosemirror-state prosemirror-transform prosemirror-view
import { bindProseMirror, proseMirrorDocument } from "pulsify-prosemirror";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { schema } from "prosemirror-schema-basic";
import { history } from "prosemirror-history";

const view = new EditorView(document.querySelector("#editor"), {
  state: EditorState.create({ schema, plugins: [history()] }),
});
const binding = bindProseMirror(view, note.body, {
  onError: (error) => console.error(error),
});

// Regular transactions flow into the field and to every peer.
view.dispatch(view.state.tr.insertText("Hi"));
proseMirrorDocument(note.body, schema); // full node tree with marks

// On unmount:
binding.destroy();
view.destroy();

Rules of the road

  • Do not add the editor's own collaboration or history plugin to a bound editor. Lexical's binding provides undo and redo; ProseMirror keeps its local history plugin and remote changes bypass it.
  • Use the same editor schema and registered node classes on every peer.
  • field.text and snapshots are a plain-text projection. Read nodes with proseMirrorDocument or lexicalDocument.
  • Always call binding.destroy() on unmount. Switching documents needs a fresh editor state.