Quill internals and the jumping cursor + the Selection and Cursor classes

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

TL;DR

If you’ve reached this page, odds are that you’re trying to figure out why the cursor is jumping under certain conditions, for example as in this still unresolved issue.

While I may not answer that directly, I can suggest one thing to do: With your browser’s JavaScript debugger (Google Chrome recommended), put a breakpoint on the Selection class’ setNativeRange() method, and an additional breakpoint on the return statement marked below in read. It will typically be something like this in a non-minimized quill.js:

    key: 'setNativeRange',
    value: function setNativeRange(startNode, startOffset) {
      var endNode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : startNode;
      var endOffset = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : startOffset;
      var force = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;

      debug.info('setNativeRange', startNode, startOffset, endNode, endOffset);
      if (startNode != null && (this.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) {
        return;
      }

Now, notice the part marked with red. If setNativeRange() returns because of this, odds are that an attempt to put the cursor in its right position failed, and that caused the cursor to jump to where the browser chose to put it.

And if that isn’t the case, maybe the position that is chosen by this call’s parameters is wrong.

If this method isn’t called at all when the cursor jumped, odds are that Quill wasn’t clever enough to know that a cursor repositioning was necessary. All requests to set the selections, by API or by internal functions, end up with this method. So if this method isn’t called, Quill didn’t even try.

The rest of this post describes the three reasons I found for my case of jumping cursor (well, actually two and a half), and how I solved them. All of which were because of the same reason: For the sake of DOM tree optimization, Quill merged two DOM text elements while the cursor was part of one of them. As a result, the browser moved the selection the end of the text element that had been deleted. And then the mechanisms that are in place in Quill to return it to the right place failed to do their work.

And here’s the bad news: To understand the reason and fix, one must first understand a few things about the Cursor and Selection classes. So take a deep breath.

The Cursor blot class

First and foremost, this blot’s name is misleading: The cursor that is displayed is always the one produced by the browser. This blot isn’t involved in generating anything visual. It would have been more accurate to call it FormatHolder or something like that.

This is going to cause confusion, so let me reiterate: There’s the Cursor blot object, which isn’t a cursor at all, and it may or may not be on the same place as the visual cursor. So when I say “cursor” below, I refer to the visual thing, not the Cursor class or object.

The purpose of the Cursor class is to allow an Inline format (e.g. bold and italic) to be enabled when there’s no text related to it. The typical situation is when we click on the “Bold” button with nothing selected. Nothing happens, but if we type text, it’s in bold. Which is the same as when typing text immediately after text that is already bold.

So the Cursor class is used to mark the existence of single 0xfeff Unicode character, which is a non-breakable zero-width white space. In the DOM, the equivalent of <span class=”ql-cursor”>&#65279</span> is inserted. The #text DOM element itself doesn’t have a corresponding blot, which is quite unusual in Quill (except for Iframes’ content). The declared length of the Cursor blot and its content is zero (which is unusual too for a #text DOM element with something in it).

Once the enclosing Inline blot is filled with some real text, the Cursor blot is removed. If it becomes empty again due to deletion of characters, the Cursor blot is inserted again.

Effectively what happens is that this character becomes the “character before”, and hence prevents the formatting to get optimized away. It’s purpose is to influence the characters to be added after it. For example, with Bold formatting, it means that the <strong></strong> pair has something between them, and that the text that is typed at this point will be in bold. Had it not been for the insertion of the cursor, it would have become impossible to enable a format. Pressing the “Bold” button would do nothing, for example, but selecting text and turning it to bold would still work.

The generation of the single Cursor object for the entire Quill object is done by the constructor of the Selection class (core/selection.js), and is kept as the class’ this.cursor. This class also applies the Cursor format when the selection is of zero length. More precisely, it calls its own getNativeRange() method to find the range of nodes in the DOM that correspond to the current selection. If the collapsed property is true (indicating a zero-length selection), plus the format is not for a Block blot, and selection isn’t the cursor itself, it ends up with

let after = blot.split(nativeRange.start.offset);
blot.parent.insertBefore(this.cursor, after);

which cuts the underlying blot where the Cursor blot will go, and then it’s inserted there. insertBefore() also takes care of updating the DOM.

But even more important, if a Cursor blot is inserted (or was already present), the selection is always turned around it:

this.setNativeRange(this.cursor.textNode, this.cursor.textNode.data.length);

In other words, that single cursor character is selected. This means that if the user types or pastes anything, the browser puts it instead of the cursor character.

When the Cursor blot should go away because some text has come instead, CursorBlot’s update() method calls its own restore() method (see blots/cursor.js). Alternatively, this method might be called by the Selection class’ update() method if it deems that the selection range has changed.

And by the way, the fact that the Cursor blot includes a character in the DOM, makes it appear in in the innerHTML property, which is unfortunate for those who want to save the innerHTML of the editor container. It’s not a really big deal, as it’s a transparent zero-width element, but it might break a word in the middle, making it unsearchable. A common solution is to inject the the Delta into a second, bogus, Quill instance, and use its HTML.

The restore method

As just mentioned, restore() is defined blots/cursor.js. This method is called to remove the Cursor blot from where it has been inserted. Or more precisely, to clean up: Since the cursor character itself was marked as selected when it was inserted, it might have been replaced with user text. So just removing the entire package might remove the newly inserted text. As this is highly related to the jumping cursor, here’s a walkthrough of the method’s code:

  restore() {
    if (this.selection.composing || this.parent == null) return;
    let textNode = this.textNode;
    let range = this.selection.getNativeRange();
    let restoreText, start, end;

After checking if the method should be executed at all, some variable initialization: @textNode is the DOM node that is wrapped by the <span> pair that the Cursor blot relates to. @range is assigned with the range, in DOM terms, of the current selection.

A short word about this.selection.composing: Composition is the use of several keystrokes to create an Asian character (e.g. Korean Hangul). This flag is set on compositionstart events and turned off on compositionend, both events generated by the browser to indicate such a keystroke session. Hence nothing should happen while this is going on.

    if (range != null && range.start.node === textNode && range.end.node === textNode) {
      [restoreText, start, end] = [textNode, range.start.offset, range.end.offset];
    }

If the Cursor blot’s text DOM element, and only it, is included in the selection, @restoreText, @start and @end are assigned with @textNode and the positions of the selection inside it. This is the case if a character was typed with the keyboard or text was pasted, but may not be if the browser’s cursor was just moved.

    // Link format will insert text outside of anchor tag
    while (this.domNode.lastChild != null && this.domNode.lastChild !== this.textNode) {
      this.domNode.parentNode.insertBefore(this.domNode.lastChild, this.domNode);
    }

The comment here speaks for itself (and it’s quite irrelevant). And then:

    if (this.textNode.data !== Cursor.CONTENTS) {
      let text = this.textNode.data.split(Cursor.CONTENTS).join('');
      if (this.next instanceof TextBlot) {
        restoreText = this.next.domNode;
        this.next.insertAt(0, text);
        this.textNode.data = Cursor.CONTENTS;
      } else {
        this.textNode.data = text;
        this.parent.insertBefore(Parchment.create(this.textNode), this);
        this.textNode = document.createTextNode(Cursor.CONTENTS);
        this.domNode.appendChild(this.textNode);
      }
    }

The condition of the first if statement is true iff the data inside the cursor has been modified. So this entire piece of code relates to when text has been typed or pasted, and hence wiped out the cursor character

So first, @text is assigned with the (updated) content of textNode, with the cursor character removed from the string, if it’s still present (why would it be?). In short, the browser put the text typed or pasted by the user instead of the cursor, and now it’s given as @text.

Then, the sibling to the right in the Parchment is checked if it’s a TextBlot. It will be if the cursor was placed along with text, but if it’s next to an Embed blot, it might not be.

So if the next sibling is a text blot, the text in the Cursor blot is injected into it before the existing text and Cursor blot gets back to having its special cursor character. Also @restoreText is updated to become the the sibling’s DOM element, indicating that the selection should be restored to that element, and not to the Cursor blot, which is now out of the game.

If the next sibling isn’t a text blot, the DOM node containing the text is assigned with @text (effectively this removes the cursor character, if it was present), and then a new text blot is created and inserted into the Parchment (and hence also into the DOM). So basically, insert text. But oops, now we lost the the this.textNode to play around with. So a new one is created, just like in the constructor method.

And then the Cursor blot is taken off the tree:

    this.remove();

Keep in mind that @this stands for the Cursor blot in the parchment. Its removal merely takes it off the Parchment and the DOM tree. And then…

    if (start != null) {
      [start, end] = [start, end].map(function(offset) {
        return Math.max(0, Math.min(restoreText.data.length, offset - 1));
      });
      return {
        startNode: restoreText,
        startOffset: start,
        endNode: restoreText,
        endOffset: end
      };
    }
  }

… the grand finale: If the Cursor blot, and only it was the selection, return the range to be selected when everything is over, in terms of the DOM text node and starts and stop offsets. These offsets are limited to between zero and the length of the text, which is quite understandable.

Note that if this isn’t the case, the function returns nothing. This is important, because the Cursor class’ update method goes

  update(mutations, context) {
    if (mutations.some((mutation) => {
      return mutation.type === 'characterData' && mutation.target === this.textNode;
    })) {
      let range = this.restore();
      if (range) context.range = range;
    }
  }

so if the restore() returns something valid, it’s stored as the @range property in the @context, which is juggled around as the call progresses.

For now, remember that there’s a context variable floating around. I need to make a not-so-small detour now.

Why the cursor jumps, and Quill’s take on the problem

Well, there might be more than one reason for a cursor jump, but I’ll discuss the one at hand: The DOM element, on which the browser’s cursor stands, is manipulated by Quill. Typically, it’s a text DOM element that is either changed or deleted. This could be the result of a direct edit, or internal manipulations, such as splitting a blot to insert formatting blots, or optimizing away blots (and hence DOM elements).

Quill handled each of these cases separately to prevent a cursor jump, and I’ll focus on the mechanisms that are related to the Cursor class.

The first mechanism takes a simple approach: update() shouldn’t change the selection. Therefore, get the selection before update() is processed, remember it, and restore it before it’s about to finish.

So the constructor of the Selection class (core/selection.js) goes:

    this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, () => {
      if (!this.hasFocus()) return;
      let native = this.getNativeRange();
      if (native == null) return;
      if (native.start.node === this.cursor.textNode) return;  // cursor.restore() will handle
      // TODO unclear if this has negative side effects
      this.emitter.once(Emitter.events.SCROLL_UPDATE, () => {
        try {
          this.setNativeRange(native.start.node, native.start.offset, native.end.node, native.end.offset);
        } catch (ignored) {}
      });
    });

As their names imply, a SCROLL_BEFORE_UPDATE is injected by Quill’s own code before an update() is executed, and SCROLL_BEFORE_UPDATE is injected just before it returns. See towards the end of this post how the said method fires off these events.

The idea is quite simple: The handler of SCROLL_BEFORE_UPDATE gets the current selection and stores it in the local variable @native, and then registers a second callback for execution when SCROLL_UPDATE is fired off, i.e when the update() is done. Since the second callback is defined within the first callback, it’s exposed to @native, and uses it to set the selection to where it was before.

This works almost all the time. The recurring problem, which is discussed further below, is that things that happened during the update() processing caused the DOM nodes that appear in @native to be removed from the DOM tree. As a result, they surely can’t convey information on where the selection was.

I guess that it was found by trial and error that the problem occurred only as a result of optimizations after the restore() method mentioned above was called, with the selection set on the cursor itself. So a hack to work this around was introduced in Quill git repo’s commit 56ce0ee54 (June 2017, included in v1.3.0.), in which there were several changes made to use the @context in order to restore the selection at a late stage. There were other commits as well related to this issue.

It’s a pin-point fix for a specific problem, and it boils down to this: restore() might return the position of the selection. If it does, store it in the @context variable, and use it instead of the SCROLL_UPDATE mechanism mentioned above (note the “if (native.start.node === this.cursor.textNode) return;” part).

For this purpose, the Selection class’ constructor added another listener:

    this.emitter.on(Emitter.events.SCROLL_OPTIMIZE, (mutations, context) => {
      if (context.range) {
        const { startNode, startOffset, endNode, endOffset } = context.range;
        this.setNativeRange(startNode, startOffset, endNode, endOffset);
      }
    });

So what happens is that when an optimize() is completed, the Quill-defined SCROLL_OPTIMIZE is generated (see code snippet for that towards the end of this post), and the @context property is the used to resume the selection to its original position. In other words, what restore() returned.

All in all, if restore() is called, with the selection spanning a single text node (in practice the Cursor blot’s text node), the selection position, in terms of the DOM, is stored in the “context” object that is juggled throughout, and is then (hopefully) restored.

In all other cases, the selection position is restored by virtue of the SCROLL_UPDATE mechanism. Also, hopefully.

Reason #1 for jumping cursor

Actually, this is quite unlikely to be the reason, but anyhow:

As mentioned above, the current selection is passed on if and only if it’s limited to the Cursor blot’s text DOM element. So if the selection is moved towards the end of the document (e.g. with a right arrow), it won’t be part of the Cursor blot, so no restoration of the selection.

Nevertheless, it might very well be that the text region, on which the browser cursor or selection stands on, is eliminated by the Text blot’s optimization (see the code in “Text blot optimization” below), and then it isn’t corrected. To the user, the cursor jumped to the end of the segment with the same formatting.

That said, the cursor won’t jump because of this in the common use case, because the SCROLL_UPDATE will handle this. But what about when interrupting a character composition with moving the cursor? Will the SCROLL_UPDATE do its magic? Haven’t tried.

Reason #2 for jumping cursor

The second problem relates to restore()’s context.range mechanism: The crux is that the selection to be restored is given in “native form”, i.e. in terms of DOM elements. Now, recall that the text node (and possibly more of them) were split into two in order to give place for inserting the Cursor blot. What happens if the removal of the Cursor blot caused the two remaining text nodes to merge into one, by virtue of optimization? In that case, @startnode, which is originally @restoreText as defined in the Cursor class, doesn’t necessarily exist any more, or more precisely, isn’t on the DOM tree anymore: The optimization necessarily removed at least one text node from the DOM tree when merging two text nodes into one.

In this case, the selection range will be defined by virtue of a DOM element that isn’t in the DOM tree anymore. What happens practically? Consider setNativeRange(), also defined in the Selection class, which starts with this:

if (startNode != null && (this.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) {
  return;
}

So if setNativeRange() is called with a DOM node that isn’t on the DOM anymore, at least one of startNode or endNode won’t have a parent node (that’s the essence of not being on the DOM tree), so the call returns without doing anything. What can it do?

And once again, the text node with the selection was eliminated, and the selection wasn’t restored, hence a jump.

This seems to happen when user-defined blot classes are added to Quill, in particular if they provoke optimization scenarios that are legit, however didn’t happen before.

Reason #3 for jumping cursor

The SCROLL_UPDATE mechanism actually suffers from the same problem: If the selection includes a text element that is then removed from the DOM tree during the processing of the update() call, most likely due to an optimization, setNativeRange() will return doing nothing, for the same reason as with reason #2.

This too is related to the removal of the Cursor blot and a merge of text blots, but as a result of moving the browser’s cursor or the selection away from the Cursor blot.

The recurring problem

At this point, it should be clear that the real problem is that there’s no robust mechanism for maintaining the selection after text DOM elements are eliminated by virtue of optimization and other manipulations. More specifically, the recurring problem is the reliance on the DOM elements. Which may appear to be a strange decision: Why not define the selection in terms of the index and length, in document-global terms? That is guaranteed to work correctly.

The answer is that I don’t know, and I’m sure someone had a good reason. I think the hint lies in the comment of this little snippet from core/quill.js:

  getSelection(focus = false) {
    if (focus) this.focus();
    this.update();  // Make sure we access getRange with editor in consistent state
    return this.selection.getRange()[0];
  }

getRange() is the internal method used to get the selection in terms of index and length. And as the comment implies, it might not work well unless the editor is after a call to update(), in order to sync the Parchment with the DOM. But one can’t call update() in code that processes update(), quite naturally.

Bug fix strategy

First and foremost, this is a good time to mention that the Embed blot (blots/embed.js) has a similar mechanism for preventing cursor jumps, most likely with the same problems, which I haven’t tended to. Mostly because I haven’t seen any actual problem with it. Left as an exercise to the reader.

Second, I’m not really going to fix the root cause of this issue, but rather add yet another hack that makes it work again for me. It seems to me that the correct way to fix this once and for all is to change the strategy altogether: That a property of the main Scroll object would hold the selection to resume to, in terms of start and end DOM nodes and the offsets within. And then, every piece of code that might remove a DOM node first checks if it appears in that selection property, and if it does, it makes sure to update it so it points at the corresponding updated position in the DOM tree. I guess the places to do that is in implementations of split() and optimize() as well as a few others.

So now back to reality and my own little hack. There are two fundamental differences between my solution and the way it was before, both relating to the Cursor class’ restore() method:

  • restore() always sets a selection for restoring.
  • restore() specifies the selection in terms of index and length, and is therefore indifferent to changes in the DOM tree structure

The main assumption behind this solution is that the call to restore() for the removal of the Cursor blot can be a result of two reasons only: Insertion of text (by typing, pasting or some other insertText() call) or a change in selection.

The solution relies on the notion that restore() already makes a distinction between whether new text has been inserted instead of the cursor character or not. So the algorithm goes

  • If text has been added, put a zero-length selection after the inserted text. This is what editors always do. So there’s no need to query for the selection in this case. Calling the Cursor blot’s offset() method gives the index.
  • Otherwise, getRange() is called to obtain the index and length. This is very likely to give correct result, as restore() was called following a change in selection, and not in the middle of processing an edit.
  • As an unlikely fallback, if getRange() returns null, set the selection at the Cursor blot’s position. Not clear if this will ever happen, but even if it does, it’s more likely while processing a selection change, in which case the cursor will just jump back, and the user will click again.

The truth is that only the first bullet here seems to matter. In the two other cases, it seems like the update() related mechanism will have the last word on where the selection ends anyhow.

Speaking of which, the way to work around reason #3 is to obtain the index-based selection range alongside with the DOM-based one. And then use the latter if the former is going to fail anyhow. It’s not just better than nothing, but my own anecdotal experience shows that it works.

The code changes

A word of truth: I didn’t really make the changes in Quill’s sources, but rather hacked the mangled quill.js file. Setting up the build environment was a bit too much for me. So what is shown below is a manually made reconstruction of the changes I made. Hopefully without mistakes.

The main change is done in the Cursor class, with restore() changed to:

  restore() {
    if (this.selection.composing || this.parent == null) return;
    let textNode = this.textNode;
    let restore_range = { index:0, length: 0 };

    // Link format will insert text outside of anchor tag
    while (this.domNode.lastChild != null && this.domNode.lastChild !== this.textNode) {
      this.domNode.parentNode.insertBefore(this.domNode.lastChild, this.domNode);
    }
    if (this.textNode.data !== Cursor.CONTENTS) {
      let text = this.textNode.data.split(Cursor.CONTENTS).join('');

      restore_range.index = this.offset(this.scroll) + text.length;

      if (this.next instanceof TextBlot) {
        this.next.insertAt(0, text);
        this.textNode.data = Cursor.CONTENTS;
      } else {
        this.textNode.data = text;
        this.parent.insertBefore(Parchment.create(this.textNode), this);
        this.textNode = document.createTextNode(Cursor.CONTENTS);
        this.domNode.appendChild(this.textNode);
      }
    } else {
      let range = this.selection.getRange()[0];

      if (range) {
	restore_range = range;
      } else {
	restore_range.index = this.offset(this.scroll);
      }
    }

    this.remove();

    return { restore_range: restore_range };
  }

Note that both @range and @restoreText have been eliminated. Instead, there’s restore_range, which is index and length based. And the method always returns a value.

Then there are adaptions in the Selection class. First, the handler of SCROLL_UPDATE, which changes to:

    this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, () => {
      if (!this.hasFocus()) return;
      let [range, native] = this.getRange();
      if (native == null) return;
      if (native.start.node === this.cursor.textNode) return;  // cursor.restore() will handle
      // TODO unclear if this has negative side effects
      this.emitter.once(Emitter.events.SCROLL_UPDATE, () => {
        try {
          if (native.start.node.parentNode == null || native.end.node.parentNode == null) {
	    this.setRange(range, Emitter.sources.SILENT);
	  } else {
	    this.setNativeRange(native.start.node, native.start.offset, native.end.node, native.end.offset);
	  }
        } catch (ignored) {}
      });
    });

The trick is simple: Rather than obtaining just the DOM-based selection range with getNativeRange(), the index-based range is obtained as well, with getRange(); Then, if setNativeRange() is doomed to fail miserably, fall back on the index-based range.

The handler for SCROLL_OPTIMIZE is changed to this:

    this.emitter.on(Emitter.events.SCROLL_OPTIMIZE, (mutations, context) => {
      if (context.range) {
        if (context.range.hasOwnProperty('restore_range')) {
	  this.setRange(context.range.restore_range, Emitter.sources.SILENT);
	} else {
	  const { startNode, startOffset, endNode, endOffset } = context.range;
	  this.setNativeRange(startNode, startOffset, endNode, endOffset);
	}
      }
    });

So it now plays along with restore_range as well as the old native range. Why keep the old? Because the Embed blot still emits a context old school, as mentioned above.

And finally, handleComposition() has one line changed, so it treats the result of cursor.restore() correctly. The part going

setTimeout(() => {
  this.setNativeRange(range.startNode, range.startOffset, range.endNode, range.endOffset);
}, 1);

changes to

setTimeout(() => {
  this.setRange(range.restore_range, Emitter.sources.SILENT); }, 1);

And this is the time I wonder about the concept that the selection position restoration is pushed 1 millisecond later, with the idea that surely that will be “after everything”. This is in fact a common way to defer work in Quill’s code, which doesn’t make me wonder less.

For reference: The code doing Text blot optimization

Just to save the need to look it up in Quill’s sources. I have made no changes. So this is implemented in parchment/src/blot/text.ts as follows:

  optimize(context: { [key: string]: any }): void {
    super.optimize(context);
    this.text = this.statics.value(this.domNode);
    if (this.text.length === 0) {
      this.remove();
    } else if (this.next instanceof TextBlot && this.next.prev === this) {
      this.insertAt(this.length(), (<TextBlot>this.next).value());
      this.next.remove();
    }
  }

Nothing surprising here: If the current text element contains nothing, just remove it. Otherwise, if there’s a sibling to the right that is also a Text blot, append its content to yourself, and remove that sibling. But why checking this.next.prev === this? When could that not be true?

For reference: The emitters of SCROLL events

Just to show how these events are generated explicitly, excerpt from blots/scroll.js:

  optimize(mutations = [], context = {}) {
    if (this.batch === true) return;
    super.optimize(mutations, context);
    if (mutations.length > 0) {
      this.emitter.emit(Emitter.events.SCROLL_OPTIMIZE, mutations, context);
    }
  }

[ ... ]

  update(mutations) {
    if (this.batch === true) return;
    let source = Emitter.sources.USER;
    if (typeof mutations === 'string') {
      source = mutations;
    }
    if (!Array.isArray(mutations)) {
      mutations = this.observer.takeRecords();
    }
    if (mutations.length > 0) {
      this.emitter.emit(Emitter.events.SCROLL_BEFORE_UPDATE, source, mutations);
    }
    super.update(mutations.concat([]));   // pass copy
    if (mutations.length > 0) {
      this.emitter.emit(Emitter.events.SCROLL_UPDATE, source, mutations);
    }
  }

Reader Comments

Thanks for this post ! I had neither the talent nor the resources to find a solution to this problem and yours does the job perfectly !

#1 
Written By Yves de Champlain on September 17th, 2023 @ 02:59

Add a Comment

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