misc/class
lib/jquery_pnotify, lib/moment, lib/lodash, misc/notification, site/engine, misc/social
if( $.browser.msie && $.browser.version <= 8 ) include('lib/respond'); $._social.__cfg = {"init":[{"service":"basic"},{"fb_app_id":"1997094873850041","service":"fb"},{"vk_app_id":"2978320","service":"vk"},{"service":"twi"}],"like":[{"service":"fb"},{"service":"vk"},{"via":"","channel":"","hash_tag":"","service":"twi"}]}; window._SiteEngine = new classes.SiteEngine( { user_id: 0, controller: 'content_article', action: 'view', content_css_version: '1459538664', social_enabled: 0} );

Faiwer

Блог web-программиста

Пишем плагин для CKEditor 4

 — 
30 ноября 2013 12:00

CKEditor - это WYSIWYG редактор HTML-кода для браузеров. Всякий раз, сталкиваясь с его документацией или же с его исходным кодом, с исходным кодом его плагинов я терялся. И это не мудрено, ведь CKEditor это очень большой продукт, имеющий довольно сложную инфраструктуру. Но, зачастую, стандартных возможностей не хватает и требуется добавить свою. В этой статье я хотел бы остановиться на плагине, который позволяет встраивать и оперировать в редакторе Yandex-картами. Вот так это будет выглядеть по окончанию редактирования:

534e37778bc3f.png

А вот так в режиме редактирования:

534e378846f37.png

Принцип работы

Итак. Давайте определим каким мы хотим видеть будущий инструмент в работе. Нужно чтобы карту можно было встроить в документ, удалить из него и была возможность изменить какие-либо её параметры. Т.к. оперирование <script />-ми в режиме редактирования HTML — задача, как минимум, не тривиальная, то мы воспользуемся стандартным плагином "fakeobjects", который позволяет на время редактирования заменить наш HTML на что-нибудь более сподручное, точнее на <img /> (именно их использует fakeobjects). Помимо прочего нам потребуется плагин "dialog" для редактирования настроек карты. Сразу отмечу, что плагин получится довольно примитивным, т.к. у меня не стояло задачи делать что-либо сложное. Плагин позволит разместить карту, используя значения 2-ух координат (широта и долгота), масштаба (1-17), сможет разместить на карте метку, а также текст с описанием под картой.

Т.к. Yandex-карты — объект динамически подключаемый, а встраивание лишних <script />-ов, по поводу и без, занятие неблагодарное, на выходе я буду получать следующий HTML-код: <em data-plugin="-json_data-">MAP</em>. И при помощи несложной javascript-функции превращать карету<em /> в карту, используя YandexMap API.

Хочу отметить, что во многом я опирался на работу стандартного плагина «Flash», поэтому часть используемых мною вещей мною до конца не понята. Во многом из-за не совершенства документации, частично из-за моей непонятливости. Большую часть выводов я сделал, опираясь именно на исходный код, и часть на базу ответов в StackOverflow.

Создание плагина

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

function()
{
    /* code */
} )();

Чтобы добавить плагин в систему воспользуемся:

CKEDITOR.plugins.add( 'ymap' /* наименование плагина */,
// объект с настройками и функциями
{
    // зависимости
    requires: [ 'dialog', 'fakeobjects' ],

    // функция вызываемая при инициалзации плагина
    init: function( editor /* этот объект - экземпляр редактора */ )
    {
    },

    // эта функция вызывается чуть позднее, и почему то отсутствует в API
    afterInit: function( editor )
    {
    }
} );

Подробнее о init:

this._initTranslations( editor ); // локализация, об этом ниже,

var req = 'em' /* tag */ + '[!data-plugin,!width,!height]' /* attrs */ +
    '(' + ymap_class_name + ')' /* classes */ +
    '{width,height}' /* styles */;
// добавляем команду для вызова диалога
editor.addCommand( 'ymap', new CKEDITOR.dialogCommand( 'ymap',
{
    allowedContent: req
} ) );

// добавляем в систему новую кнопку
editor.ui.addButton( 'YMap',
{
    label: lang.button_label,
    command: 'ymap'
} );
// регистрируем сам диалог
CKEDITOR.dialog.add( 'ymap', add_dialog /* функция, о ней ниже */ );

// вешаем свой обработчик на событие двойного-клика
// если объектом является наш fakeobject - указываем диалог настройки
editor.on( 'doubleclick', function( evt )
{
    var element = evt.data.element;

    if( element.is('img') && element.data('cke-real-element-type') == 'ymap' )
    {
        evt.data.dialog = 'ymap';
    }
} );

Наиболее важным пунктом является определение allowedContent при создании новой команды. Дело в том, что в CKEditor 4 добавили новую систему — «Allowed Content Rules». Эта система сражается с некорректным HTML кодом (например, при вставке извне). Она удаляет лишние теги, атрибуты, стили и классы из HTML-кода. А для того, чтобы системе дать понять, что лишнее, а что нет, при регистрации команд мы указываем объект, который может содержать поля allowedContent и requiredContent. Задача первого запросить возможности, задача второго отключить команду, если возможностей не хватает. Мне хватило использования только allowedContent-а. Принципы его работы можно посмотреть здесь. Я остановлюсь на самых основных:

  • Значением является строка, которая может состять из 4 частей: теги, атрибуты, стили и классы. Пример: «p[data-role]{text-align}(tip)»
  • Аттрибуты, которые являются обязательными для поддержки нужно указывать через «!». Пример: «[!width]»
  • Принцип работы несколько не очевиден, мне так и не удалось в нём разобраться. Периодически у меня отваливалась поддержка атрибутов и классов. Лечение было нетривиальным. Да прибудет с вами сила!

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

var translations =
{
    ru:
    {
        fake_object_label: 'Yandex Карта',
        title: 'Yandex Карта',
        // ...
    },
    en:
    {
        fake_object_label: 'Yandex Map',
        title: 'Yandex Map',
        // ...
    },
    def: 'ru'
};
var lang; // shotrcut

// ...

CKEDITOR.plugins.add( 'ymap',
{
    // ...

    _initTranslations: function( editor )
    {
        var current_lang = CKEDITOR.lang.detect();
        CKEDITOR.lang[ current_lang ]['ymap'] = translations[ current_lang ]
            ? translations[ current_lang ]
            : translations[ translations.def_lang ];
        lang = editor.lang.ymap;
        // подсказка при наведении мыши на fakeobject
        editor.lang.fakeobjects.ymap = lang.fake_object_label;
    },

    // ...
} );

afterInit

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

afterInit: function( editor )
{
    // получаем фильтр текущего редактора
    var dataProcessor = editor.dataProcessor,
        dataFilter = dataProcessor && dataProcessor.dataFilter;

    if( dataFilter )
    {
        // и создаём своё правило
        dataFilter.addRules
        (
            {
                // для тегов
                elements:
                {
                    // в качестве конечного тега я выбрал em. Причина такого выбора
                    // заключется в том, что мне потребовался inline-тег, чтобы 
                    // усмирить повадки CKEditor-а по любому поводу плодить
                    // пустые P-ки
                    'em': function( el )
                    {
                        // если эта em - не Ymap, значит ищем глубже
                        if( ! is_plugin_node( el ) )
                        {
                            for( var i = 0 ; i < el.children.length; ++ i )
                            {
                                if( el.children[ i ].name === 'cke:ymap' )
                                {
                                    if( ! is_plugin_node( el.children[ i ] ) )
                                    {
                                        return null; // не наш случай
                                    }

                                    // создаём новый fakeobject
                                    return create_fake_element( editor, el );
                                }
                            }

                            return null; // не наш случай
                        }

                        // иначе - создаём новый fakeobject
                        return create_fake_element( editor, el );
                    }
                }
            }, 1 /* приоритет фильтрации */
        )
    } // if dataFilter
}

Функция для проверки — является ли элемент «картой»

var is_plugin_node = function( el )
{
    return el.attributes['class'] === ymap_class_name;
};

Фукнция для превращения em в fakeobject:

var create_fake_element = function( editor, real_el )
{
    // заменяемый элемент, класс для IMG, тип объекта, растягиваемость
    return editor.createFakeParserElement( real_el, 'cke_ymap', 'ymap', true );
};

Под растягиваемостью следует понимать возможность наличия у объекта ширины и высоты. Если указать false, то fakeobject не унаследует width и height у исходного элемента.

Диалог

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

var add_dialog = function( editor )
{
    var dialog =
    {
        title: lang.title, // заголовок диалога
        // и его размеры
        width: 300,
        height: 100,
        // в этом массиве — все кнопки, табы, поля ввода…
        contents:
        [
        ],

        // методы
        onShow: function(){},
        onOk: function(){}
    };
    return dialog;
};

Ну а теперь по порядку. Начнём с компонентов:

{ // таб, обязателен, если он 1, то отображается только содержимое
    id: 'tab_info',
    expand: true,
    padding: 0,
    elements:
    [
        { // название
            id: 'name',
            type: 'text',
            label: lang.f_name,
            commit: commit,
            setup: load
        },
        { // метка
            id: 'label',
            type: 'text',
            label: lang.f_label,
            commit: commit,
            setup: load
        },
        { // гор.панелька
            type: 'hbox',
            align: 'left',
            children:
            [
                { // latitude
                    id: 'lon',
                    type: 'text',
                    label: lang.f_lat,
                    commit: commit,
                    validate: CKEDITOR.dialog.validate
                        .regex( /^[\d\,\.]+$/, lang.inc_coord ),
                    setup: load,
                    'default': '43.2503'
                },
                // …

Компонентам можно задать следующие обработчики:

  • «commit» — вызывается при сборе значений
  • «setup» — вызывается при установке значений
  • «validate» — используется для проверки значения

Поле id используется в качестве идентификатора компонента, а не в качестве атрибута. Все типы готовых валидаторов вы можете посмотреть в объекте CKEDITOR.dialog.validate.

onShow

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

// забываем параметры предыдущих вызовов
this.fake_image = this.ymap_node = null;

// проверяем был ли выбран какой-нибудь элемент перед вызовом диалога
var fake_image = this.getSelectedElement();
// если этот объект наш...
if( fake_image && fake_image.data( 'cke-real-element-type' ) === 'ymap' )
{
    this.fake_image = fake_image;
    // этот метод возвращает нашу EM-ку (не отображает в редакторе, а просто
    // возвращает объект)
    this.ymap_node = editor.restoreRealElement( fake_image );
    // т.к. все настройки мы будем хранить как JSON в атрибуте "data-plugin",
    // то получаем их назад в переменную cfg
    var cfg = JSON.parse( this.ymap_node.getAttribute('data-plugin') )
    // эта функция инициирует setup у каждого из компонентов.
    // в setup-функцию будут переданы все аргументы, которые мы здесь
    // зададим. В данном случае хватает cfg
    this.setupContent( cfg );
}

Функция setup (для компонентов):

var load = function( cfg )
{
    // просто задаём в качестве значения - данные из cfg
    // которые в свою очередь взяты из data-plugin атрибута
    // куда мы поместили их ранее (об этом позднее :) )
    this.setValue( cfg[ '_' + this.id ] );
};

onOk

Эта функция вызывается тогда, когда пользователь нажал в диалоге кнопку «Ок», и при этом все поля не содержали ошибок. Наиболее важная из всех наших функций :) В ней мы создаём и манипулируем fakeobject-ом, а также создаём итоговую EM-ку.

// this.fake_image мы задаём в onShow, если
// был выбран ранее созданный fakeobject
// если его нет, то пользователь нажал на кнопку вызова диалога как раз 
// с намерением создать НОВУЮ карту
if( ! this.fake_image )
{
    // создаём новый EM-элемент
    var node = CKEDITOR.dom.element
        .createFromHtml( '<cke:em>MAP</cke:em>', editor.document );
    // и задаём ему нужный класс
    node.addClass( ymap_class_name );
}
else
{
    // если такой объект уже есть
    // то в качестве EM-ки воспользуемся старой,
    // которую мы восстановили в onShow
    // методом restoreRealElement
    var node = this.ymap_node;
}

// определим все стили и атрибуты
var extra_styles = {}, extra_attributes = {};
// эта функция вызывает commit у каждого из компонентов и
// работает точно так же как и load.
// мы передаём в неё объекты для того, чтобы собрать все
// нужные стили и атрибуты
this.commitContent( node, extra_styles, extra_attributes );

// чтобы задать их EM-ке
node.setStyles( extra_styles );
// важный момент - сохраняем все настройки в атрибут
node.$.setAttribute( 'data-plugin', JSON.stringify( extra_attributes ) );

// и новому fakeobject-у
// первый аргумент - тег, который мы хотим спрятать
// второй аргумент - класс, который будет у тега IMG 
// третий аргумент - тип нашего объекта
// четвёртый - есть ли у нашего объекта такое понятие как размер
var new_fake_image = editor.createFakeElement( node, 'cke_ymap', 'ymap', true );
new_fake_image.setAttributes( extra_attributes );
new_fake_image.setStyles( extra_styles );

// если у нас уже был fakeobject
if( this.fake_image )
{
    // то заменим его
    new_fake_image.replace( this.fake_image );
    // и выделим
    editor.getSelection().selectElement( new_fake_image );
}
else
{
    // иначе вставим в документ новый объект
    // если пользователь перед этим что-либо выделил
    // наш объект это уничтожит
    editor.insertElement( new_fake_image );
}

Функция commit у компонентов (вызывается нами в принудительном порядке при помощи commitContent):

var commit = function( ymap, styles, attrs )
{
    var value = this.getValue();

    if( this.id === 'width' || this.id === 'height' )
    {
        // чтобы объекты действительно изменили свой размер
        // им нужно его задать в стилях
        styles[ this.id ] = value;
    }
    else if( this.id === 'lat' || this.id === 'lon' )
    {
        // если в координатах указаны запятые - заменим их на точки
        value = value.replace( ',', '.' );
    }

    // сохраняем все поля в атрибутах,
    // которые мы в onOk запишем ещё и в data-plugin
    attrs[ '_' + this.id ] = value;
};

Отмечу, что если предполагается наличие у объекта полей width и height, то их необходимо использовать, в противном случае может отвалиться allowedContents :(

CSS

В CSS нам нужно задать внешний вид для EM-ки и для IMG-fakeobject-а. Внешний вид для EM-ки задаётся в стилях вашего сайта, а внешний вид IMG в стилях, которые подключатся к редактору. Их можно задать и через JS, при помощи:

CKEDITOR.addCss( 'img.cke_ymap { /* css */ }' );

Итог

Плагин в целом оказался не сложным. Причиной тому наличие плагина fakeobject и то, что мы работаем с одним блочным объектом. Я полагаю, что работа со строчными тегами намного сложнее, из-за того, что пользователь может выделить текст так, что в него попадут разные части содержимого разных тегов. Впрочем, я не пробовал :)

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

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

Ссылки

Пример

MAP