Skip to content

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:

const state = editor.plugins.getState(wordCountKey);
console.log('Word count:', state?.count);

Dispatch metadata to a plugin:

editor.plugins.dispatchMeta(wordCountKey, { action: 'reset' });

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():

editor.setShadowStyles('.my-highlight { background: yellow; }');

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:

const nodeDeco = Decoration.node(from, to, { class: 'highlighted-block' });

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() returns true if the view handled the update, false to recreate from scratch.
  • Gate side effects in update() on actual attribute changes — this method is called on every transaction.
  • getPos() can return undefined if 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:

const view = editor.editorView;
// Full ProseMirror EditorView — use with caution

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.