Writing a simple WYSIWYG editor with ProseMirror

When Sports.ru needed its own WYSIWYG editor, we decided to make it based on the ProseMirror library. One of the key features of this tool is its modularity and wide possibilities of customization, so with its help you can very finely tailor the editor to any project. In particular, ProseMirror is already being used in The New York Times and The Guardian . In this article, we will talk about how to write your WYSIWYG editor using ProseMirror.

Writing a simple WYSIWYG editor with ProseMirror

Overview of ProseMirror


The author of ProseMirror is Marijn Haverbeke, who is known in the frontend developer community primarily as the author of the popular book Eloquent Javascript . At the beginning of our work (autumn 2018), there were no materials on working with this library, except for official documentation and tutorials from the author. The documentation package from the author includes several sections, the most useful of them are the ProseMirror Guide (description of basic concepts) and the Reference Manual (library spec). Below is a summary of key ideas from the ProseMirror Guide.

ProseMirror always stores the state of a document in its own data structure. And already from this structure in runtime the corresponding DOM elements are generated on the page with which the end user interacts. Moreover, ProseMirror stores not only the current state (state), but also the history of previous changes, which can be rolled back if necessary. Any change in state should occur through transactions, the usual manipulations with the DOM tree will not work directly here. A transaction is an abstraction that describes the logic of a step-by-step state change. The essence of their work is reminiscent of sending and executing actions in libraries for state management, for example, Redux and Vuex.

The library itself is built on independent modules that can be disabled or added depending on the needs. A list of the main modules that we thought would be needed by almost everyone:

  • prosemirror-model - a document model that describes all the components of a document, their properties and actions that can be performed on them;
  • prosemirror-state - a data structure that describes the state of the created document at a certain point in time, including selected fragments and transactions for moving from one state to another;
  • prosemirror-view - presentation of the document in the browser and tools so that the user can interact with the document;
  • prosemirror-transform - a functional for storing transaction history, with the help of which transactions are implemented and which allows you to roll back to previous states or lead the joint development of one document.

In addition to this minimal set, the following modules may also be useful:

  • prosemirror-commands - a set of ready-made commands for editing. As a rule, you need to write something more complex or individual yourself, but Marijn Haverbeke has already done some things for us, for example, deleting a selected fragment of text;
  • prosemirror-keymap - a module of two methods for specifying keyboard shortcuts;
  • prosemirror-history – , .. , , , ;
  • prosemirror-schema-list – , ( DOM-, , ).

ProseMirror


Let's start by creating an editor outline. The schema in ProseMirror defines a list of elements that may be in our document, and their properties. Each element has a toDOM method, which determines how this element will be represented in the DOM tree on the web page.

The principle of WYSIWYG is realized precisely in the fact that when creating the scheme we have full control over how editable content is displayed on the page, and, accordingly, we can give each element such an HTML structure and set styles just like the content to be viewed. Nodes can be created and customized to your requirements, in particular, most likely you may need paragraphs, headings, lists, pictures, paragraphs, media.

Suppose that each paragraph of the text should be wrapped in a tag “p” with the class “paragraph”, which determines the rules of paragraph styles necessary for the layout. Then the ProseMirror schema in this case may look like this:

import { Schema } from "prosemirror-model";

const mySchema = new Schema({
    nodes: {
        doc: {
           content: 'block'
        },
        text: {},
        paragraph: {
            content: 'inline',
            group: 'block',
            toDOM: function toDOM(node) {
                return ['p', {class: 'paragraph'}, 0];
            },
        },
    },
});

First, we import the constructor to create our schema and pass an object to it that describes the nodes (hereinafter referred to as nodes) in the future editor. Nodes are abstractions that describe the types of content being created. For example, with such a scheme, only nodes of types text and paragraph can be in the editor. Doc is the name of the top-level node, which will consist only of block elements, i.e. in this case only from paragraphs (because we have not described others).

Text are text nodes, somewhat similar to text DOM nodes. Using the group property, we can group our nodes in different ways so that they can be more easily accessed in code. Groups can be set in any way convenient for us. In our case, we divided the nodes only into block and inline. Text is inline by default, so this can be omitted explicitly.

A completely different thing is paragraph (paragraph). We announced that paragraphs consist of inline text elements, and also have their own representation in the DOM. A paragraph will be a block element, roughly in the sense that the DOM block elements are. According to this scheme, the paragraphs on the page will be presented in the online editor as follows:

<p class="paragraph">Content of the paragraph</p>

Now you can create the editor itself:

import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Schema } from "prosemirror-model";

const mySchema = new Schema({
    nodes: {
        doc: {
            content: 'block+'
        },
        text: {
            group: 'inline',
            inline: true
        },
        paragraph: {
            content: 'inline*',
            group: 'block',
            toDOM: function toDOM(node) {
                return ['p', {class: 'paragraph'}, 0];
            },
        },
    },
});

/**
 * @classdesc  
 * @param {Object} el DOM-,    
 */
export default class Wysiwyg {
    constructor(el) {
        this.el = el;
    }

    /**
      * @description     
      * @param {Object} content ,    
      */
    setArticle(content) {
        const state = EditorState.fromJSON(
            {schema: mySchema},
            content,
        );
        const view = new EditorView(this.el, {state: state});
    }
}

In the beginning, as usual, we import all the necessary constructors from the corresponding modules and take the scheme described above. We create the editor as a class with a set of necessary methods. In order for the web page to be able to edit and create content, you need to create a state, state, using the layout and content of the article, and presenting the content from the current state in the given root element. We put these actions in the setArticle method, except for which nothing is needed so far.

At the same time, content is optional. If it is not, then you get an empty editor, and the content can already be created directly on the spot. Let's say we have an HTML file with this markup:

<div id="editor"></div>

To create an empty WYSIWYG editor on a page, you need only a few lines in a script that runs on a page with this markup:

//     
import Wysiwyg from 'wysiwyg';

const root = document.getElementById('editor');
const myWysiwyg = new Wysiwyg(root);
myWysiwyg.setArticle();

Using this code, you can write any text from scratch. At this point, you can already see how ProseMirror works.

When a user types in text in this editor, state transactions occur. For example, if you type the phrase “This is my new cool WYSIWYG editor” in the editor with the above scheme, ProseMirror will respond to keyboard input by invoking the appropriate set of transactions, and after the input is complete, the content in the document state will look like this:

content: [
    {
        type: 'paragraph',
        value: '    WYSIWYG-',
    },
]

If we want some text, for example, the content of an article already created earlier, to be opened in the editor for editing, then this content must correspond to the scheme created earlier. Then the editor initialization code will look a little different:

//     
import Wysiwyg from 'wysiwyg';

const root = document.getElementById('editor');
const myWysiwyg = new Wysiwyg(root);
const content = {
    type: 'doc',
    content: [
        {
            type: 'paragraph',
            value: 'Hello, world!',
        },
        {
            type: 'paragraph',
            value: 'This is my first wysiwyg-editor',
        }
    ],
};
myWysiwyg.setArticle(content);

Hooray! We made our first editor, which can create a clean page to create new content and open existing for editing.

What to do with the editor next


But even with a full set of nodes, our editor still lacks important functions - formatting text, paragraphs. For this, in addition to the nodes, an object with settings for formatting, marks, must also be transferred to the circuit. For simplicity, we call them so - brands. And to control the addition, deletion and formatting of all these elements, users will need a menu. Menus can be added using custom plugins that stylize menu objects and describe the change in the state of a document when choosing certain actions.

Using plugins, you can create any design that extends the built-in features of the editor. The main idea of ​​plugins is that they process certain user actions in order to generate transactions corresponding to them. For example, if you want a click on the list icon in the menu to create a new empty list and move the cursor to the beginning of the first element, then we definitely need to describe this transaction in the corresponding plugin.

You can read more about formatting settings and plugins in the official documentation , as well as very small useful examples of using ProseMirror features can be very useful .

UPDIf you are interested in how we integrated a new editor based on ProseMirror into an existing project, then we talked about this in another article .

All Articles