ProseMirror Plugin System

Introduction

As a novice with ProseMirror, I struggled to grasp its usage just by reading the library guide and reference manual. Now few years later I realized that the docs are awesome, but the problem is that it is not written for beginners. So I decided to write a beginner-friendly guide to ProseMirror plugin system. I cannot provide insights beyond what's already covered in the documentation. However, I can offer a range of examples, progressing from simple to more intricate, along with valuable tips and tricks. In these examples we will use React to add custom components, but ProseMirror is framework-agnostic, so you can use Angular, SolidJS etc...

The Plugin system

Plugins are used to extend the behavior of the editor and editor state in various ways. - ProseMirror docs

  • First create a PluginKey, this is a unique identifier for the plugin. Think of it as a key in an object. Use it to get the pluginState from the editor state, and also to dispatch metas to communicate with the plugin.

  • PluginState is where the plugin's state lives. It's also accessible from outside using the pluginKey.

    • init is the initial state of the plugin.
    • apply can handle each transaction here, and return a new state for the plugin, also useful for receiving metas. If you are familiar with Redux think of init as createReducer, dispatch as dispatch the combination of meta+PluginKey as actions, apply as the reducer,
  • props can handle editor events, or add decorations, nodeViews, etc.

    • handleKeyDown handle keydown events here.
    • decorations add decorations to the editor here.
      • Decorations are used to modify the appearance of existing content without changing its underlying structure
      • add some styling to nodes with inline decorations
      • or add a widget decoration, like the loading-indicator below
    • nodeViews add nodeViews to the editor here.
      • Extend a particular node type with a nodeView
      • custom rendering: to render differently than the default behavior provided by ProseMirror
      • interactive nodes: add event listeners
      • for a full and complex example check prosemirror-image-plugin
  • view this is useful for interacting with the editor view, like dispatching transactions.

    • update handle each update of the editor view here.
      • can handle asynchronous actions
    • destroy do some cleanup here.

Character count Plugin

  • start with a simple plugin, which counts the characters in the editor.
  • update gets called on each update of the editor view, so use it to count the characters
1const characterCountPluginKey = new PluginKey("characterCountPlugin");
2const characterCountPlugin = new Plugin({
3  key: characterCountPluginKey,
4  view: () => ({
5    update: (view: EditorView) => {
6      // get the document's content and count the characters
7      const characterCount = view.state.doc.textContent.length;
8      console.log("characterCount", characterCount);
9    },
10  }),
11});
  • make it a pluginState, which can be accessed from outside
  • apply gets called on each transaction, move counter logic here
1const characterCountWithStatePluginKey = new PluginKey(
2  "characterCountWithStatePlugin",
3);
4const characterCountWithStatePlugin = new Plugin({
5  key: characterCountWithStatePluginKey,
6  state: {
7    init: () => {
8      return {
9        characterCount: 0,
10      };
11    },
12    apply: (tr, pluginState, _, newState) => {
13      const characterCount = newState.doc.textContent.length;
14      return {
15        characterCount,
16      };
17    },
18  },
19});
20
21// access the `pluginState` from outside
22
23// initialize the editor view
24const editorView = new EditorView(editorRef.current, {
25  state: EditorState.create({
26    doc: DOMParser.fromSchema(schema).parse(contentRef.current),
27    plugins: [
28      characterCountWithStatePlugin,
29    ],
30  }),
31});
32
33// get the `pluginState`
34const characterCountPluginState = characterCountWithStatePluginKey.getState(
35  editorView.state,
36);

A more complex plugin with meta communication

  • it is most likely that in a complex plugin a React component wants to communicate with the plugin
  • create a loading plugin using decoration
  • store isLoading in the pluginState, and use it to add a decoration to the editor when it is true
1const loadingPluginKey = new PluginKey("loadingPlugin");
2const loadingPlugin = new Plugin({
3  key: loadingPluginKey,
4  state: {
5    init: () => {
6      return {
7        isLoading: false,
8      };
9    },
10    apply: (tr, pluginState, _, newState) => {
11      const isLoading = tr.getMeta(loadingPluginKey);
12      return {
13        isLoading: isLoading ?? pluginState.isLoading,
14      };
15    },
16  },
17  props: {
18    decorations: (state) => {
19      const isLoading = loadingPluginKey.getState(state).isLoading;
20      if (isLoading) {
21        return DecorationSet.create(state.doc, [
22          Decoration.widget(0, () => {
23            const loader = document.createElement("div");
24            loader.className = "loader";
25            return loader;
26          }),
27        ]);
28      }
29      return null;
30    },
31  },
32});
33
34// dispatch metas to the plugin from outside
35  let isLoading = false
36  const tr = view.state.tr.setMeta(loadingPluginKey, !isLoading);
37  view.dispatch(tr);
  • add some css, to make it spin
1  .loader {
2    position: absolute;
3    bottom: 0.5rem;
4    right: 0.5rem;
5    border: 2px solid #f3f3f3;
6    border-radius: 50%;
7    border-top: 2px solid #3498db;
8    width: 20px;
9    height: 20px;
10    -webkit-animation: spin 2s linear infinite; /* Safari */
11    animation: spin 2s linear infinite;
12  }
13
14  /* Safari */
15  @-webkit-keyframes spin {
16    0% {
17      -webkit-transform: rotate(0deg);
18    }
19    100% {
20      -webkit-transform: rotate(360deg);
21    }
22  }
23
24  @keyframes spin {
25    0% {
26      transform: rotate(0deg);
27    }
28    100% {
29      transform: rotate(360deg);
30    }
31  }
  • as a conclusion, communication between a ProseMirror plugin and a React component is necessary to achieve the desired behavior in certain cases
    • dispatch meta from a React component and the plugin gets notified
1    // from a react component
2    // use the `pluginKey` to send a meta to the plugin
3    const tr = view.state.tr.setMeta(loadingPluginKey, !isLoading);
4    view.dispatch(tr);
  • update pluginState in the plugin, which is accessible outside, so a component can listen on changes
1  // from a react component
2  // use the `pluginKey` to get the plugin's state
3  const characterCountPluginState = characterCountWithStatePluginKey.getState(view.state);
  • this also works in other SPAs, or in vanilla JavaScript

Working with decorations

  • create a character count plugin that uses ProseMirror decoration
  • we can omit the apply method this time, just get the doc from the state and get the length of the textContent
  • and use the decorations prop to display the decoration in the document
1const characterCountDecoPluginKey = new PluginKey('characterCountDecoPlugin');
2const characterCountDecoPlugin = new Plugin({
3  key: characterCountDecoPluginKey,
4  props: {
5    decorations: (state) => {
6      return DecorationSet.create(state.doc, [
7        Decoration.widget(0, () => {
8          const span = document.createElement('span');
9          span.style.background = 'lightgrey';
10          span.textContent = `Character count DECORATION: ${state.doc.textContent.length}`;
11          return span;
12        }),
13      ]);
14    },
15  },
16});
  • there is one thing to know about decorations, if decorations are stored in the plugin's state, and you write something before the decoration the position of the decoration won't be updated, so you will see text going through the decoration (place the cursor at the end of the document, and click Insert deco, and type some spacebars a few words before the decoration)
  • to fix this, need to update the decoration's position on each transaction, and do that in the apply method of the plugin's state using decos.map(tr.mapping, tr.doc) `
1const insertDecorationPluginKey = new PluginKey("insertDecorationPlugin");
2const insertDecorationPlugin = new Plugin({
3  key: insertDecorationPluginKey,
4  state: {
5    init: () => {
6      return {
7        decos: DecorationSet.empty,
8        useMapping: false,
9      };
10    },
11    apply: (tr, pluginState, _, newState) => {
12      const meta = tr.getMeta(insertDecorationPluginKey);
13      let decos = pluginState.decos;
14      if (meta?.insert) {
15        decos = DecorationSet.create(tr.doc, [
16          Decoration.widget(tr.selection.from, () => {
17            const span = document.createElement("span");
18            span.style.background = "red";
19            span.textContent = 'DECORATION';
20            return span;
21          }),
22        ]);
23      }
24
25      return {
26        useMapping: meta?.mapping ?? pluginState.useMapping,
27        decos:
28          meta?.mapping || pluginState.useMapping
29            ? decos.map(tr.mapping, tr.doc)
30            : decos,
31      };
32    },
33  },
34  props: {
35    decorations: (state: EditorState) => {
36      const pluginState = insertDecorationPluginKey.getState(state);
37      return pluginState.decos;
38    },
39  },
40});
Did you like this article? Would you like to learn more?
Write to us at contact@emergence-engineering.com