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...
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.
inline
decorationswidget
decoration, like the loading-indicator
belownodeViews
add nodeViews to the editor here.
nodeView
view
this is useful for interacting with the editor view, like dispatching transactions.
update
handle each update of the editor view here.
destroy
do some cleanup here.update
gets called on each update of the editor view, so use it to count the characters1const 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});
pluginState
, which can be accessed from outsideapply
gets called on each transaction, move counter logic here1const 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);
decoration
isLoading
in the pluginState
, and use it to add a decoration
to the editor when it is true1const 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);
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 }
dispatch
meta from a React component and the plugin gets notified1 // 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);
pluginState
in the plugin, which is accessible outside, so a component can listen on changes1 // from a react component 2 // use the `pluginKey` to get the plugin's state 3 const characterCountPluginState = characterCountWithStatePluginKey.getState(view.state);
decoration
apply
method this time, just get the doc
from the state
and get the length
of the textContent
decorations
prop to display the decoration
in the document1const 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});
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)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});