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.

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 StructBlock 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 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.

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 branding_logo%}
{%endblock branding_logo%}
{%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" (inspect the heading above to see this), 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;
}

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-solid fa-triangle-exclamation 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:

# draftail_extensions.py

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",
    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": ["draftail-editor.css"]}),
    )

    features.register_converter_rule(
        "contentstate",
        feature_name,
        {
            "from_database_format": {
                f"{element}[class={css_class}]": BlockElementHandler(type_)
            },
            "to_database_format": {
                "block_map": {
                    type_: {"element": element, "props": {"class": css_class}}
                }
            },
        },
    )
  • 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.
  • 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": ["draftail-editor.css"]}),
    )
fa-solid fa-circle-info Note
I've included a call to 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.

And the conversion rule that sets up how the block will be rendered on the front-end:

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

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 adding properties to the DRAFTAIL_ICONS class in my draftail_extensions.py file that define the SVG paths for the icons fa-solid fa-align-left fa-solid fa-align-center fa-solid fa-align-right

This class was defined in the first part of this series.

class DRAFTAIL_ICONS:
    ....
    left_align = [
        "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"
    ]
    centre_align = [
        "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"
    ]
    right_align = [
        "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"
    ]
The use of SVG paths 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 takes the d property of the SVG path.

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=DRAFTAIL_ICONS.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=DRAFTAIL_ICONS.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=DRAFTAIL_ICONS.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.

If you're finding the CSS file is not loading, check to see if your css directory is listed in your STATICFILES_DIRS:

STATICFILES_DIRS = [
    os.path.join(PROJECT_DIR, 'static'),
    os.path.join(PROJECT_DIR, 'static/css'),
    os.path.join(PROJECT_DIR, 'static/js'),
]

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: start;
}
.Draftail-block--CENTRE-ALIGN .public-DraftStyleDefault-ltr {
    text-align: center;
}
.Draftail-block--RIGHT-ALIGN .public-DraftStyleDefault-ltr {
    text-align: end;
}

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 disadvantage is that the selected alignment isn't reflected in the editor.

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 Warning
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.


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