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, resolveUsers, currentUser, allowedFileHosts, printMount. |
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. |
dropCursor() | dropCursor | |
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 @handlewithcare/react-prosemirror, add it yourself:
import { reactKeys } from "@handlewithcare/react-prosemirror";
import { stesuraKeymapPlugin, 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 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 | Margin collapse, bordered-block islands, and the derived _marginTop, _padding*, _borders and _lineHeight attrs. |
highlightPlugin | Text highlight tool. |
tableResizingPlugin | Column drag-resize. |
tableSizingPlugin | Fits foreign/unprocessed tables (paste, DOCX import, AI) to their section width. |
tableEmptyRowGuardPlugin | Disallows empty rows. |
tableHeaderCellsPlugin | Derives each cell’s isHeader from its table’s hasHeaderRow, so header cells 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. |
inlineNodeSelectionHandlerPlugin | Inline node selection ergonomics. |
lowlightPlugin | Code-block syntax highlighting. |
headerFooterGuardPlugin | Guards header/footer content invariants. |
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, 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, 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’stransformPastedHTMLruns 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).
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.