sogo/UI/WebServerResources/js/Common/sgCkeditor.component.js
Francis Lachapelle bed91ce95a fix(mail(js)): avoid updating the DOM before closing editor
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.
2021-04-09 17:57:08 -04:00

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);
})();