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

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

    Вот так это будет выглядеть по окончанию редактирования:


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


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


    Итак. Давайте определим каким мы хотим видеть будущий инструмент в работе. Нужно чтобы карту можно было встроить в документ, удалить из него и была возможность изменить какие-либо её параметры. Т.к. оперирование <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-карт.

    Ссылки


    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 19
    • 0
      На гитхаб думаю стоит выложить.
      • +1
        Уже есть mapbbcode. Я считаю, не нужен, лучше приделайте mapbbcode к ckeditor'у.
        • 0
          В принципе, всё что нужно сделать, для того, чтобы настроить плагин для любой другой системы карт это… Ничего.
          Т.к. плагин не включает в себя само встраивание карт на страницу, он просто обеспечивает наличие на странице настраиваемой <em data-plugin=«json» />. А во что потом должна превратиться эта EM-ка решать тому, кто использует этот плагин :)

          В тестовом архиве для этого достаточно переписать функцию replace в index.html.
          • +1
            Ну вот mapbbcode — это универсальное решение всех проблем с картами в форумах, блогах и всём таком.
            • –1
              Вы навели меня на одну мысль. Т.к. плагин на самом деле не связан ни с какой системой карт, то можно просто добавить в его диалог настроек выпадающий список, позволяющий выбрать, какую систему карт задействовать. Сам список сделать настраиваемым через стандартную систему настроек CKeditor. Там же сделать опцию, которая задаёт систему карт по-умолчанию. + Уже в самом репозитории сделать несколько примеров, подключающих разные сервисы. Пожалуй, я так и сделаю.
        • –1
          Вопрос по теме онлайн редакторов, но не по теме поста: а нет ли редактора, который умеет подсвечивать html и css синтаксис при редактировании source?

          PS Просьба не минусовать за невтемность вопроса
          • 0
            Я подготовил тестовую страничку, в которой можно посмотреть на плагин в деле. Скачать архив (~ 900 KiB, zip).

            Только у меня ссылка ведет на какой-то файлообменник? Страницы файлообменника у меня блокируются фаерволом. Или примера и возможности нормально скачать не будет?!
            • 0
              Угу, на файло-обменник. Но в данный момент я готовлю плагин для github-а. Оформляю его ~ по стандартам других плагинов CKEditor-а. Как закину на github — обновлю статью ссылкой. И отпишу в личку =)
              • 0
                В немного сыром виде он доступен здесь.
            • 0
              Одна мааааленькая просьба. Не используйте для контейнера карты тэг акцентирования текста em. Есть же универсальный span.
              • 0
                Я пробовал и <span /> и <div />. В CKEditor-е вступает в силу какая то доп.логика и всё летит к чёрту. В конечном счёте пришёл к выводу, что это должен быть изначально inline-ый тег, и не span. Попробовал EM — сработало, на нём и остановился. Суть в том что ACR + FakeObjects работают как то аномально. Сложилось впечатление что они тестировались только на плагине Flash, и больше создателям CKEditor-а не пригодились. Чего стоят только сбои в ACR когда заявлены использование атрибутов width и height, и при их неиспользовании рандомно слетают классы при переключении режима редактирования.

                В общем не всё так просто :) Увы.
              • 0
                Мне одному дико смотреть на «imap»?
                А то знаете ли ассоциации всякие возникают.
                • +1
                  Спасибо за плагин. Мы о нем написали в блоге API Яндекс.Карт — ymapsapi.ya.ru/replies.xml?item_no=1775&ncrnd=2302

                  Скажите, а есть возможность добавить этот плагин к остальным плагинам CKEditor? Может быть я мог бы чем-то помочь?
                  • 0
                    Спасибо за публикацию. По поводу второго вопроса — ответил в личку.
                • 0
                  Благодарю за подробное описание! Скажите пожалуйста, а можно с помощью функции createFakeParserElement() или как-то по-другому сделать фейковый элемент не img-картинку заглушкой, а, например, div с произвольной надписью внутри (например с текстом «наименование»)? А то не очень хорошо когда несколько элементов отображаются одинаковой картинкой без подписей и каких-либо отличий.
                  • 0
                    Тут нужно или исходники смотреть или эксперементировать. Мне сильно не хватало вменяемой документации. В ней написано одно, а по факту получал другое. Чаще всего натыкался на не очень ясные ограничения, которые обходил «методом тыка». Попробуйте DIV-ку. Если не прокатит, то может быть, сработает какой-нибудь EM.
                    • 0
                      Да я уж пробовал туда разные теги ставить — в любых вариантах на картинку заменяет ;(
                      В общем в итоге сделал вот так:
                                      afterInit: function( editor ) {
                                        editor.dataProcessor.dataFilter.addRules(
                                        {
                                          comment : function( value )
                                          {
                                            if ( !CKEDITOR.htmlParser.comment.prototype.getAttribute ) {
                                              CKEDITOR.htmlParser.comment.prototype.getAttribute = function() {
                                                return '';
                                              };
                                              CKEDITOR.htmlParser.comment.prototype.attributes = {
                                                align : ''
                                              };
                                            }
                      
                                            if ( value.match(/mycomment:.*/) ) {
                                                                      var widgetWrapper = null,
                                                                              innerElement = new CKEDITOR.htmlParser.element( 'div', {
                                                                                      'class': 'visiblecomment'
                                                                              } );
                      
                                                                      // Adds placeholder identifier as innertext.
                                                                      innerElement.add( new CKEDITOR.htmlParser.text( value ) );
                                                                      widgetWrapper = editor.widgets.wrapElement( innerElement, 'placeholder' );
                      
                                                                      // Return outerhtml of widget wrapper so it will be placed
                                                                      // as replacement.
                                                                      return widgetWrapper;
                                            }
                      
                                            return value;
                                          }
                                        });
                                      }
                      
                      

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