Introduction
In this post I describe how I integrated the Highlight.js syntax highlighting tool into TinyMCE v5.10.2 for the sake of highlighting code while writing blog posts. So one gets the button saying “HL” as shown here:
I’ve taken a somewhat different approach than usual, mainly in three aspects:
- TinyMCE has a capability to insert code samples, and that includes syntax highlighting. Not good enough though, mainly because formatting can’t be added on top. Besides, the variety of languages is very narrow compared with highlight.js.
- The syntax highlighting is a manual formatting operation, exactly like using “bold” or “italics”: The user selects the code to be highlighted, and presses a button at the toolbar.
- The language is chosen manually.
So first, why not keep the highlighting script running periodically? Well, to begin with, it’s not an advantage when writing a blog post. I never write any code in my posts directly, but rather copy-paste it into the editor window from some other source. After all, it’s supposed to be code that runs, so I’ve obviously tested it somehow. Hence it’s already written somewhere.
On the other hand, continuous highlighting is an obstacle for writing blog posts: I often mark important parts in bold and red, and less important parts in grey. So the formatting made for syntax clarification is often just a starter. Letting the highlighter run uncontrolled would most likely mess up the manually added formatting.
As for selecting the language manually: Obviously, I know which language the code is written in, so why let an algorithm guess it? It may very well miss, in particular when the snippets are short — and in a blog post they often are.
Also, it’s not uncommon that a chunk of code contains more than one language: JavaScript code may contain snippets in HTML, and Perl code may contain snippets in Verilog. So it makes perfect sense to copy-paste the piece of code into the editor’s window, and then select each code segment and highlight it, each with its own language. And then I’m free to go in with colors to mark the important parts.
I’m not saying everyone is wrong. Automatic and periodic highlighting is great for websites with “try it out” windows for experimenting with HTML, JavaScript, CSS etc. This is not my case however.
And one more thing: I’ve deliberately chosen not to turn off spell checking, mainly because it catches spelling mistakes in comments.
This post is organized as follows: First a few words about getting started with Highlight.js. Then an example of integrating Highlight.js for use with automatic language detection (as this covers the main principles). And finally, setting it up to select the language manually.
The code examples in this post were, of course, highlighted with the plugin itself, so it has already one very satisfied user.
Getting started with Highlight.js
As mentioned, the chosen syntax highlighter in JavaScript was Highlight.js v11.3.1. The default set of languages is however web development oriented, and I need a much wider set. No problem, just go to the download page and tick whatever language is needed, and then download a custom zip file.
The zip file is however slightly misleading: Even though it contains quite a few files, the only ones that are needed are highlight.min.js at the zip’s root directory, and one of the CSS files in styles/. The highlight.js file should not be used, as it’s probably the core script without language support. So it does nothing good.
This way or another, there’s nothing to build from this bundle.
Anyhow, I’d mention that adding and removing languages can be done by editing highlight.min.js directly. It actually looks like the download site’s mechanism for supporting languages merely consists appending language related snippets from the language/ subdirectory to some core functions file.
Just look for comments saying something like
The part until a similar comment is the code that installs support for the said language. So cut and paste as necessary.
Note that the language/ directory contains all languages, not just those requested. So save the zip file in a safe place for the possibility to add a language later on without worrying about version compatibility.
There was only one little problem: As I selected which languages to support, I pretty much clicked on anything that I thought might come handy, but it turns out that the module for Python caused Firefox to fail loading highlight.min.js with
SyntaxError: invalid identity escape in regular expression
in the console. Chrome had no such problem. I’ll leave it open if it was a coincidence that Python was somehow related to a failure with a cryptic error message. As the image above implies, I opted out Python.
To give it a test run, I uncompressed the zip file into a directory named highlight/ relative to the file loaded into the browser, and then this correctly highlighted Verilog code:
<link rel="stylesheet" href="highlight/styles/stackoverflow-dark.min.css">
<script src="highlight/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<pre><code class="language-verilog">
... Verilog stuff ...
</code></pre>
This is the simplest use case: Let Highlight.js look up <pre><code> segments and highlight them automatically. But then, I chose the language manually by virtue of using the language-verilog class (this is not necessary, though).
Simple highlight TinyMCE plugin
So this is the code for the highlighting plugin with automatic language selection. Note that this is just code that is loaded along with the HTML, the good old way, even though it could be used in the common plugin framework (I guess, haven’t tried it).
(function () {
'use strict';
var highlighter = function() {
editor.dom.select('span.highlight_tmp').forEach(function(el) {
editor.dom.remove(el, true);
});
editor.formatter.apply('highlight_wrapper');
editor.dom.select('span.highlight_tmp').forEach(function(el) {
let text = el.innerText;
let hl = hljs.highlightAuto(text);
editor.dom.setHTML(el, hl.value);
editor.dom.remove(el, true);
});
editor.undoManager.add();
}
tinymce.PluginManager.add('highlight', function (editor) {
editor.ui.registry.addIcon('highlight', '<svg width="24" height="24" viewBox="0 0 256 256"><path d="M 57.00,62.00 C 57.00,62.00 41.58,147.00 41.58,147.00 41.58,147.00 34.00,189.00 34.00,189.00 34.00,189.00 50.00,189.00 50.00,189.00 57.50,188.87 56.70,186.77 58.75,175.00 58.75,175.00 63.58,148.00 63.58,148.00 64.08,145.21 64.56,139.55 66.58,137.60 68.58,135.67 72.40,136.01 75.00,136.00 75.00,136.00 111.00,136.00 111.00,136.00 111.00,136.00 102.00,189.00 102.00,189.00 102.00,189.00 124.00,189.00 124.00,189.00 124.00,189.00 133.58,135.00 133.58,135.00 133.58,135.00 141.58,91.00 141.58,91.00 141.58,91.00 147.00,62.00 147.00,62.00 147.00,62.00 131.00,62.00 131.00,62.00 123.09,62.15 124.37,63.75 122.00,76.00 122.00,76.00 117.42,101.00 117.42,101.00 116.92,103.79 116.44,109.45 114.42,111.40 112.42,113.33 108.60,112.99 106.00,113.00 106.00,113.00 70.00,113.00 70.00,113.00 70.00,113.00 79.00,62.00 79.00,62.00 79.00,62.00 57.00,62.00 57.00,62.00 Z M 174.00,62.00 C 174.00,62.00 158.58,147.00 158.58,147.00 158.58,147.00 151.00,189.00 151.00,189.00 151.00,189.00 204.00,189.00 204.00,189.00 204.00,189.00 210.41,187.98 210.41,187.98 210.41,187.98 212.61,182.00 212.61,182.00 212.61,182.00 215.00,167.00 215.00,167.00 215.00,167.00 177.00,167.00 177.00,167.00 177.00,167.00 189.42,99.00 189.42,99.00 189.42,99.00 196.00,62.00 196.00,62.00 196.00,62.00 174.00,62.00 174.00,62.00 Z" /></svg>');
editor.ui.registry.addButton('hljs', {
icon: 'highlight',
tooltip: 'Syntax highlight selection',
onAction: highlighter,
});
});
}());
It relies on the registration of a special class, named highlight_tmp, with this code snippet:
function mce_post_init(editor) {
editor.formatter.register('highlight_wrapper',
{ inline: 'span', classes: 'highlight_tmp', exact: true } );
}
A few words about the “exact” property. This is required to ensure that a separate <span> tag is created, and that it isn’t merged with another <span> tag that happens to cover the same segment. This can be the case in particular if there’s a single <span> that covers a block element end to end. The “exact” property ensures that if this happens, applying the format creates a <span> within each block, and hence there are two nested <span>’s that overlap exactly.
Merging must be prevented, because the highlight_wrapper format is used merely to isolate the selection, and the <span> element carrying it is removed after the highlighting operation completes. If it has been merged with another <span> element, the formatting the other <span> carried is removed as well.
The call to editor.formatter.register() has to be run after the editor has been fully initialized. Some do this by virtue of a timeout callback, but the clean way for doing this is within a function that is called explicitly when the editor is up and running, mce_post_init() in this case.
This function is declared in the configuration of the editor, so let’s discuss it now (only the parts relevant to highlighting are shown):
tinymce.init({
init_instance_callback : "mce_post_init",
content_css: [ 'css/highlight.css', '/js/skins/content/default/content.css'],
plugins: 'highlight [ ... ]'
toolbar: '[ ... ] hljs [ ... ]',
[ ... ]
});
For the plugin to work, it must be listed in the plugins property as shown above. Note that it’s not loaded from a separate file, since it has already been registered by the time the editor is looking for it.
Also, a button needs to be placed in the toolbar, by virtue of the “toolbar” property. And then, the content_css needs to be set up so it includes the classes for highlighting. This can be any of those supplied by Highlight.js’ zip file, for example. The CSS file under /js/skins/ is given explicitly here, as this was the default in my case. When content_css is defined explicitly, the default is dropped.
And of course, the highlighting script must be loaded with something like this in the <head> section of the HTML:
<script src="js/highlight.min.js"></script>
There’s no point loading the relevant CSS file here if the editor is placed in an iframe, which it usually is (unless inline mode is selected).
Note that the icon is given explicitly in text as an SVG. This is quite common, actually. The icon is just the letters “HL” (not very inspirational, but fits nicely).
Simple highlight plugin explained
The actual work is done in this part:
editor.formatter.apply('highlight_wrapper');
editor.dom.select('span.highlight_tmp').forEach(function(el) {
let text = el.innerText;
let hl = hljs.highlightAuto(text);
editor.dom.setHTML(el, hl.value);
editor.dom.remove(el, true);
});
First, the formatter.apply() method is called to wrap the selected region with a <span> pair of tags, so that the relevant area is isolated and with a DOM element that can be referred to. Note that if there happened to be leftovers from a previous operations (an exception occurred?) they have already been removed by the code that comes before the snippet above.
At this point, a loop runs on all DOM elements having the highlight_tmp class, fetching their pure-text representation with innerText, calling highlightAuto() on this text, and then setting the HTML of the DOM element to the result of this call.
This is followed by a call to dom.remove(), which removes the DOM element, moving all children to the parent (this is what the “true” argument means). In HTML terms, this removes the <span> tags, but leaves anything between them.
At the end of the loop, there’s
editor.undoManager.add();
which adds an undo stage after the highlighting is in place. Without this, a CTRL-Z undoes the step before applying syntax highlighting as well.
Answers to questions nobody asked
First, a couple of notes:
- If no region is selected, but the cursor is in the middle of a word, that word is highlighted — just like “bold” would do. Only at the end of a line, nothing happens (or more precisely, an empty string is highlighted).
- No menu item is added on behalf of this plugin, because adding a menu item to an existing header requires redefining it from scratch, but then what happens if newer items are added by core TinyMCE in future versions? So the correct way to do it is to add another menu header, and that’s a bit too high much.
Now the question: Why wrapping the selection with a bogus class, and then remove it? Why not just grab the text, highlight, and inject it back? Like this?
let text = editor.selection.getContent({format : 'text'});
let hl = hljs.highlightAuto(text);
editor.execCommand('mceInsertRawHTML', false, hl.value);
This works quite nicely, however if an entire <pre> region is selected, the result is that the <pre> tag is dropped, and the highlighted code appears in a plain paragraph format, newlines turned into spaces, so it’s a mess. This happens because when the entire chunk is selected, the surrounding format is also part of the selection. Hence when it’s substituted, the <pre> is dropped as well.
The next question would be why there’s a loop on DOM elements with the class. Why would there be more than one? Why not defining the wrapper by an ID (assigned randomly), with something like
{ inline: 'span', attributes: { 'id': '%id' } }
and then wrapping and lookup of the DOM element would go
let dom_id = "highlight_" + Math.floor(Math.random()*1000000000);
editor.formatter.apply('highlight_wrapper', { id: dom_id } );
let el = editor.dom.get(dom_id);
instead?
So in what case would there be more than one DOM element to process? The answer is that normally that won’t happen, but if a few paragraphs are chosen (like when pasting multi-line code into a non-<pre> context), formatter.apply() creates a separate <span> for each paragraph (more precisely, for each block element, since inline tags like <span> don’t cross block elements). Which is actually good, because the result is what one would expect.
So even though it’s bit odd to use highlighting not inside a <pre> section, the non-loop version above won’t process nor clean up more than one wrapper. That alone is a good reason to do it in a loop: Even if the user does something unexpected, no junk should be left in the document.
Ehm, I need to correct myself slightly on this: Apparently, sometimes when pasting plain text into the editor window (inside the <pre> region), <br/> tags are inserted instead of newlines. Not clear why, and it’s ugly, but with the loop the highlighting works regardless.
And lastly, is it OK to use innerText, despite its definition being pretty vague saying “it approximates the text the user would get if they highlighted the contents of the element with the cursor and then copied it to the clipboard”. For text inside a preformatted element, it looks safe enough. Maybe using TinyMCE’s API would be better still.
Manual language selection plugin
The code for the manual language is interesting in particular because it demonstrates a split button, i.e. a button that can open a selection drop-drop menu (as shown in the image above). It’s worth mentioning that only the plugin code itself has changed — the configuration in the call to tinymce.init() remains the same.
So here it is:
(function () {
'use strict';
var langname = {};
var items = [];
var langlist = hljs.listLanguages();
langlist.forEach(function(codename) {
let l = hljs.getLanguage(codename);
langname[codename] = l.name;
});
langlist.sort(function(a, b){
let x = langname[a].toLowerCase();
let y = langname[b].toLowerCase();
if (x < y) return -1;
if (x > y) return 1;
return 0;
});
var lang = langlist[0];
langlist.forEach(function(codename) {
items.push({
type: 'choiceitem',
text: langname[codename],
value: codename,
});
});
var highlighter = function() {
editor.dom.select('span.highlight_tmp').forEach(function(el) {
editor.dom.remove(el, true);
});
editor.formatter.apply('highlight_wrapper');
editor.dom.select('span.highlight_tmp').forEach(function(el) {
let text = el.innerText;
let hl = hljs.highlight(text, {
language: lang,
ignoreIllegals: true,
});
editor.dom.setHTML(el, hl.value);
editor.dom.remove(el, true);
});
editor.undoManager.add();
}
tinymce.PluginManager.add('highlight', function (editor) {
editor.ui.registry.addIcon('highlight', '<svg width="24" height="24" viewBox="0 0 256 256"><path d="M 57.00,62.00 C 57.00,62.00 41.58,147.00 41.58,147.00 41.58,147.00 34.00,189.00 34.00,189.00 34.00,189.00 50.00,189.00 50.00,189.00 57.50,188.87 56.70,186.77 58.75,175.00 58.75,175.00 63.58,148.00 63.58,148.00 64.08,145.21 64.56,139.55 66.58,137.60 68.58,135.67 72.40,136.01 75.00,136.00 75.00,136.00 111.00,136.00 111.00,136.00 111.00,136.00 102.00,189.00 102.00,189.00 102.00,189.00 124.00,189.00 124.00,189.00 124.00,189.00 133.58,135.00 133.58,135.00 133.58,135.00 141.58,91.00 141.58,91.00 141.58,91.00 147.00,62.00 147.00,62.00 147.00,62.00 131.00,62.00 131.00,62.00 123.09,62.15 124.37,63.75 122.00,76.00 122.00,76.00 117.42,101.00 117.42,101.00 116.92,103.79 116.44,109.45 114.42,111.40 112.42,113.33 108.60,112.99 106.00,113.00 106.00,113.00 70.00,113.00 70.00,113.00 70.00,113.00 79.00,62.00 79.00,62.00 79.00,62.00 57.00,62.00 57.00,62.00 Z M 174.00,62.00 C 174.00,62.00 158.58,147.00 158.58,147.00 158.58,147.00 151.00,189.00 151.00,189.00 151.00,189.00 204.00,189.00 204.00,189.00 204.00,189.00 210.41,187.98 210.41,187.98 210.41,187.98 212.61,182.00 212.61,182.00 212.61,182.00 215.00,167.00 215.00,167.00 215.00,167.00 177.00,167.00 177.00,167.00 177.00,167.00 189.42,99.00 189.42,99.00 189.42,99.00 196.00,62.00 196.00,62.00 196.00,62.00 174.00,62.00 174.00,62.00 Z" /></svg>');
editor.ui.registry.addSplitButton('hljs', {
icon: 'highlight',
tooltip: 'Syntax highlight selection',
onAction: highlighter,
onItemAction: function (api, value) {
lang = value;
highlighter();
},
fetch: function(callback) { callback(items); },
select: function(x) { return x === lang; },
});
});
}());
The larger part of the change is at the beginning of the plugin’s code, which sets up the list of items for the drop-down meny by calling hljs.listLanguages() and then obtaining the human-friendly name of the languages.
Next, hljs.highlightAuto(text) is replaced with hljs.highlight(text, { … } ), so that the language is given explicitly as the @lang local variable.
And finally, addSplitButton() is called instead of addButton(), with the same properties plus:
- onItemAction supplies the function that is called when a menu item is selected. Not surprisingly, is sets @lang to the value of the selection (the “value” property in the item entry) and then calls highlighter() to highlight the relevant chunk.
- fetch supplies a function that is called for supplying the items in the drop-down list. It’s called every time the drop-down list is opened, so the content of this list can change from one time to the other. For this plugin, the same array is supplied each time, as the language list won’t change.
- select supplies a function that evaluates true for the list item next to which a check mark should appear to indicate that it’s selected.
Summary
I have to admit that I’m quite impressed by how TinyMCE’s API was right on spot with supplying the right functions to get the job done easily. Note how short and concise the code for the automatic highlighter is. And the manual language plugin got most of its weight from the code that plays around with languages, and not setting up the GUI.
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.
The trio
The most remarkable thing with the Quill editor, is that it constantly retains three representations of the edited document, all of which are in sync with each other:
- The DOM, which is the browser’s view on the visible edit window
- The Parchment, which is Quill’s parallel representation to the DOM (kept as the quill.scroll object)
- The Delta, which is a sequence of editing operations for creating the document from scratch (kept as quill.editor.delta). This can be though of as what to type, from top to bottom, to get the document that is currently in the editor window.
This post discusses these three representations and how the relation among them is maintained.
It’s worth mentioning that apparently Quill was conceived with the idea that the Delta representation should be used exclusively, instead of the HTML code. In other words, it was envisioned that the web page for publishing would load the Delta from the server in JSON format (or something of that sort), and then a Quill parser, running on the browser as JavaScript code, would extract it into the DOM representation for view.
This is probably the reason Quill’s formal API doesn’t provide a means to obtain the HTML of the document.
Positions: index and length
Even though not directly related to either of these three, it’s worth mentioning that there’s also an index / length representation, in which the document is viewed as a linear sequence of characters.
Formatting (e.g. bold, italics, links) doesn’t occupy any width, however a new paragraph has a length of one, on behalf of the newline that it results from (even though it isn’t included in the DOM, and hence not in innerText). Embedded objects, such as images, are also considered to have a length of one.
The index and length are used extensively in Quill’s API as well as internal machinery to define positions inside the document.
Quill and DOM
As the document is edited, it’s of course shown in the browser. Hence it necessarily has a DOM representation in the browser, as a subtree of the DOM element that encloses the editing area (kept as the quill.root property). There is nothing special about this area, in fact, except that it’s editable and manipulated by Quill.
It’s however worth to make a quick recap on how the DOM tree is structured. So without going into the gory details, one can say that the DOM’s tree is a reflection of the HTML that would represent it properly: Whenever there’s a tag that needs a terminator (e.g. <p>, <div>, <b>, <a>), the node belonging to the tag becomes the parent for everything between the tag and its terminator. The siblings in this subtree are of course placed in the same order they appear in this theoretic HTML document.
Note that the tree structure doesn’t imply the graphical packing of the elements: A <div> tag creates a subtree containing everything that is within its vertical limits, but <b> does nothing of that sort. On the other hand, <br> and <hr> tags don’t generate any subtree, but do influence vertical packing.
Another thing to be aware of is that superfluous tags create DOM nodes as they appear in the originating HTML. So if the HTML says <b><i><b>Text</b></i></b>, the superfluous, internal <b> tag creates a subtree. There’s no such optimization when building the DOM.
The Parchment
The Parchment is a tree that mimics the DOM’s tree structure quite accurately, but is based upon a completely different set of classes. Accordingly, the objects in the Parchments, the blots, have different properties and hence mostly contain different kinds of information.
There are differences between the structure of these two trees only where the Cursor blot is inserted (see a separate post on this) or in some other cases that go beyond plain document editing (e.g. when something that Quill can’t digest has been pasted into the editing area, in which case that chunk becomes uneditable, and hence the relevant subtree isn’t covered in the Parchment in detail). Other than that, a difference in the tree structure indicates a bug somewhere, most likely in some add-on module.
As mentioned before, the Parchment for a document is given as quill.scroll. The nodes in the Parchment is called Blots, which is a collective name for the nodes in the Parchments (i.e. the JavaScript objects) as well as the classes of these objects. Each node has a .next and .prev property pointing at its next and previous sibling (possibly null when there’s no such). When a node has children, .children.head points at the first child (and likewise .children.tail at the last). Otherwise these are null.
Even more interesting, each blot also has a .domNode property, which points at the DOM object it represents. Conversely, each DOM object inside the editor’s area has __blot property, so that __blot.blot points back to representing blot (except for those DOM nodes that are not covered).
The important difference between a blot and DOM node it represents, is that blot classes represent the intention of the graphical elements they generate. For example, an editor may be customized to support two kinds of links: To sites of type A and sites of type B. They should have different formatting and possibly attributes. By representing each link type as different blot classes, the user creates them with different buttons, possibly with different UI for feeding their details, and the formatting (i.e. selection of the correct class) is done automatically. Nevertheless, both blots end up as a <a> DOM element.
The vast majority of functions and methods in Quill operate on the Parchment tree and the DOM tree simultaneously. The low-level methods that relate to the Parchment tree separately are implemented as the Registry class.
The Delta format
Delta is a serialized, and completely different way to look at the document. Note that the Delta format is also used to represent differences and changes, which is discussed further down. For now, I’ll focus on Delta as a representation of the entire document.
The Delta object, which is kept as quill.editor.delta, but should be obtained by application with the the quill.getContents() method, consists of a single element, an array called ops. This array consists of the sequence of operations required to reproduce the document, starting from an empty one.
So the document isn’t described in terms of its structure, but how it would have been typed into the editing window from beginning to end. It’s worth to try out the live editor example at the bottom of this page which shows the Delta object side by side (and also explains the format in more detail).
The said ops array contains only insertion operations: Each operation is represented with an object, which has at least one property, called “insert”. If it contains a string, that’s the text to add at the specific point. Newlines in this string are newlines as typed on keyboard. These end up as some block blot (paragraph, header etc. depending on the context).
If the “insert” property is an object, that requests the generation of blot. The name of the property of this object is the class of the blot, and the value of the property is that value to be assigned as the “value” of the blot object.
An insert operation may also have another property, “attributes”, which is an object. Its properties modify the insert op in a variety of ways: The font, color and also a “link” property turns the inserted element into a link.
There is hence a fundamental difference between how text is represented in Delta format vs. with HTML and the DOM. In the latter case, it goes “bold starts here, text, bold ends here”. With Delta, it goes “this is a segment of uniformly formatted text, and complete description for the formatting is this and that (among others, bold)”. So the textual parts of the document are chopped into chunks with uniform formatting, and they may span several lines.
To produce a pretty-printed JSON string of the document in Delta format:
JSON.stringify(quill.getContents(), null, ' ');
Formatting in Delta ops
It may come counterintuitive that a link is a formatting of the text segment, but when considering that links are almost always segments with uniform formatting, it turns out to be the natural solution: The link is just an attribute of the text.
Another confusing thing might be that a header (as in <h1>Header</h1>) is inserted as two ops: The first is the text, and the second is just a “\n” insert with the attributes object containing a “header” property giving the rank of the header (i.e. <h3> gets @header 3). This is because attributes that relate to blocks are applied only to the newline character(s) in the the text, controlling which block-level blot is the parent of the text before the newline.
It also goes along with the fact that Quill ingests pasted HTML by traveling through the DOM of the pasted text in post-order, meaning that the children of a parent are scanned from left to right, and then the parent. Hence when scanning e.g. “<h1>This is <i>important</i></h1>” it goes “This is”, “important”, italic tag, header tag. Those used to RPN calculators will find this familiar.
Basic API for manipulating the document
The Quill API (mainly) supplies two methods for inserting things into the document: insertText() and insertEmbed().
insertText() is exactly like typing the text with the keyboard. Newlines (“\n”) are treated like pressing Enter. For example, if the text is inserted inside a bulleted list, a new line and bullet are created, exactly as pressing Enter would.
Inserting text with updateContents(), where plain text is given in the .insert property works completely differently, because the information is treated as Delta operations. For example, a newline in a Delta op may appear to have a quirky behavior unless the Delta format is properly understood.
Another aspect of updateContents() is that it doesn’t respect surrounding formatting. So for example, if insertText() adds text where the context is bold, the added text will be bold too. updateContents() will add non-bold text in this case, unless the Delta has been set up to generate bold text. Every “insert” entry in a Delta op lists all formatting that should be applied explicitly, regardless of the surroundings.
All calls to updateContents() relate to the beginning of the document. In order to reach the place to manipulate, “retain” is used to skip to that position (using index metrics) and possibly “delete” to remove parts, as described in the API page.
In summary: insertText() is like typing, and updateContents() injects blots and text directly, with possibly counterintuitive behavior.
Several manipulation methods are listed in the Partchment’s API, in particular insertAt(), formatAt() and deleteAt(). The first too are used directly by insertText() (see core/editor.js) however these shouldn’t be used directly except for when implementing blots and other internal functionality, since they don’t update the Delta view of the document.
For usage as a replacement for insertText() and friends, remember that these are methods of the Parchment and not Quill, so a typical call would go
quill.scroll.insertAt(index, text);
It’s also possible to call these methods on any blot, however note that the index is then related to the blot’s beginning. This is in fact the case with calling the Scroll object too, since its zero index is the beginning of the document.
As mentioned in a separate post of mine, formats are in principle divided into Inline and Block formats. formatText() works with the Inline formats only (or with the Block formats when targeting the newline character), and formatLine() only with Block formats. format() checks if the format description is in the Block or Inline group, and delegates the call to formatLine() or formatText() accordingly (see core/quill.js).
So this snippet turns the selected part into red font, and gets the line in which the selection (or cursor) is included into a block quote:
quill.format("blockquote", "true");
quill.format("color", "red");
These are the functions that are called by the toolbar, so using them in a script is equivalent to that. Note however that formatLine() or formatText() allow changing any place in the document, not just where the selection is.
Assigning innerHTML directly
There are certain situations, where it’s easier to assign a DOM object’s innerHTML directly, as a quick and somewhat dirty way to update the document’s content. One use for direct innerHTML assignment is integration with Highlight.js’ module in syntax.js. It’s also a possibility for ugly hacks instead of modifying the document with Quill’s API. If you choose to do so, kindly do not refer to this post on where you got the idea from.
If and when an assignment is made to innerHTML anywhere in the editor’s area, the related blots are updated to follow suit, and the internal Delta representation is updated immediately as well. This happens as a result of the browser reporting a change (mutation) to Quill, and consequently the synchronization takes place, as explained in the next section.
This doesn’t affect blots that are away from the DOM hierarchy that was affected by the innerHTML update. In effect, this means that if these blots have object properties that are not reflected in the DOM object, they are retained nevertheless: It’s not like the entire document is refreshed from the DOM.
Hence it’s OK to hold hidden information in the blots’ objects, as long as their relevant DOM elements aren’t updated with an innerHTML assignment.
Synchronizing the Parchment and DOM with update()
In principle, the editor window is managed by the browser. In order to keep the Parchment in sync with the editor window’s content, Quill registers itself to listen to several events involving pressing keyboard keys, pasting etc. On top of that, the ScrollBlot class, which is what the top-level scroll blot is made from, registers itself as follows during its construction (from parchment/src/blot/scroll.ts):
this.observer = new MutationObserver((mutations: MutationRecord[]) => {
this.update(mutations);
});
this.observer.observe(this.domNode, OBSERVER_CONFIG);
The MutationObserver class is defined by the browser. The result of this registration is that when the browser makes any change in the editor’s window, the update() method is called with the mutation array as provided by the browser. Each entry in this array defines which DOM element has changed, and how.
Note that this doesn’t relate just to direct changes of innerHTML, but to the vast majority of user edits on the document.
The update() method that is defined in the same class (and file) goes
update(mutations?: MutationRecord[], context: { [key: string]: any } = {}): void {
mutations = mutations || this.observer.takeRecords();
// TODO use WeakMap
mutations
.map(function(mutation: MutationRecord) {
[ ... ]
})
.forEach((blot: Blot | null) => {
[ ... ]
});
[ ... ]
It loops on the array of mutations, finds the corresponding blot object for each DOM object, and calls its update() method. This allows the blot object to update itself, possibly by changing its attributes and content, or update its subtree structure to match the updated DOM tree (see update() method in src/blot/abstract/container.ts).
Note that if update() is called with no arguments, takeRecords() is called to fetch any pending mutation records from the browser. This ensures that when update() returns, any changes in the DOM have been registered in the Parchment, and hence they are in sync.
It’s important to note that this mechanism covers only changes to the DOM that are initiated by the browser, e.g. when typing text or when text is pasted. Changing the selection, pressing the Enter or Delete key initiate events that are handled otherwise — this is handled by the Keyboard module. Quill calls update() when such events involve changes in the Parchment and/or DOM, typically calling quill.update() defined as follows in core/quill.js:
update(source = Emitter.sources.USER) {
let change = this.scroll.update(source); // Will update selection before selection.update() does if text changes
this.selection.update(source);
return change;
}
As this call is made without any mutations, the purpose of this call is to ensure that the Parchment is in sync with the DOM.
Looking at insertText()
As calling insertText() is equivalent to typing text manually, it’s worth looking at its simple implementation to get an idea how Quill processes input. This is contrary to the complicated handling of interactive input.
This function is defined in core/quill.js, and essentially calls the editor object’s insertText method, which is defined as follows in see quill/core/editor.js:
insertText(index, text, formats = {}) {
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
this.scroll.insertAt(index, text);
Object.keys(formats).forEach((format) => {
this.scroll.formatAt(index, text.length, format, formats[format]);
});
return this.update(new Delta().retain(index).insert(text, clone(formats)));
}
What this demonstrates is that insertAt() is called to insert text into the DOM and Parchment. formatAt() then adds formatting as required, once again affecting both DOM and Parchment.
But then a Delta object that represents this change is generated, and the update() method is called with it. Note that “this” refers to the editor object, so this.update() is the method of the Editor class, and not the Scroll class. This is important, because the Editor’s class’ implementation of update() is completely different: Unlike the Scroll class’ implementation, which updates the Delta according to the Parchment, the Editor’s class implementation updates the Delta by manipulating Delta ops only.
This maintains the parallel view of the document in the quill.editor.delta.ops array. If this isn’t done properly, the Delta structure that is then used to save the document won’t match what’s seen on the editor window. It’s actually quite remarkable that this works.
This update() method is for low-level use, as it updates the Delta view of the document only. To apply Delta operations to a document, the API’s updateContents() should be used.
Internals: How attributes in Delta are applied
The Delta format has also other purposes, and is important within Quill’s API, in particular for requesting certain changes in the documents. This is however of less interest for those not writing or modifying Quill modules. Anyhow, this is a deep dive into the machinery that makes this happen.
The function that is used both by setContents() and updateContents() is applyDelta(), the latter defined in core/editor.js.
Aside from inserting text and objects, the attributes are applied. That’s done with this simple loop:
Object.keys(attributes).forEach((name) => {
this.scroll.formatAt(index, length, name, attributes[name]);
});
Or simply put, the keys method is applied to the attributes to fetch the attribute keys, and then formatAt() is called for each. formatAt() is hence called in the order as returned by the JavaScript’s built-in keys() method, which is the order they were inserted. However Quill is designed to organize the blot structure (and hence DOM and HTML) in a canonical manner, no matter the order of formatting, so the ordering doesn’t matter effectively.
To complete this issue, I’ll just mention what @attributes equals when the loop above is executed: At this point, the @attributes object contains the updates that are required relative to the format that exists anyway in the current position. Or in more detail, this is done by first fetching them from the Delta op:
let attributes = op.attributes || {};
and if it’s a text op (i.e. the “insert” property is a string) , the current position’s format is calculated by querying the blots above and adjacent to the left for the format they contribute. This is done by recursively calling bubbleFormats() (defined in Quill’s blots/block.js), which calls the blots’ formats() function. @attributes is modified with
attributes = DeltaOp.attributes.diff(formats, attributes) || {};
The said diff() method is defined in quill-delta/lib/op.js, and it loops through all keys in both object arguments (concatenation of the keys() array of the first argument and the second, in this order), and returns the properties in the second argument that have different values from the first argument’s object. Properties that are present only in the first argument are returned with the key set to null.
So all in all, @attributes ends up with the changes needed to update the format to the one required in the Delta op: The value if it wasn’t defined at all or was different, and null if it should be removed.
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.
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”></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);
}
}
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.
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
This post is a collection of findings I made while trying to figure out the following: Consider this simple HTML code
None enabled, <em><strong>bold and italic, </strong>only italic.</em>
which is visually formatted as follows:
None enabled, bold and italic, only italic.
Note that there’s a part starting with bold and italic, and then the bold is taken off, leaving italic only. Oddly enough, when pasting the formatted line above into Quill, the resulting innerHTML is
<p>None enabled, <strong><em>bold and italic, </em></strong><em>only italic.</em></p>
Because Quill chose to put the <strong> tag first and the <em> afterwards, it was forced to insert an </em> to allow for inserting </strong>, and then re-enable italic with <em>.
This is suboptimal, however harmless with these tags, but recall that <a> tags for links are also implemented as Inline blot extensions, so what happens if the text of a link is partly bold? Could Quill create two separate links, one for the part in bold, and one for the part that isn’t, because of the same issue shown above with nested tags? The short answer is of course no, and I’ll just explain how Quill is designed to ensure this won’t happen.
But this is an opportunity to point out, that if a link spans several Block blots (for example, several paragraphs), Quill will create a different <a>-</a> tag pair inside each paragraph. Just try it out: Select several paragraphs in the editor, and assign that text a link, and then look at the exported HTML or the DOM with the browser’s DOM viewer.
Frankly speaking, I’m not sure if it’s legal to have a link spanning across paragraphs (something like <a href=”/”> <p> This </p> <p> That </p> </a>) but it’s an uncommon use case, so who cares.
What’s important is that changing the formatting inside a small text segment won’t turn it into two links, and that doesn’t happen. The magic behind this is explained next.
DOM tree ordering: The problem
First, let’s understand the problem. Suppose there’s a piece of text in the document saying Hello: Five characters, already italics. HTML-wise, it’s <em>Hello</em>. From a blot perspective, it’s an Italic (Inline) blot with one child, a Textblot, containing the “Hello” string.
Now I mark all five characters and press the bold button on the editor. This results in a call to formatAt() with the index and length of the selected text. Say, something like
quill.scroll.formatAt(105, 5, 'bold', 'true');
I should mention that formatAt() is an internal function, and it doesn’t update the Delta representation of the document. The recommended API call is format() and friends.
A call to formatAt() results in a whole lot of activity, but the punchline is the creation of a new Bold blot and the call to wrap() (defined in the ShadowBlot class), which puts a Bold blot at the position of the blot that it’s formatting, and makes the latter the child of the former. From an HTML point of view, the <strong>-</strong> wrap everything inbetween, hence the method’s name.
I was deliberately ambiguous regarding what exactly is being wrapped, because that’s the big question: The formatAt() call relates to positions in the document, but format blots themselves contribute zero length. Therefore, that range with length 5 could mean the text only, or the text wrapped with italic tags. In other words, the wrap() call could be applied to the Textblot directly or to the Italic blot, and both would satisfy the request.
To ensure consistent behavior, there’s this little snippet in Quill’s blots/inline.js:
// Lower index means deeper in the DOM tree, since not found (-1) is for embeds
Inline.order = [
'cursor', 'inline', // Must be lower
'underline', 'strike', 'italic', 'bold', 'script',
'link', 'code' // Must be higher
];
So the @order array of the Inline class defines which formatting blot become the parent of which, which is the same as saying which DOM tag become the parent of which. Or which HTML tags wrap which.
As the comment says, the elements appearing later in this list get a higher place in the DOM hierarchy. As one would expect, “link” appears almost last, so other formatting tags are pushed between the <a>-</a> tag and not vice versa. So as one would expect, formatting inside link text never generates multiple links. Only a “code” blot can do that.
As for blot classes that aren’t listed, they get lowest down in the DOM (as if they were before “cursor”), and if two unknown blot classes compete, it’s as if they were listed in this array in alphabetical order.
The important takeaway is that if you’re writing a blot that extends Inline (even indirectly), you may want to add it to the list somewhere towards the end, if having it divided into segments is an issue (like with the Link blot).
This is done more or less like this — with emphasis on the part marked in red.
var Inline = Quill.import('blots/inline');
class MyBlot extends Inline {
[ ... ]
}
MyBlot.blotName = 'myblot';
MyBlot.tagName = 'span';
MyBlot.className = 'myclass';
Inline.order.push(MyBlot.blotName);
It would of course have been nicer to have an official API for this rather than hacking an internal variable, but I can’t see a more robust way for this.
How ordering is implemented
If you just wanted the bottom line, there’s no point reading further in this post. What follows is the partial explanation to how I reached the conclusion above. For this section, I’ll stick with Quill’s blots/inline.js unless said otherwise.
First, this is the compare() utility function:
static compare(self, other) {
let selfIndex = Inline.order.indexOf(self);
let otherIndex = Inline.order.indexOf(other);
if (selfIndex >= 0 || otherIndex >= 0) {
return selfIndex - otherIndex;
} else if (self === other) {
return 0;
} else if (self < other) {
return -1;
} else {
return 1;
}
}
It returns a negative number if its first argument’s name appears before the second in Inline.order and vice versa. That’s the main thing about it. Since indexOf() returns -1 if the string isn’t found, a name not found is considered as if it was before the first element in the array.
And now to Inline’s formatAt():
formatAt(index, length, name, value) {
if (Inline.compare(this.statics.blotName, name) < 0 && Parchment.query(name, Parchment.Scope.BLOT)) {
let blot = this.isolate(index, length);
if (value) {
blot.wrap(name, value);
}
} else {
super.formatAt(index, length, name, value);
}
}
Before getting into the details, I’ll explain this briefly: The call to compare() checks the position of the desired blot name (the format to add) vs. the name of the current blot (the one of the object that the method is called with). If the current blot appears before in Inline.order, it’s shred into pieces if necessary, and the piece that corresponds to desired segment (index, length) is pushed down as the child of a new blot that is created for the formatting.
An additional condition for this to happen is that the name of the formatting corresponds to a blot class (this is the Parchment.query() part).
Otherwise, the current blot remains intact, and formatAt() call is applied to all its children. This is the super.formatAt() part.
So now the details. It’s a long journey.
The super.formatAt() part is easier to explain. It falls on the method defined in the ContainerBlot class (see parchment/src/blot/abstract/container.ts):
formatAt(index: number, length: number, name: string, value: any): void {
this.children.forEachAt(index, length, function(child, offset, length) {
child.formatAt(offset, length, name, value);
});
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) with proper index and length for each call.
As for the shredding part with isolate() and wrap(): I’m going into these in detail below, but I’ll give the spoiler already:
- isolate() returns a blot that consists of the segment given by index and length, relative to the blot it’s called on. If that blots needs to be chopped up into siblings (including chopping up the subtree), so be it.
- wrap() creates a new blot based upon the name and value argument, and pushes the object on which it was called as the new blot’s child. Which is the equivalent of wrapping the HTML segment with the new blot’s tags.
The isolate() method
isolate() is defined in parchment/src/blot/abstract/shadow.ts:
isolate(index: number, length: number): Blot {
let target = this.split(index);
target.split(length);
return target;
}
This method quite simply chops up the blot into pieces as necessary to obtain a blot that covers exactly the desired segment, as required by the index and length arguments.
To get an idea how this work, consider the split() method for plain text blots, as defined in parchment/src/blot/text.ts:
split(index: number, force: boolean = false): Blot {
if (!force) {
if (index === 0) return this;
if (index === this.length()) return this.next;
}
let after = Registry.create(this.domNode.splitText(index));
this.parent.insertBefore(after, this.next);
this.text = this.statics.value(this.domNode);
return after;
}
This method divides the blot into two at the index (relative to the blots beginning, of course) and returns the second (new) blot. And it does nothing in particular if the split point is between blots (index at the beginning or after segment).
This snippet concisely shows how a text node is split with the DOM API’s splitText, and how the new blot node is created and inserted, but that’s a different story.
When the blot for splitting is a Bold, Italic or some other Inline class, ContainerBlot’s split() method does the work instead (see parchment/src/blot/abstract/container.ts):
split(index: number, force: boolean = false): Blot {
if (!force) {
if (index === 0) return this;
if (index === this.length()) return this.next;
}
let after = <ContainerBlot>this.clone();
this.parent.insertBefore(after, this.next);
this.children.forEachAt(index, this.length(), function(child, offset, length) {
child = child.split(offset, force);
after.appendChild(child);
});
return after;
}
So as one would expect, this method clones the blot in question, and then calls the split() method on all children that are in the range from the split point (i.e. @index) to the end of the what the blot covers. Effectively, this split() call changes only the first child in the list. The second command in the loop moves the child to the cloned blot object. So all in all, this clones the blot for which the method is called, and divides the child blots as appropriate, including splitting a child blot as necessary.
Due to the recursive nature of this method, child blots are split further down as necessary. As one would expect.
The wrap() method
So first a word about class hierarchy: FormatBlot extends ContainerBlot, which extends ShadowBlot.
The wrap() in FormatBlot (see parchment/src/blot/abstract/format.ts) reads:
wrap(name: string | Parent, value?: any): Parent {
let wrapper = super.wrap(name, value);
if (wrapper instanceof FormatBlot && wrapper.statics.scope === this.statics.scope) {
this.attributes.move(wrapper);
}
return wrapper;
}
so there’s nothing interesting here: Aside from doing things with attributes, it just calls super.wrap(). None is defined in ContainerBlot, so that leaves us with ShadowBlot (parchment/src/blot/abstract/shadow.ts):
wrap(name: string | Parent, value?: any): Parent {
let wrapper = typeof name === 'string' ? <Parent>Registry.create(name, value) : name;
if (this.parent != null) {
this.parent.insertBefore(wrapper, this.next);
}
wrapper.appendChild(this);
return wrapper;
}
So pretty much as mentioned above: A new blot object is created with the name and value given, and inserted in place of the object for which the method was called (with insertBefore) and then the latter object becomes the child of the new blot object.
formatAt() for non-ContainerBlot descendants
This isn’t related related, but not all classes are derived from ContainerBlot, which catches formatAt() and applies it to all children. So what happens otherwise?
The generic formatAt() method (as well as several other manipulation methods) is defined in parchment/src/blot/abstract/shadow.ts:
formatAt(index: number, length: number, name: string, value: any): void {
let blot = this.isolate(index, length);
if (Registry.query(name, Registry.Scope.BLOT) != null && value) {
blot.wrap(name, value);
} else if (Registry.query(name, Registry.Scope.ATTRIBUTE) != null) {
let parent = <Parent & Formattable>Registry.create(this.statics.scope);
blot.wrap(parent);
parent.format(name, value);
}
}
So when the name corresponds to a blot class (as opposed to an attribute), formatAt() calls isolate() on the current object, and then calls wrap() on the object that isolate() returned. Just like the shredding option in Inline’s formatAt(). Actually, it was probably Inline’s formatAt() method that was copied from here, but never mind that.
And if you’re as far as this, it’s probably a good idea to start over to remind yourself what this post is about.
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.
Introduction
These are some notes I made while selecting a browser-based rich text editor for my own personal use. Internet and web tech is not my expertise, so I had to catch up some stuff, just to make educated decisions.
I’ll reiterate: I take notes as I work, so this explains why there are several posts about a tool I eventually opted out.
As the title implies, I chose Quill in the beginning, and gave it up after a month. As I had fun getting my hands dirty with JavaScript (for a change, in my case) I was fine with the time spent to learn Quill’s internals (I’ve written several other posts on that), but after three weeks I felt I understood what’s going on, and a week after that I realized any change is going to be difficult regardless. Looking at how much progress I had achieved (that is, none) I also realized how far I was from finishing, should I remain with Quill. So that was a moment to ignore sunk costs, and pick something else (TinyMCE). More on this below.
Having considered a few editors out there, it seems like they all want to appear to be flexible, modular and customizable. Unfortunately, the only way to tell whether an editor is really flexible is to try to change something that is beyond where the buttons are and what they look like. So it’s requires quite some efforts to reach the point where one can tell if the choice of editor was correct.
Regardless, there’s a rather amusing thing about how many editors present their browser compatibility: It often says “supports latest versions of X, Y ,Z”. Do that convince anyone out there? Is it obvious to everyone that it’s pointless to support the latest browsers? It’s the oldest browsers that any random user out there may have that one need to support.
Editing rich text in general
The old school method for edit regions is by virtue of something like:
<textarea name="content" id="content" class="mceEditor" rows="3" cols="15" tabindex="2"></textarea>
However for rich text, the common trick is to set the contenteditable attribute to an HTML element, hence making its content editable by the browser itself with something like
<div contenteditable="true">This is rich text editable</div>
This sets the isContentEditable property for that element, and also opens for calls to execCommand() for making changes in the text (italics, bold etc.). But even as is, copy-pasting formatted text ends up formatted, and that can be fetched as the innerHTML property. Or the DOM can be traveled down by the inspecting script. This is how common rich editors implement the control buttons for requesting different type faces.
As the text is edited, the DOM tree of that element is updated.
There are however drawbacks with this method. The most commonly mentioned is that different browsers generate different HTML (i.e. a different DOM) in response to user edits. For example, a simple newline can result in the insertion of a <br>, a <p> or a <div>. Different browsers do different things.
But what’s even worse, is that the editing possibilities are limited to what the browser considers reasonable formatting. The fact that the underlying data structure for storing the edited text is the DOM, makes it significantly more difficult to inject information that isn’t DOM related, for example side information that isn’t displayed.
For this reason, there are “block styled editors” which maintain a separate data structure for the text and its edits. The HTML / DOM representation is just for viewing.
Selecting the editor
These are the candidates I looked at:
Block styled editors (don’t rely on the contenteditable element):
- Quill looked right on the spot, as it has its own data structure for representing text, hence insertion of custom content types (which is exactly what I need) comes naturally. Their git repo looks senseible, and runs back to 2012. It’s apparently the most popular.
- Trix. For better or for worse, it contains a lot of CoffeeScript.
- Editor.js. Git repo is sometimes clear, but messy most of the time. Goes back to 2015. Lots of .ts files.
- Slate. Currently Beta, thought. Basically written in .ts and .tsx. Git repo fairly organized, going back to 2016.
Traditional editors:
- CKEditor
- TinyMCE (used by to write this post in WordPress)
- Draft
- ProseMirror
- Redactor
Why I tried Quill
Since I expected a need to add all kinds of unforeseen extra information into the documents, I had the idea that a block styled editor was required. In other words, an editor that keeps the info in some separate data structure.
As far as I could tell, that left me with four candidates: Quill, Slate, Editor.js and Trix.
Another thing is that I intend to keep the edited documents in a git repository, so it’s a bonus if changes play nicely along with plain text diffs. This isn’t all that bad with HTML, but the fact that Quill relies on Delta makes it appear to be better in that respect.
On the other hand, there’s a post written by someone who went from Quill to Slate, but since I don’t expect to write very long pages (which was the main problem presented there) and assuming that the related bug was probably fixed since, it didn’t turn me off Quill. Aside from those specific bugs, that post actually praises Quill.
A complaint regarding Quill is the lack of support for Shift-Enter, for the sake of inserting a <br>. This goes against Quill’s design principles, which is why the author insists on not supporting it. As it turned out, this is just the tip of the iceberg.
Why I went away from Quill
Quill has a very restrictive view on how things should be done. As long as you’re fine with it as is, Quill is great. As long as your customization needs match those envisioned when Quill was designed, it’s a piece of cake. But try something else, and you’re in for bitter fight against the machine. As for developing further, I quite doubt it ever will. It looks like it has reached the point of deadlock where it’s so hacked that every little change is an adventure.
I should also mention that the JavaScript coding style in Quill is exceptionally difficult to read. In particular, there’s a lot of games with the reduce() method and similar tricks to to avoid plain for loops (to keep the code short and concise?), and unless you’re used to those tricks, it’s quite difficult to figure out what the code does. Or with a positive attitude: Quill is a good exercise in advanced JavaScript. Actually, if there’s any reason I’m glad that I spent time learning Quill, it’s the great exercise it was learning JavaScript and how editing works.
The main thing with Quill is that it maintains two extra parallel representations of the edited document, which must be in sync at all times. It’s a nice idea theoretically, however reality brings a lot of corner cases, which haven’t always been solved elegantly. Or can be. So there are all kinds of special-case hacks around to handle special cases. The fact that the editor works appears to rely on a battery of tests that are performed automatically. So the code isn’t stable in the sense that you can make logical changes and expect the logical outcome. Things will break.
Quill is in fact quite radical about its Delta representation of the document. The thought behind the editor is that the document should be stored in Delta format only, and that the browser that displays the content to the end user should load Quill’s JavaScript code, and obtain this Delta representation (in JSON, I guess) for producing the DOM structure. Sticking to the Delta paradigm makes it increasingly difficult to support anything but a simple document structure. That can be seen as an advantage, because documents should be kept simple…? But for people like me, who don’t want the tools to tell me what’s right and what’s wrong, it’s quite a disadvantage.
This paradigm is probably why there’s no official method to obtain the HTML code directly from Quill’s own machinery.
Let’s just mention a few options that won’t necessarily work as expected:
- Get the editor window’s innerHTML property from the DOM. The problem is that Quill inserts a Cursor blot object into the DOM under certain conditions, which is rubbish when shown to the end user. The best workaround I’ve seen for this is to generate a second instance of a Quill editor, with a hidden <div>, push the Delta there, and obtain its innerHTML. This avoids the Cursor object problem.
- Generate the HTML with some external tool. Well, that works unless there are some customized blot classes added to the editor. They will appear in the Delta representation as “hey, put a blot of this class here” and the external tool will have no idea what to do with it.
I don’t know how common it is in the web development industry to hack around and ensure that things work by virtue of heavy testing. For someone who just wants an editor for personal use, this is a big no-no.
So if there’s anything I learned from my month of trying to figure out Quill, it’s not to pick a block styled editor, but go for a traditional one. And if the HTML gets messy because of that, fix it manually. As for meta-data in the document, let a post-processing script wipe them away from the HTML, rather than avoiding their production by virtue of special blots.
Actually, after a couple of days adopting TinyMCE instead, I went back to my TODO list, and it turned out that the majority of tasks were about tweaking Quill into doing things that are there out of the box in TinyMCE. So I literally reduced the TODO list by half by migrating to TinyMCE.
Getting started
The confusing question is what to start with. The official suggestion seems to be to include a link to something like https://cdn.quilljs.com/1.0.0/quill.js in the website itself, so the JavaScript is downloaded by all websites from Quill’s servers. Nothing I would consider in any scenario, as my website would become dependent on some server I have no control over.
So what then? Build from the git repo? From the Webpack?
So first, the important note: It’s quite difficult to set up a working environment for building Quill from its git repo. There are a lot of dependencies on different tools, spanning from Ruby, Python, Node.js and whatnot, each with its own set off packages. And apparently, it’s a matter of having just the right version of some of these software elements (not necessarily the latest). So unless you want to dedicate some significant time on developing Quill’s core itself, the git repo’s only use is to see the sources, and not to build anything from them.
Which might be a hint not to try to hack the core code, but rather extend it by virtue of adding modules and such.
I ultimately chose to download quill.tar.gz from the release download page for the release version of v1.3.7. Bonus: It’s compact, and contains a few nice examples (that is, HTML that actually makes an editor).
The HTML examples all rely on quill.min.js (fetched locally). Some also rely on external sources for KaTeX (mathematical typing) and higlight.js (for syntax highlighting). The recommended example is examples/full.html, as it presents an editor with (apparently) all modules released with quill.js appearing in the toolbar.
The quill.min.js (216 kB) is the result of minification of quill.js (440 kB).
Now, it’s possible to obtain the exact same files from the Webpack offered from the site, as I show below. After trying that, I found that all files in the node_modules/quill/dist/ directory (after building, of course), were diff-identical with the files in the release version I had downloaded (which was the last release at that time). A quill.min.js.map (which can help debugging the minified version) was present only in the dist/ directory.
However building the Webpack turned out useful as a quick way to get access to other sources, in particular those for Delta and the Parchment. Which can be obtained separately, but why not get all in one?
Quill’s full repo can be fetched with
$ git clone https://github.com/quilljs/quill.git
Some initial sources to look at (except for my own posts on the matter):
Building the Webpack
Although most likely unnecessary, the Webpack can be cloned from Quickjs’ github page:
$ git clone https://github.com/quilljs/webpack-example.git
There might be a need to go “sudo apt install npm” before going on, which may also install a lot of dependencies (Node.js and a lot of other things). Then simply follow the instructions in the repo’s README.md file:
$ cd webpack-example
$ npm install
$ npm run build
“npm install” runs without root privileges downloads a lot of stuff into .npm (some 33 MB) and then extracts that into node_modules. Took less than 3 minutes, with some warnings about deprecatd packages. OK. The second command took 25 seconds, and it just created dist/bundle.js at the root directory, which is practically useless.
Trying it out
This is a good post on how to generally get started, including adding custom styles.
A simple page with an editor could read:
<!-- Add the theme's stylesheet -->
<link rel="stylesheet" href="quill.snow.css">
<!-- Create the editor container -->
<div id="editor">
<p>Hello World!</p>
<p>Some initial <strong>bold</strong> text</p>
<p><br></p>
</div>
<script src="quill.js"></script>
<script>
var quill = new Quill('#editor', {
theme: 'snow' // Specify theme in configuration
});
</script>
Note that two files are mentioned: quill.snow.css and quill.js. Only these two are required for this to work in this specific example.
The files to copy are in node_modules/quill/dist/. Note that there are other files with the same name in other directories (in particular quill.core.js). It’s those in quill/dist that should be used.
Node.js is required to build several JavaScript packages (Quill, for example) and the version that was available from the repository of my Linux Mint 18.1 was way too old: node v4.2.6 and npm v3.5.2. The attempt to build the Quill Webpack with those oldies failed miserably.
Truth to be said, there are instructions on how to install Node.js from binaries, but they relate to stash the installation away from the regular binary search part. So I improvised a bit, and hey, it works.
Download the latest binary (i.e. the Linux Binaries (x64) option) from Node.js’ main download page , extract and install. Everything (except the tar command) must be done as root:
$ tar -xvJf node-v16.13.0-linux-x64.tar.xz
# chown -R root:root node-v16.13.0-linux-x64/
# cd node-v16.13.0-linux-x64/
# mv include/node /usr/include/
# mv bin/* /usr/bin/
# mv lib/node_modules/ /usr/lib/
# mv share/man/man1/node.1 /usr/share/man/man1/
# mv share/systemtap/tapset/node.stp /usr/share/systemtap/tapset/
Note that some of the files in bin/ are relative symbolic links to lib/node_modules, so if the target directory outline is different from this, the symbolic links need to be remade.
After this, there should be no significant files to copy, so
$ find . -type f
./CHANGELOG.md
./LICENSE
./README.md
To run over SSH: Not
This is how to run a Firefox browser on a cheap VPS machine (e.g. a Google Cloud VM Instance) with an X-server connection. It’s actually not a good idea, because it’s extremely slow. The correct way is to set up a VNC server, because the X server connection exchanges information on every little mouse movement or screen update. It’s a disaster on a slow connection.
My motivation was to download a 10 GB file from Microsoft’s cloud storage. With my own Internet connection it failed consistently after a Gigabyte or so (I guess the connection timed out). So the idea is to have Firefox running on a remote server with a much better connection. And then transfer the file.
Since it’s a one-off task, and I kind-of like these bizarre experiments, here we go.
These steps:
Edit /etc/ssh/sshd_config, making sure it reads
X11Forwarding yes
Install xauth, also necessary to open a remote X:
# apt install xauth
Then restart the ssh server:
# systemctl restart ssh
and then install Firefox
# apt install firefox-esr
There will be a lot of dependencies to install.
At this point, it’s possible to connect to the server with ssh -X and run firefox on the remote machine.
Expect a horribly slow browser, though. Every small animation or mouse movement is transferred on the link, so it definitely gets stuck easily. So think before every single move, and think about every single little thing in the graphics that gets updated.
Firefox “cleverly” announces that “a web page is slowing down your browser” all the time, but the animation of these announcements become part of the problem.
It’s also a good idea to keep the window small, so there isn’t much to area to keep updated. And most important: Keep the mouse pointer off the remote window unless it’s needed there for a click. Otherwise things get stuck. Just gen into the window, click, and leave. Or stay if the click was for the sake of typing (or better, pasting something).
Run over VNC instead
This requires installing an X-Windows server. Not a big deal.
# apt update
# apt-get install xfce4
# apt install x-window-system
once installed, open a VNC window. It’s really easiest by clicking a button on the user’s VPS Client Area (also available on the control panel, but why go that far) and go
# startx
at command prompt to start the server. And then start the browser as usual.
It doesn’t make sense to have a login server as it slows down the boot process and eats memory. Unless a VNC connection is the intended way to always use the virtual machine.
Firefox is still quite slow, but not as bad as with ssh.