Advanced Plugin Patterns¶
This guide builds on Adding Custom Plugins with deeper patterns for plugin state management, custom node views, decorations, and the advanced editor APIs.
Plugin State Management¶
ProseMirror plugins can hold their own state, updated on every transaction. This is useful for tracking annotations, selections, or derived data.
import { Plugin, PluginKey } from 'prosemirror-state';
interface WordCountState {
count: number;
}
const wordCountKey = new PluginKey<WordCountState>('word-count');
const wordCountPlugin = new Plugin<WordCountState>({
key: wordCountKey,
state: {
init(_, state) {
return { count: countWords(state.doc) };
},
apply(tr, value, oldState, newState) {
// Only recompute when the document actually changed
if (!tr.docChanged) return value;
return { count: countWords(newState.doc) };
},
},
});
function countWords(doc: Node): number {
let count = 0;
doc.descendants((node) => {
if (node.isText) count += node.text!.split(/\s+/).filter(Boolean).length;
});
return count;
}
Read plugin state from outside the plugin:
Dispatch metadata to a plugin:
Decorations¶
Decorations add visual styling without modifying the document. Three types:
Inline Decorations¶
Wrap a range of text with a class or style:
import { Plugin } from 'prosemirror-state';
import { DecorationSet, Decoration } from 'prosemirror-view';
const highlightPlugin = new Plugin({
state: {
init() { return DecorationSet.empty; },
apply(tr, decorations, oldState, newState) {
// Map existing decorations through the transaction
decorations = decorations.map(tr.mapping, tr.doc);
const meta = tr.getMeta(highlightKey);
if (meta?.add) {
const { from, to, className } = meta.add;
const deco = Decoration.inline(from, to, { class: className });
decorations = decorations.add(tr.doc, [deco]);
}
return decorations;
},
},
props: {
decorations(state) { return this.getState(state); },
},
});
Inject styles for decorations
Since the editor uses Shadow DOM, inject CSS for your decoration classes via setShadowStyles():
Widget Decorations¶
Insert a DOM element at a position without changing the document:
const widget = Decoration.widget(pos, () => {
const el = document.createElement('span');
el.textContent = '💡';
el.className = 'inline-hint';
return el;
});
Node Decorations¶
Add attributes to an existing node's DOM element:
Efficient Decoration Updates¶
Always use DecorationSet.map() to carry decorations through transactions instead of rebuilding:
apply(tr, decorations) {
if (!tr.docChanged && !tr.getMeta(myKey)) return decorations;
return decorations.map(tr.mapping, tr.doc);
}
Custom Node Views¶
Node views let you replace a node's default rendering with custom DOM or a full Lit component.
Vanilla Node View¶
import { NodeView } from 'prosemirror-view';
class CounterNodeView implements NodeView {
dom: HTMLElement;
private count: number;
constructor(node, view, getPos) {
this.dom = document.createElement('div');
this.dom.className = 'counter-widget';
this.count = node.attrs.count || 0;
this.render();
this.dom.addEventListener('click', () => {
const pos = getPos();
if (pos == null) return;
view.dispatch(view.state.tr.setNodeMarkup(pos, null, {
...node.attrs,
count: this.count + 1,
}));
});
}
update(node) {
// Return false to force re-creation if the node type changed
if (node.type.name !== 'counter') return false;
this.count = node.attrs.count || 0;
this.render();
return true;
}
render() {
this.dom.textContent = `Count: ${this.count}`;
}
destroy() {
// Cleanup event listeners, subscriptions, etc.
}
}
Register it via a plugin:
const counterPlugin = new Plugin({
props: {
nodeViews: {
counter: (node, view, getPos) => new CounterNodeView(node, view, getPos),
},
},
});
Key Node View Rules¶
update()returnstrueif the view handled the update,falseto recreate from scratch.- Gate side effects in
update()on actual attribute changes — this method is called on every transaction. getPos()can returnundefinedif the node was deleted; always check before dispatching.
Advanced Editor APIs¶
Beyond commands and document, the editor exposes lower-level APIs:
Position Utilities¶
// Map a document position to screen coordinates
const coords = editor.positions.coordsAtPos(pos);
// → { top, bottom, left, right }
// Resolve a position for parent/depth context
const resolved = editor.positions.resolve(pos);
// → { parent, depth, parentOffset, ... }
DOM Utilities¶
// Get the editor's bounding rectangle
const rect = editor.dom.getBoundingRect();
// Dispatch a custom DOM event on the editor element
editor.dom.dispatchEvent(new CustomEvent('my-event', { detail: data }));
Direct ProseMirror View Access¶
For advanced use cases not covered by the stable API:
Escape hatch
Direct editorView access bypasses SciFlow's abstractions. Changes made through it won't trigger SciFlow's event handling unless you dispatch proper transactions. Prefer the stable commands, plugins, and positions APIs.