Source: admin/javascript/engine/widgets/editor.js

/* --------------------------------------------------------------
 editor.js 2017-09-05
 Gambio GmbH
 http://www.gambio.de
 Copyright (c) 2017 Gambio GmbH
 Released under the GNU General Public License (Version 2)
 [http://www.gnu.org/licenses/gpl-2.0.html]
 --------------------------------------------------------------
 */

/**
 * ## Editor Widget
 *
 * This widget will initialize instances of CKEditor or CodeMirror depending the provided data attribute of
 * each textarea, within the container the widget is bound to. Purpose of this module is to provide a common
 * wrapper of the textarea and record specific editor which means that the user will be able to set an editor
 * for a specific record and textarea and store this preference in the database.
 *
 * **Currently the available editors are "ckeditor" and "codemirror".**
 *
 * Important: Make sure that you provide the required options as described below. The module is flexible enough
 * to provide a solution for each page code base.
 *
 *
 * ### Options (Container)
 *
 * The following options are bound as data attributes to the element where the module is bound on (most of the times
 * a container that includes textarea elements).
 *
 * **Selector | `data-editor-selector` | String | Optional**
 *
 * Provide a selector for the textareas to be converted to editor instances. This option defaults to "textarea" and
 * will match all the textarea elements inside the container.
 *
 * **Event Target | `data-editor-event-target` | String | Optional**
 *
 * Provide a selector that will mark the element which will start the submit/save process of the page. If provided
 * the selected editor preference will be saved through the user configuration service with an AJAX request.
 *
 * Important: There is no default value for this option.
 *
 * **Event Type | `data-editor-event-type` | String | Optional**
 *
 * Provide a JavaScript event that will mark the submit/save process of the page. If provided an event handler
 * will be bound on the element marked by the "event-target" option and AJAX requests will save the current
 * editor preference in the user configuration table.
 *
 * Important: There is no default value for this option.
 *
 * **AutoHide | `data-editor-auto-hide` | Boolean | Optional**
 *
 * Provide "true" or "false" in order to auto hide the editors, if the textareas are not visible at the beginning.
 * Defaults value is "false"
 *
 * **AutoUpdate | `data-editor-auto-update` | Boolean | Optional**
 *
 * Indicates if the corresponding textarea of the editor should be updated automatically.
 * Default value is "true"
 *
 * **Initialization Event Type | `data-editor-init-event-type` | String | Optional**
 *
 * Provide a custom initialization event which will trigger the start of the editor conversion. By default the
 * editor instances will be created once the engine is ready 'JSENGINE_INIT_FINISHED', but there are cases where
 * a custom event is required (e.g. initialization of editor widget dynamically within a dialog).
 *
 *
 * ### Options (Textarea)
 *
 * The following options are bound as data attributes to each textarea element within the parent container.
 *
 * **Editor Identifier | `data-editor-identifier` | String | Required**
 *
 * Each child textarea element needs to have a unique identifier string which needs to apply with the following
 * naming convention the "editor-{record type}-{id}-{textarea name}-{language code}
 * (e.g. editor-products-2-short_description-de). In cases where the record ID is not set yet (new record creation),
 * it is advised that you leave the {id} placeholder and replace it later on whenever the record is generated into
 * the database (more information about this edge case in the examples below).
 *
 * **Editor Type | `data-editor-type` | String | Optional**
 *
 * This option can have one of the available editor values which will also state the selected editor upon
 * initialization. It is optional and the default value is "ckeditor".
 *
 *
 * ### Events
 *
 * The '#editor-container' element is where the widget is bound on.
 *
 * ```javascript
 * // Fires up when all textareas are ready.
 * $('#editor-container').on('editor:ready', (event, $textareas) => { ... });
 *
 * // Fires up each time a single textarea is ready.
 * $('#editor-container').on('editor:textarea_ready', (event, $textarea) => { ... });
 * ```
 *
 *
 * ### Examples
 *
 * **Simple Usage**
 *
 * Notice that this example showcases the creation of a new customer which means that the customer's ID is not known
 * yet. After its initialization, the widget will create a hidden field in the form with the
 * "editor_identifiers[textarea-identifier]" name. This hidden field will have the selected editor type as value which
 * be used by the backend callback to store the correct editor identifier value, once the customer's ID is generated
 * (record inserted). Use the "UserConfigurationService" in backend for adding the value to the database.
 *
 * ```html
 * <div data-gx-widget="editor" data-editor-event-target="#customer-form"  data-editor-event-type="submit">
 *   <form id="customer-form">
 *     <!-- Other Fields ... ->
 *     <textarea class="wysiwyg" data-editor-identifier="editor-customers-{id}-notes-de"></textarea>
 *   </form>
 * </div>
 * ```
 *
 * @module Admin/Widgets/editor
 * @requires CKEditor, CodeMirror
 */
gx.widgets.module(
	'editor',
	
	[
		`${jse.source}/vendor/codemirror/codemirror.min.css`,
		`${jse.source}/vendor/codemirror/codemirror.min.js`,
		`${gx.source}/libs/editor_instances`,
		`${gx.source}/libs/editor_values`,
		`${gx.source}/widgets/quickselect`,
		'user_configuration_service'
	],
	
	function(data) {
		
		'use strict';
		
		// ------------------------------------------------------------------------
		// VARIABLES
		// ------------------------------------------------------------------------
		
		/**
		 * Module Selector
		 *
		 * @type {jQuery}
		 */
		const $this = $(this);
		
		/**
		 * Default Options
		 *
		 * @type {Object}
		 */
		const defaults = {
			selector: 'textarea',
			autoHide: 'false',
			initEventType: 'JSENGINE_INIT_FINISHED',
			autoUpdate: 'true'
		};
		
		/**
		 * Final Options
		 *
		 * @type {Object}
		 */
		const options = $.extend(true, {}, defaults, data);
		
		/**
		 * Module Object
		 *
		 * @type {Object}
		 */
		const module = {};
		
		/**
		 * Editor Instances
		 *
		 * Identifier -> instance mapping
		 *
		 * @type {Object}
		 */
		const editors = {};
		
		/**
		 * Available Editor Types
		 *
		 * @type {String[]}
		 */
		const editorTypes = ['ckeditor', 'codemirror'];
		
		// ------------------------------------------------------------------------
		// FUNCTIONS
		// ------------------------------------------------------------------------
		
		/**
		 * Add Editor Switch Button
		 *
		 * This method will add the editor switch button and bind the click event handler.
		 *
		 * @param {jQuery} $textarea Textarea selector to be modified.
		 */
		function _addEditorSwitchButton($textarea) {
			let start = 0;
			if ($textarea.data('editorType') === 'codemirror') {
				start = 1;
			}
			
			$textarea
				.wrap('<div class="editor-wrapper" />')
				.parent()
				.prepend(`<div data-gx-widget="quickselect" data-quickselect-align="right" data-quickselect-start="`
					+ start + `">
							<div class="quickselect-headline-wrapper">
								<a class="editor-switch editor-switch-html" href="#html">
									${jse.core.lang.translate('BUTTON_SWITCH_EDITOR_TEXT', 'admin_buttons')}
								</a>
								<a class="editor-switch editor-switch-text" href="#text">
									${jse.core.lang.translate('BUTTON_SWITCH_EDITOR_HTML', 'admin_buttons')}
								</a>
							</div>
						</div>`)
				.find('.editor-switch')
				.on('click', _onSwitchButtonClick);
			
			if (!$textarea.is(':visible') && options.autoHide === 'true') {
				$textarea.parent().hide();
			}
			
			gx.widgets.init($textarea.parent());
		}
		
		/**
		 * Add a hidden editor type field.
		 *
		 * This field will contain the type of the current editor and can be used by submit callbacks whenever the
		 * record ID is not known yet and the user configuration entry is generated by the server.
		 *
		 * @param {jQuery} $textarea Textarea selector to be modified.
		 */
		function _addEditorHiddenField($textarea) {
			$textarea
				.parent()
				.append(`
					<input type="hidden" 
						name="editor_identifiers[${$textarea.data('editorIdentifier')}]" 
						value="${$textarea.data('editorType') || 'ckeditor'}" />
				`);
		}
		
		/**
		 * Create Editor Instance
		 *
		 * This method will use the "editor" library to create the appropriate editor instance, depending the textarea
		 * type attribute.
		 *
		 * @param {jQuery} $textarea Textarea selector to be modified.
		 */
		function _createEditorInstance($textarea) {
			const type = $textarea.data('editorType') || 'ckeditor';
			const identifier = $textarea.data('editorIdentifier');
			
			editors[identifier] = jse.libs.editor_instances.create($textarea, type);
		}
		
		/**
		 * On Switch Button Click Event Handler
		 *
		 * This method will use the "editor" library to change the current editor type and update the hidden input
		 * field and data attributes of the textarea. It will try to set the next available editor type.
		 */
		function _onSwitchButtonClick() {
			const $switchButton = $(this);
			const $textarea = $switchButton.parents('.editor-wrapper').find('textarea');
			const identifier = $textarea.data('editorIdentifier');
			const currentType = $textarea.data('editorType');
			const newType = $switchButton.hasClass('editor-switch-text') ? editorTypes[1] : editorTypes[0];
			
			$textarea.siblings(`[name="editor_identifiers[${identifier}]"]`).val(newType);
			$textarea.data('editorType', newType);
			
			editors[identifier] = jse.libs.editor_instances.switch($textarea, currentType, newType);
			_bindAutoUpdate($textarea);
			_updateTextArea($textarea);
		}
		
		/**
		 * On Page Submit Handler
		 *
		 * If the event target and type are provided this method will be triggered to save the user configuration
		 * values in the database with AJAX requests.
		 */
		function _onPageSubmit() {
			for (let identifier in editors) {
				jse.libs.user_configuration_service.set({
					data: {
						userId: 0,
						configurationKey: identifier,
						configurationValue: editors[identifier].type
					}
				});
			}
		}
		
		/**
		 * Bind Auto Update
		 *
		 * Binds an event handler to an editor instance to automatically update the
		 * corresponding textarea.
		 *
		 * @param {jQuery} $textarea Textarea the auto update should be bound to
		 */
		function _bindAutoUpdate($textarea) {
			if (options.autoUpdate === 'false') {
				return;
			}
			
			const instance = editors[$textarea.data('editorIdentifier')];
			
			instance.on('change', () => _updateTextArea($textarea));
		}
		
		/**
		 * Update Text Area Value
		 *
		 * Transfers the value of the editor instance of the given textarea to its corresponding textarea.
		 *
		 * @param {jQuery} $textarea The textarea to be updated.
		 */
		function _updateTextArea($textarea) {
			const editorType = $textarea.data('editorType');
			const instance = editors[$textarea.data('editorIdentifier')];
			
			switch (editorType) {
				case 'ckeditor':
					instance.updateElement();
					break;
				
				case 'codemirror':
					$textarea.val(jse.libs.editor_values.getValue($textarea));
					break;
				
				default:
					throw new Error('Editor type not recognized.', editorType);
			}
			
			$textarea.trigger('change');
		}
		
		// ------------------------------------------------------------------------
		// INITIALIZATION
		// ------------------------------------------------------------------------
		
		module.init = function(done) {
			$(document).on('JSENGINE_INIT_FINISHED', () => {
				const dependencies = [
					`${jse.source}/vendor/codemirror/css.min.js`,
					`${jse.source}/vendor/codemirror/htmlmixed.min.js`,
					`${jse.source}/vendor/codemirror/javascript.min.js`,
					`${jse.source}/vendor/codemirror/xml.min.js`
				];
				
				jse.core.module_loader.require(dependencies);
			});
			
			// Initialize the editors after a specific event in order to make sure that other modules will be
			// already initialized and nothing else will change the markup.
			$(window).on(options.initEventType, () => {
				const $textareas = $this.find(options.selector);
				
				$textareas.each((index, textarea) => {
					const $textarea = $(textarea);
					
					if (editorTypes.indexOf($textarea.data('editorType')) === -1) {
						$textarea.data('editorType', editorTypes[0]);
					}
					
					_addEditorSwitchButton($textarea);
					_addEditorHiddenField($textarea);
					_createEditorInstance($textarea);
					_bindAutoUpdate($textarea);
					
					$this.trigger('editor:textarea_ready', [$textarea]);
				});
				
				$this.trigger('editor:ready', [$textareas]);
			});
			
			// If the event target and type options are available, bind the page submit handler. 
			if (options.eventTarget !== undefined && options.eventType !== undefined) {
				$(options.eventTarget).on(options.eventType, _onPageSubmit);
			}
			
			done();
		};
		
		// Return data to module engine.
		return module;
	});