Write Your First Plugin

A plugin is one file and one global function. This guide builds a real one end to end — a reading-time badge with a toolbar button, config options and a public API — and explains the parts that are easy to get wrong. The rest of the docs tell you what each method does; this one tells you how the pieces fit.

The shape of a plugin

There is no manifest, no registry and no build step. A plugin is a global constructor function assigned to a plugin_* key on RTE_DefaultConfig. When an editor is constructed, the core walks the config for keys beginning with plugin_ and calls new on each one, then calls two optional lifecycle methods on the instance:

  • InitConfig(config) — runs before the editor exists. Defaults and toolbar registration go here. There is no editor yet; reaching for one is the most common first mistake.
  • InitEditor(editor) — runs once the editor exists. Public API, commands and event handlers go here.

The whole plugin

Save it anywhere your app serves JavaScript from — /js/readingtime.js in the example below. Do not put it in /richtexteditor/plugins/: that directory belongs to the editor and is replaced when you upgrade. It adds a toolbar button that inserts a reading-time badge and keeps it current as the document changes.

if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};

// The core walks config for keys starting with "plugin_" and calls new on each
// value. This line is the whole discovery mechanism.
RTE_DefaultConfig.plugin_readingtime = RTE_Plugin_ReadingTime;

function RTE_Plugin_ReadingTime() {
    var obj = this;
    var config, editor, timer = 0;

    obj.PluginName = "ReadingTime";

    // Runs BEFORE the editor exists: defaults and toolbar registration only.
    obj.InitConfig = function (argconfig) {
        config = argconfig;
        if (config.readingTimeEnabled === false) return;

        if (typeof config.readingTimeWordsPerMinute !== "number") config.readingTimeWordsPerMinute = 200;
        config.readingTimeLabel = config.readingTimeLabel || "min read";

        appendToolbarCommand("toolbar_default", "#{readingtime}");
        appendToolbarCommand("toolbar_full", "#{readingtime}");
    };

    // Runs once the editor exists: public API, commands, event handlers.
    obj.InitEditor = function (argeditor) {
        editor = argeditor;
        if (config.readingTimeEnabled === false) return;

        editor.readingTime = {
            estimate: function () { return estimate(); },
            insert:   function () { return insertBadge(); },
            refresh:  function () { return refreshBadge(); },
            remove:   function () { return removeBadge(); }
        };

        editor.toolbarFactoryMap = editor.toolbarFactoryMap || {};
        editor.toolbarFactoryMap["readingtime"] = function (cmd) {
            return editor.createToolbarButton(cmd);
        };

        editor.attachEvent("exec_command_readingtime", function (state) {
            state.returnValue = true;
            state.stopBubble = true;
            insertBadge();
        });

        // Coalesced with setTimeout, not requestAnimationFrame: rAF does not
        // fire in a background tab, so the badge would stop updating there.
        editor.attachEvent("change", function () {
            clearTimeout(timer);
            timer = setTimeout(refreshBadge, 300);
        });
    };

    function appendToolbarCommand(toolbar, item) {
        if (!config[toolbar]) return;
        if (config[toolbar].indexOf(item) !== -1) return;   // running twice would add two buttons
        config[toolbar] = config[toolbar] + item;
    }

    function estimate() {
        var text = editor && editor.getText ? editor.getText() : "";
        var words = String(text).split(/\s+/).filter(function (w) { return w.length > 0; }).length;
        return { words: words, minutes: Math.max(1, Math.ceil(words / (config.readingTimeWordsPerMinute || 200))) };
    }

    function findBadge() {
        var editable = editor && editor.getEditable ? editor.getEditable() : null;
        return editable ? editable.querySelector("[data-reading-time]") : null;
    }

    function insertBadge() {
        if (findBadge()) return refreshBadge();

        var editable = editor.getEditable();
        if (!editable) return null;
        var est = estimate();

        // Block-level content is placed at block level BY HAND. See the note
        // below on why editor.insertHTML() is wrong for this.
        var doc = editable.ownerDocument;
        var badge = doc.createElement("p");
        badge.setAttribute("data-reading-time", "1");
        var em = doc.createElement("em");
        em.textContent = est.minutes + " " + config.readingTimeLabel;
        badge.appendChild(em);
        editable.insertBefore(badge, editable.firstChild);

        editor.focus();
        return est;
    }

    function refreshBadge() {
        var badge = findBadge();
        if (!badge) return null;
        var est = estimate();
        var em = badge.querySelector("em") || badge;
        var next = est.minutes + " " + config.readingTimeLabel;
        // Only write when the value changed: rewriting identical text on every
        // change event moves the caret and floods the undo stack.
        if (em.textContent !== next) em.textContent = next;
        return est;
    }

    function removeBadge() {
        var badge = findBadge();
        if (!badge) return false;
        badge.parentNode.removeChild(badge);
        return true;
    }
}

The same file with its full commentary is served at /js/readingtime.js — it is the exact file this guide was verified against, so you can load it as-is and click the button.

Loading it

Script order matters, and it is the whole of the “installation”: your file must run after rte-config.js (which creates RTE_DefaultConfig) and before rte.js (which reads the config and instantiates plugins).

<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte-config.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>

<!-- Your plugin lives in YOUR asset folder, not in /richtexteditor/plugins/ -
     that directory is ours and is replaced on upgrade. Load it after
     rte-config.js (which creates RTE_DefaultConfig) and before rte.js (which
     reads the config and instantiates plugins). -->
<script src="/js/readingtime.js"></script>

<script src="/richtexteditor/rte.js"></script>
var cfg = RTE_CreateConfig();
cfg.readingTimeWordsPerMinute = 240;     // your defaults win over the plugin's
cfg.readingTimeLabel = "min to read";

var editor = new RichTextEditor("#editor", cfg);

editor.readingTime.insert();             // add the badge
editor.readingTime.estimate();           // { words: 420, minutes: 2 }
editor.readingTime.remove();             // take it out again

Four things that are easy to get wrong

1. Register in both toolbar presets

config.toolbar defaults to "default", which resolves to toolbar_default. If you append your command only to toolbar_full, the button is unreachable for every host that never configured a toolbar — and nothing reports an error. A shipped plugin in this product spent a full release cycle invisible for exactly that reason.

2. insertHTML()inserts inside the caret’s block

It is the right call for inline content. For a block it produces invalid nesting that still renders, so it is easy to ship:

// WRONG for a block-level element:
editor.insertHTML('<p data-reading-time="1">3 min read</p>');

// produces, when the caret is inside a paragraph:
<p><p data-reading-time="1">3 min read</p>alpha beta gamma</p>

A paragraph inside a paragraph. The first host edit that rewrites the outer paragraph’s textContent deletes your block along with it. If what you insert is a block, place it where blocks live yourself, as the example does with editable.insertBefore(...).

3. There is no editor.fireChange()

The core does not define it. Several plugins call it behind a typeof editor.fireChange === "function" guard, which means those calls silently do nothing — the guard is why it went unnoticed. Do not build on it. A plugin that mutates the DOM directly is responsible for updating its own derived state. The change event itself is real and does fire on user editing, which is what the example listens to.

4. Decide whether what you insert is content or chrome

This is the question every plugin has to answer. The reading-time badge is content: the author asked for it, and it should survive a save — so it goes in the document and appears in getHTML().

A hover control, a drag handle or a selection marker is chrome. Chrome must never reach getHTML(). This editor shipped a bug of exactly that shape: a “copy link” control was appended inside every heading and serialised into whatever the host saved, coming back on the next load. If your plugin injects an affordance rather than content, strip it on output.

Testing it

Drive the editor the way a person does. The example’s badge refresh is triggered by the change event, and a synthetic call will not raise it — the first version of our own test called fireChange(), saw no refresh, and blamed the plugin. Type real keystrokes, then assert on getHTML() rather than on the live DOM when what you care about is what gets saved.

Where to go next

  • All editor methods — what you can call from InitEditor.
  • All events — the bus carries ready, change, selectionchange, keyup and every exec_command_*. Note keydown is not on it; bind that to editor.getEditable() directly.
  • Configuration reference — every built-in option, and the naming convention your own options should follow.
  • Toolbar items — the command names already taken.