Écrire votre premier plugin
Un plugin, c’est un fichier et une fonction globale. Ce guide en construit un vrai de bout en bout — un badge de temps de lecture avec un bouton de barre d’outils, des options de configuration et une API publique — et explique les points sur lesquels il est facile de se tromper. Le reste de la documentation vous dit ce que fait chaque méthode ; celle-ci vous dit comment les pièces s’assemblent.
La forme d’un plugin
Il n’y a ni manifeste, ni registre, ni étape de build. Un plugin est une fonction constructeur globale affectée à une clé plugin_* de RTE_DefaultConfig. Quand un éditeur est construit, le cœur parcourt la configuration à la recherche des clés qui commencent par plugin_, appelle new sur chacune, puis appelle deux méthodes de cycle de vie facultatives sur l’instance :
InitConfig(config)— s’exécute avant que l’éditeur n’existe. Les valeurs par défaut et l’enregistrement dans la barre d’outils vont ici. Il n’y a pas encore d'editor; chercher à en utiliser un est l’erreur la plus courante au début.InitEditor(editor)— s’exécute une fois l’éditeur créé. L’API publique, les commandes et les gestionnaires d’événements vont ici.
Le plugin complet
Enregistrez-le n’importe où votre application sert du JavaScript — /js/readingtime.js dans l’exemple ci-dessous. Ne le placez pas dans /richtexteditor/plugins/ : ce répertoire appartient à l’éditeur et il est remplacé lors des mises à jour. Le plugin ajoute un bouton de barre d’outils qui insère un badge de temps de lecture et le tient à jour à mesure que le document change.
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;
}
}Le même fichier, avec tous ses commentaires, est servi à l’adresse /js/readingtime.js — c’est exactement le fichier avec lequel ce guide a été vérifié : vous pouvez donc le charger tel quel et cliquer sur le bouton.
Le charger
L’ordre des scripts compte, et c’est toute l'« installation » : votre fichier doit s’exécuter après rte-config.js (qui crée RTE_DefaultConfig) et avant rte.js (qui lit la configuration et instancie les plugins).
<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 againQuatre points sur lesquels il est facile de se tromper
1. Enregistrer le bouton dans les deux préréglages de barre d’outils
config.toolbar vaut par défaut "default", qui correspond à toolbar_default. Si vous ajoutez votre commande uniquement à toolbar_full, le bouton est inaccessible pour tous les hôtes qui n’ont jamais configuré de barre d’outils — et rien ne signale d’erreur. Un plugin livré dans ce produit est resté invisible pendant tout un cycle de publication exactement pour cette raison.
2. insertHTML() insère à l’intérieur du bloc du curseur
C’est le bon appel pour du contenu en ligne. Pour un bloc, il produit une imbrication invalide qui s’affiche quand même : il est donc facile de la livrer.
// 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>Un paragraphe dans un paragraphe. La première modification de l’hôte qui réécrit le textContent du paragraphe extérieur supprime votre bloc avec lui. Si ce que vous insérez est un bloc, placez-le vous-même là où vivent les blocs, comme le fait l’exemple avec editable.insertBefore(...).
3. editor.fireChange() n’existe pas
Le cœur ne la définit pas. Plusieurs plugins l’appellent derrière une garde typeof editor.fireChange === "function", ce qui signifie que ces appels ne font silencieusement rien — c’est à cause de la garde que personne ne l’a remarqué. Ne vous appuyez pas dessus. Un plugin qui modifie directement le DOM est responsable de la mise à jour de son propre état dérivé. L’événement change lui-même existe bel et bien et se déclenche lors de l’édition par l’utilisateur : c’est lui qu’écoute l’exemple.
4. Décider si ce que vous insérez est du contenu ou de l’interface
C’est la question à laquelle chaque plugin doit répondre. Le badge de temps de lecture est du contenu : l’auteur l’a demandé, et il doit survivre à un enregistrement — il va donc dans le document et apparaît dans getHTML().
Un contrôle au survol, une poignée de glissement ou un marqueur de sélection relève de l'interface. L’interface ne doit jamais atteindre getHTML(). Cet éditeur a livré un bug exactement de ce type : un contrôle « copier le lien » était ajouté dans chaque titre, sérialisé dans tout ce que l’hôte enregistrait, et revenait au chargement suivant. Si votre plugin injecte un élément d’interface plutôt que du contenu, retirez-le en sortie.
5. Un simple élément <style> est ignoré sous une CSP stricte
style-src 'self' régit les éléments <style>. Sous cette directive, le navigateur conserve votre élément dans le DOM mais abandonne ses règles : le plugin s’affiche sans style, sans qu’aucune exception ne soit levée — l’indice est que document.styleSheets.length ne correspond pas au nombre de balises <style>, et non une exception. Insérez l’élément exactement comme d’habitude, puis appelez editor.ensureStyleSheetLive(doc, el, id, csstext) juste après l’avoir ajouté. Cette méthode ne fait rien du tout, sauf si la politique a effectivement vidé l’élément ; dans ce cas, elle réinjecte le même CSS via le CSSOM, que la CSP n’intercepte pas.
N’utilisez pas adoptedStyleSheets vous-même. Une feuille de style adoptée se place après toutes les feuilles de style du document : y mettre vos règles systématiquement leur donne la priorité sur le contentCssUrl propre à la page hôte — votre plugin se mettrait à écraser le style du contenu du client sur des pages qui n’ont même pas de CSP. Conserver l’élément comme voie principale préserve la cascade.
Le tester
Pilotez l’éditeur comme le ferait une personne. L’actualisation du badge de l’exemple est déclenchée par l’événement change, et un appel synthétique ne le déclenche pas — la première version de notre propre test appelait fireChange(), ne voyait aucune actualisation et accusait le plugin. Tapez de vraies frappes clavier, puis faites vos assertions sur getHTML() plutôt que sur le DOM vivant lorsque ce qui vous importe, c’est ce qui est enregistré.
Pour aller plus loin
- Toutes les méthodes de l’éditeur — ce que vous pouvez appeler depuis
InitEditor. - Tous les événements — le bus transporte
change,selectionchange,keydown,keyupet tous lesexec_command_*. L’événementinputn’y figure pas. Pour les écouteurs DOM natifs, depuis core 2.6.10, utilisezeditor.bindEditableEvent("keydown", fn)oueditor.bindDocumentEvent("input", fn, true), et non avecaddEventListenersureditor.getEditable()oueditor.getDocument(): la zone d’édition peut être déplacée après le démarrage de votre plugin (certains plugins intégrés l’enveloppent dans un panneau), un cadre déplacé reçoit un nouveau document, et les écouteurs ajoutés directement restent sur l’ancien et ne se déclenchent jamais. L’éditeur rattache tout ce qui est lié via ces deux méthodes et déclenchedocumentreplacedlorsque cela se produit. Sur les cœurs plus anciens, lisezeditor.getEditable()au moment où votre gestionnaire s’exécute plutôt que de conserver une référence obtenue dansInitEditor. - Référence de configuration — toutes les options intégrées, et la convention de nommage que vos propres options doivent suivre.
- Éléments de barre d’outils — les noms de commandes déjà utilisés.