Joplin

An open source note taking and to-do application with synchronisation capabilities

Creating a table of content pluginπŸ”—

This tutorial will guide you through the steps to create a table of content plugin for Joplin. It will display a view next to the current note that will contain links to the sections of a note. It will be possible to click on one of the header to jump to the relevant section.

Through this tutorial you will learn about several aspect of the Joplin API, including:

##Β Setting up your environment

Before getting any further, make sure your environment is setup correctly as described in the Get Started guide.

Registering the pluginπŸ”—

All plugins must register themselves and declare what events they can handle. To do so, open src/index.ts and register the plugin as below. We'll also need to run some initialisation code when the plugin starts, so add the onStart() event handler too:

// Register the plugin
joplin.plugins.register({

	// Run initialisation code in the onStart event handler
	onStart: async function() {
		console.info('TOC plugin started!');
	},

});

If you now build the plugin and try to run it in Joplin, you should see the message TOC plugin started! in the dev console.

Getting the current noteπŸ”—

In order to create the table of content, you will need to access the content of currently selected note, and you will need to refresh the TOC every time the note changes. All this can be done using the workspace, which provides information about the active content being edited.

So within the onStart event handler, add the following:

joplin.plugins.register({

	onStart: async function() {

		// Later, this is where you'll want to update the TOC
		async function updateTocView() {
			// Get the current note from the workspace.
			const note = await joplin.workspace.selectedNote();

			// Keep in mind that it can be `null` if nothing is currently selected!
			if (note) {
				console.info('Note content has changed! New note is:', note);
			} else {
				console.info('No note is selected');
			}
		}

		// This event will be triggered when the user selects a different note
		joplin.workspace.onNoteSelectionChange(() => {
			updateTocView();
		});

		// This event will be triggered when the content of the note changes
		// as you also want to update the TOC in this case.
		joplin.workspace.onNoteContentChange(() => {
			updateTocView();
		});

		// Also update the TOC when the plugin starts
		updateTocView();
	},

});

Getting the note sections and slugsπŸ”—

Now that you have the current note, you'll need to get the headers in that note in order to build the TOC from it. There are many ways to do so, such as using a Markdown parser, but for now a quick and dirty solution is to get all the lines that start with # followed by a space. Any such line should be a header.

The function below, which you can copy anywhere in your file, will use this method and return an array of headers, with the text and level (H1, H2, etc.) of header:

function noteHeaders(noteBody:string) {
	const headers = [];
	const lines = noteBody.split('\n');
	for (const line of lines) {
		const match = line.match(/^(#+)\s(.*)*/);
		if (!match) continue;
		headers.push({
			level: match[1].length,
			text: match[2],
		});
	}
	return headers;
}

Then call this function from your event handler:

joplin.plugins.register({

	onStart: async function() {

		async function updateTocView() {
			const note = await joplin.workspace.selectedNote();

			if (note) {
				const headers = noteHeaders(note.body);
				console.info('The note has the following headers', headers);
			} else {
				console.info('No note is selected');
			}
		}

		// ...
	},

});

Later you will also need a way to generate the slug for each header. A slug is an identifier which is used to link to a particular header. Essentially a header text like "My Header" is converted to "my-header". And if there's already a slug with that name, a number is appended to it. Without going into too much details, this is the function you will need for Joplin, so copy it somewhere in your file:

let slugs = {};

function headerSlug(headerText) {
	const s = nodeSlug(headerText);
	let num = slugs[s] ? slugs[s] : 1;
	const output = [s];
	if (num > 1) output.push(num);
	slugs[s] = num + 1;
	return output.join('-');
}

Again try to run the plugin and if you select a note with multiple headers, you should see the header list in the console.

Creating a webviewπŸ”—

In order to display the TOC in Joplin, you will need a webview panel. Webviews are a simple way to add custom content to the UI using HTML/CSS and JavaScript. First you would create the webview object, then you can set its content using the html property.

Here's how it could be done:

joplin.plugins.register({

	onStart: async function() {
		// Create the webview object
		const tocView = joplin.views.createWebviewPanel();

		// Set some initial content while the TOC is being created
		tocView.html = 'Loading...';

		async function updateTocView() {
			const note = await joplin.workspace.selectedNote();
			slugs = {}; // Reset the slugs

			if (note) {
				const headers = noteHeaders(note.body);

				// First create the HTML for each header:
				const itemHtml = [];
				for (const header of headers) {
					const slug = headerSlug(header.text);

					// - We indent each header based on header.level.
					//
					// - The slug will be needed later on once we implement clicking on a header.
					//   We assign it to a "data" attribute, which can then be easily retrieved from JavaScript
					//
					// - Also make sure you escape the text before inserting it in the HTML to avoid XSS attacks
					//   and rendering issues. Joplin provides the function joplin.utils.escapeHtml for this purpose.
					itemHtml.push(`
						<p class="toc-item" style="padding-left:${(header.level - 1) * 15}px">
							<a class="toc-item-link" href="#" data-slug="${joplin.utils.escapeHtml(slug)}">
								${joplin.utils.escapeHtml(header.text)}
							</a>
						</p>
					`);
				}

				// Finally, insert all the headers in a container and set the webview HTML:
				tocView.html = `
					<div class="container">
						${itemHtml.join('\n')}
					</div>
				`;
			} else {
				tocView.html = 'Please select a note to view the table of content';
			}
		}

		// ...
	},

});

Now run the plugin again and you see the TOC dynamically update as you change notes.

Styling the viewπŸ”—

In order to better integrate the TOC to Joplin, you might want to style it using CSS. To do so, first add a webview.css file next to index.ts, then you will need to let Joplin know about this file. This is done using the addScript() function (which is also used to add JavaScript files as we'll see later), like so:

const tocView = joplin.views.createWebviewPanel();
tocView.addScript('./webview.css'); // Add the CSS file to the view

This file is just a plain CSS file you can use to style your view. Additionally, you can access from there a number of theme variables, which you can use to better integrate the view to the UI. For example, using these variables you can use a dark background in dark mode, and a light one in light mode.

For now, the CSS file below would give the view the correct font color and family, and the right background colour:

.container {
	background-color: var(--joplin-background-color);
	color: var(--joplin-color);
	font-size: var(--joplin-font-size);
	font-family: var(--joplin-font-family);
}

.toc-item a {
	color: var(--joplin-color);
	text-decoration: none;
}

Making the webview interactiveπŸ”—

The next step is to make the TOC interactive so that when the user clicks on a link, the note is scrolled to right header. This can be done using an external JavaScript file that will handle the click events. As for the CSS file, create a webview.js file next to index.ts, then add the script to the webview:

const tocView = joplin.views.createWebviewPanel();
tocView.addScript('./webview.css'); // Add the CSS file to the view
tocView.addScript('./webview.js'); // Add the JS file to the view

To check that everything's working, let's create a simple event handler that display the header slug when clicked:

// There are many ways to listen to click events, you can even use
// something like jQuery or React. For now to keep it simple, let's
// do it in plain JavaScript:
document.addEventListener('click', event => {
	const element = event.target;
	// If a TOC header has been clicked:
	if (element.className === 'toc-item-link') {
		// Get the slug and display it:
		const slug = element.dataset.slug;
		alert('Clicked header slug: ' + slug);
	}
})

If everything works well, you should now see the slug whenever you click on a header link. The next step will be to use that slug to scroll to the right header.

Passing messages between the webview and the pluginπŸ”—

For security reason, webviews run within their own sandbox (iframe) and thus do not have access to the Joplin API. You can however send messages to and from the webview to the plugin, and you can call the Joplin API from the plugin.

From within a webview, you have access to the webviewApi object, which among others has a postMessage() function you can use to send a message to the plugin. Let's use this to post the slug info to the plugin:

Change webview.js like so:

document.addEventListener('click', event => {
	const element = event.target;
	if (element.className === 'toc-item-link') {
		// Post the message and slug info back to the plugin:
		webviewApi.postMessage({
			name: 'scrollToHash',
			hash: element.dataset.slug,
		});
	}
})

Then from the plugin, in src/index.ts, you can listen to this message using the onMessage() handler. Then from this handler, you can call the scrollToHash command and pass it the slug (or hash).

joplin.plugins.register({
	onStart: async function() {
		const tocView = joplin.views.createWebviewPanel();

		// ...

		tocView.onMessage((message) => {
			if (message.name === 'scrollToHash') {
				// As the name says, the scrollToHash command makes the note scroll
				// to the provided hash.
				joplin.commands.execute('scrollToHash', {
					hash: message.hash,
				})
			}
		});

		// ...
	}

	// ...

And that's it! If you run this code you should now have a fully functional TOC. The full source code is available there:

https://github.com/laurent22/joplin/tree/dev/CliClient/tests/support/plugins/toc/

Various improvements can be made such as improving the styling, making the header collapsible, etc. but that tutorial should provide the basic building blocks to do so. You might also want to check the plugin API for further information or head to the development forum for support.