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 thepluginStatefrom the editor state, and also to dispatch metas to communicate with the plugin. -
PluginStateis where the plugin's state lives. It's also accessible from outside using thepluginKey.initis the initial state of the plugin.applycan handle each transaction here, and return a new state for the plugin, also useful for receiving metas. If you are familiar with Redux think ofinitas createReducer,dispatchasdispatchthe combination ofmeta+PluginKeyas actions,applyas the reducer,
-
propscan handle editor events, or add decorations, nodeViews, etc.handleKeyDownhandle keydown events here.decorationsadd 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
inlinedecorations - or add a
widgetdecoration, like theloading-indicatorbelow
nodeViewsadd 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
-
viewthis is useful for interacting with the editor view, like dispatching transactions.updatehandle each update of the editor view here.- can handle asynchronous actions
destroydo some cleanup here.
Character count Plugin
- start with a simple plugin, which counts the characters in the editor.
updategets 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 applygets 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
isLoadingin thepluginState, and use it to add adecorationto 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
dispatchmeta 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
pluginStatein 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
applymethod this time, just get thedocfrom thestateand get thelengthof thetextContent - and use the
decorationsprop to display thedecorationin 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
decorationthe position of thedecorationwon'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 theapplymethod 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});


