Rich text editors, many of you have never heard of them before. It’s a pit no one wants to tread in. How bad is it?

Here I pick part of a big brother’s answer on Zhihu, if you are interested, you can go to see. It takes too long for an editor to achieve commercial-grade quality from the main examples we’ve seen so far:

  • Quill editor.QuillFour years have passed since the first Issue was received in 2012 and the 1.0 version was released in 2016.
  • Prosemirror editor.ProsemirrorThe author had been in development for half a year before the official open source fund maintenance in 2015, and nearly three years had passed by the time version 1.0 was released.
  • Slate has been open source for nearly two years and still has a bunch of weird bugs.

These one-man editor projects are measured in years to achieve stable quality. Given the current “go live next week” pace of the Internet, a few years is not worth it. Therefore, under the constraints of manpower and time rationality, using open source framework is the best choice.

The desire for a highly configurable, modular editor meant that this was not an out-of-the-box application, and Quill was already an application with a lot of style and interaction logic integrated, sometimes with some specification requirements not fully met. Slate is based on the React view layer, and our technology stack is Vue, so we don’t consider it. We can study it later when we have the opportunity, so we finally choose Prosemirror. But the other two editor frameworks are still very powerful and worth learning.

At present, prosemirror can find almost no Chinese materials by using the search engine, so you can only search in forums and issues or ask questions to the author if you have any problems. The following content is simplified from the official website, plus their own understanding of it in the process of use. I hope you will be interested in Prosemirror after reading it, and learn something from the author’s design ideas and share with us.

ProseMirror profile

A toolkit for building rich-text editors on the web

Prosemirror author Marijn is the author of the Codemirror editor, which is already used in Chrome and Firefox’s built-in debugging tools, and the Acorn interpreter, which is dependent on Babel.

Prosemirror is not a big, monolithic framework, it is made up of countless small modules, like Lego, it is a stacked editor.

Its core libraries are:

  • prosemirror-model: Defines the editor’s document model, which describes the data structure of the editor’s content
  • prosemirror-state: Provides data structures that describe the overall state of the editor, includingselection(select) and from one state to the nexttransaction(transaction)
  • prosemirror-view: Implements a user interface component that displays a given editor state as an editable element in the browser and handles user interaction
  • prosemirror-transform: includes the ability to modify documents by recording and replaying, which isstateIn the moduletransactionAnd it makes undo and collaborative editing possible.

In addition, prosemirror provides many modules, such as prosemiror-Commands basic editing commands, prosemiror-keymap key binding, prosemiror-History history, prosemiror-commands basic editing commands, prosemiror-Keymap key binding, prosemiror-History, Prosemirror – Inputrules input macro, Prosemirror – Collab collaborative editing, Prosemirror – Schema-basic simple document mode, etc.

You should now have an idea of what each of them does, and they form the basis of the entire editor.

Implement an editor demo

import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"

let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
Copy the code

Let’s look at what the code above does, starting with the first line. Prosemirror asks you to specify a schema that the document conforms to. So a basic schema is introduced from Prosemiror-schema-basic. So what is this schema?

Because Prosemirror defines its own data structure to represent document content. Between the Prosemirror structure and THE HTML Dom structure, there is a need to parse and transform. The bridge between the two is our schema, so we need to understand the prosemirror document structure first.

Prosemirror Document structure

The prosemirror document is a Node that contains zero or more Fragments of Child Nodes.

Somewhat similar to the recursion and tree structure of the browser DOM. But it differs in the way it stores inline content.

<p>This is <strong>strong text with <em>emphasis</em></strong></p>
Copy the code

In HTML, the tree structure looks like this:

p //"this is "
  strong //"strong text with "
	em //"emphasis"
Copy the code

In Prosemirror, the inline content is modeled as a flat sequence, with strong and EM (Mark) as additional data from paragraph(Node) :

"paragraph(Node)"
// "this is " | "strong text with" | "emphasis"
                    "strong(Mark)"       "strong(Mark)"."em(Mark)"
Copy the code

The object structure of a Prosemirror document is as follows

Node:
  type: NodeType // Contains Node names, attributes, etc
  content: Fragment // Contains multiple nodes
  attrs: Object // Custom attributes, image can be used to store SRC, etc.
  marks: [Mark, Mark...] // An array containing a list of Mark instances, such as em and strong
Copy the code
Mark:
  type: MarkType // Contains Mark's name, attributes, etc
  attrs: Object // Custom attributes
Copy the code

Prosemirror provides two types of indexes

  • Tree type, this sumDom structureSimilar, you can usechildorchildCountAnd other methods directly access child nodes
  • Flat sequences of tags, which take the index in the tag sequence as the location of the document, are a counting convention
    • At the beginning of the document, the index position is 0
    • Entering or leaving a node that is not a leaf is counted as a flag
    • Each node in a text node counts as a tag
    • Leaves with no content (e.gimage) is also a tag

For example, there is an HTML fragment for

<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
Copy the code

The count is marked as

0   1 2 3 4    5
 <p> O n e </p>

5            6   7 8 9 10    11   12            13
 <blockquote> <p> T w o <img> </p> </blockquote>
Copy the code

Each node has a nodeSize property that represents the size of the entire node. Resolving these positions manually involves quite a bit of counting, and Prosemirror provides the Node.resolve method to resolve these positions and get more information about the location, such as what the parent Node is, the offset from the parent Node, the ancestor of the parent Node, and so on.

Now that we know the data structure of Prosemirror, and that schema is the schema that is converted between two documents, going back to where we were, we introduced a basic schema from Prosemiror-schema-basic, so what does this basic schema look like? Look at the last line of the source code

export const schema = new Schema({nodes, marks})
Copy the code

A schema is an instance of a schema generated by passing in Nodes and Marks. The code before the instance is defining Nodes and marks. Collapse the code to see that Nodes is

{
  doc: {... }// Top-level documentblockquote: {... }//<blockquote>code_block: {... }//<pre>hard_break: {... }//<br>heading: {... }//

..

horizontal_rule: {... }//<hr>image: {... }//<img>paragraph: {... }//<p>text: {... }/ / text } Copy the code

Marks,

{
  em: {... }//<em>link: {... }//<a>strong: {... }//<strong>code: {... }//<code>
}
Copy the code

They represent the types of nodes that might appear in the editor and how they are nested. They each contain a set of rules that describe the relationship between a Prosemirror document and a Dom document, and how to convert a Dom to a Node or a Node to a Dom. Each node in the document has a corresponding type. Start at the top doc:

doc: {
  content: "block+"
}
Copy the code

Each schema must define a top-level node, a DOC. Content controls which sequences of child nodes are valid for this node type. For example, “paragraph” represents a paragraph, “paragraph+” represents one or more paragraphs, and “paragraph*” represents zero or more paragraphs. You can use a range like a regular expression after the name. At the same time you can also use combination expressions such as “heading com.lowagie.text.paragraph +”, “{com.lowagie.text.paragraph | blockquote} +”. Here “block +” said “(com.lowagie.text.paragraph | blockquote) +”. Then look at EM:

em: {
  parseDOM: [{tag: "i" },
    { tag: "em" },
    { style: "font-style=italic"}].toDOM: function() {
    return ["em"]}}Copy the code

ParseDOM and toDOM represent conversions between documents, and the code above has three parsing rules:

  • <i>The label
  • <em>The label
  • font-style=italicThe style of the

When a rule is matched, it is rendered as an HTML structure.

Similarly, we can implement an underline mark:

underline: {
  parseDOM: [{tag: 'u' },
    { style: 'text-decoration:underline'}].toDOM: function() {
    return ['span', { style: 'text-decoration:underline'}}}]Copy the code

Both Node and Mark can use attrs to store custom attributes, such as image, SRC, Alt, and title.

Back to

import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"

let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
Copy the code

We use editorstate.create to create the editor’s state state through the base rule schema. Next, we create an editor view for the state and attach it to document.body. This renders our state state as an editable DOM node, and a transaction occurs as the user types.

Transaction

Transactions occur when users type or otherwise interact with the view. Describes changes made to state and can be used to create new states and then update the view.

Here is prosemirror’s simple circular data flow: The editor view displays a given state, and when some event occurs, it creates a transaction and broadcasts it. This transaction is then typically used to create a new state, which is provided to the view using the updateState method.

DOM Event ↘ EditorView Transaction ↙new EditorState
Copy the code

By default, state updates happen at the bottom, but you can write plugins or configure views to do this. For example, let’s modify the view-creating code above:

// (Imports omitted)
let state = EditorState.create({schema})
let view = new EditorView(document.body, {
  state,
  dispatchTransaction(transaction) {
    console.log("create new transaction")
    let newState = view.state.apply(transaction)
    view.updateState(newState)
  }
})
Copy the code

Added a dispatchTransaction prop to the EditorView, which is called every time a transaction is created. Written this way, each state update must be manually called updateState.

Immutable

Prosemirror data structures are immutable, you cannot assign them directly, you can only create new references through the corresponding API. But the same parts are shared between different references. This is like having a deeply nested document tree that is immutable. Even if you change only one leaf node in one place, a new tree will be created, but the new tree will be shared with the original tree except the changed leaf node. With immutable, you can undo redo by toggling back and forth between different states each time you type the editor to generate a new state. At the same time, updating the state redraw document becomes more efficient.

State

What constitutes a prosemirror state? State has three main components: your document doc, the current selection selection, and the currently storedMark set storedMarks.

When you initialize state, you can provide it with the initial document to use via the doc property. Here we can use the DOM structure under THE ID content as the editor’s initial document. The Dom parser converts the Dom structure into a Prosemirror structure using our parsing schema.

import { DOMParser } from "prosemirror-model"
import { EditorState } from "prosemirror-state"
import { schema } from "prosemirror-schema-basic"

let state = EditorState.create({
  doc: DOMParser.fromSchema(schema).parse(document.querySelector("#content"))})Copy the code

Prosemirror supports selection of multiple types (and allows third-party code to define new selection types; note that any new type needs to be inherited from Selection). Selection, like documents and other state-related values, is immutable, and changing a selection creates a new selection and keeps its new state. Selection has at least from and to pointing to the location of the current document to indicate the scope of the selection. The most common selection type is TextSelection, which is used for cursors or selected text. Prosemirror also supports NodeSelection, for example, when you press CTRL/CMD to click on a Node. The selection ranges from the position before the node to the position after it. StoredMarks represents a set of marks that need to be applied to the next input.

Plugins

Plugin extends the editor and editor state in a variety of ways. When you create a new state, you can provide it with a set of plugins that will be stored in this state and any states derived from it. It can also influence how transactions are applied and how editors based on this state behave. When a plugin is created, it is passed an object specifying its behavior.

let myPlugin = new Plugin({
  props: {
    handleKeyDown(view, event) {
      // Called when a keyDown event is received
      console.log("A key was pressed!")
      return false // We did not handle this}}})let state = EditorState.create({schema, plugins: [myPlugin]})
Copy the code

When a plug-in needs its own Plugin state, it can define it through the state property.

let transactionCounter = new Plugin({
  state: {
    init() { return 0 },
    apply(tr, value) { return value + 1}}})function getTransactionCount(state) {
  return transactionCounter.getState(state)
}
Copy the code

The above plug-in defines a simple Plugin state that counts transactions that have been applied to state. Here is a helper function that calls plugin’s getState method to get plugin state from the full editor’s state.

Because editor state is immutable, and plugin state is part of that state, plugin state is also immutable, meaning that their apply methods must return a new value, not modify the old value. A plugin can often add additional information metadata to a transaction. For example, when an undo history operation is performed, the generated transaction is marked, and when the plugin sees it, it does not treat it like a normal transaction, it treats it specifically: removing it from the top of the undo stack and placing it on the redo stack.

Going back to the original example, we can bind command to the KeyMap plugin for keyboard input, as well as the History plugin, which does undo and redo by observing transactions.

// (Omitted repeated imports)
import { undo, redo, history } from "prosemirror-history"
import { keymap } from "prosemirror-keymap"

let state = EditorState.create({
  schema,
  plugins: [
    history(),
    keymap({"Mod-z": undo, "Mod-y": redo})
  ]
})
let view = new EditorView(document.body, {state})
Copy the code

Plugin is registered when state is created, and with the view created by state you will be able to undo the last change by pressing Ctrl-Z (or cmD-z on OS X).

Commands

Undo, redo, above, is a command, and most editing operations are treated as commands. It can be bound to menus or keys, or otherwise exposed to the user. In Prosemirror, command is the function that implements editing operations, most of which are done using the editor state and dispatch functions (editorView.dispatch or some other transaction function). Here’s a simple example:

function deleteSelection(state, dispatch) {
  if (state.selection.empty) return false
  if (dispatch) dispatch(state.tr.deleteSelection())
  return true
}
Copy the code

When command is not available, you should return false or do nothing. If applicable, you need to dispatch a transaction and then return true. In order to be able to check whether command applies to a given state without actually executing it, the Dispatch argument is optional. When no Dispatch is passed in, Command should only return true, Instead of doing anything, this can be used to make your menu bar gray to indicate that the current command is not available. Some commands may need to interact with the DOM, and you can pass it a third argument, view, which is the view of the entire editor. Prosemiror-commands provides many editing commands, from simple to complex. It also comes with a basic KeyMap that provides key bindings for editors to use to enable editors to perform input and delete operations, binding a number of Schema-independent commands to the keys that are typically used for them. It also exports a number of command constructors, such as toggleMark, which passes in a mark type and custom attribute attrs and returns a command function that toggles the Mark type on the current selection. To customize the editor, or to allow users to interact with Node, you can write your own command. For example a simple clear style format brush command:

function clear(state, dispatch) {
  if (state.selection.empty) return false;
  const{$from, $to } = state.selection;
  if (dispatch) dispatch(state.tr.removeMark($from.pos, $to.pos, null));
  return true
}
Copy the code

conclusion

This should be a quick introduction to how Prosemirror works, so that you don’t have to wonder where to start when you first encounter it and see so many libraries. Prosemirror in addition to the concepts described above, props, NodeViews, etc., allow you to control the way views draw documents. If you’d like to learn more about Prosemirror, head over to its website and forums to become a contributor.