HowTo: Custom plugins for the Wagtail rich text editor

June 22nd, 2017 by Max Brauer

The Content Management System (CMS) for the django web framework wagtail is an awesome piece of technology, developed by torchbox for the Royal College of Art. In comparison to other CMS, wagtail lacks basic builtin content types but it provides a very easy way of generating your very own page types.

This blog you are currently reading is build with wagtail and even if wagtail has a very good documentation, at some points it lacks of decent examples that go the full way. From the beginning to the end. One of those missing examples can be found about the Editor Interface of the hooks section. The hooks section itself explains in a very good way how to customize the internals of the wagtail admin or the navigation without breaking the existing parts.

It explains hows how to include custom CSS, custom JavaScript and allow additional HTML tags in the editor but it does not show how to write the JavaScript that is needed, to actually add features to the rich text editor.

This blog post shows how to extend the rich text editor of wagtail from the beginning to the end. If you follow this tutorial you will be able to mark text in your editor and turn it into a preformatted code block that automatically gets highlighted using highlightjs.

This blog post is split into 3 parts:

  1. white-list HTML tags for the editor
  2. add custom CSS files for the editor
  3. add custom JavaScript for the editor

White-list HTML tags for the editor

To include a custom tag into the hellojs rich text editor, you first need to add wagtail_hooks.py to your django app. This file will automatically get loaded on startup by wagtail and hooks defined in there will be collected. To allow additional HTML tags, that not get filtered out by wagtail, you need to register to theconstruct_whitelister_elements_rules hook from wagtail. For now, the wagtail_hooks.py needs the following content:

from wagtail.wagtailcore import hooks
from wagtail.wagtailcode.whitelist import allow_without_attributes, attribute_rule


def whitelister_element_rules():
return {
'pre': allow_without_attributes,
'code': attribute_rules({'class': True}),
}


hooks.register('construct_whitelister_element_rules', whitelister_element_rules)

First of all, we need to import the hooks registry from wagtail as well as the allow_without_attributes  and  attribute_role for the white listing. When you use the hooks.register function for this hook, you have to provide a callable as second element. You are free to choose it's name. In the example above the callable is a function called whitelister_element_rules. The hook expects a dictionary as return value which contains the HTML to white-list as a key and additional information as values. The example above white-lists the pre tag and uses allow_without_attributes as values. Using this will still leave pre tags in the HTML but will remove all attributes of the tag. The code block in the example above uses the attribute_rule function for its values. This one also allows white-listing for attributes for an HTML tag. The example above preserves the class attributes of an code block. This is important later, when we want to define the language for hightlightjs.

Add custom CSS files for the editor

Once we have white-listed additional tags for the editor, we need to be able to insert custom CSS and JavaScript. The CSS is needed to ensure, the content we see inside of the rich text editor looks as close as possible to the final result on the page. We also need the custom CSS to add our icons to the buttons of the rich text editor. To add CSS files, we need to listen to the insert_editor_css by defining a callable and register it for that hook. The complete code we need to add would look like the following:

from django.utils.html import format_html_join


def editor_css():
    cs_files = [
        'css/icons.css',
        'highlightjs/styles/monokai_sublime.css',
    ]
    return format_html_join('\n', '<link rel="stylesheet" href="{0}{1}">',
        ((settings.STATIC_URL, cs_file) for cs_file in cs_files)
    )


hooks.register('insert_editor_css', editor_css)

First of all, we need the format_html_join to add multiple css files to the editor. If you just need one css file, you can use format_html instead, which will be used later. The format_html_join function accepts 3 arguments. The first argument is the string used to join the list of provided elements. The second argument is a string template of which format will be called with each element in the third argument. The third argument is an iterable, containing an iterable as arguments. The iterable in the example above is a generator, that will create tuples containing the static url as first and the path of the css file as second argument.

In the example above, 2 CSS files are added to the page where the rich text editor gets loaded. First of all, it loads a css called icons.css with the following content:

.code-icon {
  display: block;
  width: 14px;
  height: 14px;
  background-size: 100% 100%;
}

.code-icon-python {
  background-image: url('../img/python.svg');
}

.code-icon-javascript {
  background-image: url('../img/javascript.svg');
}

.code-icon-css {
  background-image: url('../img/css.svg');
}

code {
  padding: 0.3em;
  margin: 0;
  font-size: 85%;
  background-color: rgb(27, 31, 35);
  background-color: rgba(27, 31, 35, 0.05);
  border-radius: 3px;
}

The css is simple. It just formats tag with the classes .code as well as .code-icon-<language> to make them fit into the overall theme of the editor. There is also styling for code blocks that will not be highlighted with highlightjs (e.g. inline code blocks).

Add custom JavaScript for the editor

Adding JavaScript is similar to adding CSS, however it listens to the insert_editor_js hook instead:

from django.utils.html import format_html


def editor_js():
    js_includes = format_html('<script src="{0}{1}"></script>'.format(
settings.STATIC_URL, 'js/hello_code_plugin.js'
    )
    return js_includes + format_html(
        """
<script>
registerHalloPlugin('codebutton');
</script>
       """
    )


hooks.register('insert_editor_js', editor_js)

The editor_js function returns the script tag for the js/hello_code_plugin.js file as well as some custom script tag to register our own plugin.

The most complicated part is the code of the hello_code_plugin.js. Writing a plugin for the hellojs editor follows basically the rules of creating a jQuery UI plugin. The content of the hello_code_plugin.js is the following:

var widget;

function instertLanguageCodeblock(event) {
var insertionPoint, lastSelection;
language = $(event.currentTarget).data('language');
lastSelection = widget.options.editable.getSelection();
insertionPoint = $(lastSelection.endContainer).parentsUntil('.richtext').last();
var elem = '<pre><code class="hljs ' + language + '">' + lastSelection + '</code></pre>';
var node = lastSelection.createContextualFragment(elem);
lastSelection.deleteContents();
lastSelection.insertNode(node);
}

(function() {
(function($) {
return $.widget('IKS.codebutton', {
options: {
uuid: '',
editable: null
},
populateToolbar: function(toolbar) {
widget = this;
var button = $('<span></span>');
languages = ['python', 'javascript', 'css']
button.hallobutton({
uuid: this.options.uuid,
editable: this.options.editable,
label: 'Inline Code Block',
icon: 'fa fa-code',
command: null
});
toolbar.append(button);
for (var i = 0; i < languages.length; i++) {
language_button = $('<span data-language="' + languages[i] + '"></span>');
language_button.hallobutton({
uuid: this.options.uuid,
editable: this.options.editable,
label: languages[i],
icon: 'code-icon code-icon-' + languages[i],
command: null
});
language_button.on('click', instertLanguageCodeblock);
toolbar.append(language_button);
}
button.on("click", function (event) {
var insertionPoint, lastSelection;
language = $(event.currentTarget).data('language')
lastSelection = widget.options.editable.getSelection();
insertionPoint = $(lastSelection.endContainer).parentsUntil('.richtext').last();
var elem;
elem = '<code>' + lastSelection + '</code>';
var node = lastSelection.createContextualFragment(elem);
lastSelection.deleteContents();
lastSelection.insertNode(node);
}
);
}
});
})(jQuery);
}).call(this);

If you skip the first function definition, you see the creation of the codeblock widget in the IKS namespace. The important configuration is the populateToolbar. Here, an empty span tag is created on which the hellobutton function is called. Important here is the icon attribute. You can provide css classes, added to the icon of the button. If you use fontAwesome, you can here insert all icons. The example above shows for the first button the use of fa fa-code classes to provide the icon and then append the button to the toolbar.

After that, we iterate of the predefined list languages. For each string in this list, an additional button gets created to add a <pre><code></code></pre> tags from whose the code tag contains the string as a class. This is needed, so highlightjs is able to identify the correct language. Additional to creating and adding the buttons to the toolbar, a on click handler is added. The handler is called insertLanguageCodeblock and defined at the top of the file. The on click handler added to the first button, which gets added to the bottom is similar to the code in the insertLanguageCodeblock so I will not talk about it in detail.

First, the code gets the clicked language from the data attribute, the selected text from the text area and the point to insert. Then the code creates the new element, removes the selection and inserts it at the prior selected point to insert.

Finally, don't forget to insert the css and js of highlightjs to the frontend activate it.

That's it! Following this, you now have the possibility to add inline code blocks as well as preformatted blocks for Python, JavaScript and CSS code.

I'm currently thinking of releasing a small django app that adds the above created feature to wagtail. Until then, you can check out the code of this website if you want to see the full code. It's part of the blogutils app.