Plugins
The editor’s behaviour is composed from ProseMirror plugins in three tiers:
- Core plugins: everything in
@stesura/core, assembled bystesuraPlugins(each opt-out). - Feature-pack plugins: plugins outside core (pagination, references, track-changes, proofread, comments, …).
- Collab plugins: composed by your mount hook (
useCollabEditorviacomposeCollabPlugins).
The mount hooks (useLocalEditor, useCollabEditor, …) build tier 1 for you and accept extraPlugins for tier 2. You only assemble plugin arrays by hand for custom mounts and tests.
stesuraPlugins
import { stesuraPlugins } from "@stesura/core/plugins";
stesuraPlugins(
schema: Schema,
editorId: string,
configuration?: ConfigPluginProps,
options?: StesuraPluginsOptions, // base + custom toggles in one object
): Plugin[]It returns basePlugins(…) followed by customPlugins(…). options merges both toggle sets (minus field, which is filled with editorId). Both pieces remain exported.
configuration (ConfigPluginProps) carries the mount config:
| Key | Description |
|---|---|
config | ConfigPluginConfig: onToast, onDiagnostic, resolveUsers, currentUser, allowedFileHosts, printMount, batchSteps. |
toastText | Resolves a toast key to display text. Only needed when mounting core without editor-react. |
defaults | Initial values for the editor’s stores. |
extensionStores | Extra stores registered alongside the core ones. |
parentEditorId | Marks a sub-editor; store lookups fall through to this editor. |
basePlugins
import { basePlugins } from "@stesura/core/plugins";
basePlugins(
schema: Schema,
editorId: string,
configuration?: ConfigPluginProps,
props?: BasePluginsProps,
): Plugin[]Source: packages/core/src/plugins/index.ts.
| Plugin | Toggle (props.<key>: false) | Notes |
|---|---|---|
configPlugin | none (always mounted) | Carries editorId + ConfigPluginProps; on a root editor it also creates and registers the stores. See onToast. |
history() | history | Disable when a collab provider supplies its own undo/redo. Pitter Patter does not, so leave it on there. |
gapCursor() + gapCursorClassPlugin() | gapCursor | |
tableEditing() | tableEditing | From prosemirror-tables. |
dropTargetPlugin() | dropCursor (hides the bar only) | One drop target for the cursor and every drop: positions between section parts or sections snap to a body edge, the bar is drawn in the column a block drop point belongs to, and where nothing can land there’s no bar and the browser shows no-drop. dropCursor: false hides the bar; the drop rules stay. |
docStylingPlugin | docStyling | Applies doc.attrs.docBgColor (and its contrasting “auto” text color). |
mathPlugins(schema) | mathPlugins | Math node behaviour + input rules (here because the rules need the schema). |
reactKeys() is not included. The editor-react mount hooks add it (with stesuraKeymapPlugin); in a hand-built array for @stesura/react-prosemirror, add it yourself:
import { reactKeys } from "@stesura/react-prosemirror";
import { stesuraKeymapPlugin } from "@stesura/core/keymap";
import { stesuraPlugins } from "@stesura/core/plugins";
const plugins = [reactKeys(), stesuraKeymapPlugin(schema), ...stesuraPlugins(schema, editorId)];onToast is an accessibility contract
Several core actions have no visible outcome when they are refused (dropping a
table into a table, an upload the host rejects) and report it only through
configuration.config.onToast. Without it the mount warns once, and messages go
to console.warn where no user perceives them.
Your onToast must therefore render into an ARIA live region (role="status"
for ordinary messages, role="alert" for errors), mounted before the message
arrives so the insertion is announced.
The demo routes onToast into Sonner, whose <Toaster /> renders an
aria-live region with role="status" per toast. Any toast library with the
same contract will do; a bespoke one needs the region added by hand.
Toasts raised inside a sub-editor (a footnote body, a header) go to the root
mount’s onToast: the sub-editor’s own configPlugin carries only the ids.
Toast messages are keys, not sentences
Core emits a ToastKey and, where the message interpolates something, its
params, never English prose. @stesura/editor-react resolves the key
against the mount’s locale and hands your callback a Toast:
import type { ToastCallback } from "@stesura/core/types";
const onToast: ToastCallback = (toast) => {
// Default copy, already in the editor's locale:
showToast(toast.description, toast.type);
// …or replace a specific message with your own:
if (toast.key === "nestedTablesNotAllowed") showToast(myOwnWording, "warning");
};key and params are absent when your own code raises a toast through the same
callback. Without editor-react nothing translates the key, so description
falls back to it; pass your own resolver as ConfigPluginProps["toastText"].
customPlugins
import { customPlugins } from "@stesura/core/plugins";
customPlugins({ field, /* per-plugin toggles */ }): Plugin[]field (the editor id) is required; the call throws without it. stesuraPlugins fills it with editorId. Every plugin is on by default; pass false to disable one.
| Toggle key | Notes |
|---|---|
uniqueIdPlugin | Keeps node ids unique in one document-wide namespace. Covers every node whose spec declares an id attr: all non-inline nodes (via the uniqueId global attribute) plus inline nodes that declare it themselves, like image_anchored. Paste/drop copies get fresh ids; a duplicated id that a cross-reference targets is retired rather than guessed at. Load-time healing is prepareDocForLoad’s and createNormalizeTransaction’s job. Required, see below. |
styleSheetPlugin | Resolves the doc’s stylesheet, writes its CSS and heals references to missing styles. See Stylesheets. Takes no options. |
TOCplugin | Builds the heading-based outline. |
imagePlugin | Image insertion / replacement. |
selectionHighlightPlugin | Keeps the selection visible while focus is elsewhere. |
formatPainterPlugin | Format painter mode. |
searchPlugin | Find/replace state. |
spacingPlugin | The spacing store: margin collapse, bordered-block islands, and each block’s derived top margin, padding, borders and line height. |
highlightPlugin | Text highlight tool. |
tableResizingPlugin | Column drag-resize. |
tableSizingPlugin | Fits tables with a column of unknown width (paste, Markdown, AI) to the width they lay out in, and keeps percent and fixed-width tables’ columns in step with their width (page setup, columns, host cell). |
tableEmptyRowGuardPlugin | Disallows empty rows. |
tableHeaderCellsPlugin | Derives each cell’s isHeader from its table’s headerRowCount, so header cells render and serialize as <th scope="col">. |
pendingLinkPlugin | Transient link-insertion state. |
tableDragHandlePlugin | Drag handles for rows/columns. |
tableCellSelectionPreserver | Keeps cell selection across remote collab updates. |
invisibleCharacters | Whitespace visualisation. |
tabStopsPlugin | Sizes tab characters against paragraph tab stops. |
languagePlugin | Emits lang for assistive technology: on the editor root (from doc.attrs.language), plus blocks whose effective language (proofingLanguage ?? style.language) differs from it. See Accessibility. |
wrapSelectionWithPlugin | Wraps the selection when you type quotes or brackets. |
typographyPlugin | Smart quotes, dashes, ellipsis. |
linkifyPlugin | Autolinks typed/pasted URLs. |
numberingPlugin | Multi-level list numbering. Also gates numberingPropsPlugin (caret teleport) and numberingInputRulesPlugin (typing 1. , a) , * , - , … starts a list). |
headingInputRulesPlugin | #-style heading input rules. |
highlightCounterNodesPlugin | Highlights counters in numbered lists. |
sectionUnwrapPastePlugin | Unwraps pasted section scaffolding. |
trackChangesStripPastePlugin | Strips track-changes marks and attrs from pasted or dropped content, so forged HTML can’t author someone else’s suggestion. Holds without the track-changes pack. |
wordPasteHandlerPlugin | Normalises desktop-Word paste: equations → math nodes, lists → numbering, indentation/spacing → formatting attrs. |
computedStylePastePlugin | Strips the source page’s computed style (colour, size, font, background, alignment, indent, heading weight, link underline) from a Chrome/Safari copy, so pasted web text adopts the document’s styles. Bold, italic, underline and links are kept. |
lowlightPlugin | Code-block syntax highlighting. |
headerFooterGuardPlugin | Guards header/footer content invariants. |
floatingBoxClipboardPlugin | Copies text boxes as phrasing elements carrying their content as JSON, so the HTML parser keeps the host paragraph whole on paste. |
floatingBoxEditorBindingPlugin | Pushes main-editor changes (collaborators included) into an open text box editor. |
viewModeInteractionsPlugin is added unconditionally (a no-op outside view mode) and has no toggle.
Turning uniqueIdPlugin off is unsupported. Ids are healed once on load and
never again, so from the first edit on every split, paste and cell split leaves a
null or duplicate id behind: pagination under-fills pages, node comments cannot be
created, cross-references resolve to the wrong target, and the TOC skips nodes. A
root mount without it logs a one-time console.warn.
Things people commonly look for that are not here: commentsPlugin (comments come from commentsPlugins() in @stesura/comments), proofreadPlugin, paginationPlugin, trackChangesPlugin, referencesPlugins, notesPlugin, headerFooterSyncPlugin. They live in feature packs (next section).
Feature-pack plugins
These ship outside core so you only pay for what you mount.
| Plugin | Package | Pairs with UI extension |
|---|---|---|
paginationPlugins() (paginationPlugin, columnLayoutPlugin, columnSpanPlugin, headerFooterBindingPlugin, headerFooterSyncPlugin, in that order) | @stesura/pagination | paginationUiExtension (@stesura/pagination-react) |
proofreadPlugin | @stesura/proofread | proofreadUIExtension (@stesura/proofread-react) |
trackChangesPlugin | @stesura/track-changes | trackChangesUIExtension (@stesura/track-changes-react) |
referencesPlugins() (cross-references, notes, both note bindings) | @stesura/references | referenceUiExtensions (@stesura/references-react) |
commentsPlugins() | @stesura/comments | commentsUiExtension (@stesura/comments-react) |
proofreadPlugin requires a checking backend (generateProofreadErrors), parentEditorId and a cache factory (createCache: createCollabProofreadCacheFactory from @stesura/collab-adapter-react, or createIdbProofreadCacheFactory from @stesura/proofread/cache-idb). trackChangesPlugin has to be paired with transactionModifier={trackChangesTransactionModifier} on StesuraEditor (and on the mount hook) so transactions go through the diff layer.
Collab plugins (composed for you)
useCollabEditor composes composeCollabPlugins from @stesura/collab-adapter — collab({ version }) plus the presence plugin — in front of your plugins. See Collaboration.
Plugin keys
@stesura/core/plugins re-exports the keys from packages/core/src/plugins/keys.ts:
configPluginKey, numberingPluginKey, numberingPropsPluginKey, trackChangesKey, paginationPluginKey, codeHighlightPluginKey, formatPainterPluginKey, highlightPluginKey, imagePluginKey, imageUploadPolicyKey, invisibleCharactersPluginKey, languagePluginKey, pendingLinkPluginKey, mathEditorPluginKey, searchPluginKey, headerFooterSyncPluginKey, styleSheetPluginKey, tableDragHandlePluginKey, tocPluginKey, wordPasteHandlerPluginKey, computedStylePastePluginKey, plus tabStopsPluginKey.
trackChangesKey, paginationPluginKey and headerFooterSyncPluginKey live in core so other packages can read those states without depending on the feature packs. Keys that are not in core: pendingCommentPluginKey comes from @stesura/comments (the other comment plugin keys are internal); proofreadPluginKey from @stesura/proofread.
Read state with the key:
import { highlightPluginKey } from "@stesura/core/plugins";
const state = highlightPluginKey.getState(view.state);Send a message via transaction metadata:
const tr = view.state.tr.setMeta(highlightPluginKey, { persistedColor: "#FFFF00" });
view.dispatch(tr);Order matters
configPlugincomes first (frombasePlugins), so every plugin after it can resolve the editor’s identity and stores.basePluginsprecedecustomPlugins:tableSizingPluginmust run aftertableEditing()’s table fixing.transformPastedhooks chain in registration order:uniqueIdPlugin(id strip on copies) →sectionUnwrapPastePlugin→trackChangesStripPastePlugin.wordPasteHandlerPlugin’s andcomputedStylePastePlugin’stransformPastedHTMLrun before the parse.- Collab sync plugins run before user plugins so transactions are mapped against the latest shared state.
The pattern that satisfies all of the above:
const plugins = [
reactKeys(),
stesuraKeymapPlugin(schema),
...stesuraPlugins(schema, editorId, { config: { onToast } }, { history: false }),
// feature-pack plugins last:
trackChangesPlugin(),
...paginationPlugins(),
...referencesPlugins(),
...commentsPlugins(),
];Writing a custom plugin
Standard ProseMirror; the editor doesn’t constrain you:
import { Plugin, PluginKey } from "prosemirror-state";
const myPluginKey = new PluginKey<{ wordCount: number }>("my-plugin");
const myPlugin = new Plugin({
key: myPluginKey,
state: {
init: () => ({ wordCount: 0 }),
apply(tr, value, _old, newState) {
if (!tr.docChanged) return value;
let count = 0;
newState.doc.descendants((n) => {
if (n.isText && n.text) count += n.text.split(/\s+/).filter(Boolean).length;
});
return { wordCount: count };
},
},
});Append it after stesuraPlugins(...) in your plugin array (or pass it through the mount hook’s extraPlugins).
Reading steps: batch steps
In edit mode, a bulk command lands as one BatchStep instead of thousands of ordinary steps. This covers Ctrl+A formatting, indent, spacing, alignment, language, style rename/delete, change case and Replace All. A batch holds AddMarkStep, RemoveMarkStep, AttrStep and ReplaceSteps that stay inside one textblock. One step means one copy of the document instead of one per step, and one commit in collab.
A plugin that only uses tr.mapping, step.getMap() or tr.docChanged needs nothing: a batch maps like the steps it holds. A plugin (or a transactionModifier) that tests step classes (step instanceof ReplaceStep, AttrStep, …) sees a BatchStep and misses what’s inside it. Read steps through standardStepsOf:
import { AttrStep } from "prosemirror-transform";
import { standardStepsOf } from "@stesura/core/helpers";
tr.steps.forEach((step, i) => {
for (const inner of standardStepsOf(step)) { // a batch's steps, or [step]
if (inner instanceof AttrStep && inner.attr === "styleId") {
// inner.pos is in tr.docs[i], the document before the batch.
// A position after it: step.getMap().map(inner.pos)
}
}
});- Every inner position is in the document before the batch,
tr.docs[i]. The document after it istr.docs[i + 1], ortr.docfor the last step. Don’t map an inner step through the inner steps before it; a batch’s ordering already makes its positions hold. rangesOf(step)returns the ranges a step touched, sorted, when that’s all you need.- A batch can hold tens of thousands of steps. Walking or resolving the document once per inner step makes a plugin slow on Ctrl+A over a long document; collect the ranges first and walk once.
Batches only come from edit mode: with track changes on (or in review mode) bulk commands keep ordinary steps. Set config.batchSteps: false to keep them on ordinary steps everywhere, at the cost of slower bulk commands and, in collab, Ctrl+A formatting refused past the edit step limit on long documents.
Plugins inside sub-editors
Header/footer, footnote, and endnote panels are nested ProseMirror sub-editors: a
separate editor whose doc mirrors one region of the main document, kept in sync by a
two-way step binding. When a panel mounts, it calls your plugin factory again to
build its own plugin instances, then strips a curated exclusion list
(SUB_EDITOR_EXCLUDED_PLUGINS in @stesura/core/sub-editor: pagination, notes,
cross-references, header/footer sync, comments, numbering, stylesheet, TOC, proofread,
history, the root configPlugin, …), adding its own configPlugin (with parentEditorId)
and history(). Everything else, including your custom plugins, runs inside the
sub-editor. Collab plugins never reach a panel: the mount hook adds them to the root
state only, not to the pluginFactory it returns.
A plugin is sub-editor-safe by default. It only becomes a problem if it:
- Dispatches into other editors (looks up a view by id and calls
dispatch). Inside a sub-editor this can echo transactions across the sync boundary. - Keys global state by
editorIdwithout handling sub-editor ids. Each sub-editor has its own id; itsconfigPlugincarriesparentEditorId, which you read when you need the owning document’s stores. - Structurally rewrites the doc in
appendTransactionbased on assumptions about the document shape. The sub-editor doc isdoc > wrapper > section_content > blocks; positions below2are scaffolding that never syncs to the main document.
Even then, the failure is contained: the binding fences scaffolding steps, verifies
convergence after every sync, and resyncs the panel from the main document when they
diverge. A misbehaving plugin costs a logged resync, never silent divergence or a
crash. To keep your plugin out of panels, give it a PluginKey and add its label via
the kind config’s extraExcludedPlugins, or filter it in your own plugin factory.
Next steps
- Customizing the Toolbar: toolbar tabs and UI extensions.
- Extending the Schema: adding custom nodes/marks.
- Comments: adapter-driven comment threads.