Escribe tu primer plugin
Un plugin es un archivo y una función global. Esta guía construye uno real de principio a fin (una etiqueta de tiempo de lectura con un botón en la barra de herramientas, opciones de configuración y una API pública) y explica las partes en las que es fácil equivocarse. El resto de la documentación te dice qué hace cada método; esta te dice cómo encajan las piezas.
La forma de un plugin
No hay manifiesto, ni registro, ni paso de compilación. Un plugin es una función constructora global asignada a una clave plugin_* de RTE_DefaultConfig. Cuando se construye un editor, el core recorre la configuración en busca de claves que empiecen por plugin_, llama a new con cada una y luego llama a dos métodos opcionales del ciclo de vida de la instancia:
InitConfig(config): se ejecuta antes de que exista el editor. Aquí van los valores predeterminados y el registro en la barra de herramientas. Todavía no hay ningúneditor; intentar usarlo es el primer error más habitual.InitEditor(editor): se ejecuta cuando ya existe el editor. Aquí van la API pública, los comandos y los controladores de eventos.
El plugin completo
Guárdalo en cualquier lugar desde el que tu aplicación sirva JavaScript: /js/readingtime.js en el ejemplo de abajo. No lo pongas en /richtexteditor/plugins/: ese directorio pertenece al editor y se sustituye al actualizar. Añade un botón a la barra de herramientas que inserta una etiqueta de tiempo de lectura y la mantiene actualizada a medida que cambia el documento.
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;
}
}El mismo archivo con todos sus comentarios se sirve en /js/readingtime.js: es exactamente el archivo con el que se verificó esta guía, así que puedes cargarlo tal cual y hacer clic en el botón.
Cargarlo
El orden de los scripts importa, y es toda la “instalación”: tu archivo debe ejecutarse después de rte-config.js (que crea RTE_DefaultConfig) y antes de rte.js (que lee la configuración y crea las instancias de los 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 againCuatro cosas en las que es fácil equivocarse
1. Regístralo en los dos presets de barra de herramientas
config.toolbar vale "default" de forma predeterminada, que se resuelve como toolbar_default. Si añades tu comando solo a toolbar_full, el botón es inaccesible para cualquier aplicación que nunca haya configurado una barra de herramientas, y nada informa de un error. Un plugin publicado de este producto pasó un ciclo de versiones completo invisible exactamente por ese motivo.
2. insertHTML() inserta dentro del bloque del cursor
Es la llamada correcta para el contenido en línea. Para un bloque, produce un anidamiento no válido que aun así se muestra, así que es fácil publicarlo con el error:
// 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 párrafo dentro de un párrafo. La primera edición de la aplicación que reescriba el textContent del párrafo exterior borrará tu bloque con él. Si lo que insertas es un bloque, colócalo tú mismo donde van los bloques, como hace el ejemplo con editable.insertBefore(...).
3. No existe editor.fireChange()
El core no lo define. Varios plugins lo llaman protegidos por una comprobación typeof editor.fireChange === "function", lo que significa que esas llamadas no hacen nada sin avisar: esa comprobación es la razón de que pasara desapercibido. No te apoyes en ello. Un plugin que modifica el DOM directamente es responsable de actualizar su propio estado derivado. El evento change en sí es real y sí se lanza cuando el usuario edita, que es lo que escucha el ejemplo.
4. Decide si lo que insertas es contenido o interfaz
Esta es la pregunta que tiene que responder todo plugin. La etiqueta de tiempo de lectura es contenido: el autor la pidió y debe sobrevivir a un guardado, así que va en el documento y aparece en getHTML().
Un control al pasar el ratón, un tirador para arrastrar o un marcador de selección son interfaz. La interfaz nunca debe llegar a getHTML(). Este editor publicó un error exactamente con esa forma: se añadía un control “copiar enlace” dentro de cada encabezado, se serializaba en lo que guardaba la aplicación y volvía a aparecer en la siguiente carga. Si tu plugin inyecta un elemento de interfaz en lugar de contenido, quítalo de la salida.
5. Un elemento <style> simple se descarta con una CSP estricta
style-src 'self' controla los elementos <style>. Con esa directiva, el navegador mantiene tu elemento en el DOM pero descarta sus reglas, así que el plugin se muestra sin estilos sin que se lance nada: la señal es que document.styleSheets.length no coincide con el número de etiquetas <style>, no una excepción. Inserta el elemento exactamente como lo harías y llama a editor.ensureStyleSheetLive(doc, el, id, csstext) justo después de añadirlo. No hace nada a menos que la política haya vaciado realmente el elemento, en cuyo caso vuelve a inyectar el mismo CSS a través del CSSOM, que la CSP no intercepta.
No recurras tú mismo a adoptedStyleSheets. Una hoja de estilos adoptada se ordena después de todas las hojas de estilos del documento, así que poner ahí tus reglas sin condiciones hace que se impongan al propio contentCssUrl de la página anfitriona: tu plugin empezaría a sustituir los estilos del contenido del cliente en páginas que no tienen ninguna CSP. Mantener el elemento como vía principal es lo que preserva la cascada.
Probarlo
Maneja el editor como lo haría una persona. La actualización de la etiqueta del ejemplo la provoca el evento change, y una llamada sintética no lo lanza: la primera versión de nuestra propia prueba llamaba a fireChange(), no veía ninguna actualización y culpaba al plugin. Escribe pulsaciones de teclado reales y luego haz las comprobaciones sobre getHTML() en lugar de sobre el DOM real cuando lo que te importa es lo que se guarda.
Siguientes pasos
- Todos los métodos del editor: lo que puedes llamar desde
InitEditor. - Todos los eventos: el bus transporta
change,selectionchange,keydown,keyupy todos losexec_command_*. El eventoinputno está en él. Para listeners nativos del DOM, desde core 2.6.10, enlázalos coneditor.bindEditableEvent("keydown", fn)oeditor.bindDocumentEvent("input", fn, true), no conaddEventListenersobreeditor.getEditable()oeditor.getDocument(): el área de edición se puede mover después de que arranque tu plugin (algunos plugins integrados la envuelven en un panel), un marco que se mueve recibe un documento nuevo, y los listeners añadidos directamente se quedan en el antiguo y nunca se disparan. El editor vuelve a adjuntar todo lo que se enlaza con estos dos métodos y lanzadocumentreplacedcuando ocurre. En core más antiguos, leeeditor.getEditable()cuando se ejecute tu controlador en lugar de guardar una referencia desdeInitEditor. - Referencia de configuración: todas las opciones integradas y la convención de nombres que deberían seguir tus propias opciones.
- Elementos de la barra de herramientas: los nombres de comando que ya están ocupados.