← すべてのドキュメント

はじめてのプラグインを作る

プラグインは、1 つのファイルと 1 つのグローバル関数です。このガイドでは、ツールバーのボタン、設定オプション、公開 API を備えた読了時間のバッジという実際のプラグインを最初から最後まで作り、間違えやすい部分を説明します。ほかのドキュメントは各メソッドが何をするかを説明していますが、このガイドは部品がどう組み合わさるかを説明します。

プラグインの形

マニフェストも、レジストリも、ビルドの手順もありません。プラグインは、RTE_DefaultConfig の plugin_* キーに割り当てたグローバルなコンストラクター関数です。エディターが作成されると、コアは設定から plugin_ で始まるキーを探してそれぞれに new を呼び出し、その後インスタンスの 2 つの省略可能なライフサイクルメソッドを呼び出します。

  • InitConfig(config) — エディターが存在する前に実行されます。既定値とツールバーへの登録はここで行います。まだ editor はありません。これを使おうとするのが、最初によくある間違いです。
  • InitEditor(editor) — エディターが存在するようになった後に 1 回実行されます。公開 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

間違えやすい 4 つのこと

1. 両方のツールバーのプリセットに登録する

config.toolbar の既定値は "default" で、これは toolbar_default に解決されます。コマンドを toolbar_full にだけ追加すると、ツールバーを設定していないすべてのホストではボタンに到達できず、しかもエラーは何も報告されません。この製品でも、リリース済みのプラグインが、まさにこの理由で丸 1 リリースサイクルの間表示されていなかったことがあります。

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. 挿入するものがコンテンツか UI の装飾かを決める

これは、すべてのプラグインが答えなければならない問いです。読了時間のバッジはコンテンツです。作成者が求めたもので、保存しても残るべきなので、文書に入れられ、getHTML() に現れます。

ホバー時のコントロール、ドラッグ用のハンドル、選択範囲のマーカーは UI の装飾(chrome)です。装飾が getHTML() に含まれてはいけません。このエディターでも、まさにそうしたバグをリリースしたことがあります。「リンクをコピー」のコントロールがすべての見出しの中に追加され、ホストが保存するものにシリアライズされて、次に読み込んだときにまた現れたのです。プラグインがコンテンツではなく操作のための要素を挿入する場合は、出力時にそれを取り除いてください。

5. 厳格な CSP の下では、単純な <style> 要素は捨てられる

style-src 'self' は <style> 要素を制御します。その下では、ブラウザーは要素を DOM に残したままそのルールを破棄するので、何も例外がスローされないまま、プラグインはスタイルなしで表示されます。手がかりは例外ではなく、document.styleSheets.length が <style> タグの数と一致しないことです。要素はいつもどおりに挿入し、追加した直後に editor.ensureStyleSheetLive(doc, el, id, csstext) を呼び出してください。ポリシーによって実際に要素が空にされた場合以外は何もせず、空にされた場合は、CSP が介入しない CSSOM を通じて同じ CSS を再挿入します。

自分で adoptedStyleSheets を使わないでください。adopted stylesheet は文書内のすべてのスタイルシートの後に並ぶので、ルールを無条件にそこに置くと、ホストのページ自身の contentCssUrl に勝ってしまいます。CSP がまったくないページでも、プラグインが顧客のコンテンツのスタイルを上書きし始めることになります。要素を主な経路として維持することで、カスケードが保たれます。

テストする

人が操作するのと同じようにエディターを動かしてください。例のバッジの更新は change イベントで行われ、合成した呼び出しではこのイベントは発生しません。当社のテストの最初のバージョンでは fireChange() を呼び出し、更新されないのを見てプラグインのせいにしてしまいました。実際のキー入力を行い、気にしているのが保存される内容であれば、実際の DOM ではなく getHTML() に対してアサートしてください。

次に読むもの

  • エディターのすべてのメソッド — InitEditor から呼び出せるもの。
  • すべてのイベント — イベントバスには change、selectionchange、keydown、keyup、そしてすべての exec_command_* が流れます。input イベントは含まれていません。ネイティブ DOM リスナーは、core 2.6.10 以降では、 editor.getEditable() や editor.getDocument() に対する addEventListener ではなく、editor.bindEditableEvent("keydown", fn) または editor.bindDocumentEvent("input", fn, true) でバインドしてください。プラグインの開始後に編集領域が移動することがあり(組み込みのプラグインの中には編集領域をパネルで包むものがあります)、移動したフレームは新しい文書を持つため、直接追加したリスナーは古い文書に残ったまま発生しなくなります。エディターは、この 2 つのメソッドでバインドしたものを付け直し、そのときに documentreplaced を発生させます。古いコアでは、InitEditor で取得した参照を保持するのではなく、ハンドラーの実行時に editor.getEditable() を読み取ってください。
  • 設定リファレンス — すべての組み込みのオプションと、独自のオプションが従うべき命名規則。
  • ツールバーの項目 — すでに使われているコマンド名。