New paragraph type that just adds a class

It took me a few hours of digging to figure out how to register a new node type that would simply be a paragraph that adds a css class name. I found the dino example to be a bit confusing, especially as it only handles an inline type without children rather than a block type. I thought I’d put my example code here just to document how to do this, and would welcome suggestions for improving/simplifying. Might be useful to add something like this to the docs.

import {ProseMirror} from "prosemirror";
import {Paragraph, Schema, defaultSchema} from "prosemirror/dist/model";

/**
 *  Create a new node type that is a paragraph with a class name.
 *  @param {String} name - The human-readable name of the paragraph type
 *  @param {String} classname - The CSS class name to apply to the paragraph
 *  @param {Number} menuRank - Rank order for menu placement.
 *  @return {Object} the new node type
 */
function classyParagraphType(name, classname, menuRank) {
  // The base type just extends from Paragraph.
  class ClassyParagraph extends Paragraph {};
  // parser: identify an element for this type if it is a "p" with our class name.
  ClassyParagraph.register("parseDOM", "p", {
    // DOMParseSpec implementation: http://prosemirror.net/ref.html#DOMParseSpec
    rank: 10, // Make sure this is lower than the paragraph type default of 50.
    parse: function(dom, state) {
      let classnames = dom.getAttribute("class");
      if (!classnames || !classnames.split(" ").includes(classname)) {
        return false;
      }
      // wrap our block type around child nodes.
      state.wrapIn(dom, this);
    }
  });
  // serializer: render this node with the class.
  ClassyParagraph.prototype.serializeDOM = function(node, s) {
    return s.renderAs(node, "p", {"class": classname});
  };
  // menu: add this type to the menu.
  ClassyParagraph.register("command", "make", {
    derive: true,
    label: `Change to ${name} paragraph`,
    menu: {
      group: "textblock",
      rank: menuRank,
      display: {type: "label", label: `${name} paragraph`},
      activeDisplay: `${name} paragraph`,
      "class": `menu-${classname}`
    }
  })
  return ClassyParagraph
}

// Create a new editor schema that adds a couple of paragraph-with-classname types.
const editorSchema = new Schema(defaultSchema.spec.update({
  // Register a "drop cap" paragraph type
  dropcap: classyParagraphType("Drop Cap", "dropcap", 7),
  // Register a "pull quote" paragraph type
  pullquote: classyParagraphType("Pull Quote", "pullquote", 8),
}));

// Instantiate an editor with our new schema.
let editor = new ProseMirror({
    place: document.getElementById("editor"),
    menuBar: true,
    schema: editorSchema
});

The key parts that were confusing/absent from the dino example which took me a bunch of time to figure out:

  • The attrs definition in the class is confusing. I’m still not sure whether this could be more elegantly accomplished using attribute definitions rather than overriding the serializer/parser; but it didn’t seem that specifying an attribute definition (e.g. get attrs() { return {"class": new Attribute({default: classname})} }) worked for rendering or parsing the class attribute without also overriding "parseDOM" and serializeDOM.
  • The parser for a block type needs to use state.wrapIn rather than state.insert to render its child components.
  • Dino’s serializeDOM implementation makes use of the not very documented elt, which was giving me trouble. It took me a while to figure out that serializeDOM offers the second “serializer” argument with renderAs, which I needed to get children to render properly.
  • The derive key in registering a command isn’t super clear. Dino adds all kinds of stuff pertaining to choosing the “type” of dino to show. It took a bunch of spelunking in the source and confusing error messages to figure out I just needed derive: true.

Hope this example helps someone else out.

Thank you so much! For days I was trying to add a text alignment menu and update attributes on paragraphs. But I didn’t think about using the make command as a starting point.