1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-12 08:54:00 +02:00
joplin/docs/api/tutorials/toc_plugin/index.html
2020-10-09 18:38:28 +01:00

681 lines
24 KiB
HTML

<!doctype html>
<html>
<!--
!!! WARNING !!!
This file was auto-generated from readme/api/tutorials/toc_plugin.md and any manual change
made to it will be overwritten. To make a change to this file please modify
the source Markdown file:
https://github.com/laurent22/joplin/blob/master/readme/api/tutorials/toc_plugin.md
-->
<head>
<title>Creating a table of content plugin | Joplin</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://joplinapp.org/css/bootstrap.min.css">
<link rel="shortcut icon" type="image/x-icon" href="favicon.ico">
<!-- <link rel="stylesheet" href="https://joplinapp.org/css/fontawesome-all.min.css"> -->
<link rel="stylesheet" href="https://joplinapp.org/css/fork-awesome.min.css">
<script src="https://joplinapp.org/js/jquery-3.2.1.slim.min.js"></script>
<style>
body {
background-color: #F1F1F1;
color: #333333;
}
table {
margin-bottom: 1em;
}
td, th {
padding: .8em;
border: 1px solid #ccc;
}
.page-markdown table pre,
.page-markdown table blockquote {
margin-bottom: 0;
}
.page-markdown table pre,
.page-markdown table blockquote {
margin-bottom: 0;
}
.page-markdown table pre {
background-color: rgba(0,0,0,0);
border: none;
margin: 0;
padding: 0;
}
h1, h2 {
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-weight: 600;
font-size: 2em;
margin-bottom: 16px;
}
h2 {
font-size: 1.6em;
}
h3 {
font-size: 1.3em;
}
code {
color: black;
background-color: #eee;
border: 1px solid #ccc;
font-size: .85em;
}
pre code {
border: none;
}
pre {
font-size: .85em;
}
blockquote {
font-size: 1em;
color: #555;
};
#toc ul {
margin-bottom: 10px;
}
#toc > ul > li {
margin-bottom: 10px;
}
#toc {
padding-bottom: 1em;
}
.title {
display: flex;
align-items: center;
}
.title-icon {
display: flex;
height: 1em;
}
.title-text {
display: flex;
font-weight: normal;
margin-bottom: .2em;
margin-left: .5em;
}
.sub-title {
font-weight: normal;
}
.container {
background-color: white;
padding: 0;
box-shadow: 0 10px 20px #888888;
}
table.screenshots {
margin-top: 2em;
margin-bottom: 2em;
}
table.screenshots th {
height: 3em;
text-align: center;
}
table.screenshots th,
table.screenshots td {
border: 1px solid #C2C2C2;
}
img[align="left"] {
margin-right: 10px;
margin-bottom: 10px;
}
.mobile-screenshot {
height: 40em;
padding: 1em;
}
.cli-screenshot-wrapper {
background-color: black;
vertical-align: top;
padding: 1em 2em 1em 1em;
}
.cli-screenshot {
font-family: "Monaco", "Inconsolata", "CONSOLAS", "Deja Vu Sans Mono", "Droid Sans Mono", "Andale Mono", monospace;
background-color: black;
color: white;
border: none;
}
.cli-screenshot .prompt {
color: #48C2F0;
}
.top-screenshot {
margin-top: 2em;
text-align: center;
}
.header {
position: relative;
padding-left: 2em;
padding-right: 2em;
padding-top: 1em;
padding-bottom: 1em;
color: white;
background-color: #2B2B3D;
}
.header a h1 {
color: white;
}
.header a:hover {
text-decoration: none;
}
.content {
padding-left: 2em;
padding-right: 2em;
padding-bottom: 2em;
padding-top: 2em;
}
.forkme {
position: absolute;
right: 0;
top:0;
}
.nav-wrapper {
position: relative;
width: inherit;
}
.nav {
background-color: black;
display: table;
width: inherit;
}
.nav.sticky {
position:fixed;
top: 0;
width: inherit;
box-shadow: 0 0 10px #000000;
}
.nav a {
color: white;
display: inline-block;
padding: .6em .9em .6em .9em;
}
.nav ul {
padding-left: 2em;
margin-bottom: 0;
display: table-cell;
/* min-width: 250px; */
/* For GSoC: */
min-width: 470px;
}
.nav ul li {
display: inline-block;
padding: 0;
}
.nav li.selected {
background-color: #222;
font-weight: bold;
}
.nav-right {
display: table-cell;
width: 100%;
text-align: right;
vertical-align: middle;
line-height: 0;
}
.nav-right .share-btn {
display: none;
}
.nav-right .small-share-btn {
display: none;
}
.footer {
padding-top: 1em;
border-top: 1px solid #d4d4d4;
margin-top: 2em;
color: gray;
font-size: .9em;
}
a.heading-anchor {
display: inline-block;
opacity: 0;
width: 1.3em;
font-size: 0.7em;
margin-left: 0.4em;
line-height: 1em;
text-decoration: none;
transition: opacity 0.3s;
}
a.heading-anchor:hover,
h1:hover a.heading-anchor,
h2:hover a.heading-anchor,
h3:hover a.heading-anchor,
h4:hover a.heading-anchor,
h5:hover a.heading-anchor,
h6:hover a.heading-anchor {
opacity: 1;
}
.bottom-links {
display: flex;
justify-content: center;
border-top: 1px solid #d4d4d4;
margin-top: 30px;
padding-top: 25px;
}
@media all and (min-width: 400px) {
.nav-right .share-btn {
display: inline-block;
}
.nav-right .small-share-btn {
display: none;
}
}
</style>
</head>
<body>
<div class="container page-toc_plugin">
<div class="header">
<a class="forkme" href="https://github.com/laurent22/joplin"><img src="https://joplinapp.org/images/ForkMe.png"/></a>
<a href="https://joplinapp.org"><h1 class="title"><img class="title-icon" src="https://joplinapp.org/images/Icon512.png"><span class="title-text">Joplin</span></h1></a>
<p class="sub-title">An open source note taking and to-do application with synchronisation capabilities</p>
</div>
<div class="nav-wrapper">
<div class="nav">
<ul>
<li class=""><a href="https:&#x2F;&#x2F;joplinapp.org/" title="Home"><i class="fa fa-home"></i></a></li>
<li><a href="https://discourse.joplinapp.org" title="Forum">Forum</a></li>
<li><a class="help" href="#" title="Menu">Menu</a></li>
<!-- <li><a class="gsod" href="https://joplinapp.org/gsod2020/" title="Google Season of Docs 2020">GSoD 2020</a></li> -->
</ul>
<div class="nav-right">
<!--
<iframe class="share-btn" src="https://www.facebook.com/plugins/share_button.php?href=http%3A%2F%2Fjoplinapp.org&layout=button&size=small&mobile_iframe=true&width=60&height=20&appId" width="60" height="20" style="border:none;overflow:hidden" scrolling="no" frameborder="0" allowTransparency="true"></iframe>
<iframe class="share-btn" src="https://platform.twitter.com/widgets/tweet_button.html?url=http%3A%2F%2Fjoplinapp.org" width="62" height="20" title="Tweet" style="border: 0; overflow: hidden;"></iframe>
-->
<iframe class="share-btn share-btn-github" src="https://ghbtns.com/github-btn.html?user=laurent22&repo=joplin&type=star&count=true" frameborder="0" scrolling="0" width="100px" height="20px"></iframe>
</div>
</div>
</div>
<div class="content">
<div id="toc"><ul>
<li>
<p>Applications</p>
<ul>
<li><a href="https://joplinapp.org/desktop/">Desktop application</a></li>
<li><a href="https://joplinapp.org/mobile/">Mobile applications</a></li>
<li><a href="https://joplinapp.org/terminal/">Terminal application</a></li>
<li><a href="https://joplinapp.org/clipper/">Web Clipper</a></li>
</ul>
</li>
<li>
<p>Support</p>
<ul>
<li><a href="https://discourse.joplinapp.org">Joplin Forum</a></li>
<li><a href="https://joplinapp.org/markdown/">Markdown Guide</a></li>
<li><a href="https://joplinapp.org/e2ee/">How to enable end-to-end encryption</a></li>
<li><a href="https://joplinapp.org/conflict/">What is a conflict?</a></li>
<li><a href="https://joplinapp.org/debugging/">How to enable debug mode</a></li>
<li><a href="https://joplinapp.org/faq/">FAQ</a></li>
</ul>
</li>
<li>
<p>Joplin API</p>
<ul>
<li><a href="https://joplinapp.org/api/overview/">Joplin API Overview</a></li>
<li><a href="https://joplinapp.org/api/get_started/plugins/">Plugin development</a></li>
<li><a href="https://joplinapp.org/api/tutorials/toc_plugin/">Plugin tutorial</a></li>
<li><a href="https://joplinapp.org/plugins/api/classes/joplin.html">Plugin API</a></li>
<li><a href="https://joplinapp.org/api/references/rest_api/">Data API documentation</a></li>
</ul>
</li>
<li>
<p>Development</p>
<ul>
<li><a href="https://github.com/laurent22/joplin/blob/dev/BUILD.md">How to build the apps</a></li>
<li><a href="https://joplinapp.org/spec/">End-to-end encryption spec</a></li>
<li><a href="https://joplinapp.org/spec/history/">Note History spec</a></li>
<li><a href="https://joplinapp.org/spec/sync_lock/">Sync Lock spec</a></li>
<li><a href="https://joplinapp.org/spec/plugins/">Plugin Architecture spec</a></li>
</ul>
</li>
<li>
<p>Google Summer of Code 2020</p>
<ul>
<li><a href="https://joplinapp.org/gsoc2020/index/">Google Summer of Code 2020</a></li>
<li><a href="https://joplinapp.org/gsoc2020/ideas/">Project Ideas</a></li>
</ul>
</li>
<li>
<p>About</p>
<ul>
<li><a href="https://joplinapp.org/changelog/">Changelog (Desktop App)</a></li>
<li><a href="https://joplinapp.org/changelog_cli/">Changelog (CLI App)</a></li>
<li><a href="https://joplinapp.org/stats/">Stats</a></li>
<li><a href="https://joplinapp.org/donate/">Donate</a></li>
</ul>
</li>
</ul>
</div>
<h1>Creating a table of content plugin<a name="creating-a-table-of-content-plugin" href="#creating-a-table-of-content-plugin" class="heading-anchor">🔗</a></h1>
<p>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.</p>
<p>Through this tutorial you will learn about several aspect of the Joplin API, including:</p>
<ul>
<li>The plugin API</li>
<li>How to create a webview</li>
<li>How to listen to changes in the user interface</li>
</ul>
<p>## Setting up your environment</p>
<p>Before getting any further, make sure your environment is setup correctly as described in the <a href="https://github.com/laurent22/joplin/blob/dev/readme/api/get_started/plugins/">Get Started guide</a>.</p>
<h2>Registering the plugin<a name="registering-the-plugin" href="#registering-the-plugin" class="heading-anchor">🔗</a></h2>
<p>All plugins must <a href="https://joplinapp.org/plugins/api/classes/joplinplugins.html">register themselves</a> and declare what events they can handle. To do so, open <code>src/index.ts</code> and register the plugin as below. We'll also need to run some initialisation code when the plugin starts, so add the <code>onStart()</code> event handler too:</p>
<pre><code class="language-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!');
},
});
</code></pre>
<p>If you now build the plugin and try to run it in Joplin, you should see the message <code>TOC plugin started!</code> in the dev console.</p>
<h2>Getting the current note<a name="getting-the-current-note" href="#getting-the-current-note" class="heading-anchor">🔗</a></h2>
<p>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 <a href="https://joplinapp.org/plugins/api/classes/joplinworkspace.html">workspace API</a>, which provides information about the active content being edited.</p>
<p>So within the <code>onStart()</code> event handler, add the following:</p>
<pre><code class="language-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(() =&gt; {
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(() =&gt; {
updateTocView();
});
// Also update the TOC when the plugin starts
updateTocView();
},
});
</code></pre>
<p>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.</p>
<h2>Getting the note sections and slugs<a name="getting-the-note-sections-and-slugs" href="#getting-the-note-sections-and-slugs" class="heading-anchor">🔗</a></h2>
<p>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 <code>#</code> followed by a space. Any such line should be a header.</p>
<p>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:</p>
<pre><code class="language-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;
}
</code></pre>
<p>Then call this function from your event handler:</p>
<pre><code class="language-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');
}
}
// ...
},
});
</code></pre>
<p>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 &quot;My Header&quot; is converted to &quot;my-header&quot;. 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 &quot;slug&quot; package to generate this for you, so install it using <code>npm i -s slug</code> from the root of your plugin directory.</p>
<p>Then this is the function you will need for Joplin, so copy it somewhere in your file:</p>
<pre><code class="language-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 &gt; 1) output.push(num);
slugs[s] = num + 1;
return output.join('-');
}
</code></pre>
<p>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:</p>
<pre><code class="language-typescript">// From https://stackoverflow.com/a/6234804/561309
function escapeHtml(unsafe:string) {
return unsafe
.replace(/&amp;/g, &quot;&amp;amp;&quot;)
.replace(/&lt;/g, &quot;&amp;lt;&quot;)
.replace(/&gt;/g, &quot;&amp;gt;&quot;)
.replace(/&quot;/g, &quot;&amp;quot;&quot;)
.replace(/'/g, &quot;&amp;#039;&quot;);
}
</code></pre>
<p>Again try to run the plugin and if you select a note with multiple headers, you should see the header list in the console.</p>
<h2>Creating a webview<a name="creating-a-webview" href="#creating-a-webview" class="heading-anchor">🔗</a></h2>
<p>In order to display the TOC in Joplin, you will need a <a href="https://joplinapp.org/plugins/api/classes/joplinviewspanels.html">webview panel</a>. 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.</p>
<p>Here's how it could be done:</p>
<pre><code class="language-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 &quot;data&quot; 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(`
&lt;p class=&quot;toc-item&quot; style=&quot;padding-left:${(header.level - 1) * 15}px&quot;&gt;
&lt;a class=&quot;toc-item-link&quot; href=&quot;#&quot; data-slug=&quot;${escapeHtml(slug)}&quot;&gt;
${escapeHtml(header.text)}
&lt;/a&gt;
&lt;/p&gt;
`);
}
// Finally, insert all the headers in a container and set the webview HTML:
await joplin.views.panels.setHtml(panel, `
&lt;div class=&quot;container&quot;&gt;
${itemHtml.join('\n')}
&lt;/div&gt;
`);
} else {
await joplin.views.panels.setHtml(panel, 'Please select a note to view the table of content');
}
}
// ...
},
});
</code></pre>
<p>Now run the plugin again and you should see the TOC dynamically updating as you change notes.</p>
<h2>Styling the view<a name="styling-the-view" href="#styling-the-view" class="heading-anchor">🔗</a></h2>
<p>In order to better integrate the TOC to Joplin, you might want to style it using CSS. To do so, first add a <code>webview.css</code> file next to <code>index.ts</code>, then you will need to let Joplin know about this file. This is done using the <code>addScript()</code> function (which is also used to add JavaScript files as we'll see later), like so:</p>
<pre><code class="language-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');
</code></pre>
<p>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.</p>
<p>The CSS file below would give the view the correct font color and family, and the right background colour:</p>
<pre><code class="language-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;
}
</code></pre>
<p>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.</p>
<h2>Making the webview interactive<a name="making-the-webview-interactive" href="#making-the-webview-interactive" class="heading-anchor">🔗</a></h2>
<p>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 <code>webview.js</code> file next to <code>index.ts</code>, then add the script to the webview:</p>
<pre><code class="language-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
</code></pre>
<p>To check that everything's working, let's create a simple event handler that display the header slug when clicked:</p>
<pre><code class="language-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 =&gt; {
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);
}
});
</code></pre>
<p>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.</p>
<h2>Passing messages between the webview and the plugin<a name="passing-messages-between-the-webview-and-the-plugin" href="#passing-messages-between-the-webview-and-the-plugin" class="heading-anchor">🔗</a></h2>
<p>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.</p>
<p>From within a webview, you have access to the webviewApi object, which among others has a <code>postMessage()</code> function you can use to send a message to the plugin. Let's use this to post the slug info to the plugin:</p>
<p>Change <code>webview.js</code> like so:</p>
<pre><code class="language-javascript">document.addEventListener('click', event =&gt; {
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,
});
}
});
</code></pre>
<p>Then from the plugin, in <code>src/index.ts</code>, you can listen to this message using the <code>onMessage()</code> handler. Then from this handler, you can call the <code>scrollToHash</code> command and pass it the slug (or hash).</p>
<pre><code class="language-typescript">joplin.plugins.register({
onStart: async function() {
const panel = await joplin.views.panels.create();
// ...
await joplin.views.panels.onMessage(panel, (message) =&gt; {
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,
})
}
});
// ...
}
// ...
</code></pre>
<p>And that's it! If you run this code you should now have a fully functional TOC. The full source code is available there:</p>
<p><a href="https://github.com/laurent22/joplin/tree/dev/CliClient/tests/support/plugins/toc/">https://github.com/laurent22/joplin/tree/dev/CliClient/tests/support/plugins/toc/</a></p>
<p>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 <a href="https://joplinapp.org/plugins/api/classes/joplin.html">plugin API</a> for further information or head to the <a href="https://discourse.joplinapp.org/c/development/6">development forum</a> for support.</p>
<div class="bottom-links">
<a href="https://github.com/laurent22/joplin/blob/master/readme/api/tutorials/toc_plugin.md">
<i class="fa fa-github"></i> Improve this doc
</a>
</div>
<script>
function stickyHeader() {
return; // Disabled
if ($(window).scrollTop() > 179) {
$('.nav').addClass('sticky');
} else {
$('.nav').removeClass('sticky');
}
}
$('#toc').hide();
$('.help').click(function(event) {
event.preventDefault();
$('#toc').show();
});
$(window).scroll(function() {
stickyHeader();
});
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-103586105-1', 'auto');
ga('send', 'pageview');
</script>
<div class="footer">
Copyright (c) 2016-2020 Laurent Cozic
</div>
</body>
</html>