← 全部文档

编写第一个插件

一个插件就是一个文件加一个全局函数。本指南从头到尾构建一个真实的插件——一个带有工具栏按钮、配置选项和公开 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 中获得的引用。
  • 配置参考——每个内置选项,以及您自己的选项应遵循的命名约定。
  • 工具栏项——已被占用的命令名称。