Creating a Markdown editor plugin
This guide demonstrates how to create a Markdown editor plugin. It expects you to have first read the table of contents tutorial or have basic plugin development experience.
This guide describes how to create a plugin for Joplin's CodeMirror 6-based Markdown editor. The plugin created in this guide should work on both mobile and desktop. However, on Joplin desktop before version 3.1, the beta editor will need to be enabled in settings > general.
Setup
Create the plugin
Start by creating the plugin with yo joplin
. The beta Markdown editor is still new, so make sure the joplin
generator is up-to-date.
You should now have a directory structure similar to the following:
📂 codemirror6-plugin/
⏐ 📂 publish/
⏐ 📂 api/
⏐ 📂 node_modules/
⏐ 📂 dist/
⏐ 📂 src/
⏐ ⏐ manifest.json
⏐ ⏐ index.ts
⏐ webpack.config.js
⏐ tsconfig.json
⏐ package-lock.json
⏐ README.md
⏐ .gitignore
⏐ plugin.config.json
⏐ .npmignore
⏐ GENERATOR_DOC.md
⏐ package.json
Update the plugin build script
At the time of this writing, this section was necessary. If Joplin 2.14 is no longer in pre-release, you might be able to skip this section.
To create a plugin that supports the beta editor, you'll want to update webpack.config.js
to the latest version. Doing this allows importing CodeMirror packages without bundling additional copies of them with the plugin.
To do this, replace the contents of webpack.config.js
with the unreleased version of webpack.config.js
on Joplin's GitHub repository.
Content script setup
Create the content script
Now that the plugin has been created, we can create and register a CodeMirror content script.
Start by opening plugin.config.json
.It should look similar to this:
{
"extraScripts": []
}
The "extraScripts"
entry provides a list of TypeScript files that will be compiled in addition to src/index.ts
. This will allow registering built versions of these files as CodeMirror or renderer content scripts.
To add a content script, start by creating a contentScript.ts
file in the src
directory. Next, add the path to contentScript.ts
to extraScripts
:
{
- "extraScripts": []
+ "extraScripts": ["contentScript.ts"]
}
Notice that the above path is relative to the src
directory.
The plugin's directory structure should now look similar to this:
📂 codemirror6-plugin/
⏐ 📂 publish/
⏐ 📂 api/
⏐ 📂 node_modules/
⏐ 📂 dist/
⏐ 📂 src/
⏐ ⏐ contentScript.ts
⏐ ⏐ manifest.json
⏐ ⏐ index.ts
⏐ plugin.config.json
⏐ ...
Register the content script
Open src/index.ts
. It should look similar to this:
import joplin from 'api';
joplin.plugins.register({
onStart: async function() {
// eslint-disable-next-line no-console
console.info('Hello world. Test plugin started!');
},
});
Next, use joplin.contentScripts.register to add the content script to Joplin:
import joplin from 'api';
+import { ContentScriptType } from 'api/types';
joplin.plugins.register({
onStart: async function() {
- // eslint-disable-next-line no-console
- console.info('Hello world. Test plugin started!');
+ const contentScriptId = 'some-content-script-id';
+ joplin.contentScripts.register(
+ ContentScriptType.CodeMirrorPlugin,
+ contentScriptId,
+ './contentScript.js',
+ );
},
});
When Joplin starts, this causes contentScript.js
(which is built from contentScript.ts
) to be loaded as a CodeMirror plugin.
Register CodeMirror extensions from the content script
Next, open contentScript.ts
and add the following content:
// 1. Import a CodeMirror extension
import { lineNumbers } from '@codemirror/view';
export default (_context: { contentScriptId: string, postMessage: any }) => {
return {
plugin: (codeMirrorWrapper: any) => {
// 2. Adds the built-in CodeMirror 6 extension to the editor
codeMirrorWrapper.addExtension(lineNumbers());
},
};
};
The above script adds the built-in CodeMirror lineNumbers
extension to the editor. It's also possible to pass an array of extensions to .addExtension
.
If you build the plugin with npm install
or npm run dist
, you might see the following error:
bash$ npm run dist
...
ERROR in /home/builder/Documents/joplin/packages/app-cli/tests/support/plugins/cm6-test/src/contentScript.ts
2:28-46
[tsl] ERROR in /home/builder/Documents/joplin/packages/app-cli/tests/support/plugins/cm6-test/src/contentScript.ts(2,29)
TS2307: Cannot find module '@codemirror/view' or its corresponding type declarations.
At present, TypeScript can't find type information for @codemirror/view
. To fix this, run npm install --save-dev @codemirror/view
in the plugin's base directory:
$ cd path/to/codemirror6-plugin/
$ npm install --save-dev @codemirror/view
The default webpack.config.js
tells Webpack not to bundle several packages, including @codemirror/view
. As such, the @codemirror/view
plugin is used only for type information.
This is what we want. If @codemirror/view
is bundled with the plugin, it could conflict with the version of @codemirror/view
used by Joplin. In general, CodeMirror packages can break if multiple copies of the same package try to use the same editor. This is also why a newer version of webpack.config.js
is required to build the plugin.
Try it!
We now have an extension that adds line numbers to Joplin's markdown editor.
To try it,
- Open Joplin.
- Open "Options", then "Plugins".
- Click "Show Advanced Settings"
- Enter the path to the
codemirror6-plugin
directory into the "Development plugins" box. - Open the "General" tab and make sure "opt in to the editor beta" is checked.
- Restart Joplin.
- Make sure Joplin closes completely before opening it again. On Windows/Linux, this can be done by closing Joplin with
File
>Quit
.
- Make sure Joplin closes completely before opening it again. On Windows/Linux, this can be done by closing Joplin with
Your editor should now have line numbers!
If the plugin fails to load, you might see an error similar to the following in Joplin's development tools (Help
> Toggle development tools
):
Error: Unrecognized extension value in extension set (function(t={}){return[kn.of(t),gn(),An]}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.
If you do, be sure to follow the steps in the "Update the Plugin Build Script" section. If that section doesn't help, change
import { lineNumbers } from '@codemirror/view';
to
import joplin from "api";
const { lineNumbers } = joplin.require('@codemirror/view');
Connect to the main script
Next, we'll see how to communicate between the plugin's main script and the editor. We'll do this using joplin.contentScripts.onMessage
and context.postMessage
.
Register a setting
Let's start by registering a setting.
Open index.ts
and, near the top of the file, create a new function, registerSettings.ts
:
import joplin from 'api';
import { ContentScriptType } from 'api/types';
// Add this:
const registerSettings = async () => {
const sectionName = 'example-cm6-plugin';
await joplin.settings.registerSection(sectionName, {
label: 'CodeMirror 6 demo plugin',
description: 'Settings for the CodeMirror 6 example plugin.',
icon: 'fas fa-edit',
});
// TODO:
};
// ...
The call to joplin.settings.registerSection
creates a new section in Joplin's settings. This is where we'll put new settings.
As before, icon
can be any FontAwesome 5 Free icon name. The description
property is an optional extended description to be shown at the top of our settings page.
Next, let's register a setting.
Add a new highlightLineSettingId
constant to the top of index.ts
. Then, register a setting with highlightLineSettingId
as its ID using joplin.settings.registerSettings
:
import joplin from 'api';
// Add an import for SettingItemType:
import { ContentScriptType, SettingItemType } from 'api/types';
// Add this:
const highlightLineSettingId = 'highlight-active-line';
const registerSettings = async () => {
const sectionName = 'example-cm6-plugin';
await joplin.settings.registerSection(sectionName, {
label: 'CodeMirror 6 demo plugin',
description: 'Settings for the CodeMirror 6 example plugin.',
iconName: 'fas fa-edit',
});
// Add this:
await joplin.settings.registerSettings({
[highlightLineSettingId]: {
section: sectionName,
value: true, // Default value
public: true, // Show in the settings screen
type: SettingItemType.Bool,
label: 'Highlight active line',
},
});
};
// ...
Finally, add a call to registerSettings
from onStart
.
We can get and set settings in the plugin's main script (src/index.ts
), but not directly in the plugin's content script.
index.ts
should now look like this.
index.ts
:
import joplin from 'api';
import { ContentScriptType, SettingItemType } from 'api/types';
const highlightLineSettingId = 'highlight-active-line';
const registerSettings = async () => {
const sectionName = 'example-cm6-plugin';
await joplin.settings.registerSection(sectionName, {
label: 'CodeMirror 6 demo plugin',
description: 'Settings for the CodeMirror 6 example plugin.',
iconName: 'fas fa-edit',
});
await joplin.settings.registerSettings({
[highlightLineSettingId]: {
section: sectionName,
value: true, // Default value
public: true, // Show in the settings screen
type: SettingItemType.Bool,
label: 'Highlight active line',
},
});
};
joplin.plugins.register({
onStart: async function() {
await registerSettings();
const contentScriptId = 'some-content-script-id';
await joplin.contentScripts.register(
ContentScriptType.CodeMirrorPlugin,
contentScriptId,
'./contentScript.js',
);
},
});
Create an onMessage
listener that returns the setting value
Create a new registerMessageListener
function, just above joplin.plugins.register({
. In this function, register an onMessage
listener with joplin.contentScripts.onMessage
. We'll listen for the getSettings
message and return an object with the plugin's current settings:
// ... in index.ts ...
// ...hidden...
// Add this:
const registerMessageListener = async (contentScriptId: string) => {
await joplin.contentScripts.onMessage(
contentScriptId,
// Sending messages with `context.postMessage`
// from the content script with `contentScriptId`
// calls this onMessage listener:
async (message: any) => {
if (message === 'getSettings') {
const settingValue = await joplin.settings.value(highlightLineSettingId);
return {
highlightActiveLine: settingValue,
};
}
},
);
};
joplin.plugins.register({
onStart: async function() {
await registerSettings();
// Add this:
const contentScriptId = 'some-content-script-id';
await registerMessageListener(contentScriptId);
await joplin.contentScripts.register(
ContentScriptType.CodeMirrorPlugin,
contentScriptId,
'./contentScript.js',
);
}
});
Get the setting from the content script
Open contentScript.ts
and update it with the following:
import { lineNumbers, highlightActiveLine } from '@codemirror/view';
// We're now using `context`: Rename it from `_context`
// to `context`.
export default (context: { contentScriptId: string, postMessage: any }) => {
return {
// An `async` was also added so that we can `await` the result of
// `context.postMessage`:
plugin: async (codeMirrorWrapper: any) => {
codeMirrorWrapper.addExtension(lineNumbers());
// Add this:
// Get settings from the main script with postMessage:
const settings = await context.postMessage('getSettings');
if (settings.highlightActiveLine) {
codeMirrorWrapper.addExtension(highlightActiveLine());
}
},
};
};
Above, we get settings from index.ts
with context.postMessage('getSettings')
. This calls the onMessage
listener that was registered earlier. Its return value is stored in the settings
variable.
Note that highlightActiveLine
is another built-in CodeMirror extension. It adds the cm-activeLine
class to all lines that have a cursor on them.
Alternative approach to getting settings: Registering an editor command
Above, we use postMessage
and onMessage
to access settings.
An alternative way to do this would be to register an editor command with code similar to the following:
// You may need to add @codemirror/state to package.json
import { Compartment } from '@codemirror/state';
// ...
plugin: async (codeMirrorWrapper: any) => {
// See https://codemirror.net/examples/config/#compartments
const highlightExtension = new Compartment();
codeMirrorWrapper.addExtension(highlightExtension.of([]));
// Registers a command with name "myExtension__setHighlightActiveLine" that can be
// called from the main plugin script with joplin.commands.execute('editor.execCommand', ...).
codeMirrorWrapper.registerCommand('myExtension__setHighlightActiveLine', (highlighted: boolean) => {
const extension = highlighted ? [ highlightActiveLine() ] : [ ];
codeMirrorWrapper.editor.dispatch({
effects: [ highlightExtension.reconfigure(extension) ],
});
});
},
In index.ts
, we could then call the following function when the plugin's settings change and after the content script loads:
const updateContentScriptSettings = async () => {
await joplin.commands.execute('editor.execCommand', {
name: 'myExtension__setHighlightActiveLine',
args: [ await joplin.settings.value(highlightLineSettingId) ],
});
};
Style the active line
If you run the plugin, you might notice that the active line has a blue background. Let's customise it with CSS!
There are two different ways of doing this: With a .css
file and with a CodeMirror theme. In this tutorial, we'll use a .css
file.
Create a new style.css
file within the src
directory. Set its content to
.cm-editor .cm-line.cm-activeLine {
/* See https://joplinapp.org/help/api/references/plugin_theming
for more information about styling with plugins */
color: var(--joplin-color);
background-color: rgba(200, 200, 0, 0.4);
}
Next, load the CSS file from the CodeMirror content script:
import { lineNumbers, highlightActiveLine } from '@codemirror/view';
export default (context: { contentScriptId: string, postMessage: any }) => {
return {
plugin: async (codeMirrorWrapper: any) => {
// ...hidden
},
assets: () => {
return [ { name: './style.css' } ];
},
};
};
The active line should now have a light-yellow background, but only when the "highlight active line" setting is enabled.
CodeMirror 5 compatibility
As of Joplin v2.14 we recommend that you create CodeMirror 6-based plugins. If you still need to support older versions of Joplin, you can target both CodeMirror 5 and CodeMirror 6. Follow the tutorial below for information on how to do this.
Joplin's legacy markdown editor uses CodeMirror 5. The beta editor uses CodeMirror 6.
Unfortunately, the CodeMirror 5 API and CodeMirror 6 APIs are very different. As such, you'll likely need two different content scripts — one for CodeMirror 5 and one for CodeMirror 6. This pull request provides an example of how CodeMirror 6 support might be added to an existing plugin.
To add CodeMirror 5 compatibility to our CodeMirror 6 plugin, we'll:
- Create another content script for CodeMirror 5. Use only CodeMirror 5 APIs.
- Within the
plugin
function, check whethercodeMirrorWrapper
is actually a CodeMirror 5 editor. This can be done by checking whethercodeMirrorWrapper.cm6
is defined. (If it is, it's a reference to a CodeMirror 6EditorView
). - If
codeMirrorWrapper.cm6
is defined, only load the CodeMirror 5 content script. Otherwise, only load the CodeMirror 6 content script.
Create a content script for CodeMirror 5
For organisational purposes, make a new folder, src/contentScripts
. Next, move the existing contentScript.ts
to src/contentScripts/codeMirror6.ts
and create a new contentScripts/codeMirror5.ts
file.
You should now have the following folder structure:
📂 codemirror6-plugin/
⏐ 📂 publish/
⏐ 📂 api/
⏐ 📂 node_modules/
⏐ 📂 dist/
⏐ 📂 src/
⏐ ⏐ 📂 contentScripts/
⏐ ⏐ ⏐ codeMirror6.ts
⏐ ⏐ ⏐ codeMirror5.ts
⏐ ⏐ manifest.json
⏐ ⏐ index.ts
⏐ plugin.config.json
⏐ ...
For now, let src/contentScripts/codeMirror5.ts
's content be the same as the original CodeMirror 6 content script.
Next, update plugin.config.json
so that both content scripts are compiled by Webpack:
{
"extraScripts": [
"contentScripts/codeMirror6.ts",
"contentScripts/codeMirror5.ts"
]
}
Register the content script
Update index.ts
so that both the CodeMirror 5 and CodeMirror 6 content scripts are registered:
// ...
// Add this
const registerCodeMirrorContentScript = async (contentScriptName: string) => {
const id = contentScriptName;
await registerMessageListener(id);
await joplin.contentScripts.register(
ContentScriptType.CodeMirrorPlugin,
id,
`./contentScripts/${id}.js`,
);
};
joplin.plugins.register({
onStart: async function() {
await registerSettings();
// Add this:
await registerCodeMirrorContentScript('codeMirror6');
await registerCodeMirrorContentScript('codeMirror5');
// DELETE this:
//await joplin.contentScripts.register(
// ContentScriptType.CodeMirrorPlugin,
// contentScriptId,
// './contentScripts/contentScript.js',
//);
}
});
Update the CodeMirror 5 content script
Replace the CodeMirror 5 content script's content with the following:
// Don't import CodeMirror 6 packages here -- doing so won't work in the CM5 editor.
export default (context: { contentScriptId: string, postMessage: any }) => {
return {
plugin: async (codeMirror: any) => {
// Exit if not a CodeMirror 5 editor.
if (codeMirror.cm6) {
return;
}
codeMirror.defineOption('enable-highlight-extension', true, async function() {
const settings = await context.postMessage('getSettings');
// At this point, `this` points to the CodeMirror
// editor instance
this.setOption('styleActiveLine', settings.highlightActiveLine);
});
},
// Sets CodeMirror 5 default options.
codeMirrorOptions: {
'lineNumbers': true,
'enable-highlight-extension': true,
},
// Additional CodeMirror scripts. Has no effect in CodeMirror 6.
// See https://codemirror.net/5/doc/manual.html#addon_active-line
codeMirrorResources: [ 'addon/selection/active-line.js' ],
assets: () => {
return [ { name: './style.css' } ];
},
};
};
Although Joplin does provide a limited CodeMirror 5 compatibility layer in the CodeMirror 6 editor, in the future, new plugins will be unable to use this compatibility layer.
Make the CodeMirror 6 content script only load in CodeMirror 6
At the beginning of contentScripts/codeMirror6.ts
's plugin
function, add:
import { lineNumbers, highlightActiveLine } from '@codemirror/view';
export default (context: { contentScriptId: string, postMessage: any }) => {
return {
plugin: async (codeMirrorWrapper: any) => {
// Exit if not a CodeMirror 6 editor.
if (!codeMirrorWrapper.cm6) return;
codeMirrorWrapper.addExtension(lineNumbers());
// ...
},
assets: () => {
// ...
},
};
};
Summary
To support both CodeMirror 5 and CodeMirror 6, we register two content scripts. One will fail to load in CodeMirror 5 and the other we disable in CodeMirror 6.