Pasting into a Quill editor: Making the text take formatting as usually expected

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

One of the odd things with Quill is that pasting into the editor window retains the exact formatting, rather than mixing the formats. In other words, if you paste format-less text (from some plain text editor, for example) in the middle of text of italics, the pasted text will become an island of text without italics.

This behavior is counterintuitive, and goes against what the browser’s native editor does. It becomes even more annoying when pasting text in the middle of text that is a link, because links is a format in Quill. So the text before and after the pasted text retain the link, but the pasted text in the middle becomes without linking.

This behavior is nevertheless in line with how Quill considers the operation of pasting: The insertion of a Delta segment. And since the format in the Delta segment is that of the pasted text only, inserting it means that the surrounding formats are not in effect in the pasted segment. Logical in terms of Quill, completely odd in reality.

Luckily, it’s not difficult to fix this.

How Quill does pasting

Before jumping to the solution, I’d like to explain the mechanism for handling pasting briefly. Upon initilization, the Clipboard class (see modules/clipboard.js) creates a <div> element after the editor window, having the class ql-clipboard, which is invisible by virtue of CSS. The DOM object is stored as its own property @container.

It also binds the browsers “paste” event to its onPaste() method during its initialization, as follows:

  onPaste(e) {
    if (e.defaultPrevented || !this.quill.isEnabled()) return;
    let range = this.quill.getSelection();
    let delta = new Delta().retain(range.index);
    let scrollTop = this.quill.scrollingContainer.scrollTop;
    this.container.focus();
    this.quill.selection.update(Quill.sources.SILENT);
    setTimeout(() => {
      delta = delta.concat(this.convert()).delete(range.length);
      this.quill.updateContents(delta, Quill.sources.USER);
      // range.length contributes to delta.length()
      this.quill.setSelection(delta.length() - range.length, Quill.sources.SILENT);
      this.quill.scrollingContainer.scrollTop = scrollTop;
      this.quill.focus();
    }, 1);
  }

So when called, this method gives the dedicated clipboard element focus, causing the pasted material to go there. It then queues a callback for execution 1 ms after its invocation. The callback creates a delta representation of the pasted content by calling its own convert() method, which traverses the DOM of the @container, returning the Delta ops that would have generated it (the traverse() method handles this operation).

This Delta representation is concatenated with the “retain” operation to skip the part up to the pasted position, and a “delete” to remove the part that was selected when the pasting occurred. That results in a Delta representation on the fix to be made in order to implement the paste operation. With that result, the API function updateContents() is called in order to carry it out.

But since the pasting was redirected to inside a dedicated container, and not to where the cursor stood, the Delta representation surely can’t include the formatting that was in effect at the position it’s applied into. And with Delta as in Delta, if a formatting isn’t explicitly listed in the attributes, it’s off. Meaning, that if the Delta “insert” is applied where that formatting was previously on, and it doesn’t appear in the insert’s attributes, it should be turned off. So that’s how that annoying formatting island is created.

How updateContents() does formatting

updateContents() calls the Editor’s class’ applyDelta() (see core/editor.js), which loops on the Delta object’s op array elements. For each element, the Delta op’s diff function (see quill-delta/lib/op.js) is called to compare the attributes that go along with the “insert” op, with those that are in effect where the op is about to be applied. A local variable @attributes contains the result: Its keys are the keys of the attributes that are different (and hence need an update). If the attribute is present in the current context, and not in the op, the value of the key is null.

And then comes the punchline:

      Object.keys(attributes).forEach((name) => {
        this.scroll.formatAt(index, length, name, attributes[name]);
      });

so this is where the formatting of the relevant text segment is set up. Including nullifying the format attributes that are absent in the insert op.

The fix

Given the presentation of the problem, the fix is quite straightforward: Modify applyDelta() so it doesn’t make formatAt() calls that remove formats, if it runs on behalf of a paste operation. In that case, only make the calls that add formats.

So first, the Clipboard’s onPaste() class is modified to begin like this:

  onPaste(e) {
[ ... ]
    setTimeout(() => {
      delta = delta.concat(this.convert()).delete(range.length);
      if (delta.ops && delta.ops[0])
        delta.ops[0].joinformats = 1;
      this.quill.updateContents(delta, Quill.sources.USER);
[ ... ]
    }, 1);
  }

So an additional key is added to the first op in the Delta, in order to hint that it’s a result of a Paste. The first op is used rather than setting delta.joinformats, because the Delta is copy-created along the foodchain, so this property wouldn’t survive.

And then, in the Editor class, update applyDelta() so it starts with:

  applyDelta(delta) {
    let joinformats = delta.ops && delta.ops[0] && delta.ops[0].joinformats;
    let consumeNextNewline = false;
[ ... ]

and then the part mentioned above to

      Object.keys(attributes).forEach((name) => {
        if (attributes[name] || !joinformats)
          this.scroll.formatAt(index, length, name, attributes[name]);
      });

which prevents the removal of formats that aren’t in the Delta op’s attributes, if @joinformats is set. Naturally, this shouldn’t affect all Delta applications, or formats would remain forever.

But what about updating the Delta?

After I finished this little hack, I slapped my forehead with “OK, nice, you’ve just fixed the visual representation, but it’s worth nothing as the Delta isn’t updated”. In other words, the fix above got the blots and hence DOM correctly, but if the Delta isn’t synchronized with this, it will turn out wrong next time the document is loaded.

This would indeed have been a problem had the Delta been updated by somehow joining the existing Delta with the Delta to apply. Luckily, this is not the way it’s done. So as it turned out, I actually got it right.

That’s because applyDelta() ends with

return this.update(delta);

which effectively calls editor.update(). This method, when called without arguments, updates the editor’s stored Delta with:

this.delta = this.getDelta();

and getDelta(), surprisingly enough, rebuilds the Delta afresh from the content of the scroll:

  getDelta() {
    return this.scroll.lines().reduce((delta, line) => {
      return delta.concat(line.delta());
    }, new Delta());
  }

This isn’t as wasteful as it sounds, because some blot elements cache their Delta representation. But the bottom line is that because the Delta is deduced from the blot, that little fix above updates the Delta correctly as well. All well that ends well.

Reader Comments

Cool

#1 
Written By Lubos Repka on June 20th, 2023 @ 14:24

Add a Comment

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