Guide for writing ProseMirror plugins
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 thepluginState
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 thepluginKey
.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 ofinit
as createReducer,dispatch
asdispatch
the combination ofmeta
+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 theloading-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
- Extend a particular node type with a
-
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 thepluginState
, and use it to add adecoration
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 thedoc
from thestate
and get thelength
of thetextContent
- and use the
decorations
prop to display thedecoration
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 thedecoration
won't be updated, so you will see text going through thedecoration
(place the cursor at the end of the document, and clickInsert 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 theapply
method of the plugin's state usingdecos.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});