Adding a menu

Most of the examples use the example setup package to create a menu, but we actually don't recommend using that and the example menu package in actual production, since they are rather simplistic, opinionated modules, and you're likely to run into their limitations rather quickly.

This example will go through building a custom (and ugly) menu for a ProseMirror editor.

The idea is, roughly, to create a number of user interface elements and tie them to commands. When clicked, they should execute these commands on the editor.

One question is how to deal with commands that aren't always applicable—when you are in a paragraph, should the control for ‘make this a paragraph’ be shown? If so, should it be grayed out? This example will simply hide buttons when their command is not currently applicable.

To be able to do that, it needs to update the menu structure every time the editor state changes. (Depending on the number of items in your menu, and the amount of work required for determining whether they are applicable, this can get expensive. There's no real solution for this, except either keeping the number and complexity of the commands low, or not changing the look of your menu depending on state.)

If you already have some kind of dataflow abstraction that you're routing ProseMirror updates though, writing the menu as a separate component and connecting it to the editor state should work well. If not, a plugin is probably the easiest solution.

The component for the menu might look something like this:

class MenuView {
  constructor(items, editorView) {
    this.items = items
    this.editorView = editorView

    this.dom = document.createElement("div")
    this.dom.className = "menubar"
    items.forEach(({dom}) => this.dom.appendChild(dom))
    this.update()

    this.dom.addEventListener("mousedown", e => {
      e.preventDefault()
      editorView.focus()
      items.forEach(({command, dom}) => {
        if (dom.contains(e.target))
          command(editorView.state, editorView.dispatch, editorView)
      })
    })
  }

  update() {
    this.items.forEach(({command, dom}) => {
      let active = command(this.editorView.state, null, this.editorView)
      dom.style.display = active ? "" : "none"
    })
  }

  destroy() { this.dom.remove() }
}

It takes an array of menu items, which are objects with command and dom properties, and puts those into a menu bar element. Then, it wires up an event handler which, when a mouse button is pressed on this bar, figures out which item was clicked, and runs the corresponding command.

To update the menu for a new state, all commands are run without dispatch function, and the items for those that return false are hidden.

Wiring this component to an actual editor view is a bit awkward—it needs access to the editor view when initialized, but at the same time, that editor view's dispatchTransaction prop needs to call its update method. Plugins can help here. They allow you define a plugin view, like this:

import {Plugin} from "prosemirror-state"

function menuPlugin(items) {
  return new Plugin({
    view(editorView) {
      let menuView = new MenuView(items, editorView)
      editorView.dom.parentNode.insertBefore(menuView.dom, editorView.dom)
      return menuView
    }
  })
}

When an editor view is initialized, or when the set of plugins in its state change, the plugin views for any plugins that define them get initialized. These plugin views then have their update method called every time the editor's state is updated, and their destroy method called when they are torn down. So by adding this plugin to an editor, we can make sure that the editor view gets a menu bar, and that this menu bar is kept in sync with the editor.

The actual menu items might look like this, for a basic menu with strong, emphasis, and block type buttons.

import {toggleMark, setBlockType, wrapIn} from "prosemirror-commands"
import {schema} from "prosemirror-schema-basic"

// Helper function to create menu icons
function icon(text, name) {
  let span = document.createElement("span")
  span.className = "menuicon " + name
  span.title = name
  span.textContent = text
  return span
}

// Create an icon for a heading at the given level
function heading(level) {
  return {
    command: setBlockType(schema.nodes.heading, {level}),
    dom: icon("H" + level, "heading")
  }
}

let menu = menuPlugin([
  {command: toggleMark(schema.marks.strong), dom: icon("B", "strong")},
  {command: toggleMark(schema.marks.em), dom: icon("i", "em")},
  {command: setBlockType(schema.nodes.paragraph), dom: icon("p", "paragraph")},
  heading(1), heading(2), heading(3),
  {command: wrapIn(schema.nodes.blockquote), dom: icon(">", "blockquote")}
])

The prosemirror-menu package works similarly, but adds support for things like simple drop-down menus and active/inactive icons (to highlight the strong button when strong text is selected).