Popover & text analysis

This tutorial will show you how to build a simple text analysis plugin. You will analyze the text, as it is written, and display the information in a popover from the top bar.

The end result

Let's jump ahead and see exactly what it is you will create when following the steps in this tutorial.

Prerequisites

For simplicity's sake these instructions assumes that you have followed the quickstart guide first to get a working plugin environment.

If you have followed the instructions in the quickstart guide you should now have a simple plugin displaying some UI components in the sidebar.

Start by removing the DevKitComponent.js file.

Then remove related code as well as all label handling in DevKitPackage.js. You should be left with two files in the src directory: DevKitPackage.js and index.js.

The DevKitPackage.js should, maybe minus some comments, look like below.

import './scss/index.scss'

export default {
    name: 'npwriterdevkit',
    id: 'se.infomaker.npwriterdevkit',

    configure: function(config, pluginConfig) {

    }
}

The basics of a popover

In order to create a popover dialog you will need to create a UI component and register it with the configurator in the DevKitPackage.js.

Create a basic component, TextanalyzerComponent.js, in the src directory and add the following code.

const {Component} = substance

class TextanalyzerComponent extends Component {
    render($$) {
        return $$('div')
            .append([
                $$('h1').append('My text analyzer'),
                $$('p').append('Nothing to see yet')
            ])
    }
}

export {TextanalyzerComponent}

As you can see this is a basic component, inherits from Component, with a render function that outputs a header and paragraph. In order to have this component rendered in a popover you will use the addPopover() method.

Import the component in the package file.

import {TextanalyzerComponent} from './TextanalyzerComponent'

Then use this component in a popover. It will use the Font Awesome icon fa-info aligned to the right. In addition it will be sticky, meaning that it won't automatically be closed when it looses focus. The first parameter should be the name of the plugin.

config.addPopover(
    'npwriterdevkit',
    {
        icon: 'fa-info',
        align: 'right',
        sticky: true
    },
    TextanalyzerComponent
)

The complete DevKitPackage.js file now looks like below. And this is actually all you need to do to have a popover rendered in the Writer.

import {TextanalyzerComponent} from './TextanalyzerComponent'

export default {
    name: 'npwriterdevkit',
    id: 'se.infomaker.npwriterdevkit',

    configure: function(config, pluginConfig) {

        config.addPopover(
            'npwriterdevkit',
            {
                icon: 'fa-info',
                align: 'right',
                sticky: true
            },
            TextanalyzerComponent
        )
    }
}

If you reload it, a popover i icon will be visible somewhere to the right in top bar. If clicked you will see the rendered component.

Listening to events

In order to analyze the text as the user writes you need to listen to the DOCUMENT_CHANGED event. When adding event listeners in either the didMount() lifecycle method or the constructor() it is also always a good idea to unregister event listeners in the component lifecycle method dispose().

Import the Writer api in the top of the TextanalyzerComponent.js file.

const {api, event} = writer

Then add a constructor and dispose method to the class that register and unregister events respectively. As you will see in many api methods the first parameter is the name of the plugin.

constructor(...args) {
    super(...args)

    api.events.on('npwriterdevkit', event.DOCUMENT_CHANGED, () => {
        this.calculateText()
    })
}

dispose() {
    api.events.off('npwriterdevkit', event.DOCUMENT_CHANGED);
}

calculateText() {
    // Still empty
}

Analyzing and display text information

So every time the document is changed calculateText() is called. Now use the api method api.document.getDocumentNodes() to fetch all document elements of the article to count the words and characters.

When done you can extend the components state using this.extendState({}).

calculateText() {
    const nodes = api.document.getDocumentNodes()

    let textContent = "";
    nodes.forEach(function (node) {
        if (node.content) {
            textContent += node.content.trim()
        }
    })

    this.extendState({
        words: textContent.split(/\s+/).length,
        characters: textContent.length
    })
}

Displaying the data in the popover

So nothing really happens until it shows up in the popover of course. Go back to the render function you created earlier. Render an empty popover if the data has not been collected.

render($$) {
    const {words, characters} = this.state
    if (words === undefined || characters === undefined) {
        return $$('div')
    }

    return $$('div')
        .append([
            $$('h1').append('Word count'),
            $$('p').append(`${words} words`),
            $$('h1').append('Text length'),
            $$('p').append(`${characters} characters`)
        ])
}

It doesn't look very nice. And unless you write something nothing will be displayed at all. To fix that you need to add a call the calculateText() as soon as the component has mounted, in the didMount() lifecycle method, and also add some css styling.

First add the didMount() method to your component below the constructor.

didMount() {
    this.calculateText()
}

Then create a scss file (which if you use the DevKit is already there) with some nice styling. Create (or edit) index.scss in the scss directory.

div.mypopoveranalysis {
    padding: 20px;

    h1 {
        font-size: 14px;
        line-height: 16px;
        margin: 0;
    }

    p {
        margin: 0 0 10px 0 !important;
    }
}

Import the style file in the top and add the class to the wrapper div element in the render function.

import './scss/index.scss'

// ...

render($$) {
    // ...
    return $$('div')
        .addClass('mypopoveranalysis')
        .append([
            ...
        ])
}

The css file will not be loaded automatically. You need to change the plugin configuration and add it. Bring up the Configuration test tool using the keyboard shortcut cmd+shift+y or ctrl+shift+y depending on whether you are using OS X or Windows. Then add the css file that should be loaded. The DevKit plugin is configured to create a style.css in the dist directory.

Wrap up

You should now have a simple, but fully working, text analysis plugin that lives in the top bar. Exactly as the preview in the beginning of the tutorial.

To wrap it up, here are all the relevant files and their content.

src/
    scss/
        index.scss
    DevKitPackage.js
    index.js
    TextanalyzerComponent.js

scss/index.scss

div.mypopoveranalysis {
    padding: 20px;

    h1 {
        font-size: 14px;
        line-height: 16px;
        margin: 0;
    }

    p {
        margin: 0 0 10px 0 !important;
    }
}

DevKitPackage

import './scss/index.scss'
import {TextanalyzerComponent} from './TextanalyzerComponent'

export default {
    name: 'npwriterdevkit',
    id: 'se.infomaker.npwriterdevkit',

    configure: function(config, pluginConfig) {

        config.addPopover(
            'npwriterdevkit',
            {
                icon: 'fa-info',
                align: 'right',
                sticky: true
            },
            TextanalyzerComponent
        )
    }
}

index.js

As you haven't renamed the package file this file has not been altered, but here it is for clarity's sake.

import DevkitPackage from './DevKitPackage'
import {registerPlugin} from 'writer'

(() => {
    // Register the plugin with the Writer when registerPlugin() is available
    if (registerPlugin) {
        registerPlugin(DevkitPackage)
    }
    else {
        console.error('Register method not yet available')
    }
})()

TextanalyzerComponent.js

const {Component} = substance
const {api, event} = writer
import './scss/index.scss'

class TextanalyzerComponent extends Component {
    constructor(...args) {
        super(...args)

        api.events.on('npwriterdevkit', event.DOCUMENT_CHANGED, () => {
            this.calculateText()
        })
    }

    didMount() {
        this.calculateText()
    }

    dispose() {
        api.events.off('npwriterdevkit', event.DOCUMENT_CHANGED);
    }

    calculateText() {
        const nodes = api.document.getDocumentNodes()

        let textContent = "";
        nodes.forEach(function (node) {
            if (node.content) {
                textContent += node.content.trim()
            }
        })

        this.extendState({
            words: textContent.split(/\s+/).length,
            characters: textContent.length
        })
    }

    render($$) {
        const {words, characters} = this.state
        if (words === undefined || characters === undefined) {
            return $$('div')
        }

        return $$('div')
            .addClass('mypopoveranalysis')
            .append([
                $$('h1').append('Word count'),
                $$('p').append(`${words} words`),
                $$('h1').append('Text length'),
                $$('p').append(`${characters} characters`)
            ])
    }
}

export {TextanalyzerComponent}