Updating a Bard addon for Tiptap 3 for Statamic 6
With Statamic 6 around the corner, it is time to re-visit the Les Mills Class Types Bard extension for Statamic. If you remember, this was created for Statamic 3, then upgraded for Statamic 4. And with Statamic 6’s upgrade to Vue 3, a new guide to get you up and running.
This change, while dramatic at the code level, is exceptional for the dev experience in the long run - but there are some little things that you need to be aware of to get up and running.
This article expects you know a little bit about how Tiptap extensions work, Vue components and slots.
If you’re new, welcome! Looking through the source code of this addon can be a helpful learning resource for you.
The key points are:
Vite is required
only for Statamic 6
Vue 3 only
we get access to Statamic 6’s gorgeous new UI components
now for Tiptap 3
Yikes, sounds full on: but it’s not too rough… one step at a time, let’s jump to it!
Upgrade from Laravel Mix to Vite
If you haven’t done so already, upgrade from Laravel Mix (Webpack) to Vite.
You’ll probably want to refer to Laravel’s migration notes, as well as Statamic’s Vite in Addons guide.
Don’t get too focused on the Statamic notes for your package.json
file: we’ll be changing that next with Statamic 6 specific updates.
With Laravel Mix no longer maintained and no longer supported for Statamic, just do it already.
The summary of major changes are:
updating your
package.json
removing
webpack.mix.js
andwebpack.config.js
removing the
mix-manifest.json
updating your addon’s
ServiceProvider
with the Vite approach (and removing the old$scripts
approach)
But wait, we can’t compile anything yet: there’s more to do.
Upgrade your addon to require Statamic 6
You’ll also need to update your composer.json
file to be for Statamic 6 only. Update your statamic/cms
dependency to be ^6.0
.
Your addon cannot (easily) work for both Statamic 5 and 6 at the same time: one uses Vue 2 and one uses Vue 3. If you need to maintain for Statamic 5, manage a separate branch and version program.
You can now run a composer update
to get started.
Now that you’re on Statamic 6, there are some additional steps to do to get started - and Statamic have written a guide to help us out.
Take special note of the first section: Vite
In your package.json
file, you’ll be changing your scripts
, and also including a new dev dependency for Statamic 6’s awesomesauce.
The dev
script is particularly important.
While my dev script is usually just vite
, you must make your dev
script be vite build --watch
.
This also means you must symlink from your dev directory to your site install directory for dev purposes. This may change in the future, but is a required step for now: otherwise Vue reactivity will not take place.
There are also updates to your vite.config.js
file - of course make appropriate path references for your Bard addon source input files too.
For consistency with my other addons, I’ve also renamed the resources/js/bard.js
file to resources/js/cp.js
. You can have this file called whatever you want - this is just a consistency thing for me.
You’ll notice a few other files are renamed given the approach is a little simpler now too.
Set up bard when Statamic boots
In the previous version, we just added the extension to Statamic’s $bard
: but we can’t do that in Statamic 6.
We need to ensure that the addExtension
and buttons
calls are wrapped within the booting callback.
1Statamic.booting(() => {2 // our addExtension and buttons calls live in here3});
The Tiptap API has changed slightly between 2 and 3 and within Statamic.
Our extension now must be included as part of a callback:
1import LesMillsClassTypes from "./Bard/LesMillsClassTypes";2 3Statamic.booting(() => {4 // Statamic 5 version5 Statamic.$bard.addExtension(() => LesMillsClassTypes);6 7 // Statamic 6 version8 Statamic.$bard.addExtension(({tiptap}) => LesMillsClassTypes(tiptap));9});
Our button now is configured in a slightly different way, with args
removed, and a new active
prop added too: this is what will be passed to our button to determine if the button is active or not.
1import LesMillsClassTypes from "./Bard/LesMillsClassTypes"; 2import LesMillsClassTypesButton from "./Bard/LesMillsClassTypes.vue"; 3 4Statamic.booting(() => { 5 Statamic.$bard.addExtension(({tiptap}) => LesMillsClassTypes(tiptap)); 6 Statamic.$bard.buttons((buttons, button) => { 7 return button({ 8 name: 'lesmillsclass', 9 text: 'Les Mills Class Type',10 html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24" fill="currentColor"><path d="M7.6 17.295c-.03.32.22.76.65.76H22.5l-.76 4.82H3.87c-1.76 0-2.47-1.6-2.36-2.58l2.93-19.17h5.58l-2.41 16.17"/></svg>',11 component: LesMillsClassTypesButton,12 active: editor => editor.isActive('lesMillsClassType')13 });14 });15});
This active
callback is simply looking at our editor
instance, and checking if the current mark is a lesMillsClassType - and returns a boolean value. That will get passed to our Button component.
At this stage we have our addon:
set up for Statamic 6
set up for Tiptap 3 (partially… still more to do)
set up for Vue 3 (partially… still more to do)
Let’s keep going!
The architecture of a Bard extension
There are two key parts to our Bard extension that we need to understand:
A Mark which tells Bard (and underneath it, Tiptap) how to render our new extension within the editor itself, and
A Vue component which is the button (and any interactive components) that appears in Bard’s toolbar.
What we’ve done so far is configured just that: we’re telling Bard to use our Mark in its configuration potential, and we’re saying to use our Vue component as the UI button.
Make sense on what part does what? Yes (hopefully), so let’s update the Mark next.
Updating the Tiptap Mark
A Mark is like an element in Tiptap - and for this addon, we want to wrap the highlighted text with a span (eventually), so a Mark is the perfect fit here.
We need to change how we write our Mark though.
Check out Statamic’s guide for this.
Essentially, instead of returning a default Mark, we need to export a callback which we can pass tiptap to (we already did that change in our cp.js
file before).
1// for Statamic 6 2export default function (tiptap) { 3 4 const { Mark, mergeAttributes } = tiptap.core; 5 6 return Mark.create({ 7 name: 'lesMillsClassType', 8 9 // the rest of our Mark configuration10 11 });12}
At this point, I’ve also done two things:
remove
pasteRules
remove
plugins
Neither of these were doing anything, so let’s clean up our code.
This next part is important for Marks that have an attribute that could be false
- like ours.
The other interesting thing is changing how we approach the button. To be honest, I like the idea of telling Bard (and therefore Tiptap) to simply toggle the mark… however given we want to either have a Mark with a class name attached, or nothing at all, we need to rely on explicit “setting” and “unsetting” of the Mark, rather than the toggle.
Previously, when trying to unset the Mark, we passed the class type as false
- but the toggle approach simply sets the attribute as false, but keeps the Mark.
Using a “set” and “unset” approach makes it really explicit - and with the “toggle” approach, false
or null
was not behaving as expected.
If you want your Mark to have all three - a set, unset and toggle - then we can do something like:
1addCommands() {2 return {3 addLesMillsClassType: attributes => ({chain}) => chain().setMark(this.name, attributes).run(),4 removeLesMillsClassType: () => ({chain}) => chain().unsetMark(this.name).run(),5 toggleLesMillsClassType: attributes => ({chain}) => chain().toggleMark(this.name, attributes).run(),6 }7},
This is now exposing three new functions to our Mark we can call - addLesMillsClassType
, removeLesMillsClassType
and toggleLesMillsClassType
.
I’ve ensured each of these method names is explicit and unique within the Tiptap universe.
Our existing approach to addAttributes
, parseHTML
and renderHTML
can stay the same.
Updating the Bard Button
This is the big one. But also a good one.
Given the Composition API is so much better than Options API, I’ve rewritten the Bard Button using the Composition API.
OK, OK, I said it: I prefer the Composition API. And I get it, you might not.
If you really love the Options API and refuse to step forward to Composition API - it’ll still work, but your code will be different to what I include here.
I was personally hesitant to start using the Composition API, but after learning more about it, the power and flexibility (and cleaner code) of this approach makes me a happier dev.
If you’re unsure, spend some time with it, learn, build, and get your head around it: as you build bigger apps, you’ll start to see the real benefits.
So the purpose of this Vue 3 component is to:
appear in the Bard toolbar as a button, and
display a list of Class Types that the user can select from
In earlier versions of this addon, we had to make a dropdown list ourselves. However, with Statamic 6, we have a number of utilities exposed to us from Statamic themselves, including the UI components that we can selectively use in our addons - and not just Bard extensions.
These are built using Reka UI, and many have had convenience wrappers added by Statamic too, so definitely worth exploring the UI components when developing addons: whether that is a full page CP view or something small like a Bard button.
The basic architecture of our addon is:
a button that appears in the Bard toolbar,
a list of class types to pick from, and
telling the Tiptap editor to add or remove a class type from selected text
We are going to use two Statamic 6 UI components here: Button, and Popover. The Popover will be the dropdown list of class types to pick from, and the Button will be the trigger.
Let’s break this apart and build it up slowly. Up first, the skeleton of our Vue 3 component:
1<script setup> 2import {computed} from 'vue'; 3 4const props = defineProps({ 5 button: Object, 6 active: Boolean, 7 variant: String, 8 config: Object, 9 bard: {},10 editor: {},11})12 13const classTypes = [14 {15 key: 'bodyattack',16 name: 'BodyAttack',17 colour: '#FCC500'18 },19 // our class types object20];21 22const setClassType = function (classType) {23 // tell Tiptap to do something24}25 26const currentClassType = computed(() => {27 return props.editor.getAttributes('lesMillsClassType').classType;28});29 30</script>31<template>32 <div>Our component</div>33</template>
This is the bare minimum we need, and let’s take a look at what we’ve got here. In our script setup
, we have a number of props (from core’s ToolbarButton.vue) that we will forward to our (eventual) Button, an array of Class Types (note, updated from the previous version), a function to set the class type, and a computed property of the current class type from the editor instance.
Except the props (they’re new for v6) this should all look very similar from the previous v2 of the addon, but re-worked for the Composition API.
We can now import the Button and Popover Statamic UI components. The Popover component has a template for the trigger - our Button - and then the main slot for the actual menu contents.
We can now build out our <template>
:
1<script setup> 2import {Button, Popover} from '@statamic/cms/ui'; 3 4// the rest of our setup script would be here 5 6</script> 7<template> 8 <Popover class="les-mills-class-types" align="start" :inset="true" > 9 <template #trigger>10 <Button11 class="px-2!"12 :class="{ active }"13 variant="ghost"14 size="sm"15 :aria-label="button.text"16 v-tooltip="button.text"17 >18 <ui-icon :name="button.svg" v-if="button.svg" class="size-4"/>19 <div class="flex items-center" v-html="button.html" v-if="button.html"/>20 </Button>21 </template>22 23 <button v-for="(classType) in classTypes"24 class="text-left px-3 py-2 w-full hover:bg-gray-100 flex gap-x-3 items-center"25 @click="setClassType(classType.key)"26 :class="{ 'bg-gray-200' : classType.key === currentClassType }">27 <span class="block size-4 rounded-full flex-none" :style="{28 'background-color': classType.colour29 }"></span>30 <span class="block text-sm text-left whitespace-nowrap">{{ classType.name }}</span>31 </button>32 33 </Popover>34</template>
Here we’re using a Popover, giving it a class, telling it to inset (so the contents runs to the edge of the Popover with no padding), and then defining a Button to use as the trigger, and the main contents to simply be a loop over our classTypes
object that, when clicked, will trigger our setClassType
function.
The Button itself we just want to pass our props across - and you could, if you wanted, define your entire button here (such as custom SVG icon) instead of the <ui-icon>
or <div>
in the Button’s slot: but also one day maybe Statamic will expose a more re-usable Bard button component, so keeping the config the way it is would make that change easier.
You’ll notice one of our props is active
: remember how we defined that active logic earlier? Well that true
or false
response will make its way to the Button to then help it know what state to show. When the user has the Mark selected, the button will appear selected.
One last thing to do: our setClassType
function: we need our button to do something. All we need to do is tell Tiptap to set or unset based on the currently selected class. Our options are either explicit - set and unset - or a toggle. Given we are setting a prop, we need to use the set/unset approach, but if you don’t have attributes on your Bard addon, you may want to use the toggle approach.
1const setClassType = function (classType) {2 if (classType === currentClassType.value) {3 props.editor.chain().focus().removeLesMillsClassType().run()4 } else {5 props.editor.chain().focus().addLesMillsClassType({classType: classType}).run()6 }7}
And you know what, that’s it.
We have a Vue component that gets the props for our button, uses the Popover to display a list of class types, and tells Tiptap to do something.
When we’re all done:
1<script setup> 2import {Button, Popover} from '@statamic/cms/ui'; 3import {computed, reactive} from 'vue'; 4 5const props = defineProps({ 6 button: Object, 7 active: Boolean, 8 variant: String, 9 config: Object,10 bard: {},11 editor: {},12})13 14const classTypes = [15 {16 key: 'bodyattack',17 name: 'BodyAttack',18 colour: '#FCC500'19 },20 {21 key: 'bodybalance',22 name: 'BodyBalance',23 colour: '#b9d47d'24 },25 {26 key: 'bodypump',27 name: 'BodyPump',28 colour: '#ea4851'29 },30 {31 key: 'bodyvive',32 name: 'BodyVive',33 colour: '#752f8b'34 },35 {36 key: 'cxworx',37 name: 'CXWORX',38 colour: '#e75204'39 },40 {41 key: 'les-mills-core',42 name: 'Les Mills Core',43 colour: '#444444'44 },45 {46 key: 'les-mills-tone',47 name: 'Les Mills Tone',48 colour: '#777777'49 }50];51 52const setClassType = function (key) {53 if (key === currentKey.value) {54 props.editor.chain().focus().removeLesMillsClassType().run()55 }56 else {57 props.editor.chain().focus().addLesMillsClassType({key: key}).run()58 }59}60 61const currentKey = computed(() => {62 return props.editor.getAttributes('lesMillsClassType').key;63});64 65</script>66<template>67 <Popover class="les-mills-class-types" align="start" :inset="true" >68 <template #trigger>69 <Button70 class="px-2!"71 :class="{ active }"72 variant="ghost"73 size="sm"74 :aria-label="button.text"75 v-tooltip="button.text"76 >77 <ui-icon :name="button.svg" v-if="button.svg" class="size-4"/>78 <div class="flex items-center" v-html="button.html" v-if="button.html"/>79 </Button>80 </template>81 <button v-for="(classType) in classTypes"82 class="text-left px-3 py-2 w-full hover:bg-gray-100 flex gap-x-3 items-center"83 @click="setClassType(classType.key)"84 :class="{ 'bg-gray-200' : classType.key === currentClassType }">85 <span class="block size-4 rounded-full flex-none" :style="{86 'background-color': classType.colour87 }"></span>88 <span class="block text-sm text-left whitespace-nowrap">{{ classType.name }}</span>89 </button>90 </Popover>91</template>92<style>93.les-mills-class-types {94 width: auto !important;95}96</style>
You’ll see I’ve also set a class to the Popover content: just to make the popover narrower. This also shows how you could make it wider too!
This is a really ambitious and detailed upgrade - not only of upgrading the Les Mills Class Type Bard extension, but also following better processes that I follow day-to-day now as a developer. We all learn, change and grow: and this to me is the result of my ever-expanding skillset.
The full source code for this addon is available on Github.
Or if you want to install it and look for yourself, run composer require martyfriedel/les-mills-class-types
- just remember, v3 (the latest at the time of writing) it is for Statamic 6 users only.
Hopefully this has given you the confidence to write your own Bard extensions for Statamic 6, including exploring more about what Tiptap can do, using Statamic 6’s UI components to their full power, and writing cleaner and lighter code to help make your addon maintenance easier in the long run.