Wagtail: Extending the Draftail Editor Part 3 - Dynamic Text
Introduction
Draftail has a category of features called 'entities' which allow you to add components that will (among other things) allow you to add dynamic text to your RichText fields and blocks.
They're quite complicated to write, needing you to dive into React components and draft.js, the latter you're required to have an in-depth understanding of before attempting this.
The requirement I had been working on at the time was to provide a mechanism for inserting dynamic inline text into reports, live results pages, dashboards etc.. A keyword, or set of keywords, could be highlighted with a style from a button which would get used as parameters to a JavaScript function and parsed during document loading. In the real world, this could be status updates, research results, economic/financial data, live resource availability etc..
One absolute showstopper not mentioned on any documentation at the time of writing is that entities cannot overlap, meaning a content editor couldn't add a hyperlink to an inline entity for example. In fact it will, somewhat ungraciously, crash the Draftail editor.
In the end, I abandoned the entity route and created a Draftail inline plugin with custom element tag that is then processed by the site JavaScript to render the required text.
In the example below, I go through adding a button to insert FontAwesome icons inline in the editor as a proof of concept for more useful applications.
This example uses the register_inline_styling()
function I defined in Part 1 of this series. If you haven't read that article yet, it may be a good place to start.
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_inline_styling(
features,
feature_name,
description,
type_,
tag='span',
format=None,
editor_style=None,
label=None,
icon=None
):
control = {"type": type_, "description": description}
if label:
control["label"] = label
elif icon:
control["icon"] = icon
else:
control["label"] = description
if editor_style:
control["style"] = editor_style
if not format:
style_map = {"element": tag}
markup_map = tag
else:
style_map = f'{tag} {format}'
markup_map = f'{tag}[{format}]'
features.register_editor_plugin(
"draftail", feature_name, InlineStyleFeature(control)
)
db_conversion = {
"from_database_format": {markup_map: InlineStyleElementHandler(type_)},
"to_database_format": {"style_map": {type_: style_map}},
}
features.register_converter_rule("contentstate", feature_name, db_conversion)
To use the example in this atrticle on your own site, you will need to load the FontAwesome JavaScript with your own kit ID.
If you don't already have FontAwesome login and kit set up, you can get a free kit from here.
Once you have your kit ID, make sure you have the following script loaded in any page using the plug-in:
<script src="https://kit.fontawesome.com/your-kit-id.js" crossorigin="anonymous"></script>
If you are using a free kit, then only free icons will be rendered with this plug-in.
Feature Requirements
- We need to create an inline style plugin for Draftail that is easily distinguishable in the editor as special format text.
- We need to easily identify the rendered feature elements in the DOM and loop through them in the document ready event.
- From each feature element, take the inner text and use it to render the appropriate dynamic text (in this case, format the element for the FontAwesome JavaScript to render).
This last step is where you would use your real-world code to generate the dynamic text. For example, a dummy stock element that returns ticker prices might be rendered initially by Draftail as <stock>GOOGL open</stock>
, the JS would then replace this element with a span tag with inner HTML displaying the last opening price for Google and perhaps an accompanying Sparkline.
In this example, I'll create <span>
tags with a custom css class fa-icon
.
Registering the Editor Style
As mentioned previously, in Part 1, I covered inline styles and a custom function to register those easily.
I've registered an icon for the FontAwesome logo . The code for the icon is:
<svg
id="icon-font-awesome"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
d='M 1011.0111,38.377438 V 802.30364 c -142.37657,51.24671 -185.81845,72.75487 -269.67821, 72.75487 -141.76722,0 -195.43205,-72.75487 -336.92846,-72.75487 -48.94828,0 -86.83863, 8.61918 -121.2762,19.93029 -34.66323,10.76317 -66.483,-15.15575 -66.483,-47.89545 v -1.69632 c 0, -21.08755 12.94004,-40.01519 32.54192,-47.42709 42.15555,-16.58334 89.77237,-32.04375 155.21728, -32.04375 141.56412,0 195.22894,72.75488 336.92846,72.75488 57.54639,0 96.7005,-10.46761 161.28786, -33.42177 V 186.84286 c -46.87209,16.5972 -95.3916,33.42177 -161.28786,33.42177 -0.009,0 0.009,0 0, 0 -141.74465,0 -195.45461,-72.75488 -336.92846,-72.75488 -128.85878,0 -181.44038,59.3407 -296.08182, 68.43506 v 750.0573 c 0,30.23874 -24.259748,54.56619 -54.161303,54.56619 C 24.259752,1020.5683 0, 996.24085 0,966.00211 V 56.566157 C 0,26.418355 24.259752,2 54.161307,2 84.062862,2 108.32261, 26.418355 108.32261,56.566157 V 108.83599 C 222.96405,97.809077 275.54565,38.377438 404.40443, 38.377438 c 141.65438,0 195.09354,72.754872 336.92846,72.754872 84.85272,0 131.56685, -23.076933 269.67821,-72.754872 z'
/>
</svg>
For best results, you should use an SVG formatted for a 1024x1024px viewbox to render correctly.
Here, I'll just register the style in wagtail_hooks.py
using the previously defined register_inline_styling
function with the following notes:
feature_name='fa'
- this is the value you'll need to add to your editor feature listformat='class="fa-icon"'
- this is our identifier class.editor_style
- this defines how the style will be rendered in the Draftail editor only and has no effect on the front-end styling. Here, I'm using a monospace font wrapped in an orange pill-box to make it easy for the content editor to recognise that this is the dynamic text keyword definition and not something that will appear on the front-end.icon='font-awesome'
- the SVG icon defined above, remember to drop the 'icon-
' prefix from the svg id attribute here. If you prefer to just use a simple label, you can change this line tolabel = '⚐'
.
@hooks.register("register_rich_text_features")
def register_fa_styling(features):
"""Add font-awesome icons to the richtext editor and page."""
register_inline_styling(
features=features,
feature_name='fa',
description="Font Awesome Icon",
type_="FA",
format='class="fa-icon"',
editor_style={
'background-color': 'orange',
'color': '#666',
'font-family': 'monospace',
'font-size': '0.9rem',
'font-weight': 'bolder',
'padding': '0 0.4rem',
'border-radius': '0.6rem'
},
icon='font-awesome'
)
'fa'
to your editor's feature list before it will appear on your toolbar. See here for more details.
You must add You should now see the FontAwesome
symbol on your toolbar.To try it out, enter "This is the fa-solid fa-house home icon" in your rich text editor with some other text, select the text "fa-solid fa-house" and click the FontAwesome icon.
Before styling:
This is the fa-solid fa-house home icon
With the styling applied:
This is the fa-solid fa-house home icon
In the editor, the style is formatted according to the style we specified in the editor_style
parameter when registering the feature above.
Now, preview the page, you will see the phrase "This is the fa-solid fa-house home icon". If you inspect the line, you should see that is has been rendered as:
<p>This is the <span class="fa-icon">fa-solid fa-house<span> home icon<p>
We want to hide the element rendered by Draftail as this contains the raw data that we don't want to display. The JavaScript we add later will render a visible element with the dynamic text.
In your site CSS, add the following definition for fa-icon
:
.fa-icon{
display: none;
}
Rendering the Dynamic Text with JavaScript
What's left to do is to loop through the <span class="fa-icon">
elements and render the dynamic text.
In this simple example, all we need to do is transform the element into a format that the FontAwesome JavaScript will recognise and render to the associated icon.
In a real world case, this is where you might make a call out to a remote data source and/or do some additional processing and formatting.
The FontAwesome documentation states that the icon should be defined in the class properties of an <i>
element, for example
<i class="fa-solid fa-house"></i>
Digging a bit further, it states that a <span>
element can be used instead. A little experimenting revealed that any element I tried could be used. Whatever element is used is replaced with an <svg>
by the FontAwesome code. In this case, we can keep the <span>
element for this purpose.
Our steps for this case are:
- Identify and loop through all the
<span class="fa-icon">
elements on the page once the page is ready. - For each one, use the inner text to set the class name attribute.
- Replace the displayed text with a placeholder (to display until the dynamic text is rendered). I found the icons to be approximately four spaces wide. Doing this prevents the original text from being displayed and stops "jitter" on the page while the dynamic text loads. Additionally, any invalid icon classes will remain as blank spaces on the page.
- By replacing the class list on the
<span>
element, we remove thedisplay:none;
style applied by thefa-icon
class (the FontAwesome script will ignore any hidden or non-displayed elements).
In short, we'll be turning
<span class="fa-icon">fa-solid fa-house</span>
into
<span class="fa-solid fa-house"> </span>
The JavaScript for this is:
document.addEventListener('DOMContentLoaded', () => {
faIcons = [...document.getElementsByClassName('fa-icon')];
faIcons.forEach(faIcon => {
const faClass = faIcon.innerText;
if (faClass) {
faIcon.innerHTML = " ".repeat(4);
faIcon.className = faClass;
}
});
});
Note I'm converting the HTMLCollection
returned by Document.getElementsByClassName()
into an array:
faIcons = [...document.getElementsByClassName('fa-icon')];
The reason for this is that an HTMLCollection
is not a static array. If I remove the fa-icon
from an element, the collection is immediately updated and that element is removed. It also makes for much easier coding to use the intrinsic .foreach()
function available to arrays.
From Mozilla's documentation:
HTMLCollection.
Warning: This is a liveChanges in the DOM will reflect in the array as the changes occur. If an element selected by this array no longer qualifies for the selector, it will automatically be removed. Be aware of this for iteration purposes.
As previously mentioned, in a real world case, this is where you would put the meat of your code to pull in external data, perform additional calculations, render graph/image data etc..
With the Font Awesome script commented out in your <head>
, you would now see our example above rendered as
This is the home icon
from html:
This is the <span class="fa-solid fa-house"> </span> home icon
With the FontAwesome script loaded on the page, you will see this gets replaced by the following:
This is the
home iconwith html
<p>
This is the
<svg class="svg-inline--fa fa-house" data-prefix="fas" data-icon="house"
role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" data-fa-i2svg="">
<path fill="currentColor"
d="M575.8 255.5c0 18-15 32.1-32 32.1h-32l.7 160.2c0 2.7-.2 5.4-.5 8.1V472c0 22.1-17.9 40-40 40H456c-1.1 0-2.2 0-3.3-.1c-1.4 .1-2.8 .1-4.2 .1H416 392c-22.1 0-40-17.9-40-40V448 384c0-17.7-14.3-32-32-32H256c-17.7 0-32 14.3-32 32v64 24c0 22.1-17.9 40-40 40H160 128.1c-1.5 0-3-.1-4.5-.2c-1.2 .1-2.4 .2-3.6 .2H104c-22.1 0-40-17.9-40-40V360c0-.9 0-1.9 .1-2.8V287.6H32c-18 0-32-14-32-32.1c0-9 3-17 10-24L266.4 8c7-7 15-8 22-8s15 2 21 7L564.8 231.5c8 7 12 15 11 24z">
</path>
</svg>
<!-- <span class="fa-solid fa-house"> </span> Font Awesome fontawesome.com -->
home icon
</p>
Note that our original <span>
element that has been commented out by the FontAwesome script.
Inline Styles vs. Draftail Entities
As stated in the introduction, this example is by no means the definitive way to serve dynamic text in your rich text blocks, but it is perhaps the simpler method.
The big advantage of this method is that you can include the dynamic text in a hyperlink or any other inline or block style as you would a normal piece of text.
A link spanning some dynamic text with overlapping highlight inline style and block style.
Using an entity would crash the Draftail editor of you tried to add a link that spanned all or part of that entity. You would need to define a link as a property of the entity, even if the surrounding text was part of that link. The text on either side would have to be defined as separate links etc.. It gets messy and complicated for the content editor who is unlikely to be making design calls on whether something is an entity, block or inline style.
The disadvantage of course is that the keywords being supplied to the JavaScript are entered freehand and not selected from a list of options. Your content editors would need to know the valid keywords to enter. In my case, the content editors were expert enough in their fields to already know what keywords they wanted to use once the format was understood, and because the possible combinations of keywords were both dynamic and vast, pre-made selectors weren't really viable in any case.
To build forms in a Draftail entity requires good React knowledge and also an intimate understanding of how entities work in Draftail and Draft.js. The process to build a simple entity add-in is surprisingly complicated. The documentation is pretty awful in my experience. Key information is missing from the start with no top-level introduction. There are no plain English explanations or methodical case studies to progress through, there is a lot of trial-and-error to see what works and what doesn't.
In the end, which route you choose would depend on the use case, your content editor's preference and capabilities, and whether you have the time/patience to sift through Draftail/Draft.js to work out what is needed to build your component. Sometimes, like Occam's Razor, the simple route is the best.
Conclusion
In this article, I covered one method of rendering inline dynamic text by creating identifiable elements that could be manipulated to produce a result at load time.
- We created a
<span>
with specific css class to identify our dynamic text keywords at load time. - A simple JavaScript example was given to show where you might inject your dynamic text.
- Finally, we took a quick look at the merits of using this method over creating an entity for this task and when one method or the other might be better suited.