Quill internals and the base blot classes: Block, Inline, Embed and friends

This post was written by eli on December 16, 2021
Posted Under: JavaScript,Rich text editors

Everything in this post relates to Quill v1.3.7. I don’t do web tech for a living, and my knowledge on Quill is merely based upon reading its sources. Besides, I eventually picked another editor for my own use.

Introduction

Quill offers a variety of blot classes, which are the base for creating custom blot classes by virtue of extension. Understanding the ideas behind these classes is important, in particular as extending the most suitable class for custom blots is required to ensure its intuitive behavior in the editor.

This post focuses on the three main base classes: Block, Inline and Embed, which are intended to be the base for custom blot classes. There are however a few other base classes, which are required to understand how things work under the hood.

As discussed in a separate post of mine, the DOM and Parchment tree (i.e. the Scroll) have the same tree structure, and hence there’s a blot object in the Parchment tree for each node in the DOM and vice versa, with a few exceptions that are irrelevant for the current topic.

Recall that a pair of HTML tags that form an enclosure (e.g. <strong> and </strong>) correspond to a single DOM node, having the elements within the closure as its children in the DOM tree. Hence if some text is marked as bold in the editor, a blot is created to correspond to the DOM object for the enclosure of <strong> tags, and the bold text itself is placed as a child of this blot.

A few base blot classes

In essence, there isn’t a single blot class that isn’t interesting when developing a custom class, since imitating an existing implementation is the safest way to get it right. But these are those most important to know about:

  • TextBlot (exported as blots/text, defined in blots/text.js), effectively same as Parchment.Text (parchment/src/blot/text.ts), corresponding to #text DOM items, and is hence always a leaf (i.e. it has no children). Not surprisingly, this is the blot class used for text.
  • Embed (exported as blots/embed, defined in blots/embed.js) extending Parchment.Embed (parchment/src/blot/embed.ts): Intended for tags such as <img>, this blot class is used for insertion of elements that come instead of text, but isn’t text. In the Delta model, it occupies a horizontally packed element with a length of one character.
  • BlockEmbed (exported as blots/block/embed, defined in blots/block.js) extending Parchment.Embed (parchment/src/blot/embed.ts) is essentially an Embed blot that is allowed where a Block blot would fit in, so it’s occupies vertical space of its own, rather than being horizontally packed.
  • Inline (exported as blots/inline, defined in blots/inline.js) extending Parchment.Inline (parchment/src/blot/inline.ts): This is the blot class intended for formatting tags such as bold, italic and also links (with <a href=”"> tags), and is used for any tag enclosure that don’t cause the browser to jump to a new line. The default tag for this blot is <span>.
  • Block (exported as blots/block, defined in blots/block.js) extending Parchment.Block (parchment/src/blot/block.ts): Its default tag is <p>, which implies its intention: Usage for tag enclosures that create vertical segments. Its direct children are allowed to be of blots of the classes Inline, Parchment.Embed or TextBlot, or classes derived from these. In other words, child blots that create horizontal packing of elements.
  • Container (exported as blots/container, defined in blots/container.js) effectively same as Parchment.Container (parchment/src/blot/abstract/container.ts): This class has no default tag, and is used for vertical segment enclosures that must be nested, for example <ul> and <li>. Its allowed direct children may only be other block-type blot classes, that is Block, BlockEmbed and Container.

Almost all blot classes are somehow extensions of these six.

Note that first three blot classes listed here are fundamentally different from the other three: The first three, TextBlot, Embed and BlockEmbed, represent content, and hence their related Parchment classes extend LeafBlot. Inline and Block, on the other hand, represent formatting, and hence their related Parchment classes extend FormatBlot. Container, unlike the other five, extend ShadowBlot: It can’t be generated directly by virtue of formatting, but only internally to create a tree structure that is needed indirectly by some formatting command.

Block blots are considered to represent a newline (“\n”) character, and their length is accordingly one. In other words, the index in the document after a Block blot is higher than the one before by one.

Quill’s built-in format blots is listed here. The division into Block, Inline and Embed in that list is somewhat inaccurate, but accurate enough for end-user purposes (in particular regarding List being a Container, not Block).

Quill’s Parchment tree model

The relationship between the browser and Quill is bidirectional, so the browser makes certain changes to the document, and Quill controls the structure of the Scroll (i.e. the Parchment tree), and hence also the DOM tree.

The main influence of the tree model is on the document’s top hierarchy node, which is the editor’s root DOM node, or interchangeably, the Parchment tree’s root (the Scroll blot). All children of this top node are the document’s lines, and all document lines are children of this top node. In other words, all <p>, <div>, <h1>, <h2> and similar DOM elements are always direct children of the root DOM node. Accordingly, the corresponding blot classes for these tags always extend the Block blot class (possibly indirectly).

All other blots, which represent horizontally packed DOM elements, form a subtree of a single block blot. In other words, there’s a linear sequence of lines from the document’s beginning to end, each represented by a block blot. Inside each line, there’s only text, inline formatting or inline embedded objects. Vertical packing occurs only at the top level, horizontal formatting can have any depth.

The only exception is the Container blot, however its use doesn’t conflict with the concept of document lines. Rather, it allows grouping block-like blots, as the children of a Container can only be Block, BlockEmbed and Container. This allows a not completely flat tree structure from the top level, but the tree can still be traversed from its beginning to end, and walk from line to line, each represented by either a BlockEmbed blot, or a Block blot with children that constitute horizontally packed elements. Containers merely group block-like blots.

The Container blot is applied when nesting is inevitable. For example, bulleted and enumerated lists are interesting cases, because they require a blot to correspond to the <ul> or <ol> tag enclosure, and then a blot for each <li> enclosure. So clearly, the blot that corresponds to <ul> or <ol> must be a direct child of the Scroll blot. On the other hand, the former blot must have children which are Block blots, corresponding to <li> enclosures.

By making the blots referring to <ul> and <ol> extend the Container class, and make the <li>’s blot extend the Block class, the latter can be children of the former, which is necessary to mimic the DOM tree structure (see formats/list.js). But since <li> is a Block blot (it must be, or else how could its children be text?) it can’t have a Container nor Block blot as a direct child. As a result, nested lists are not generated by Quill. When such are needed, CSS is used to indent <li> items visually, to make an appearance of a nested list.

Not surprisingly, the Scroll blot class extends Parchment.Container, and allows only Block, BlockEmbed and Container as its direct children (see blots/scroll.js).

From a user’s point of view, this means that everything in the document is in the context of a line that is of a single formatting type. It’s either a header, a list, a plain paragraph or something of that sort. One can’t insert a header nor a code block into a bulleted list, for example, even though that wouldn’t violate the DOM structure. In fact, one can’t insert a <p> paragraph enclosure into a list either, nor a code block.

Or as said in this Quill’s doc page:

While Inline blots can be nested, Block blots cannot. Instead of wrapping, Block blots replace one another when applied to the same text range.

Had it not been for this simple line structure, it would have been significantly more difficult to obtain a concise Delta representation.

The concept of a “line”

Another way to understand the tree structure, is looking at the implementation of API’s getLines() (defined in core/quill.js), which is described “Returns the lines contained within the specified location”. It would be more accurate to say “within the specified range”. Anyhow, this function merely calls lines() as defined in blots/scroll.js:

  lines(index = 0, length = Number.MAX_VALUE) {
    let getLines = (blot, index, length) => {
      let lines = [], lengthLeft = length;
      blot.children.forEachAt(index, length, function(child, index, length) {
        if (isLine(child)) {
          lines.push(child);
        } else if (child instanceof Parchment.Container) {
          lines = lines.concat(getLines(child, index, lengthLeft));
        }
        lengthLeft -= length;
      });
      return lines;
    };
    return getLines(this, index, length);
  }

First, I’d mention that forEachAt() (and similar methods) is implemented in parchment/src/collection/linked-list.ts. As its name implies, it calls a function on all blots within a range (index, length), setting the index and length for each call relative to the blot being processed.

Also, isLine() is a local function defined as:

function isLine(blot) {
  return (blot instanceof Block || blot instanceof BlockEmbed);
}

With this information, the mechanism is quite clear: All blots in the requested loop are scanned. Only those that are extended from Block or BlockEmbed classes are added to the list. If a blot that is extended from the Container class is encountered, lines() calls itself recursively on that blot, or in other words, the subtree is scanned in the same manner.

The takeaway from this code dissection is that only Block and BlockEmbed based blots are considered “lines”, and that Container blocks are just a way to create a parent node for a subtree.

Likewise, getLine() is defined as to “Returns the line Blot at the specified index within the document”. This method just wraps line(), which is also defined in the same file:

line(index) {
  if (index === this.length()) {
    return this.line(index - 1);
  }
  return this.descendant(isLine, index);
}

@this in the code above is the scroll object. So once again, the same principle. In this case, the “descendant” method is used to look up a blot that is extended from either Block or BlockEmbed somewhere down the tree. Container blots aren’t related to directly here, because they are just passed through as the tree is traversed.

Add a Comment

required, use real name
required, will not be published
optional, your blog address