Tintero Developers

Your first plugin

From an empty folder to a panel running inside Tintero, with nothing installed but a zip command.

On this page

A plugin is a folder with two files in it, zipped. No build step, no toolchain, no account. You can have one running in the time it takes to read this page.

What you are building

A sidebar panel that counts the words in whatever document is open, and updates when you switch to another one.

word-count/
  plugin.json    ← who you are, where you show up, what you are allowed to touch
  plugin.js      ← all your JavaScript
  plugin.css     ← optional, loaded for you
  icon.svg       ← optional, SVG only

1. The manifest

Every plugin declares itself in plugin.json.

{
  "id": "com.example.word-count",
  "name": "Word Count",
  "version": "1.0.0",
  "description": "A live word count for the document you are writing",
  "author": { "name": "Your Name" },
  "license": "MIT",
  "type": "sidebar-panel",
  "surfaces": ["sidebar"],
  "main": "plugin.js",
  "scopes": ["ui.sidebar", "editor.read"],
  "ui": { "sidebar": { "label": "Words", "tooltip": "Live word count", "width": 260 } }
}

Two of those fields do most of the work:

  • surfaces is where you show up. sidebar is the narrow panel. There are three other places, covered in Surfaces.
  • scopes is what you are asking permission to do. ui.sidebar lets you draw a panel, editor.read lets you read what someone is writing. They approve the list once, at install, and every call you make afterwards is checked against it.

type is the older version of surfaces, from back when a plugin could only be one thing. It is still required, so write both. If they disagree, surfaces wins.

2. The code

(function () {
  const plugin = new TinteroPlugin();

  plugin.onActivate = async function () {
    document.body.innerHTML = `
      <style>
        .box { padding: 10px; font-family: inherit; }
        .count { font-size: 28px; font-weight: 600; color: var(--accent-color, #c98a48); }
        .label { color: var(--text-secondary, #a98e6b); font-size: 12px; }
      </style>
      <div class="box">
        <div class="count" id="count">0</div>
        <div class="label">words</div>
      </div>`;

    const refresh = async () => {
      const words = await tintero.editor.getWordCount();
      document.getElementById('count').textContent = words;
    };

    await refresh();
    tintero.events.on('editor.activeDocumentChanged', refresh);
  };

  registerPlugin(plugin);
})();

Three things are worth pointing at.

new TinteroPlugin() makes your plugin object and registerPlugin(plugin) hands it to the app. Call that exactly once, right at the end. Call it twice and the second one is ignored, with a warning in the console.

onActivate is where you build your interface and load your data. It runs once every time your plugin starts, in each place it is showing.

tintero is how you talk to the app. Everything on it returns a promise, because every call is a message out and an answer back.

3. Package it

cd word-count && zip ../word-count.zip plugin.json plugin.js

That is the entire build. If you have a plugin.css, add it too: a stylesheet named after your main file gets loaded automatically.

4. Load it

In Tintero: Settings → Plugins → Load local plugin (dev), then pick the zip.

You get the permission dialog, listing exactly what your manifest asked for. Approve it and your panel shows up in the sidebar.

Tintero's install dialog, listing each requested permission with a risk level beside it
The install dialog for a real plugin. Each line is one entry from your scopes array, and the risk rating next to it is Tintero's, not the author's.

5. Iterate

Change your code, rebuild the zip, press Reload. Turn on Auto-reload and you can stop pressing anything: Tintero watches the file and restarts the plugin whenever it changes.

What to know before you go further

Your code is boxed in. No reaching the app’s page, no cookies, no localStorage, no network from inside your frame. Use tintero.storage to keep things and tintero.net.fetch to get online. Both are in the security model.

Every call is checked. Permissions every time, plus 100 calls / 1 s, an argument cap of 5 MB, and a 30 s timeout. Ordinary code never gets close to any of them.

Document text is not HTML. It is ProseMirror JSON, handed to you as a string, with converters for text, HTML and Markdown. See Document content.

Grab the type definitions. tintero-plugin-sdk.d.ts describes the whole API and the manifest. Drop it next to your source and your editor will catch a misspelled permission long before you package anything.

const manifest: TinteroSDK.PluginManifest = {
  scopes: ['project.write.fileContents'],
  //       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  //       Type '"project.write.fileContents"' is not assignable to type 'PluginScope'.
  //       Did you mean '"project.write.fileContent"'?
};

Where next