A modified version of Atlassian's React+TypeScript PM editor

Hi,

it has been a while, a year I guess, since I last posted here. Having been busy with other things only now I finally have had a chance to fully immerse myself into understanding how to build a real, production-level ProseMirror editor that leverages React and TypeScript.

In my previous attempt I wandered off a little bit to a wrong direction by trying to come up with my own solution. Now, knowing the complexity of the task, I had a more pragmatic approach and emulated as best as I could the existing implementations, namely Atlassian’s open-source PM editor Link to their bitbucket

My goal was to re-implement an MVP using their architecture and after some struggle, I managed to pull it off. I think they have done a really great job working out the major pain points which are myriad when creating sturdy rich-text editors. Also they have very generously open-sourced their editor. If you play around with their hosted example here Atlaskit by Atlassian you’ll, however, might notice some performance issues. At least I found the With plugin state example to be quite sluggish and it registers keypresses with a noticeable latency. So it’s not all perfect.

I have published my code here GitHub - TeemuKoivisto/prosemirror-react-typescript-example: Minimal boilerplate to start a project with ProseMirror, React, TypeScript for anyone to inspect and improve upon. Hosted example is here React App

During my development process there has been various smaller and larger matters that I have needed to solve. For example, once I was able to implement the rendering with React portals I noticed my approach to be quite unusable when rendering large numbers of nodeviews. This was mainly due to how the rendering of the portals was done in one big loop that every update caused to re-render. From the Atlassian editor’s commit history you can find a similar observation https://bitbucket.org/atlassian/atlaskit-mk-2/commits/d520a6fb6dab1027d3873eec9317c4e8574d07fb which they solved by using unstable_renderSubtreeIntoContainer.

What I myself did was revert back to ReactDOM.render which was a lot faster than using portals, but I think a bigger performance boost was pooling all the updates per each PM’s updateState call and flushing them then all at once. I guess this could also make portals work but I haven’t done any benchmarking. The problem with using the ReactDOM.render is, in addition to not being able to share React context, that the render calls are not batched which I assume is really un-optimal for React as it is designed to update large trees fast, not dozens of little trees in separate renders. I don’t know if this is prevented with portals.

Other curious aspects I have had to solve was the interface for the editor-plugin that wraps the PM plugins. Again, I used the Atlassian’s version as a basis which works quite well but there are some parts of the interfacing/typings that I feel still need some work. Also I’m not 100% about the proper way to divide the plugin functionality. Compared to PM-based editor frameworks Atlassian’s editor doesn’t store schema alongside the editor-plugins but in a separate repository. This isn’t really a modular approach to create shareable editor-plugins, but for a purpose-built editor I think it makes dealing with the schema a lot easier.

Most of this stuff is pretty self-explanatory once you go through the code. I made three editors to showcase different approaches. One important thing that I most recently added was the performance analytics. I based it on the Atlassian editor yet I’m not sure do they use performance tracking very thoroughly (for example some measurements are not even used for anything). While still a work-in-progress, AnalyticsProvider should showcase a basic prototype to log execution times. With large apps I assume this becomes quite critical as things can get quite messy with a lot of plugins and types of users with various appliances.

Another interesting thing I did was trial the editors with SSR, mainly Next.js, and while it worked all right there are some caveats to be aware of. Mainly not being able to access document or window object in server-side requires some if’fing to do. It doesn’t seem possible to hydrate a static HTML directly into PM editor doc or should DOMParser.parse be able to do the trick? You could of course make your own parser and then provide the doc in the initial createState call. But this probably isn’t very useful pursuit since you can just replace the state in a useEffect hook which seems to work just fine (and I don’t think you need the editor HTML for SEO purposes, just for faster initial render).

Some of the major things I have not yet completely worked out is the toolbar components that are provided by the editor-plugins as React components. For now the toolbar is just fixed to one layout. In addition, I dropped most of the analytics features outside of execution timing and I haven’t gotten around implementing the plugin extensions. Oh yeah and I need to add a ton of tests.

I also have one direct question for @marijn (which I’ll just ask here for brevity’s sake). PluginKey receives name as its parameter and it is used to generate an unique string key to identify/access the plugin. However, from the docs and the TypeScript typings I gather that this key is not public and can’t be accessed directly? In my use case I am using PluginKey as a parameter to add/remove event listeners in an object for which I have to use some string key to store the values. While writing this it came to my mind that I could also use a Map instead of object which would allow me to use PluginKeys as keys. However, this still seems a bit cumbersome and I wanted to ask if this is an intentional design choice? Especially since PluginKey is basically a wrapper around a string.

Anyway, I hope this all is helpful to whoever is making their own production-ready PM editors out there. I think the setup is quite solid and so far it seems to be working fine. With the Atlassian editor as reference, implementing new features should be a lot easier since you can just look at how they have done it and then working out your own solution.

If you wanted to compare this approach to purely React-based rich-text editors, such as Slate.js, I have come to a conclusion that you will never achieve a similar synchronization in the DOM management if you had React with full control of the rendering and event dispatching. But at the same time I am pretty sure that having PM handle the DOM and React the occasional UI widgets, this approach is a lot more performant and customizable. The time required to work out how to setup a working React+PM scaffolding is another question, can’t say this was a trivial job even with a help of a good example. Maybe now however the job got easier :).

Teemu

(Sorry that this ended up being so long. It might show that I been writing my thesis for the past year)

7 Likes

Thanks for sharing!

I found the Atlassian approach with React portals to be a bit hard to follow when I was first learning ProseMirror and I think this repo would have been helpful in speeding up that process.

Hey, good to hear. Yep it’s kinda messy with three different classes of which only PortalProviderAPI is actually needed. I would say Atlassian should do a rewrite just to remove all the cruft and fix those performance issues with for example WithPluginState doing some crazy shenanigans. You can see from the flame graphs the rendering get pretty choked up but not sure exactly what causes the delay when handling the keypresses.

EDIT: I also did some benchmarking with ReactDOM.render vs createPortal and noticed portals to be faster (350ms vs 230ms) in an operation where I create a large number of nodeviews in one big transaction. In deletion portals seem to be magnitudes faster as unmountComponentAtNode forces React to immediately remove the node whereas by simply removing the portal from the map and then rerendering them incurs almost no latency.

But hmm, I think the rendering is now actually done outside of the dispatchTransaction where I track these latencies and therefore I can’t accurately calculate how long the unmounting of components take vs calling unmountComponentAtNode directly. Probably it’s a bit faster. In the meantime I guess I’ll revert back to portals.

EDIT EDIT: Ok about 184ms and 270ms with portals vs 362ms and 573ms the old-fashioned way in the main task without the smaller tasks numbered in.