Plugin: Word count

Let's get started by writing a very simple plugin and looking at some of the tools needed to develop one effectively. We'll start by writing a plugin that tells you how many words are in the current editor and display it in a small modal window.

Scaffolding Your Plugin

You can generate code scaffolding by running following command:

$ ipm init -p wordcount

You can see that ipm has created about a dozen files that make up the plugin. Let's take a look at each of them to get an idea of how a plugin is structured, then we can modify them to get our word count functionality.

The basic plugin layout is as follows:

wordcount/
├── keymaps
│   └── wordcount.json
├── lib
│   ├── wordcount-message-dialog.js
│   └── wordcount.js
├── menus
│   └── wordcount.json
├── package.json
└── styles
    └── wordcount.less

Not every plugin will have (or need) all of these directories. Let's see what some of these are so we can start messing with them.

package.json

Similar to Node modules, Inkdrop plugins contain a package.json file in their top-level directory. This file contains metadata about the plugin, such as the path to its "main" module, library dependencies, and manifests specifying the order in which its resources should be loaded.

In addition to some of the regular Node package.json keys available, Inkdrop package.json files have their own additions.

  • main: the path to the JavaScript file that's the entry point to your package. If this is missing, Inkdrop will default to looking for an index.js.
  • styles: an Array of Strings identifying the order of the style sheets your plugin needs to load. If not specified, style sheets in the styles directory are added alphabetically.
  • keymaps: an Array of Strings identifying the order of the key mappings your plugin needs to load. If not specified, mappings in the keymaps directory are added alphabetically.
  • menus: an Array of Strings identifying the order of the menu mappings your plugin needs to load. If not specified, mappings in the menus directory are added alphabetically.

The package.json in the plugin we've just generated looks like this currently:

{
  "name": "wordcount",
  "main": "./lib/wordcount",
  "version": "0.0.0",
  "description": "A short description of your package",
  "keywords": [],
  "repository": "https://github.com/inkdropapp/wordcount",
  "license": "MIT",
  "engines": {
    "inkdrop": "^5.x"
  },
  "dependencies": {}
}

One of the first things you should do is ensure that this information is filled out. The name, description, repository URL the project will be at, and the license can all be filled out immediately. The other information we'll get into more detail on as we go.

Source Code

If you want to extend Inkdrop's behavior, your plugin should contain a single top-level module, which you export from whichever file is indicated by the main key in your package.json file. In the plugin we just generated, the main package file is lib/wordcount.js. The remainder of your code should be placed in the lib directory, and required from your top-level file. If the main key is not in your package.json file, it will look for index.js as the main entry point.

Your plugin's top-level module is a singleton object that manages the lifecycle of your extensions to Inkdrop. Even if your plugin creates ten different components and appends them to different parts of the DOM, it's all managed from your top-level object.

Your plugin's top-level module can implement the following basic methods:

  • activate(): This optional method is called when your plugin is activated. Use this to do initialization work when your plugin is started (like setting up React Components or binding events).
  • deactivate(): This optional method is called when the window is shutting down. If your plugin is watching any files or holding external resources in any other way, release them here. If you're just subscribing to things on window, you don't need to worry because that's getting torn down anyway.

Style Sheets

Style sheets for your plugin should be placed in the styles directory. Any style sheets in this directory will be loaded and attached to the DOM when your plugin is activated. Style sheets can be written as CSS or Less, but Less is recommended.

Ideally, you won't need much in the way of styling. Inkdrop's styles are based on Semantic UI which provides a standard set of components which define both the colors and UI elements for any plugin that fits into Inkdrop seamlessly.

If you do need special styling, try to keep only structural styles in the plugin style sheets. If you must specify colors and sizing, these should be taken from the pre-defined CSS Variables where you can find at variables.less like this:

.special-button.highlighted {
  background-color: var(--highlight-background);
}

An optional styles array in your package.json can list the style sheets by name to specify a loading order; otherwise, style sheets are loaded alphabetically.

Keymaps

You can provide key bindings for commonly used actions for your extension, especially if you're also adding a new command. In our new plugin, we have a keymap filled in for us already in the keymaps/wordcount.json file:

{
  "body": {
    "ctrl-alt-o": "wordcount:toggle"
  }
}

This means that if you press Alt+Ctrl+O, our package will run the wordcount:toggle command. We'll look at that code next, but if you want to change the default key mapping, you can do that in this file.

Keymaps are placed in the keymaps subdirectory. By default, all keymaps are loaded in alphabetical order. An optional keymaps array in your package.json can specify which keymaps to load and in what order.

Keybindings are executed by determining which element the keypress occurred on. In the example above, the wordcount:toggle command is executed when pressing Alt+Ctrl+O on the body element. This means the key combination will work anywhere in the application.

See also: Keymap Manager

Menus are placed in the menus subdirectory. This defines menu elements like what pops up when you right click a context-menu or would go in the application menu to trigger functionality in your plugin.

By default, all menus are loaded in alphabetical order. An optional menus array in your package.json can specify which menus to load and in what order.

See also: Menu Manager

Application Menu

It's recommended that you create an application menu item under the Plugins menu for common actions with your plugin that aren't tied to a specific element. If we look in the menus/wordcount.json file that was generated for us, we'll see a section that looks like this:

{
  "menu": [
    {
      "label": "Plugins",
      "submenu": [
        {
          "label": "Word Count",
          "submenu": [
            {
              "label": "Toggle",
              "command": "wordcount:toggle"
            }
          ]
        }
      ]
    }
  ]
}

This section puts a "Toggle" menu item under a menu group named "Word Count" in the "Plugins" menu.

Application Menu

When you select that menu item, it will run the wordcount:toggle command, which we'll look at in a bit.

The menu templates you specify are merged with all other templates provided by other plugins in the order which they were loaded.

Context Menu

It's recommended to specify a context menu item for commands that are linked to specific parts of the interface. In our menus/wordcount.json file, we can see an auto-generated section that looks like this:

{
  "context-menu": {
    ".CodeMirror": [
      {
        "label": "Toggle Word Count",
        "command": "empty:toggle"
      }
    ]
  }
}

This adds a "Toggle Word Count" menu option to the menu that pops up when you right-click in an Inkdrop text editor pane.

Context Menu

When you click that it will again run the wordcount:toggle method in your code.

Context menus are created by determining which element was selected and then adding all of the menu items whose selectors match that element (in the order which they were loaded). The process is then repeated for the elements until reaching the top of the DOM tree.

You can also add separators and submenus to your context menus. To add a submenu, provide a submenu key instead of a command. To add a separator, add an item with a single type: 'separator' key/value pair. For instance, you could do something like this:

{
  "context-menu": {
    "body": [
      {
        "label": 'Text'
        "submenu": [
          {"label": 'Copy Selected Text', "command": 'core:copy'}
          {"type": 'separator'}
          {"label": 'Selector All', "command": 'core:select-all'}
          {"type": 'separator'}
          {"label": 'Undo', "command": 'core:undo'}
        ]
      }
    ]
  }
}

Developing Your Plugin

Currently with the generated package we have, if we run that wordcount:toggle command through the menu or the command palette, we'll get a dialog that says "Word Count was toggled!".

Dialog

Linking to the App Locally

You can install the plugin locally for development.

Run ipm link --dev to symlink your repository to /dev/packages in the user data directory.

Let Inkdrop run in Development Mode by selecting the menu Inkdrop > Preferences on macOS or File > Settings on Windows and Linux, clicking the General tab on the left hand navigation, and check the "Development Mode", then reload the app by pressing Alt+Cmd+Ctrl+L / Alt+Ctrl+L.

Understanding the Generated Code

Let's take a look at the code in our lib directory and see what is happening.

There are two files in our lib directory. One is the main file (lib/wordcount.js), which is pointed to in the package.json file as the main file to execute for this plugin. This file handles the logic of the whole plugin.

The second file is a React Component class, lib/wordcount-message-dialog.js, which handles the UI elements of the plugin. Let's look at this file first:

'use babel'

import React, { useEffect, useCallback } from 'react'
import { logger, useModal } from 'inkdrop'

const WordcountMessageDialog = props => {
  const modal = useModal()
  const { Dialog } = inkdrop.components.classes

  const toggle = useCallback(() => {
    modal.show()
    logger.debug('Wordcount was toggled!')
  }, [])

  useEffect(() => {
    const sub = inkdrop.commands.add(document.body, {
      'wordcount:toggle': toggle
    })
    return () => sub.dispose()
  }, [toggle])

  return (
    <Dialog {...modal.state} onBackdropClick={modal.close}>
      <Dialog.Title>Wordcount</Dialog.Title>
      <Dialog.Content>Wordcount was toggled!</Dialog.Content>
      <Dialog.Actions>
        <button className="ui button" onClick={modal.close}>
          Close
        </button>
      </Dialog.Actions>
    </Dialog>
  )
}

export default WordcountMessageDialog

Inkdrop is built with React, and you can make UI components with it. The WordcountMessageDialog defines a React Component which shows a modal message dialog on toggling from the command. This component is a function component that renders the message dialog using Dialog built-in component imported from the component manager by doing like so:

const { Dialog } = inkdrop.components.classes

It uses useModal built-in React Hook method to manage the dialog visibility state. You can show the dialog by calling modal.show() and hide with modal.hide() method. To toggle the dialog by a command, it defines a callback function with React's useCallback as following:

const toggle = useCallback(() => {
  modal.show()
  logger.debug('Wordcount was toggled!')
}, [])

Then, it binds the callback function with the "wordcount:toggle" command with React's useEffect:

useEffect(() => {
  const sub = inkdrop.commands.add(document.body, {
    'wordcount:toggle': toggle
  })
  return () => sub.dispose()
}, [toggle])

The inkdrop.commands.add method returns an instance of the CompositeDisposable class. It automatically unbinds the command when the component is unmounted from the DOM by calling CompositeDisposable#dispose() method.

The second file we have is the main entry point to the plugin. Again, because it's referenced in the package.json file. Let's take a look at that file, it's pretty simple:

'use babel'

import WordcountMessageDialog from './wordcount-message-dialog'

module.exports = {
  activate() {
    inkdrop.components.registerClass(WordcountMessageDialog)
    inkdrop.layouts.addComponentToLayout('modals', 'WordcountMessageDialog')
  },

  deactivate() {
    inkdrop.layouts.removeComponentFromLayout(
      'modals',
      'WordcountMessageDialog'
    )
    inkdrop.components.deleteClass(WordcountMessageDialog)
  }
}

In the activate method called on activation of the plugin, it registers the WordcountMessageDialog class to a Inkdrop component registry and adds it to a hidden modal layout.

See also: Component Manager and Layout Manager

The Flow

So, let's review the actual flow in this plugin.

  1. Inkdrop starts up
  2. Inkdrop starts loading plugins
  3. Inkdrop reads your package.json
  4. Inkdrop loads keymaps, menus, styles and the main module
  5. Inkdrop finishes loading plugins
  6. Inkdrop executes the activate method in your main module which sets up the UI by creating the hidden modal message dialog
  7. Inkdrop executes the plugin command wordcount:toggle which reveals the hidden modal message dialog
  8. At some point, the user executes the wordcount:toggle command again
  9. Inkdrop executes the command which hides the modal message dialog

Counting the Words

So now that we understand what is happening, let's modify the code so that our little modal message dialog shows us the current word count instead of static text.

We'll do this in a very simple way. When the dialog is toggled, we'll count the words right before displaying the modal. So let's do this in the toggle command. If we add some code to count the words and ask the React component to update itself, we'll have something as following in lib/wordcount-message-dialog.js. First, import useState method from react:

import React, { useState, useEffect, useCallback } from 'react'

Next, import react-redux. You don't have to add it as a dependency because Inkdrop provides it out of the box:

import { useSelector } from 'react-redux'

Then, in the dialog component, add the following line to remember the word count using React's useState:

const [count, setCount] = useState(0)

The app state is managed with Redux. You can refer the state via inkdrop.store or the Redux function. In this example, we use useSelector function to get the current state of the editing note like so:

const noteBody = useSelector(selectEditingNoteBody)

The selectEditingNoteBody function should be defined outside the component function like so:

const selectEditingNoteBody = ({ editingNote }) =>
  editingNote ? editingNote.body : ''

Now, let's define a function that counts the words:

const countWords = useCallback(() => {
  return noteBody.split(/\s+/).length
}, [noteBody])

We call it when toggling the dialog:

const toggle = useCallback(() => {
  setCount(countWords())
  modal.show()
}, [countWords])

Finally, we tell our message dialog to display the word count.

return (
  <Dialog {...modal.state} onBackdropClick={modal.close}>
    <Dialog.Title>Wordcount</Dialog.Title>
    <Dialog.Content>There are {count} words.</Dialog.Content>
    <Dialog.Actions>
      <button className="ui button" onClick={modal.close}>
        Close
      </button>
    </Dialog.Actions>
  </Dialog>
)

Now, lib/wordcount-message-dialog.js looks like this:

'use babel'

import React, { useState, useEffect, useCallback } from 'react'
import { logger, useModal } from 'inkdrop'
import { useSelector } from 'react-redux'

const selectEditingNoteBody = ({ editingNote }) =>
  editingNote ? editingNote.body : ''

const WordcountMessageDialog = props => {
  const modal = useModal()
  const { Dialog } = inkdrop.components.classes
  const [count, setCount] = useState(0)
  const noteBody = useSelector(selectEditingNoteBody)

  const countWords = useCallback(() => {
    return noteBody.split(/\s+/).length
  }, [noteBody])

  const toggle = useCallback(() => {
    setCount(countWords())
    modal.show()
  }, [countWords])

  useEffect(() => {
    const sub = inkdrop.commands.add(document.body, {
      'wordcount:toggle': toggle
    })
    return () => sub.dispose()
  }, [toggle])

  return (
    <Dialog {...modal.state} onBackdropClick={modal.close}>
      <Dialog.Title>Wordcount</Dialog.Title>
      <Dialog.Content>There are {count} words.</Dialog.Content>
      <Dialog.Actions>
        <button className="ui button" onClick={modal.close}>
          Close
        </button>
      </Dialog.Actions>
    </Dialog>
  )
}

export default WordcountMessageDialog

Pretty simple! We take the count number that was passed in and place it into a string that we then stick into the element that our component is controlling.

Word count

Basic Debugging

You'll notice a few console.log statements in the code. One of the cool things about Inkdrop being built on Chromium is that you can use some of the same debugging tools available to you that you have when doing web development.

To open up the Developer Console, press Alt+Cmd+I on macOS or Alt+Ctrl+I on Linux and Windows, or choose the menu option Developer > Toggle Developer Tools.

DevTools

From here you can inspect objects, run code and view console output just as though you were debugging a web site.

Publishing

Now that our simple plugin is working and tested, let's go ahead and publish it so it's available to the world.

Inkdrop bundles a command line utility called ipm which we first used back in Using ipm (Inkdrop Plugin Manager) to search for and install plugins via the command line. The ipm command can also be used to publish Inkdrop plugins to the public registry and update them.

Prepare Your Package

There are a few things you should double check before publishing:

  • Your package.json file has name, description, and repository fields.
  • Your package.json file has a version field with a value of "0.0.0".
  • Your package.json file has an engines field that contains an entry for Inkdrop.
    • If your plugin is a theme, it should be: "engines": {"inkdrop": "^5.x"}.
    • Otherwise, it should be: "engines": {"inkdrop": ">=5.6.0 <7"}.
  • Your plugin has a README.md file at the root.
  • Change the repository url in the package.json file to match the URL of your repository.
  • Your plugin is in a Git repository that has been pushed to GitHub. Follow this guide if your plugin isn't already on GitHub.

Publish Your Package

Before you publish a plugin it is a good idea to check ahead of time if a plugin with the same name has already been published to the Inkdrop package registry. You can do that by visiting https://my.inkdrop.app/plugins/wordcount to see if the plugin already exists. If it does, update your plugin's name to something that is available before proceeding.

Now let's review what the ipm publish command does:

  1. Registers the plugin name on my.inkdrop.app if it is being published for the first time.
  2. Updates the version field in the package.json file and commits it.
  3. Creates a new Git tag for the version being published.
  4. Pushes the tag and current branch up to GitHub.
  5. Updates my.inkdrop.app with the new version being published.

Now run the following commands to publish your package:

cd ~/github/my-package
ipm publish minor

Your package is now published and available on my.inkdrop.app. Head on over to https://my.inkdrop.app/plugins/wordcount to see your plugin's page.

With ipm publish, you can bump the version and publish by using

ipm publish <version-type>

where <version-type> can be major, minor and patch.

The major option to the publish command tells ipm to increment the first number of the version before publishing so the published version will be 1.0.0 and the Git tag created will be v1.0.0.

The minor option to the publish command tells ipm to increment the second number of the version before publishing so the published version will be 0.1.0 and the Git tag created will be v0.1.0.

The patch option to the publish command tells ipm to increment the third number of the version before publishing so the published version will be 0.0.1 and the Git tag created will be v0.0.1.

Use major when you make a change that breaks backwards compatibility, like changing defaults or removing features. Use minor when adding new functionality or making improvements on existing code. Use patch when you fix a bug that was causing incorrect behaviour. Check out semantic versioning to learn more about best practices for versioning your plugin releases.

You can also run ipm help publish to see all the available options and ipm help to see all the other available commands.

Summary

We've now generated, customized and published our first plugin for Inkdrop. Congratulations! Now anyone can install our masterpiece from directly within Inkdrop as we did in Extend Inkdrop with Plugins.

If you can't decide what to build, there are many ideas posted on our forum and those topics are tagged with plugin-idea. You will get inspirations from them!

Can you help us improve the docs? 🙏

The source of these docs is here on GitHub. If you see a way these docs can be improved, please fork us!