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 anindex.js
.styles
: an Array of Strings identifying the order of the style sheets your plugin needs to load. If not specified, style sheets in thestyles
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 thekeymaps
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 themenus
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.
Do not forget to update the repository URL. The one generated for you is invalid by design and will prevent you from publishing your package until updated.
Inkdrop is planned to bump up its major version to 6, which has breaking changes to the UI theming system. If your plugin is not a theme, you can target the engine version to {"inkdrop": ">=5.6.0 <7"}
in your package.json
file.
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
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.
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.
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!".
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.
- Inkdrop starts up
- Inkdrop starts loading plugins
- Inkdrop reads your
package.json
- Inkdrop loads keymaps, menus, styles and the main module
- Inkdrop finishes loading plugins
- Inkdrop executes the
activate
method in your main module which sets up the UI by creating the hidden modal message dialog - Inkdrop executes the plugin command
wordcount:toggle
which reveals the hidden modal message dialog - At some point, the user executes the
wordcount:toggle
command again - 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.
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.
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 hasname
,description
, andrepository
fields. - Your
package.json
file has aversion
field with a value of "0.0.0". - Your
package.json
file has anengines
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"}
.
- If your plugin is a theme, it should be:
- 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:
- Registers the plugin name on my.inkdrop.app if it is being published for the first time.
- Updates the
version
field in thepackage.json
file and commits it. - Creates a new Git tag for the version being published.
- Pushes the tag and current branch up to GitHub.
- 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!