Escreva seu primeiro plugin
Um plugin é um arquivo e uma função global. Este guia constrói um plugin real do início ao fim — um selo de tempo de leitura com um botão na barra de ferramentas, opções de configuração e uma API pública — e explica as partes em que é fácil errar. O restante da documentação diz o que cada método faz; este guia mostra como as peças se encaixam.
A estrutura de um plugin
Não há manifesto, nem registro, nem etapa de build. Um plugin é uma função construtora global atribuída a uma chave plugin_* em RTE_DefaultConfig. Quando um editor é criado, o core percorre a configuração em busca de chaves que começam com plugin_, chama new em cada uma e depois chama dois métodos de ciclo de vida opcionais na instância:
InitConfig(config)— é executado antes de o editor existir. Valores padrão e o registro na barra de ferramentas ficam aqui. Ainda não háeditor; tentar usá-lo é o erro inicial mais comum.InitEditor(editor)— é executado assim que o editor existe. API pública, comandos e handlers de eventos ficam aqui.
O plugin completo
Salve-o em qualquer local de onde seu aplicativo sirva JavaScript — /js/readingtime.js no exemplo abaixo. Não o coloque em /richtexteditor/plugins/: esse diretório pertence ao editor e é substituído quando você atualiza. O plugin adiciona um botão na barra de ferramentas que insere um selo de tempo de leitura e o mantém atualizado conforme o documento muda.
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;
}
}O mesmo arquivo, com todos os comentários, é servido em /js/readingtime.js — é exatamente o arquivo com que este guia foi verificado, então você pode carregá-lo como está e clicar no botão.
Como carregá-lo
A ordem dos scripts importa, e ela é toda a “instalação”: seu arquivo precisa ser executado depois de rte-config.js (que cria RTE_DefaultConfig) e antes de rte.js (que lê a configuração e instancia os 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 againQuatro coisas em que é fácil errar
1. Registre nos dois presets de barra de ferramentas
config.toolbar tem como padrão "default", que corresponde a toolbar_default. Se você adicionar seu comando apenas a toolbar_full, o botão fica inacessível para todo host que nunca configurou uma barra de ferramentas — e nenhum erro é informado. Um plugin publicado neste produto passou um ciclo de release inteiro invisível exatamente por esse motivo.
2. insertHTML() insere dentro do bloco onde está o cursor
É a chamada certa para conteúdo inline. Para um bloco, ela produz um aninhamento inválido que ainda assim é renderizado, por isso é fácil publicar com esse erro:
// 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>Um parágrafo dentro de um parágrafo. A primeira edição do host que reescrever o textContent do parágrafo externo apaga o seu bloco junto. Se o que você insere é um bloco, coloque-o você mesmo onde ficam os blocos, como o exemplo faz com editable.insertBefore(...).
3. Não existe editor.fireChange()
O core não o define. Vários plugins o chamam protegidos por typeof editor.fireChange === "function", o que significa que essas chamadas silenciosamente não fazem nada — é por causa dessa proteção que ninguém percebeu. Não construa nada sobre ele. Um plugin que altera o DOM diretamente é responsável por atualizar o próprio estado derivado. O evento change em si existe e é disparado quando o usuário edita, e é ele que o exemplo escuta.
4. Decida se o que você insere é conteúdo ou chrome
Essa é a pergunta que todo plugin precisa responder. O selo de tempo de leitura é conteúdo: o autor pediu por ele, e ele deve sobreviver a um salvamento — então vai para o documento e aparece em getHTML().
Um controle de hover, uma alça de arrastar ou um marcador de seleção é chrome. O chrome nunca deve chegar a getHTML(). Este editor já publicou um bug exatamente assim: um controle “copy link” era adicionado dentro de cada título e serializado em tudo o que o host salvava, voltando no carregamento seguinte. Se o seu plugin injeta um elemento de interface em vez de conteúdo, remova-o na saída.
5. Um elemento <style> simples é descartado sob uma CSP estrita
style-src 'self' controla os elementos <style>. Com essa diretiva, o navegador mantém seu elemento no DOM, mas descarta as regras dele, então o plugin é renderizado sem estilo sem que nada lance erro — o sinal é document.styleSheets.length não bater com o número de tags <style>, e não uma exceção. Insira o elemento exatamente como faria normalmente e chame editor.ensureStyleSheetLive(doc, el, id, csstext) logo depois de anexá-lo. Ele não faz absolutamente nada, a menos que a política tenha de fato esvaziado o elemento; nesse caso, injeta de novo o mesmo CSS pelo CSSOM, que a CSP não intercepta.
Não use adoptedStyleSheets por conta própria. Uma adopted stylesheet fica depois de todas as folhas de estilo do documento, então colocar suas regras ali incondicionalmente faz com que elas prevaleçam sobre o contentCssUrl da própria página host — seu plugin passaria a sobrescrever o estilo de conteúdo do cliente em páginas que nem têm CSP. Manter o elemento como caminho principal é o que preserva a cascata.
Como testar
Controle o editor como uma pessoa faria. A atualização do selo no exemplo é acionada pelo evento change, e uma chamada sintética não o dispara — a primeira versão do nosso próprio teste chamava fireChange(), não via nenhuma atualização e culpava o plugin. Digite teclas de verdade e depois faça as asserções sobre getHTML(), e não sobre o DOM ao vivo, quando o que importa é o que é salvo.
Próximos passos
- Todos os métodos do editor — o que você pode chamar em
InitEditor. - Todos os eventos — o barramento transporta
change,selectionchange,keydown,keyupe todos osexec_command_*. O eventoinputnão passa por ele. Para listeners nativos do DOM, a partir do core 2.6.10, vincule-os comeditor.bindEditableEvent("keydown", fn)oueditor.bindDocumentEvent("input", fn, true), e não comaddEventListeneremeditor.getEditable()oueditor.getDocument(): a área de edição pode ser movida depois que seu plugin inicia (alguns plugins nativos a envolvem em um painel), um frame movido recebe um novo documento, e os listeners adicionados diretamente ficam no documento antigo e nunca são disparados. O editor reanexa tudo o que foi vinculado por esses dois métodos e disparadocumentreplacedquando isso acontece. Em versões mais antigas do core, leiaeditor.getEditable()quando seu handler for executado, em vez de guardar uma referência obtida emInitEditor. - Referência de configuração — todas as opções nativas e a convenção de nomes que suas próprias opções devem seguir.
- Itens da barra de ferramentas — os nomes de comando que já estão em uso.