Adding custom ProseMirror plugins¶
This guide shows how to add custom ProseMirror plugins to the SciFlow editor.
Using the feature system (recommended)¶
The recommended way to add plugins is through the Feature system:
import { Plugin, PluginKey } from 'prosemirror-state';
import type { Feature } from '@sciflow/editor-core';
// Create your plugin
const myPluginKey = new PluginKey('my-plugin');
const myPlugin = new Plugin({
key: myPluginKey,
state: {
init: () => ({ /* initial state */ }),
apply: (tr, state) => { /* update state */ }
}
});
// Wrap it in a Feature
const myFeature: Feature = {
name: 'my-custom-feature',
addPlugins() {
return [myPlugin];
}
};
// Add to editor (prefer configureFeatures when available)
if (typeof editor.configureFeatures === 'function') {
await editor.configureFeatures([citationFeature, figureFeature, myFeature]);
} else {
editor.features = [citationFeature, figureFeature, myFeature];
}
Rendering decorations¶
ProseMirror widget decorations can render arbitrary DOM content — that's completely standard PM usage, nothing specific to this package. A host that owns its own prosemirror-view instance can build a Decoration.widget wrapping any element it likes (a rich inline node showing full markup, a tooltip, whatever the use case needs), with no restriction on what goes inside.
A plain Plugin (registration, commands, and state.apply) built with your own prosemirror-state copy also works with @sciflow/editor-start/bundle. The feature system is duck-typed, and Editor.create() does not depend on which module built the Plugin object.
Decorations are the exception, and it's specific to consuming /bundle. The bundle inlines its own prosemirror-view (it doesn't externalize that dependency), so a DecorationSet/Decoration built with your app's own prosemirror-view is a genuinely different class from the bundle's — this is not a limit on what a decoration can render, it's a limit on who is allowed to construct one. A single decorating plugin built this way renders fine in isolation — but the moment a SECOND decoration source coexists (any other feature/plugin that also implements props.decorations, which is the baseline for every <sciflow-editor> instance: the editor's own dropCursor/gapCursor plugins already count as one), ProseMirror's internal decoration-merging logic (DecorationGroup.from()) branches on instanceof DecorationSet and fails across the module boundary — crashing editor.mount() outright, not degrading silently.
Use createRangeDecorationsFeature instead. It builds the Decoration and DecorationSet objects internally with the bundle's own prosemirror-view copy, which prevents this conflict:
import { createRangeDecorationsFeature } from '@sciflow/editor-start/bundle';
const { feature, key } = createRangeDecorationsFeature('my-highlights');
await editor.configureFeatures([...otherFeatures, feature]);
// Update the live ranges any time — plain data, no ProseMirror imports needed on your side:
editor.plugins.dispatchMeta(key, [
{ from: 12, to: 40, class: 'my-added' }, // a real [from, to) span
{ from: 40, class: 'my-removed' }, // no `to` — a zero-width point marker
{ from: 60, class: 'my-removed', text: 'the deleted sentence' }, // point marker WITH visible content
]);
A point marker's optional text renders as the widget's textContent — for example, keeping deleted text visible/struck-through inline at the point of deletion, rather than an empty tick mark. When text is set, the marker also drops aria-hidden in favor of aria-label="Deleted text: <text>" (so assistive tech still gets the content, with context) and gets a data-with-text="true" attribute so class can style the empty-marker and text-bearing cases differently. Omitting text (the default) is unchanged from before this field existed. Truncation/length limits, if you want any, are your call — this factory renders whatever string you pass, verbatim.
Style .my-added/.my-removed the same way as any other decoration class — see "Styling Plugin Decorations" below.
Read-only mode¶
Same shape as createRangeDecorationsFeature above — createReadOnlyFeature vetoes doc-changing transactions (filterTransaction), disables contenteditable/caret/IME (props.editable), and marks the surface with a data-readonly="true" attribute for token-based styling. Selection/scroll transactions stay allowed, so browsing (clicking around, selecting text) keeps working. What it blocks is the local user's own edits — typing, commands, input rules — not the document itself: content arriving from outside this client still applies, see "Programmatic doc replacement and remote collaborative steps" below.
import { createReadOnlyFeature } from '@sciflow/editor-start/bundle';
const { feature, key } = createReadOnlyFeature('my-read-only');
await editor.configureFeatures([...otherFeatures, feature]);
editor.plugins.dispatchMeta(key, true); // now read-only
editor.plugins.dispatchMeta(key, false); // editable again — same editor instance, no re-init
The flag lives in plugin state (not a value captured once at construction time), so toggling is live. See packages/editor/start/src/lib/read-only.ts's module doc for a caveat worth knowing: a host that inspects a REJECTED transaction directly (rather than comparing before/after state) can still see tr.docChanged === true on it — the doc itself is guaranteed unchanged, but tr.docChanged reflects what the transaction would have done, not whether it was applied.
Programmatic doc replacement and remote collaborative steps still work while read-only. "Read-only" means "the user cannot edit this document" — not "this document can never change from any source." Reassigning the document on a read-only instance (for example, <sciflow-editor>'s .doc setter, or Editor.updateFromSync() directly — a revision viewer paging through history is the canonical case) still renders, because that call's reconciliation transaction carries @sciflow/editor-core's EXTERNAL_SYNC_TRANSACTION_META, and createReadOnlyFeature lets a transaction carrying that meta through even while read-only. Authority-confirmed steps applied through Editor.receiveSteps() carry the same marker, so a read-only participant on a step-authority backend (see Sync Strategy) keeps receiving remote edits and its Editor.getVersion() keeps advancing:
filterTransaction(tr, state) {
if (!tr.docChanged) return true;
if (isExternalSyncTransaction(tr)) return true; // host-driven doc swap, not a user edit
return !isReadOnly(state);
}
Writing a custom read-only-style filterTransaction (rather than using createReadOnlyFeature)? Follow the same check — isExternalSyncTransaction is exported from @sciflow/editor-core. Without it, the FIRST doc assigned to a read-only instance renders, and every later one is silently swallowed: editor.getDoc() reports the new content, but the view never repaints. On a collaborative editor the same omission swallows every remote step: Editor.getVersion() stays frozen at the version the client mounted with and that client diverges from the authority for good, with nothing thrown or logged.
Accessing plugin state¶
Use the stable plugin API to read and update plugin state:
// Read plugin state
const state = editor.plugins.getState(myPluginKey);
// Update plugin state via meta
editor.plugins.dispatchMeta(myPluginKey, {
action: 'update',
data: { /* your data */ }
});
Document traversal¶
Access the document structure to find positions:
const doc = editor.document;
if (doc) {
doc.descendants((node, pos) => {
if (node.type.name === 'paragraph') {
console.log('Found paragraph at', pos);
}
});
}
Position utilities¶
Map document positions to screen coordinates:
// Get coordinates for a position
const coords = editor.positions.coordsAtPos(100);
if (coords) {
console.log('Position 100 is at', coords.top, coords.left);
}
// Resolve position for context
const resolved = editor.positions.resolve(100);
if (resolved) {
console.log('Parent node:', resolved.parent);
console.log('Depth:', resolved.depth);
}
Styling plugin decorations¶
If your plugin creates ProseMirror decorations (for example, highlights, annotations, or inline widgets), add CSS to your global stylesheet scoped to .sf-editable-surface. The editor’s contenteditable lives in the light DOM, so ordinary global CSS rules reach decoration classes directly — no special injection API is needed:
/* Global stylesheet */
.sf-editable-surface .my-highlight {
background: rgba(255, 200, 0, 0.3);
}
.sf-editable-surface .my-annotation {
border-bottom: 2px solid orange;
}
Or inject a <style> dynamically:
const style = document.createElement(‘style’);
style.textContent = `.sf-editable-surface .my-highlight { background: rgba(255, 200, 0, 0.3); }`;
document.head.appendChild(style);
See Web Components Basics — Styling Editor Content for the full picture, including how to override chrome (border, focus ring) via shadowStyles.
Custom citation source editor¶
The selection editor’s citation adapter is pluggable. Use a custom adapter to drive your own UI:
import { setCitationSourceAdapter, SourceField } from '@sciflow/editor-start';
setCitationSourceAdapter({
render(container, value, context) {
// Build your UI and call context.applySource(SourceField.toString(items)) on save
},
update(container, value, context) { /* optional */ },
destroy(container) { /* optional */ },
});
You can also set selectionEditor.citationSourceAdapter per element instance for one-off overrides.