Skip to Content
Plugins

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, resolveUsers, currentUser, allowedFileHosts, printMount.
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.
dropCursor()dropCursor
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 @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 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 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.
spacingPluginMargin collapse, bordered-block islands, and the derived _marginTop, _padding*, _borders and _lineHeight attrs.
highlightPluginText highlight tool.
tableResizingPluginColumn drag-resize.
tableSizingPluginFits foreign/unprocessed tables (paste, DOCX import, AI) to their section width.
tableEmptyRowGuardPluginDisallows empty rows.
tableHeaderCellsPluginDerives each cell’s isHeader from its table’s hasHeaderRow, so header cells 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.
inlineNodeSelectionHandlerPluginInline node selection ergonomics.
lowlightPluginCode-block syntax highlighting.
headerFooterGuardPluginGuards 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.

PluginPackagePairs with UI extension
paginationPlugins() (paginationPlugin, 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-adaptercollab({ 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

  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) → sectionUnwrapPastePlugintrackChangesStripPastePlugin. wordPasteHandlerPlugin’s transformPastedHTML runs 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).

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