← Gesamte Dokumentation

Ihr erstes Plugin schreiben

Ein Plugin ist eine Datei und eine globale Funktion. Diese Anleitung baut ein echtes Plugin von Anfang bis Ende – ein Badge mit der Lesezeit, mit Symbolleisten-Schaltfläche, Konfigurationsoptionen und öffentlicher API – und erklärt die Stellen, an denen man leicht Fehler macht. Der Rest der Dokumentation sagt Ihnen, was jede Methode tut; diese Seite zeigt, wie die Teile zusammenpassen.

Der Aufbau eines Plugins

Es gibt kein Manifest, keine Registry und keinen Build-Schritt. Ein Plugin ist eine globale Konstruktorfunktion, die einem Schlüssel plugin_* in RTE_DefaultConfig zugewiesen wird. Beim Erstellen eines Editors durchläuft der Kern die Konfiguration nach Schlüsseln, die mit plugin_ beginnen, ruft für jeden new auf und dann zwei optionale Lebenszyklusmethoden auf der Instanz:

  • InitConfig(config) – läuft, bevor der Editor existiert. Standardwerte und die Registrierung in der Symbolleiste gehören hierher. Es gibt noch keinen editor; danach zu greifen, ist der häufigste erste Fehler.
  • InitEditor(editor) – läuft, sobald der Editor existiert. Öffentliche API, Befehle und Ereignishandler gehören hierher.

Das vollständige Plugin

Speichern Sie es an einer beliebigen Stelle, von der Ihre App JavaScript ausliefert – im Beispiel unten /js/readingtime.js. Legen Sie es nicht in /richtexteditor/plugins/ ab: Dieses Verzeichnis gehört dem Editor und wird bei einem Upgrade ersetzt. Das Plugin fügt eine Symbolleisten-Schaltfläche hinzu, die ein Badge mit der Lesezeit einfügt und es aktuell hält, während sich das Dokument ändert.

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;
    }
}

Dieselbe Datei mit allen Kommentaren wird unter /js/readingtime.js ausgeliefert – genau die Datei, an der diese Anleitung geprüft wurde, sodass Sie sie unverändert laden und auf die Schaltfläche klicken können.

Laden

Die Reihenfolge der Skripte ist wichtig, und sie ist die ganze „Installation“: Ihre Datei muss nach rte-config.js laufen (das RTE_DefaultConfig erstellt) und vor rte.js (das die Konfiguration liest und die Plugins instanziiert).

<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<!-- Optional, and only present in the npm package and on this site: the download
     packages ship no standalone rte-config.js, because rte.js already contains the
     defaults. Omit this line if the file is not in your copy. -->
<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

Vier Dinge, bei denen man leicht Fehler macht

1. In beiden Symbolleisten-Vorgaben registrieren

config.toolbar ist standardmäßig "default", was zu toolbar_default aufgelöst wird. Hängen Sie Ihren Befehl nur an toolbar_full an, ist die Schaltfläche für jeden Host unerreichbar, der nie eine Symbolleiste konfiguriert hat – und nichts meldet einen Fehler. Ein ausgeliefertes Plugin dieses Produkts war genau aus diesem Grund einen ganzen Release-Zyklus lang unsichtbar.

2. insertHTML() fügt innerhalb des Blocks am Cursor ein

Für Inline-Inhalte ist das der richtige Aufruf. Für einen Block erzeugt er eine ungültige Verschachtelung, die trotzdem dargestellt wird, und deshalb rutscht der Fehler leicht in ein Release:

// 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>

Ein Absatz in einem Absatz. Die erste Änderung des Hosts, die den textContent des äußeren Absatzes neu schreibt, löscht Ihren Block mit. Wenn Sie einen Block einfügen, platzieren Sie ihn selbst dort, wo Blöcke hingehören, so wie das Beispiel mit editable.insertBefore(...).

3. Es gibt kein editor.fireChange()

Der Kern definiert es nicht. Mehrere Plugins rufen es hinter einer Prüfung typeof editor.fireChange === "function" auf, sodass diese Aufrufe stillschweigend nichts tun – wegen dieser Prüfung blieb es unbemerkt. Bauen Sie nicht darauf auf. Ein Plugin, das das DOM direkt verändert, ist selbst dafür verantwortlich, seinen abgeleiteten Zustand zu aktualisieren. Das Ereignis change selbst gibt es wirklich, und es wird bei Bearbeitung durch den Benutzer ausgelöst; darauf reagiert das Beispiel.

4. Entscheiden, ob das Eingefügte Inhalt oder Oberfläche ist

Diese Frage muss jedes Plugin beantworten. Das Badge mit der Lesezeit ist Inhalt: Der Autor hat es angefordert, und es soll ein Speichern überstehen – also gehört es in das Dokument und erscheint in getHTML().

Ein Element, das beim Überfahren erscheint, ein Ziehgriff oder eine Auswahlmarkierung ist Oberfläche. Oberfläche darf nie in getHTML() gelangen. Dieser Editor hatte genau so einen Fehler: Ein Element „Link kopieren“ wurde in jede Überschrift eingefügt und in alles serialisiert, was der Host speicherte, und kam beim nächsten Laden zurück. Fügt Ihr Plugin ein Bedienelement statt Inhalt ein, entfernen Sie es bei der Ausgabe.

5. Ein einfaches <style>-Element wird unter einer strengen CSP verworfen

style-src 'self' regelt <style>-Elemente. Darunter behält der Browser Ihr Element im DOM, verwirft aber seine Regeln, sodass das Plugin ungestaltet erscheint, ohne dass eine Ausnahme fliegt – erkennbar ist das daran, dass document.styleSheets.length nicht zur Anzahl der <style>-Tags passt, nicht an einer Ausnahme. Fügen Sie das Element ganz normal ein und rufen Sie direkt danach editor.ensureStyleSheetLive(doc, el, id, csstext) auf. Die Methode tut überhaupt nichts, sofern die Richtlinie das Element nicht tatsächlich geleert hat; in diesem Fall fügt sie dasselbe CSS über das CSSOM erneut ein, das die CSP nicht abfängt.

Greifen Sie nicht selbst zu adoptedStyleSheets. Ein übernommenes Stylesheet wird nach jedem Stylesheet im Dokument einsortiert; legen Sie Ihre Regeln dort ohne Bedingung ab, schlagen sie die eigene contentCssUrl der Host-Seite – Ihr Plugin würde dann auf Seiten ganz ohne CSP die Inhaltsgestaltung des Kunden überschreiben. Nur wenn das Element der primäre Weg bleibt, bleibt die Kaskade erhalten.

Testen

Bedienen Sie den Editor so, wie es ein Mensch tut. Die Aktualisierung des Badges im Beispiel wird durch das Ereignis change ausgelöst, und ein synthetischer Aufruf löst es nicht aus – die erste Version unseres eigenen Tests rief fireChange() auf, sah keine Aktualisierung und gab dem Plugin die Schuld. Tippen Sie echte Tastenanschläge und prüfen Sie dann getHTML() statt des laufenden DOM, wenn es Ihnen darauf ankommt, was gespeichert wird.

Wie es weitergeht

  • Alle Editor-Methoden – was Sie aus InitEditor aufrufen können.
  • Alle Ereignisse – der Bus überträgt change, selectionchange, keydown, keyup und jedes exec_command_*. Das Ereignis input läuft nicht darüber. Native DOM-Listener binden Sie ab Core 2.6.10 mit editor.bindEditableEvent("keydown", fn) oder editor.bindDocumentEvent("input", fn, true), nicht mit addEventListener auf editor.getEditable() oder editor.getDocument(): Der Bearbeitungsbereich kann verschoben werden, nachdem Ihr Plugin gestartet ist (manche eingebauten Plugins legen ihn in ein Panel), ein verschobener Rahmen erhält ein neues Dokument, und direkt hinzugefügte Listener bleiben am alten und werden nie ausgelöst. Der Editor hängt alles, was über diese beiden Methoden gebunden ist, neu an und löst dabei documentreplaced aus. Bei älteren Cores lesen Sie editor.getEditable(), wenn Ihr Handler läuft, statt eine Referenz aus InitEditor aufzubewahren.
  • Konfigurationsreferenz – jede eingebaute Option und die Namenskonvention, der Ihre eigenen Optionen folgen sollten.
  • Symbolleisteneinträge – die bereits vergebenen Befehlsnamen.