Wagtail: Extending the Draftail Editor Part 2 - Block Styles

Introduction

Earlier, I looked at making some basic configuration changes to your Draftail rich text editor.

In the previous article, I looked at creating inline styles and adding these to your editors.

This article will go over customising and creating block styles - those styles that apply to a paragraph or block of text as a whole such as lists or blockquotes.

fa-regular fa-pen-to-square fa-xl Note
In Draftail, blocks of text refer to single structural segment of text such as <p>...<p>, <ul>...</ul>, <h3>...</h3> etc.. It does not refer to a Wagtail Block of text.

I'll go through how to add text alignment buttons which Draftail lacks the ability to do. Because Draftail supports only one concurrent block style (for example, you couldn't mark a block of text H2 and centre aligned in the editor which are both block styles), I'll look at alternatives to using block styles for alignment.

fa-solid fa-triangle-exclamation fa-xl WARNING
Before getting started with block styles you should be aware that the Draftail editor does not support overlapping block styles. You can combine a block style with an entity or inline style, but not two block styles applied to the same block of text.

If you've arrived here looking to add custom list styles, be sure to read Wagtail: Extending the Draftail Editor Part 4 - Custom Lists afterwards.

Customising Existing Block Styles

Your front-end may have some particular styling for block types that you want reflected in the editor interface. The blockquote style is a good example of this.

On this site, the blockquote is styled like this

However, the default editor style looks like this.

It would be preferable to have the front-end style reflected in the editor.

This doesn't require a plug-in, just some custom CSS.

Adding a Custom Admin CSS file

Customising your Wagtail admin site CSS is beyond this article, but, in case you're not already doing so, a quick way to add your own CSS is to create a custom admin base template. The most basic would be the following /wagtailadmin/base.html created in your site template folder:

{% extends "wagtailadmin/base.html" %}
{% load static %}
{% block extra_css %}
    <link rel="stylesheet" href="{% static 'css/admin.css' %}"  type="text/css">
{% endblock %}

Create an admin.css in your static CSS folder and add any required fonts and variables needed.

Block Style Editor CSS Classes

Every block style is rendered in the editor with the CSS class class="Draftail-block--_type". An H3 block uses the feature with _type header-three, so the block is rendered in the editor with class="Draftail-block--header-three", the blockquote feature is rendered with class="Draftail-block--blockquote" etc.. This applies to built-in block-styles and also any custom ones you later create (as we'll see later).

On my front-end, the blockquote style is defined:

blockquote, .blockquote {
    border-inline-start: 0.5rem solid var(--bs-info);
    border-radius: 0.25rem;
    background-color: var(--bs-light);
    font-style: normal;
    font-family: var(--font-family-headings);
    font-weight: 500;
    padding: 1.2rem 2rem;
    margin: 1.6rem 0;
  }

All that's required for the Draftail editor to reflect this is to copy the class definition to the admin.css file and rename it accordingly:

.Draftail-block--blockquote {
    border-inline-start: 0.5rem solid var(--bs-info) !important;
    border-radius: 0.25rem;
    background-color: var(--bs-light);
    font-style: normal;
    font-family: var(--font-family-headings);
    font-weight: 500;
    padding: 1.2rem 2rem !important;
    margin: 1.6rem 0;
    color: var(--bs-dark);
}

Note, I've included an additional definition for text color. Without this, the text color will inherit the active wagtail theme color. The color I'm using here maintains contrast with the block background-color.

So long as your CSS variables are also defined (or loaded externally), you will see your blockquote text formatted in the editor as it is on the front-end now.

fa-regular fa-pen-to-square fa-xl Note
I needed to add !important to border-inline-start and padding. CSS declared in the built-in draftail.css takes precedence, any conflicting properties in your admin.css must include the !important flag to take effect.

Registering Custom Block Styles with the Draftail Editor

The Wagtail documentation gives a brief introduction to creating new block styles.

It's a very similar process to the one I described in the previous post for creating new inline styles, with a few differences:

  • The Draftail control has an optional element property that directs how to style the block in the editor.
  • The plugin is registered with the BlockFeature method instead of InlineStyleFeature
  • The conversion is set up with BlockElementHandler and block_map

The Block Style Register Function

As with inline style plugins, I created a function to register block plugins and saved this to the same draftail_extensions.py file:

import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail.admin.rich_text.converters.html_to_contentstate import (
    BlockElementHandler,
    InlineStyleElementHandler,
)
from wagtail.admin.rich_text.editors.draftail.features import InlineStyleFeature

def register_block_feature(
    features,
    feature_name,
    description,
    type_,
    css_class,
    element="div",
    wrapper=None,
    label=None,
    icon=None,
    editor_style=None,
):
    control = {
        "type": type_,
        "description": description,
        "element": element,
    }
    if label:
        control["label"] = label
    elif icon:
        control["icon"] = icon
    else:
        control["label"] = description
    if editor_style:
        control["style"] = editor_style

    features.register_editor_plugin(
        "draftail",
        feature_name,
        draftail_features.BlockFeature(control, css={"all": ["css/draftail-editor.css"]}),
    )

    block_map = {"element": element, "props": {"class": css_class}}
    if wrapper:
        block_map["wrapper"] = wrapper

    features.register_converter_rule(
        "contentstate",
        feature_name,
        {
            "from_database_format": {
                f"{element}[class={css_class}]": BlockElementHandler(type_)
            },
            "to_database_format": {
                "block_map": {
                    type_: block_map
                }
            },
        },
    )
  • features: supplied by the register_rich_text_features hook and requires passing through
  • feature_name: the name used internally by Wagtail for this feature - this is the name you use in your feature list when including it in your editor.
  • description: this is the text displayed when the mouse is hovered over the toolbar button.
  • type_: For block styles, this will be the suffix for the editor style class as mentioned above. I generally use the feature name in uppercase.
  • css_class: the front-end CSS class that this element will be rendered with (i.e. the text block will be wrapped with <element class=css_class>...</element>)
  • element: optional - this is the HTML element tag to be used (i.e. tag='p' will render <p>...</p>), <div> by default.
  • wrapper: optional list style for <li> list elements - see Part 4 for a discussion on custom lists
  • editor_style: optional, used to alter the way the formatted text is displayed in the editor, defined as a dictionary of css properties (e.g. editor_style={'color': 'red', 'font-size': 'larger'}). It's preferable to define style using a Draftail-block--feature_name CSS class as described in the first part of this article.
  • label, icon: choose one of these. label will take unicode glyphs which we'll use for the underline style, while icon can take an SVG path (set for a 1024x1024px viewbox). Ignore the documentation's claim that you can use Font Awesome icons, this is no longer true. If you supply neither, the function will pass the description to the label property.
  • Finally, after the declarations, are the two methods to register the plug-in and the conversion rule:
features.register_editor_plugin(
        "draftail",
        feature_name,
        draftail_features.BlockFeature(control, css={"all": ["css/draftail-editor.css"]}),
    )
Note

I've included a call to css/draftail-editor.css in the register_editor_plugin method. This tells the Draftail editor where to find the styling for this block.

It's optional and can be left out, and of course, you can name this CSS as you like. I found it better to have a dedicated CSS for block styles - using the admin CSS causes it to be loaded twice. Leaving the CSS definition off and loading via the admin CSS changes the precedence, any styles that conflict with the built-in Draftail styles will be overwritten.

Understanding the Converter Rule

Note that the register_block_feature function includes the following code:

block_map = {"element": element, "props": {"class": css_class}}
if wrapper:
    block_map["wrapper"] = wrapper

features.register_converter_rule(
    "contentstate",
    feature_name,
    {
        "from_database_format": {
            f"{element}[class={css_class}]": BlockElementHandler(type_)
        },
        "to_database_format": {
            "block_map": {
                type_: block_map
            }
        },
    },
)

This registers the conversion rule that instructs Draftail how to interpret formatted rich text and convert to draft.js raw content and back again:

to_database_format

  • This tells Draftail how to take a piece of rendered rich text in the editor and convert it to the raw content stored on saving the page/model.
  • It takes a dictionary with single member whose key is "block_map", with a dictionary value which describes properties of the block (usually element and class).
  • In the register_block_feature function, the block map is first set up as a dictionary variable to allow for inclusion of the optional wrapper attribute (more on that later). By default, the element and class are added.

from_database_format

  • This tells draftail which registered 'type' to pass into the BlockElementHandler for a piece of raw content (found in the stored database value) to render as rich text. It's effectively the reverse of to_database_format.
  • It takes a dictionary with a single key/value pair. Think of this a bit like a css selector string (f"{element}[class={css_class}]") that identifies your particular custom block and style to apply to elements that match that selector (BlockElementHandler(type_))
Caution

Make sure the 'selector' (key value) is unique and is sufficiently specific to apply only to the relevant custom block type. Overlapping Draftail styles will cause styles to be overwritten unexpectedly when reloading the raw content into the draftail editor. This will happen if your 'selector' could return content with more than one Draftail style.

Creating the Alignment Block Styles

Because my site's body style defaults to justify, I'll create three styles: left, centre and right aligned. When none of these are applied, the text is justified.

  • The alignment property will apply to a paragraph as a whole, so each style will be registered with the <p> element.
  • I will use standard bootstrap classes for the front-end alignment. If you're not using bootstrap, just create appropriate classes in your site's front-end CSS.
  • The toolbar buttons will be styled with SVG paths using the control's icon property.

Defining the Icons

I'll start with the svg files that I register with Wagtail for the icons fa-solid fa-align-left fa-solid fa-align-center fa-solid fa-align-right

<svg
   id='icon-left-align'
   viewBox="0 0 1024 1024"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:svg="http://www.w3.org/2000/svg">
   <path
      d="M 584.19821,168.76226 H 73.024776 C 32.701407,168.76226 0,137.69252 0,99.38113 0, 61.069736 32.701407,30 73.024776,30 H 584.19821 c 40.39183,0 73.02477,31.069736 73.02477, 69.38113 0,38.31139 -32.63294,69.38113 -73.02477,69.38113 z m 0, 555.04902 H 73.024776 C 32.701407,723.81128 0,692.80659 0,654.43015 0,616.05372 32.701407, 585.04902 73.024776,585.04902 H 584.19821 c 40.39183,0 73.02477,31.0047 73.02477,69.38113 0, 38.37644 -32.63294,69.38113 -73.02477,69.38113 z M 0,376.90564 C 0,338.5292 32.701407, 307.52451 73.024776,307.52451 H 949.32209 c 40.39183,0 73.02481,31.00469 73.02481,69.38113 0,        38.37644 -32.63298,69.38113 -73.02481,69.38113 H 73.024776 C 32.701407,446.28677 0,415.28208 0, 376.90564 Z M 949.32209,1001.3358 H 73.024776 C 32.701407,1001.3358 0,970.3311 0,931.95467 0, 893.57823 32.701407,862.57354 73.024776,862.57354 H 949.32209 c 40.39183,0 73.02481, 31.00469 73.02481,69.38113 0,38.37643 -32.63298,69.38113 -73.02481,69.38113 z"
   />
</svg>
<svg 
    id="icon-centre-align" 
    viewBox="0 0 1024 1024"
    xmlns="http://www.w3.org/2000/svg"
    xmlns:svg="http://www.w3.org/2000/svg">
    <path 
        d='M 729.54876,161.46125 H 293.0195 c -40.24254,0 -72.75487,-31.67405 -72.75487,-70.73062 C 220.26463, 51.674059 252.77696,20 293.0195,20 h 436.52926 c 40.24254,0 72.75488,31.674059 72.75488,70.73063 0, 39.05657 -32.51234,70.73062 -72.75488,70.73062 z M 947.81339,444.38376 H 74.754876 C 34.580543, 444.38376 2,412.77601 2,373.65314 2,334.53026 34.580543,302.92251 74.754876, 302.92251 H 947.81339 c 40.24254,0 72.75491,31.60775 72.75491,70.73063 0,39.12287 -32.51237, 70.73062 -72.75491,70.73062 z M 2,939.49815 C 2,900.37528 34.580543,868.76753 74.754876, 868.76753 H 947.81339 c 40.24254,0 72.75491,31.60775 72.75491,70.73062 0,39.12288 -32.51237, 70.73065 -72.75491,70.73065 H 74.754876 C 34.580543,1010.2288 2,978.62103 2,939.49815 Z M 729.54876, 727.30627 H 293.0195 c -40.24254,0 -72.75487,-31.60775 -72.75487,-70.73062 0,-39.12288 32.51233, -70.73063 72.75487,-70.73063 h 436.52926 c 40.24254,0 72.75488,31.60775 72.75488,70.73063 0, 39.12287 -32.51234,70.73062 -72.75488,70.73062 z'
    />
</svg>
<svg
    id='icon-right-align'
    viewBox="0 0 1024 1024"
    xmlns="http://www.w3.org/2000/svg"
    xmlns:svg="http://www.w3.org/2000/svg">
    <path
        d='M 947.56774,163.47496 H 437.33896 c -40.31719,0 -72.88983,-29.43806 -72.88983, -65.73748 C 364.44913,61.438065 397.02177,32 437.33896,32 h 510.22878 c 40.31718, 0 72.88986,29.438065 72.88986,65.73748 0,36.29942 -32.57268,65.73748 -72.88986, 65.73748 z m 0,525.89984 H 437.33896 c -40.31719,0 -72.88983,-29.37643 -72.88983, -65.73748 0,-36.36104 32.57264,-65.73748 72.88983,-65.73748 h 510.22878 c 40.31718, 0 72.88986,29.37644 72.88986,65.73748 0,36.36105 -32.57268,65.73748 -72.88986,65.73748 z M 0, 360.6874 C 0,324.32636 32.640975,294.94992 72.889826,294.94992 H 947.56774 c 40.31718, 0 72.88986,29.37644 72.88986,65.73748 0,36.36104 -32.57268,65.73748 -72.88986, 65.73748 H 72.889826 C 32.640975,426.42488 0,397.04844 0,360.6874 Z M 947.56774, 952.32472 H 72.889826 C 32.640975,952.32472 0,922.94829 0,886.58724 0,850.2262 32.640975, 820.84976 72.889826,820.84976 H 947.56774 c 40.31718,0 72.88986,29.37644 72.88986,65.73748 0, 36.36105 -32.57268,65.73748 -72.88986,65.73748 z'
    />
</svg>
fa-regular fa-pen-to-square fa-xl Note
The use of SVG icons to create button icons was introduced in the previous article. Briefly, you must use an SVG formatted for a 1024x1024px viewbox to render correctly. The icon parameter takes the id property of the SVG file without the 'icon-' prefix.

Registering the Block Styles

Now, in your wagtail_hooks.py, we can call the register function for each of the three styles with appropriate parameters:

# wagtail_hooks.py

@hooks.register('register_rich_text_features')
def register_align_left_feature(features):
    register_block_feature(
        features=features,
        feature_name='left-align',
        type_='LEFT-ALIGN',
        description='Left align text',
        css_class='text-start',
        element='p',
        icon='left-align'
    )
    
@hooks.register('register_rich_text_features')
def register_align_centre_feature(features):
    register_block_feature(
        features=features,
        feature_name='CENTRE-ALIGN',
        type_='centre-align',
        description='Centre align text',
        css_class='text-center',
        element='p',
        icon='centre-align'
    )
    
@hooks.register('register_rich_text_features')
def register_align_right_feature(features):
    register_block_feature(
        features=features,
        feature_name='RIGHT-ALIGN',
        type_='right-align',
        description='Right align text',
        css_class='text-end',
        element='p',
        icon='right-align'
    )

As with inline styles, you need to add the new block styles to your editor. How to do this was covered in Configuring Rich Text Blocks for Your Wagtail Site. To your features list, add 'left-align', 'centre-align', 'right-align' and reload the web service on your server, the align buttons will be on your toolbar when you next load a rich text block in your admin.

 
customised draftail toolbar with inline styles and block styles added
Example Draftail 3.x toolbar showing the three alignment buttons.
 

Adding the Editor CSS

Before the alignment styling is reflected in the editor however, there is one more addition to be made - we need to add the Draftail-block-- classes to the CSS.

When I registered the styles above, I defined the CSS file as draftail-editor.css. This file should reside in your static/css directory.

Normally, we would just need to define the Draftail block style here, but Draftail has a default left alignment set in public-DraftStyleDefault-ltr for some reason which will override any alignment we set in the block class. So, in this case, we define the editor alignment CSS classes as:

.Draftail-block--LEFT-ALIGN .public-DraftStyleDefault-ltr {
    text-align: left;
}
.Draftail-block--CENTRE-ALIGN .public-DraftStyleDefault-ltr {
    text-align: center;
}
.Draftail-block--RIGHT-ALIGN .public-DraftStyleDefault-ltr {
    text-align: right;
}

Now you can style your text with alignment and see it reflected in your editor.

Left

Centre

Right

Pro's & Con's of Block Style Alignment

Pro's

  • Familiar interface.
  • No extra blocks required, self-contained.

Con's

  • Only one block style per block can be applied (a centre-aligned H3 block is not possible like this for example).
  • Each new paragraph has default alignment. Alignment must be re-applied every time.

Alternatives to Block Style Alignment

1. Use a Header Block

If it's just headers you want to align on the fly, one option is to use a header block as described in a previous article here.

2. Use a Rich Text StructBlock

The method I use on this site (and elsewhere) is to create a StructBlock comprised of an alignment choice and the rich text block itself. This StructBlock then gets incorporated into every other StructBlock on the site that uses rich text.

This has the advantage of applying alignment to the block as a whole while leaving you free to use other block styles. The lesser disadvantage is that the selected alignment isn't reflected in the editor, but the big change is that this will disable the Wagtail 5+ behaviour of being able to split rich text blocks and insert blocks of other types.

Choices

I use bootstrap classes on this site, so I set the values in my choices to reflect those:

class TextAlignmentChoiceBlock(ChoiceBlock):
    choices=[
        ('justify', 'Justified'), 
        ('start', 'Left'), 
        ('center', 'Centre'), 
        ('end', 'Right')
    ]

Abstract Base Block

Create a base abstract StructBlock with the default editor next:

class RichTextStructBlock(StructBlock):
    alignment = TextAlignmentChoiceBlock(
        default = 'justify',
        label="Text Alignment"
    )
    content = RichTextBlock()

    class Meta:
        template = 'blocks/richtext_block.html'
        label = _("Rich Text Block")
        icon = 'pilcrow'
        abstract = True

Rich Text Struct Blocks with Configured Editor

Use the base block above to construct a rich text block type for each of the editors defined in your WAGTAILADMIN_RICH_TEXT_EDITORS setting (more info):

class DefaultRichTextBlock(RichTextStructBlock):
    pass

class MinimalRichTextBlock(RichTextStructBlock):
    content = RichTextBlock(editor='minimal')

class BasicRichTextBlock(RichTextStructBlock):
    content = RichTextBlock(editor='basic')

Template

The alignment values coming from the ChoiceBlock were justify, start, center, end. As I'm using bootstrap, text-{{ self.alignment }} will match bootstrap alignment classes.

{% load wagtailcore_tags  %}
<div class="text-{{ self.alignment }}">
    {{ self.content }}
</div>
fa-solid fa-triangle-exclamation fa-xl Important
The decision on using the standard RichTextBlock versus a rich text StructBlock as above should ideally be made before building out your site content. As the two have different data structure, you would need some careful manual migration to switch on an existing site without losing all your rich text content.

Now the StructBlock can be called inside other StuctBlock's rather than calling the RichTextBlock directly. For example:

class SimpleCard(StructBlock):
    background = ColourThemeChoiceBlock(
        default='bg-transparent',
        label=_("Card Background Colour")
    )    
    text = BasicRichTextBlock(
        label=_("Card Body Text"),
        help_text=_("Body text for this card."),
    )

    class Meta:
        template = 'blocks/simple_card_block.html'
        label = _("Simple Card (Text Only)")
        icon = 'form'

Conclusion

In this article, I showed how to customise existing block styles in the Draftail rich text editor, and how to register new block styles in Draftail by creating three alignment options.

We looked briefly at the pro's and con's of using alignment buttons in a rich text block and examined a couple of alternatives using a separate header block and a custom rich text StructBlock to overcome the Draftail limitation of only one concurrent active block style in any block of text.

In Part 3, I'll cover one method you might use inline styles and JavaScript to achieve dynamic text in your rich text areas.

Part 4 examines creating custom list styles, their limitations and a couple of examples of how you can use these.


  Please feel free to leave any questions or comments below, or send me a message here