Building a Sleek RTE in GrapesJS using ProseMirror

Trying to squeeze TipTap into GrapesJS is a task most developers regret, but there’s a cleaner, battle-tested alternative, which is tapping directly into ProseMirror

By Brendon Ngirazi
Rich TextProseMirrorTipTapGrapesJS
Building a Sleek RTE in GrapesJS using ProseMirror

If you have ever tried embedding a modern rich text editor into GrapesJS, you have almost certainly looked at TipTap. On paper, it sounds like the ideal pairing: TipTap provides a sleek and functional RTE right out the gate. In practice, integrating TipTap into GrapesJS is notoriously frustrating.

There is a significantly cleaner architecture: bypass TipTap's application shell, drop straight to ProseMirror as the model layer via grapesjs-prosemirror, and construct a native, floating bubble toolbar using shadcn/ui and grapesjs-react.

Why ProseMirror works better than TipTap:

1. The Clashing Editor Problem (TipTap vs. GrapesJS)

TipTap is not just an editing engine; it is designed as an all-in-one, standalone application layer built to manage document lifecycles.

  • TipTap expects to own the editor's mount node, event delegation, focus transitions, and document serialization.
  • GrapesJS also manages DOM nodes inside its iframe canvas, handles element selection, binds drag-and-drop listeners, and triggers component-level lifecycle updates. 

When you initialize TipTap inside a GrapesJS canvas text component, these runtimes immediately clash. You run into focus stealing, broken undo/redo histories, destroyed cursor selections on canvas clicks, and broken styling.

2. ProseMirror: The Foundation

TipTap is fundamentally a wrapper around ProseMirror.

ProseMirror does not try to be opinionated. It is a toolkit for building rich text editors based on functional state management:

  • EditorState: An immutable data structure representing the document, selection, and schema.
  • Transaction: Explicit changes applied to the state (similar to Redux or Git commits).
  • EditorView: A lightweight layer that translates the state into the DOM and catches user interactions.

Because ProseMirror acts strictly as a headless model layer, it does not battle GrapesJS for DOM control. It can be mounted, updated, and dismantled on demand without hijacking canvas events.

3. The Role of grapesjs-prosemirror

Instead of manually wiring up ProseMirror schemas, transactions, serializer round-trips, and GrapesJS's setCustomRte() hook, grapesjs-prosemirror handles the plumbing:

  • Hooks cleanly into GrapesJS’s text component edit lifecycle (double-click to activate, blur to sync). 
  • Manages the ProseMirror instance creation, cleanup and HTML serialization. 
  • Exposes a pluggable UIAdapter lifecycle (mount, update, destroy), passing the active view, state, and toolbar container element so you can render whatever React interface you want. 

Building the RTE

We will be focusing solely on the RTE creation and not around the setup of the project itself, you can start from any of the examples provided for grapesjs-react, or implement in an existing project.

Part 1: Building the UI Adapter

ProseMirror runs on an imperative transaction loop, while React relies on declarative rendering. To connect them without unnecessary full-tree re-renders, use an event bus inside a custom UIAdapter.

Whenever ProseMirror dispatches a transaction (e.g., cursor moves or marks change), grapesjs-prosemirror calls update(), triggering our lightweight bus to re-render the toolbar.

react-ui-adapter.tsx

Preventing the default mouse-down behavior is essential in any rich text editor so clicking the toolbar does not steal focus or blur the canvas text selection. Let's add a ToolbarButton

primitive.

toolbar-button.tsx

To build mark controls cleanly, isolate the button primitive with shadcn and rely on the command utilities provided by @workspace/grapesjs-prosemirror:

  • markIsActive(schema, state, markName): Reads the current cursor or selection state to determine if the mark is applied.
  • run(view, markType): Toggles the mark in the ProseMirror transaction pipeline.
rte-toolbar.tsx

Beyond simple inline mark toggles, you will often need complex input components like numeric stepper selects.

Here is an example extension: a composite FontSizeInput that pairs shadcn's <Select> with increment/decrement steppers. Notice again that onMouseDown stops propagation to ensure GrapesJS does not cancel the active text selection while you adjust the font size.

font-size-input.tsx

To incorporate this into your toolbar, read the current size using getActiveFontSize(view) and apply updates via setFontSize(schema, view, value):

tsx

There is a lot more utilities that come with grapesjs-prosemirror which we will not be covering, feel free to check out the plugin and reach out if you need help with any features or if there's any features currently missing that you would like to be includes.

Part 2: Registering the Plugin with GrapesJS

Once your UI adapter and components are in place, plug it directly into your GrapesJS editor initialization:

typescript

Jump-Start Your Implementation

Building a custom rich text editor interface, syncing reactive button states to transaction streams, and matching canvas overlay positions takes time to get right.

If you want to skip building the editor infrastructure from scratch, take a look at grapesjs-shadcn. It is a complete, production-ready GrapesJS preset built on @grapesjs/react, Tailwind CSS, and shadcn/ui. It includes this full ProseMirror integration right out of the box and it is the framework behind ComposeIt's editor. It is currently available with a massive discount, saving you weeks of wiring up canvas adapters and UI glue so you can focus directly on building your application.

We use cookies to ensure you get the best experience on our website. For more information on how we use cookies, please see our privacy policy.