Quill, Shift-Enter and <br> tags

This post was written by eli on December 8, 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

These are my somewhat wandering and inconsistent notes as I solved the issue of Shift-Enter for Quill, on my own behalf. I ended up with the solution outlined in the last section of this post. So the vast majority of this post describes things I didn’t use in the end, however I left them as they say a few things on how to program with Quill.

Only when I had finished my own <br> inserting blot (which is what this post is about, actually), I found this rather equivalent one. Well, it’s probably better than mine, as it apparently supports <br> in pasted input. But what makes that solution interesting is how similar it is to mine (great minds think alike?), and yet it’s different in some aspects.

Note to self: There are unapplied commits in my own git repo with the options I didn’t go for eventually.

To <br> or not to <br>

Quill’s lack of support for the Shift-Enter generation of a <br> tag seems to irritate a few people out there, so the immediate question is what it’s actually needed for.

It’s worth to point out that the <br> is a bit of an anomaly in the world of HTML and DOM, since the commonly used tags for finishing horizontal packing of elements and move down vertically, are tags that enclose a chunk of HTML code, e.g. <p> and </p>, <div> and </div> etc. Hence the normal way to control vertical spacing is to enclose a vertical segment of the document, and put all the elements as the children of the DOM node that the tag generates.

Actually, there’s also <hr>, but it’s likewise messy in this sense. Maybe there are another few, but the point is that these would surely not have been included in the standard, had it been written today.

Analyzing a DOM containing <br>, it’s evident that it’s implemented as a zero by zero pixels element at the end of the line it appears in, and apparently it has this magic thing that it forces a new line.

As for Quill, it’s quite understandable that the idea of a <br> is rejected, since it breaks Quill’s model saying that vertical spacing is represented only by Block blots, and everything that comes until the next Block blot is the former blot’s children in the Parchment tree. This has been softened up a bit with the introduction of the Container blot, but <br> has still no place there.

So the question is: Is there any good reason to use <br> in documents? I’d say no. But then, when do I personally use Shift-Enter? I can think about two scenarios:

  • For creating text with narrower vertical spacing, for example in footnotes under a table. Since a regular Enter creates a paragraph, it’s typically vertically spaced from the previous one. <br> is usually closer.
  • For starting a new line within a list (i.e. within a <li> + </li> pair). Regular Enter starts a new bullet, Shift-Enter starts a new line within the current bullet.

None of these require the <br> tag, though. Shift-Enter could create a <p> or <div> tag with a class that has a smaller vertical spacing, and inside a list, it could create a <p> tag instead of a creating a new bullet.

Another important scenario is when pasting a several lines of text. Should the newlines translate into paragraphs or <br>’s? It depends on the scenario, more than anything.

How browsers respond to Shift-Enter

Most non-Quill editors use the contenteditable attribute to let the browser’s internal rich text editor do the heavy lifting.

Both Firefox and Chrome create a <div> + </div> pair when pressing Enter. With Shift-Enter, both generate a <br> tag, as expected. However if the cursor is positioned at the end of a paragraph or some other vertical enclosure, two <br> tags are created. The text that is typed immediately after pressing Shift-Enter is inserted between the two <br> tags on Firefox, and instead of the second <br> tag in Chrome.

The reason two <br> tags are inserted is that otherwise the cursor doesn’t go down to the following line. Don’t ask me why exactly, but apparently if a vertical container ends with a <br>, the cursor can’t be placed after it.

My implementation for <br>

So this is my JavaScript snippet for implementing the insertion of <br> on typing Shift-Enter. Actually, it pushes two <br> elements in the same scenarios just discussed. This means that pointless, useless but nevertheless harmless <br> might be hidden in the edited document forever (or until deleted by moving the cursor just before the new line, and pressing “Delete” on the keyboard, removing the invisible <br>).

It’s a plain JavaScript add-on, working with Quill v1.3.7. Well, working most of the time. In a plain paragraph it works, inside lists it works, but inside a code clause it may not work. And when pasting something that contains <br>, well, it’s not even in the loop.

The idea behind this code is to mimic the behavior of inserting an Image, as implemented in Quill’s Image class. If it works with an <img> it should work with a <br>.

(function() {
  var Parchment = Quill.import('parchment');
  var Delta = Quill.import('delta');

  class ShiftEnterBlot extends Parchment.Embed {} // Actually EmbedBlot
  ShiftEnterBlot.blotName = 'ShiftEnter';
  ShiftEnterBlot.tagName = 'br';

  Quill.register(ShiftEnterBlot);

  quill.keyboard.bindings[13].unshift({
    key: 13,
    shiftKey: true,
    handler: function(range) {
	quill.updateContents(new Delta()
			     .retain(range.index)
			     .delete(range.length)
			     .insert({ "ShiftEnter": true }),
			     'user');

	if (!quill.getLeaf(range.index + 1)[0].next) {
	  quill.updateContents(new Delta()
			       .retain(range.index + 1)
			       .delete(0)
			       .insert({ "ShiftEnter": true }),
			       'user');
	}

	quill.setSelection(range.index + 1, Quill.sources.SILENT);
	return false; // Don't call other candidate handlers
      }});
 })();

And now to a walkthrough of this code. Maybe it’s the only part of this post that is actually worth something.

The ShiftEnter blot

First, the Parchment and Delta modules are imported. Then the ShiftEnterBlot is defined as an extension of Parchment.Embed, which is actually Parchment’s EmbedBlot class. The naming is confusing.

I had previously failed with importing ‘blots/embed’ (apparently FormulaBlot) as well as ‘blots/block/embed’ (BlockEmbed).

How did I figure out that EmbedBlot is available as Parchment.Embed? It can be deduced from Quill’s sources, but I went for some reverse engineering of the JavaScript packaging.

So the trick was to search quill.js for lines saying exports.default, and nail down the one with

exports.default = EmbedBlot;

That line finishes the code for that export, and just below it there was a comment saying

/***/ }),
/* 49 */
/***/

so that’s the code for the next segment. Hence the index for EmbedBlot is 48. Somewhere else in quill.js, there’s a line saying

var embed_1 = __webpack_require__(48);

and then there’s

var Parchment = {
    Scope: Registry.Scope,
    create: Registry.create,
    Leaf: leaf_1.default,
[ ... ]
    Embed: embed_1.default,
    Scroll: scroll_1.default,
[ ... ]
    },
};
exports.default = Parchment;

So there we have it that EmbedBlot is exported as Parchment.Embed.

With this at hand, the new blot class is registered:

  ShiftEnterBlot.blotName = 'ShiftEnter';
  ShiftEnterBlot.tagName = 'br';

  Quill.register(ShiftEnterBlot);

It’s given a name and a tag. The name appears in the Delta, and the tag in the DOM. There’s always one blot for one DOM element and vice versa.

Keyboard handler registration

The part it the snippet above is this:

  quill.keyboard.bindings[13].unshift({
    key: 13,
    shiftKey: true,
    handler: function(range) {

This is where the function that is given next is set to become the first function to be processed when Shift-Enter is pressed. My first attempt was to use quill.keyboard.addBinding(), but since the handlers are processed in the order they were registered. Since Quill’s default handlers are added at initialization, the only way to prevent them is to add them in the configuration. In other words, the binding should appear where the Quill object is created, under modules: { keyboard: { bindings: .. } } }.

Or with the hack shown above.

Shoving in a <br> or two

So the handler goes on with

	quill.updateContents(new Delta()
			     .retain(range.index)
			     .delete(range.length)
			     .insert({ "ShiftEnter": true }),
			     'user');

This is where my implementation and the other one go different ways: I’m creating a Delta object, requesting to add a ShiftEnter blot. The other implementation uses insertEmbed() instead. Which I failed colossally with: I got a <br> inserted indeed, but the DOM became populated with leftovers from a Cursor blot. In short, it messed up. I suspect that it worked on the other implementation, because it sets the value to “\n” and length() accordingly. But I’m not sure.

This way or another, pushing a Delta is based upon the formal API, and is hence safer, I guess.

As for the second, optional <br>, the code goes on with:

	if (!quill.getLeaf(range.index + 1)[0].next) {
	  quill.updateContents(new Delta()
			       .retain(range.index + 1)
			       .delete(0)
			       .insert({ "ShiftEnter": true }),
			       'user');
	}

The getLeaf() call gets the ShiftEnterBlot object of the newly inserted <br>, and checks if its the last sibling in the subtree. If so, the <br> is the last element in the DOM’s subtree, since the trees are equally shaped. And if this is indeed the case, a second blot is pushed in. The other implementation does something similar, but instead of looking at the blot’s @next property, it compares the parents of the current blot with the one at index+1.

And then finally, the cursor is moved and the handler returns with false to indicate that the keystroke’s processing is completed (i.e. the other handlers, if present, aren’t reached):

	quill.setSelection(range.index + 1, Quill.sources.SILENT);
	return false; // Don't call other candidate handlers

Some notes on the other implementation

The other implementation has other differences from mine. The most important one is that it extend the Break blot, which in turn extends Parchment.Embed. I went for Parchment.Embed directly. This probably explains the part in the other implementation that goes

    insertInto(parent, ref) {
      Embed.prototype.insertInto.call(this, parent, ref);
    }}

This seems to skip Break class’ insertInto() method, by going directly to Embed’s method. So it would probably have been better to extend Parchment.Embed like I did.

Another thing about that other implementation is that it adds a matcher for <br> tags as follows:

function lineBreakMatcher() {
  var Delta = Quill.import("delta");
  var newDelta = new Delta();
  newDelta.insert({ break: "" });
  return newDelta;
}

However oddly enough, this generates <br> elements in the DOM without any reference to their related blot (i.e. without a __blot property), which causes exceptions in Quill’s on core code as it traverses the DOM. So I’m not sure what happened here.

A completely different approach

As mentioned above, I ended up adopting the method explained below. However I wouldn’t recommend this on an editor for other people to use, because odds are that they will complain about weird behavior. It’s not weird when one understands how it works, so this is good for my own use.

Recall that the real reason I want Shift-Enter is for starting a new paragraph inside a list item. So how about inserting a pair of <p>-</p> tags on Ctrl-Enter, and declare that as Inline blot, so it can live under Block blots?

The problem with inline blots is that if there are two such one after the other (say, multiple small paragraphs inside a list bullet), they will be fused into one by optimization. But that can be solved. Consider this:

(function() {
  var Inline = Quill.import('blots/inline');
  var Parchment = Quill.import('parchment');

  class ShiftEnterBlot extends Inline {
    static create(value) {
      let node = super.create(value);
      node.__rand = value;
      return node;
    }

    static formats(domNode) {
      let blot = Parchment.find(domNode);

      if (blot && blot.parent && blot.parent.children &&
	  blot.parent.children.head !== blot)
	return domNode.__rand;
    }
  }

  ShiftEnterBlot.blotName = 'ShiftEnter';
  ShiftEnterBlot.tagName = 'p';
  ShiftEnterBlot.className = 'shift-enter-class';

  Inline.order.push(ShiftEnterBlot.blotName);

  Quill.register(ShiftEnterBlot);

  quill.keyboard.bindings[13].unshift({
    key: 13,
	shiftKey: true,
	handler: function(range) {
	quill.format('ShiftEnter', 'rand-' + Math.floor(1000000000 * Math.random()));

	return false; // Don't call other candidate handlers
      }});
 })();

So when Ctrl-Shift is called, a call to format() is made, to apply a bogus inline format named ShiftEnter on the current selection or position. It’s like applying bold or italics, but with the wrapper tag <p> instead. So it creates a new paragraph. Actually, something of the sort of

<p class="shift-enter-class">text</p>

so these <p> enclosures have a dedicated class. Useful for obtaining lines with smaller space, just like <br> usually gives. If desirable, of course.

In order to prevent the fusing of adjacent paragraphs of this sort, each is given a random number as its value. This value is kept in a hidden property of the resulting DOM object, __rand. What prevents these from fusing into one chunk is the formats() method, which returns this random number. By doing so, Quill treats each segment with different random number as non-fusable. By the same coin, formats() returns an undefined value (i.e. no return statement at all) if the ShiftEnter blot is the first one in the line: It’s not just pointless to start a <p> pair there, but necessary for e.g. list items: Since pressing Enter causes Quill to copy all formats to the next paragraph, or list item, this would have meant that the line begins a line after the bullet.

By virtue of Quill’s optimization and canonization, these <p> enclosures are never nested into each other.

This random number is invisible in innerHTML, since it’s a property of the DOM objects and not an attribute, however it’s visible (as it has to be) in the Delta representation.

The Inline.order.push() call makes sure that ShiftEnter isn’t cut into pieces by other Inline formats, as explained in a separate post of mine.

Even though Shift-Enter behaves as one would expect when it’s used to terminate a line, some unexpected things happen when used in the middle of a line, or when text is selected. In particular it may come unexpected that the correct way to push down existing text into a new line is to select it and then press Shift-Enter. It’s not weird if one understands that it’s some kind of inline text formatting, which is why I argued above that it’s good for me but not for anyone to use.

Add a Comment

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