Если цены на хлеб начнут повышаться, люди станут покупать его больше.
статья
Жорж Парадокс

Создание плагина "Спойлер" для текстового WEB редактора TinyMCE v5

Привет всем!

Я тут писал одну статью, для своего портала, и понял, что в редакторе TinyMCE, в котором я собственно и создаю свои статьи, очень не хватает одного компонента. Этот компонент называется - "Спойлер". И это, разумеется, не тот спойлер, который крепиться на кузов гоночных автомобилей. Этот компонент используется как правило в статьях, чтобы временно скрыть некоторый текст, но, при нажатии на заголовок спойлера, этот текст можно увидеть. В общем он выглядит вот так:

Нажмите сюда.
У лукоморья дуб зеленый;
Златая цепь на дубе том:
И днем и ночью кот ученый
Всё ходит по цепи кругом;

В этой статье изрядная доля текста будет в виде программного кода на JavaScript, и чтобы страница со статьей не оказалось очень большой, программный код я как раз то и буду запаковывать в компонент "спойлер".

1. Что такое плагин для TinyMCE и как его подключать

А начать я хочу с того, что вообще такое из себя представляет плагин для TinyMCE.

Сам проект редактора располагается в одноименном директории "tinymce", внутри которого находиться основной скрипт "tinymce.js", или его сжатая версия "tinymce.min.js". Чтобы редактор заработал, этот скрипт требуется подключить на своей странице.

<script type="text/javascript" src="/static/tinymce/tinymce.js"></script>

В этом же директории располагается директорий "plugins", в котором, собственно, и находятся все стандартные плагины редактора. В самом директории плагина располагается скрипт под именем "plugin.js" или его сжатый вариант "plugin.min.js". Если TinyMCE запускается скриптом tinymce.js, то он соответственно будет подключать несжатый вариант плагина, а если tinymce.min.js, то и плагин потребуется сжатый, т.е. plugin.min.js.

Но, создать плагин мало, его ещё надо подключить к редактору. И для этого используется инициализатор TinyMCE, который представляет из себя следующий код на JavaScrip:

tinymce.init({ /* список настроек редактора */ });

Этот скрипт можно поместить прямо в HTML разметку, где-нибудь в начале, но я предпочитаю инициализатор записать в отдельный файл, допустим "init_tinymce.js", и подключить уже этот файл.

В инициализаторе, среди множества других настроек, должна быть так же настройка под именем "plugins", в которой, в виде текстовой строки, через пробел, добавляются все плагины, которые должны быть подключены к редактору. После того как плагин будет подключен, в редакторе на панель инструментов так же надо добавить кнопку нового плагина, и, если нужно, добавить этот плагин в меню. Чтобы добавить кнопку на панель инструментов, нужно прописать наименование плагина в настройке "toolbar".

2. Что такое "Спойлер"

Ну, что такое спойлер я уже, в общем-то, написал в самом начале этой статьи, и даже показал пример, как он выглядит. А теперь я хотел бы показать, как он выглядит изнутри. А изнутри он выглядит довольно просто:

<div class="spoiler" >
<div class="spoiler_head">Заголовок спойлера</div>
<div class="spoiler_body">Содержимое тела спойлера.</div>
</div>

Как можно понять из разметки, внутри блока находятся два подблока, один с заголовком, на который нужно будет нажать кнопкой мыши, чтобы отобразить содержимое спойлера, а другой с содержимым спойлера, которое собственно и будет скрыто, пока не будет активирован заголовок спойлера.

Кроме HTML разметки, чтобы "спойлер" был похож на "спойлер", нужно подключить CSS стили, которые настроят его внешний вид. Например, вот такая вот CSS таблица стилей:

CSS таблица стилей для "Спойлера"
.spoiler {
    border: 1px solid black;
    border-radius: 2px;
    padding: 1px;
    margin: 0 2px 1rem 2px;
    overflow: auto;
    box-shadow: 0 0 3px gray;
}

.spoiler .spoiler_head {
    border: 1px dotted black;
    background: lavender;
    border-radius: 2px;
    padding: 2px 5px;
    font-weight: bold;
    font-size: 10pt;
    cursor: pointer;
}

.spoiler .spoiler_body {
    margin: 5px;
}

А так же, чтобы спойлер заработал, т.е. чтобы он "открывался" и "закрывался", нужен небольшой скрипт:

Код на javaScript
"use strict";

/*
 * Скрипт для работы спойлера. Требуется jQuery
 */
$(".spoiler .spoiler_body").hide();
$(".spoiler .spoiler_head").attr("title", "Развернуть спойлер").click(function () {
    var _this = this;

    var body = $(this).next(".spoiler_body");
    if (body.is(":hidden")) {
        $(body).show('fast', function () {
            return $(_this).attr("title", "Свернуть спойлер");
        });
    } else {
        $(body).hide('fast', function () {
            return $(_this).attr("title", "Развернуть спойлер");
        });
    }
});

Не буду подробно останавливаться на этом коде, он довольно простой. Скажу только, что при загрузке страницы он скрывает все спойлеры, находящиеся на этой странице, и устанавливает на их заголовки событие на "клик" мыши, при котором, если спойлер скрыт, то он быстро, но плавно откроется, а если открыт, то соответственно, скроется. Чтобы обеспечить плавность открытия и скрытия спойлера, я воспользовался возможностями jQuery, поэтому, чтобы спойлер работал, так же надо не забыть подключить на своей странице библиотеку jQuery.

Чтобы не использовать jQuery, можно  воспользоваться CSS анимацией и чистым javaScript, с помощью которого нужно просто менять высоту блока содержимого спойлера. Для скрытия спойлера нужно устанавливать нулевую высоту, а для открытия, высоту равную скроллингу блока, которая его полностью отобразит. А CSS анимация, в свою очередь, обеспечит плавное изменение высоты блока.

Специально для противников библиотеки jQuery, я представляю этот альтернативный скрипт:

Код на javaScript
'use strict';

/*
 * Скрипт для работы спойлера на чистом javaScript
 */
// Найдем на странице все спойлеры,
document.querySelectorAll('.spoiler_x').forEach(function (spoiler) {
    // Найдем заголовок
    var spoilerHead = spoiler.getElementsByClassName('spoiler_head')[0];
    // и тело переданного спойлера.
    var spoilerBody = spoiler.getElementsByClassName('spoiler_body')[0];
    // Далее подключим обработчик на нажатие кнопки на заголовок.
    spoilerHead.onclick = function () {
        // Если высота тела спойлера нулевая,
        if (spoilerBody.classList.contains('show')) {
            // то нужно изменить ее до максимальной высоты.
            hideBody(spoilerBody);
        } else {
            // Иначе наоборот уменьшить высоту содержимого спойлера до нуля.
            showBody(spoilerBody);
        }
    };


    /**
     * Отображает содержимое
     * @param body
     * */
    function showBody(body) {
        body.classList.add('show');
        body.style.height = spoilerBody.scrollHeight + 'px';
    }

    /**
     * Скрывает содержимое
     * @param body
     * */
    function hideBody(body) {
        body.style.height = "0px";
        body.addEventListener('transitionend', function () {
            body.classList.remove('show');
        }, {once: true})
    }
});

Чтобы этот программный код плавно работал, в CSS стили нужно добавить пару строчек, а точнее один блок изменить и один добавить:

.spoiler .spoiler_body {
    margin: 5px;
    transition: height 0.5s;
    overflow: hidden;
    height: 0;
    display: none;
}

.spoiler .spoiler_body.show {
    display: block;
}

Но, так как я jQuery, не смотря на критику в его сторону, ценю, и он у меня постоянно подключен к сайту, то я все-таки решил воспользоваться его возможностями. Правда, с одной оговоркой. CSS анимация, как ни крути, а она более преимущественна, чем скриптовая, она гораздо легче и более оптимизирована. Поэтому, для реализации анимации открытия и закрытия спойлера я все таки воспользовался именно возможностями CSS, а не jQuery.

Код на javaScript
/*
 * Скрипт для работы спойлера. Требуется jQuery
 */
$('.spoiler').each(function () {
    // Найдем заголовок спойлера,
    let spoilerHead =  $(this).find('.spoiler_head')[0];
    // и тело спойлера.
    let spoilerBody = $(this).find('.spoiler_body')[0];
    // Далее создадим обработку события нажатия на заголовок.
    $(spoilerHead).on('click', function () {
        // Если спойлер отображается,
        if ($(spoilerBody).hasClass('show')) {
            // то нужно его скрыть.
            hideBody(spoilerBody);
            $(spoilerHead).attr("title", "Отобразить спойлер");
        } else {
            // А если не отображается, то оотбразить.
            showBody(spoilerBody);
            $(spoilerHead).attr("title", "Скрыть спойлер");
        }
    });

    /**
     * Отображает содержимое
     * @param body
     */
    function showBody(body) {
        // Добавим класс 'show' и этим отобразим соодержимое (display: block).
        $(body).addClass('show');
        // После этого изменим высоту блока на максимальную.
        $(body).height(body.scrollHeight);
    }

    /**
     * Скрывает содержимое
     * @param body
     */
    function hideBody(body) {
        // Уменьшим высоту блока до нуля,
        $(body).height(0);
        // и после того как анимация уменьшения высоты закончится,
        $(body).one('transitionend', function () {
            // удалим класс 'show', чем окончательно скроем блок (display: none).
            $(body).removeClass('show');
        });
    }
});

Два предыдущих плагина работают аналогично, просто первый работает с использованием jQuery, а второй только на чистом javaScript. На этот раз спойлеры появятся на экране уже скрытыми и с нулевой высотой содержимого спойлера. А вот чтобы они отобразились, в содержимое спойлера теперь вначале надо добавить класс "show", что отобразит его. Но, так как высота содержимого спойлера у него нулевая, то он все ещё не будет виден, поэтому далее надо увеличить его высоту до высоты равной скроллингу блока. А для скрытия спойлера, соответственно вначале надо уменьшить блок его содержимого до нуля, после чего удалить класс "show", что приведет к окончательному скрытию содержимого спойлера. Чем собственно скрипт выше и занимается. А CSS анимация благодаря строке transition: height 0.25s linear; сама обеспечит все остальное.

Хочу заметить, что указанные выше изменения в CSS стилях нужно произвести именно в файле, который будет непосредственно подключен на страницу с готовым содержимым, где этот спойлер и должен работать. Так как, если эти изменения произвести в файле добавляемом в редактор TinyMCE, то благодаря параметрам height: 0; и display: none; отредактировать спойлер будет весьма проблемно, потому что его содержимое в редакторе окажется просто скрытым. Таким образом потребуется два файла стилей CSS, один, который будет подключен к редактору, и второй, который надо не забыть подключить на страницу, где готовый спойлер будет отображаться. Там же, кстати, должен быть подключен файл скрипта работы плагина.

Для сравнения я отображу три варианта работы спойлера:

- с jQuery и скриптовой анимацией:

Нажмите сюда.
У лукоморья дуб зеленый;
Златая цепь на дубе том:
И днем и ночью кот ученый
Всё ходит по цепи кругом;

- с jQuery и CSS анимацией:

Нажмите сюда.
Идет налево песнь заводит,
На право сказку говорит.
Там чудеса, там Леший бродит,
Русалка на ветвях сидит
...

- без jQuery но с CSS анимацией:

Нажмите сюда.
Там на неведомых дорожках,
Следы невиданных зверей.
Избушка там на курьих ножках,
Идет-бредёт сама себе.
...

Как можно увидеть, последние два спойлера работают абсолютно одинаково.

3. Реализация плагина

Итак, перейдем уже наконец к непосредственной реализации самого плагина. Для этого, в директории с плагинами plugins, нужно создать новый каталог и назвать его spoiler. Далее в нем создать файл plugin.js, в котором должен быть реализован следующий метод:

tinymce.PluginManager.add('spoiler', function (editor, url) {
    /* Здесь будет код плагина */
});

Этот плагин сразу же можно подключить в редактор TinyMCE, прописав его в инициализаторе в настройке "plugins":

tinyMCE.init({
    language: "ru",
    selector: "textarea#id_content",
    plugins: "spoiler link image lists preview codesample contextmenu table",
    /* ... */
})

4. Иконка плагина

С первой проблемой, которой я столкнулся при создании плагина в TinyMCE пятой версии, это добавление иконки, отображающей кнопку плагина на панели инструментов редактора. В четвертой версии все было просто, надо было взять файл иконки, в формате допустим jpg, png, или даже ico, и подключить её, что-то вроде:

editor.addButton('spoiler', {
    tooltip: 'Добавить/Удалить спойлер',
    image: url + '/img/eye-blocked.png',
    /* ... */
});

Но оказалось, что в пятой версии, такое не проходит. По крайней мере, сколько я не искал, как подключить обычный файл, я такой возможности, так и не нашёл.

Здесь подход оказался принципиально другой. Все иконки в редакторе TinyMCE 5 добавляются в виде SVG. Кто не знает, SVG это Scalable Vector Graphics — масштабируемая векторная графика. Такая графика создаётся с помощью специальной XML разметки, в которой в структурированном виде с помощью специальных "команд" описывается векторное изображение.

Все векторные картинки добавляются в TinyMCE в коллекцию с помощью команды tinymce.editor.ui.Registry.addIcon(). Посмотреть все стандартные иконки из коллекции можно в официальной документации по редактору, и любую из этих иконок Вы, в принципе, можете использовать. Но их там не так уже и много, и все они, скорее всего уже используются в каких-то стандартных плагинах. Поэтому хотелось бы все-таки использовать свою иконку.

Для добавления новой иконки для начала нужно создать SVG файл, например article_split_icon.svg. В этом файле должно быть содержимое примерно следующего вида:

XML разметка SVG изображения
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <defs>
        <symbol id="split" viewBox="0 0 320 320">
             <g stroke="#000000" stroke-width="20" >
                  <line x1="0" y1="160" x2="135" y2= "160" />
                  <line x1="135" y1="70" x2="135" y2= "250" />
                  <line x1="175" y1="160" x2="320" y2= "160" />
                  <line x1="175" y1="70" x2="175" y2= "250" />
             </g>
        </symbol>
    </defs>
</svg>

Для примера я взял разметку с простенькой иконкой к одному из своих плагинов. Основной тэг, на который надо обратить внимание, это symbol и его идентификатор id="split". Именно на этот идентификатор надо будет ссылаться, чтобы подключить иконку заключённую внутри тэга. В разметку можно добавить несколько изображений, каждое заключённое в свой тэг "symbol" и имеющее свой уникальный идентификатор.

Теперь вернёмся к нашему плагину. Для начала нужно добавить в коллекцию иконок нужную иконку, обозначающую плагин "спойлер".  Это можно сделать следующей командой:

editor.ui.registry.addIcon('spoiler', '<svg width="24" height="24"><use xlink:href="' + url + '/img/spoiler_icons.svg#addspoiler"></use></svg>');

В качестве комментария добавлю, что переменная url это аргумент функции добавления плагина, и содержит она текущий каталог плагина. Файл, содержащий SVG изображение, находиться во вложенном каталоге плагина img, и называется этот файл spoiler_icons.svg, а идентификатором изображение является addspoiler.

Кстати, заодно сразу можно подключить файл таблицы стилей CSS этого плагина, чтобы уже в редакторе он имел правильный вид. Это делается вот так:

editor.contentCSS.push(url + '/css/spoiler.min.css');

5. Регистрация кнопки плагина

После того как своя иконка под названием "spoiler" была добавлена в коллекцию, можно регистрировать кнопку. Для этого в плагине надо реализовать два следующих метода-регистратора плагина:

Код на javaScript
    editor.ui.registry.addToggleButton('spoiler', {
        tooltip: 'Добавить/Удалить спойлер',
        icon: 'spoiler',
        onAction: function () {},
    });
    editor.ui.registry.addMenuItem('spoiler', {
        icon: 'spoiler',
        text: 'Добавить/Удалить спойлер',
        onAction: function () {},
    });

Хочу заметить, что если же с изображением кнопки все-таки не получается, то можно создать кнопку и без изображения. Для этого в методе-регистраторе кнопки addToggleButton() нужно убрать параметр "icon", а за место него добавить параметр "text" с наименованием плагина, как это сделано в методе-регистраторе инициализации пункта меню. И тогда на панели инструментов за место кнопки появиться просто текст-наименование плагина. И из метода-регистратора меню addMenuItem() параметр "icon" тоже можно убрать, оставив только наименование плагина.

Но, запустив редактор, Вы обнаружите, что кнопки плагина на панели инструментов всё ещё нет. Это потому, что в инициализаторе редактора пока еще подключён только сам плагин. Что бы кнопка появилась, эту кнопку надо добавить в настройку "toolbar" инициализатора:

Код на javaScript
tinyMCE.init({
    language: "ru",
    selector: "textarea#id_content",
    plugins: "spoiler link image lists preview codesample contextmenu table",
    toolbar: "spoiler styleselect bold italic | alignleft aligncenter alignright alignjustify ",
    /* ... */
})

В меню плагин добавить несколько посложнее. Меню настраивается в инициализаторе редактора в параметре menubar:

menubar: 'edit insert format table',

Но плагин надо добавлять не в само меню, а в виде подпункта в один из разделов меню. Например, я добавил плагин в раздел "Вставить". Для этого в инициализаторе надо настроить этот раздел. В общем, я просто покажу Вам результат настройки инициализатора редактора, который добавляет плагин и на панель инструментов, и в раздел меню "Вставить":

Код на javaScript
tinyMCE.init({
    language: "ru",
    selector: "textarea#id_content",
    plugins: "spoiler link image lists preview codesample contextmenu table",
    toolbar: "spoiler styleselect bold italic | alignleft aligncenter alignright alignjustify ",
    menubar: 'edit insert format table',
    menu: {
        insert: { title: 'Insert', items: 'spoiler image link media codesample inserttable insertdatetime' },
    },
    /* ... */
})

6. Простая реализация спойлера

Теперь, когда настройка инициализатора редактора TinyMCE закончена, можно продолжить регистрацию добавления кнопки плагина на панель инструментов и регистрацию добавления плагина в раздел меню. А точнее реализовать метод onAction(), который, собственно, и отвечает за работу добавления спойлера. Причем, так как спойлер должен добавляться одинаково и от нажатия кнопки на панели инструментов, и из раздела меню, то, соответственно, и реализация метода "onAction()", и для регистратора кнопки и для регистратора меню, должно быть одинаковым. Поэтому проще всего вывести этот метод в отдельную функцию main(), и подключить её к обоим регистраторам. А реализация самой функции main() следующая:

Код на javaScript
    /**
     * Главная функция плагина
     * @param api
     */
    function main(api) {
        var txt = '<div class="spoiler">' +
                  '<div class="spoiler_head">Заголовок спойлера</div>' +
                  '<div class="spoiler_body">Содержимое спойлера</div>' +
                  '</div>';
        editor.insertContent(txt);
    }

В этой функции создаётся переменная с HTML разметкой, реализующей спойлер, после чего метод редактора insertContent() добавляет эту разметку в редактор в позицию, где в текущий момент установлен текстовый курсор.

Ну вот и все, теперь, нажав на кнопку плагина, можно добавить спойлер. Но, хотелось бы ещё иметь возможность этот спойлер удалить. Нет, его конечно можно удалить, произведя в редакторе различные махинации посредством кнопок-стрелок вверх-вниз, delete, enter, выделяя различные диапазоны текста. Но это очень неудобно. Хотелось бы его удалить так же легко, как и добавили, и даже было бы лучше это сделать той же кнопкой.

А для этого, во-первых, содержимое главной функции лучше перенести в отдельную функцию под названием addSpoiler(), а функцию "main()" уже использовать в качестве управляющей функции, которая будет определять, надо ли спойлер добавить или удалить.

А во-вторых, чтобы спойлер удалить, его надо определить в разметке, как компонент. И для этого нужно создать вспомогательную функцию getSpoiler() следующего содержания:

Код на javaScript
    /**
     * Возвращает верхний DOM спойлера, или null
     * @returns {*}
     */
    function getSpoiler() {
        var spoiler = null;
        // Найдем узел, на котором стоит курсор,
        var node = editor.selection.getNode();
        // и проверим, если это сам спойлер
        if (editor.dom.hasClass(node, "spoiler")) {
            // то его и возьмем.
            spoiler = node;
        } else {
            // Иначе найдем самый верхний узел спойлера, и возьмем его.
            spoiler = editor.dom.getParent(node, ".spoiler");
        }
        // Если спойлер был найден, то он и вернется. Иначе вернеся undefined или null.
        return spoiler;
    }

В принципе в этой функции я постарался оставить подробные и достаточно понятные комментарии, поэтому, думаю, в дополнительных разъяснениях она не нуждается.

Далее, когда спойлер стало возможным определить, его можно удалить. Для этого надо создать функцию удаления спойлера removeSpoiler(). Пока что она еще очень простенькая и имеет следующий вид:

Код на javaScript
    /**
     * Удаляет спойлер
     * @param spoiler {HTMLElement} блок спойлера
     */
    function removeSpoiler(spoiler) {
        // Проверим, если спойлер был найден,
        if (spoiler) {
            // то удалим его.
            spoiler.remove();
        }
    }

Ну а теперь осталось изменить главную функцию main(), в которой проверяется, если курсор установлен на спойлере, то этот спойлер удаляется, а если нет, то создается и добавляется новый спойлер.

Код на javaScript
    /**
     * Главная функция плагина
     * @param api
     */
    function main(api) {
        // Найдем спойлер.
        var spoiler = getSpoiler();
        // Если найден выбранный спойлер,
        if (spoiler) {
            // то надо удалить его.
            removeSpoiler(spoiler);
        } else {
            // Иначе надо добавить новый спойлер.
            addSpoiler();
        }
    }

Ну вот, в общем-то, плагин спойлера и готов. Но полезно было бы сразу добавить еще одну небольшую функцию, которая сама по себе не влияет на функциональность плагина, но, тем не менее, я думаю она необходима. Речь идет о подсветке кнопки плагина, когда текстовый курсор находиться внутри блока спойлера. Согласитесь, что это достаточно эффектно, да и просто удобно видеть визуальную подсказку, что ты выбрал именно свой компонент, или покинул его. Тем более что у других стандартных блочных компонентов редактора подсветка кнопки так же реализована.

Но тут возникла вторая проблема, с которой пришлось повозиться. Дело в том, что в TinyMCE 4, в котором я поначалу реализовал другой свой плагин, эта подсветка подключалась очень легко, всего лишь путем добавления опции stateSelector:'.element_class_name' в регистраторе кнопки. Но в пятой версии редактора, такая опция уже не сработала. А вот что дальше делать, было пока что не очевидно. И даже пройдясь несколько раз по оригинальной документации TinyMCE, я не сразу смог понять, как реализовывается подсветка кнопки, несмотря на то, что ответ не единожды попадался мне на глаза.

Правда, я сразу догадался о том, что она как-то должна настраиваться внутри метода регистратора кнопки под наименованием onSetup() .

Оказалось, что в пятой версии редактора подсветка кнопки подключается посредством реализации обработки события изменения выбранного узла внутри редактора - NodeChange, с помощью следующей конструкции:

Код на javaScript
onSetup: function(api) {
        var nodeChangeHandler = function (eventApi) {
            api.setActive(getSpoiler());
        };
        editor.on('NodeChange', nodeChangeHandler);

        /* onSetup should always return the unbind handlers */
        return function () {
            return editor.off('NodeChange', nodeChangeHandler);
        };
}

Подсветку кнопки включает и отключает метод setActive(bool), в качестве параметра в который передается логическое значение, которое и обозначает, должна ли кнопка стать подсвеченной. Как видно из кода, в качестве параметра я передаю свою вспомогательную функцию "getSpoiler()", которая возвращает компонент "спойлер", если курсор расположен внутри этого компонента. Именно это и можно считать за значение "истина". Если же курсор будет расположен вне блока "спойлер", то функция "getSpoiler()" вернёт "undefined", что будет считаться как значение "ложь". Поэтому, фактически, после каждого изменения положения курсора в тексте будет запрошен результат функции "getSpoiler()" и если она вернёт компонент, то подсветка кнопки включиться, если она компонент не вернёт, то подсветка будет отключена.

Для удобства подключения я завернул эту конструкцию в функцию toggleActiveState(api), и уже эту функцию я присвоил методу регистрации кнопки "onSetup()"

Единственное, что мне так осталось и не понятно, зачем в этой конструкции в конце производиться отвязка обработчика события "NodeChange", так как подсветка вроде бы прекрасно работает и без этой отвязки. Но, именно такая конструкция указана в оригинальной документации, и в программном коде даже специально присутствует комментарий по поводу этой отвязки события, поэтому я оставил эту конструкцию именно в том виде, каком её рекомендует разработчик. Вероятно, это действие важно в случае настройки других установок плагина, ведь в этот метод можно включить не только настройку подсветки кнопки, но и, допустим, в принципе доступность кнопки, которая меняется методом setDisabled(bool), и многое другое.

И так, представляю Вам окончательный программный код плагина для TinyMCE, добавляющий и удаляющий элемент спойлер:

Код на javaScript
tinymce.PluginManager.add('spoiler', function (editor, url) {
    editor.contentCSS.push(url + '/css/spoiler.min.css');
    editor.ui.registry.addIcon('spoiler', '<svg width="24" height="24"><use xlink:href="'
        + url + '/img/spoiler_icons.svg#addspoiler"></use></svg>');

    /**
     * Добавляет спойлер.
     */
    function addSpoiler() {
        var txt = '<div class="spoiler">' +
            '<div class="spoiler_head">Заголовок спойлера</div>' +
            '<div class="spoiler_body">Содержимое спойлера</div>' +
            '</div>';
        editor.insertContent(txt);
    }

    /**
     * Удаляет спойлер
     * @param spoiler {HTMLElement} блок спойлера
     */
    function removeSpoiler(spoiler) {
        // Проверим, если спойлер был найден,
        if (spoiler) {
            // то удалим его.
            spoiler.remove();
        }
    }

    /**
     * Возвращает верхний DOM спойлера, или null
     * @returns {*}
     */
    function getSpoiler() {
        var spoiler = null;
        // Найдем узел, на котором стоит курсор,
        var node = editor.selection.getNode();
        // и проверим, если это сам спойлер
        if (editor.dom.hasClass(node, "spoiler")) {
            // то его и возьмем.
            spoiler = node;
        } else {
            // Иначе найдем самый верхний узел спойлера, и возьмем его.
            spoiler = editor.dom.getParent(node, ".spoiler");
        }
        // Если спойлер был найден, то он и вернется. Иначе вернеся undefined или null.
        return spoiler;
    }

    /**
     * Переключатель активности кнопки
     * @param api
     * @returns {function(): *}
     */
    function toggleActiveState(api) {
        var nodeChangeHandler = function (eventApi) {
            api.setActive(getSpoiler());
        };
        
        /* onSetup should always return the unbind handlers */
        editor.on('NodeChange', nodeChangeHandler);
        return function () {
            return editor.off('NodeChange', nodeChangeHandler);
        };
    }

    /**
     * Главная функция плагина
     * @param api
     */
    function main(api) {
        // Найдем спойлер.
        var spoiler = getSpoiler();
        // Если найден выбранный спойлер,
        if (spoiler) {
            // то надо удалить его.
            removeSpoiler(spoiler);
        } else {
            // Иначе надо добавить новый спойлер.
            addSpoiler();
        }
    }

    editor.ui.registry.addToggleButton('spoiler', {
        tooltip: 'Добавить/Удалить спойлер',
        icon: 'spoiler',
        onAction: main,
        onSetup: toggleActiveState
    });
    editor.ui.registry.addMenuItem('spoiler', {
        icon: 'spoiler',
        text: 'Добавить/Удалить спойлер',
        onAction: main,
    });
});

7. Улучшенный спойлер

Представленный выше программный код уже конечно полностью рабочий, спойлер можно создавать, удалять, менять его заголовок, изменять содержимое спойлера. Но плагин ещё вполне можно улучшить в плане функциональности. Хотелось бы добавить возможность создавать спойлер с уже подготовленным для него содержимым. То есть, если выделен какой-то текст внутри редактора, то при создании спойлера этот текст сразу должен стать содержимым спойлера. А при удалении спойлера, чтобы его содержимое оставалось в тексте редактора на его месте.

И начать я хочу с изменения функции добавления спойлера "addSpoiler()". И тут в принципе все довольно просто. Чтобы спойлер создавался с содержимым, надо взять то, что в данный момент выделено текстовым курсором, и добавить разметку спойлера уже с этим выделенным текстом. Единственное, что хотелось бы ещё отработать, это вариант, когда при добавлении спойлера никакое содержимое не выделено. Тогда, наверное, стоило бы добавить в него какое-то содержимое по умолчанию

Вот обновленная функция добавления спойлера:

Код на javaScript
    /**
     * Добавляет спойлер.
     */
    function addSpoiler() {
        // Возьмем выделенное содержимое курсора.
        var selection = editor.selection;
        var bodyContent = selection.getContent();
        // Если это содержимое есть, то оно должно стать содержимым спойлера.
        if (!bodyContent) {
            // Иначе в качестве содержимого установим текст по умолчанию.
            bodyContent = 'Содержимое спойлера.';
        }
            var txt = '<div class="spoiler">' +
                '<div class="spoiler_head">Заголовок спойлера</div>' +
                '<div class="spoiler_body">' + bodyContent + '</div>' +
                '</div>';
        editor.undoManager.transact(function () {
            editor.insertContent(txt);
        });
    }

В этой функции также можно увидеть новую конструкцию, которой не было в предыдущей версии функции:

editor.undoManager.transact(function () {
	/* ... */
});

Этот специальный метод редактора TinyMCE всего лишь помогает создать возможность отмены последнего действия при изменении содержимого текста в редакторе. То есть при нажатии комбинации клавиш "Ctrl + z" или соответствующей кнопки, если она добавлена на панель инструментов, только что созданный спойлер будет удален. Внутрь этой конструкции нужно поместить программный код, который вносит какие-либо изменение в содержимое текста, которые потом при желании можно было бы отменить. Хочу заметить, что у меня и без этой конструкции только что созданный спойлер удалялся. А вот наоборот, только что удаленный спойлер при отмене действия не возвращался назад, поэтому эта конструкция в данном случае, наверное, более актуальна при удалении спойлера. Но я на всякий случай и добавление спойлера создал внутри этой конструкции.

А теперь немного усложним функцию удаления спойлера "removeSpoiler(spoiler)". И тут я тоже сразу хотел бы отметить один момент. Так как я учёл вариант создания спойлера без содержимого, вернее в этом случае должно быть добавлено содержимое по умолчанию, то я так же решил учесть и вариант удаления спойлера с содержимым по умолчанию, чтобы в этом случае спойлер все-таки удалялся полностью, вместе с содержимым. Это будет удобно, если спойлер был создан случайно, в этом случае его можно быстро убрать вторым нажатием на ту же кнопку. Конечно такого же эффекта можно добиться отменой последнего действия, но все-таки я привык создавать достаточно полнофункциональные компоненты, поэтому возможность удаления спойлера с содержимым по умолчанию я все же предусмотрел.

И так, для начала перед удалением спойлера в любом случае надо получить его содержимое:

var spoilerBody = spoiler.getElementsByClassName('spoiler_body')[0].innerHTML;

А далее следует проверить, если содержимым является содержимое по умолчанию, то спойлер просто надо удалить полностью. А если нет, то надо создать новый параграф, в который заключить содержимое спойлера, и заменить удаляемый спойлер на созданный ранее параграф с его содержимым. Вот как-то так:

Код на javaScript
    /**
     * Удаляет спойлер
     * @param spoiler {HTMLElement} блок спойлера
     */
    function removeSpoiler(spoiler) {
        // Проверим, если спойлер был найден,
        if (spoiler) {
            // то найдем содержимое спойлера.
            var spoilerBody =
                spoiler.getElementsByClassName('spoiler_body')[0].innerHTML;

            // Если содержимое спойлер составляет содержимое по умолчанию,
            if (spoilerBody === 'Содержимое спойлера.') {
                // то спойлер можно полностью удалить, вместе с его содержимым.
                spoiler.remove();
            } else {
                // Иначе вместо спойлера оставим только его содержимое.
                // Для этого создадим новый параграф,
                var newPara = document.createElement('p');
                // и добавим в него содержимое спойлера.
                newPara.innerHTML = spoilerBody;
                editor.undoManager.transact(function () {
                    // Далее, заменим спойлер на созданный нами ранее блок
                    // с содержимым спойлера.
                    spoiler.parentNode.replaceChild(newPara, spoiler);
                });
            }

        }
    }

В дальнейшем, для удобства, начальное значение заголовка спойлера и содержимое спойлера по умолчанию лучше вывести отдельно в виде константы.

8. Диалоговые окна редактора TinyMCE

Несмотря на то, что наш плагин работает, есть некоторые неудобства при работе с ним. Вернее есть неудобства при редактировании содержимого спойлера, или, если один спойлер прижат вплотную к другому спойлеру, то что-то добавить между ними довольно сложно, так же как и сложно что-либо добавить после спойлера, в случае, когда он установлен в самом низу содержимого редактора.

Эти проблемы может помочь решить стандартное диалоговое окно редактора TinyMCE. Да и вообще, многие стандартные блочные компоненты редактора создаются и редактируются посредством диалогового окна. И поэтому я вполне обоснованно решил, что для создания, удаления и теперь тогда уже и редактирования спойлера, тоже очень неплохо было бы использовать диалоговые окна редактора.

И начну я, конечно же, с того, как вообще создаётся и открывается диалоговое окно в редакторе. А создаётся оно с помощью следующего метода:

editor.windowManager.open(windowOptions);

где windowOptions это объект (словарь данных) с параметрами окна. В общем виде в параметрах указывается заголовок окна, его размер, его тело, т.е. основное содержимое, и набор кнопок. Ну и еще там конечно довольно много различных настроек и их комбинаций, и со всеми этими параметрами Вы можете подробно ознакомиться на официальном сайте редактора TinyMCE.

При создании же параметров диалогового окна для спойлера надо учесть то, что окно создания спойлера и окно для его редактирования, немного должны различаться. Например, заголовок окна должен немного меняться, и комбинация кнопок будет разная. Кнопка "Добавить" должна быть именно в диалоговом окне создания спойлера, тогда как в окне редактирования спойлера должны быть кнопки "Изменить" и "Удалить". А вот кнопка "Закрыть" должна быть одинакова в обоих окнах, как и само содержимое окна должно быть аналогичным, и его размер.

В соответствии с вышесказанным получается, что стоит сделать некоторую начальную, общую конфигурацию окна, из которой уже дальше следует изменить ее отдельно для окна создания и для окна редактирования спойлера:

Код на javaScript
    // Начальные опции диалогового окна.
    var beginWindowOptions = {
        size: 'large',
        body: {
            type: 'panel',
            items: [
                {
                    type: 'input',
                    name: 'title',
                    inputMode: 'text',
                    label: 'Title'
                },
                {
                    type: 'textarea',
                    flex: true,
                    name: 'body',
                    label: 'Body',
                    minHeight: 500,
                    maximized: true
                }
            ]
        },
        buttons: [
            {
                text: 'Close',
                type: 'cancel',
                onclick: 'close'
            },
        ],
    };

    /**
     * Опции для диалогового окна добавления спойлера
     * @returns {object}
     */
    function windowOptionsAddSpoiler() {
        // Для начала нужно создать копию опций диалогового окна,
        var windowOptions = JSON.parse(JSON.stringify(beginWindowOptions));
        windowOptions.title = 'Добавить спойлер';
        // Надо добавить кнопку добавления спойлера,
        windowOptions.buttons.push({
            text: 'Add',
            type: 'submit',
            primary: true,
            enabled: false
        });
        // а так же обработчик на добавление спойлера.
        windowOptions.onSubmit = onSubmitAddSpoiler;
        return windowOptions;
    }

    /**
     * Опции для диалогового окна редактирования спойлера
     * @param spoiler
     * @returns {object}
     */
    function windowOptionsEditSpoiler(spoiler) {
        // Для начала нужно создать копию опций диалогового окна.
        var windowOptions = JSON.parse(JSON.stringify(beginWindowOptions));
        windowOptions.title = 'Редактировать спойлер';
        // Далее надо добавить кнопку изменения спойлера,
        windowOptions.buttons.push({
            text: 'Change',
            type: 'submit',
            primary: true,
            enabled: false
        });
        // и удаления.
        windowOptions.buttons.push({
            text: 'Delete',
            type: 'custom',
            primary: true,
        });
        // Так же надо добавить обработчик события для кнопки изменения спойлера.
        windowOptions.onSubmit = onSubmitEditSpoiler;
        // И кнопки удаления спойлера
        windowOptions.onAction = function (api) {
            if (confirm("Вы действительно хотите удалить спойлер?")) {
                removeSpoiler(spoiler);
                api.close();
            }
        };
        return windowOptions;
    }

Теперь о реализации нажатия кнопок, указанных в настройках конфигурации

Кнопка закрытия окна реализуется очень легко, для этого просто достаточно указать тип кнопки "cancel" и в качестве обработчика события "onclick" указать строку - 'close'.

Основная же кнопка, которая должна произвести изменения и закрыть окно, является кнопкой типа 'submit'. Кнопка такого типа должна быть в окне всего одна. Нет, можно конечно сделать таких кнопок много, и всех их даже по-разному назвать, но обрабатываться все они будут все равно одинакового в соответствии с реализацией обработчика onSubmit.  В нашем случае это кнопка "Добавить" в одном окне и кнопка "Изменить" в другом окне. Соответственно получается, что в разных окнах обработка Submit должна быть разная. Реализацию обработчика события "onSubmit", функции onSubmitAddSpoiler(api) и onSubmitEditSpoiler(api), я покажу чуть позже.

Обработчик кнопки удаления, имеющей тип "custom", реализован уже прямо внутри опций, в виде реализации обработчика onAction(api, details) в опциях диалогового окна. Вообще обработчик "onAction" призван обрабатывать все добавленные кнопки типа "custom". Чтобы отличать, какая из кнопок была нажата, в методе "onAction" присутствует опция "details", через которую и можно проверить, которая из кнопок была нажата. Но, так как в нашем окне есть только одна кнопка типа "custom", это кнопка удаления спойлера, то опция "details" в этом случае, скорее всего вообще будет отсутствовать.

Непосредственное удаление спойлера производиться с помощью функции removeSpoiler(spoiler), точно такой же функции, с помощью которой удалялся спойлер и в первой версии плагина. Но в обработчике нажатия кнопки удаления перед выполнением этой функции будет запрошено подтверждение на удаление спойлера, и при положительном ответе, после удаления компонента, произойдет закрытие окна:

        windowOptions.onAction = function (api) {
            if (confirm("Вы действительно хотите удалить спойлер?")) {
                removeSpoiler(spoiler);
                api.close();
            }
        };

Обработчик события кнопки добавления спойлера выглядит следующим образом:

Код на javaScript
    /**
     * Функция сабмита формы добавления нового спойлера
     * @param api
     */
    function onSubmitAddSpoiler(api) {
        // Получим данные окна.
        var data = api.getData();
        // и сформируем разметку спойлера с учетом его заголовка
        // и содержимого взятого из полей диалогового окна
        var txt = '<div class="spoiler" contenteditable="false">' +
            '<div class="spoiler_head">' + data.title + '</div>' +
            '<div class="spoiler_body">' + data.body + '</div>' +
            '</div>';
        editor.undoManager.transact(function () {
            // Теперь эту разметку вставим в текст редактора,
            editor.insertContent(txt);
        });
        // и закроем диалоговое окно.
        api.close();
    }

Согласитесь, очень напоминает функцию добавления спойлера из предыдущей версии плагина. Разница лишь в том, что в прошлой версии содержимое спойлера бралось из выделенного в данный момент в редакторе текста, а теперь это содержимое берётся из текстового поля диалогового окна. Ну и заголовок спойлера теперь тоже указывается в поле ввода окна. Хотя, хочу сразу заметить, что содержимое спойлера, в конечном счёте, всё так же будет браться из выделенного в редакторе текста, это все-таки довольно практично. Только теперь это содержимое будет вначале инициализировано в текстовое поле открывающегося диалогового окна, где его можно изменить-подкорректировать, и затем кнопкой "Добавить" отправить во-вновь создаваемый спойлер.

Отдельно хочу обратить внимание на атрибут contenteditable="false", присутствующий на этот раз в разметке. Так как теперь спойлер планируется изменять через диалоговое окно, то редактировать его содержимое и заголовок напрямую в тексте уже нецелесообразно, и лучше эту возможность вообще убрать. И вот именно этот аттрибут "contenteditable" со значением "false" и запретит редактировать спойлер непосредственно в самом тексте, превратив его, таким образом, в цельный блочный компонент, управлять которым внутри текстовых данных станет гораздо удобнее.

Ну а теперь рассмотрим обработчик события кнопки редактирования спойлера:

Код на javaScript
    /**
     * Функция сабмита формы редактирования спойлера
     * @param api
     */
    function onSubmitEditSpoiler(api) {
        // Получим данные окна.
        var data = api.getData();
        // Для создания нового спойлера надо вначале создать div-блок,
        var newSpoiler = document.createElement('div');
        // и добавить ему класс "spoiler".
        newSpoiler.className = "spoiler";
        newSpoiler.setAttribute("contenteditable", "false");
        // Далее добавим внутрь этого блока заголовок и содержимое спойлера.
        newSpoiler.innerHTML =
            '<div class="spoiler_head">' + data.title + '</div>' +
            '<div class="spoiler_body">' + data.body + '</div>';
        // Теперь получим существующий спойлер,
        var oldSpoiler = getSpoiler();
        editor.undoManager.transact(function () {
            // и заменим его на новый, только что созданный, спойлер.
            oldSpoiler.parentNode.replaceChild(newSpoiler, oldSpoiler);
        });
        api.close();
    }

Эту функция я также подробно описал в комментариях. Но, в двух словах она работает так...

Из диалогового окна снова надо получить данные о заголовке и содержимом спойлера, которые уже наверняка были изменены, ведь именно для этого это окно и было открыто. Далее, надо создать новый каркас спойлера в виде DIV блока с классом "spoiler" и атрибутом "contenteditable", который запретит редактировать содержимое спойлера в самом текстовом редакторе. Теперь в этот каркас заворачивается разметка заголовка и содержимое спойлера с новыми данными. С помощью вспомогательной функции "getSpoiler()" нужно получить текущий блок спойлера и заменить его на-новый, только что созданный, спойлер. В конце окно закрывается, чтобы было отлично видно результат.

Ну вот, наконец, объект с опциями для создания диалогового окна, даже для двух диалоговых окон, готов. И теперь уже можно написать функцию самого открытия окна:

Код на javaScript
    /**
     * Открывает диалоговое окно для создания, редактирования и удаления спойлера.
     * @param bodyContent {Text} содержимое спойлера
     * @param titleContent {Text} заголовок спойлера
     * @param isEdit {bool} флаг обозначающий, что это окно именно для редактрования спойлера
     * @param spoiler {HTMLElement} текущий спойлер. Если окно для добавления спойлера,
     *                              то будет undefined.
     */
    function openWindow(bodyContent, titleContent, isEdit, spoiler) {
        titleContent = titleContent || HEADER_DEFAULT;
        isEdit = isEdit || false;
        // В зависимости от того, создается ли спойлер или редактируется, возьмем нужные опции,
        var windowOptions = isEdit ? windowOptionsEditSpoiler(spoiler) : windowOptionsAddSpoiler();
        // и добавим в эти опции начальные значения заголовка и содержимого спойлера.
        windowOptions.initialData = {title: titleContent, body: bodyContent};

        // Откроем диалог создания или редактирования нового спойлера.
        editor.windowManager.open(windowOptions);
    }

С помощью этой функции окно будет открываться как для создания нового спойлера, так и для редактирования существующего. А какое окно будет открыто, в первую очередь зависит от флага isEdit, значение которого надо передать в параметры функции. Проверяя, далее, значение этого флага, будет взята соответствующая конфигурация окна. Но, какая-бы конфигурация не была, в нее надо по любому добавить инициализацию двух текстовых полей, в которых содержатся значение заголовка спойлера и значение содержимого спойлера. Благодаря этой инициализации окно откроется не пустое, а уже со-значениям в соответствующих текстовых полях.

После того как произошло определение какое же должно открыться окно, и соответственно полностью сформирована конфигурация этого окна, эту конфигурацию осталось передать в метод открытия окна.

Далее надо разобрать две функции, с помощью которых главный метод плагина будет открывать окно для создания или редактирования спойлера. И вот первая функция:

Код на javaScript
    /**
     * Открывает окно добавления нового спойлера.
     */
    function addSpoiler() {
        // Возьмем выделенное содержимое курсора.
        var selection = editor.selection;
        var bodyContent = selection.getContent();
        // Если это содержимое есть, то оно должно стать содержимым спойлера.
        if (!bodyContent) {
            // Иначе в качестве содержимого установим текст по умолчанию.
            bodyContent = BODY_DEFAULT;
        }

        // Откроем диалоговое окно с переданным содержимым.
        openWindow(bodyContent);
    }

Эта функция опять же очень похожа ну ту, что была в первой версии плагина. За исключением того, что выделенный текст теперь не попадает сразу внутрь разметки спойлера в виде его содержимого, а для начала передается в метод открытия диалогового окна, где после открытия этого окна оно будет доступно для редактирования, прежде чем попадет внутрь спойлера.

Теперь достаточно простая функция для редактирования спойлера:

Код на javaScript
    /**
     * Открывает окно редактирования выбранного спойлера
     * @param spoiler элемент спойлер
     */
    function editSpoiler(spoiler) {
        // Из спойлера возьмем заголовок
        var title = spoiler.getElementsByClassName('spoiler_head')[0].textContent;
        // и содержимое спойлера,
        var body = spoiler.getElementsByClassName('spoiler_body')[0].innerHTML;
        // и передадим это в окно, вместе с флагом, что это окно для редакитрования спойлера.
        openWindow(body, title, true, spoiler);
    }

В эту функцию передается уже готовый спойлер, из которого достается заголовок, его содержимое, и это все, вместе с самим спойлером, передается в метод открытия диалогового окна, с флагом о редактировании спойлера равным "true". Благодаря значению этого флага окно откроется уже именно для редактирования спойлера и в том числе с кнопкой удаления текущего спойлера.

Ну вот и все,  плагин уже почти готов. Осталось добавить основную функцию плагина "main()", которая и будет управлять созданием и редактирование спойлера:

Код на javaScript
    /**
     * Главная функция плагина
     * @param api
     */
    function main(api) {
        // Попробуем найти спойлер.
        var spoiler = getSpoiler();
        // Если найден выбранный спойлер,
        if (spoiler) {
            // то надо удалить его.
            editSpoiler(spoiler);
        } else {
            // Иначе надо добавить новый спойлер.
            addSpoiler();
        }
    }

Здесь все совсем просто. Проверяем, находимся ли мы сейчас на спойлере, и если да, то откроется окно для его редактирования, если нет, то для создания нового спойлера.

Ну а теперь полный программный код изменённого плагина:

Код на javaScript
tinymce.PluginManager.add('spoiler', function (editor, url) {
    const HEADER_DEFAULT = 'Заголовок спойлера';
    const BODY_DEFAULT = 'Содержимое спойлера.';
    editor.contentCSS.push(url + '/css/spoiler.min.css');
    editor.ui.registry.addIcon('spoiler', '<svg width="24" height="24"><use xlink:href="'
        + url + '/img/spoiler_icons.svg#addspoiler"></use></svg>');

    // Начальные опции диалогового окна.
    var beginWindowOptions = {
        size: 'large',
        body: {
            type: 'panel',
            items: [
                {
                    type: 'input',
                    name: 'title',
                    inputMode: 'text',
                    label: 'Title'
                },
                {
                    type: 'textarea',
                    flex: true,
                    name: 'body',
                    label: 'Body',
                    minHeight: 500,
                    maximized: true
                }
            ]
        },
        buttons: [
            {
                text: 'Close',
                type: 'cancel',
                onclick: 'close'
            },
        ],
    };

    /**
     * Опции для диалогового окна добавления спойлера
     * @returns {object}
     */
    function windowOptionsAddSpoiler() {
        // Для начала нужно создать копию опций диалогового окна,
        var windowOptions = JSON.parse(JSON.stringify(beginWindowOptions));
        windowOptions.title = 'Добавить спойлер';
        // Надо добавить кнопку добавления спойлера,
        windowOptions.buttons.push({
            text: 'Add',
            type: 'submit',
            primary: true,
            enabled: false
        });
        // а так же обработчик на добавление спойлера.
        windowOptions.onSubmit = onSubmitAddSpoiler;
        return windowOptions;
    }

    /**
     * Опции для диалогового окна редактирования спойлера
     * @param spoiler
     * @returns {object}
     */
    function windowOptionsEditSpoiler(spoiler) {
        // Для начала нужно создать копию опций диалогового окна.
        var windowOptions = JSON.parse(JSON.stringify(beginWindowOptions));
        windowOptions.title = 'Редактировать спойлер';
        // Далее надо добавить кнопку изменения спойлера,
        windowOptions.buttons.push({
            text: 'Change',
            type: 'submit',
            primary: true,
            enabled: false
        });
        // и удаления.
        windowOptions.buttons.push({
            text: 'Delete',
            type: 'custom',
            primary: true,
        });
        // Так же надо добавить обработчик события для кнопки изменения спойлера.
        windowOptions.onSubmit = onSubmitEditSpoiler;
        // И кнопки удаления спойлера
        windowOptions.onAction = function (api) {
            if (confirm("Вы действительно хотите удалить спойлер?")) {
                removeSpoiler(spoiler);
                api.close();
            }
        };
        return windowOptions;
    }

    /**
     * Функция сабмита формы добавления нового спойлера
     * @param api
     */
    function onSubmitAddSpoiler(api) {
        // Получим данные окна.
        var data = api.getData();
        // и сформируем разметку спойлера с учетом его заголовка
        // и содержимого взятого из полей диалогового окна
        var txt = '<div class="spoiler" contenteditable="false">' +
            '<div class="spoiler_head">' + data.title + '</div>' +
            '<div class="spoiler_body">' + data.body + '</div>' +
            '</div>';
        editor.undoManager.transact(function () {
            // Теперь эту разметку вставим в текст редактора,
            editor.insertContent(txt);
        });
        // и закроем диалоговое окно.
        api.close();
    }

    /**
     * Функция сабмита формы редактирования спойлера
     * @param api
     */
    function onSubmitEditSpoiler(api) {
        // Получим данные окна.
        var data = api.getData();
        // Для создания нового спойлера надо вначале создать div-блок,
        var newSpoiler = document.createElement('div');
        // и добавить ему класс "spoiler".
        newSpoiler.className = "spoiler";
        newSpoiler.setAttribute("contenteditable", "false");
        // Далее добавим внутрь этого блока заголовок и содержимое спойлера.
        newSpoiler.innerHTML =
            '<div class="spoiler_head">' + data.title + '</div>' +
            '<div class="spoiler_body">' + data.body + '</div>';
        // Теперь получим существующий спойлер,
        var oldSpoiler = getSpoiler();
        editor.undoManager.transact(function () {
            // и заменим его на новый, только что созданный, спойлер.
            oldSpoiler.parentNode.replaceChild(newSpoiler, oldSpoiler);
        });
        api.close();
    }

    /**
     * Открывает диалоговое окно для создания, редактирования и удаления спойлера.
     * @param bodyContent {Text} содержимое спойлера
     * @param titleContent {Text} заголовок спойлера
     * @param isEdit {bool} флаг обозначающий, что это окно именно
     *                      для редактирования спойлера
     * @param spoiler {HTMLElement} текущий спойлер. Если окно для добавления спойлера,
     *                              то должно быть undefined.
     */
    function openWindow(bodyContent, titleContent, isEdit, spoiler) {
        titleContent = titleContent || HEADER_DEFAULT;
        isEdit = isEdit || false;
        // В зависимости от того, создается ли спойлер или редактируется,
        // возьмем нужные опции,
        var windowOptions = isEdit
            ? windowOptionsEditSpoiler(spoiler)
            : windowOptionsAddSpoiler();
        // и добавим в эти опции начальные значения заголовка и содержимого спойлера.
        windowOptions.initialData = {title: titleContent, body: bodyContent};

        // Откроем диалог создания или редактирования нового спойлера.
        editor.windowManager.open(windowOptions);
    }

    /**
     * Открывает окно добавления нового спойлера.
     */
    function addSpoiler() {
        // Возьмем выделенное содержимое курсора.
        var selection = editor.selection;
        var bodyContent = selection.getContent();
        // Если это содержимое есть, то оно должно стать содержимым спойлера.
        if (!bodyContent) {
            // Иначе в качестве содержимого установим текст по умолчанию.
            bodyContent = BODY_DEFAULT;
        }

        // Откроем диалоговое окно с переданным содержимым.
        openWindow(bodyContent);
    }

    /**
     * Открывает окно редактирования выбранного спойлера
     * @param spoiler элемент спойлер
     */
    function editSpoiler(spoiler) {
        // Из спойлера возьмем заголовок
        var title = spoiler.getElementsByClassName('spoiler_head')[0].textContent;
        // и содержимое спойлера,
        var body = spoiler.getElementsByClassName('spoiler_body')[0].innerHTML;
        // и передадим это в окно, вместе с флагом,
        // что это окно для редактирования спойлера.
        openWindow(body, title, true, spoiler);
    }

    /**
     * Удаляет спойлер
     * @param spoiler {HTMLElement} блок спойлера
     */
    function removeSpoiler(spoiler) {
        // Проверим, если спойлер был найден,
        if (spoiler) {
            // то найдем содержимое спойлера.
            var spoilerBody =
                spoiler.getElementsByClassName('spoiler_body')[0].innerHTML;
            // Если содержимое спойлер составляет содержимое по умолчанию,
            if (spoilerBody === BODY_DEFAULT) {
                // то спойлер можно полностью удалить, вместе с его содержимым.
                spoiler.remove();
            } else {
                // Иначе вместо спойлера оставим только его содержимое.
                // Для этого создадим новый параграф,
                var newPara = document.createElement('p');
                // и добавим в него содержимое спойлера.
                newPara.innerHTML = spoilerBody;
                editor.undoManager.transact(function () {
                    // Далее, заменим спойлер на созданный нами ранее
                    // блок с содержимым спойлера.
                    spoiler.parentNode.replaceChild(newPara, spoiler);
                });
            }
        }
    }


    /**
     * Возвращает верхний DOM спойлера, или null
     * @returns {*}
     */
    function getSpoiler() {
        var spoiler = null;
        // Найдем узел, на котором стоит курсор,
        var node = editor.selection.getNode();
        // и проверим, если это сам спойлер
        if (editor.dom.hasClass(node, "spoiler")) {
            // то его и возьмем.
            spoiler = node;
        } else {
            // Иначе найдем самый верхний узел спойлера, и возьмем его.
            spoiler = editor.dom.getParent(node, ".spoiler");
        }
        // Если спойлер был найден, то он и вернется.
        // Иначе вернется undefined или null.
        return spoiler;
    }

    /**
     * Переключатель активности кнопки
     * @param api
     * @returns {function(): *}
     */
    function toggleActiveState(api) {
        var nodeChangeHandler = function (eventApi) {
            api.setActive(getSpoiler());
        };

        /* onSetup should always return the unbind handlers */
        editor.on('NodeChange', nodeChangeHandler);
        return function () {
            return editor.off('NodeChange', nodeChangeHandler);
        };
    }

    /**
     * Главная функция плагина
     * @param api
     */
    function main(api) {
        // Попробуем найти спойлер.
        var spoiler = getSpoiler();
        // Если найден выбранный спойлер,
        if (spoiler) {
            // то надо удалить его.
            editSpoiler(spoiler);
        } else {
            // Иначе надо добавить новый спойлер.
            addSpoiler();
        }
    }

    editor.ui.registry.addToggleButton('spoiler', {
        tooltip: 'Добавить/Удалить спойлер',
        icon: 'spoiler',
        onAction: main,
        onSetup: toggleActiveState
    });
    editor.ui.registry.addMenuItem('spoiler', {
        icon: 'spoiler',
        text: 'Добавить/Удалить спойлер',
        onAction: main,
    });
});

Хочу еще заметить одну такую вещь. Все наименования кнопок в настройках параметров диалогового окна я указал на английском языке: 'Add', 'Close', 'Change', 'Delete', хотя никто не запрещает это сделать и на русском. Но, дело в том, что в TinyMCE присутствует файл локализации, который можно скачать с официального сайта редактора для нужного Вам языка, и он должен располагаться в директории "langs" редактора. Я, разумеется, скачал локализацию для русского языка, это файл ru.js, и именно эту локализацию я далее указал в инициализаторе редактора - language: "ru". Если открыть файл локализации, то в нем можно обнаружить в виде словаря английские слова и переводы к ним на языке локализации, в моем случае это могучий Русский язык. Правда, перевод оказался не на кириллице, а в виде юникод символов. В этом файле представлены слова используемые в редакторе, и плагинах. Но в него вполне можно добавить и свои слова, чтобы те же наименования своих кнопок могли отображаться при желании на нескольких языках. Что я и сделал. Я дополнительно добавил в этот справочник еще три слова, которых в нем не было, и которые я использовал в наименовании кнопок:

"Change": "\u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c",
"Delete": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c",
"Add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c",

Как видно, чтобы создать перевод, пришлось их перекодировать в Unicode символы.

9. Два плагина в одном

Поработав с этим новым плагином я довольно скоро понял, что и он тоже не лишен недостатков, и у предыдущего плагина по отношению к новому есть даже некоторые достоинства. Одним словом, оба плагина имеют права быть. Но, иметь два плагина, имеющих в своей основе одинаковое назначение, как-то не эффективно. И я решил, а почему бы не объединить оба этих плагина в один, тем более, что их программный код во многом похож, а некоторые функции так и вообще абсолютно одинаковы. А для выбора, каким образом спойлер должен создаваться, сразу или посредством диалогового окна, можно использовать инициализатор редактора TinyMCE, добавив в него, допустим, параметр spoiler_windows с логическим значением. Если это значение истинное, то спойлер должен открываться через диалоговое окно, если ложное, то сразу добавляться в содержимое текста в редакторе.

Вот он полный программный код этого плагина:

Код на javaScript
   tinymce.PluginManager.add('spoiler', function (editor, url) {
    const HEADER_DEFAULT = 'Заголовок спойлера';
    const BODY_DEFAULT = 'Содержимое спойлера.';
    editor.contentCSS.push(url + '/css/spoiler.min.css');
    editor.ui.registry.addIcon('spoiler', '<svg width="24" height="24"><use xlink:href="'
        + url + '/img/spoiler_icons.svg#addspoiler"></use></svg>');

    // Начальные опции диалогового окна.
    var beginWindowOptions = {
        title: 'Добавить спойлер',
        size: 'large',
        body: {
            type: 'panel',
            items: [
                {
                    type: 'input',
                    name: 'title',
                    inputMode: 'text',
                    label: 'Title'
                },
                {
                    type: 'textarea',
                    flex: true,
                    name: 'body',
                    label: 'Body',
                    minHeight: 500,
                    maximized: true
                }
            ]
        },
        buttons: [
            {
                text: 'Close',
                type: 'cancel',
                onclick: 'close'
            },
        ],
    };

    /**
     * Опции для диалогового окна добавления спойлера
     * @returns {object}
     */
    function windowOptionsAddSpoiler() {
        // Для начала нужно создать копию опций диалогового окна,
        var windowOptions = JSON.parse(JSON.stringify(beginWindowOptions));
        windowOptions.title = 'Добавить спойлер';
        // Надо добавить кнопку добавления спойлера,
        windowOptions.buttons.push({
            text: 'Add',
            type: 'submit',
            primary: true,
            enabled: false
        });
        // а так же обработчик на добавление спойлера.
        windowOptions.onSubmit = onSubmitAddSpoiler;
        return windowOptions;
    }

    /**
     * Опции для диалогового окна редактирования спойлера
     * @param spoiler
     * @returns {object}
     */
    function windowOptionsEditSpoiler(spoiler) {
        // Для начала нужно создать копию опций диалогового окна.
        var windowOptions = JSON.parse(JSON.stringify(beginWindowOptions));
        windowOptions.title = 'Редактировать спойлер';
        // Далее надо добавить кнопку изменения спойлера,
        windowOptions.buttons.push({
            text: 'Change',
            type: 'submit',
            primary: true,
            enabled: false
        });
        // и удаления.
        windowOptions.buttons.push({
            text: 'Delete',
            type: 'custom',
            primary: true,
        });
        // Так же надо добавить обработчик события для кнопки изменения спойлера.
        windowOptions.onSubmit = onSubmitEditSpoiler;
        // И кнопки удаления спойлера
        windowOptions.onAction = function (api) {
            if (confirm("Вы действительно хотите удалить спойлер?")) {
                removeSpoiler(spoiler);
                api.close();
            }
        };
        return windowOptions;
    }

    /**
     * Функция сабмита формы добавления нового спойлера
     * @param api
     */
    function onSubmitAddSpoiler(api) {
        // Получим данные окна.
        var data = api.getData();
        // и сформируем разметку спойлера с учетом его заголовка
        // и содержимого взятого из полей диалогового окна
        var txt = '<div class="spoiler" contenteditable="false">' +
            '<div class="spoiler_head">' + data.title + '</div>' +
            '<div class="spoiler_body">' + data.body + '</div>' +
            '</div>';
        editor.undoManager.transact(function () {
            // Теперь эту разметку вставим в текст редактора,
            editor.insertContent(txt);
        });
        // и закроем диалоговое окно.
        api.close();
    }

    /**
     * Функция сабмита формы редактирования спойлера
     * @param api
     */
    function onSubmitEditSpoiler(api) {
        // Получим данные окна.
        var data = api.getData();
        // Для создания нового спойлера надо вначале создать div-блок,
        var newSpoiler = document.createElement('div');
        // и добавить ему класс "spoiler".
        newSpoiler.className = "spoiler";
        newSpoiler.setAttribute("contenteditable", "false");
        // Далее добавим внутрь этого блока заголовок и содержимое спойлера.
        newSpoiler.innerHTML =
            '<div class="spoiler_head">' + data.title + '</div>' +
            '<div class="spoiler_body">' + data.body + '</div>';
        // Теперь получим существующий спойлер,
        var oldSpoiler = getSpoiler();
        editor.undoManager.transact(function () {
            // и заменим его на новый, только что созданный, спойлер.
            oldSpoiler.parentNode.replaceChild(newSpoiler, oldSpoiler);
        });
        api.close();
    }

    /**
     * Открывает диалоговое окно для создания, редактирования и удаления спойлера.
     * @param bodyContent {Text} содержимое спойлера
     * @param titleContent {Text} заголовок спойлера
     * @param isEdit {bool} флаг обозначающий, что это окно именно
     *                      для редактирования спойлера
     * @param spoiler {HTMLElement} текущий спойлер. Если окно для добавления спойлера,
     *                              то должно быть undefined.
     */
    function openWindow(bodyContent, titleContent, isEdit, spoiler) {
        titleContent = titleContent || HEADER_DEFAULT;
        isEdit = isEdit || false;
        // В зависимости от того, создается ли спойлер или редактируется,
        // возьмем нужные опции,
        var windowOptions = isEdit
            ? windowOptionsEditSpoiler(spoiler)
            : windowOptionsAddSpoiler();
        // и добавим в эти опции начальные значения заголовка и содержимого спойлера.
        windowOptions.initialData = {title: titleContent, body: bodyContent};

        // Откроем диалог создания или редактирования нового спойлера.
        editor.windowManager.open(windowOptions);
    }

    /**
     * Открывает окно добавления нового спойлера.
     */
    function addSpoiler() {
        // Возьмем выделенное содержимое курсора.
        var selection = editor.selection;
        var bodyContent = selection.getContent();
        // Если это содержимое есть, то оно должно стать содержимым спойлера.
        if (!bodyContent) {
            // Иначе в качестве содержимого установим текст по умолчанию.
            bodyContent = BODY_DEFAULT;
        }
        // Если в настройках редактора стоит, что спойлер должен открываться окном,
        if (editor.settings.spoiler_windows) {
            // то откроем окно.
            openWindow(bodyContent);
        } else {
            // Иначе сразу же создадим спойлер,
            var txt = '<div class="spoiler">' +
                '<div class="spoiler_head">' + HEADER_DEFAULT + '</div>' +
                '<div class="spoiler_body">' + bodyContent + '</div>' +
                '</div>';
            editor.undoManager.transact(function () {
                // и добавим его в редактор.
                editor.insertContent(txt);
            });
        }
    }

    /**
     * Открывает окно редактирования выбранного спойлера
     * @param spoiler элемент спойлер
     */
    function editSpoiler(spoiler) {
        // Из спойлера возьмем заголовок
        var title = spoiler.getElementsByClassName('spoiler_head')[0].textContent;
        // и содержимое спойлера,
        var body = spoiler.getElementsByClassName('spoiler_body')[0].innerHTML;
        // и передадим это в окно, вместе с флагом,
        // что это окно для редактирования спойлера.
        openWindow(body, title, true, spoiler);
    }

    /**
     * Удаляет спойлер
     * @param spoiler {HTMLElement} блок спойлера
     */
    function removeSpoiler(spoiler) {
        // Проверим, если спойлер был найден,
        if (spoiler) {
            // то найдем содержимое спойлера.
            var spoilerBody =
                spoiler.getElementsByClassName('spoiler_body')[0].innerHTML;
            // Если содержимое спойлер составляет содержимое по умолчанию,
            if (spoilerBody === BODY_DEFAULT) {
                // то спойлер можно полностью удалить, вместе с его содержимым.
                spoiler.remove();
            } else {
                // Иначе вместо спойлера оставим только его содержимое.
                // Для этого создадим новый параграф,
                var newPara = document.createElement('p');
                // и добавим в него содержимое спойлера.
                newPara.innerHTML = spoilerBody;
                editor.undoManager.transact(function () {
                    // Далее, заменим спойлер на созданный нами ранее
                    // блок с содержимым спойлера.
                    spoiler.parentNode.replaceChild(newPara, spoiler);
                });
            }
        }
    }

    /**
     * Возвращает верхний DOM спойлера, или null
     * @returns {*}
     */
    function getSpoiler() {
        var spoiler = null;
        // Найдем узел, на котором стоит курсор,
        var node = editor.selection.getNode();
        // и проверим, если это сам спойлер
        if (editor.dom.hasClass(node, "spoiler")) {
            // то его и возьмем.
            spoiler = node;
        } else {
            // Иначе найдем самый верхний узел спойлера, и возьмем его.
            spoiler = editor.dom.getParent(node, ".spoiler");
        }
        // Если спойлер был найден, то он и вернется.
        // Иначе вернется undefined или null.
        return spoiler;
    }

    /**
     * Переключатель активности кнопки
     * @param api
     * @returns {function(): *}
     */
    function toggleActiveState(api) {
        var nodeChangeHandler = function (eventApi) {
            api.setActive(getSpoiler());
        };

        /* onSetup should always return the unbind handlers */
        editor.on('NodeChange', nodeChangeHandler);
        return function () {
            return editor.off('NodeChange', nodeChangeHandler);
        };
    }

    /**
     * Главная функция плагина
     * @param api
     */
    function main(api) {
        // Попробуем найти спойлер.
        var spoiler = getSpoiler();
        // Если найден выбранный спойлер,
        if (spoiler) {
            // то проверим опцию spoiler_windows инициализатора.
            if (editor.settings.spoiler_windows) {
                editSpoiler(spoiler);
            } else {
                // то надо удалить его.
                removeSpoiler(spoiler);
            }
        } else {
            // Иначе надо добавить новый спойлер.
            addSpoiler();
        }
    }

    editor.ui.registry.addToggleButton('spoiler', {
        tooltip: 'Добавить/Удалить спойлер',
        icon: 'spoiler',
        onAction: main,
        onSetup: toggleActiveState
    });
    editor.ui.registry.addMenuItem('spoiler', {
        icon: 'spoiler',
        text: 'Добавить/Удалить спойлер',
        onAction: main,
    });
});

В общем, так как оба плагина весьма похожи по своему принципу работы, то потребовалось всего лишь немного изменить две функции из прошлого плагина: главную "main()" и функцию добавления нового спойлера "addSpoiler()". В обеих этих функциях проверяется переменная "settings.spoiler_windows", после чего либо открывается окно, для дальнейшего действия, либо спойлер сразу создается, или удаляется в содержимом текста.

И еще раз напомню, главное теперь, чтобы плагин заработал в "оконном режиме", надо не забыть добавить опцию "spoiler_windows" со значением true в инициализатор редактора:

Код на javaScript
tinyMCE.init({
    language: "ru",
    selector: "textarea#id_content",
    plugins: "spoiler link image lists preview codesample contextmenu table",
    spoiler_windows: true,
    toolbar: "spoiler styleselect bold italic | alignleft aligncenter alignright alignjustify ",
    menubar: 'edit insert format table',
    menu: {
        insert: { title: 'Insert', items: 'spoiler image link media codesample inserttable insertdatetime' },
    },
    /* ... */
})

10. В заключении

Я весьма подробно попытался объяснить, как я создавал свой плагин, не столько для того, чтобы бы Вы поняли как он работает, а чтобы стал понятен сам общий принцип создания плагинов для редактора TinyMCE 5, и основные возможности, которые можно использовать при создании плагинов. На самом деле возможностей в Вашем распоряжении будет гораздо больше, если Вы ознакомитесь с официальной документацией редактора. Но, думаю, что даже опираясь на ту информацию, которою я Вам постарался предоставить в своей статье, и по аналогии с тем плагином, который я в ней описал, Вы уже вполне сможете создать какой-нибудь свой собственный плагин.

Удачи Вам в разработке.

к началу статьи
0 66 0
Мы используем cookie-файлы, чтобы получить статистику, которая помогает нам улучшить сервис для Вас с целью персонализации сервисов и предложений. Вы можете прочитать подробнее о cookie-файлах или изменить настройки браузера. Продолжая пользоваться сайтом без изменения настроек, вы даёте согласие на использование ваших cookie-файлов.