编写第一个插件
一个插件就是一个文件加一个全局函数。本指南从头到尾构建一个真实的插件——一个带有工具栏按钮、配置选项和公开 API 的阅读时长标记——并解释其中容易出错的部分。文档的其余部分告诉您每个方法做什么;本页告诉您各个部分如何组合在一起。
插件的结构
没有清单文件、没有注册表,也没有构建步骤。插件是一个全局构造函数,赋值给 RTE_DefaultConfig 上一个以 plugin_* 命名的键。构造编辑器时,核心会遍历配置中以 plugin_ 开头的键,对每一个调用 new,然后调用实例上的两个可选生命周期方法:
InitConfig(config)——在编辑器存在之前运行。默认值和工具栏注册放在这里。此时还没有editor;试图访问它是最常见的新手错误。InitEditor(editor)——在编辑器存在后运行一次。公开 API、命令和事件处理函数放在这里。
完整的插件
把它保存到您的应用提供 JavaScript 的任何位置——下面的示例中是 /js/readingtime.js。不要把它放在 /richtexteditor/plugins/ 中:那个目录属于编辑器,升级时会被替换。这个插件添加一个工具栏按钮,用于插入阅读时长标记,并在文档变化时保持标记为最新。
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;
}
}带有完整注释的同一个文件发布在 /js/readingtime.js——它正是本指南验证时所用的文件,因此可以直接加载并点击按钮。
加载插件
脚本顺序很重要,这也就是“安装”的全部内容:您的文件必须在 rte-config.js 之后运行(它会创建 RTE_DefaultConfig),并在 rte.js 之前运行(它会读取配置并实例化插件)。
<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四件容易出错的事
1. 在两个工具栏预设中都要注册
config.toolbar 默认为 "default",它解析为 toolbar_default。如果只把命令追加到 toolbar_full,那么对于从未配置过工具栏的所有宿主来说,这个按钮都无法访问——而且不会报告任何错误。本产品中就有一个已发布的插件,正是因为这个原因,整整一个发布周期都没有显示出来。
2. insertHTML() 会插入到光标所在的块内部
对于内联内容,它是正确的调用。但对于块,它会产生无效但仍能渲染的嵌套,因此很容易被发布出去:
// 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>段落里套着段落。宿主第一次重写外层段落的 textContent 时,您的块就会随之被删除。如果插入的是块,请像示例中用 editable.insertBefore(...) 那样,自己把它放到块应该在的位置。
3. 不存在 editor.fireChange()
核心没有定义它。有几个插件在 typeof editor.fireChange === "function" 判断的保护下调用它,这意味着这些调用悄无声息地什么也没做——正是这个保护判断让问题一直没被发现。不要依赖它。直接修改 DOM 的插件要负责更新自己的派生状态。change 事件本身是真实存在的,并且在用户编辑时确实会触发,示例监听的就是它。
4. 判断您插入的是内容还是界面装饰
这是每个插件都必须回答的问题。阅读时长标记是内容:它是作者要求添加的,应当在保存后保留——因此它放在文档中,并出现在 getHTML() 中。
悬停控件、拖动手柄或选区标记属于界面装饰。界面装饰绝不能进入 getHTML()。这个编辑器就曾发布过一个正是这种形式的缺陷:一个“复制链接”控件被追加到每个标题内部,并被序列化进宿主保存的任何内容中,在下次加载时又重新出现。如果您的插件注入的是交互辅助元素而不是内容,请在输出时把它去除。
5. 在严格的 CSP 下,普通的 <style> 元素会被丢弃
style-src 'self' 管控 <style> 元素。在这一策略下,浏览器会把您的元素保留在 DOM 中,但丢弃其中的规则,因此插件在没有任何异常的情况下以无样式状态渲染——能看出问题的迹象是 document.styleSheets.length 与 <style> 标签的数量不一致,而不是异常。照常插入元素,然后在追加之后立即调用 editor.ensureStyleSheetLive(doc, el, id, csstext)。除非策略确实清空了该元素,否则它什么也不做;如果被清空,它会通过 CSSOM 重新注入相同的 CSS,而 CSP 不会拦截 CSSOM。
不要自己去使用 adoptedStyleSheets。采用的样式表排在文档中每一个样式表之后,因此无条件地把规则放在那里,会让它们压过宿主页面自己的 contentCssUrl——您的插件会在根本没有 CSP 的页面上开始覆盖客户的内容样式。把元素作为主要途径,才能保持正确的层叠顺序。
测试插件
像真人一样操作编辑器。示例中标记的刷新由 change 事件触发,而合成的调用不会触发它——我们自己测试的第一个版本调用了 fireChange(),没有看到刷新,就把问题归咎于插件。请输入真实的按键,并且当您关心的是保存的内容时,针对 getHTML() 而不是实时 DOM 进行断言。
下一步
- 所有编辑器方法——可以在
InitEditor中调用的方法。 - 所有事件——事件总线上有
change、selectionchange、keydown、keyup以及所有exec_command_*。input事件不在总线上。绑定原生 DOM 监听器时,从 core 2.6.10 起,请用editor.bindEditableEvent("keydown", fn)或editor.bindDocumentEvent("input", fn, true)绑定它们,而不是在editor.getEditable()或editor.getDocument()上使用addEventListener:编辑区域可能会在插件启动之后被移动(有些内置插件会把它包进一个面板中),被移动的框架会得到一个新的 document,而直接添加的监听器仍留在旧 document 上,永远不会触发。编辑器会重新附加所有通过这两个方法绑定的监听器,并在发生这种情况时触发documentreplaced。在较早的 core 上,请在处理函数运行时读取editor.getEditable(),而不是保留在InitEditor中获得的引用。 - 配置参考——每个内置选项,以及您自己的选项应遵循的命名约定。
- 工具栏项——已被占用的命令名称。