Skip to main content

Create a custom block

Codex allows you to create custom blocks in Codex Editor through plugins. Users can use these blocks to add custom content into your entries' RichContent field, which is not provided as a feature in Codex. Users can add these blocks into the content by just typing the 'slash' command same as for the other native Codex blocks.

Pre-requisites

Before continuing with this tutorial, please make sure you have read our plugins Quick Start guide and Create your first plugin tutorial, which goes through the setup of the first plugin used during this tutorial.

Technology

Before continuing to create your first custom block, let's look into the technology of Codex Editor, which powers the Rich Content field. Codex Editor is an advanced block-based and channel agnostic editor. The content written in Codex Editor is based on blocks where users can add any type of content. Each block works independently and can contain any kind of necessary meta-data for rendering the content to the end-users. Even though the Codex Editor generates the final HTML of the block, the primary data are saved as JSON attributes into the block data, meaning you can use this meta-data to render the content in any channel: web, native app, external system, etc.

The Codex Editor is built on top of the Tiptap editor. Tiptap is a headless wrapper around ProseMirror – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as New York Times, The Guardian or Atlassian. Create exactly the rich text editor you want out of customizable building blocks. Tiptap comes with sensible defaults, a lot of extensions and a friendly API to customize every aspect. It's backed by a welcoming community, open source, and free. Learn more about Tiptap at: https://tiptap.dev/

While building Codex custom blocks does not require knowledge about how Tiptap works, it's good to know the underlying technology in cases when you need to develop more advanced blocks.

Define the widget

When creating a custom block, you need to define it as a widget in the manifest file. Each widget contains two main Vue components: renderEditor and renderEditorPanel. The render editor panel can also be an array of panels.

Let's continue with our example about the sport plugin.

manifest.json
{
"plugin_name": "sport",
"plugin_display_name": "Sport",
"version": "1.0.0",
"widgets": [
{
"displayName": "Match",
"name": "match",
"image": "block/image.svg",

"renderEditor": "block/Editor.vue",
"renderEditorPanel": [
{
"icon": "Settings",
"label": "General",
"component": "block/GeneralPanel.vue"
},
{
"icon": "Embed",
"label": "Metadata",
"component": "block/MetadataPanel.vue"
}
],

"attrs": {
"firstTeamName": {
"default": "Barcelona"
},
"firstTeamScore": {
"default": "1"
},
"secondTeamName": {
"default": "Real Madrid"
},
"secondTeamScore": {
"default": "2"
},
"showLeagueName": {
"default": false
},
"leagueName": {
"default": null
}
}
}
]
}

Descriptions about widget attributes

displayName

A string that represents a human-readable name for the block.

name

A string that represents a unique name for the block. Two widgets in the same plugin cannot have the same name.

image

A string that represents the path of the image to be used as a visual identifier of the block. SVG format preferred

renderEditor

A string that represents the path of the render editor component for the block. This component will render the content in the editor and allows users to work with the block.

renderEditorPanel

A string or array of objects that represents the block's side panel components. A block can have one or more panels in the Codex admin while using the block, allowing users to configure extra information about the block. In the example of sport, we have created two side panels, one for general information about the match and the other for the metadata of the match, such as the league name.

Each editor panel has

icon

A string that represents the name of the icon to be used for the panel.

label

A string that represents the label of the panle.

component

A string that represents tha path of the component to be used for that specific panel.

attrs

An object that represents the attributes for the block.

Block output

Based on the manifest file above, the output of the block in the RichContent field would be like this:

{
"type": "sport-match",
"text": null,
"marks": [],
"content": [],
"attrs": {
"firstTeamName": "Barcelona",
"firstTeamScore": "1",
"secondTeamName": "Real Madrid",
"secondTeamScore": "2",
"showLeagueName": true,
"leagueName": "La Liga",
"blockId": "bl7e34ba5119934fd7964d"
},
"contentHTML": "-"
}

As we can see, the block type is {plugin_name}-{widget_name}, where in this case, for the plugin sport and the widget match, we have the block type sport-match. The attributes are automatically saved as part of the attrs property of the block based on their definition in the manifest file.

Editor component

As mentioned earlier, this component is rendered inside the content editor and can have two purposes:

  1. Preview the block in the editor for the users
  2. Allow users to perform changes in the block or manage the content that is going to be displayed there based on the block type

In the example about the sports match, we're going to develop a block editor which only displays information about the match and the score based on the values defined in the manifest file.

Editor.vue
<template>
<div class="sport-match__wrapper">
<h4 class="sport-match__score">
<span class="sport-match__first-team-name">
{{ firstTeamName }}
</span>
<span class="sport-match__first-team-score">
{{ firstTeamScore }}
</span>
-
<span class="sport-match__second-team-score">
{{ secondTeamScore }}
</span>
<span class="sport-match__second-team-name">
{{ secondTeamName }}
</span>
</h4>
<h5 v-if="showLeagueName">
<small>League: </small> {{ leagueName }}
</h5>
</div>
</template>

<script>
import { generateComputedPropsFromAttrs } from '@/components/codex-layout-editor/BuilderUtils'

export default {
props: {
updateAttrs: {
type: Function,
default: () => {},
},
widget: {
required: true,
type: Object,
},
selected: {
type: Boolean,
},
},
data() {
return {
}
},
computed: {
...generateComputedPropsFromAttrs([
'firstTeamName',
'firstTeamScore',
'secondTeamName',
'secondTeamScore',
'showLeagueName',
'leagueName',
]),
},
mounted() {
},

}
</script>

<style>
/* your custom style here */
</style>

Editor panels

Editor panels allow developers to have advanced configurations about the block in different tabs and customized input types. We will create two panels for demonstration purposes, GeneralPanel.vue and MetadataPanel.vue.

The general panel contains the inputs for match team names and their respective scores. In addition, users can open the editor panel and write the team names and scores when they add the match widget inside the rich content field.

The metadata panel also appears in the side panel in the editor as another tab, where users can specify if they want to show the league name or not and write the league name.

All these configurations are saved automatically as attrs attributes of the block.

GeneralPanel.vue
<template>
<div>
<b-form-group label="First team name">
<b-form-input v-model="firstTeamName" />
</b-form-group>
<b-form-group label="First team score">
<b-form-input v-model="firstTeamScore" />
</b-form-group>
<b-form-group label="Second team name">
<b-form-input v-model="secondTeamName" />
</b-form-group>
<b-form-group label="Second team score">
<b-form-input v-model="secondTeamScore" />
</b-form-group>
</div>
</template>

<script>
import { generateComputedPropsFromAttrs } from '@/views/layouts/BuilderUtils'

export default {
props: {
updateAttrs: {
type: Function,
default: () => {},
},
widget: {
required: true,
type: Object,
},
},
data() {
return {
}
},
computed: {
...generateComputedPropsFromAttrs([
'firstTeamName',
'firstTeamScore',
'secondTeamName',
'secondTeamScore',
])
},
}
</script>

<style>
/* your custom style here */
</style>
MetadataPanel.vue
<template>
<div>
<b-form-group label="Show league name">
<b-form-checkbox v-model="showLeagueName" />
</b-form-group>

<b-form-group label="League name">
<b-form-input v-model="leagueName" />
</b-form-group>
</div>
</template>

<script>
import { generateComputedPropsFromAttrs } from '@/views/layouts/BuilderUtils'

export default {
props: {
updateAttrs: {
type: Function,
default: () => {},
},
widget: {
required: true,
type: Object,
},
},
data() {
return {
}
},
computed: {
...generateComputedPropsFromAttrs([
'showLeagueName',
'leagueName',
])
},
}
</script>

<style>
/* your custom style here */
</style>

How to use

How the match block is ready to be used. Create a model and add the rich content field to it. Then, go to create an entry for that model. When you go to the respective rich content field of the entry, type '/' and you will see a list of available blocks for that model. Search for your block; in this case, 'match' and click enter. Now you can see the output of the Editor.vue component of the block. If you hover over the block, you will see the settings icon. If you click the settings icon, you will see the editor panels on the right side with two different types, one for GeneralPanel.vue and the other for MetadataPanel.vue. If you perform changes in these panels, you will also see the output of the block will be updated automatically based on what the user writes in the input fields of panels.

Reference other entires

As part of the custom block you can also reference other entries in the attrs field. To do that you need to set a field with the name references and the value of an array of object. Here is an example of referencing fields into the Codex Editor blocks.

  "type": "sport-match",
"text": null,
"marks": [],
"content": [],
"attrs": {
"firstTeamName": "Barcelona",
"firstTeamScore": "1",
"secondTeamName": "Real Madrid",
"secondTeamScore": "2",
"showLeagueName": true,
"leagueName": "La Liga",
"blockId": "bl7e34ba5119934fd7964d",
"references": [
{
"model": "team",
"entryId": "me8isd3s2"
},
{
"model": "stadium",
"entryId": "me8rfd3s2"
},
{
"model": "player",
"entryId": "me8okl3s2"
}
]
},
"contentHTML": "-"

Nested blocks

A custom block can also contain nested blocks. To create a block for such cases, you must extend the TipTap functionality and handle node creation. Let's take an example to create a custom Factbox block that contains other blocks. The Factbox is a wrapping block with custom styling for end-users, but with any type of content. To create such block, first, let's define the manifest file:

mainfest.json
{
"plugin_name": "factbox",
"plugin_display_name": "Factbox",
"version": "1.0.0",
"routes": [

],
"navigation": [

],
"fields": [

],
"widgets": [
{
"displayName": "Factbox",
"name": "factbox",
"group": "textual",
"image": "factbox-block/image.svg",
"attrs": {
"blockId": {
"default": null
}
},
"createTiptapNode": "factbox-block/factboxTipTapNode.js",
"renderEditorBar": "factbox-block/FactboxPanel.vue"
}
]
}

You can spot the difference between this manifest file and the first one we created at the beginning of this tutorial. To create nested blocks, we need to define two new fields:

createTiptapNode

A string that represents the path of Tiptap node custom implementation for the block.

renderEditorBar

A string that represents the path of render editor bar for the block.

Since Tiptap already contains a similar node for such case, we will extend the blockquote node of Tiptap to create the Factbox block:

factboxTipTapNode.js
import { Node } from 'tiptap'
import { wrappingInputRule, toggleWrap, wrapIn } from 'tiptap-commands'

export default function(componentName, componentAttrs, component) {

class Factbox extends Node {
get name() {
return componentName
}

get schema() {
return {
attrs: componentAttrs,
defining: true,
draggable: false,
content: 'block+',
group: 'block',
parseDOM: [{ tag: 'blockquote' }],
toDOM: node => ['blockquote', { class: 'blockquote-factbox', 'data-id': node.attrs.blockId }, 0],
}
}

commands({ type }) {
return () => wrapIn(type)
}

keys({ type }) {
return {
'Ctrl-}': toggleWrap(type),
}
}

inputRules({ type }) {
return [wrappingInputRule(/^\s*>>\s$/, type)]
}
}

return new Factbox()
}

And for the FactboxPanel we have:

FactboxPanel.vue
<template>
<TextFormatPanel
:toggle-link-popup="toggleLinkPopup"
:commands="commands"
:is-active="isActive"
:include-hyper-link="true"
/>
</template>

<script>
import TextFormatPanel from '@/components/codex-editor/panels/TextFormatPanel.vue'

export default {
components: {
TextFormatPanel,
},
props: ['attrs', 'updateAttrs', 'commands', 'isActive', 'toggleLinkPopup'],
computed: {
level: { get() { return this.attrs.level }, set(e) { this.updateAttrs({ level: e }) } },
},
methods: {
isLevel(level) {
return this.level === level
},
setLevel(level) {
this.level = level
},
},
}
</script>

Now, if you add the Factbox into your editor, you will see a custom background for the block and be able to add any type of block inside the Factbox.