Skip to Content
GuidesPlugins

Plugins

The editor’s behaviour is composed from ProseMirror plugins in three tiers:

  1. Core plugins: everything in @stesura/core, assembled by stesuraPlugins (each opt-out).
  2. Feature-pack plugins: plugins outside core (pagination, references, track-changes, proofread, comments, …).
  3. Collab plugins: composed by your mount hook (useCollabEditor via composeCollabPlugins).

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:

KeyDescription
configConfigPluginConfig: onToast, onDiagnostic, resolveUsers, currentUser, allowedFileHosts, printMount, batchSteps.
toastTextResolves a toast key to display text. Only needed when mounting core without editor-react.
defaultsInitial values for the editor’s stores.
extensionStoresExtra stores registered alongside the core ones.
parentEditorIdMarks 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.

PluginToggle (props.<key>: false)Notes
configPluginnone (always mounted)Carries editorId + ConfigPluginProps; on a root editor it also creates and registers the stores. See onToast.
history()historyDisable when a collab provider supplies its own undo/redo. Pitter Patter does not, so leave it on there.
gapCursor() + gapCursorClassPlugin()gapCursor
tableEditing()tableEditingFrom 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.
docStylingPlugindocStylingApplies doc.attrs.docBgColor (and its contrasting “auto” text color).
mathPlugins(schema)mathPluginsMath 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 keyNotes
uniqueIdPluginKeeps 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.
styleSheetPluginResolves the doc’s stylesheet, writes its CSS and heals references to missing styles. See Stylesheets. Takes no options.
TOCpluginBuilds the heading-based outline.
imagePluginImage insertion / replacement.
selectionHighlightPluginKeeps the selection visible while focus is elsewhere.
formatPainterPluginFormat painter mode.
searchPluginFind/replace state.
spacingPluginThe spacing store: margin collapse, bordered-block islands, and each block’s derived top margin, padding, borders and line height.
highlightPluginText highlight tool.
tableResizingPluginColumn drag-resize.
tableSizingPluginFits 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).
tableEmptyRowGuardPluginDisallows empty rows.
tableHeaderCellsPluginDerives each cell’s isHeader from its table’s headerRowCount, so header cells render and serialize as <th scope="col">.
pendingLinkPluginTransient link-insertion state.
tableDragHandlePluginDrag handles for rows/columns.
tableCellSelectionPreserverKeeps cell selection across remote collab updates.
invisibleCharactersWhitespace visualisation.
tabStopsPluginSizes tab characters against paragraph tab stops.
languagePluginEmits 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.
wrapSelectionWithPluginWraps the selection when you type quotes or brackets.
typographyPluginSmart quotes, dashes, ellipsis.
linkifyPluginAutolinks typed/pasted URLs.
numberingPluginMulti-level list numbering. Also gates numberingPropsPlugin (caret teleport) and numberingInputRulesPlugin (typing 1. , a) , * , - , … starts a list).
headingInputRulesPlugin#-style heading input rules.
highlightCounterNodesPluginHighlights counters in numbered lists.
sectionUnwrapPastePluginUnwraps pasted section scaffolding.
trackChangesStripPastePluginStrips 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.
wordPasteHandlerPluginNormalises desktop-Word paste: equations → math nodes, lists → numbering, indentation/spacing → formatting attrs.
computedStylePastePluginStrips 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.
lowlightPluginCode-block syntax highlighting.
headerFooterGuardPluginGuards header/footer content invariants.
floatingBoxClipboardPluginCopies text boxes as phrasing elements carrying their content as JSON, so the HTML parser keeps the host paragraph whole on paste.
floatingBoxEditorBindingPluginPushes 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.

PluginPackagePairs with UI extension
paginationPlugins() (paginationPlugin, columnLayoutPlugin, columnSpanPlugin, headerFooterBindingPlugin, headerFooterSyncPlugin, in that order)@stesura/paginationpaginationUiExtension (@stesura/pagination-react)
proofreadPlugin@stesura/proofreadproofreadUIExtension (@stesura/proofread-react)
trackChangesPlugin@stesura/track-changestrackChangesUIExtension (@stesura/track-changes-react)
referencesPlugins() (cross-references, notes, both note bindings)@stesura/referencesreferenceUiExtensions (@stesura/references-react)
commentsPlugins()@stesura/commentscommentsUiExtension (@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

  1. configPlugin comes first (from basePlugins), so every plugin after it can resolve the editor’s identity and stores.
  2. basePlugins precede customPlugins: tableSizingPlugin must run after tableEditing()’s table fixing.
  3. transformPasted hooks chain in registration order: uniqueIdPlugin (id strip on copies) → sectionUnwrapPastePlugin → trackChangesStripPastePlugin. wordPasteHandlerPlugin’s and computedStylePastePlugin’s transformPastedHTML run before the parse.
  4. 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 is tr.docs[i + 1], or tr.doc for 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:

  1. 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.
  2. Keys global state by editorId without handling sub-editor ids. Each sub-editor has its own id; its configPlugin carries parentEditorId, which you read when you need the owning document’s stores.
  3. Structurally rewrites the doc in appendTransaction based on assumptions about the document shape. The sub-editor doc is doc > wrapper > section_content > blocks; positions below 2 are 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

Last updated on