ProseMirror API
Stesura is built on ProseMirror, and its state, transactions, commands and plugins are plain ProseMirror. This page covers the concepts you need to extend the editor and the Stesura helpers around them.
Core Concepts
Document Model
A document is a tree of nodes. Each node has a type (from the schema), attributes, and either child content or, for text, marks.
doc
├── section
│ ├── section_header
│ ├── section_content
│ │ ├── paragraph attrs: { styleId: "Normal", textAlign: "center", … }
│ │ │ ├── text "Hello " marks: [strong]
│ │ │ └── text "world"
│ │ └── paragraph
│ │ └── text "Second paragraph"
│ └── section_footer
└── footnotesText never starts at position 0: section scaffolding comes first. Work from the selection, or find nodes by walking the doc.
The document is immutable. To change it, build a transaction and dispatch it.
Transactions
A transaction (tr) describes changes to the editor state. Its methods chain:
const { from } = view.state.selection;
const tr = view.state.tr
.insertText("Hello", from)
.addMark(from, from + 5, view.state.schema.marks.strong.create())
.setMeta("source", "my-plugin");
view.dispatch(tr);Commands
A command has the signature:
type Command = (
state: EditorState,
dispatch?: (tr: Transaction) => void,
view?: EditorView
) => boolean;It returns true if it can run (and ran, if dispatch was provided). Calling it without dispatch is a dry run.
import { toggleBold } from "@stesura/core/commands";
const canToggle = toggleBold(view.state);
toggleBold(view.state, view.dispatch, view);Composing commands
To run several commands as ONE transaction (one undo step, each command seeing what the previous ones wrote), compose them:
import { composeCommands, setSpacing, setTextAlign } from "@stesura/core/commands";
const formatParagraph = composeCommands(
setTextAlign("center"),
setSpacing({ before: 12, after: 6 })
);
formatParagraph(view.state, view.dispatch);composeCommands succeeds if any command did something; a command that returns false keeps whatever it already wrote. sequenceCommands stops at the first false and dispatches nothing, so the group is all-or-nothing. Neither is ProseMirror’s chainCommands, which stops at the first command that succeeds. See Composing commands.
Accessing the Editor
useEditorEventCallback
The way to act on the editor from React components rendered inside the ProseMirror tree (node views, floating menus):
import { useEditorEventCallback } from "@handlewithcare/react-prosemirror";
import { toggleBold } from "@stesura/core/commands";
const BoldButton = () => {
const handleClick = useEditorEventCallback((view) => {
toggleBold(view.state, view.dispatch, view);
});
return <button onClick={handleClick}>Bold</button>;
};The callback is stable and always sees the latest EditorView, without causing re-renders.
The toolbar and other chrome render outside that tree, so there use useRunTbCommand or useBoundEditorViewCallback from @stesura/editor-react. They target the editor bound to the toolbar, which can be a header, footer or note sub-editor. See Customizing the Toolbar.
Plugin State
Read plugin state through the exported plugin keys:
import { highlightPluginKey, trackChangesKey } from "@stesura/core/plugins";
const highlightState = highlightPluginKey.getState(view.state);
const trackChangesState = trackChangesKey.getState(view.state); // undefined unless the track-changes pack is mountedTransaction Metadata
Plugins communicate through transaction metadata:
// Set metadata
tr.setMeta(myPluginKey, { action: "update", value: 42 });
// Read metadata (in a plugin's apply function)
const meta = tr.getMeta(myPluginKey);Schema
The schema defines which nodes and marks are valid. Build it once per app with stesuraSchema and reuse it; it is a value, not a singleton.
import { stesuraSchema } from "@stesura/core/schema";
const schema = stesuraSchema();
schema.nodes.paragraph;
schema.marks.textStyle;See Schema for the full reference.
Plugins
ProseMirror plugins hold state and hook into editor behaviour:
import { Plugin, PluginKey } from "prosemirror-state";
const myPluginKey = new PluginKey<{ count: number }>("my-plugin");
const myPlugin = new Plugin({
key: myPluginKey,
state: {
init: () => ({ count: 0 }),
apply(tr, value) {
const meta = tr.getMeta(myPluginKey);
return meta ? { count: meta.count } : value;
},
},
props: {
handleKeyDown(view, event) {
return false; // true prevents default handling
},
},
});See Plugins for Stesura’s plugin set.
Key Helpers
@stesura/core/helpers wraps common ProseMirror queries:
import {
findNode,
findNodes,
getAttributes,
getMarkAttributes,
getMarkType,
getNodeType,
isMarkActive,
isNodeActive,
} from "@stesura/core/helpers";
isMarkActive(state.schema.marks.strong, state); // at the selection
isNodeActive(state, "heading", { level: 2 });
getAttributes(state, "heading"); // { level: 2, textAlign: null, … }
findNode(state.doc, (node) => node.type.name === "toc"); // { node, pos } | undefined| Helper | Signature |
|---|---|
isMarkActive | (mark, state) => boolean |
isNodeActive | (state, typeOrName, attrs?) => boolean |
getAttributes / getMarkAttributes | (state, typeOrName) => Attrs |
findNode / findNodes | (node, predicate): first match / all matches among node’s descendants, with positions |
getNodeType / getMarkType | (nameOrType, schema) |
docChanged / getChangedRanges | For appendTransaction: did any transaction change the doc / the ranges a transform changed |
Next Steps
- Schema: node and mark reference.
- Global Attributes: how shared attributes are composed onto nodes.
- Commands: Core: document manipulation commands.