/*
* Copyright (C) eZ Systems AS. All rights reserved.
* For full copyright and license information view LICENSE file distributed with this source code.
*/
/* global CKEDITOR */
YUI.add('ez-richtext-editview', function (Y) {
"use strict";
/**
* Provides the field edit view for the RichText (ezrichtext) fields
*
* @module ez-richtext-editview
*/
Y.namespace('eZ');
var FIELDTYPE_IDENTIFIER = 'ezrichtext',
L = Y.Lang,
FOCUS_CLASS = 'is-focused',
EDITOR_FOCUSED_CLASS = 'is-editor-focused',
EDITABLE_CLASS = 'ez-richtext-editable',
AlloyEditor = Y.eZ.AlloyEditor,
ToolbarConfig = Y.eZ.AlloyEditorToolbarConfig;
/**
* Rich Text edit view
*
* @namespace eZ
* @class RichTextEditView
* @constructor
* @extends eZ.FieldEditView
* @uses eZ.Processable
*/
Y.eZ.RichTextEditView = Y.Base.create('richTextEditView', Y.eZ.FieldEditView, [Y.eZ.Processable], {
events: {
'.ez-richtext-switch-focus': {
'tap': '_setFocusMode',
},
'.ez-richtext-save-and-return': {
'tap': '_unsetFocusMode',
}
},
initializer: function () {
var config = this.get('config');
this._handleFieldDescriptionVisibility = false;
if ( config && config.rootInfo && config.rootInfo.ckeditorPluginPath ) {
this._set('ckeditorPluginPath', config.rootInfo.ckeditorPluginPath);
}
this.after('activeChange', function (e) {
if ( this.get('active') ) {
this._initEditor();
} else {
if (this.get('editor')) {
this.get('editor').destroy();
}
}
});
this.after('focusModeChange', this._uiFocusMode);
this._processEvent = ['instanceReady', 'updatedEmbed'];
},
/**
* Makes sure the editor is destroyed and re-initialized as if the view
* just becomes active.
*
* @method _afterActiveReRender
* @protected
*/
_afterActiveReRender: function () {
this.get('editor').destroy();
this._initEditor();
},
/**
* `focusModeChange` event handler, it adds or removes the focused
* class on the view container.
*
* @method _uiFocusMode
* @protected
*/
_uiFocusMode: function () {
var container = this.get('container');
if ( this.get('focusMode') ) {
container.addClass(FOCUS_CLASS);
} else {
container.removeClass(FOCUS_CLASS);
}
},
/**
* tap event handler on the focus button.
*
* @method _setFocusMode
* @protected
* @param {EventFacade} e
*/
_setFocusMode: function (e) {
e.preventDefault();
this._set('focusMode', true);
},
/**
* tap event handler on the save and return button.
*
* @method _unsetFocusMode
* @protected
* @param {EventFacade} e
*/
_unsetFocusMode: function (e) {
e.preventDefault();
this._set('focusMode', false);
},
/**
* Returns an objects (`name` and `identifier`) representing the image
* variations configured in Platform sorted by name.
*
* @method _getImageVariations
* @return Array
*/
_getImageVariations: function () {
var config = this.get('config'),
variations = [];
if ( !config || !config.imageVariations ) {
return variations;
}
variations = Object.keys(config.imageVariations).map(function (identifier) {
return {
identifier: identifier,
name: identifier, // TODO put the real name as soon as variations get real name
};
});
return variations.sort(function (a, b) {
return a.name.localeCompare(b.name);
});
},
/**
* Registers the plugin which name is given in the given plugin dir.
*
* @method _registerExternalCKEditorPlugin
* @protected
*/
_registerExternalCKEditorPlugin: function (pluginName, pluginDir) {
CKEDITOR.plugins.addExternal(pluginName, this.get('ckeditorPluginPath') + '/' + pluginDir);
},
/**
* Initializes the editor
*
* @protected
* @method _initEditor
*/
_initEditor: function () {
var editor, nativeEd, valid, setEditorFocused, unsetEditorFocused,
extraPlugins = [
'ezaddcontent', 'widget', 'ezembed', 'ezremoveblock',
'ezfocusblock', 'yui3', 'ezpaste', 'ezmoveelement',
];
if (this.get('isNotTranslatable')) {
return;
}
this._registerExternalCKEditorPlugin('widget', 'widget/');
this._registerExternalCKEditorPlugin('lineutils', 'lineutils/');
editor = AlloyEditor.editable(
this.get('container').one('.ez-richtext-editor').getDOMNode(), {
toolbars: this.get('toolbarsConfig'),
extraPlugins: AlloyEditor.Core.ATTRS.extraPlugins.value + ',' + extraPlugins.join(','),
removePlugins: AlloyEditor.Core.ATTRS.removePlugins.value + ',ae_embed',
eZ: {
editableRegion: '.' + EDITABLE_CLASS,
imageVariations: this._getImageVariations(),
},
}
);
nativeEd = editor.get('nativeEditor');
valid = Y.bind(this.validate, this);
setEditorFocused = Y.bind(this._uiHandleEditorFocus, this, true);
unsetEditorFocused = Y.bind(this._uiHandleEditorFocus, this, false);
Y.Array.each(this.get('forwardEvents'), function (evtName) {
nativeEd.on(evtName, Y.bind(this._forwardEditorEvent, this));
}, this);
nativeEd.on('blur', valid);
nativeEd.on('focus', valid);
nativeEd.on('change', valid);
nativeEd.on('focus', setEditorFocused);
nativeEd.on('blur', unsetEditorFocused);
this._set('editor', editor);
},
/**
* Forwards the given event to the YUI stack
*
* @method _forwardEditorEvent
* @param {Object} e the CKEditor event info
* @protected
*/
_forwardEditorEvent: function (e) {
this.fire(e.name, e.data);
},
/**
* Adds or removes the editor focused class on the view container.
*
* @method _uiHandleEditorFocus
* @param {Boolean} focus
*/
_uiHandleEditorFocus: function (focus) {
var container = this.get('container');
if ( focus ) {
container.addClass(EDITOR_FOCUSED_CLASS);
} else {
container.removeClass(EDITOR_FOCUSED_CLASS);
}
},
validate: function () {
if ( !this.get('fieldDefinition').isRequired ) {
this.set('errorStatus', false);
return;
}
if ( this._isEmpty() ) {
this.set('errorStatus', Y.eZ.trans('this.field.is.required', {}, 'fieldedit'));
} else {
this.set('errorStatus', false);
}
},
/**
* Checks whether the field is empty. The field is considered empty if:
* * there's no section element
* * or the section element has no child
* * or the section element has only child without content
*
* @method _isEmpty
* @protected
* @return {Boolean}
*/
_isEmpty: function () {
var section = Y.Node.create(this._getXHTML5EditValue()),
hasChildNodes = function (element) {
return !!element.get('children').size();
},
hasChildWithContent = function (element) {
return element.get('children').some(function (node) {
return L.trim(node.get('text')) !== '';
});
},
hasEmbedElement = function(element) {
return !!element.one('[data-ezelement=ezembed]');
};
return !section || !hasChildNodes(section) || !(hasChildWithContent(section) || hasEmbedElement(section));
},
_variables: function () {
return {
"isRequired": this.get('fieldDefinition').isRequired,
"xhtml": this._serializeFieldValue(),
"editableClass": EDITABLE_CLASS,
};
},
/**
* Returns a DocumentFragment object or null if the parser failed to
* load the xhtml5edit version of the rich text field. The document
* fragment only contains the content of the root <section> element.
*
* @method _getHTMLDocumentFragment
* @return {DocumentFragment}
*/
_getHTMLDocumentFragment: function () {
var fragment = Y.config.doc.createDocumentFragment(),
root = fragment.ownerDocument.createElement('div'),
doc = (new DOMParser()).parseFromString(this.get('field').fieldValue.xhtml5edit, "text/xml"),
importChildNodes = function (parent, element, skipElement) {
// recursively import element and its descendants under
// parent this allows to correctly transform an `xhtml5edit`
// document (it's an XML document) to an HTML document a
// browser can understand.
var i, newElement;
if ( skipElement ) {
newElement = parent;
} else {
if ( element.nodeType === Node.ELEMENT_NODE ) {
newElement = parent.ownerDocument.createElement(element.localName);
for (i = 0; i != element.attributes.length; i++) {
importChildNodes(newElement, element.attributes[i], false);
}
parent.appendChild(newElement);
} else if ( element.nodeType === Node.TEXT_NODE ) {
parent.appendChild(parent.ownerDocument.createTextNode(element.nodeValue));
return;
} else if ( element.nodeType === Node.ATTRIBUTE_NODE ) {
parent.setAttribute(element.localName, element.value);
return;
} else {
return;
}
}
for (i = 0; i != element.childNodes.length; i++) {
importChildNodes(newElement, element.childNodes[i], false);
}
};
if ( !doc || !doc.documentElement || doc.querySelector("parsererror") ) {
console.warn(
"Unable to parse the content of the RichText field #" + this.get('field').id
);
return null;
}
fragment.appendChild(root);
importChildNodes(root, doc.documentElement, true);
return fragment;
},
/**
* Serializes the Document to a string
*
* @method _serializeFieldValue
* @protected
* @return {String}
*/
_serializeFieldValue: function () {
var doc = this._getHTMLDocumentFragment(), section;
if ( !doc ) {
return "";
}
section = doc.childNodes.item(0);
if ( !section.hasChildNodes() ) {
// making sure to have at least a paragraph element
// otherwise CKEditor adds a br to make sure the editor can put
// the caret inside the element.
doc.childNodes.item(0).appendChild(Y.config.doc.createElement('p'));
}
return section.innerHTML;
},
/**
* Returns the field value suitable for the REST API based on the
* current input. Also fills the `xhtml5edit` property so this value can
* also be used when switching language.
*
* @method _getFieldValue
* @protected
* @return {Object}
*/
_getFieldValue: function () {
var value;
if (this.get('editor')) {
value = this._getXHTML5EditValue();
} else {
// Editor is disabled, using the previous value
value = this.get('field').fieldValue.xhtml5edit;
}
return {
xml: value,
xhtml5edit: value,
};
},
/**
* Returns the content of the editor in the XHTML5Edit format. The
* actual editor's content is passed through a set of
* EditorContentProcessors.
*
* @method _getXHTML5EditValue
* @protected
* @return {String}
*/
_getXHTML5EditValue: function () {
var data = this.get('editor').get('nativeEditor').getData();
Y.Array.each(this.get('editorContentProcessors'), function (processor) {
data = processor.process(data);
});
return data;
},
}, {
ATTRS: {
processors: {
writeOnce: 'initOnly',
valueFn: function () {
return [{
processor: new Y.eZ.RichTextResolveImage(),
priority: 100,
}, {
processor: new Y.eZ.RichTextResolveEmbed(),
priority: 50,
}];
},
},
/**
* Stores the focus mode state. When true, the rich text UI is
* supposed to be fullscreen with an action bar on the right.
*
* @attribute focusMode
* @type {Boolean}
* @readOnly
*/
focusMode: {
value: false,
readOnly: true,
},
/**
* The AlloyEditor
*
* @attribute editor
* @type AlloyEditor.Core
*/
editor: {
value: null,
readOnly: true,
},
/**
* AlloyEditor toolbar configuration
*
* @attribute toolbarsConfig
* @type {Object}
*/
toolbarsConfig: {
valueFn: function () {
return {
styles: {
selections: [
ToolbarConfig.Link,
ToolbarConfig.Text,
ToolbarConfig.Table,
new ToolbarConfig.HeadingConfig(),
new ToolbarConfig.ParagraphConfig(),
ToolbarConfig.Image,
ToolbarConfig.Embed,
],
tabIndex: 1
},
ezadd: {
buttons: ['ezheading', 'ezparagraph', 'ezlist', 'ezimage', 'ezembed'],
tabIndex: 2,
},
};
},
},
/**
* The path to use to load the CKEditor plugins
*
* @attribute ckeditorPluginPath
* @readOnly
* @type {String}
* @default '/bundles/ezplatformuiassets/vendors'
*/
ckeditorPluginPath: {
value: '/bundles/ezplatformuiassets/vendors',
readOnly: true,
},
/**
* Editor events to forward to the YUI stack
*
* @attribute forwardEvents
* @readOnly
* @type {Array}
* @default ['contentDiscover', 'loadImageVariation', 'contentSearch', 'instanceReady', 'updatedEmbed']
*/
forwardEvents: {
value: ['contentDiscover', 'loadImageVariation', 'contentSearch', 'instanceReady', 'updatedEmbed'],
readOnly: true,
},
/**
* Hold the list of editor content processors. Those components
* should have a `process` method and are there to clean up the
* editor content before using it through REST.
*
* @attribute editorContentProcessors
* @type Array of {eZ.EditorContentProcessor}
*/
editorContentProcessors: {
valueFn: function () {
return [
new Y.eZ.EditorContentProcessorRemoveIds(),
new Y.eZ.EditorContentProcessorEmptyEmbed(),
new Y.eZ.EditorContentProcessorRemoveAnchors(),
new Y.eZ.EditorContentProcessorXHTML5Edit(),
];
},
},
}
});
Y.eZ.FieldEditView.registerFieldEditView(
FIELDTYPE_IDENTIFIER, Y.eZ.RichTextEditView
);
});