1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-29 22:48:10 +02:00

All: Add support for application plugins (#3257)

This commit is contained in:
Laurent
2020-10-09 18:35:46 +01:00
committed by GitHub
parent 833fb1264f
commit fe41d37f8f
804 changed files with 95622 additions and 5307 deletions

View File

@@ -0,0 +1,47 @@
# Getting started with plugin development
In this article you will learn the basic steps to build and test a plugin in Joplin.
## Setting up your environment
First you need to setup your environment:
- Make sure you have [Node.js](https://nodejs.org/) and [git](https://git-scm.com) installed.
- Install Joplin and run it in development mode.
You will also need to have Joplin installed and running in development mode, which we'll describe later.
But first install Yeoman and the Joplin Plugin Generator:
npm install -g yo generator-joplin
Then to create the plugin, run:
yo joplin
This will create the basic scafolding of the plugin. At the root of it, there is a number of configuration files which you normally won't need to change. Then the `src/` directory will contain your code. By default, the project uses TypeScript, but you are free to use plain JavaScript too - eventually the project is compiled to plain JS in any case.
The `src/` directory also contains a [manifest.json](https://github.com/laurent22/joplin/blob/dev/readme/api/references/plugin_manifest/) file, which you can edit to set various information about the plugin, such as its name, homepage URL, etc.
## Building the plugin
The file `src/index.ts` already contain some basic code meant for testing the plugin. In particular it contains a call to [joplin.plugins.register](https://joplinapp.org/plugins/api/classes/joplinplugins.html), which all plugins should call to register the plugin. And an `onStart()` event handler, which will be executed by Joplin when the plugin starts.
To try this basic plugin, compile the app by running the following from the root of the project:
npm run dist
Doing so should compile all the files into the `dist/` directory. This is from here that Joplin will load the plugin.
## Testing the plugin
In order to test the plugin, you might want to run Joplin in [Development Mode](https://github.com/laurent22/joplin/blob/dev/readme/api/references/development_mode/). Doing so means that Joplin will run using a different profile, so you can experiment with the plugin without risking to accidentally change or delete your data.
Finally, in order to test the plugin, open the Setting screen, then navigate the the **Plugins** section, and add the plugin path in the **Development plugins** text field. For example, if your plugin project path is `/home/user/src/joplin-plugin`, add this in the text field.
Restart the app, and Joplin should load the plugin and execute its `onStart` handler. If all went well you should see the test message in the plugin console: "Test plugin started!".
# Next steps
- You might want to check the [plugin tutorial](https://github.com/laurent22/joplin/blob/dev/readme/api/tutorials/toc_plugin/) to get a good overview of how to create a complete plugin and how to use the plugin API.
- For more information about the plugin API, check the [Plugin API reference](https://joplinapp.org/plugins/api/classes/joplin.html).

18
readme/api/overview.md Normal file
View File

@@ -0,0 +1,18 @@
# Extending Joplin
Joplin provides a number of extension points to allow third-party applications to access its data, or to develop plugins.
The two main extension points are:
- The [data API](https://github.com/laurent22/joplin/blob/dev/readme/api/references/rest_api.md), which is a server that provides access to Joplin data to external applications. It is possible, using standard HTTP calls, to create, modify or delete notes, notebooks, tags, etc. as well as attach files to notes and retrieve these files. This is for example how the web clipper communicates with Joplin.
- The [plugin API](https://joplinapp.org/plugins/api/classes/joplin.html), which allows directly modifying Joplin by adding new features to the application. Using this API, you can:
- Access notes, folders, etc. via the data API
- Add a view to display custom data using HTML/CSS/JS
- Create a dialog to display information and get input from the user
- Create a new command and associate a toolbar button or menu item with it
- Get access to the note currently being edited and modify it
- Listen to various events and run code when they happen
- Hook into the application to set additional options and customise Joplin's behaviour
- Create a module to export or import data into Joplin
- Define new settings and setting sections, and get/set them from the plugin

View File

@@ -0,0 +1,5 @@
# Development mode
When experimenting with Joplin, for example when developing a plugin or trying a theme, you might want to run Joplin in development mode. Doing so means that Joplin will run using a different profile, so you can experiment without risking to accidentally change or delete your data.
To enable Development Mode, open Joplin as normal, then go to **Help => Copy dev mode command to clipboard**. This will copy a command to the clipboard. Now close Joplin, and start it again in dev mode using the command you've just copied.

View File

@@ -0,0 +1,21 @@
# Plugin Manifest
The manifest file is a JSON file that describes various properties of the plugin. If you use the Yeoman generator, it should be automatically generated based on the answers you've provided. The supported properties are:
- `manifest_version`: For now should always be "1"
- `name`: Name of the plugin
- `description`: Additional information about the plugin
- `version`: Version number such as "1.0.0"
- `homepage_url`: Homepage URL of the plugin (can also be, for example, a link to a GitHub repository)
Here's a complete example:
```json
{
"manifest_version": 1,
"name": "Joplin Simple Plugin",
"version": "1.0.0",
"description": "To test loading and running a plugin",
"homepage_url": "https://joplinapp.org"
}
```

View File

@@ -0,0 +1,332 @@
# Joplin Data API
This API is available when the clipper server is running. It provides access to the notes, notebooks, tags and other Joplin object via a REST API. Plugins can also access this API even when the clipper server is not running.
In order to use it, you'll first need to find on which port the service is running. To do so, open the Web Clipper Options in Joplin and if the service is running it should tell you on which port. Normally it runs on port **41184**. If you want to find it programmatically, you may follow this kind of algorithm:
```javascript
let port = null;
for (let portToTest = 41184; portToTest <= 41194; portToTest++) {
const result = pingPort(portToTest); // Call GET /ping
if (result == 'JoplinClipperServer') {
port = portToTest; // Found the port
break;
}
}
```
# Authorisation
To prevent unauthorised applications from accessing the API, the calls must be authentified. To do so, you must provide a token as a query parameter for each API call. You can get this token from the Joplin desktop application, on the Web Clipper Options screen.
This would be an example of valid cURL call using a token:
curl http://localhost:41184/notes?token=ABCD123ABCD123ABCD123ABCD123ABCD123
In the documentation below, the token will not be specified every time however you will need to include it.
# Using the API
All the calls, unless noted otherwise, receives and send **JSON data**. For example to create a new note:
curl --data '{ "title": "My note", "body": "Some note in **Markdown**"}' http://localhost:41184/notes
In the documentation below, the calls may include special parameters such as :id or :note_id. You would replace this with the item ID or note ID.
For example, for the endpoint `DELETE /tags/:id/notes/:note_id`, to remove the tag with ID "ABCD1234" from the note with ID "EFGH789", you would run for example:
curl -X DELETE http://localhost:41184/tags/ABCD1234/notes/EFGH789
The four verbs supported by the API are the following ones:
* **GET**: To retrieve items (notes, notebooks, etc.).
* **POST**: To create new items. In general most item properties are optional. If you omit any, a default value will be used.
* **PUT**: To update an item. Note in a REST API, traditionally PUT is used to completely replace an item, however in this API it will only replace the properties that are provided. For example if you PUT {"title": "my new title"}, only the "title" property will be changed. The other properties will be left untouched (they won't be cleared nor changed).
* **DELETE**: To delete items.
# Filtering data
You can change the fields that will be returned by the API using the `fields=` query parameter, which takes a list of comma separated fields. For example, to get the longitude and latitude of a note, use this:
curl http://localhost:41184/notes/ABCD123?fields=longitude,latitude
To get the IDs only of all the tags:
curl http://localhost:41184/tags?fields=id
# Error handling
In case of an error, an HTTP status code >= 400 will be returned along with a JSON object that provides more info about the error. The JSON object is in the format `{ "error": "description of error" }`.
# About the property types
* Text is UTF-8.
* All date/time are Unix timestamps in milliseconds.
* Booleans are integer values 0 or 1.
# Testing if the service is available
Call **GET /ping** to check if the service is available. It should return "JoplinClipperServer" if it works.
# Searching
Call **GET /search?query=YOUR_QUERY** to search for notes. This end-point supports the `field` parameter which is recommended to use so that you only get the data that you need. The query syntax is as described in the main documentation: https://joplinapp.org/#searching
To retrieve non-notes items, such as notebooks or tags, add a `type` parameter and set it to the required [item type name](#item-type-id). In that case, full text search will not be used - instead it will be a simple case-insensitive search. You can also use `*` as a wildcard. This is convenient for example to retrieve notebooks or tags by title.
For example, to retrieve the notebook named `recipes`: **GET /search?query=recipes&type=folder**
To retrieve all the tags that start with `project-`: **GET /search?query=project-*&type=tag**
# Item type IDs
Item type IDs might be refered to in certain object you will retrieve from the API. This is the correspondance between name and ID:
Name | Value
---- | -----
note | 1
folder | 2
setting | 3
resource | 4
tag | 5
note_tag | 6
search | 7
alarm | 8
master_key | 9
item_change | 10
note_resource | 11
resource_local_state | 12
revision | 13
migration | 14
smart_filter | 15
# Notes
## Properties
Name | Type | Description
--- | --- | ---
id | text |
parent_id | text | ID of the notebook that contains this note. Change this ID to move the note to a different notebook.
title | text | The note title.
body | text | The note body, in Markdown. May also contain HTML.
created_time | int | When the note was created.
updated_time | int | When the note was last updated.
is_conflict | int | Tells whether the note is a conflict or not.
latitude | numeric |
longitude | numeric |
altitude | numeric |
author | text |
source_url | text | The full URL where the note comes from.
is_todo | int | Tells whether this note is a todo or not.
todo_due | int | When the todo is due. An alarm will be triggered on that date.
todo_completed | int | Tells whether todo is completed or not. This is a timestamp in milliseconds.
source | text |
source_application | text |
application_data | text |
order | numeric |
user_created_time | int | When the note was created. It may differ from created_time as it can be manually set by the user.
user_updated_time | int | When the note was last updated. It may differ from updated_time as it can be manually set by the user.
encryption_cipher_text | text |
encryption_applied | int |
markup_language | int |
is_shared | int |
body_html | text | Note body, in HTML format
base_url | text | If `body_html` is provided and contains relative URLs, provide the `base_url` parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the '?'). For example if the original page was `https://stackoverflow.com/search?q=%5Bjava%5D+test`, the base URL is `https://stackoverflow.com/search`.
image_data_url | text | An image to attach to the note, in [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
crop_rect | text | If an image is provided, you can also specify an optional rectangle that will be used to crop the image. In format `{ x: x, y: y, width: width, height: height }`
## GET /notes
Gets all notes
## GET /notes/:id
Gets note with ID :id
## GET /notes/:id/tags
Gets all the tags attached to this note.
## GET /notes/:id/resources
Gets all the resources attached to this note.
## POST /notes
Creates a new note
You can either specify the note body as Markdown by setting the `body` parameter, or in HTML by setting the `body_html`.
Examples:
* Create a note from some Markdown text
curl --data '{ "title": "My note", "body": "Some note in **Markdown**"}' http://127.0.0.1:41184/notes
* Create a note from some HTML
curl --data '{ "title": "My note", "body_html": "Some note in <b>HTML</b>"}' http://127.0.0.1:41184/notes
* Create a note and attach an image to it:
curl --data '{ "title": "Image test", "body": "Here is Joplin icon:", "image_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAANZJREFUeNoAyAA3/wFwtO3K6gUB/vz2+Prw9fj/+/r+/wBZKAAExOgF4/MC9ff+MRH6Ui4E+/0Bqc/zutj6AgT+/Pz7+vv7++nu82c4DlMqCvLs8goA/gL8/fz09fb59vXa6vzZ6vjT5fbn6voD/fwC8vX4UiT9Zi//APHyAP8ACgUBAPv5APz7BPj2+DIaC2o3E+3o6ywaC5fT6gD6/QD9/QEVf9kD+/dcLQgJA/7v8vqfwOf18wA1IAIEVycAyt//v9XvAPv7APz8LhoIAPz9Ri4OAgwARgx4W/6fVeEAAAAASUVORK5CYII="}' http://127.0.0.1:41184/notes
### Creating a note with a specific ID
When a new note is created, it is automatically assigned a new unique ID so **normally you do not need to set the ID**. However, if for some reason you want to set it, you can supply it as the `id` property. It needs to be a **32 characters long string** in hexadecimal. **Make sure it is unique**, for example by generating it using whatever GUID function is available in your programming language.
curl --data '{ "id": "00a87474082744c1a8515da6aa5792d2", "title": "My note with custom ID"}' http://127.0.0.1:41184/notes
## PUT /notes/:id
Sets the properties of the note with ID :id
## DELETE /notes/:id
Deletes the note with ID :id
# Folders
This is actually a notebook. Internally notebooks are called "folders".
## Properties
Name | Type | Description
--- | --- | ---
id | text |
title | text | The folder title.
created_time | int | When the folder was created.
updated_time | int | When the folder was last updated.
user_created_time | int | When the folder was created. It may differ from created_time as it can be manually set by the user.
user_updated_time | int | When the folder was last updated. It may differ from updated_time as it can be manually set by the user.
encryption_cipher_text | text |
encryption_applied | int |
parent_id | text |
is_shared | int |
## GET /folders
Gets all folders
The folders are returned as a tree. The sub-notebooks of a notebook, if any, are under the `children` key.
## GET /folders/:id
Gets folder with ID :id
## GET /folders/:id/notes
Gets all the notes inside this folder.
## POST /folders
Creates a new folder
## PUT /folders/:id
Sets the properties of the folder with ID :id
## DELETE /folders/:id
Deletes the folder with ID :id
# Resources
## Properties
Name | Type | Description
--- | --- | ---
id | text |
title | text | The resource title.
mime | text |
filename | text |
created_time | int | When the resource was created.
updated_time | int | When the resource was last updated.
user_created_time | int | When the resource was created. It may differ from created_time as it can be manually set by the user.
user_updated_time | int | When the resource was last updated. It may differ from updated_time as it can be manually set by the user.
file_extension | text |
encryption_cipher_text | text |
encryption_applied | int |
encryption_blob_encrypted | int |
size | int |
is_shared | int |
## GET /resources
Gets all resources
## GET /resources/:id
Gets resource with ID :id
## GET /resources/:id/file
Gets the actual file associated with this resource.
## POST /resources
Creates a new resource
Creating a new resource is special because you also need to upload the file. Unlike other API calls, this one must have the "multipart/form-data" Content-Type. The file data must be passed to the "data" form field, and the other properties to the "props" form field. An example of a valid call with cURL would be:
curl -F 'data=@/path/to/file.jpg' -F 'props={"title":"my resource title"}' http://localhost:41184/resources
The "data" field is required, while the "props" one is not. If not specified, default values will be used.
## PUT /resources/:id
Sets the properties of the resource with ID :id
## DELETE /resources/:id
Deletes the resource with ID :id
# Tags
## Properties
Name | Type | Description
--- | --- | ---
id | text |
title | text | The tag title.
created_time | int | When the tag was created.
updated_time | int | When the tag was last updated.
user_created_time | int | When the tag was created. It may differ from created_time as it can be manually set by the user.
user_updated_time | int | When the tag was last updated. It may differ from updated_time as it can be manually set by the user.
encryption_cipher_text | text |
encryption_applied | int |
is_shared | int |
parent_id | text |
## GET /tags
Gets all tags
## GET /tags/:id
Gets tag with ID :id
## GET /tags/:id/notes
Gets all the notes with this tag.
## POST /tags
Creates a new tag
## POST /tags/:id/notes
Post a note to this endpoint to add the tag to the note. The note data must at least contain an ID property (all other properties will be ignored).
## PUT /tags/:id
Sets the properties of the tag with ID :id
## DELETE /tags/:id
Deletes the tag with ID :id
## DELETE /tags/:id/notes/:note_id
Remove the tag from the note.

View File

@@ -0,0 +1,341 @@
# 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:
- The plugin API
- How to create a webview
- How to listen to changes in the user interface
## Setting up your environment
Before getting any further, make sure your environment is setup correctly as described in the [Get Started guide](https://github.com/laurent22/joplin/blob/dev/readme/api/get_started/plugins/).
## Registering the plugin
All plugins must [register themselves](https://joplinapp.org/plugins/api/classes/joplinplugins.html) 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:
```typescript
// Import the Joplin API
import joplin from 'api';
// Register the plugin
joplin.plugins.register({
// Run initialisation code in the onStart event handler
// Note that due to the plugin multi-process architecture, you should
// always assume that all function calls and event handlers are async.
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 the currently selected note, and you will need to refresh the TOC every time the note changes. All this can be done using the [workspace API](https://joplinapp.org/plugins/api/classes/joplinworkspace.html), which provides information about the active content being edited.
So within the `onStart()` event handler, add the following:
```typescript
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
await 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.
await joplin.workspace.onNoteContentChange(() => {
updateTocView();
});
// Also update the TOC when the plugin starts
updateTocView();
},
});
```
Try the above and you should see in the console the event handler being called every time a new note is opened, or whenever the note content changes.
## Getting the note sections and slugs
Now that you have the current note, you'll need to extract the headers from that note in order to build the TOC from it. Since the note content is plain Markdown, there are several 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 any number of `#` 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:
```typescript
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:
```typescript
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, you will need the "slug" package to generate this for you, so install it using `npm i -s slug` from the root of your plugin directory.
Then this is the function you will need for Joplin, so copy it somewhere in your file:
```typescript
const nodeSlug = require('slug');
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('-');
}
```
And you will need a utility function to escape HTML. There are many packages to do this but for now you can simply use this:
```typescript
// From https://stackoverflow.com/a/6234804/561309
function escapeHtml(unsafe:string) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
```
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](https://joplinapp.org/plugins/api/classes/joplinviewspanels.html). Panels are a simple way to add custom content to the UI using HTML/CSS and JavaScript. First you would create the panel object and get back a view handler. Using this handler, you can set various properties such as the HTML content.
Here's how it could be done:
```typescript
joplin.plugins.register({
onStart: async function() {
// Create the panel object
const panel = await joplin.views.panels.create();
// Set some initial content while the TOC is being created
await joplin.views.panels.setHtml(panel, '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. For this use the `escapeHtml()` function you've added earlier.
itemHtml.push(`
<p class="toc-item" style="padding-left:${(header.level - 1) * 15}px">
<a class="toc-item-link" href="#" data-slug="${escapeHtml(slug)}">
${escapeHtml(header.text)}
</a>
</p>
`);
}
// Finally, insert all the headers in a container and set the webview HTML:
await joplin.views.panels.setHtml(panel, `
<div class="container">
${itemHtml.join('\n')}
</div>
`);
} else {
await joplin.views.panels.setHtml(panel, 'Please select a note to view the table of content');
}
}
// ...
},
});
```
Now run the plugin again and you should see the TOC dynamically updating 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:
```typescript
const panel = await joplin.views.panels.create();
// Add the CSS file to the view, right after it has been created:
await joplin.views.panels.addScript(panel, './webview.css');
```
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.
The CSS file below would give the view the correct font color and family, and the right background colour:
```css
/* In webview.css */
.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;
}
```
Try the plugin and the styling should be improved. You may also try to switch to dark or light mode and see the style being updated.
## 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:
```typescript
// In index.ts
const panel = joplin.views.createWebviewPanel();
await joplin.views.panels.addScript(panel, './webview.css');
await joplin.views.panels.addScript(panel, './webview.js'); // Add the JS file
```
To check that everything's working, let's create a simple event handler that display the header slug when clicked:
```javascript
// In webview.js
// There are many ways to listen to click events, you can even use
// something like jQuery or React. This is how it can be done using
// 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;
console.info('Clicked header slug: ' + slug);
}
});
```
If everything works well, you should now see the slug in the console 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:
```javascript
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).
```typescript
joplin.plugins.register({
onStart: async function() {
const panel = await joplin.views.panels.create();
// ...
await joplin.views.panels.onMessage(panel, (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](https://joplinapp.org/plugins/api/classes/joplin.html) for further information or head to the [development forum](https://discourse.joplinapp.org/c/development/6) for support.