How we use ProseMirror in our project

We are developing a web application that allows for real-time discussion of issues and supports collaborative editing of messages. We use React, ProseMirror and AWS AppSync.

In this article, we will cover our use of ProseMirror to create a post editor. ProseMirror provides tools to create a WYSIWYG text editor in the web interface. We will look at the features that we used ourselves: how to create your own simple types of nodes in ProseMirror (for attached files and images), what transactions are in ProseMirror, and how to create a node with more complex logic – with dynamic content change by GraphQL subscription. In future articles, we will also cover the implementation of co-editing.

Application interface

Application interface

Example of a message with formatting

Example of a message with formatting

Table of contents

  1. General information about ProseMirror

  2. How to create your own node type

  3. Transactions

  4. How we implemented image loading

  5. How we implemented mention with NodeView

General information about ProseMirror

The container for the editor is a div with the “contenteditable” attribute. All input to the container is intercepted and processed by ProseMirror, after which it changes the contents of the container.

The document is represented as an immutable data structure that can be converted to and from JSON. Changes are described using transactions, which can also be represented as JSON and transmitted over the network. Due to this, ProseMirror supports co-editing out of the box.

A document is a tree of nodes (Nodes), which may contain child nodes. Also Node can contain an array of marks – Marks – modifiers for nodes (bold, italic, etc.)

Examples of our nodes and labels

Examples of our nodes and labels

The characteristics of the node are:

  • The type of this node (paragraph, list, list item, heading, image, etc.)

  • Child nodes (content) – an array (Fragment) of nested nodes, which can be either nodes or just blocks of text.

  • Attributes (attrs) – an object with additional parameters for the node. For example, the user id to mention or a link to an image.

  • Marks – an array of marks.

A document has a Schema – a description of the Nodes and Marks allowed in the document.

How to create your own node type

Now we will tell you how we created our own type of node – attachment – attached document.

In order to add your own Node type, you need to describe how to convert it to DOM and vice versa.

Attachment contains the display name of the document and a download link.

The “attrs” object describes the attributes of this node. In this case “src (link) and “filename (display name).

The “parseDOM” array contains objects that describe how this Node can be represented in the DOM, and how to get attributes from this representation. In this case, “attachment” is represented in the DOM as an “a” tag with the class “attachment”. Attributesrc is extracted from “href” and filename is the text inside the “a” tag.

Node to DOM conversion is described in the “toDOM” method. This is where the reverse of the method takes place. getAttrs: tegu a “href”, “class” and inner text are given.

Transactions

Document changes are described by transactions. A transaction is a description of the change made: adding a node, deleting a node, changing a node. The transaction is applied to the old state and the result is a new state. In code, we can create our own transaction and apply it to the state by calling the “apply” method.

The contents of the transaction can be viewed. To do this, when creating an editor, a callback “dispatchTransaction” is passed. We use it to ensure that the maximum document length is not exceeded, to send changes to the server (co-authoring), and to send information about the changed position of the selection to the server (other people’s selection is displayed in co-authoring).

When the user interacts with the interface, ProseMirror processes DOM events, converting them into transactions that apply to the current state, creating a new state based on it, based on which the new DOM is formed.

Steps are the basic building blocks that describe document changes. Transform describes an array of Steps.

Step Types:

  • ReplaceStep – Replacing a part of a document with a Slice with different content.

  • ReplaceAroundStep – replacing part of a document with a Slice while preserving this part by placing it in this Slice.

  • AddMarkStep – Adds a Mark to all content between two positions.

  • RemoveMarkStep – removes the specified Mark from the content between two positions.

  • AddNodeMarkStep – adding a Mark to the Node at the specified position

  • RemoveNodeMarkStep – remove the Mark from the Node at the specified position

  • AttrStep – changing the attribute value of the Node at the specified position.

Steps do not need to be created manually. To create changes, an empty Transform is created, and methods are called on it that add actions to this Transform. For example, adding and removing Marks or Nodes, tree operations.

Indexing

There are 2 ways to refer to parts of a document: as a tree and by offset.

Consider such a document.

In the form of a tree, it will be presented as follows.

And the DOM corresponding to this tree with offsets will be like this.

Working with a document as a tree is useful when recursive traversal of the document is required.

For example, we use this approach when loading an image. First, a preloader is added to the document, and after the image is loaded, it is replaced with a Node with a full-fledged image. To find the preloader to replace, we recursively traverse the document tree.

How the image is loaded

When the user inserts an image into a post, we generate a GUID for it, apply a transaction that inserts a pending_attached_image with that GUID at the cursor location. When the image upload completes, pending_attached_image is replaced with attached_image, which contains a link to the image.

Further, we will consider step by step how this process takes place using an example where 2 pictures are sequentially inserted into the message.

Before inserting pictures

Before inserting pictures

Document nodes before inserting the picture

Document nodes before inserting the picture

Inserting the first image...

Inserting the first image…

Document nodes during first image loading

Document nodes during first image loading

Finished uploading the first image

Finished uploading the first image

Document nodes after the first image is loaded

Document nodes after the first image is loaded

Inserting the second picture...

Inserting the second picture…

Document nodes while loading the second picture

Document nodes while loading the second picture

Completed loading of the second picture

Completed loading of the second picture

Document nodes after the second picture is loaded

Document nodes after the second picture is loaded

Here we have used 2 node types: “pending_attached_image” and “attached_image”. The first one is used for an image that has not yet been loaded, and it is replaced by the second one when the download is complete.

We also use recursive tree traversal when processing pasting from the clipboard. For example, external images are currently ignored, they are removed from the inserted content. To do this, we form a new tree by adding all inserted Nodes to it, except for images. Also, when pasting, the new document size is checked, and if it exceeds the maximum allowable document size, the pasting is canceled.

Offset is used to track the position of the selection in the document.

Node has a property “nodeSize” – the size of the entire Node, and “content.size” – the size of the content of the Node.

How we implemented mention with NodeView

We were faced with the task of creating a “mention” node – a mention of the user. This node knows the user’s id, should load the username, and show the new name when the user changes it. And while the name is loading, show the preloader. The user should not be able to edit the content of this site.

To implement this behavior, we created a NodeView for “mention”. NodeView extends a normal node. When a “mention” node is created, control is passed to the corresponding NodeView to create the DOM.

To create a “mention” node, we have described the attributes of the “mention” node in the schema: “data-guid” (GUID of this mention) and “data-user-id” (Id of the user mentioned). In the “toDOM” method of this node, we create a “mention” tag, but in fact, an element is added to the DOM at this moment, which we will create in the NodeView for mention.

In NodeView, we create the DOM ourselves and add logic that will allow this DOM to change dynamically when the user’s data is updated. With GraphQL, we subscribe to user list updates. In NodeView, we react to the updated list of users that came by subscription. If the username has changed, we update the name in the node’s DOM.

The subscription to the list of users in the NodeView does not occur directly through GraphQL, but through a special usersWatcher object that receives data from the GraphQL subscription in a special React controller component and distributes it to all currently existing mention nodes. To avoid confusion with regular GraphQL subscriptions, we have used the terms watch/unwatch to refer to a “mention” update subscription. We will cover this in more detail in another article.

Mention before name loading and after

Mention before name loading and after

This is how the HTML code of the “mention” node looks like during and after the name is loaded. “data-guid” and “data-user-id” are set in the node’s attributes, and the content of the “span” tag is set in the NodeView.

The NodeView code for the “mention” node looks like this.

            mention: (node, view, getPos) => {
                const mentionGuid = node.attrs['data-guid'];

Create span.

                const span = document.createElement('span');
                span.innerText="Loading...";
                span.classList.add('mention');
                span.setAttribute('data-guid', mentionGuid);
                span.setAttribute('data-user-id', node.attrs['data-user-id']);

                const userId = parseInt(node.attrs['data-user-id']);

                let destroy: (() => void) | undefined;
                if (that.usersWatcher) {
                    const { watch, unwatch } = that.usersWatcher;

Subscribe to user updates (watch). The callback passed to watch will be called when the list of users is updated, and it will change the text in the span.

                    const watchGuid = watch(users => {
                        const user = users.find(user => user.id === userId);

Update text in span.

                        span.innerText = user
                            ? '@' + user.displayName
                            : User #${userId} not found;
                    });

We create a callback “destroy”, which will be called when the node is destroyed. In it, we unsubscribe (unwatch) from updating the list of users.

                    destroy = () => {
                        unwatch(watchGuid);

We check for what reason the node was destroyed. It could have been removed when editing the document, or the editor could have been destroyed. In the first case, we will remove this mention from the database so that the notification for the mentioned user disappears.

There is no way in ProseMirror to check why a node is being destroyed, so we added the “isBeingDestroyed” flag to the editor, which is set when the editor is destroyed.

                        if (!that.isBeingDestoyed) {
                            that.onRemoveMention(that.id, mentionGuid);
                        }
                    };
                } else {
                    span.innerText = #${userId};
                }

We return from the NodeView the DOM of the node and callback “destroy”.

                return {
                    dom: span,
                    destroy,
                };
            },

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *