When destroying the CKEditor instance (closing the mail editor in HTML mode), we no longer try to update the associated textarea because we're removing it anyway.
420 lines
12 KiB
JavaScript
420 lines
12 KiB
JavaScript
/* -*- Mode: javascript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
/**
|
|
* sgCkeditor - A component for the CKEditor v4
|
|
* Based on https://github.com/jziggas/ng-ck/.
|
|
* @memberof SOGo.Common
|
|
* @example:
|
|
<sg-ckeditor
|
|
config="$ctrl.config"
|
|
on-instance-ready="$ctrl.onEditorReady($editor)"
|
|
on-focus="$ctrl.onEditorFocus($editor)"
|
|
ng-model="$ctrl.content"></sg-ckeditor>
|
|
*/
|
|
function sgCkeditorConfigProvider() {
|
|
// Default plugins that have successfully passed through Angular's $sanitize service
|
|
var defaultConfiguration = {
|
|
toolbarGroups: [
|
|
{ name: 'basicstyles', groups: [ 'basicstyles', 'colors', 'list', 'indent', 'blocks', 'align', 'links', 'insert', 'spellchecker', 'styles', 'mode' ] }
|
|
],
|
|
|
|
// The default plugins included in the basic setup define some buttons that
|
|
// are not needed in a basic editor. They are removed here.
|
|
removeButtons: 'Strike,Subscript,Superscript,BGColor,Anchor,Format,Image',
|
|
|
|
// Allow the toolbar to be collapsed (useful for small screens)
|
|
toolbarCanCollapse: true,
|
|
|
|
// Dialog windows are also simplified.
|
|
removeDialogTabs: 'link:advanced',
|
|
|
|
enterMode: CKEDITOR.ENTER_BR,
|
|
tabSpaces: 4,
|
|
// fullPage: true, include header and body
|
|
allowedContent: true, // don't filter tags
|
|
entities: false,
|
|
|
|
// Configure autogrow
|
|
// https://ckeditor.com/docs/ckeditor4/latest/guide/dev_autogrow.html
|
|
autoGrow_onStartup: true,
|
|
autoGrow_minHeight: 300,
|
|
autoGrow_bottomSpace: 0,
|
|
language: 'en',
|
|
|
|
// The Upload Image plugin requires a remote URL to be defined even though we won't use it
|
|
imageUploadUrl: '/SOGo/'
|
|
};
|
|
|
|
var events = [
|
|
'activeEnterModeChange',
|
|
'activeFilterChange',
|
|
'afterCommandExec',
|
|
'afterInsertHtml',
|
|
'afterPaste',
|
|
'afterPasteFromWord',
|
|
'afterSetData',
|
|
'afterUndoImage',
|
|
'ariaEditorHelpLabel',
|
|
'autogrow',
|
|
'beforeCommandExec',
|
|
'beforeDestroy',
|
|
'beforeGetData',
|
|
'beforeModeUnload',
|
|
'beforeSetMode',
|
|
'beforeUndoImage',
|
|
'blur',
|
|
'change',
|
|
'configLoaded',
|
|
'contentDirLoaded',
|
|
'contentDom',
|
|
'contentDomInvalidated',
|
|
'contentDomUnload',
|
|
'customConfigLoaded',
|
|
'dataFiltered',
|
|
'dataReady',
|
|
'destroy',
|
|
'dialogHide',
|
|
'dialogShow',
|
|
'dirChanged',
|
|
'doubleclick',
|
|
'dragend',
|
|
'dragstart',
|
|
'drop',
|
|
'elementsPathUpdate',
|
|
'fileUploadRequest',
|
|
'fileUploadResponse',
|
|
'floatingSpaceLayout',
|
|
'focus',
|
|
'getData',
|
|
'getSnapshot',
|
|
'insertElement',
|
|
'insertHtml',
|
|
'insertText',
|
|
'instanceReady',
|
|
'key',
|
|
'langLoaded',
|
|
'loadSnapshot',
|
|
'loaded',
|
|
'lockSnapshot',
|
|
'maximize',
|
|
'menuShow',
|
|
'mode',
|
|
'notificationHide',
|
|
'notificationShow',
|
|
'notificationUpdate',
|
|
'paste',
|
|
'pasteFromWord',
|
|
'pluginsLoaded',
|
|
'readOnly',
|
|
'removeFormatCleanup',
|
|
'required',
|
|
'resize',
|
|
'save',
|
|
'saveSnapshot',
|
|
'selectionChange',
|
|
'setData',
|
|
'stylesSet',
|
|
'template',
|
|
'toDataFormat',
|
|
'toHtml',
|
|
'unlockSnapshot',
|
|
'updateSnapshot',
|
|
'widgetDefinition'
|
|
];
|
|
|
|
var config = angular.copy(defaultConfiguration);
|
|
|
|
this.$get = function () {
|
|
return {
|
|
config: config,
|
|
events: events
|
|
}
|
|
};
|
|
}
|
|
|
|
var sgCkeditorComponent = {
|
|
controllerAs: 'vm',
|
|
require: {
|
|
ngModelCtrl: 'ngModel'
|
|
},
|
|
bindings: {
|
|
checkTextLength: '<?',
|
|
config: '<?',
|
|
maxLength: '<?',
|
|
minLength: '<?',
|
|
ckMargin: '@?',
|
|
onActiveEnterModeChange: '&?',
|
|
onActiveFilterChange: '&?',
|
|
onAfterCommandExec: '&?',
|
|
onAfterInsertHtml: '&?',
|
|
onAfterPaste: '&?',
|
|
onAfterPasteFromWord: '&?',
|
|
onAfterSetData: '&?',
|
|
onAfterUndoImage: '&?',
|
|
onAriaEditorHelpLabel: '&?',
|
|
onAutogrow: '&?',
|
|
onBeforeCommandExec: '&?',
|
|
onBeforeDestroy: '&?',
|
|
onBeforeGetData: '&?',
|
|
onBeforeModeUnload: '&?',
|
|
onBeforeSetMode: '&?',
|
|
onBeforeUndoImage: '&?',
|
|
onBlur: '&?',
|
|
onChange: '&?',
|
|
onConfigLoaded: '&?',
|
|
onContentChanged: '&?', // Not CKEditor API
|
|
onContentDirLoaded: '&?',
|
|
onContentDom: '&?',
|
|
onContentDomInvalidated: '&?',
|
|
onContentDomUnload: '&?',
|
|
onCustomConfigLoaded: '&?',
|
|
onDataFiltered: '&?',
|
|
onDataReady: '&?',
|
|
onDestroy: '&?', // Not sure if this works because of the cleanup done in $onDestroy. Needs testing.
|
|
onDialogHide: '&?',
|
|
onDialogShow: '&?',
|
|
onDirChanged: '&?',
|
|
onDoubleclick: '&?',
|
|
onDragend: '&?',
|
|
onDragstart: '&?',
|
|
onDrop: '&?',
|
|
onElementsPathUpdate: '&?',
|
|
onFileUploadRequest: '&?',
|
|
onFileUploadResponse: '&?',
|
|
onFloatingSpaceLayout: '&?',
|
|
onFocus: '&?',
|
|
onGetData: '&?',
|
|
onGetSnapshot: '&?',
|
|
onInsertElement: '&?',
|
|
onInsertHtml: '&?',
|
|
onInsertText: '&?',
|
|
onInstanceReady: '&?',
|
|
onKey: '&?',
|
|
onLangLoaded: '&?',
|
|
onLoadSnapshot: '&?',
|
|
onLoaded: '&?',
|
|
onLockSnapshot: '&?',
|
|
onMaximize: '&?',
|
|
onMenuShow: '&?',
|
|
onMode: '&?',
|
|
onNotificationHide: '&?',
|
|
onNotificationShow: '&?',
|
|
onNotificationUpdate: '&?',
|
|
onPaste: '&?',
|
|
onPasteFromWord: '&?',
|
|
onPluginsLoaded: '&?',
|
|
onReadOnly: '&?',
|
|
onRemoveFormatCleanup: '&?',
|
|
onRequired: '&?',
|
|
onResize: '&?',
|
|
onSave: '&?',
|
|
onSaveSnapshot: '&?',
|
|
onSelectionChange: '&?',
|
|
onSetData: '&?',
|
|
onStylesSet: '&?',
|
|
onTemplate: '&?',
|
|
onToDataFormat: '&?',
|
|
onToHtml: '&?',
|
|
onUnlockSnapshot: '&?',
|
|
onUpdateSnapshot: '&?',
|
|
onWidgetDefinition: '&?',
|
|
placeholder: '<?',
|
|
readOnly: '<?',
|
|
required: '<?'
|
|
},
|
|
template: '<textarea ng-attr-placeholder="{{vm.placeholder}}"></textarea>',
|
|
controller: sgCkeditorController
|
|
};
|
|
|
|
sgCkeditorController.$inject = ['$element', '$scope', '$parse', '$timeout', 'sgCkeditorConfig'];
|
|
function sgCkeditorController ($element, $scope, $parse, $timeout, sgCkeditorConfig) {
|
|
var vm = this;
|
|
var config;
|
|
var content;
|
|
var editor;
|
|
var editorElement;
|
|
var editorChanged = false;
|
|
var modelChanged = false;
|
|
|
|
this.$onInit = function () {
|
|
vm.ngModelCtrl.$render = function () {
|
|
if (editor) {
|
|
editor.setData(vm.ngModelCtrl.$viewValue, {
|
|
noSnapshot: true,
|
|
callback: function () {
|
|
editor.fire('updateSnapshot')
|
|
}
|
|
})
|
|
}
|
|
};
|
|
|
|
config = vm.config ? angular.merge(sgCkeditorConfig.config, vm.config) : sgCkeditorConfig.config;
|
|
|
|
if (config.language) {
|
|
// Pickup the first matching language supported by SCAYT
|
|
// See http://docs.ckeditor.com/#!/guide/dev_howtos_scayt
|
|
config.scayt_sLang = _.find(['en_US', 'en_GB', 'pt_BR', 'da_DK', 'nl_NL', 'en_CA', 'fi_FI', 'fr_FR', 'fr_CA', 'de_DE', 'el_GR', 'it_IT', 'nb_NO', 'pt_PT', 'es_ES', 'sv_SE'], function(sLang) {
|
|
return sLang.lastIndexOf(config.language, 0) == 0;
|
|
}) || 'en_US';
|
|
|
|
// Disable caching of the language
|
|
// See https://github.com/WebSpellChecker/ckeditor-plugin-scayt/issues/126
|
|
config.scayt_disableOptionsStorage = 'lang';
|
|
}
|
|
|
|
if (vm.ckMargin) {
|
|
// Set the margin of the iframe editable content
|
|
CKEDITOR.addCss('.cke_editable { margin-top: ' + vm.ckMargin +
|
|
'; margin-left: ' + vm.ckMargin +
|
|
'; margin-right: ' + vm.ckMargin + '; }');
|
|
}
|
|
};
|
|
|
|
this.$postLink = function () {
|
|
editorElement = $element[0].children[0];
|
|
editor = CKEDITOR.replace(editorElement, config);
|
|
|
|
editor.on('instanceReady', onInstanceReady);
|
|
editor.on('pasteState', onEditorChange);
|
|
editor.on('change', onEditorChange);
|
|
editor.on('paste', onEditorPaste);
|
|
editor.on('fileUploadRequest', onEditorFileUploadRequest);
|
|
|
|
if (content) {
|
|
modelChanged = true
|
|
editor.setData(content, {
|
|
noSnapshot: true,
|
|
callback: function () {
|
|
editor.fire('updateSnapshot')
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
this.$onChanges = function (changes) {
|
|
if (
|
|
changes.ngModel &&
|
|
changes.ngModel.currentValue !== changes.ngModel.previousValue
|
|
) {
|
|
content = changes.ngModel.currentValue;
|
|
if (editor && !editorChanged) {
|
|
if (content) {
|
|
editor.setData(content, {
|
|
noSnapshot: true,
|
|
callback: function () {
|
|
editor.fire('updateSnapshot')
|
|
}
|
|
});
|
|
modelChanged = true;
|
|
}
|
|
}
|
|
editorChanged = false;
|
|
}
|
|
if (editor && changes.readOnly) {
|
|
editor.setReadOnly(changes.readOnly.currentValue);
|
|
}
|
|
}
|
|
|
|
this.$onDestroy = function () {
|
|
var noUpdate = true;
|
|
editorElement.classList.add('ng-cloak');
|
|
editor.destroy(noUpdate);
|
|
}
|
|
|
|
function onInstanceReady (event) {
|
|
// Register binded callbacks for all available events
|
|
_.forEach(_.filter(sgCkeditorConfig.events, function (eventName) {
|
|
return eventName != 'instanceReady';
|
|
}), function (eventName) {
|
|
var callbackName = 'on' + eventName[0].toUpperCase() + eventName.slice(1);
|
|
if (vm[callbackName]) {
|
|
editor.on(eventName, function (event) {
|
|
vm[callbackName]({
|
|
'$event': event,
|
|
'$editor': editor
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
if (vm.onInstanceReady) {
|
|
vm.onInstanceReady({
|
|
'$event': event,
|
|
'$editor': editor
|
|
});
|
|
}
|
|
|
|
editorElement.classList.remove('ng-cloak');
|
|
vm.ngModelCtrl.$render();
|
|
}
|
|
|
|
function onEditorChange () {
|
|
var html = editor.getData();
|
|
var body = editor.document.getBody();
|
|
var text;
|
|
|
|
if (_.isEmpty(body))
|
|
return;
|
|
else
|
|
text = body.getText();
|
|
|
|
if (text === '\n') {
|
|
text = '';
|
|
}
|
|
|
|
if (!modelChanged && html !== vm.ngModelCtrl.$viewValue) {
|
|
editorChanged = true;
|
|
vm.ngModelCtrl.$setViewValue(html);
|
|
validate(vm.checkTextLength ? text : html);
|
|
if (vm.onContentChanged) {
|
|
vm.onContentChanged({
|
|
'editor': editor,
|
|
'html': html,
|
|
'text': text
|
|
});
|
|
}
|
|
}
|
|
modelChanged = false;
|
|
}
|
|
|
|
function onEditorPaste (event) {
|
|
var html;
|
|
if (event.data.type == 'html') {
|
|
html = event.data.dataValue;
|
|
// Remove images to avoid ghost image in Firefox; images will be handled by the Image Upload plugin
|
|
event.data.dataValue = html.replace(/<img( [^>]*)?>/gi, '');
|
|
}
|
|
}
|
|
|
|
function onEditorFileUploadRequest (event) {
|
|
// Intercept the request when an image is pasted, keep an inline base64 version only.
|
|
var data, img;
|
|
data = event.data.fileLoader.data;
|
|
img = editor.document.createElement('img');
|
|
img.setAttribute('src', data);
|
|
editor.insertElement(img);
|
|
event.cancel();
|
|
}
|
|
|
|
function validate (body) {
|
|
if (vm.maxLength) {
|
|
vm.ngModelCtrl.$setValidity('maxlength', body.length > vm.maxLength + 1);
|
|
}
|
|
if (vm.minLength) {
|
|
vm.ngModelCtrl.$setValidity('minlength', body.length <= vm.minLength);
|
|
}
|
|
if (vm.required) {
|
|
vm.ngModelCtrl.$setValidity('required', body.length > 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
angular
|
|
.module('sgCkeditor', [])
|
|
.provider('sgCkeditorConfig', sgCkeditorConfigProvider)
|
|
.component('sgCkeditor', sgCkeditorComponent);
|
|
})();
|