Creating an addon for the Bard editor in Statamic 3

Creating a Bard addon for adding a class to inline content for Statamic 3

Published June 5th, 2021

Bard offers incredible flexibility – especially with Sets – when it comes to authoring content. And not just content, but varied layouts and structures while maintaining a code-free authoring experience.

But being able to add a class to selected content – inline or block - is just not possible.

And I get why it’s not a standard feature: everyone’s use case is different, and engineering something that adds classes to content while providing a perfect user experience in every use case is not really a possibility.

The good news: it’s not that hard to create an addon yourself that does exactly what you need it to do for your content authors.

This requires knowledge of Statamic, addons, composer, and willingness to explore a smidge of how ProseMirror works.

So… let’s get started!

What is Les Mills?

This is the first big question people want answered: what the heck is Les Mills?

In the context here, I’m referring to Les Mills group fitness classes, delivered in gyms, virtually and on-demand in thousands of clubs in hundreds of countries. You may have even heard of BODYPUMP, the original barbell group fitness class, with up to 1,000 reps per class. Yep, a 1 with 3 zeros after it.

Why does it matter? Well I teach Les Mills group fitness classes – and have since 2007 – and am trained in 5 programs – BodyAttack, BodyBalance, BodyPump, Les Mills Core (formerly CXWORX) and Les Mills Tone. And for my site here, I needed to be able to highlight class names on the Les Mills page so I could colour them with their program brand colours. And this is how it all came about: I needed to write a Bard addon that would help me achieve this.

Fun fact too: Les Mills International, the company who produce the programs, is actually named after a real person too: gold medal-winning athlete Les Mills from New Zealand. LMI is run by Les Mills’ son, Phillip Mills.

Create your addon

Where you want to install your addon is up to you.

If you are writing an addon that is for a single project, then you could just store it with the project. If you’re wanting to reuse or distribute it, then you may want to store it outside of your project. That decision is up to you.

Simply run the please command:

php please make:addon martyfriedel/les-mills-class-types

If you’re keeping it in your project, that’s it. If you want to move it outside, I found the easiest thing to do is:

Firstly, remove it from your project’s composer:

composer remove martyfriedel/les-mills-class-types

Secondly, move your code from the addons folder to wherever you want to store it.

And finally, update your composer.json file manually:

{
    "require": {
        ...
        "martyfriedel/les-mills-class-types": "*",
    },
    ...
    "repositories": [
        {
            "type": "path",
            "url": "path/to/addon"
        }
    ]
}

And then run:

composer update

The most useful thing you can do though is set up a symlink to your build folder so you don’t need to keep publishing after every change:

ln -s /path/to/addon/martyfriedel/les-mills-class-types/dist public/vendor/les-mills-class-types

With this in place, every change you make to your build will be picked up by Statamic.

Refer to Statamic’s as-always-excellent documentation for full addon details.

The parts of your addon

Your Bard addon for Statamic includes:

  • dist
    Contains your production-ready built code

  • resources
    Your frontend source – including JavaScript and CSS

  • src
    Your PHP source – for getting your addon in to Statamic

Plus of course you’ll need your build files too – I’ll assume that you’re all set to manage this yourself, and know how your build process works.

Remember, if you need, you can refer to the source code for this addon for a full working version if you want to take a peek.

Setting up for Statamic

Inside your src folder, we need to do two things:

  1. update the ServiceProvider to tell Statamic what our addon is made up of, and

  2. add a new class that tells PHP how to handle and process our new Mark type

Inside the ServiceProvider, we need to tell Statamic about our JavaScript, CSS and additional fonts (for the icon for the toolbar), plus add our new Mark to the Augmentor so that it can be rendered.

Adding the assets is as easy as setting some paths:

protected $scripts = [
    __DIR__.'/../dist/js/les-mills-class-types.js',
];

protected $stylesheets = [
    __DIR__.'/../dist/css/les-mills-class-types.css'
];

protected $publishables = [
    __DIR__.'/../dist/css/fonts' => 'css/fonts'
];

And adding our Mark to the Augmentor is as simple as doing just that in the boot function:

public function boot()
{
    parent::boot();
    Augmentor::addMark(LesMillsClassType::class);
}

Obviously, we also need to create a new Mark. This is what tells the Statamic Augmentor how to process our new Mark type when it finds it in any of our Bard content blocks.

Let’s start simply by creating our Mark, and defining the type and tag:

namespace MartyFriedel\LesMillsClassTypes;

use ProseMirrorToHtml\Marks\Mark;

class LesMillsClassType extends Mark
{
    protected $markType = 'lesMillsClassType';
    protected $tagName = 'span';
}

The $markType is what we will use in our JavaScript files, and is what the mark appears as in your .md content files.

If you’ve looked at your content files before, you’ll notice things like “type: paragraph” – pretty obvious that this is a paragraph.

This means our addon will now make “type: lesMillsClassType” appear in our content files. When Statamic is processing our content, it will now know to look at our LesMillsClassType Mark type to be told how to render it.

We need to add two functions to our Mark – one that looks for matching the type, and the other for rendering it.

namespace MartyFriedel\LesMillsClassTypes;

use ProseMirrorToHtml\Marks\Mark;

class LesMillsClassType extends Mark
{
    protected $markType = 'lesMillsClassType';
    protected $tagName = 'span';

    public function matching(): bool
    {
        return $this->mark->type === $this->markType;
    }

    public function tag(): ?array
    {
        return [
            [
                'tag'   => 'span',
                'attrs' => [
                    'class' => 'les-mills-class '.$this->mark->attrs->key
                ],
            ],
        ];
    }
}

Reading through these two examples should hopefully make sense.

For matching, we have a match when the mark’s type is equal to our $markType. Makes sense, right?

For tag, we want to return a tag – and all we want to do here is create a span, and set a class. For our needs, tag is returning an array that includes our definition.

The tag is a span, and we’re adding the “class” attribute of “les-mills-class [whatever_our_key_attribute_is]”.

And that’s it – the PHP and Statamic side of things is done. Hopefully nothing is here that is too complicated or confusing.

Setting up for Bard 

Our frontend work is a little more detailed than the backend, and has a few more pieces.

We have a CSS file that is used to define our toolbar icon, plus the appearance of our <span> in the editor.

We have a fonts folder that uses includes the icon for the toolbar as a webfont.

And we have all the JavaScript.

The CSS

The easy stuff is the CSS and fonts. Refer to the source of the complete CSS file, and the webfont is just a font. The purpose of these files is to add the webfont for the toolbar button, and add styles that will style the content within the editor.

The only kooky part here is that your icon definition needs to follow the same naming convention that Statamic is expecting – and that is pre-fixed with an “fa-“. Given I wanted to use a custom icon, I made my own webfont for this purpose. So in the CSS you’ll see:

.fa-les-mills {
    font-family: 'icomoon' !important;
    /* ... */
}

.fa-les-mills:before {
    content: "\e900";
}

This is simply pointing to my font family, and defining the icon content. As a web developer, nothing too taxing, but just worth pointing out. I made my font in icomoon – but you can use whatever family you want.

The JavaScript

There are three main pieces for the JavasScript:

  1. bard.js, the entry point to the JavaScript and defining the button,

  2. LesMillsClassTypes, defining the behaviour of the Mark itself

  3. LesMillsClassTypesMenu, a Vue.js Single File Component that will appear when you click the button

Our bard.js is straightforward – there are two key actions taking place here. Firstly, extending Bard with our new Mark, and secondly, adding our Button.

Extending Bard is as simple as passing our new Mark (don’t worry, we’ll look at the Mark next):

Statamic.$bard.extend(({mark}) => mark(new LesMillsClassTypes()));

Adding the button has some more lines, but hopefully all is pretty self explanatory. We’re adding a new button, with a given Text (label), command, icon and component (our LesMillsClassTypesMenu)

Statamic.$bard.buttons(() => {
    return {
        name: 'lesmillsclass',
        text: 'Les Mills Class Type',
        command: 'classType',
        args: {
            key: ""
        },
        icon: 'les-mills',
        component: LesMillsClassTypesMenu
    };
});

Putting it altogether, it’s a short and simple file:

import LesMillsClassTypes from "./LesMillsClassTypes";
import LesMillsClassTypesMenu from "./LesMillsClassTypesMenu.vue";

Statamic.$bard.extend(({mark}) => mark(new LesMillsClassTypes()));
Statamic.$bard.buttons(() => {
    return {
        name: 'lesmillsclass',
        text: 'Les Mills Class Type',
        command: 'classType',
        args: {
            key: ""
        },
        icon: 'les-mills',
        component: LesMillsClassTypesMenu
    };
});

Let’s look at the Mark type first – our LesMillsClassTypes.js file.

This is defining how the Bard editor should handle our Mark. As a skeleton, we need a little bit of structure – and Statamic’s source tells us just that.

return new class extends Mark {
    get name() {
        return extension.name();
    }

    get schema() {
        return extension.schema();
    }

    commands(args) {
        return extension.commands({...args, updateMark, removeMark, toggleMark });
    }

    pasteRules(args) {
        return extension.pasteRules({...args, pasteRule});
    }

    get plugins() {
        return extension.plugins().map(plugin => new Plugin(plugin));
    }
}

This becomes the skeleton structure for our Mark class:

export default class LesMillsClassTypes {
    name() {
        return "lesMillsClassType";
    }

    schema() {
        // ...
    }

    commands({type, updateMark}) {
        // ...
    }

    pasteRules({type}) {
        return [];
    }

    plugins() {
        return [];
    }
}

You can see here that we have defined our name – lesMillsClassType – which matches the $markType in our LesMillsClassType.php file.

Given this is about simply wrapping selected content with a span and a class, we won’t be using pasteRules or plugins.

Firstly, let’s build the schema. This describes:

  • the attributes we want to use

  • how to parse the DOM (and what to do with it)

  • how to output the Mark to the DOM

This is simply a standard object structure with attrs, parseDOM and toDOM:

schema() {
    return {
        attrs: {
            key: '',
        },
        parseDOM: [
            {
                tag: "span.les-mills-class",
                getAttrs: (dom) => ({
                    key: dom.getAttribute('data-class')
                })
            }
        ],
        toDOM: (mark) => [
            "span",
            {
                'class': 'les-mills-class ' + mark.attrs.key,
                'data-class': mark.attrs.key
            },
            0,
        ],
    };
}

When parsing from the DOM, we are expecting a span tag with the class “les-mills-class”, and we want to get the internal “key” attribute from the data-class attribute of the element.

And when we are sending the mark to the DOM, we will send a span, with a class of “les-mills-class” plus the key, and add the data-class attribute. I’ve just done this to ensure attributes are clean (given we are adding a second class otherwise).

We also to set our command too – for this use-case, I want to allow the Mark to be toggled – so if the highlighted text is just text, make it a “Les Mills Class Type”, and if it already is, remove it. To do this, we can use the updateMark command.

commands({type, updateMark}) {
    return (attrs) => updateMark(type, attrs);
}

So that’s a good start – you highlight text, and then when you pick a class type, it updates the selection with that class. But how do you remove it?

You could use the “Remove Formatting” button – but that’s also not a user-friendly instruction for a non-technical author.

To overcome this, we can update the command to look at the key attribute – if it is false, then remove the mark – otherwise, update it.

commands({type, updateMark, removeMark}) {
    return attrs => {
        if (attrs.key) {
            return updateMark(type, attrs)
        }

        return removeMark(type)
    }
}

The Button for Bard

We’ve created our Mark type in both PHP and JavaScript – but we also need a drop down menu so we can select our classes. Sure, you could have an input and allow the user to put a class name in themselves – but I want a great authoring experience, and selecting from a list is a great way to do this (and control what the user can and cannot do).

We achieve this by creating a Vue.js Single File Component – if you look back at our bard.js file, remember we referenced the Component – well, we’re going to build it now.

At its simplest level, our component needs a button that can be clicked to open the menu, and the items in the menu need to do something to the editor.

<template>
    <div>
        <button
            class="bard-toolbar-button"
            :class="{ 'active': showOptions }"
            v-html="button.html"
            v-tooltip="button.text"
            @click="showOptions = !showOptions"
        ></button>
        <div v-if="showOptions">
            Menu
        </div>
    </div>
</template>
<script>
export default {
    mixins: [BardToolbarButton],
    data() {
        return {
            showOptions: false
        };
    }
};
</script>

You can see here that we’ve created a button, including the BardToolbarButton mixin (from the core Statamic CP) which brings in 5 props for us to use – check out the source for more detailsbutton, active, config, bard and editor.

If you were to run with just this, you’ll have a button appear that will show hide a div that pops up and says “menu”. This is the essential foundation for what we need. But a really solid start.

Now we need our menu to include something – in this case, a list of colour-coded Les Mills class names.

<template>
    <div>
        <button
            class="bard-toolbar-button"
            :class="{ 'active': showOptions }"
            v-html="button.html"
            v-tooltip="button.text"
            @click="showOptions = !showOptions"
        ></button>
        <div v-if="showOptions">
            <button v-for="(type, key) in classTypes" @click="setClassType(key)">
                <span class="class-type-mark" :style="'background-color: ' + type.colour"></span>
                <span class="class-type-label">{{ type.name }}</span>
            </button>
        </div>
    </div>
</template>
<script>
export default {
    mixins: [BardToolbarButton],
    computed: {
        classTypes() {
            return {
                'bodyattack': {
                    name: 'BodyAttack',
                    colour: '#FCC500'
                },
                'bodybalance': {
                    name: 'BodyBalance',
                    colour: '#b9d47d'
                },
                'bodypump': {
                    name: 'BodyPump',
                    colour: '#ea4851'
                },
                'cxworx': {
                    name: 'CXWORX',
                    colour: '#e75204'
                },
                'les-mills-core': {
                    name: 'Les Mills Core',
                    colour: '#444444'
                },
                'les-mills-tone': {
                    name: 'Les Mills Tone',
                    colour: '#777777'
                }
            };
        },
    },
    data() {
        return {
            showOptions: false
        };
    },
    methods: {
        setClassType(classTypeKey) {
            // update the editor
            this.editor.commands.lesMillsClassType({
                key: classTypeKey
            })

            // hide the menu
            this.showOptions = false;
        }
    }
};
</script>

We’ve got a computed list of Class Types, and we’re outputting the each in to a button with the colour.

There’s also a method called setClassType which is calling our lesMillsClassType editor command, and passing the selected key as a parameter. This is what is being passed to our LesMillsClassTypes.js file.

Some styles are also needed here too:

<style lang="postcss">
.class-type-label {
    @apply block text-left whitespace-nowrap;
}

.class-type-mark {
    @apply block w-4 h-4 mr-3 flex-none;
}
</style>

You’ll notice I have used Tailwind’s @apply to create actual classes for my addon styles.

The reason here is that if I used inline classes, like in a normal Tailwind project, the CSS of my addon actually overrides (and breaks) Statamic’s layouts.

So by using @apply, I can still keep my Tailwind terminology that I know and love, but also isolate it for use within my Vue component styling.

To help polish the look, I’ve gone through and created a few other classes to style the wrapper, menu and buttons – check out the full source for the completed code – nothing really taxing, but just styles.

And while this is usable… I feel it is missing some decent quality-of-life improvements.

So I’ve also gone ahead and:

  • added v-click-outside if the menu is open, but clicked outside

  • added a state to show the currently selected Class Type in the drop down

  • revised setClassType logic to clear the formatting if text is selected (that is already using a Les Mills Class Type) and the same type is selected

  • added the “active” state to the button if a Class Type is selected within the Bard editor (like how the “B” is shown as active when you select bolded text)

There’s two key points here.

Firstly, figuring out what the currently selected key is. Remember how we used the BardToolbarButton mixin? Well that gives us access to the editor, which means we can get the Mark attributes and in turn, get the key. So I created this as another computed variable.

currentKey() {
    return this.editor.getMarkAttrs('lesMillsClassType').key;
}

This can be used to update the button’s active state and highlight the active class in the menu.

<template>
    <div class="class-type-wrapper">
        <button
            class="bard-toolbar-button"
            :class="{ 'active': currentKey || showOptions }"
            v-html="button.html"
            v-tooltip="button.text"
            @click="showOptions = !showOptions"
        ></button>
        <div class="class-type-container" v-if="showOptions" v-click-outside="closeClassTypeMenu">
            <button v-for="(type, key) in classTypes" class="class-type-button" @click="setClassType(key)"
                    :class="{ 'active' : key == currentKey }">
                <span class="class-type-mark" :style="'background-color: ' + type.colour"></span>
                <span class="class-type-label">{{ type.name }}</span>
            </button>
        </div>
    </div>
</template>

So now the button will be “active” if there is a currentKey, or the menu is open. And in the menu, the class “active” will be added to the current Class Type.

The other big point here is the quality-of-life improvements regarding selection behaviour. I wanted the button to behave in an expected way:

  • if there is no Class Type, wrap in a span and set the correct Class Type

  • if there is a Class Type already, and the new selection is different, change the Class Type

  • if there is a Class Type already selected, and you select the same one again, it removes the styling (i.e. like a toggle)

To achieve this, I’ve updated the command in the LesMillsClassTypes.js file to look at the currently passed attribute:

commands({type, updateMark, removeMark}) {
    return attrs => {
        if (attrs.key) {
            return updateMark(type, attrs)
        }

        return removeMark(type)
    }
}

If there is a key attribute, I want to update the mark – otherwise, remove the mark. This means the author doesn’t need to “clear formatting” to remove it. So that’s really neat.

The other change is in the LesMillsClassTypesMenu.vue where I’ve added the logic about de-selecting if the same Class Type is selected:

setClassType(classTypeKey) {
    // update the editor
    this.editor.commands.lesMillsClassType({
        key: classTypeKey == this.currentKey ? false : classTypeKey
    })

    // hide the menu
    this.showOptions = false;
},

So if the new classTypeKey is equal to the currentKey, then pass false (which LesMillsClassTypes.js will know to run the removeMark instead of updateMark).

While these may be small in the scheme of things, I feel the quality-of-life additions are what make a good addon. Not just about getting the job done, but presenting expected behaviour for the user.

So what does our final LesMillsClassTypesMenu.vue file look like?

<template>
    <div class="class-type-wrapper">
        <button
            class="bard-toolbar-button"
            :class="{ 'active': currentKey || showOptions }"
            v-html="button.html"
            v-tooltip="button.text"
            @click="showOptions = !showOptions"
        ></button>
        <div class="class-type-container" v-if="showOptions" v-click-outside="closeClassTypeMenu">
            <button v-for="(type, key) in classTypes" class="class-type-button" @click="setClassType(key)"
                    :class="{ 'active' : key == currentKey }">
                <span class="class-type-mark" :style="'background-color: ' + type.colour"></span>
                <span class="class-type-label">{{ type.name }}</span>
            </button>
        </div>
    </div>
</template>
<script>
import vClickOutside from 'v-click-outside'

export default {
    directives: {
        clickOutside: vClickOutside.directive
    },
    mixins: [BardToolbarButton],
    computed: {
        classTypes() {
            return {
                'bodyattack': {
                    name: 'BodyAttack',
                    colour: '#FCC500'
                },
                'bodybalance': {
                    name: 'BodyBalance',
                    colour: '#b9d47d'
                },
                'bodypump': {
                    name: 'BodyPump',
                    colour: '#ea4851'
                },
                'cxworx': {
                    name: 'CXWORX',
                    colour: '#e75204'
                },
                'les-mills-core': {
                    name: 'Les Mills Core',
                    colour: '#444444'
                },
                'les-mills-tone': {
                    name: 'Les Mills Tone',
                    colour: '#777777'
                }
            };
        },
        currentKey() {
            return this.editor.getMarkAttrs('lesMillsClassType').key;
        }
    },
    data() {
        return {
            showOptions: false
        };
    },
    methods: {
        closeClassTypeMenu() {
            // close the menu
            this.showOptions = false;
        },
        setClassType(classTypeKey) {
            // update the editor
            this.editor.commands.lesMillsClassType({
                key: classTypeKey == this.currentKey ? false : classTypeKey
            })

            // hide the menu
            this.showOptions = false;
        },
    }
};
</script>
<style lang="postcss">
.class-type-wrapper {
    @apply inline-block relative;
}

.class-type-container {
    @apply absolute bg-white border border-gray-300 rounded-sm z-10 divide-y divide-gray-100 shadow-lg;
}

.class-type-button {
    @apply text-left px-3 py-2 w-full hover:bg-gray-100 flex items-center;
}

.class-type-button.active {
    @apply bg-gray-200;
}

.class-type-label {
    @apply block text-left whitespace-nowrap;
}

.class-type-mark {
    @apply block w-4 h-4 mr-3 flex-none;
}
</style>

Finishing up

Firstly, remove your link you created for your build directory.

And it’s up to you to finish up your project config based on where your addon will live – either installable via composer, or local to your project.

If you’re wanting to install via composer, remove your addon from your project, publish your addon, and then install via composer.

If you’re keeping the addon local to your project, then publish using:

php please vendor:publish --tag="les-mills-class-types"

And you now have a working addon for Bard.

If you are looking for the full source code, check out the martyfriedel/les-mills-class-types repo on Github.

This was a real deep-dive for me in to the workings of Bard buttons and commands – and the best spot to look is the Statamic source.

You may be interested in...