Creating a content object plugin

Introduction

This article will teach you how to go about creating a simple but complete content block object plugin. There are also inline content objects in the Writer. Even so, we normally talk about content objects and inline objects. A content object lives in the editable area.

Examples of content objects are images, videos, maps and embedded social media posts.

There are number of CSS classes and building blocks you need to use in order to create a Writer compliant content object. These building blocks will ensure that your content object follows the UX guidelines of the Writer as well as give it support for generic properties.

Generic properties are configurable key/values that can be attached to any content object through configuration independent of individual plugin configurations.

Overview

The Writer, and its plugins, are all built on a component model. There is a complete separation between the data a plugin handles and the UI used to visualize, or render, the data. As the UI is data driven this means that changes to data results in the UI being rerendered.

Even though the Writer is not based on React a good knowledge of React and how components, props and state works is very useful.

A simple content object require building a few different building blocks, of which some are tightly coupled through naming only. The parts required are:

  1. Tool A small component rendering a menu option in the content menu.

  2. Command Executed by clicking the tool in the content menu.

  3. Node A schema that defines a data node. A data node is created by the command. (Data nodes can be inserted in other ways, e.g. drag’n drop, which is not part of this tutorial.)

  4. Component Renders the data node into a visible UI object in the editors content area. Many plugins will have many sub components to maintain a good coding structure.

  5. Converter Exports the data node to an articles xml and imports the xml from an article into a data node.

The bits and pieces

The plugin package

What binds all the parts together is the package file. This is a simple object with a configure function which you provide to the Writer. The configure function is called whenever the Writer is ready to initialise the plugin.

The configure function has two parameters. First a configurator object which contains all the functions necessary to register the plugins different building blocks. The second parameter is the plugin configuration as a plain object.

const MyPodcastPackage = {
    name: 'my-podcast',
    configure(configurator, pluginConfig) {
        // Registration of all the bits and pieces
    }
}

export {MyPodcastPackage}

The tool and the command

First you need a menu item in the content menu. A tool is a simple button component that can be rendered in the content menu (or other menus). Tools are normally very simple and there only to execute a command. The responsibility of the command is to perform the desired action. For example create and insert a data node.

To work as intended a tool must have a command registered with the same name. A command on the other hand can be be used independently.

The tool is a class that inherits from Tool found in Substance whereas the command inherits from WriterCommand found in Writer.

import {Tool} from 'substance'
import {api, UIIcon} from 'writer'

class PodcastTool extends Tool {
    render($$) {
        let el = $$('div').attr('title', this.getLabel('Insert my podcast'))

        el.append([
            $$('button')
                .addClass('se-tool')
                .append($$(UIIcon, {
                    name: 'microphone-bold'
                }))
            ).on('click', this.onClick.bind(this))
        ])

        return el
    }

    onClick() {
        // Execute a named command
        this.context.api.editorSession.executeCommand('insert-mypodcast', {})
    }
}

export {InsertMyDataNodeTool} //shouldn't it be named {PodcastTool}?
import {WriterCommand, idGenerator} from 'writer'

class PodcastCommand extends WriterCommand {
    execute(params) {
        params.editorSession.transaction(tx => {
            // Perform actions in a transaction that become
            // part of the undo/redo history
        })
        return true
    }
}

export {PodcastCommand}

Both the tool and the command is registered in the package configure function.

import {PodcastTool} from './tools/PodcastTool.js'
import {InsertPodcastCommand} from './commands/PodcastCommand.js'

const MyPodcastPackage = {
    name: 'my-podcast',
    configure(configurator, pluginConfig) {
        configurator.addContentMenuTopTool('podcast.insert', PodcastTool)
        configurator.addCommand('podcast.insert', PodcastCommand)
    }
}

export {MyPodcastPackage}

The node and the component

A block node is in this context is a simple schema. Its purpose is to describe the data structure of a content objects data node. The data node will later be handed to a component as props to be rendered in the Writers editor area or be used by the converter to convert the data to and from XML.

The node schema, which extends the BlockNode imported from the Writer, must have a property called type. The type is what ties the data node to a specific (named) converter and a specific (named) component.

import {BlockNode} from 'writer'
class PodcastNode extends BlockNode {}

PodcastNode.define({
   type: 'mypodcastnode',
   dataType: {type: 'string'},
   id: {type: 'string'},
   url: {type: 'string', optional: false, default: ''}
   caption: {type: 'string', optional: true, default: ''}
})

export {PodcastNode}

The component is what render the user interface your plugin displays to the user in the editor content area. Only one component is needed, but most plugins are split into many sub components to keep the project well structured.

A component without any bells and whistles could look like below.

import {Component} from 'substance'

class PodcastComponent extends Component {
    render($$) {
        const el = $$('div').addClass('im-blocknode__container')

        // Build the html structure and render the node data
        el.append([
            $$('strong').append(this.props.node.title),
            $$('div').append(this.props.node.url)
        ])

        // Add support for generic properties
        el.append($$(GenericPropsComponent, {
            pluginName: 'my-podcast',
            node: this.props.node,
            isolatedNodeState: this.props.isolatedNodeState
        }).ref('genericProps'))

        return el
    }
}

export {PodcastComponent}

And again both the node and the component must be added to the writer in the package configure function.

Note that we use the node type as the first parameter when we add the component to make sure the Writer know that these are connected.

import {PodcastTool} from './tools/PodcastTool.js'
import {PodcastCommand} from './commands/PodcastCommand.js'
import {PodcastNode} from './nodes/PodcastNode.js'
import {PodcastComponent} from './nodes/PodcastComponent.js'

const MyPodcastPackage = {
    name: 'my-podcast',
    configure(configurator, pluginConfig) {
        configurator.addContentMenuTopTool('podcast.insert', PodcastTool)
        configurator.addCommand('podcast.insert', PodcastCommand)
        configurator.addNode(PodcastNode)
        configurator.addComponent(PodcastNode.type, PodcastComponent)
    }
}

export {MyPodcastPackage}

Now that a node and component to render the node is defined you can go back to the command and actually create the node.

Note the type property of the node object which tells the Writer which node schema to use.

import {WriterCommand, idGenerator} from 'writer'

class PodcastCommand extends WriterCommand {
    execute(params) {
        params.editorSession.transaction(tx => {
            tx.insertBlockNode({
                id: idGenerator(),
                type: 'mypodcastnode',
                url: 'https://example.org/mypodcast/1',
                title: 'My podcast #1'
            })
        })
        return true
    }
}

export {PodcastCommand}

Converter

Now that the plugin can create and render a data node it is time to look into how to make sure the data is included when the article is saved. And of course that the plugin also picks it up when the article is opened in the Writer again.

For this a converter needs to be defined. A converter includes an export() function for converting the data node to XML and an import function to parse the XML into the data node again.

In addition two properties, type and tagName, as well as a matchElement() function is needed. The type property is what ties the converter to the node for exporting. The tagName defines the xml element name for the exported data. The matchElement() function on the other hand allows your code to investigate which xml elements you understand and wants to import.

Note the type which ties this converter to the specific node type.

const PodcastConverter = {
    type: 'mypodcastnode',
    tagName: 'object',
    matchElement: function(el) {
        return el.is('object[type="x-my/podcast"]')
    },

    import: function(el, node) {
        node.id = el.attr('id')
        node.url = el.find('url').text()
        node.title = el.find('title').text()
    },

    export: function(node, el, converter) {
        const $$ = converter.$$
        el.attr({
            id: node.id,
            type: 'x-my/podcast',
        })

        el.append([
            $$('url').append(node.url),
            $$('title').append(node.title)
        ])
    }
}

export {PodcastConverter}

Adding the converter should then make the package file complete.

import {PodcastTool} from './tools/PodcastTool.js'
import {PodcastCommand} from './commands/PodcastCommand.js'
import {PodcastNode} from './nodes/PodcastNode.js'
import {PodcastComponent} from './nodes/PodcastComponent.js'
import {PodcastConverter} from './converters/PodcastConverter.js'

const MyPodcastPackage = {
    name: 'my-podcast',
    configure(configurator, pluginConfig) {
        configurator.addContentMenuTopTool('podcast.insert', PodcastTool)
        configurator.addCommand('podcast.insert', PodcastCommand)
        configurator.addNode(PodcastNode)
        configurator.addComponent(PodcastNode.type, PodcastComponent)
        configurator.addConverter(PodcastConverter)
    }
}

export {MyPodcastPackage}

Registering the plugin

Finally it is time to wrap it up by registering the plugin package when the Writer loads the plugin. This is done through a self executing function that call the registerPlugin() function in the Writer. Usually this is done in a separate index.js file.

import {registerPlugin} from 'writer'
import {MyPodcastPackage} from './MyPodcastPackage';

(() => {
    if (registerPlugin) {
        registerPlugin(MyPodcastPackage)
    }
    else {
        console.error('Register method not yet available')
    }
})()

Wrap up

These are the most basic building blocks needed to create a simple content object plugin in the Writer and it should get you started. Other things you might want to look into are drop handlers, events, macros and ways to add components to the top bar or the meta data column.